# 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 typing import assert_never

from debian.debian_support import DpkgArchTable

from debusine.artifacts import SourcePackage
from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.db.models import Artifact, CollectionItem, WorkRequest
from debusine.server.collections.lookup import lookup_single
from debusine.server.workflows.base import Workflow, WorkflowValidationError
from debusine.server.workflows.models import (
    SbuildWorkflowData,
    WorkRequestWorkflowData,
)
from debusine.tasks.models import BackendType, SbuildBuildComponent, SbuildData


class SbuildWorkflow(Workflow[SbuildWorkflowData]):
    """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

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

    @cached_property
    def environment_type(self) -> str:
        """Return image or tarball based on backend."""
        assert self.data.backend != BackendType.SCHROOT
        assert self.data.backend != BackendType.AUTO

        match self.data.backend:
            case BackendType.QEMU | BackendType.INCUS_VM:
                return "image"
            case BackendType.UNSHARE | BackendType.INCUS_LXC:
                return "tarball"
            case _ as unreachable:
                assert_never(unreachable)

    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, architecture: str) -> str:
        """Build the environment lookup for building on architecture."""
        vendor, codename = self.data.target_distribution.split(":", 1)
        envtype = self.environment_type

        lookup = (
            f"{vendor}/match:format={envtype}"
            f":codename={codename}:architecture={architecture}"
            f":backend={self.data.backend}"
        )
        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."""
        lookup = self._get_environment_lookup(architecture)
        envtype = self.environment_type

        # set in BaseInternalTask.__init__() along with work_request
        assert self.workspace is not None
        assert self.work_request is not None
        try:
            env = lookup_single(
                lookup,
                self.workspace,
                user=self.work_request.created_by,
                default_category=CollectionCategory.ENVIRONMENTS,
                expect_type=CollectionItem.Types.ARTIFACT,
            ).artifact
        except KeyError:
            raise WorkflowValidationError(
                "environment not found for"
                f" {self.data.target_distribution!r} {architecture!r}"
                f" ({lookup!r})"
            )
        # DebianEnvironmentsManager name artifacts based on their category,
        # not mismatch possible when looking for 'envtype'
        assert env.category == f"debian:system-{envtype}"

        return env

    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(self._get_host_architecture(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."""
        assert self.work_request is not None

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

        for architecture in sorted(architectures):
            host_architecture = self._get_host_architecture(architecture)
            task_data = SbuildData(
                input=self.data.input,
                host_architecture=host_architecture,
                backend=backend,
                distribution=codename,
                build_components=[
                    (
                        SbuildBuildComponent.ALL
                        if architecture == "all"
                        else SbuildBuildComponent.ANY
                    )
                ],
            )
            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}",
                ),
            )

    def _populate_any(self, architectures: set[str]) -> None:
        """Create WorkRequests using non-schroot backends."""
        assert self.work_request is not None
        for architecture in sorted(architectures):
            host_architecture = self._get_host_architecture(architecture)
            env = self._get_environment_lookup(host_architecture)

            task_data = SbuildData(
                input=self.data.input,
                host_architecture=host_architecture,
                environment=env,
                backend=self.data.backend,
                build_components=[
                    (
                        SbuildBuildComponent.ALL
                        if architecture == "all"
                        else SbuildBuildComponent.ANY
                    )
                ],
            )
            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}",
                ),
            )
