# 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 re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any

from django.conf import settings
from django.db.models import QuerySet
from django.db.models.fields.json import KT
from django.http import Http404, HttpRequest, HttpResponseBase
from django.shortcuts import get_object_or_404
from django.utils.cache import patch_cache_control, patch_vary_headers
from rest_framework import status
from rest_framework.renderers import TemplateHTMLRenderer
from rest_framework.request import Request
from rest_framework.response import Response

from debian import deb822
from debusine.artifacts.models import ArtifactCategory, CollectionCategory
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.server.exceptions import DebusineAPIException
from debusine.server.views.base import BaseAPIView
from debusine.web.archives.converters import SnapshotConverter
from debusine.web.archives.renderers import SigningKeysRenderer
from debusine.web.views.files import FileDownloadMixin, FileUI


@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()


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

    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()
        if (suite := self.kwargs.get("suite")) is not None:
            qs = qs.filter(name=suite)
        return qs

    def set_archive(self) -> None:
        """Set the archive based on the request arguments."""
        self.set_current_workspace(self.kwargs["workspace"])
        workspace = context.require_workspace()
        try:
            self.archive = Collection.objects.get(
                workspace=workspace, category=CollectionCategory.ARCHIVE
            )
        except Collection.DoesNotExist:
            raise DebusineAPIException(
                title="Archive not found",
                detail=(
                    f"No {CollectionCategory.ARCHIVE} collection found in "
                    f"{workspace}"
                ),
                status_code=status.HTTP_404_NOT_FOUND,
            )
        self.enforce(self.archive.can_display)

    def dispatch(
        self, request: HttpRequest, *args: Any, **kwargs: Any
    ) -> HttpResponseBase:
        """Dispatch the request, setting appropriate response headers."""
        response = super().dispatch(request, *args, **kwargs)
        patch_cache_control(response, **self.cache_control)
        patch_vary_headers(response, ("Authorization",))
        return response


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

    renderer_classes = [TemplateHTMLRenderer]
    template_name = "web/archive-root.html"

    def get(
        self, request: Request, *args: Any, **kwargs: Any  # noqa: U100
    ) -> Response:
        """Describe an archive."""
        self.set_archive()
        suites: list[RenderableSuite] = []
        for suite in self.get_suites().order_by("name"):
            suites.append(
                RenderableSuite(
                    request_scheme=self.request.scheme or "http",
                    snapshot=self.kwargs.get("snapshot"),
                    collection=suite,
                )
            )
        return Response(
            {
                "base_template": "web/_base_unscoped.html",
                "suites": suites,
                "scope": self.kwargs["scope"],
            }
        )


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

    renderer_classes = [TemplateHTMLRenderer]
    template_name = "web/suite-root.html"

    def get(
        self, request: Request, *args: Any, **kwargs: Any  # noqa: U100
    ) -> Response:
        """Describe a suite."""
        self.set_archive()
        try:
            suite = self.get_suites().get()
        except Collection.DoesNotExist:
            raise DebusineAPIException(
                title="Suite not found", status_code=status.HTTP_404_NOT_FOUND
            )
        assert suite is not None
        return Response(
            {
                "base_template": "web/_base_unscoped.html",
                "suite": RenderableSuite(
                    request_scheme=self.request.scheme or "http",
                    snapshot=self.kwargs.get("snapshot"),
                    collection=suite,
                ),
                "scope": self.kwargs["scope"],
            }
        )


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

    renderer_classes = [SigningKeysRenderer]

    def get(
        self, request: Request, *args: Any, **kwargs: Any  # noqa: U100
    ) -> Response:
        """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 Response({"signing_keys": 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: Request, *args: Any, **kwargs: Any  # noqa: U100
    ) -> HttpResponseBase:
        """Download a file."""
        self.set_archive()
        try:
            file_in_artifact = get_object_or_404(self.get_queryset())
        except Http404 as exc:
            raise DebusineAPIException(
                title=str(exc), status_code=status.HTTP_404_NOT_FOUND
            )
        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."""

    @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,
            )
        )


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

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

    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

    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"))
        )

    def get_queryset(self) -> QuerySet[FileInArtifact]:
        """Filter to a file with the requested checksum."""
        # XXX 404 if bytes.fromhex fails
        return (
            super()
            .get_queryset()
            .filter(file__sha256=bytes.fromhex(self.kwargs["checksum"]))
        )


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

    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)$"
    )

    @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

    def get_collection_items(self) -> QuerySet[CollectionItem]:
        """Filter to the requested pool file."""
        if self.kwargs["sourceprefix"] != make_source_prefix(
            self.kwargs["source"]
        ):
            raise DebusineAPIException(
                title="No FileInArtifact matches the given query.",
                status_code=status.HTTP_404_NOT_FOUND,
            )

        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

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

    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"],
            )
        )
