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

"""Package publishing workflow."""

import logging
from typing import Any, override

from debusine.artifacts.models import (
    ArtifactCategory,
    CollectionCategory,
    DebianBinaryPackage,
    DebianSourcePackage,
    DebianUpload,
)
from debusine.client.models import LookupChildType, RuntimeParameter
from debusine.db.models import (
    Artifact,
    Collection,
    WellKnownWorkflowTemplate,
    WorkRequest,
    WorkflowTemplate,
    Workspace,
)
from debusine.db.tasks.inputs import CollectionInSameWorkspace
from debusine.server.collections.lookup import artifact_ids
from debusine.server.tasks.models import (
    CopyCollectionItemsCopies,
    CopyCollectionItemsData,
)
from debusine.server.workflows import Workflow, workflow_utils
from debusine.server.workflows.models import (
    PackagePublishWorkflowData,
    PackagePublishWorkflowDynamicData,
    UpdateSuitesData,
    WorkRequestWorkflowData,
)
from debusine.tasks import TaskConfigError
from debusine.tasks.inputs import Stage
from debusine.tasks.models import (
    InputArtifact,
    InputArtifactMultiple,
    InputArtifactSingle,
    LookupMultiple,
)

logger = logging.getLogger(__name__)


class PackagePublishWorkflow(
    Workflow[PackagePublishWorkflowData, PackagePublishWorkflowDynamicData]
):
    """Publish packages to a target suite."""

    TASK_NAME = "package_publish"

    target_suite = CollectionInSameWorkspace(
        category=CollectionCategory.SUITE, stage=Stage.PENDING
    )

    def _split_component_and_section(
        self, component_and_section: str
    ) -> tuple[str, str]:
        """
        Split a ``Section`` field into component and section.

        See
        https://www.debian.org/doc/debian-policy/ch-archive.html#s-subsections
        (which refers to components as "archive areas").
        """
        if "/" in component_and_section:
            component, section = component_and_section.split("/", 1)
            return component, section
        else:
            return "main", component_and_section

    def _add_source(
        self, copies: list[CopyCollectionItemsCopies], target_suite: Collection
    ) -> None:
        """Add a copy item for the source artifact."""
        if self.data.source_artifact is None:
            return

        original_source_artifact = self.work_request.lookup_single(
            self.data.source_artifact, expect_type=LookupChildType.ARTIFACT
        ).artifact
        source_artifact = workflow_utils.locate_debian_source_package(
            "source_artifact", original_source_artifact
        )
        variables: dict[str, Any] = {}

        # Set the component and section.
        # TODO: We should use the following logic:
        #  * use the current overrides in the target suite, if any
        #  * if the source artifact was looked up via a collection, use the
        #    current overrides from there, if any
        #  * if the source artifact is an upload, use the component and
        #    section listed for its `.dsc` in its `.changes` file
        #  * otherwise, default to `main`/`misc`
        if original_source_artifact.category == ArtifactCategory.UPLOAD:
            # Extract reasonable component and section defaults from the
            # metadata for the .dsc file in the .changes file.
            original_source_data = original_source_artifact.create_data()
            assert isinstance(original_source_data, DebianUpload)
            [dsc_section] = [
                file["section"]
                for file in original_source_data.changes_fields["Files"]
                if file["name"].endswith(".dsc")
            ]
            variables["component"], variables["section"] = (
                self._split_component_and_section(dsc_section)
            )
        else:
            # If we don't have a .changes file, then we can't easily get at
            # the source package's component or section defaults (at least
            # not without unpacking the source package), so the best we can
            # do is to pick some generic defaults.
            source_data = source_artifact.create_data()
            assert isinstance(source_data, DebianSourcePackage)
            # TODO: These log messages aren't visible to users; see
            # https://salsa.debian.org/freexian-team/debusine/-/issues/901.
            if "component" not in self.data.suite_variables:
                logger.warning(
                    "No component specified when adding %s to %s; "
                    "defaulting to 'main'",
                    source_data.get_label(),
                    self.data.target_suite,
                )
                variables["component"] = "main"
            if "section" not in self.data.suite_variables:
                logger.warning(
                    "No section specified when adding %s to %s; "
                    "defaulting to 'misc'",
                    source_data.get_label(),
                    self.data.target_suite,
                )
                variables["section"] = "misc"

        copies.append(
            CopyCollectionItemsCopies(
                source_items=LookupMultiple(
                    (f"{source_artifact.id}@artifacts",)
                ),
                # Identify the target collection by ID to avoid strange
                # behaviour if the lookup result changes between now and
                # when the child task executes, e.g. due to creating a
                # collection earlier in the inheritance chain.  Implementing
                # https://salsa.debian.org/freexian-team/debusine/-/issues/495
                # may allow us to do better here.
                target_collection=target_suite.id,
                unembargo=self.data.unembargo,
                replace=self.data.replace,
                variables=variables | self.data.suite_variables,
            )
        )

    def _add_binary(
        self,
        copies: list[CopyCollectionItemsCopies],
        target_suite: Collection,
        binary_artifact: Artifact,
    ) -> None:
        """Add copy items for binary artifacts."""
        binary_data = binary_artifact.create_data()
        assert isinstance(binary_data, DebianBinaryPackage)
        variables: dict[str, Any] = {}

        # TODO: We should use the following logic:
        #  * use the current overrides in the target suite, if any
        #  * if a binary artifact was looked up via a collection, use the
        #    current overrides from there, if any
        #  * if a binary artifact has `Section`/`Priority` fields in its
        #    `.deb`, use those
        #  * otherwise, default to `main`/`misc`/`optional`
        if "Section" in binary_data.deb_fields:
            variables["component"], variables["section"] = (
                self._split_component_and_section(
                    binary_data.deb_fields["Section"]
                )
            )
        else:
            # TODO: These log messages aren't visible to users; see
            # https://salsa.debian.org/freexian-team/debusine/-/issues/901.
            if "component" not in self.data.suite_variables:
                logger.warning(
                    "No component specified when adding %s to %s; "
                    "defaulting to 'main'",
                    binary_data.get_label(),
                    self.data.target_suite,
                )
                variables["component"] = "main"
            if "section" not in self.data.suite_variables:
                logger.warning(
                    "No section specified when adding %s to %s; "
                    "defaulting to 'misc'",
                    binary_data.get_label(),
                    self.data.target_suite,
                )
                variables["section"] = "misc"

        if "Priority" in binary_data.deb_fields:
            variables["priority"] = binary_data.deb_fields["Priority"]
        elif "priority" not in self.data.suite_variables:
            # TODO: This log message isn't visible to users; see
            # https://salsa.debian.org/freexian-team/debusine/-/issues/901.
            logger.warning(
                "No priority specified when adding %s to %s; "
                "defaulting to 'optional'",
                binary_data.get_label(),
                self.data.target_suite,
            )
            variables["priority"] = "optional"

        copies.append(
            CopyCollectionItemsCopies(
                source_items=LookupMultiple(
                    (f"{binary_artifact.id}@artifacts",)
                ),
                # Identify the target collection by ID to avoid strange
                # behaviour if the lookup result changes between now and
                # when the child task executes, e.g. due to creating a
                # collection earlier in the inheritance chain.  Implementing
                # https://salsa.debian.org/freexian-team/debusine/-/issues/495
                # may allow us to do better here.
                target_collection=target_suite.id,
                unembargo=self.data.unembargo,
                replace=self.data.replace,
                variables=variables | self.data.suite_variables,
            )
        )

    def _add_build_logs(
        self,
        copies: list[CopyCollectionItemsCopies],
        *,
        source_workspace: Workspace,
        target_workspace: Workspace,
    ) -> None:
        """Add copy items for build logs."""
        try:
            source_build_logs_collection = self.lookup_singleton_collection(
                CollectionCategory.PACKAGE_BUILD_LOGS,
                workspace=source_workspace,
            )
        except KeyError:
            source_build_logs_collection = None
        try:
            target_build_logs_collection = self.lookup_singleton_collection(
                CollectionCategory.PACKAGE_BUILD_LOGS,
                workspace=target_workspace,
            )
        except KeyError:
            target_build_logs_collection = None
        if (
            source_build_logs_collection is not None
            and target_build_logs_collection is not None
            and source_build_logs_collection != target_build_logs_collection
        ):
            copies.append(
                CopyCollectionItemsCopies(
                    source_items=LookupMultiple.model_validate(
                        {
                            "collection": source_build_logs_collection.id,
                            "lookup__same_work_request": (
                                self.data.binary_artifacts
                            ),
                        }
                    ),
                    target_collection=target_build_logs_collection.id,
                    unembargo=self.data.unembargo,
                    replace=self.data.replace,
                )
            )

    def _add_task_history(
        self,
        copies: list[CopyCollectionItemsCopies],
        *,
        source_workspace: Workspace,
        target_workspace: Workspace,
    ) -> None:
        """Add copy items for task history."""
        try:
            source_task_history_collection = self.lookup_singleton_collection(
                CollectionCategory.TASK_HISTORY, workspace=source_workspace
            )
        except KeyError:
            source_task_history_collection = None
        try:
            target_task_history_collection = self.lookup_singleton_collection(
                CollectionCategory.TASK_HISTORY, workspace=target_workspace
            )
        except KeyError:
            target_task_history_collection = None
        if (
            source_task_history_collection is not None
            and target_task_history_collection is not None
            and source_task_history_collection != target_task_history_collection
        ):
            copies.append(
                CopyCollectionItemsCopies(
                    source_items=LookupMultiple.model_validate(
                        {
                            "collection": source_task_history_collection.id,
                            "lookup__same_workflow": self.data.binary_artifacts,
                        }
                    ),
                    target_collection=target_task_history_collection.id,
                    unembargo=self.data.unembargo,
                    replace=self.data.replace,
                )
            )

    @override
    def populate(self) -> None:
        """Create work requests."""
        assert self.dynamic_data is not None
        subject = self.dynamic_data.subject
        assert subject is not None

        target_suite = self.target_suite.collection
        target_workspace = target_suite.workspace

        copies: list[CopyCollectionItemsCopies] = []
        self._add_source(copies, target_suite)
        binary_artifacts_workspace: Workspace | None = None
        for binary_artifact in workflow_utils.locate_debian_binary_packages(
            "binary_artifacts",
            [
                result.artifact
                for result in self.work_request.lookup_multiple(
                    self.data.binary_artifacts,
                    expect_type=LookupChildType.ARTIFACT,
                )
            ],
        ):
            self._add_binary(copies, target_suite, binary_artifact)
            if binary_artifacts_workspace is None:
                binary_artifacts_workspace = binary_artifact.workspace
            elif binary_artifact.workspace != binary_artifacts_workspace:
                # TODO: This should be handled during the configuration
                # stage, but we first need to have input fields that can
                # cope with promises.
                raise TaskConfigError(
                    f"Binary artifacts must all be in the same workspace"
                    f" ({binary_artifact.workspace} !="
                    f" {binary_artifacts_workspace})"
                )
        if binary_artifacts_workspace is not None:
            self._add_build_logs(
                copies,
                source_workspace=binary_artifacts_workspace,
                target_workspace=target_workspace,
            )
            self._add_task_history(
                copies,
                source_workspace=binary_artifacts_workspace,
                target_workspace=target_workspace,
            )

        copy_collection_items, _ = self.work_request_ensure_child_server(
            task_name="copycollectionitems",
            task_data=CopyCollectionItemsData(copies=copies),
            # TODO: Include subject in workflow data.
            workflow_data=WorkRequestWorkflowData(
                display_name="Copy packages into target suite",
                step="copy-collection-items",
            ),
        )
        self.requires_artifact(copy_collection_items, copies[0].source_items)

        if self.data.update_indexes:
            update_suites_callback, _ = self.work_request_ensure_child_internal(
                task_name="workflow",
                # TODO: Include subject in workflow data.
                workflow_data=WorkRequestWorkflowData(
                    display_name="Start workflow to update repository indexes",
                    step="trigger-update-suites",
                ),
            )
            update_suites_callback.add_dependency(copy_collection_items)
            self.orchestrate_child(update_suites_callback)

    def callback_trigger_update_suites(self) -> bool:
        """
        Update suites once the items have been copied.

        We have to run this in the target workspace, which might not be the
        same as this workflow's workspace.  That involves triggering a
        separate workflow using a well-known template.
        """
        # TODO: At the moment, this only works for users who have the OWNER
        # role on the target workspace.
        # https://salsa.debian.org/freexian-team/debusine/-/issues/634 is
        # needed to fix this.
        template, _ = WorkflowTemplate.objects.get_or_create(
            name=WellKnownWorkflowTemplate.UPDATE_SUITES,
            task_name="update_suites",
            workspace=self.target_suite.collection.workspace,
            runtime_parameters=RuntimeParameter.ANY,
        )
        WorkRequest.objects.create_workflow(
            template=template,
            data=UpdateSuitesData(
                only_suites=[self.target_suite.collection.name]
            ).model_dump(exclude_unset=True),
        ).mark_pending()
        return True

    @override
    def compute_dynamic_data(self) -> PackagePublishWorkflowDynamicData:
        """
        Compute dynamic data for this workflow.

        :subject: source package names of ``binary_artifacts`` separated
          by spaces
        :parameter_summary: source package names of ``binary_artifacts``
          separated by spaces → suite
        :source_artifact_id: id of the source artifact
        :binary_artifacts_ids: ids of the binary artifacts
        :target_suite_id: collection id of the target_suite
        """
        binaries = self.work_request.lookup_multiple(
            self.data.binary_artifacts,
            expect_type=LookupChildType.ARTIFACT_OR_PROMISE,
        )

        source_package_names = " ".join(
            workflow_utils.get_source_package_names(
                binaries,
                configuration_key="binary_artifacts",
                artifact_expected_categories=(
                    ArtifactCategory.BINARY_PACKAGE,
                    ArtifactCategory.UPLOAD,
                ),
            )
        )

        try:
            source_artifact_id = workflow_utils.source_package(self).id
        except LookupError:
            source_artifact_id = None

        dynamic_data = PackagePublishWorkflowDynamicData(
            subject=source_package_names,
            parameter_summary=f"{source_package_names} → "
            f"{self.data.target_suite}",
            source_artifact_id=source_artifact_id,
            binary_artifacts_ids=artifact_ids(binaries),
            target_suite_id=self.target_suite.collection_id,
        )
        self.save_field_values(dynamic_data)
        return dynamic_data

    @override
    def get_input_artifacts(self) -> list[InputArtifact]:
        """Return input artifacts."""
        artifacts: list[InputArtifact] = []
        if self.data.source_artifact is not None:
            artifacts.append(
                InputArtifactSingle(
                    lookup=self.data.source_artifact,
                    label="source_artifact",
                    artifact_id=(
                        self.dynamic_data.source_artifact_id
                        if self.dynamic_data is not None
                        else None
                    ),
                )
            )

        artifacts.append(
            InputArtifactMultiple.from_ids(
                lookup=self.data.binary_artifacts,
                label="binary_artifacts",
                artifact_ids=(
                    self.dynamic_data.binary_artifacts_ids
                    if self.dynamic_data is not None
                    else None
                ),
            )
        )

        return artifacts
