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

"""Workflow to coordinate metadata updates for all archives in a workspace."""

import logging
from typing import override

from django.db.models import Exists, F, Max, Q, QuerySet
from django.db.models.sql.constants import LOUTER
from django_cte import CTE, with_cte

from debusine.artifacts.models import (
    ArtifactCategory,
    CollectionCategory,
    TaskTypes,
)
from debusine.assets import KeyPurpose
from debusine.assets.models import AssetCategory, SigningKeyData
from debusine.db.models import (
    AssetUsage,
    Collection,
    CollectionItem,
    WorkRequest,
    Workspace,
)
from debusine.db.permissioncontext import PermissionContext
from debusine.server.workflows import Workflow, WorkflowValidationError
from debusine.server.workflows.models import (
    UpdateSuiteData,
    UpdateSuitesData,
    WorkRequestWorkflowData,
)
from debusine.signing.tasks.models import GenerateKeyData

logger = logging.getLogger(__name__)


class UpdateSuitesWorkflow(Workflow[UpdateSuitesData]):
    """Coordinate metadata updates for all suites in the workspace."""

    TASK_NAME = "update_suites"

    @override
    def validate_input(self) -> None:
        """Thorough validation of input data."""
        # Only a workspace owner may run this workflow, since that implies
        # the SIGNER role on newly-created signing keys.
        if not self.workspace.has_role(
            PermissionContext.for_work_request(work_request=self.work_request),
            Workspace.Roles.OWNER,
        ):
            raise WorkflowValidationError(
                'Only a workspace owner may run the "update_suites" workflow'
            )

    def get_archive(self) -> Collection:
        """
        Return the workspace's archive.

        This deliberately doesn't follow the workspace's inheritance chain.
        """
        return self.workspace.collections.get(
            category=CollectionCategory.ARCHIVE, name="_"
        )

    def _find_stale_suites(self) -> QuerySet[Collection]:
        """
        Return all stale suites in this workspace.

        For this purpose, a suite is "stale" if any source or binary
        packages in it have been changed (collection items created or
        removed) since its most recent ``Release`` file was generated.
        """
        suites: QuerySet[Collection]
        if self.data.force_basic_indexes:
            suites = Collection.objects.all()
        else:
            # Use a couple of CTEs to ensure that PostgreSQL uses a
            # reasonable index to find index:Release items, and that it only
            # does the work of figuring out the latest Release creation time
            # once.

            # Build a virtual table of previously-generated Release indexes.
            release_item = CTE(
                CollectionItem.objects.filter(
                    parent_category=CollectionCategory.SUITE,
                    child_type=CollectionItem.Types.ARTIFACT,
                    category=ArtifactCategory.REPOSITORY_INDEX,
                    name="index:Release",
                ),
                name="release_item",
            )
            # Annotate collections with the most recent Release index
            # creation time in each one.
            collection_generated = CTE(
                release_item.join(
                    Collection,
                    id=release_item.col.parent_collection_id,
                    _join_type=LOUTER,
                )
                .values("id")
                .annotate(latest_created=Max(release_item.col.created_at)),
                name="collection_generated",
            )
            # Find suite names where collection items have been created or
            # removed more recently than their most recent Release index.
            suites = with_cte(
                release_item,
                collection_generated,
                select=(
                    collection_generated.join(
                        Collection, id=collection_generated.col.id
                    )
                    .annotate(
                        changed=Exists(
                            CollectionItem.objects.filter(
                                parent_collection=collection_generated.col.id
                            )
                            .annotate(
                                latest_created=(
                                    collection_generated.col.latest_created
                                )
                            )
                            .filter(
                                Q(latest_created__isnull=True)
                                | Q(created_at__gt=F("latest_created"))
                                | Q(removed_at__gt=F("latest_created"))
                            )
                        ),
                    )
                    .filter(changed=True)
                ),
            )

        if self.data.only_suites is not None:
            suites = suites.filter(name__in=self.data.only_suites)

        return suites.filter(
            workspace=self.workspace,
            id__in=Collection.objects.exported_suites(),
        ).order_by("name")

    def _populate_generate_key(self) -> WorkRequest:
        generate_key, _ = self.work_request_ensure_child_signing(
            task_name="generatekey",
            task_data=GenerateKeyData(
                purpose=KeyPurpose.OPENPGP_REPOSITORY,
                description=f"Archive signing key for {self.workspace}",
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name=f"Generate signing key for {self.workspace}",
                step="generate-signing-key",
            ),
        )
        generated_key, _ = self.work_request_ensure_child_internal(
            task_name="workflow",
            workflow_data=WorkRequestWorkflowData(
                display_name="Record generated key", step="generated-key"
            ),
        )
        generated_key.add_dependency(generate_key)
        self.orchestrate_child(generated_key)
        return generated_key

    def _populate_main(self, generated_key: WorkRequest | None) -> None:
        for suite in self._find_stale_suites():
            update_suite, _ = self.work_request_ensure_child_workflow(
                task_name="update_suite",
                task_data=UpdateSuiteData(suite_collection=suite.id),
                workflow_data=WorkRequestWorkflowData(
                    display_name=f"Update {suite.name}",
                    step=f"update-{suite.name}",
                ),
            )
            if generated_key is not None:
                update_suite.add_dependency(generated_key)

        for update_suite in self.work_request.children.filter(
            task_type=TaskTypes.WORKFLOW,
            task_name="update_suite",
            status__in=(
                WorkRequest.Statuses.BLOCKED,
                WorkRequest.Statuses.PENDING,
            ),
        ):
            self.orchestrate_child(update_suite)

    @override
    def populate(self) -> None:
        """Create child work requests for all stale suites."""
        try:
            archive = self.get_archive()
        except Collection.DoesNotExist:
            logger.info(
                "Workspace %s has no archive; not updating suites.",
                self.workspace,
            )
            return

        generated_key: WorkRequest | None = None
        if "signing_keys" not in archive.data:
            generated_key = self._populate_generate_key()
        self._populate_main(generated_key)

    def callback_generated_key(self) -> bool:
        """Configure the generated key."""
        # Configure the archive to use the new key.
        archive = self.get_archive()
        assert "signing_keys" not in archive.data
        signing_key = self.work_request.children.get(
            task_type=TaskTypes.SIGNING, task_name="generatekey"
        ).asset_set.get(category=AssetCategory.SIGNING_KEY)
        AssetUsage.objects.get_or_create(
            asset=signing_key, workspace=self.workspace
        )
        signing_key_data = signing_key.data_model
        assert isinstance(signing_key_data, SigningKeyData)
        archive.data["signing_keys"] = [signing_key_data.fingerprint]
        archive.save()
        return True
