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

"""Piuparts workflow."""

from collections.abc import Callable, Iterable
from contextlib import suppress
from functools import cached_property
from typing import Any, override

from django.utils import timezone

from debusine.artifacts.models import (
    ArtifactCategory,
    DebianBinaryPackage,
    DebianUpload,
    get_source_package_version,
)
from debusine.client.models import LookupChildType
from debusine.db.models import Artifact, ArtifactRelation, WorkRequest
from debusine.server.collections.lookup import artifact_ids
from debusine.server.workflows import workflow_utils
from debusine.server.workflows.models import (
    PiupartsWorkflowData,
    PiupartsWorkflowDynamicData,
    RegressionTrackingUpdateQAResults,
    WorkRequestWorkflowData,
)
from debusine.server.workflows.regression_tracking import (
    RegressionTrackingWorkflow,
)
from debusine.tasks.models import (
    ActionUpdateCollectionWithArtifacts,
    BackendType,
    InputArtifact,
    InputArtifactMultiple,
    InputArtifactSingle,
    OutputData,
    PiupartsData,
    PiupartsDataInput,
    RegressionAnalysis,
)


class PiupartsWorkflow(
    RegressionTrackingWorkflow[
        PiupartsWorkflowData, PiupartsWorkflowDynamicData
    ]
):
    """
    Checks binary packages for all architectures of a target distribution.

    Creates piuparts tasks corresponding to the output of a set of
    build tasks for multiple architectures. This is meant to be part
    of a larger QA workflow such as Debian Pipeline.
    """

    # Workflow name (used by create-workflow-template)
    TASK_NAME = "piuparts"

    @override
    def post_init(self) -> None:
        super().post_init()
        if self.data.backend == BackendType.AUTO:
            self.data.backend = BackendType.UNSHARE

    def _has_current_reference_qa_result(self, architecture: str) -> bool:
        """
        Return True iff we have a current reference QA result.

        A piuparts analysis is outdated if the underlying binary packages
        are outdated (i.e.  have smaller version numbers) compared to what's
        available in the ``debian:suite`` collection

        Otherwise, it is current.
        """
        # This method is only called when update_qa_results is "yes", in
        # which case these are checked by a model validator.
        assert self.qa_suite is not None
        assert self.reference_qa_results is not None

        source_data = workflow_utils.source_package_data(self)
        latest_result = self.reference_qa_results.manager.lookup(
            f"latest:piuparts_{source_data.name}_{architecture}"
        )
        return (
            latest_result is not None
            and latest_result.data["version"] == source_data.version
        )

    def _populate_single(self, build_architecture: str) -> None:
        """
        Create a Piuparts task for a single concrete architecture.

        It always includes the "all" packages.

        If the "build_architecture" is not in
        "data.arch_all_expected_installable" and any of the binary artifacts
        are "Architecture: all":
        """
        if (
            self.data.update_qa_results == RegressionTrackingUpdateQAResults.YES
            and self._has_current_reference_qa_result(build_architecture)
        ):
            return

        # Define piuparts environment
        base_tgz = f"{self.data.vendor}/match:codename={self.data.codename}"
        environment = self.data.environment or base_tgz
        backend = self.data.backend

        # group arch-all + arch-any
        arch_subset_binary_artifacts = (
            workflow_utils.filter_artifact_lookup_by_arch(
                self, self.data.binary_artifacts, {build_architecture, "all"}
            )
        )

        extra_repositories = workflow_utils.configure_for_overlay_suite(
            self,
            extra_repositories=self.data.extra_repositories,
            vendor=self.data.vendor,
            codename=self.data.codename,
            environment=environment,
            backend=self.data.backend,
            architecture=build_architecture,
            try_variant="piuparts",
        )

        # Create work-request (idempotent)
        workflow_data_step = f"piuparts-{build_architecture}"
        workflow_data_kwargs: dict[str, Any] = {}

        if (
            # When updating reference results for regression tracking, task
            # failures never cause the parent workflow or dependent tasks to
            # fail.
            self.data.update_qa_results != RegressionTrackingUpdateQAResults.NO
            # If ``build_architecture`` is not listed in
            # ``data.arch_all_expected_installable`` and at least one
            # architecture-independent package is present, then
            # ``workflow_data.allow_failure`` is set to ``True``
            or (
                build_architecture not in self.arch_all_expected_installable
                and "all" in self.binary_architectures
            )
        ):
            workflow_data_kwargs["allow_failure"] = True

        wr, created = self.work_request_ensure_child_worker(
            task_name="piuparts",
            task_data=PiupartsData(
                backend=backend,
                environment=environment,
                input=PiupartsDataInput(
                    binary_artifacts=arch_subset_binary_artifacts
                ),
                build_architecture=build_architecture,
                base_tgz=base_tgz,
                extra_repositories=extra_repositories,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name=f"Piuparts {build_architecture}",
                step=workflow_data_step,
                **workflow_data_kwargs,
            ),
        )

        # Any of the lookups in input.binary_artifacts may result in
        # promises, and in that case the workflow adds corresponding
        # dependencies. Binary promises must include an architecture field
        # in their data.
        self.requires_artifact(wr, arch_subset_binary_artifacts)
        promise_name = f"{self.data.prefix}piuparts-{build_architecture}"
        self.provides_artifact(wr, ArtifactCategory.PIUPARTS, promise_name)

        if created and (
            self.data.update_qa_results != RegressionTrackingUpdateQAResults.NO
        ):
            # Checked by a model validator.
            assert self.data.reference_qa_results is not None

            source_data = workflow_utils.source_package_data(self)

            # Back off if another workflow gets there first.
            self.skip_if_qa_result_changed(
                wr, package=source_data.name, architecture=build_architecture
            )

            # Record results in the reference collection.
            action = ActionUpdateCollectionWithArtifacts(
                collection=self.data.reference_qa_results,
                variables={
                    "package": source_data.name,
                    "version": source_data.version,
                    "architecture": build_architecture,
                    "timestamp": int(
                        (self.qa_suite_changed or timezone.now()).timestamp()
                    ),
                    "work_request_id": wr.id,
                },
                artifact_filters={"category": ArtifactCategory.PIUPARTS},
            )
            wr.add_event_reaction("on_success", action)
            wr.add_event_reaction("on_failure", action)

    def _populate_regression_analysis_callback(
        self, architectures: Iterable[str]
    ) -> None:
        """Create a callback to collate regression analysis results."""
        if self.data.enable_regression_tracking:
            # Checked by a model validator.
            assert self.data.reference_prefix

            regression_analysis, _ = self.work_request_ensure_child_internal(
                task_name="workflow",
                workflow_data=WorkRequestWorkflowData(
                    allow_dependency_failures=True,
                    step="regression-analysis",
                    display_name="Regression analysis",
                    visible=False,
                ),
                unblock_strategy=WorkRequest.UnblockStrategy.INCREMENTAL,
            )
            for architecture in sorted(architectures):
                for prefix in (self.data.reference_prefix, self.data.prefix):
                    with suppress(KeyError):
                        self.requires_artifact(
                            regression_analysis,
                            f"internal@collections/name:"
                            f"{prefix}piuparts-{architecture}",
                        )
            self.orchestrate_child(regression_analysis)

    @cached_property
    def binary_architectures(self) -> frozenset[str]:
        """Return architectures of the binary artifacts."""
        return frozenset(
            workflow_utils.get_lookup_architectures(
                self, self.data.binary_artifacts
            )
        )

    @cached_property
    def build_architectures(self) -> frozenset[str]:
        """Return build architectures."""
        if self.binary_architectures == {"all"}:
            # "build_architectures" is all the architectures: to test the "all"
            # packages for each architecture
            build_architectures = workflow_utils.get_available_architectures(
                self, vendor=self.data.vendor, codename=self.data.codename
            )
        else:
            # When a package produces some architecture-dependent binary
            # packages we assume it is only usable on those architectures
            build_architectures = set(self.binary_architectures)

        if self.data.architectures is not None:
            build_architectures.intersection_update(self.data.architectures)

        build_architectures.discard("all")

        return frozenset(build_architectures)

    @cached_property
    def arch_all_expected_installable(self) -> frozenset[str]:
        """Return arches where arch-all binaries should be installable."""
        if self.data.arch_all_expected_installable is not None:
            return frozenset(self.data.arch_all_expected_installable)

        return self.build_architectures

    @override
    def populate(self) -> None:
        """Create a Piuparts task for each concrete architecture."""
        # piuparts will be run on the intersection of the provided
        # list of architectures (if any) and the architectures
        # provided in binary_artifacts

        for build_architecture in self.build_architectures:
            self._populate_single(build_architecture)

        self._populate_regression_analysis_callback(self.build_architectures)

    def _extract_artifact_details(
        self, artifact: Artifact | None
    ) -> tuple[str | None, int | None, WorkRequest | None]:
        """Extract details from a ``debian:piuparts`` artifact."""
        source_version: str | None
        artifact_id: int | None
        wr: WorkRequest | None
        if artifact is None:
            source_version = None
            artifact_id = None
            wr = None
        else:
            analyzed_artifact = (
                artifact.relations.filter(
                    type=ArtifactRelation.Relations.RELATES_TO
                )
                .earliest("id")
                .target
            )
            analyzed_data = analyzed_artifact.create_data()
            assert isinstance(
                analyzed_data,
                (DebianBinaryPackage, DebianUpload),
            )
            source_version = get_source_package_version(analyzed_data)
            artifact_id = artifact.id
            wr = artifact.created_by_work_request
        return source_version, artifact_id, wr

    def callback_regression_analysis(self) -> bool:
        """
        Analyze regressions compared to reference results.

        This is called once for each architecture, but updates the whole
        analysis for all architectures each time.  This is partly for
        simplicity and robustness (we don't need to work out how to combine
        the new analysis with a previous one), and partly to make it easier
        to handle cases where there isn't a one-to-one mapping between the
        reference results and the new results.
        """
        # Select the newest result for each architecture.
        reference_artifacts = self.find_reference_artifacts("piuparts")
        new_artifacts = self.find_new_artifacts(
            "piuparts", ArtifactCategory.PIUPARTS
        )

        output_data = self.work_request.output_data or OutputData()
        output_data.regression_analysis = {}
        for architecture in sorted(
            set(reference_artifacts) | set(new_artifacts)
        ):
            reference = reference_artifacts.get(architecture)
            reference_source_version, reference_artifact_id, reference_wr = (
                self._extract_artifact_details(reference)
            )
            new = new_artifacts.get(architecture)
            new_source_version, new_artifact_id, new_wr = (
                self._extract_artifact_details(new)
            )

            output_data.regression_analysis[architecture] = RegressionAnalysis(
                original_source_version=reference_source_version,
                original_artifact_id=reference_artifact_id,
                new_source_version=new_source_version,
                new_artifact_id=new_artifact_id,
                status=self.compare_qa_results(reference_wr, new_wr),
            )

        self.work_request.output_data = output_data
        self.work_request.save()
        return True

    @override
    def get_callback(self, step: str) -> Callable[[], bool]:
        # Current versions of this workflow create a single incremental
        # callback for all architectures called "regression-analysis", but
        # we may be run on workflows that were orchestrated when we created
        # one "regression-analysis-{architecture}" callback per
        # architecture.
        if not step.startswith("regression-analysis"):
            raise NotImplementedError(
                f"Unhandled workflow callback step: {step}"
            )
        return self.callback_regression_analysis

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

        :subject: source package names of ``binary_artifacts`` separated
          by spaces
        :parameter_summary: "subject" in ``vendor``:``codename``
        :source_artifact_id: id of the ``source_artifact``
        :binary_artifacts_ids: ids of the ``binary_artifacts``
        """
        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,
                ),
            )
        )

        return PiupartsWorkflowDynamicData(
            subject=source_package_names,
            parameter_summary=f"{source_package_names} in "
            f"{self.data.vendor}:{self.data.codename}",
            source_artifact_id=workflow_utils.source_package(self).id,
            binary_artifacts_ids=artifact_ids(binaries),
        )

    @override
    def get_input_artifacts(self) -> list[InputArtifact]:
        """Return the list of input artifacts."""
        return [
            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
                ),
            ),
            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
                ),
            ),
        ]
