# 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 operator
import re
from collections.abc import Callable, Generator
from contextlib import contextmanager
from functools import partial, reduce
from typing import Any, override

from debian.debian_support import Version
from django.db import IntegrityError
from django.db.models import Q, 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,
    DebianDebugSymbols,
)
from debusine.assets.asset_utils import ExportablePGPKeyring
from debusine.assets.models import AssetCategory, KeyPurpose
from debusine.client.models import RuntimeParameter
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    Asset,
    Collection,
    CollectionItem,
    CollectionItemMatchConstraint,
    CollectionRelation,
    User,
    WellKnownWorkflowTemplate,
    WorkRequest,
    WorkflowTemplate,
)
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}"


def path_as_constraint_key(path: str) -> str:
    """Return a file path unchanged for use as a constraint key."""
    return path


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,
            ArtifactCategory.DEBUG_SYMBOLS,
        }
    )

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

    def required_suites(self) -> list[Collection]:
        """
        Return recursively required suites.

        The returned list excludes this suite and avoids duplicates.
        """
        required: list[Collection] = []
        visited_ids: set[int] = {self.collection.id}

        def add_required_suites(collection: Collection) -> None:
            relations = (
                collection.relations.filter(
                    type=CollectionRelation.Types.SUITE_REQUIRES,
                    target__category=CollectionCategory.SUITE,
                )
                .select_related("target")
                .order_by("position")
            )

            for relation in relations:
                target = relation.target

                if target.id in visited_ids:
                    continue

                visited_ids.add(target.id)

                required.append(target)

                add_required_suites(target)

        add_required_suites(self.collection)

        return required

    def lookup_newest_package_recursive(
        self, query: str
    ) -> CollectionItem | None:
        """
        Return the newest package from this suite or suites it requires.

        :param query: usually a ``source:``, ``source-version:``, ``binary:`` or
           ``binary-version:`` lookup query.
        :raise LookupError: the query is not a package lookup query.
        """
        newest: CollectionItem | None = None

        for suite in [self.collection, *self.required_suites()]:
            manager = suite.manager
            assert isinstance(manager, DebianSuiteManager)

            item = manager.lookup(query)

            if item is not None and (
                newest is None
                or Version(item.data["version"])
                > Version(newest.data["version"])
            ):
                newest = item

        return newest

    @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,
        constraint_name: str,
        path_to_key: Callable[[str], str],
    ) -> Generator[CollectionItemMatchConstraint]:
        """Yield constraints for an item added to this collection."""
        assert item.artifact is not None
        for file_in_artifact in item.artifact.fileinartifact_set.all():
            key = path_to_key(file_in_artifact.path)
            hash_digest = (
                f"{file_in_artifact.file.current_hash_algorithm}:"
                f"{file_in_artifact.file.hash_digest.hex()}"
            )

            # Each key 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=constraint_name,
                key=key,
                value=hash_digest,
            )
            if (archive := self.parent_archive) is not None:
                # 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.
                yield CollectionItemMatchConstraint(
                    collection=archive,
                    collection_item_id=item.id,
                    constraint_name=constraint_name,
                    key=key,
                    value=hash_digest,
                )
            # TODO: manage the set of constraints on suite-level operations,
            # such as deleting a suite or moving a suite to another workspace

    @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"]}"
                    )

    def update_indexes(self, *, user: User) -> None:
        """
        Update indexes for this suite.

        This involves starting a workflow using a well-known template.
        """
        from debusine.server.workflows.models import UpdateSuitesData

        template, _ = WorkflowTemplate.objects.get_or_create(
            name=WellKnownWorkflowTemplate.UPDATE_SUITES,
            task_name="update_suites",
            workspace=self.collection.workspace,
            runtime_parameters=RuntimeParameter.ANY,
        )
        WorkRequest.objects.create_workflow(
            template=template,
            data=UpdateSuitesData(
                only_suites=[self.collection.name]
            ).model_dump(exclude_unset=True),
            created_by=user,
        ).mark_pending()

    def _debug_symbols_item_data_and_name(
        self, artifact: Artifact, build_id: str
    ) -> tuple[dict[str, Any], str]:
        """Return item data and name for one debug-symbols build-ID."""
        debug_data = DebianDebugSymbols.model_validate(artifact.data)
        if build_id not in debug_data.build_ids:
            raise ItemAdditionError(
                f"{artifact}: build-ID {build_id} is not in artifact data"
            )

        try:
            related_binary = Artifact.objects.get(
                category=ArtifactCategory.BINARY_PACKAGE,
                targeted_by__artifact=artifact,
                targeted_by__type=ArtifactRelation.Relations.RELATES_TO,
            )
        except (Artifact.DoesNotExist, Artifact.MultipleObjectsReturned):
            raise ItemAdditionError(
                f"Adding {artifact.category} to {CollectionCategory.SUITE} "
                "requires exactly one related binary package"
            )

        binary_data = BinaryPackage.create_data(related_binary.data)

        data = {
            "build_id": build_id,
            "srcpkg_name": binary_data.srcpkg_name,
            "srcpkg_version": binary_data.srcpkg_version,
            "package": binary_data.deb_fields["Package"],
            "version": binary_data.deb_fields["Version"],
            "architecture": binary_data.deb_fields["Architecture"],
        }
        name = "debugsym:{package}_{version}_{architecture}_{build_id}".format(
            **data
        )
        return data, name

    @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",),
            ArtifactCategory.DEBUG_SYMBOLS: ("build_id",),
        }
        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

        constraint_name: str | None = None
        path_to_key: Callable[[str], str] | None = 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"],
            )
            constraint_name = "pool-file"
            path_to_key = partial(
                make_pool_filename, source_package_name, data["component"]
            )
        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']}",
            )
            constraint_name = "pool-file"
            path_to_key = partial(
                make_pool_filename, source_package_name, data["component"]
            )
        elif artifact.category == ArtifactCategory.DEBUG_SYMBOLS:
            data, name = self._debug_symbols_item_data_and_name(
                artifact, variables["build_id"]
            )
            constraint_name = "build-id"
            path_to_key = path_as_constraint_key
        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 constraint_name is not None:
                assert path_to_key is not None
                CollectionItemMatchConstraint.objects.bulk_create(
                    self.make_constraints(item, constraint_name, path_to_key)
                )
            return item
        except IntegrityError as exc:
            description = str(exc)
            if "db_collectionitemmatchconstraint_match_value" in description:
                description = (
                    f"{self.collection}: Artifact {name} already "
                    f"exists in the archive pool"
                )
            raise ItemAdditionError(description) from exc

    @override
    def do_remove_item(
        self,
        item: CollectionItem,
        *,
        user: User | None = None,
        workflow: WorkRequest | None = None,
        remove_associated: bool = False,
    ) -> None:
        """
        Remove an item from the managed collection.

        Called by remove_item().

        :param remove_associated: if True, also remove items associated with
          ``item``: binary packages and debug symbols built from a source
          package, or debug symbols related to a binary package.
        :raise ItemRemovalError: the item cannot be removed: breaks
          collection's constraints.
        """
        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,
                    ArtifactCategory.DEBUG_SYMBOLS,
                }
            )
            and self.may_reuse_versions
        ):
            # This deletes both suite and archive constraints.
            CollectionItemMatchConstraint.objects.filter(
                collection_item_id=item.id,
                constraint_name__in=("pool-file", "build-id"),
            ).delete()

        if remove_associated and item.artifact is not None:
            conditions: list[Q] = []
            if item.category == ArtifactCategory.BINARY_PACKAGE:
                conditions.append(
                    Q(
                        category=ArtifactCategory.DEBUG_SYMBOLS,
                        data__package=item.data["package"],
                        data__version=item.data["version"],
                        data__architecture=item.data["architecture"],
                        artifact__relations__target=item.artifact,
                        artifact__relations__type=(
                            ArtifactRelation.Relations.RELATES_TO
                        ),
                    )
                )
            elif item.category == ArtifactCategory.SOURCE_PACKAGE:
                conditions.append(
                    Q(
                        category=ArtifactCategory.DEBUG_SYMBOLS,
                        data__srcpkg_name=item.data["package"],
                        data__srcpkg_version=item.data["version"],
                        artifact__relations__type=(
                            ArtifactRelation.Relations.RELATES_TO
                        ),
                        artifact__relations__target__relations__target=(
                            item.artifact
                        ),
                        artifact__relations__target__relations__type=(
                            ArtifactRelation.Relations.BUILT_USING
                        ),
                    )
                )
                conditions.append(
                    Q(
                        category=ArtifactCategory.BINARY_PACKAGE,
                        # BUILT_USING relations from debian:binary-package to
                        # debian-source-package aren't quite specific enough,
                        # so the source package name and version must match.
                        data__srcpkg_name=item.data["package"],
                        data__srcpkg_version=item.data["version"],
                        # Just in case source package versions were reused,
                        # make sure that these binary packages were really
                        # built from this particular source package.
                        artifact__relations__target=item.artifact,
                        artifact__relations__type=(
                            ArtifactRelation.Relations.BUILT_USING
                        ),
                    )
                )

            if conditions:
                for remove_item in (
                    self.collection.child_items.active()
                    .filter(child_type=CollectionItem.Types.ARTIFACT)
                    .filter(reduce(operator.or_, conditions))
                    .distinct()
                ):
                    self.remove_item(remove_item, user=user, workflow=workflow)

    @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]
