# 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.

"""View extension to display contents of files."""

import io
import mimetypes
import mmap
import os.path
from collections.abc import Generator, Iterable
from functools import cached_property
from pathlib import Path
from typing import (
    Annotated,
    Any,
    NamedTuple,
    Self,
    TYPE_CHECKING,
    TypeAlias,
    override,
)

import pydantic
import pygments
import pygments.formatters
import pygments.lexers
from django.http import FileResponse
from django.http.response import HttpResponseBase
from django.shortcuts import redirect
from django.utils.html import escape, format_html
from django.utils.http import parse_header_parameters
from django.utils.safestring import SafeString
from django.views.generic.base import View
from pydantic_core import core_schema
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.utils import parse_range_header
from debusine.web.views import view_utils
from debusine.web.views.base import TemplateWidget

if TYPE_CHECKING:
    from _typeshed import SupportsRead

    SupportsRead  # fake usage for vulture
    HtmlFormatter: TypeAlias = pygments.formatters.HtmlFormatter
else:
    # pygments doesn't support generic types at run-time yet.
    from debusine.utils.typing_utils import patch_runtime_generic_support

    HtmlFormatter = patch_runtime_generic_support(
        pygments.formatters.HtmlFormatter
    )


# Above this file size, only offer to view or download raw
MAX_FILE_SIZE = 2 * 1024 * 1024


class FileUI(NamedTuple):
    """Information about how to display a file."""

    #: content type to use for downloads
    content_type: str
    #: tag identifying specialised view functions to display the file
    widget_class: type["FileWidget"]
    #: pygments lexer to use (default: autodetect from mimetype)
    pygments_lexer: str | None = None

    @classmethod
    def from_file_in_artifact(cls, file: FileInArtifact) -> Self:
        """Get a FileUI for a file."""
        content_type: str
        widget_class: type[FileWidget]

        if (
            file.artifact.category == ArtifactCategory.AUTOPKGTEST
            and file.path == "log"
        ):
            content_type = "text/plain; charset=utf-8"
            widget_class = LogFileWidget
        elif file.content_type is not None:
            content_type = file.content_type
            main_type, _ = parse_header_parameters(content_type)
            if main_type.startswith("text/") or main_type in {
                "application/json",
                "application/pgp-signature",
                "inode/x-empty",
            }:
                if main_type not in {
                    "text/plain",
                    "text/pgp",  # lower-cased by parse_header_parameters
                    "text/markdown",
                    "application/json",
                    "application/pgp-signature",
                }:
                    # Serve other text types as text/plain; clients should
                    # not be allowed to tell Debusine to serve arbitrary
                    # JavaScript or similar.
                    content_type = "text/plain"
                widget_class = TextFileWidget
            else:
                if main_type not in {
                    "application/gzip",
                    "application/octet-stream",
                    "application/vnd.debian.binary-package",
                    "application/x-bzip2",
                    "application/x-tar",
                    "application/x-xz",
                    "application/zstd",
                }:
                    # Serve other binary types as application/octet-stream,
                    # to avoid attacks where clients try to tell Debusine to
                    # serve something like malicious images that browsers
                    # may try to interpret.
                    content_type = "application/octet-stream"

                widget_class = BinaryFileWidget
        else:
            match os.path.splitext(file.path)[1]:
                case ".buildinfo" | ".dsc" | ".changes":
                    content_type = "text/plain; charset=utf-8"
                    widget_class = TextFileWidget
                case ".txt" | ".log" | ".build" | ".buildlog" | ".sources":
                    content_type = "text/plain; charset=utf-8"
                    widget_class = TextFileWidget
                case '.md':
                    content_type = "text/markdown; charset=utf-8"
                    widget_class = TextFileWidget
                case _:
                    # Logic taken from django FileResponse.set_headers
                    encoding_map = {
                        'bzip2': 'application/x-bzip',
                        'gzip': 'application/gzip',
                        'xz': 'application/x-xz',
                    }
                    _content_type, encoding = mimetypes.guess_file_type(
                        file.path
                    )
                    content_type = _content_type or 'application/octet-stream'
                    # Encoding isn't set to prevent browsers from automatically
                    # uncompressing files.
                    if encoding:
                        content_type = encoding_map.get(encoding, content_type)
                    widget_class = BinaryFileWidget

        pygments_lexer: str | None = None
        match os.path.splitext(file.path)[1]:
            case ".buildinfo" | ".dsc" | ".changes":
                pygments_lexer = "debcontrol"

        if file.file.size > MAX_FILE_SIZE:
            widget_class = TooBigFileWidget

        return cls(content_type, widget_class, pygments_lexer)


def add_line_number(
    *, line_number: int, suffix: str, line: SafeString
) -> SafeString:
    """Return HTML to render the line number and contents."""
    return format_html(
        '<a id="L{line_number}{suffix}" href="#L{line_number}{suffix}">'
        '{line_number}</a>'
        '<i>{line}</i>\n',
        line_number=line_number,
        suffix=suffix,
        line=line,
    )


class LinenoHtmlFormatter(HtmlFormatter[Any]):
    """HtmlFormatter that keeps a count of line numbers."""

    def __init__(self, *args: Any, suffix: str, **kwargs: Any) -> None:
        """Keep a count of line numbers."""
        self.suffix = suffix
        self.linenumber_count = 0
        super().__init__(*args, **kwargs)

    @override
    def wrap(
        self, source: Iterable[tuple[int, str]]
    ) -> Generator[tuple[int, str]]:
        """Format each line with the line number."""
        for formatted, line in source:
            # formatted can be:
            # - 1: line of formatted code
            # - 0: Pygments wrapping
            if formatted:
                self.linenumber_count += 1
                yield (
                    1,
                    add_line_number(
                        line_number=self.linenumber_count,
                        suffix=self.suffix,
                        line=SafeString(line.rstrip("\n")),
                    ),
                )
            else:
                yield 0, line


class FileWidget(TemplateWidget):
    """Widget that displays a file."""

    def __init__(
        self,
        file_in_artifact: FileInArtifact,
        *,
        file_ui: FileUI | None = None,
        file_tag: str | None = None,
        file_contents: bytes | None = None,
        **kwargs: Any,
    ) -> None:
        """
        Initialize object with a file to display.

        :param file_tag: there might be pages with more
          than one FileWidget (even referring to the same file). In order
          to create unique anchors to the lines, it's possible to pass a
          file_tag. The anchors will contain it, currently in the form of:
          "#L{line_number}-{file_tag}"
        :param file_contents: if set and smaller than MAX_FILE_SIZE it will
          be used by read_file() method. Otherwise, it will be read on demand
           by the method read_file().
        """
        self.file_in_artifact = file_in_artifact
        self.file_ui = file_ui or FileUI.from_file_in_artifact(file_in_artifact)
        self.file_tag = file_tag
        self.file_contents = None

        if file_contents is not None and len(file_contents) <= MAX_FILE_SIZE:
            self.file_contents = file_contents

    @classmethod
    def create(
        cls,
        file_in_artifact: FileInArtifact,
        *,
        file_ui: FileUI | None = None,
        file_tag: str | None = None,
        file_contents: bytes | None = None,
        **kwargs: Any,
    ) -> "FileWidget":
        """Create a file widget for the given file."""
        file_ui = file_ui or FileUI.from_file_in_artifact(file_in_artifact)
        return file_ui.widget_class(
            file_in_artifact,
            file_ui=file_ui,
            file_tag=file_tag,
            file_contents=file_contents,
            **kwargs,
        )

    def suffix(self) -> str:
        """Return suffix for the ids: "-{self.file_tag}" or ""."""
        return f"-{self.file_tag}" if self.file_tag else ""

    @override
    def get_context_data(self) -> dict[str, Any]:
        ctx = super().get_context_data()
        ctx["file_in_artifact"] = self.file_in_artifact
        ctx["file_ui"] = self.file_ui
        return ctx

    def read_file(self) -> bytes:
        """Return self.file_contents or read it."""
        if self.file_contents is not None:
            return self.file_contents

        self.file_contents = view_utils.read_file(
            self.file_in_artifact, max_file_size=MAX_FILE_SIZE
        )
        return self.file_contents


class LogFileWidget(FileWidget):
    """Show log files."""

    class Section(pydantic.BaseModel):
        """Information from a section of the log.."""

        title: str
        start_line: int
        end_line: int
        expanded: bool

    class RenderedSection(pydantic.BaseModel):
        """Rendered section: content and other required information."""

        start_line: int
        title: str | None
        expanded: bool
        content: Annotated[
            SafeString,
            pydantic.GetPydanticSchema(
                lambda *_: core_schema.is_instance_schema(SafeString)
            ),
        ]

    template_name = "web/_file_log.html"

    def __init__(
        self,
        file_in_artifact: FileInArtifact,
        *,
        sections: list[Section] | None = None,
        **kwargs: Any,
    ) -> None:
        """Initialize LogFileWidget."""
        super().__init__(file_in_artifact, **kwargs)

        self._sections = sections if sections is not None else []

        LogFileWidget.RenderedSection.model_rebuild()

    @staticmethod
    def _add_line_numbers(
        lines: list[str], *, start: int, end: int, suffix: str
    ) -> SafeString:
        return SafeString(
            "".join(
                add_line_number(line_number=n, suffix=suffix, line=escape(line))
                for n, line in enumerate(lines[start - 1 : end], start=start)
            )
        )

    def _render(
        self, file_contents: list[str], suffix: str
    ) -> list[RenderedSection]:
        rendered_sections = []
        line: int = 1
        for section in self._sections:
            if line < section.start_line:
                # Some lines of the file are not in any Section. Display them
                # (without title, so are not collapsible)
                rendered_sections.append(
                    LogFileWidget.RenderedSection(
                        start_line=line,
                        title=None,
                        expanded=True,
                        content=self._add_line_numbers(
                            file_contents,
                            start=line,
                            end=section.start_line - 1,
                            suffix=suffix,
                        ),
                    )
                )

            rendered_sections.append(
                LogFileWidget.RenderedSection(
                    start_line=section.start_line,
                    title=section.title,
                    expanded=section.expanded,
                    content=self._add_line_numbers(
                        file_contents,
                        start=section.start_line,
                        end=section.end_line,
                        suffix=suffix,
                    ),
                )
            )

            line = section.end_line + 1

        end_line = len(file_contents)
        if line <= end_line:
            # Add any remaining lines of the file
            rendered_sections.append(
                LogFileWidget.RenderedSection(
                    start_line=line,
                    title=None,
                    expanded=True,
                    content=self._add_line_numbers(
                        file_contents,
                        start=line,
                        end=end_line,
                        suffix=suffix,
                    ),
                )
            )

        return rendered_sections

    @override
    def get_context_data(self) -> dict[str, Any]:
        """Add context data for log files."""
        context = super().get_context_data()

        context["formatted_content_div_id"] = (
            f"formatted-content{self.suffix()}"
        )

        if not self.file_in_artifact.complete:
            return context

        raw_bytes = self.read_file()
        raw_str = raw_bytes.decode("utf-8", errors="replace")
        file_contents = raw_str.replace("\r", "^M")
        file_contents = file_contents.rstrip("\n")

        file_contents_list = file_contents.split("\n")

        context["sections"] = self._render(file_contents_list, self.suffix())
        context["suffix"] = self.suffix()
        context["file_lineno_width"] = max(len(str(len(file_contents_list))), 2)

        return context


class TextFileWidget(FileWidget):
    """Show text files."""

    template_name = "web/_file_text.html"

    # add_context_data_* names match FileViewTypes enum options

    @override
    def get_context_data(self) -> dict[str, Any]:
        """Add context data for text files."""
        context = super().get_context_data()

        context["formatted_content_div_id"] = (
            f"formatted-content{self.suffix()}"
        )

        if self.file_in_artifact.complete:
            # Generate CSS with:
            # python3 -m pygments -S github-dark -f html \
            #     > debusine/web/static/web/css/debusine-code-highlight.css
            # See https://pygments.org/styles/ for a list of styles
            if self.file_ui.pygments_lexer:
                lexer = pygments.lexers.get_lexer_by_name(
                    self.file_ui.pygments_lexer
                )
            else:
                lexer = None
                try:
                    lexer = pygments.lexers.get_lexer_for_mimetype(
                        self.file_ui.content_type
                    )
                except pygments.util.ClassNotFound:
                    # Try using the main_type only
                    main_type, _ = parse_header_parameters(
                        self.file_ui.content_type
                    )
                    try:
                        lexer = pygments.lexers.get_lexer_for_mimetype(
                            main_type
                        )
                    except pygments.util.ClassNotFound:
                        pass

                if lexer is None:
                    lexer = pygments.lexers.get_lexer_for_mimetype("text/plain")
            assert lexer is not None

            formatter = LinenoHtmlFormatter(
                cssclass="formatted-content",
                linenos=False,
                suffix=self.suffix(),
            )

            formatted = pygments.highlight(
                self.read_file().decode(errors="replace"),
                lexer,
                formatter,
            )

            context["file_content"] = SafeString(formatted)
            context["file_lineno_width"] = max(
                len(str(formatter.linenumber_count)), 2
            )

        return context


class BinaryFileWidget(FileWidget):
    """Show binary files."""

    template_name = "web/_file_binary.html"


class TooBigFileWidget(FileWidget):
    """Show files that are too big to be loaded."""

    template_name = "web/_file_too_big.html"

    @override
    def get_context_data(self) -> dict[str, Any]:
        """Add context data for files too big to display."""
        context = super().get_context_data()
        context["file_max_size"] = MAX_FILE_SIZE
        return context


class FileDownloadMixin(View):
    """File streaming functions for views."""

    def stream_file(
        self,
        file_in_artifact: FileInArtifact,
        ui_info: FileUI,
        download: bool = True,
    ) -> HttpResponseBase:
        """Return a response that streams the_given file."""
        if not file_in_artifact.complete:
            return ProblemResponse(
                "Cannot download incomplete file",
                status_code=status.HTTP_404_NOT_FOUND,
            )

        scope = file_in_artifact.artifact.workspace.scope
        file_backend = scope.download_file_backend(file_in_artifact.file)
        entry = file_backend.get_entry(file_in_artifact.file)

        url = entry.get_url(content_type=ui_info.content_type)
        if url is not None:
            # The client can download the file from the backend
            # TODO: this does not allow to set content-disposition, that is, to
            # distinguish between "view raw" or "download". Not sure if it can
            # be solved.
            return redirect(url)

        with entry.get_stream() as file:
            file_size = file_in_artifact.file.size
            try:
                content_range = parse_range_header(
                    self.request.headers, file_size
                )
            except ValueError as exc:
                # It returns ProblemResponse because ranges are not used
                # by end users directly
                return ProblemResponse(str(exc))

            status_code: int
            start: int | None
            end: int | None
            if content_range is None:
                # Whole file
                status_code = status.HTTP_200_OK
                start = 0
                end = file_size - 1
            else:
                # Part of a file
                status_code = status.HTTP_206_PARTIAL_CONTENT
                start = content_range["start"]
                end = content_range["end"]

                # It returns ProblemResponse because ranges are not used
                # by end users directly
                if start > file_size:
                    return ProblemResponse(
                        f"Invalid Content-Range start: {start}. "
                        f"File size: {file_size}"
                    )

                elif end >= file_size:
                    return ProblemResponse(
                        f"Invalid Content-Range end: {end}. "
                        f"File size: {file_size}"
                    )

            # Use mmap:
            # - No support for content-range or file chunk in Django
            #   as of 2023, so create filelike object of the right chunk
            # - Prevents FileResponse.file_to_stream.name from taking
            #   precedence over .filename and break mimestype
            file_partitioned: Iterable[object]
            if file_size == 0:
                # cannot mmap an empty file
                file_partitioned = io.BytesIO(b"")
            else:
                # We have to rely on Django to close the mmap on completion
                file_partitioned = mmap.mmap(
                    file.fileno(), end + 1, prot=mmap.PROT_READ
                )
                file_partitioned.seek(start)

            filename = Path(file_in_artifact.path)

            response = FileResponse(
                file_partitioned,
                filename=filename.name,
                status=status_code,
            )

            response["Accept-Ranges"] = "bytes"
            response["Content-Length"] = end - start + 1
            if file_size > 0:
                response["Content-Range"] = f"bytes {start}-{end}/{file_size}"
            if download:
                disposition = "attachment"
            else:
                disposition = "inline"
            response["Content-Disposition"] = (
                f'{disposition}; filename="{filename.name}"'
            )
            response["Content-Type"] = ui_info.content_type

            return response


class PathMixin(View):
    """View that accepts a path kwarg."""

    @staticmethod
    def normalize_path(path: str) -> str:
        """
        Normalize a path used as a subdirectory prefix.

        It will also constrain paths not to point above themselves by extra ../
        components
        """
        path = os.path.normpath(path).strip("/")
        while path.startswith("../"):
            path = path[3:]
        if path in ("", ".", ".."):
            return "/"
        return f"/{path}/"

    @cached_property
    def path(self) -> str:
        """
        Return the current subdirectory as requested by the user.

        This returns a path relative to the root of the artifact, with ""
        standing for the whole artifact
        """
        if not (path := self.kwargs.get("path")):
            return ""
        return self.normalize_path(path)[1:]
