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

"""Autopkgtest workflow."""

from functools import cached_property

from debusine.artifacts.models import (
    ArtifactCategory,
    DebianBinaryPackages,
    DebianUpload,
)
from debusine.db.models import WorkRequest
from debusine.server.collections.lookup import (
    LookupResult,
    lookup_multiple,
    reconstruct_lookup,
)
from debusine.server.workflows.base import Workflow, WorkflowValidationError
from debusine.server.workflows.models import (
    AutopkgtestWorkflowData,
    AutopkgtestWorkflowDynamicData,
    WorkRequestWorkflowData,
)
from debusine.tasks.models import (
    AutopkgtestData,
    AutopkgtestInput,
    BackendType,
    LookupMultiple,
    LookupSingle,
)
from debusine.tasks.server import TaskDatabaseInterface


class AutopkgtestWorkflow(
    Workflow[AutopkgtestWorkflowData, AutopkgtestWorkflowDynamicData]
):
    """Run autopkgtests for a single source on a set of architectures."""

    TASK_NAME = "autopkgtest"

    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
    ) -> AutopkgtestWorkflowDynamicData:
        """Resolve lookups for this workflow."""
        # We need more information about lookups than just the artifact IDs,
        # so we don't currently use the results of this, but it may be
        # useful anyway for showing links to specific artifacts in the web
        # UI.
        return AutopkgtestWorkflowDynamicData(
            source_artifact_id=task_database.lookup_single_artifact(
                self.data.source_artifact
            ),
            binary_artifacts_ids=task_database.lookup_multiple_artifacts(
                self.data.binary_artifacts
            ),
            context_artifacts_ids=task_database.lookup_multiple_artifacts(
                self.data.context_artifacts
            ),
        )

    def lookup_result_architecture(self, result: LookupResult) -> str:
        """Get architecture from result of looking up a binary artifact."""
        # This workflow is normally used with lookups in the internal
        # collection that may result in promises or real artifacts, and in
        # those cases there should be an "architecture" per-item data field.
        # However, it may be convenient to use it with other kinds of
        # lookups, so do our best to figure out what the user meant in those
        # cases.
        if result.collection_item is not None:
            architecture = result.collection_item.data.get("architecture")
            if isinstance(architecture, str):
                return architecture
        elif result.artifact is not None:
            artifact_data = result.artifact.create_data()
            match artifact_data:
                case DebianBinaryPackages():
                    return artifact_data.architecture
                case DebianUpload():
                    architecture = artifact_data.changes_fields.get(
                        "Architecture"
                    )
                    assert isinstance(architecture, str)
                    return architecture
        raise WorkflowValidationError(
            f"Cannot determine architecture for lookup result: {result}"
        )

    @cached_property
    def architectures(self) -> set[str]:
        """Concrete architectures to run tests on."""
        assert self.work_request is not None
        assert self.workspace is not None

        workflow_root = self.work_request.get_workflow_root()
        binary_artifacts_results = lookup_multiple(
            lookup=self.data.binary_artifacts,
            workspace=self.workspace,
            user=self.work_request.created_by,
            workflow_root=workflow_root,
        )

        architectures = {
            self.lookup_result_architecture(result)
            for result in binary_artifacts_results
        }

        # If the input only contains architecture-independent binary
        # packages, then run tests on amd64 (hardcoded for now).  If it
        # contains a mix of architecture-independent and
        # architecture-dependent binary packages, then running tests on any
        # of the concrete architectures will do.
        if architectures == {"all"}:
            architectures = {"amd64"}
        architectures.discard("all")

        if self.data.architectures:
            architectures &= set(self.data.architectures)

        return architectures

    def validate_input(self) -> None:
        """Thorough validation of input data."""
        # binary_artifacts is validated by accessing self.architectures.
        self.architectures

    def _filter_artifact_lookup(
        self, lookup: LookupMultiple, architecture: str
    ) -> LookupMultiple:
        """
        Filter an artifact lookup by architecture.

        Results that do not have an `architecture` field in their per-item
        data are assumed to be relevant to all architectures.
        """
        results = lookup_multiple(
            lookup,
            self.workspace,
            user=self.work_request.created_by,
            workflow_root=self.work_request.get_workflow_root(),
        )
        relevant: list[LookupSingle] = []
        for result in results:
            item = result.collection_item
            if item is None or item.data.get("architecture", architecture) in {
                architecture,
                "all",
            }:
                relevant.append(reconstruct_lookup(result))
        return LookupMultiple.parse_obj(sorted(relevant))

    def _populate_single(self, architecture: str) -> None:
        """Create an autopkgtest work request for a single architecture."""
        assert self.work_request is not None

        filtered_binary_artifacts = self._filter_artifact_lookup(
            self.data.binary_artifacts, architecture
        )
        filtered_context_artifacts = self._filter_artifact_lookup(
            self.data.context_artifacts, architecture
        )
        task_data = AutopkgtestData(
            input=AutopkgtestInput(
                source_artifact=self.data.source_artifact,
                binary_artifacts=filtered_binary_artifacts,
                context_artifacts=filtered_context_artifacts,
            ),
            host_architecture=architecture,
            environment=(
                f"{self.data.vendor}/match:codename={self.data.codename}"
            ),
            backend=self.data.backend,
            include_tests=self.data.include_tests,
            exclude_tests=self.data.exclude_tests,
            debug_level=self.data.debug_level,
            extra_environment=self.data.extra_environment,
            needs_internet=self.data.needs_internet,
            fail_on=self.data.fail_on,
            timeout=self.data.timeout,
        )
        wr = self.work_request.create_child(
            task_name="autopkgtest",
            task_data=task_data.dict(exclude_unset=True),
            workflow_data=WorkRequestWorkflowData(
                display_name=f"autopkgtest {architecture}",
                step=f"autopkgtest-{architecture}",
            ),
        )
        self.requires_artifact(wr, filtered_binary_artifacts)
        self.requires_artifact(wr, filtered_context_artifacts)
        self.provides_artifact(
            wr,
            ArtifactCategory.AUTOPKGTEST,
            f"{self.data.prefix}autopkgtest-{architecture}",
        )

    def populate(self) -> None:
        """Create autopkgtest work requests for all architectures."""
        assert self.work_request is not None

        children = self.work_request.children.all()
        existing_architectures = {
            child.task_data["host_architecture"] for child in children
        }

        # We don't expect there to be a scenario where there are existing
        # work requests that we no longer need.  Leave an assertion so that
        # if this happens we can work out what to do in that corner case.
        if old_architectures := existing_architectures - self.architectures:
            raise AssertionError(
                f"Unexpected work requests found: {old_architectures}"
            )

        if new_architectures := self.architectures - existing_architectures:
            for architecture in new_architectures:
                self._populate_single(architecture)

    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 "run autopkgtests"
