# 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

from debusine.artifacts.models import (
    ArtifactCategory,
    CollectionCategory,
    DebianBinaryPackage,
    DebianSourcePackage,
    DebianUpload,
)
from debusine.client.models import LookupChildType
from debusine.db.models import (
    Artifact,
    Collection,
    WellKnownWorkflowTemplate,
    WorkRequest,
    WorkflowTemplate,
    Workspace,
)
from debusine.server.collections.lookup import lookup_multiple, lookup_single
from debusine.server.tasks.models import (
    CopyCollectionItemsCopies,
    CopyCollectionItemsData,
)
from debusine.server.workflows import Workflow, workflow_utils
from debusine.server.workflows.models import (
    PackagePublishWorkflowData,
    UpdateSuitesData,
    WorkRequestWorkflowData,
)
from debusine.tasks.models import BaseDynamicTaskData, LookupMultiple
from debusine.tasks.server import TaskDatabaseInterface

logger = logging.getLogger(__name__)


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

    TASK_NAME = "package_publish"

    @property
    def target_suite(self) -> Collection:
        """Look up the target suite."""
        return lookup_single(
            lookup=self.data.target_suite,
            workspace=self.work_request.workspace,
            user=self.work_request.created_by,
            workflow_root=self.work_request.workflow_root,
            expect_type=LookupChildType.COLLECTION,
        ).collection

    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 = lookup_single(
            self.data.source_artifact,
            self.workspace,
            user=self.work_request.created_by,
            workflow_root=self.work_request.workflow_root,
            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.parse_obj(
                    [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.parse_obj(
                    [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],
        target_workspace: Workspace,
    ) -> None:
        """Add copy items for build logs."""
        if self.data.binary_artifacts.__root__:
            try:
                source_build_logs_collection = self.lookup_singleton_collection(
                    CollectionCategory.PACKAGE_BUILD_LOGS
                )
            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.parse_obj(
                            {
                                "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],
        target_workspace: Workspace,
    ) -> None:
        """Add copy items for task history."""
        if self.data.binary_artifacts.__root__:
            try:
                source_task_history_collection = (
                    self.lookup_singleton_collection(
                        CollectionCategory.TASK_HISTORY
                    )
                )
            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.parse_obj(
                            {
                                "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,
                    )
                )

    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
        target_workspace = target_suite.workspace

        copies: list[CopyCollectionItemsCopies] = []
        self._add_source(copies, target_suite)
        for binary_artifact in workflow_utils.locate_debian_binary_packages(
            "binary_artifacts",
            [
                result.artifact
                for result in lookup_multiple(
                    self.data.binary_artifacts,
                    self.workspace,
                    user=self.work_request.created_by,
                    workflow_root=self.work_request.workflow_root,
                    expect_type=LookupChildType.ARTIFACT,
                )
            ],
        ):
            self._add_binary(copies, target_suite, binary_artifact)
        self._add_build_logs(copies, target_workspace)
        self._add_task_history(copies, 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.workspace,
        )
        WorkRequest.objects.create_workflow(
            template=template,
            data=UpdateSuitesData(only_suites=[self.target_suite.name]).dict(
                exclude_unset=True
            ),
        )
        return True

    def build_dynamic_data(
        self,
        task_database: TaskDatabaseInterface,  # noqa: U100
    ) -> BaseDynamicTaskData:
        """
        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
        """
        binaries = lookup_multiple(
            self.data.binary_artifacts,
            workflow_root=self.work_request.workflow_root,
            workspace=self.workspace,
            user=self.work_request.created_by,
            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,
                ),
            )
        )

        return BaseDynamicTaskData(
            subject=source_package_names,
            parameter_summary=f"{source_package_names} → "
            f"{self.data.target_suite}",
        )
