# Copyright 2024 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 contextlib
import io
import mimetypes
import mmap
import os.path
from collections.abc import Generator, Iterable, Iterator
from functools import cached_property
from pathlib import Path
from typing import Any, IO, NamedTuple, Self, TYPE_CHECKING

from django.http import FileResponse
from django.http.response import HttpResponseBase
from django.shortcuts import redirect
from django.template.context import Context
from django.utils.safestring import SafeString
from django.views.generic.base import View

import pygments
import pygments.formatters
import pygments.lexers

from rest_framework import status

from debusine.db.models import FileInArtifact
from debusine.server.views import ProblemResponse
from debusine.utils import parse_range_header
from debusine.web.views.base import Widget

if TYPE_CHECKING:
    HtmlFormatter = pygments.formatters.HtmlFormatter
else:
    # pygments doesn't support generic types at run-time yet.
    class _HtmlFormatter:
        def __class_getitem__(*args):
            return pygments.formatters.HtmlFormatter

    HtmlFormatter = _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."""
        # TODO: this is currently rather simple minded.
        # If it will need more complexity in the future,
        # debusine.artfacts.models.ArtifactData can be extended with a
        # get_content_type or get_file_ui method.
        # Another thing we may want to consider is adding a content_type
        # argument to File.
        content_type: str
        widget_class: type["FileWidget"]
        pygments_lexer: str | None = None
        match os.path.splitext(file.path)[1]:
            case ".buildinfo" | ".dsc" | ".changes":
                content_type = "text/plain; charset=utf-8"
                widget_class = TextFileWidget
                pygments_lexer = "debcontrol"
            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_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

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

        return cls(content_type, widget_class, pygments_lexer)


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

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

    def wrap(
        self, *args: Any, **kwargs: Any
    ) -> Generator[tuple[int, str], None, None]:
        """Count rendered lines."""
        for val in super().wrap(*args, **kwargs):
            self.line_count += val[0]
            yield val

    def render_linenos(self) -> str:
        """Render line numbers to go alongside rendered contents."""
        width = len(str(self.line_count))
        return SafeString(
            "\n".join(
                f"<a id='L{n}' href='#L{n}'>{str(n).rjust(width)}</a>"
                for n in range(1, self.line_count + 1)
            )
        )


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

    template_name: str

    def __init__(
        self, file_in_artifact: FileInArtifact, file_ui: FileUI
    ) -> None:
        """Initialize object with a file to display."""
        self.file_in_artifact = file_in_artifact
        self.file_ui = file_ui or FileUI.from_file_in_artifact(file_in_artifact)

    @classmethod
    def create(
        cls, file_in_artifact: FileInArtifact, file_ui: FileUI | None = None
    ) -> "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)

    @contextlib.contextmanager
    def _open_file(self) -> Iterator[IO[bytes]]:
        assert self.file_in_artifact is not None
        workspace = self.file_in_artifact.artifact.workspace
        file_backend = workspace.default_file_store.get_backend_object()
        with file_backend.get_stream(self.file_in_artifact.file) as stream:
            yield stream

    def get_context_data(self) -> dict[str, Any]:
        """Return the context for this widget."""
        return {
            "file_in_artifact": self.file_in_artifact,
            "file_ui": self.file_ui,
        }

    def render(self, context: Context) -> str:
        """Render the widget."""
        assert context.template is not None
        template = context.template.engine.get_template(self.template_name)
        with context.update(self.get_context_data()):
            return template.render(context)


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

    template_name = "web/_file_text.html"

    # add_context_data_* names match FileViewTypes enum options

    def get_context_data(self) -> dict[str, Any]:
        """Add context data for text files."""
        context = super().get_context_data()
        # 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:
            try:
                lexer = pygments.lexers.get_lexer_for_mimetype(
                    self.file_ui.content_type
                )
            except pygments.util.ClassNotFound:
                lexer = pygments.lexers.get_lexer_for_mimetype("text/plain")

        formatter = LinenoHtmlFormatter(
            cssclass="file_highlighted",
            linenos=False,
        )

        with self._open_file() as stream:
            formatted = pygments.highlight(stream.read(), lexer, formatter)

        context["file_linenumbers"] = SafeString(formatter.render_linenos())
        context["file_content"] = SafeString(formatted)
        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"

    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."""
        try:
            content_range = parse_range_header(self.request.headers)
        except ValueError as exc:
            # It returns ProblemResponse because ranges are not used
            # by end users directly
            return ProblemResponse(str(exc))

        workspace = file_in_artifact.artifact.workspace
        file_backend = workspace.default_file_store.get_backend_object()

        # Ignore func-returns-value for now: LocalFileBackend.get_url always
        # returns None, but the backend might be something else in future.
        url = file_backend.get_url(
            file_in_artifact.file
        )  # type: ignore[func-returns-value]
        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 file_backend.get_stream(file_in_artifact.file) as file:
            file_size = file_in_artifact.file.size
            status_code: int
            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:
                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:]
