# Copyright 2024 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.
"""Sbuild Workflow."""

from functools import cached_property

from debian.debian_support import DpkgArchTable

from debusine.artifacts import SourcePackage
from debusine.artifacts.models import (
    ArtifactCategory,
    BareDataCategory,
    CollectionCategory,
)
from debusine.db.models import (
    Artifact,
    CollectionItem,
    TaskDatabase,
    WorkRequest,
)
from debusine.server.collections.lookup import lookup_single
from debusine.server.workflows.base import Workflow, WorkflowValidationError
from debusine.server.workflows.models import (
    SbuildWorkflowData,
    SbuildWorkflowDynamicData,
    WorkRequestWorkflowData,
)
from debusine.tasks import get_environment
from debusine.tasks.models import (
    ActionRetryWithDelays,
    ActionUpdateCollectionWithArtifacts,
    ActionUpdateCollectionWithData,
    BackendType,
    SbuildBuildComponent,
    SbuildData,
)
from debusine.tasks.server import TaskDatabaseInterface


class SbuildWorkflow(Workflow[SbuildWorkflowData, SbuildWorkflowDynamicData]):
    """Build a source package for all architectures of a target distribution."""

    TASK_NAME = "sbuild"

    def __init__(self, work_request: "WorkRequest"):
        """Instantiate a Workflow with its database instance."""
        super().__init__(work_request)
        if self.data.backend == BackendType.AUTO:
            self.data.backend = BackendType.UNSHARE

    def compute_dynamic_data(
        self, task_database: TaskDatabaseInterface
    ) -> SbuildWorkflowDynamicData:
        """Resolve lookups for this workflow."""
        return SbuildWorkflowDynamicData(
            input_source_artifact_id=task_database.lookup_single_artifact(
                self.data.input.source_artifact
            ),
            build_logs_collection_id=(
                None
                if self.data.build_logs_collection is None
                else task_database.lookup_single_collection(
                    self.data.build_logs_collection,
                    default_category=CollectionCategory.PACKAGE_BUILD_LOGS,
                )
            ),
        )

    @cached_property
    def source_package(self) -> Artifact:
        """Source package artifact."""
        assert self.workspace is not None
        assert self.work_request is not None
        artifact = lookup_single(
            self.data.input.source_artifact,
            self.workspace,
            user=self.work_request.created_by,
            expect_type=CollectionItem.Types.ARTIFACT,
        ).artifact
        if artifact.category != ArtifactCategory.SOURCE_PACKAGE:
            raise ValueError(
                f"source artifact is type {artifact.category!r}"
                f" instead of {ArtifactCategory.SOURCE_PACKAGE}"
            )
        return artifact

    @cached_property
    def architectures(self) -> set[str]:
        """Architectures to build."""
        workflow_arches = set(self.data.architectures)

        artifact_data = SourcePackage.create_data(self.source_package.data)
        package_arches = set(
            artifact_data.dsc_fields.get("Architecture", "").split()
        )
        if not package_arches:
            raise WorkflowValidationError("package architecture list is empty")

        # Intersect architectures with dsc_fields["Architecture"]
        architectures_to_build = set()
        # Support architecture wildcards (any, <os>-any, any-<cpu>)
        # https://www.debian.org/doc/debian-policy/ch-customized-programs.html#architecture-wildcards
        arch_table = DpkgArchTable.load_arch_table()

        for package_arch in package_arches:
            for workflow_arch in workflow_arches:
                if workflow_arch == "all":
                    # all matches any according to DpkgArchTable, but that's
                    # not useful here.
                    if package_arch == "all":
                        architectures_to_build.add("all")
                elif arch_table.matches_architecture(
                    workflow_arch, package_arch
                ):
                    architectures_to_build.add(workflow_arch)

        if not architectures_to_build:
            raise WorkflowValidationError(
                "None of the workflow architectures are supported"
                " by this package:"
                f" workflow: {', '.join(sorted(workflow_arches))}"
                f" package: {', '.join(sorted(package_arches))}"
            )

        return architectures_to_build

    def _get_host_architecture(self, architecture: str) -> str:
        """
        Get the host architecture to build a given architecture.

        `architecture` may be "all".
        """
        # TODO (#358): un-hardcode "amd64" default,
        # possibly support XS-Build-Indep-Architecture
        return "amd64" if architecture == "all" else architecture

    def _get_environment_lookup(self) -> str:
        """
        Build the environment lookup.

        :py:func:`get_environment` will fill in additional constraints.
        """
        vendor, codename = self.data.target_distribution.split(":", 1)

        lookup = f"{vendor}/match:codename={codename}"
        if self.data.environment_variant:
            lookup += f":variant={self.data.environment_variant}"

        return lookup

    def _get_environment(self, architecture: str) -> Artifact:
        """
        Lookup an environment to build on the given architecture.

        This is only used for validation that we have all the environments
        we need; the actual tasks will do their own lookups.
        """
        lookup = self._get_environment_lookup()
        host_architecture = self._get_host_architecture(architecture)

        # set in BaseServerTask.__init__() along with work_request
        assert self.workspace is not None
        assert self.work_request is not None
        try:
            env_id = get_environment(
                TaskDatabase(self.work_request),
                lookup,
                architecture=host_architecture,
                # TODO: This duplicates logic from
                # BaseTaskWithExecutor.backend.
                backend=(
                    "unshare"
                    if self.data.backend == "auto"
                    else self.data.backend
                ),
                default_category=CollectionCategory.ENVIRONMENTS,
            )
        except KeyError:
            raise WorkflowValidationError(
                "environment not found for"
                f" {self.data.target_distribution!r} {architecture!r}"
                f" ({lookup!r})"
            )
        return Artifact.objects.get(id=env_id)

    def validate_input(self) -> None:
        """Thorough validation of input data."""
        # Validate target_distribution
        if ":" not in self.data.target_distribution:
            raise WorkflowValidationError(
                "target_distribution must be in vendor:codename format"
            )

        # Artifact and architectures are validated by accessing
        # self.architectures
        self.architectures

        vendor, codename = self.data.target_distribution.split(":", 1)

        match self.data.backend:
            case BackendType.SCHROOT:
                # TODO: validate that this is using valid architecture names
                # (if #310 is accepted).

                # 'distribution' is validated later through the
                # schroot list on the Worker
                pass
            case _:
                # Make sure we have environments for all architectures we need
                for arch in self.architectures:
                    # Attempt to get the environment to validate its lookup
                    self._get_environment(arch)

    def populate(self) -> None:
        """Create sbuild WorkRequests for all architectures."""
        assert self.work_request is not None
        children = self.work_request.children.all()
        existing_arches: set[str] = set()
        for child in children:
            if child.task_data["build_components"] == [
                SbuildBuildComponent.ALL
            ]:
                existing_arches.add("all")
            else:
                existing_arches.add(child.task_data["host_architecture"])

        # Idempotence
        # It is unlikely that there are workrequests already created that we do
        # not need anymore, and I cannot think of a scenario when that may
        # happen. Still, I'm leaving an assertion to catch it, so that if it
        # happens it can be detected and studied to figure out what corner case
        # has been hit and how to handle it
        assert not existing_arches - self.architectures

        if arches := self.architectures - existing_arches:
            match self.data.backend:
                case BackendType.SCHROOT:
                    self._populate_schroot(arches)
                case _:
                    self._populate_any(arches)

    def _populate_schroot(self, architectures: set[str]) -> None:
        """Create WorkRequests using the schroot backend."""
        vendor, codename = self.data.target_distribution.split(":", 1)
        for architecture in sorted(architectures):
            self._populate_single(architecture, distribution=codename)

    def _populate_any(self, architectures: set[str]) -> None:
        """Create WorkRequests using non-schroot backends."""
        for architecture in sorted(architectures):
            self._populate_single(
                architecture, environment=self._get_environment_lookup()
            )

    def _populate_single(
        self,
        architecture: str,
        *,
        environment: str | None = None,
        distribution: str | None = None,
    ):
        """Create a single sbuild WorkRequest."""
        assert self.work_request is not None
        assert self.dynamic_data is not None
        host_architecture = self._get_host_architecture(architecture)
        task_data = SbuildData(
            input=self.data.input,
            host_architecture=host_architecture,
            environment=environment,
            backend=self.data.backend,
            distribution=distribution,
            build_components=[
                (
                    SbuildBuildComponent.ALL
                    if architecture == "all"
                    else SbuildBuildComponent.ANY
                )
            ],
            build_profiles=self.data.build_profiles,
            binnmu=self.data.binnmu,
        )
        wr = self.work_request.create_child(
            task_name="sbuild",
            task_data=task_data.dict(),
            workflow_data=WorkRequestWorkflowData(
                display_name=f"Build {architecture}",
                step=f"build-{architecture}",
            ),
        )

        self.provides_artifact(
            wr,
            ArtifactCategory.UPLOAD,
            f"build-{architecture}",
            data={"architecture": architecture},
        )

        if self.dynamic_data.build_logs_collection_id is not None:
            vendor, codename = self.data.target_distribution.split(":", 1)
            source_package_data = SourcePackage.create_data(
                Artifact.objects.get(
                    id=self.dynamic_data.input_source_artifact_id
                ).data
            )
            variables = {
                "work_request_id": wr.id,
                "vendor": vendor,
                "codename": codename,
                "architecture": host_architecture,
                "srcpkg_name": source_package_data.name,
                "srcpkg_version": source_package_data.version,
            }
            event_reactions = wr.event_reactions
            event_reactions.on_creation.append(
                ActionUpdateCollectionWithData(
                    collection=self.dynamic_data.build_logs_collection_id,
                    category=BareDataCategory.PACKAGE_BUILD_LOG,
                    data=variables,
                )
            )
            event_reactions.on_success.append(
                ActionUpdateCollectionWithArtifacts(
                    collection=self.dynamic_data.build_logs_collection_id,
                    variables=variables,
                    artifact_filters={
                        "category": ArtifactCategory.PACKAGE_BUILD_LOG
                    },
                )
            )
            wr.event_reactions = event_reactions
            wr.save()

        if self.data.retry_delays is not None:
            event_reactions = wr.event_reactions
            event_reactions.on_failure.append(
                ActionRetryWithDelays(delays=self.data.retry_delays)
            )
            wr.event_reactions = event_reactions
            wr.save()

    def get_label(self) -> str:
        """Return the task label."""
        # TODO: copy the source package information in dynamic task data and
        # use them here if available
        return "build a package"
