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

"""The collection manager for debian:suite collections."""

import datetime as dt
import re
from collections.abc import Generator
from contextlib import contextmanager
from typing import Any, override

from debian.debian_support import Version
from django.db import IntegrityError
from django.db.models import QuerySet
from django.db.models.fields.json import KT
from django.utils import timezone
from pgpy import PGPKey

from debusine.artifacts.local_artifact import BinaryPackage, SourcePackage
from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.assets.asset_utils import ExportablePGPKeyring
from debusine.assets.models import AssetCategory, KeyPurpose
from debusine.db.models import (
    Artifact,
    Asset,
    Collection,
    CollectionItem,
    CollectionItemMatchConstraint,
    User,
    WorkRequest,
)
from debusine.db.models.assets import get_public_keys
from debusine.server.collections.base import (
    CollectionManagerInterface,
    ItemAdditionError,
)


def make_source_prefix(source_package_name: str) -> str:
    """
    Make the prefix for a source package name in a Debian repository's pool.

    This is the "<first letter of source name>" / "<first 4 letters of
    source name>" segment, as described in
    https://lists.debian.org/debian-devel/2000/10/msg01340.html.
    """
    if source_package_name.startswith("lib"):
        return source_package_name[:4]
    else:
        return source_package_name[:1]


def make_pool_filename(
    source_package_name: str, component: str, base_name: str
) -> str:
    """
    Make a file name in a Debian repository's pool.

    See https://lists.debian.org/debian-devel/2000/10/msg01340.html for an
    explanation of the structure.
    """
    prefix = make_source_prefix(source_package_name)
    return f"pool/{component}/{prefix}/{source_package_name}/{base_name}"


class DebianSuiteManager(CollectionManagerInterface):
    """Manage collection of category debian:suite."""

    COLLECTION_CATEGORY = CollectionCategory.SUITE
    VALID_ARTIFACT_CATEGORIES = frozenset(
        {
            ArtifactCategory.SOURCE_PACKAGE,
            ArtifactCategory.BINARY_PACKAGE,
            ArtifactCategory.REPOSITORY_INDEX,
        }
    )

    @override
    def __init__(self, collection: Collection) -> None:
        super().__init__(collection)
        self.may_rewind_versions = False

    @property
    def parent_archive(self) -> Collection | None:
        """Get the parent archive, if there is one."""
        try:
            return Collection.objects.get(
                workspace=self.collection.workspace,
                category=CollectionCategory.ARCHIVE,
                name="_",
            )
        except Collection.DoesNotExist:
            return None

    @property
    def may_reuse_versions(self) -> bool:
        """True if versions in this suite may be reused."""
        if self.collection.data.get("may_reuse_versions", False):
            return True
        if (archive := self.parent_archive) is None:
            return False
        if archive.data.get("may_reuse_versions", False):
            return True
        return False

    def get_signing_keys(self) -> QuerySet[Asset]:
        """Return signing keys for this suite."""
        if self.collection.data.get("signing_keys") is not None:
            fingerprints = self.collection.data["signing_keys"]
        else:
            if (archive := self.parent_archive) is not None:
                fingerprints = archive.data.get("signing_keys", [])
            else:
                return Asset.objects.none()
        # TODO: This might not be quite right for snapshots; we always just
        # return the current signing keys.  However, at the moment we only
        # track the creation time of assets, but we don't track when asset
        # usage records are created or removed and we don't retain removed
        # assets; so it's hard to do much better.
        return Asset.objects.filter(
            category=AssetCategory.SIGNING_KEY,
            data__purpose=KeyPurpose.OPENPGP_REPOSITORY,
            data__fingerprint__in=fingerprints,
            usage__workspace=self.collection.workspace,
        )

    def export_signing_keys(self) -> str | None:
        """Export signing keys for this suite as an ASCII-armored keyring."""
        signing_keys = self.get_signing_keys()
        if not signing_keys:
            return None
        # PGPy's upstream maintenance status is currently unclear; see e.g.
        # https://github.com/SecurityInnovation/PGPy/issues/462.  Since it's
        # pure-Python the risk is relatively low, but it's still possible
        # that it might be confused into producing misleading keyrings.
        # Think more carefully before allowing untrusted users to upload
        # their own signing keys.
        return str(
            ExportablePGPKeyring(
                *(
                    PGPKey.from_blob(public_key)[0]
                    for public_key in get_public_keys(signing_keys)
                )
            )
        )

    def make_constraints(
        self, item: CollectionItem, source_package_name: str, component: str
    ) -> Generator[CollectionItemMatchConstraint]:
        """Yield constraints for an item added to this collection."""
        assert item.artifact is not None
        containing_archives = Collection.objects.filter(
            category=CollectionCategory.ARCHIVE,
            child_items__removed_at__isnull=True,
            child_items__collection=self.collection,
        )
        for file_in_artifact in item.artifact.fileinartifact_set.all():
            pool_filename = make_pool_filename(
                source_package_name, component, file_in_artifact.path
            )
            file = file_in_artifact.file
            hash_digest = (
                f"{file.current_hash_algorithm}:{file.hash_digest.hex()}"
            )
            # Each file name in pool/ must only refer to at most one
            # concrete file in the suite at a given time.
            yield CollectionItemMatchConstraint(
                collection=self.collection,
                collection_item_id=item.id,
                constraint_name="pool-file",
                key=pool_filename,
                value=hash_digest,
            )
            # The pool/ directory is shared between suites in the same
            # archive, so we also need archive-level constraints to ensure
            # that different suites in the same archive don't contain the
            # same pool file with different contents.
            for archive in containing_archives:
                yield CollectionItemMatchConstraint(
                    collection=archive,
                    collection_item_id=item.id,
                    constraint_name="pool-file",
                    key=pool_filename,
                    value=hash_digest,
                )

    @contextmanager
    def allow_rewinding_versions(self) -> Generator[None]:
        """Temporarily allow rewinding versions in this suite."""
        original_may_rewind_versions = self.may_rewind_versions
        try:
            self.may_rewind_versions = True
            yield
        finally:
            self.may_rewind_versions = original_may_rewind_versions

    def _check_existing_versions(
        self,
        new_version: Version,
        existing_items: QuerySet[CollectionItem],
        *,
        kind: str,
        package_name: str,
    ) -> None:
        if not self.may_rewind_versions and not self.collection.data.get(
            "may_rewind_versions", False
        ):
            for existing_item in existing_items:
                if new_version < Version(existing_item.data["version"]):
                    raise ItemAdditionError(
                        f"{self.collection}: New {kind} version "
                        f"{package_name} {new_version} < "
                        f"existing version {existing_item.data["version"]}"
                    )

    @override
    def do_add_artifact(
        self,
        artifact: Artifact,
        *,
        user: User,
        workflow: WorkRequest | None = None,
        variables: dict[str, Any] | None = None,
        name: str | None = None,
        replace: bool = False,
        created_at: dt.datetime | None = None,
        replaced_by: CollectionItem | None = None,
    ) -> CollectionItem:
        required_variables: dict[str, tuple[str, ...]] = {
            ArtifactCategory.SOURCE_PACKAGE: ("component", "section"),
            ArtifactCategory.BINARY_PACKAGE: (
                "component",
                "section",
                "priority",
            ),
            ArtifactCategory.REPOSITORY_INDEX: ("path",),
        }
        for required_variable in required_variables[artifact.category]:
            if required_variable not in (variables or {}):
                raise ItemAdditionError(
                    f'Adding {artifact.category} to {CollectionCategory.SUITE} '
                    f'requires "{required_variable}"'
                )
        assert variables is not None

        if artifact.category == ArtifactCategory.SOURCE_PACKAGE:
            source_package_data = SourcePackage.create_data(artifact.data)
            data = {
                "package": source_package_data.name,
                "version": source_package_data.version,
                "component": variables["component"],
                "section": variables["section"],
            }
            name = "{package}_{version}".format(**data)
            source_package_name = source_package_data.name
            self._check_existing_versions(
                Version(data["version"]),
                self.collection.child_items.active().filter(
                    child_type=CollectionItem.Types.ARTIFACT,
                    category=ArtifactCategory.SOURCE_PACKAGE,
                    data__package=data["package"],
                ),
                kind="source",
                package_name=data["package"],
            )
        elif artifact.category == ArtifactCategory.BINARY_PACKAGE:
            binary_package_data = BinaryPackage.create_data(artifact.data)
            data = {
                "srcpkg_name": binary_package_data.srcpkg_name,
                "srcpkg_version": binary_package_data.srcpkg_version,
                "package": binary_package_data.deb_fields["Package"],
                "version": binary_package_data.deb_fields["Version"],
                "architecture": binary_package_data.deb_fields["Architecture"],
                "component": variables["component"],
                "section": variables["section"],
                "priority": variables["priority"],
            }
            name = "{package}_{version}_{architecture}".format(**data)
            source_package_name = binary_package_data.srcpkg_name
            self._check_existing_versions(
                Version(data["version"]),
                self.collection.child_items.active().filter(
                    child_type=CollectionItem.Types.ARTIFACT,
                    category=ArtifactCategory.BINARY_PACKAGE,
                    data__package=data["package"],
                    data__architecture=data["architecture"],
                ),
                kind="binary",
                package_name=f"{data['package']}:{data['architecture']}",
            )
        else:
            assert artifact.category == ArtifactCategory.REPOSITORY_INDEX
            data = {"path": variables["path"]}
            name = "index:{path}".format(**data)

        if replace:
            self.remove_items_by_name(
                name=name,
                child_types=[CollectionItem.Types.ARTIFACT],
                user=user,
                workflow=workflow,
            )

        try:
            item = CollectionItem.objects.get(
                parent_collection=self.collection,
                name=name,
                artifact=artifact,
                child_type=CollectionItem.Types.ARTIFACT,
                category=artifact.category,
                data=data,
            )
        except CollectionItem.DoesNotExist:
            pass
        else:
            # The same artifact is already in the collection with the same
            # name and data.
            return item

        try:
            item = CollectionItem.objects.create_from_artifact(
                artifact,
                parent_collection=self.collection,
                name=name,
                data=data,
                created_at=created_at,
                created_by_user=user,
                created_by_workflow=workflow,
                replaced_by=replaced_by,
            )
            if replaced_by is None and artifact.category in {
                ArtifactCategory.SOURCE_PACKAGE,
                ArtifactCategory.BINARY_PACKAGE,
            }:
                CollectionItemMatchConstraint.objects.bulk_create(
                    self.make_constraints(
                        item, source_package_name, data["component"]
                    )
                )
            return item
        except IntegrityError as exc:
            raise ItemAdditionError(str(exc))

    @override
    def do_remove_item(
        self,
        item: CollectionItem,
        *,
        user: User | None = None,
        workflow: WorkRequest | None = None,
    ) -> None:
        item.removed_by_user = user
        item.removed_by_workflow = workflow
        item.removed_at = timezone.now()
        item.save()
        if (
            item.artifact is not None
            and (
                item.artifact.category
                in {
                    ArtifactCategory.SOURCE_PACKAGE,
                    ArtifactCategory.BINARY_PACKAGE,
                }
            )
            and self.may_reuse_versions
        ):
            # This deletes both suite and archive constraints.
            CollectionItemMatchConstraint.objects.filter(
                collection_item_id=item.id, constraint_name="pool-file"
            ).delete()

    @override
    def do_lookup(self, query: str) -> CollectionItem | None:
        """
        Return one CollectionItem based on the query.

        :param query: `source:NAME`, `source-version:NAME_VERSION`,
          `binary:NAME_ARCHITECTURE`,
          `binary-version:NAME_VERSION_ARCHITECTURE`, or `index:PATH`.  If
          more than one possible CollectionItem matches the query (which is
          possible for `source:` and `binary:` queries): return the one with
          the highest version.
        """
        qs = self.collection.child_items.active().filter(
            child_type=CollectionItem.Types.ARTIFACT,
            # Technically redundant, but helps PostgreSQL to use the correct
            # index where available (e.g. db_ci_suite_source_idx).
            parent_category=CollectionCategory.SUITE,
        )
        sort_by_version = True

        if m := re.match(r"^source:(.+)$", query):
            qs = qs.annotate(package=KT("data__package")).filter(
                category=ArtifactCategory.SOURCE_PACKAGE,
                package=m.group(1),
            )
        elif m := re.match(r"^source-version:(.+?)_(.+)$", query):
            qs = qs.annotate(
                package=KT("data__package"), version=KT("data__version")
            ).filter(
                category=ArtifactCategory.SOURCE_PACKAGE,
                package=m.group(1),
                version=m.group(2),
            )
        elif m := re.match(r"^binary:(.+?)_(.+)$", query):
            qs = qs.annotate(
                package=KT("data__package"),
                architecture=KT("data__architecture"),
            ).filter(
                category=ArtifactCategory.BINARY_PACKAGE,
                package=m.group(1),
                architecture__in={m.group(2), "all"},
            )
        elif m := re.match(r"^binary-version:(.+?)_(.+?)_(.+)$", query):
            qs = qs.annotate(
                package=KT("data__package"),
                version=KT("data__version"),
                architecture=KT("data__architecture"),
            ).filter(
                category=ArtifactCategory.BINARY_PACKAGE,
                package=m.group(1),
                version=m.group(2),
                architecture__in={m.group(3), "all"},
            )
        elif m := re.match(r"^index:(.+)$", query):
            qs = qs.annotate(path=KT("data__path")).filter(
                category=ArtifactCategory.REPOSITORY_INDEX,
                path=m.group(1),
            )
            sort_by_version = False
        else:
            raise LookupError(f'Unexpected lookup format: "{query}"')

        # Ideally the sorting would be done in the database, but that
        # requires the debversion extension and installing that requires a
        # superuser, which is cumbersome since it means we can't do it in a
        # Django migration as currently configured.  We don't expect a
        # single suite to contain many active versions of a single package,
        # so doing the sorting in Python should be fine in practice.
        items = list(qs)
        if sort_by_version:
            items = sorted(
                items, key=lambda item: Version(item.data["version"])
            )
        if not items:
            return None
        return items[-1]
