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

"""Views for package archives."""

import datetime as dt
import logging
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import cached_property
from typing import Any, override

from debian import deb822
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.handlers.exception import response_for_exception
from django.db.models import QuerySet
from django.db.models.fields.json import KT
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBase
from django.shortcuts import get_object_or_404
from django.utils.cache import patch_cache_control, patch_vary_headers
from django.views.generic import TemplateView
from pgpy import PGPKey

from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.assets.asset_utils import ExportablePGPKeyring
from debusine.db.context import context
from debusine.db.models import Asset, Collection, CollectionItem, FileInArtifact
from debusine.db.models.assets import get_public_keys
from debusine.server.collections.debian_suite import (
    DebianSuiteManager,
    make_source_prefix,
)
from debusine.web.archives.converters import SnapshotConverter
from debusine.web.views.base import BaseUIView
from debusine.web.views.files import FileDownloadMixin, FileUI
from debusine.web.views.places import Place


@dataclass(frozen=True, kw_only=True)
class RenderableSuite:
    """Information about a suite that can be rendered via a template."""

    request_scheme: str
    snapshot: dt.datetime | None = None
    collection: Collection

    @property
    def collection_manager(self) -> DebianSuiteManager:
        """The collection manager for the suite."""
        return DebianSuiteManager(self.collection)

    @property
    def name(self) -> str:
        """The name of the suite."""
        return self.collection.name

    @property
    def components(self) -> list[str] | None:
        """The components that exist in this suite."""
        components = self.collection.data.get("components")
        assert isinstance(components, (list, type(None)))
        return components

    @property
    def architectures(self) -> list[str] | None:
        """The architectures that exist in this suite."""
        architectures = self.collection.data.get("architectures")
        assert isinstance(architectures, (list, type(None)))
        return architectures

    @property
    def release_fields(self) -> dict[str, str]:
        """Static fields to set in this suite's ``Release`` file."""
        release_fields = self.collection.data.get("release_fields", {})
        assert isinstance(release_fields, dict)
        return release_fields

    @property
    def description(self) -> str | None:
        """The description of the suite, if any."""
        return self.release_fields.get("Description")

    def as_deb822_source(self) -> str:
        """Render a deb822-style APT source."""
        archive_fqdn = settings.DEBUSINE_DEBIAN_ARCHIVE_PRIMARY_FQDN
        workspace = self.collection.workspace
        source = deb822.Deb822()
        source["Types"] = "deb"
        source["URIs"] = (
            f"{self.request_scheme}://{archive_fqdn}"
            f"/{workspace.scope.name}/{workspace.name}"
        )
        source["Suites"] = self.collection.name
        if self.components is not None:
            source["Components"] = " ".join(self.components)
        if signing_keys := self.collection_manager.export_signing_keys():
            source["Signed-By"] = "\n" + "\n".join(
                " " + (line or ".") for line in signing_keys.splitlines()
            )
        if self.snapshot is not None:
            source["Snapshot"] = SnapshotConverter().to_url(self.snapshot)
        return source.dump()

    def get_required_renderable_suites(self) -> list["RenderableSuite"]:
        """Return renderable information for suites recursively required."""
        assert isinstance(self.collection.manager, DebianSuiteManager)

        return [
            RenderableSuite(
                request_scheme=self.request_scheme,
                snapshot=self.snapshot,
                collection=suite,
            )
            for suite in self.collection.manager.required_suites()
        ]

    def required_suites_as_deb822_sources(self) -> str:
        """Render deb822-style APT sources required by this suite."""
        return "\n".join(
            suite.as_deb822_source()
            for suite in self.get_required_renderable_suites()
        )


class SigningKeysResponse(HttpResponse):
    """A renderer for OpenPGP signing keys."""

    def __init__(self, keys: list[bytes]) -> None:
        """Construct an HTTP response from a list of keys."""
        content = str(
            ExportablePGPKeyring(*(PGPKey.from_blob(key)[0] for key in keys))
        )
        super().__init__(
            content=content.encode("utf-8"),
            content_type="application/pgp-keys; charset=utf-8",
        )


class ArchiveView(BaseUIView, ABC):
    """A view of something in an archive."""

    base_template = "web/_base_archives.html"
    archive: Collection

    @property
    def cache_control(self) -> dict[str, Any]:
        """Return appropriate ``Cache-Control`` arguments for this response."""
        # Responses resulting from a snapshot-specific query are immutable.
        # For other responses, default to telling caches to consider them
        # stale after a relatively short delay; subclasses may override
        # this.
        return (
            {"max-age": 31536000}
            if self.kwargs.get("snapshot") is not None
            else {"max-age": 1800, "proxy-revalidate": True}
        )

    def get_snapshot(self) -> QuerySet[CollectionItem]:
        """Return a queryset of items active at the selected timestamp."""
        qs = CollectionItem.objects.filter(
            parent_collection__workspace=context.require_workspace()
        )
        if (snapshot := self.kwargs.get("snapshot")) is not None:
            return qs.active_at(snapshot)
        else:
            return qs.active()

    def get_suites(self) -> QuerySet[Collection]:
        """Return a queryset of relevant suites."""
        # Collections don't have created/removed times, but that isn't a big
        # problem for snapshot support since the items they contain do.
        qs = (
            Collection.objects.in_current_workspace()
            .exported_suites()
            .can_display(context.pc)
        )
        if (suite := self.kwargs.get("suite")) is not None:
            qs = qs.filter(name=suite)
        return qs

    @cached_property
    def suite(self) -> Collection | None:
        """The current suite, if this is a view for a specific suite."""
        if "suite" in self.kwargs:
            try:
                return self.get_suites().get()
            except Collection.DoesNotExist as exc:
                if (workspace := context.require_workspace()).public:
                    raise Http404("Suite not found") from exc
                else:
                    # Report this as if the workspace as a whole did not
                    # exist, to avoid information leaks.
                    logging.debug(
                        "permission denied on suite in %s reported as 404",
                        workspace,
                    )
                    self.raise_workspace_not_found(workspace)
        else:
            return None

    def enforce_visibility(self) -> None:
        """Enforce visibility of the archive or the suite, as appropriate."""
        collection = self.suite or self.archive
        try:
            self.enforce(collection.can_display)
        except PermissionDenied:
            # Report this as if the workspace as a whole did not exist, to
            # avoid information leaks.
            logging.debug("permission denied on %s reported as 404", collection)
            self.raise_workspace_not_found(collection.workspace)

    def set_archive(self) -> None:
        """Set the archive based on the request arguments."""
        self.set_current_workspace(
            self.kwargs["workspace"], check_visibility=False
        )
        workspace = context.require_workspace()
        try:
            self.archive = Collection.objects.get(
                workspace=workspace, category=CollectionCategory.ARCHIVE
            )
        except Collection.DoesNotExist:
            raise Http404(
                f"No {CollectionCategory.ARCHIVE} collection found in "
                f"{workspace}"
            )
        self.enforce_visibility()

    @override
    def dispatch(
        self, request: HttpRequest, *args: Any, **kwargs: Any
    ) -> HttpResponseBase:
        """Dispatch the request, setting appropriate response headers."""
        try:
            response = super().dispatch(request, *args, **kwargs)
        except Exception as exc:
            response = response_for_exception(request, exc)
        patch_cache_control(response, **self.cache_control)
        patch_vary_headers(response, ("Authorization",))
        return response


class ArchiveRootView(ArchiveView, TemplateView):
    """The root of an archive."""

    template_name = "web/archive-root.html"

    @override
    def get_context_data(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
        self.set_archive()
        ctx = super().get_context_data(**kwargs)

        suites = [
            RenderableSuite(
                request_scheme=self.request.scheme or "http",
                snapshot=self.kwargs.get("snapshot"),
                collection=suite,
            )
            for suite in self.get_suites().order_by("name")
        ]

        ctx["suites"] = suites
        ctx["workspace_description"] = context.require_workspace().description
        return ctx

    @override
    def get_place(self) -> Place:
        assert context.workspace is not None
        return context.workspace.ui(self.request).place_suite_list


class SuiteRootView(ArchiveView, TemplateView):
    """The root of a suite."""

    template_name = "web/suite-root.html"

    @override
    def get_context_data(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
        self.set_archive()

        ctx = super().get_context_data(**kwargs)

        assert self.suite is not None
        ctx["suite"] = RenderableSuite(
            request_scheme=self.request.scheme or "http",
            snapshot=self.kwargs.get("snapshot"),
            collection=self.suite,
        )
        return ctx

    @override
    def get_place(self) -> Place:
        assert self.suite is not None
        return self.suite.ui(self.request).place_suite_root


class SigningKeysView(ArchiveView):
    """Signing keys for an archive or a suite."""

    def get(
        self,
        request: HttpRequest,  # noqa: ARG002, U100
        *args: Any,
        **kwargs: Any,
    ) -> HttpResponse:
        """Show signing keys for an archive or a suite."""
        self.set_archive()
        signing_keys: set[Asset] = set()
        for suite in self.get_suites():
            signing_keys.update(
                DebianSuiteManager(suite).get_signing_keys() or []
            )
        return SigningKeysResponse(list(get_public_keys(signing_keys)))


class ArchiveFileView(ArchiveView, FileDownloadMixin, ABC):
    """A file in an archive."""

    @abstractmethod
    def get_collection_items(self) -> QuerySet[CollectionItem]:
        """
        Return a queryset of collection items matching the requested file.

        Subclasses should use their URL arguments to narrow the filter until
        it is guaranteed to return at most one item pointing to an artifact
        containing exactly one file.
        """
        return self.get_snapshot().filter(
            child_type=CollectionItem.Types.ARTIFACT, artifact__isnull=False
        )

    def get_queryset(self) -> QuerySet[FileInArtifact]:
        """Filter to a single matching file."""
        # Maybe this should use the generic collection lookup mechanism, but
        # that doesn't currently support snapshots or other things we need
        # such as constraining pool files by component and source package
        # name.  In any case, it seems worth having an optimized lookup path
        # for these views.
        return FileInArtifact.objects.filter(
            artifact__in=self.get_collection_items().values("artifact")
        )

    def get(
        self,
        request: HttpRequest,  # noqa: ARG002, U100
        *args: Any,
        **kwargs: Any,
    ) -> HttpResponseBase:
        """Download a file."""
        self.set_archive()
        try:
            file_in_artifact = get_object_or_404(self.get_queryset())
        except Http404:
            if (workspace := context.require_workspace()).public:
                raise
            else:
                # Report this as if the workspace as a whole did not exist,
                # to avoid information leaks.
                logging.debug(
                    "permission denied on file in %s reported as 404", workspace
                )
                self.raise_workspace_not_found(workspace)
        ui_info = FileUI.from_file_in_artifact(file_in_artifact)
        return self.stream_file(file_in_artifact, ui_info)


class SuiteFileView(ArchiveFileView, ABC):
    """A file looked up via a suite."""

    @override
    @abstractmethod
    def get_collection_items(self) -> QuerySet[CollectionItem]:
        """Filter to items in any suite in the selected archive."""
        return (
            super()
            .get_collection_items()
            .filter(
                parent_collection__in=self.get_suites(),
                # Technically redundant with the filters in
                # self.get_suites(), but helps PostgreSQL to use the correct
                # index.
                parent_category=CollectionCategory.SUITE,
            )
        )

    @override
    def enforce_visibility(self) -> None:
        # A narrowly-scoped token might only have permission to see an
        # individual suite within the archive, rather than the whole
        # archive.  Such tokens should be able to see any file referenced
        # via any suite that they can see.  Since this view filters to items
        # in any of the suites returned by get_suites, and since get_suites
        # only returns visible suites, we can safely skip further
        # permissions enforcement.
        pass


class DistsByHashFileView(SuiteFileView):
    """An index file in a ``by-hash`` directory."""

    @override
    @property
    def cache_control(self) -> dict[str, Any]:
        """``by-hash`` responses are always immutable."""
        return {"max-age": 31536000}

    @override
    def get_snapshot(self) -> QuerySet[CollectionItem]:
        """
        Return a queryset of items that existed at the selected timestamp.

        This is slightly different for ``by-hash`` files than others: the
        items need to have existed at the given timestamp, but they don't
        need to have been active.
        """
        qs = CollectionItem.objects.filter(
            parent_collection__workspace=context.require_workspace()
        )
        if (snapshot := self.kwargs.get("snapshot")) is not None:
            qs = qs.filter(created_at__lte=snapshot)
        return qs

    @override
    def get_collection_items(self) -> QuerySet[CollectionItem]:
        """Filter to the requested index file."""
        return (
            super()
            .get_collection_items()
            .annotate(path=KT("data__path"))
            .filter(
                category=ArtifactCategory.REPOSITORY_INDEX,
                # The directory name must be equal to everything up to the
                # last slash.
                path__regex=f"^{re.escape(self.kwargs['directory'])}/[^/]+$",
            )
            # Release files and friends don't have by-hash entries.
            .exclude(path__in=("Release", "Release.gpg", "InRelease"))
        )

    @override
    def get_queryset(self) -> QuerySet[FileInArtifact]:
        """Filter to a file with the requested checksum."""
        # XXX 404 if bytes.fromhex fails
        # We may match multiple identical files, take the first.
        return (
            super()
            .get_queryset()
            .filter(file__sha256=bytes.fromhex(self.kwargs["checksum"]))
        )[:1]


class DistsFileView(SuiteFileView):
    """An index file in a suite."""

    @override
    def get_collection_items(self) -> QuerySet[CollectionItem]:
        """Filter to the requested index file."""
        return (
            super()
            .get_collection_items()
            .annotate(path=KT("data__path"))
            .filter(
                category=ArtifactCategory.REPOSITORY_INDEX,
                path=self.kwargs["path"],
            )
        )


class PoolFileView(SuiteFileView):
    """
    A package in the archive's pool.

    While the suite isn't in the URL, we still need to look up pool files
    via any suite in the archive.  It may be in more than one suite, but
    constraints guarantee that they all refer to the same contents.
    """

    _re_binary_package = re.compile(
        r"^([^_]+)_([^_]+)_([^.]+)\.(?:deb|ddeb|udeb)$"
    )

    @override
    @property
    def cache_control(self) -> dict[str, Any]:
        """Pool files in ``may_reuse_versions=False`` archives are immutable."""
        if hasattr(self, "archive") and not self.archive.data.get(
            "may_reuse_versions", False
        ):
            return {"max-age": 31536000}
        else:
            return super().cache_control

    @override
    def get_collection_items(self) -> QuerySet[CollectionItem]:
        """Filter to the requested pool file."""
        if self.kwargs["sourceprefix"] != make_source_prefix(
            self.kwargs["source"]
        ):
            raise Http404("No FileInArtifact matches the given query.")

        qs = (
            super()
            .get_collection_items()
            .annotate(component=KT("data__component"))
            .filter(component=self.kwargs["component"])
        )
        # Although filtering on collection item properties here is somewhat
        # redundant with the path filter that get_queryset() will add, it
        # allows us to make use of db_ci_suite_source_idx and
        # db_ci_suite_binary_source_idx, and it means the database can
        # filter out irrelevant CollectionItem rows before needing to look
        # at FileInArtifact.  We don't bother constraining these searches by
        # version, since some files in source packages only contain the
        # upstream part of the version; this also saves us from worrying
        # about epochs.
        if m := self._re_binary_package.match(self.kwargs["filename"]):
            qs = qs.annotate(
                srcpkg_name=KT("data__srcpkg_name"),
                package=KT("data__package"),
                architecture=KT("data__architecture"),
            ).filter(
                category=ArtifactCategory.BINARY_PACKAGE,
                srcpkg_name=self.kwargs["source"],
                package=m.group(1),
                architecture=m.group(3),
            )
        else:
            qs = qs.annotate(package=KT("data__package")).filter(
                category=ArtifactCategory.SOURCE_PACKAGE,
                package=self.kwargs["source"],
            )
        return qs

    @override
    def get_queryset(self) -> QuerySet[FileInArtifact]:
        """Filter to a file with the requested name."""
        return (
            super()
            .get_queryset()
            .filter(path=self.kwargs["filename"])
            .distinct("file")
        )


class TopLevelFileView(ArchiveFileView):
    """A top-level file in the archive, not in any suite."""

    @override
    def get_collection_items(self) -> QuerySet[CollectionItem]:
        """Filter to the requested index file."""
        return (
            super()
            .get_collection_items()
            .annotate(path=KT("data__path"))
            .filter(
                parent_category=CollectionCategory.ARCHIVE,
                category=ArtifactCategory.REPOSITORY_INDEX,
                path=self.kwargs["path"],
            )
        )


class DebugInfoView(SuiteFileView):
    """A debug info file corresponding to an ELF object in the archive."""

    _re_build_id = re.compile(r"^[0-9a-f]{40}$")

    @override
    @property
    def cache_control(self) -> dict[str, Any]:
        """Debuginfo responses are always immutable."""
        return {"max-age": 31536000}

    @property
    def build_id(self) -> str:
        """Return the requested build-ID, normalized to lowercase."""
        return str(self.kwargs["build_id"]).lower()

    @override
    def get_collection_items(self) -> QuerySet[CollectionItem]:
        """Filter to a debug-symbols item for the requested build-ID."""
        if not self._re_build_id.fullmatch(self.build_id):
            raise Http404("Invalid build-ID.")
        return (
            super()
            .get_collection_items()
            .annotate(build_id=KT("data__build_id"))
            .filter(
                category=ArtifactCategory.DEBUG_SYMBOLS,
                build_id=self.build_id,
            )
        )

    @override
    def get_queryset(self) -> QuerySet[FileInArtifact]:
        """Filter to the file whose path is the build-ID."""
        return (
            super().get_queryset().filter(path=self.build_id).distinct("file")
        )

    @override
    def get(
        self, request: HttpRequest, *args: Any, **kwargs: Any
    ) -> HttpResponseBase:
        """Download a debuginfo file and set debuginfod headers."""
        response = super().get(request, *args, **kwargs)
        build_id = self.build_id
        response["X-DEBUGINFOD-FILE"] = (
            f"/usr/lib/debug/.build-id/{build_id[:2]}/{build_id[2:]}.debug"
        )
        response["X-DEBUGINFOD-SIZE"] = str(self.get_queryset().get().file.size)
        return response
