# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for the file widgets."""

import json
import os.path
import textwrap
import unittest
from typing import ClassVar, NamedTuple, cast, override
from unittest import mock

from django.http.request import HttpHeaders
from django.http.response import HttpResponseBase, HttpResponseRedirectBase
from django.template import engines
from django.test import override_settings
from django.utils.datastructures import CaseInsensitiveMapping
from django.utils.html import escape
from django.utils.safestring import SafeString, mark_safe
from pydantic import ValidationError
from rest_framework import status

from debusine.artifacts.models import ArtifactCategory
from debusine.db.models import FileInArtifact
from debusine.server.views import ProblemResponse
from debusine.test.django import TestCase
from debusine.web.views import files
from debusine.web.views.files import (
    FileDownloadMixin,
    FileUI,
    FileWidget,
    LinenoHtmlFormatter,
    LogFileWidget,
    MAX_FILE_SIZE,
    PathMixin,
    add_line_number,
)
from debusine.web.views.tests.utils import ViewTestMixin


class FileUITests(TestCase):
    """Tests for the FileUI class."""

    def assertRenders(
        self, file_ui: FileUI, file_in_artifact: FileInArtifact
    ) -> None:
        """Test that the given widget renders without errors."""
        template = engines["django"].from_string(
            "{% load debusine %}{% widget widget %}"
        )
        setattr(template, "engine", engines["django"])
        widget = file_ui.widget_class(file_in_artifact, file_ui=file_ui)
        # Set DEBUG=True to have render raise exceptions instead of logging
        # them
        with override_settings(DEBUG=True):
            template.render({"widget": widget})

    def test_from_file_in_artifact_explicit_content_type(self) -> None:
        """Test from_file_in_artifact with explicit content-types."""

        class SubTest(NamedTuple):
            """Define a subtest."""

            filename: str
            db_content_type: str
            widget_class: type[FileWidget]
            rendered_content_type: str
            size: int = 1024

        sub_tests = [
            SubTest(
                "build.changes",
                "text/plain; charset=utf-8",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "file.log",
                "text/plain; charset=utf-8",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "file.txt",
                "text/plain; charset=utf-8",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "hello.build",
                "text/plain; charset=utf-8",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "hello.buildinfo",
                "text/plain; charset=utf-8",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "file.sources",
                "text/plain; charset=utf-8",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "readme.md",
                "text/markdown; charset=utf-8",
                files.TextFileWidget,
                "text/markdown; charset=utf-8",
            ),
            SubTest(
                "log",
                "text/plain; charset=utf-8",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "file.js", "text/javascript", files.TextFileWidget, "text/plain"
            ),
            SubTest(
                "output.json",
                "application/json; charset=us-ascii",
                files.TextFileWidget,
                "application/json; charset=us-ascii",
            ),
            SubTest(
                "empty",
                "inode/x-empty; charset=binary",
                files.TextFileWidget,
                "text/plain",
            ),
            SubTest(
                "Release.gpg",
                "application/pgp-signature; charset=utf-8",
                files.TextFileWidget,
                "application/pgp-signature; charset=utf-8",
            ),
            SubTest(
                "InRelease",
                "text/PGP; charset=utf-8",
                files.TextFileWidget,
                "text/PGP; charset=utf-8",
            ),
            SubTest(
                "a.out",
                "application/octet-stream",
                files.BinaryFileWidget,
                "application/octet-stream",
            ),
            SubTest(
                "big.bin",
                "application/octet-stream",
                files.TooBigFileWidget,
                "application/octet-stream",
                2**32,
            ),
            SubTest(
                "image.png",
                "application/png",
                files.BinaryFileWidget,
                "application/octet-stream",
            ),
        ]

        for sub_test in sub_tests:
            with self.subTest(sub_test.filename):
                artifact, _ = self.playground.create_artifact(
                    paths={sub_test.filename: b"test"}, create_files=True
                )
                file_in_artifact = FileInArtifact.objects.get(artifact=artifact)
                file_in_artifact.content_type = sub_test.db_content_type
                file_in_artifact.file.size = sub_test.size
                fileui = FileUI.from_file_in_artifact(file_in_artifact)
                self.assertEqual(
                    fileui.content_type, sub_test.rendered_content_type
                )
                self.assertEqual(fileui.widget_class, sub_test.widget_class)
                self.assertRenders(fileui, file_in_artifact)

    def test_from_file_in_artifact_guess_from_file_name(self) -> None:
        """Test from_file_in_artifact, guessing based on the file name."""

        class SubTest(NamedTuple):
            """Define a subtest."""

            filename: str
            widget_class: type[FileWidget]
            content_type: str
            size: int = 1024

        sub_tests = [
            SubTest(
                "build.changes",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "file.log", files.TextFileWidget, "text/plain; charset=utf-8"
            ),
            SubTest(
                "file.txt", files.TextFileWidget, "text/plain; charset=utf-8"
            ),
            SubTest(
                "hello.build", files.TextFileWidget, "text/plain; charset=utf-8"
            ),
            SubTest(
                "hello.buildinfo",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "file.sources",
                files.TextFileWidget,
                "text/plain; charset=utf-8",
            ),
            SubTest(
                "readme.md",
                files.TextFileWidget,
                "text/markdown; charset=utf-8",
            ),
            SubTest(
                "a.out", files.BinaryFileWidget, "application/octet-stream"
            ),
            SubTest(
                "big.bin",
                files.TooBigFileWidget,
                "application/octet-stream",
                2**32,
            ),
        ]

        for sub_test in sub_tests:
            with self.subTest(sub_test.filename):
                artifact, _ = self.playground.create_artifact(
                    paths={sub_test.filename: b"test"}, create_files=True
                )
                file_in_artifact = FileInArtifact.objects.get(artifact=artifact)
                file_in_artifact.file.size = sub_test.size
                fileui = FileUI.from_file_in_artifact(file_in_artifact)
                self.assertEqual(fileui.content_type, sub_test.content_type)
                self.assertEqual(fileui.widget_class, sub_test.widget_class)
                self.assertRenders(fileui, file_in_artifact)

    def test_from_file_in_artifact_autopkgtest_log(self) -> None:
        artifact, _ = self.playground.create_artifact(
            paths=["log"],
            create_files=True,
            category=ArtifactCategory.AUTOPKGTEST,
        )
        file_in_artifact = artifact.fileinartifact_set.get(path="log")

        file_ui = FileUI.from_file_in_artifact(file_in_artifact)

        self.assertEqual(
            file_ui,
            FileUI(
                content_type="text/plain; charset=utf-8",
                widget_class=LogFileWidget,
                pygments_lexer=None,
            ),
        )

    def test_compressed(self) -> None:
        """Test from_file_in_artifact with a compressed file."""
        artifact, _ = self.playground.create_artifact(
            paths=["file.md.gz"], create_files=True
        )
        fileui = FileUI.from_file_in_artifact(
            artifact.fileinartifact_set.get(path="file.md.gz")
        )

        self.assertEqual(fileui.content_type, "application/gzip")
        self.assertEqual(fileui.widget_class, files.BinaryFileWidget)


class PathMixinTests(ViewTestMixin, unittest.TestCase):
    """Tests for the ArtifactDetailView class."""

    def test_normalize_path(self) -> None:
        """Test PathMixin.normalize_path."""
        f = PathMixin.normalize_path
        self.assertEqual(f(""), "/")
        self.assertEqual(f("/"), "/")
        self.assertEqual(f("."), "/")
        self.assertEqual(f(".."), "/")
        self.assertEqual(f("../"), "/")
        self.assertEqual(f("../../.././../../"), "/")
        self.assertEqual(f("src/"), "/src/")
        self.assertEqual(f("src/.."), "/")
        self.assertEqual(f("/a/b/../c/./d//e/f/../g/"), "/a/c/d/e/g/")

    def test_path(self) -> None:
        """Test PathMixin.path."""
        view = self.instantiate_view_class(PathMixin, "/")
        self.assertEqual(view.path, "")

        view = self.instantiate_view_class(
            PathMixin, "/", path="/foo/bar/../baz"
        )
        self.assertEqual(view.path, "foo/baz/")


class FileDownloadMixinTests(ViewTestMixin, TestCase):
    """Test FileDownloadMixin."""

    playground_memory_file_store = False

    contents: ClassVar[dict[str, bytes]]
    empty: ClassVar[FileInArtifact]
    file: ClassVar[FileInArtifact]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.contents = {
            "empty.bin": b"",
            "file.md": bytes(range(256)),
        }
        artifact, _ = cls.playground.create_artifact(
            paths=cls.contents,
            create_files=True,
        )
        cls.empty = artifact.fileinartifact_set.get(path="empty.bin")
        cls.file = artifact.fileinartifact_set.get(path="file.md")

    def get_stream_response(
        self,
        file_in_artifact: FileInArtifact,
        range_header: tuple[int | None, int | None] | str | None = None,
        download: bool = True,
    ) -> HttpResponseBase:
        """Instantiate the view and get a streaming file response."""
        request = self.make_request("/")
        headers = {}
        match range_header:
            case str():
                headers["Range"] = range_header
            case [range_start, range_end]:
                if range_start is None:
                    range_start = ""
                if range_end is None:
                    range_end = ""
                headers["Range"] = f"bytes={range_start}-{range_end}"
        if headers:
            request.headers = cast(
                HttpHeaders,
                CaseInsensitiveMapping({**request.headers, **headers}),
            )

        ui_info = FileUI.from_file_in_artifact(file_in_artifact)
        view = self.instantiate_view_class(FileDownloadMixin, request)
        response = view.stream_file(file_in_artifact, ui_info, download)
        if isinstance(response, ProblemResponse):
            # This is needed by assertResponseProblem
            setattr(
                response,
                "json",
                lambda: json.loads(response.content.decode(response.charset)),
            )

        return response

    def assertFileResponse(
        self,
        response: HttpResponseBase,
        file_in_artifact: FileInArtifact,
        range_start: int = 0,
        range_end: int | None = None,
        disposition: str = "attachment",
    ) -> None:
        """Assert that response has the expected headers and content."""
        if range_start == 0 and range_end is None:
            self.assertEqual(response.status_code, status.HTTP_200_OK)
        else:
            self.assertEqual(
                response.status_code, status.HTTP_206_PARTIAL_CONTENT
            )

        file_contents = self.contents[file_in_artifact.path]
        if range_end is None:
            range_end = len(file_contents) - 1
        expected_contents = file_contents[range_start : range_end + 1]

        headers = response.headers
        self.assertEqual(headers["Accept-Ranges"], "bytes")
        self.assertEqual(headers["Content-Length"], str(len(expected_contents)))

        if len(expected_contents) > 0:
            self.assertEqual(
                headers["Content-Range"],
                f"bytes {range_start}-{range_end}/{len(file_contents)}",
            )

        filename = os.path.basename(file_in_artifact.path)
        self.assertEqual(
            headers["Content-Disposition"],
            f'{disposition}; filename="{filename}"',
        )

        streaming_content = getattr(response, "streaming_content", None)
        assert streaming_content is not None
        self.assertEqual(b"".join(streaming_content), expected_contents)

    def test_get_file(self) -> None:
        """Get return the file."""
        response = self.get_stream_response(self.file)
        self.assertFileResponse(response, self.file)
        self.assertEqual(
            response.headers["content-type"], "text/markdown; charset=utf-8"
        )

    def test_get_file_inline(self) -> None:
        """Get return the file."""
        response = self.get_stream_response(self.file, download=False)
        self.assertFileResponse(response, self.file, disposition="inline")
        self.assertEqual(
            response.headers["content-type"], "text/markdown; charset=utf-8"
        )

    def test_get_empty_file(self) -> None:
        """Test empty downloadable file (which mmap doesn't support)."""
        response = self.get_stream_response(self.empty)
        self.assertFileResponse(response, self.empty)
        self.assertEqual(
            response.headers["content-type"],
            "application/octet-stream",
        )

    def test_get_incomplete_file(self) -> None:
        """Get returns 404 for an incomplete file."""
        self.file.complete = False
        self.file.save()
        response = self.get_stream_response(self.file)
        self.assertResponseProblem(
            response,
            "Cannot download incomplete file",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_file_range(self) -> None:
        """Get return part of the file (based on Range header)."""
        start, end = 10, 20
        response = self.get_stream_response(
            self.file, range_header=(start, end)
        )
        self.assertFileResponse(response, self.file, start, end)

    def test_get_file_content_range_to_end_of_file(self) -> None:
        """Server returns a file from a position to the end."""
        start, end = 5, len(self.contents["file.md"]) - 1
        response = self.get_stream_response(
            self.file, range_header=(start, end)
        )
        self.assertFileResponse(response, self.file, start, end)

    def test_get_file_range_no_end_position(self) -> None:
        """Server handles ``Range`` with no end position."""
        start, end = 5, len(self.contents["file.md"]) - 1
        response = self.get_stream_response(
            self.file, range_header=(start, None)
        )
        self.assertFileResponse(response, self.file, start, end)

    def test_get_file_range_suffix(self) -> None:
        """Server handles ``Range`` with a suffix byte-range request."""
        suffix_length = 10
        end = len(self.contents["file.md"]) - 1
        start = end - suffix_length + 1
        response = self.get_stream_response(
            self.file, range_header=(None, suffix_length)
        )
        self.assertFileResponse(response, self.file, start, end)

    def test_get_file_content_range_invalid(self) -> None:
        """Get return an error: Range header was invalid."""
        invalid_range_header = "invalid-range"
        response = self.get_stream_response(
            self.file, range_header=invalid_range_header
        )
        self.assertResponseProblem(
            response, f'Invalid Range header: "{invalid_range_header}"'
        )

    def test_get_file_content_range_no_positions(self) -> None:
        """Get return an error: Range header contains neither start nor end."""
        invalid_range_header = "bytes=-"
        response = self.get_stream_response(
            self.file, range_header=invalid_range_header
        )
        self.assertResponseProblem(
            response, f'Invalid Range header: "{invalid_range_header}"'
        )

    def test_get_file_range_start_greater_file_size(self) -> None:
        """Get return 400: client requested an invalid start position."""
        file_size = len(self.contents["file.md"])
        start, end = file_size + 10, file_size + 20
        response = self.get_stream_response(
            self.file, range_header=(start, end)
        )
        self.assertResponseProblem(
            response,
            f"Invalid Content-Range start: {start}. File size: {file_size}",
        )

    def test_get_file_range_end_is_file_size(self) -> None:
        """Get return 400: client requested and invalid end position."""
        end = len(self.contents["file.md"])
        response = self.get_stream_response(self.file, range_header=(0, end))
        self.assertResponseProblem(
            response,
            f"Invalid Content-Range end: {end}. File size: {end}",
        )

    def test_get_file_range_end_greater_file_size(self) -> None:
        """Get return 400: client requested an invalid end position."""
        file_size = len(self.contents["file.md"])
        end = file_size + 10
        response = self.get_stream_response(self.file, range_header=(0, end))
        self.assertResponseProblem(
            response,
            f"Invalid Content-Range end: {end}. File size: {file_size}",
        )

    def test_get_file_url_redirect(self) -> None:
        """
        Get file response: redirect if get_url for the file is available.

        This would happen if the file is stored in a FileStore supporting
        get_url (e.g. an object storage) instead of being served from the
        server's file system.
        """
        destination_url = "https://some-backend.net/file?token=asdf"

        with mock.patch(
            "debusine.server.file_backend.local.LocalFileBackendEntry.get_url",
            autospec=True,
            return_value=destination_url,
        ) as mock_get_url:
            response = self.get_stream_response(self.file)
            self.assertEqual(response.status_code, status.HTTP_302_FOUND)
            assert isinstance(response, HttpResponseRedirectBase)
            self.assertEqual(response.url, destination_url)
            ui_info = FileUI.from_file_in_artifact(self.file)
            mock_get_url.assert_called_once_with(
                mock.ANY, content_type=ui_info.content_type
            )


class FileWidgetTests(ViewTestMixin, TestCase):
    """Test FileView."""

    contents: ClassVar[dict[str, bytes]]
    binary: ClassVar[FileInArtifact]
    dsc: ClassVar[FileInArtifact]
    empty: ClassVar[FileInArtifact]
    large: ClassVar[FileInArtifact]
    text: ClassVar[FileInArtifact]
    json: ClassVar[FileInArtifact]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.contents = {
            "file.md": b"# Title\nText",
            "file.dsc": b"Source: hello",
            "empty.bin": b"",
            "file.bin": bytes(range(256)),
            "largefile.bin": b"large",
            "output.json": b'{"name": "John"}',
        }
        artifact, _ = cls.playground.create_artifact(
            paths=cls.contents,
            create_files=True,
        )
        cls.empty = artifact.fileinartifact_set.get(path="empty.bin")
        cls.dsc = artifact.fileinartifact_set.get(path="file.dsc")
        cls.text = artifact.fileinartifact_set.get(path="file.md")
        cls.json = artifact.fileinartifact_set.get(path="output.json")
        cls.json.content_type = "application/json; charset=us-ascii"
        cls.json.save()
        cls.binary = artifact.fileinartifact_set.get(path="file.bin")
        cls.large = artifact.fileinartifact_set.get(path="largefile.bin")
        cls.large.file.size = 2**32
        cls.large.file.save()

    def test_context_data(self) -> None:
        """Test context for file types that are supported."""
        for file, expected_content in [
            (self.text, "# Title"),
            (self.json, "John"),
            (self.dsc, '<span class="k">Source</span>'),
        ]:
            with self.subTest(file=file.path):
                widget = FileWidget.create(file)
                context = widget.get_context_data()

                file_content = context.pop("file_content")
                self.assertIn(expected_content, file_content)

                self.assertEqual(
                    context,
                    {
                        "file_in_artifact": file,
                        "file_lineno_width": 2,
                        "file_ui": FileUI.from_file_in_artifact(file),
                        "formatted_content_div_id": "formatted-content",
                        "widget": widget,
                    },
                )

    def test_context_data_text_incomplete(self) -> None:
        """Test context for incomplete text files."""
        file = self.text
        file.complete = False
        file.save()
        widget = FileWidget.create(file)
        context = widget.get_context_data()

        self.assertNotIn("file_content", context)

        self.assertEqual(
            context,
            {
                "file_in_artifact": file,
                "file_ui": FileUI.from_file_in_artifact(file),
                "formatted_content_div_id": "formatted-content",
                "widget": widget,
            },
        )

    def test_context_data_empty(self) -> None:
        """Test context for empty binary files."""
        file = self.empty
        widget = FileWidget.create(file)
        context = widget.get_context_data()
        self.assertEqual(
            context,
            {
                "file_in_artifact": file,
                "file_ui": FileUI.from_file_in_artifact(file),
                "widget": widget,
            },
        )

    def test_text_file_line_numbers(self) -> None:
        """Test a text file has the line numbers."""
        file = self.text
        context = FileWidget.create(file).get_context_data()
        self.assertHTMLEqual(
            context["file_content"],
            textwrap.dedent(
                """\
                <div class="formatted-content">
                <a id="L1" href="#L1">1</a><i># Title\n</i>
                <a id="L2" href="#L2">2</a><i>Text\n</i>
                </div>\n
                """
            ),
        )

    def test_context_data_binary(self) -> None:
        """Test context for binary files."""
        file = self.binary
        widget = FileWidget.create(file)
        context = widget.get_context_data()
        self.assertEqual(
            context,
            {
                "file_in_artifact": file,
                "file_ui": FileUI.from_file_in_artifact(file),
                "widget": widget,
            },
        )

    def test_context_data_large(self) -> None:
        """Test context for files too large."""
        file = self.large
        widget = FileWidget.create(file)
        context = widget.get_context_data()
        self.assertEqual(
            context,
            {
                "file_in_artifact": file,
                "file_ui": FileUI.from_file_in_artifact(file),
                "file_max_size": MAX_FILE_SIZE,
                "widget": widget,
            },
        )

    def test_read_file_from_fileinartifact(self) -> None:
        file = self.binary
        widget = FileWidget.create(file)

        self.assertEqual(widget.read_file(), self.contents["file.bin"])

        # File contents is cached
        self.assertEqual(widget.file_contents, self.contents["file.bin"])

    def test_read_file_from_contents(self) -> None:
        file = self.binary
        widget = FileWidget.create(file, file_contents=b"already-read-contents")

        # File contents is used instead of reading self.binary
        self.assertEqual(widget.read_file(), b"already-read-contents")

    def test_initial_contents_discarded(self) -> None:
        file = self.binary
        big_contents = b"x" * (MAX_FILE_SIZE + 1)

        widget = FileWidget.create(file, file_contents=big_contents)

        # Contents is too big: discarded
        self.assertIsNone(widget.file_contents)

        # Contents was discarded: read_file() tries to read and succeeds
        self.assertEqual(widget.read_file(), self.contents["file.bin"])


class LogFileWidgetTests(ViewTestMixin, TestCase):
    """Tests for LogFileWidget."""

    contents: ClassVar[dict[str, bytes]]
    file: ClassVar[FileInArtifact]

    before_contents = (
        '<div class="formatted-content"><pre class="m-0 p-0"><span></span>'
    )
    after_contents = '</pre></div>'

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.contents = {
            "log": textwrap.dedent(
                """\
            first line
            second line\rprogress\rprogress\rfinished
            third line
            fourth line"""
            ).encode(),
            "output.json": json.dumps({"key": "value"}).encode(),
        }
        artifact, _ = cls.playground.create_artifact(
            paths=cls.contents,
            create_files=True,
        )
        cls.file = artifact.fileinartifact_set.get(path="log")

    @staticmethod
    def render(widget: LogFileWidget) -> str:
        template = engines["django"].from_string(
            "{% load debusine %}{% widget widget %}"
        )
        setattr(template, "engine", engines["django"])
        # Set DEBUG=True to have render raise exceptions instead of logging
        # them
        with override_settings(DEBUG=True):
            return template.render({"widget": widget})

    def test_get_context(self) -> None:
        widget = LogFileWidget(
            self.file,
            sections=[
                LogFileWidget.Section(
                    title="Start",
                    start_line=1,
                    end_line=2,
                    expanded=True,
                ),
                LogFileWidget.Section(
                    title="End",
                    start_line=3,
                    end_line=4,
                    expanded=True,
                ),
            ],
        )

        context = widget.get_context_data()

        # Sections as expected
        self.assertEqual(
            context["sections"],
            [
                LogFileWidget.RenderedSection(
                    expanded=True,
                    content=add_line_numbers(
                        start=1,
                        lines=[
                            "first line",
                            "second line^Mprogress^Mprogress^Mfinished",
                        ],
                    ),
                    start_line=1,
                    title="Start",
                ),
                LogFileWidget.RenderedSection(
                    expanded=True,
                    content=add_line_numbers(
                        start=3, lines=["third line", "fourth line"]
                    ),
                    start_line=3,
                    title="End",
                ),
            ],
        )

        self.assertIsInstance(context["file_ui"], FileUI)
        self.assertEqual(context["file_in_artifact"], self.file)
        self.assertEqual(
            context["formatted_content_div_id"], "formatted-content"
        )
        self.assertCountEqual(
            context.keys(),
            {
                "formatted_content_div_id",
                "file_in_artifact",
                "file_lineno_width",
                "file_ui",
                "sections",
                "suffix",
                "widget",
            },
        )

    def test_get_context_without_sections(self) -> None:
        widget = LogFileWidget(self.file, sections=[])

        context = widget.get_context_data()
        self.assertEqual(
            context["sections"],
            [
                LogFileWidget.RenderedSection(
                    start_line=1,
                    content=add_line_numbers(
                        start=1,
                        lines=[
                            "first line",
                            "second line^Mprogress^Mprogress^Mfinished",
                            "third line",
                            "fourth line",
                        ],
                    ),
                    expanded=True,
                    title=None,
                )
            ],
        )

    def test_get_context_with_sections_add_beginning_and_end(self) -> None:
        widget = LogFileWidget(
            self.file,
            file_tag="tag",
            sections=[
                LogFileWidget.Section(
                    title="Middle",
                    start_line=2,
                    end_line=3,
                    expanded=True,
                ),
            ],
        )

        context = widget.get_context_data()

        # Sections as expected
        self.assertEqual(
            context["sections"],
            [
                LogFileWidget.RenderedSection(
                    content=add_line_numbers(
                        start=1, suffix="-tag", lines=["first line"]
                    ),
                    expanded=True,
                    start_line=1,
                    title=None,
                ),
                LogFileWidget.RenderedSection(
                    content=add_line_numbers(
                        start=2,
                        suffix="-tag",
                        lines=[
                            "second line^Mprogress^Mprogress^Mfinished",
                            "third line",
                        ],
                    ),
                    expanded=True,
                    start_line=2,
                    title="Middle",
                ),
                LogFileWidget.RenderedSection(
                    content=add_line_numbers(
                        start=4, suffix="-tag", lines=["fourth line"]
                    ),
                    expanded=True,
                    start_line=4,
                    title=None,
                ),
            ],
        )
        self.assertEqual(context["suffix"], "-tag")
        self.assertIsInstance(context["file_ui"], FileUI)
        self.assertEqual(context["file_in_artifact"], self.file)
        self.assertEqual(
            context["formatted_content_div_id"], "formatted-content-tag"
        )
        self.assertCountEqual(
            context.keys(),
            {
                "formatted_content_div_id",
                "file_in_artifact",
                "file_lineno_width",
                "file_ui",
                "sections",
                "suffix",
                "widget",
            },
        )

    def test_contents_displayed_sections(self) -> None:
        rendered = self.render(
            LogFileWidget(
                self.file,
                sections=[
                    LogFileWidget.Section(
                        title="A title",
                        start_line=2,
                        end_line=3,
                        expanded=False,
                    )
                ],
            )
        )

        log_contents = (
            self.contents["log"].decode().replace("\r", "^M").split("\n")
        )

        tree = self.assertHTMLValid(rendered)

        file_contents = self.assertHasElement(
            tree, '//div[@id="formatted-content"]'
        )

        card_body = self.assertHasElement(
            file_contents, './/div[contains(@class, "card-body")]'
        )

        # Assert two untitled sections (lines that are not collapsible)
        first_untitled_section = self.assertHasElement(
            card_body, './pre[@class="formatted-content"][1]'
        )
        self.assertEqual(len(first_untitled_section.xpath("./i")), 1)
        self.assertEqual(
            first_untitled_section.xpath("./i")[0].text, log_contents[0]
        )

        second_untitled_section = self.assertHasElement(
            card_body, './pre[@class="formatted-content"][2]'
        )
        self.assertEqual(len(second_untitled_section.xpath("./i")), 1)
        self.assertEqual(
            second_untitled_section.xpath("./i")[0].text, log_contents[3]
        )

        # Assert lines in the section "A title"
        details = self.assertHasElement(card_body, "./details")
        summary = self.assertHasElement(details, "./summary")
        label = self.assertHasElement(
            summary, './/span[@class="log-section-label"]'
        )
        self.assertTextContentEqual(label, "A title")

        section_pre = self.assertHasElement(
            details, './pre[@class="formatted-content"]'
        )

        section_lines = section_pre.xpath("./i")
        self.assertEqual(len(section_lines), 2)

        self.assertEqual(section_lines[0].text, log_contents[1])
        self.assertEqual(section_lines[1].text, log_contents[2])

    def test_incomplete(self) -> None:
        self.file.complete = False
        self.file.save()
        widget = LogFileWidget(self.file, sections=[])
        context = widget.get_context_data()

        self.assertEqual(
            context,
            {
                "formatted_content_div_id": "formatted-content",
                "file_ui": FileUI.from_file_in_artifact(self.file),
                "file_in_artifact": self.file,
                "widget": widget,
            },
        )

    def test_add_line_numbers(self) -> None:
        self.assertHTMLEqual(
            LogFileWidget._add_line_numbers(
                [
                    "first line",
                    "second <b>line</b>",
                    "third <b>line</b>",
                    "fourth <b>line</b>",
                ],
                start=2,
                end=3,
                suffix="-big",
            ),
            textwrap.dedent(
                """
                <a id="L2-big" href="#L2-big">2</a>
                <i>second &lt;b&gt;line&lt;/b&gt;</i>
                <a id="L3-big" href="#L3-big">3</a>
                <i>third &lt;b&gt;line&lt;/b&gt;</i>"""
            ),
        )


class LinenoHtmlFormatterTests(TestCase):
    """Tests for LinenoHtmlFormatter class."""

    def test_wrap(self) -> None:
        formatter = LinenoHtmlFormatter(suffix="-suffix")

        with mock.patch(
            "debusine.web.views.files.add_line_number"
        ) as add_line_number_mock:
            self.assertEqual(
                list(formatter.wrap([(0, "<pre>")])), [(0, "<pre>")]
            )

        add_line_number_mock.assert_not_called()

        with mock.patch(
            "debusine.web.views.files.add_line_number", return_value="CHANGED"
        ) as add_line_number_mock:
            self.assertEqual(
                list(formatter.wrap([(1, "foo"), (1, "bar\n")])),
                [(1, "CHANGED"), (1, "CHANGED")],
            )

        self.assertEqual(
            add_line_number_mock.mock_calls,
            [
                mock.call(line_number=1, suffix="-suffix", line="foo"),
                mock.call(line_number=2, suffix="-suffix", line="bar"),
            ],
        )


class RenderedSectionTests(TestCase):
    """Tests for RenderedSection."""

    def test_content_raise_not_safe_string(self) -> None:
        with self.assertRaises(ValidationError) as exc:
            LogFileWidget.RenderedSection(
                start_line=1,
                title=None,
                expanded=True,
                content="<b>unsafe</b>",
            )

        self.assertIn("content", str(exc.exception))

    def test_content_validator(self) -> None:
        # Does not raise
        rendered_section = LogFileWidget.RenderedSection(
            start_line=1,
            title=None,
            expanded=True,
            content=escape("<b>safe</b>"),
        )
        self.assertIsInstance(rendered_section.content, SafeString)


def add_line_numbers(
    *, start: int, lines: list[str], suffix: str = ""
) -> SafeString:
    """Format lines."""
    formatted = [
        add_line_number(
            line_number=line_number,
            suffix=suffix,
            line=SafeString(line),
        )
        for line_number, line in enumerate(lines, start=start)
    ]
    return mark_safe("".join(formatted))
