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

"""Autopkgtest workflow."""

import datetime as dt
from collections.abc import Callable
from contextlib import suppress
from functools import cached_property
from typing import Any, TypeAlias, override

from django.utils import timezone

from debusine.artifacts import SourcePackage
from debusine.artifacts.models import (
    ArtifactCategory,
    DebianAutopkgtest,
    DebianAutopkgtestResultStatus,
)
from debusine.client.models import LookupChildType
from debusine.db.models import Artifact, WorkRequest
from debusine.server.collections.lookup import artifact_ids
from debusine.server.workflows import workflow_utils
from debusine.server.workflows.base import WorkflowValidationError
from debusine.server.workflows.models import (
    AutopkgtestWorkflowData,
    AutopkgtestWorkflowDynamicData,
    RegressionTrackingUpdateQAResults,
    WorkRequestWorkflowData,
)
from debusine.server.workflows.regression_tracking import (
    RegressionTrackingWorkflow,
)
from debusine.tasks.models import (
    ActionUpdateCollectionWithArtifacts,
    AutopkgtestData,
    AutopkgtestInput,
    BackendType,
    InputArtifact,
    InputArtifactMultiple,
    InputArtifactSingle,
    OutputData,
    RegressionAnalysis,
    RegressionAnalysisStatus,
)


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

    TASK_NAME = "autopkgtest"

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

    @cached_property
    def architectures(self) -> set[str]:
        """Concrete architectures to run tests on."""
        try:
            architectures = workflow_utils.get_lookup_architectures(
                self, self.data.binary_artifacts
            )
        except ValueError as e:
            raise WorkflowValidationError(str(e)) from e

        # If the input only contains architecture-independent binary
        # packages, then run tests on {arch_all_build_architecture}.  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 = {self.data.arch_all_build_architecture}
        architectures.discard("all")

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

        return architectures

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

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

        An autopkgtest analysis is outdated if:

        * either the underlying source or binary packages are outdated (i.e.
          have different version numbers) compared to what's available in
          the ``debian:suite`` collection
        * or the timestamp of the analysis is older than 30 days compared to
          the ``Date`` timestamp of the ``debian:suite`` collection

        Otherwise, it is current.
        """
        # This method is only called when update_qa_results is "yes", in
        # which case this is checked by a model validator.
        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:autopkgtest_{source_data.name}_{architecture}"
        )
        return (
            latest_result is not None
            and latest_result.data["version"] == source_data.version
            and (
                self.qa_suite_changed is None
                or dt.datetime.fromtimestamp(
                    latest_result.data["timestamp"], tz=dt.UTC
                )
                >= self.qa_suite_changed - dt.timedelta(days=30)
            )
        )

    def _populate_single(self, architecture: str) -> None:
        """Create an autopkgtest work request for a single architecture."""
        if (
            self.data.update_qa_results == RegressionTrackingUpdateQAResults.YES
            and self._has_current_reference_qa_result(architecture)
        ):
            return

        source_artifact = workflow_utils.locate_debian_source_package_lookup(
            self, "source_artifact", self.data.source_artifact
        )
        filtered_binary_artifacts = (
            workflow_utils.filter_artifact_lookup_by_arch(
                self, self.data.binary_artifacts, {architecture, "all"}
            )
        )
        filtered_context_artifacts = (
            workflow_utils.filter_artifact_lookup_by_arch(
                self, self.data.context_artifacts, {architecture, "all"}
            )
        )

        environment = f"{self.data.vendor}/match:codename={self.data.codename}"
        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=architecture,
            try_variant="autopkgtest",
        )

        task_data = AutopkgtestData(
            input=AutopkgtestInput(
                source_artifact=source_artifact,
                binary_artifacts=filtered_binary_artifacts,
                context_artifacts=filtered_context_artifacts,
            ),
            build_architecture=architecture,
            environment=environment,
            backend=self.data.backend,
            include_tests=self.data.include_tests,
            exclude_tests=self.data.exclude_tests,
            extra_repositories=extra_repositories,
            debug_level=self.data.debug_level,
            pin_packages=self.data.pin_packages,
            extra_environment=self.data.extra_environment,
            needs_internet=self.data.needs_internet,
            fail_on=self.data.fail_on,
            timeout=self.data.timeout,
        )
        workflow_data_step = f"autopkgtest-{architecture}"
        workflow_data_kwargs: dict[str, Any] = {}
        if self.data.update_qa_results != RegressionTrackingUpdateQAResults.NO:
            # When updating reference results for regression tracking, task
            # failures never cause the parent workflow or dependent tasks to
            # fail.
            workflow_data_kwargs["allow_failure"] = True
        wr, created = self.work_request_ensure_child_worker(
            task_name="autopkgtest",
            task_data=task_data,
            workflow_data=WorkRequestWorkflowData(
                display_name=f"autopkgtest {architecture}",
                step=workflow_data_step,
                **workflow_data_kwargs,
            ),
        )
        self.requires_artifact(wr, source_artifact)
        self.requires_artifact(wr, filtered_binary_artifacts)
        self.requires_artifact(wr, filtered_context_artifacts)
        promise_name = f"{self.data.prefix}autopkgtest-{architecture}"
        self.provides_artifact(wr, ArtifactCategory.AUTOPKGTEST, 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=architecture,
                promise_name=promise_name,
            )

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

    def _populate_regression_analysis_callback(
        self, architectures: set[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}autopkgtest-{architecture}",
                        )
            self.orchestrate_child(regression_analysis)

    @override
    def populate(self) -> None:
        """Create autopkgtest work requests for all architectures."""
        for architecture in sorted(self.architectures):
            self._populate_single(architecture)
        self._populate_regression_analysis_callback(self.architectures)

    def _extract_artifact_details(
        self, artifact: Artifact | None
    ) -> tuple[str | None, int | None, WorkRequest | None]:
        """Extract details from a ``debian:autopkgtest`` 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:
            data = artifact.create_data()
            assert isinstance(data, DebianAutopkgtest)
            source_version = data.source_package.version
            artifact_id = artifact.id
            wr = artifact.created_by_work_request
        return source_version, artifact_id, wr

    @staticmethod
    def compare_qa_results_fine_grained(
        reference: Artifact | None, new: Artifact | None
    ) -> dict[str, str]:
        """Do a fine-grained comparison of two QA results."""
        reference_results = (
            {} if reference is None else reference.data["results"]
        )
        new_results = {} if new is None else new.data["results"]
        DARS: TypeAlias = DebianAutopkgtestResultStatus
        details = {}
        for test_name in set(reference_results) | set(new_results):
            match reference_results.get(test_name), new_results.get(test_name):
                case (
                    {"status": DARS.PASS | DARS.SKIP} | None,
                    {"status": DARS.FAIL},
                ):
                    details[test_name] = "regression"
                case (
                    {"status": DARS.FAIL | DARS.FLAKY},
                    {"status": DARS.PASS | DARS.SKIP} | None,
                ):
                    details[test_name] = "improvement"
                case _:
                    details[test_name] = "stable"
        return details

    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("autopkgtest")
        new_artifacts = self.find_new_artifacts(
            "autopkgtest", ArtifactCategory.AUTOPKGTEST
        )

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

            status = self.compare_qa_results(reference_wr, new_wr)
            details = self.compare_qa_results_fine_grained(reference, new)
            if status == RegressionAnalysisStatus.STABLE:
                if "regression" in details.values():
                    status = RegressionAnalysisStatus.REGRESSION
                elif "improvement" in details.values():
                    status = RegressionAnalysisStatus.IMPROVEMENT
                else:
                    status = RegressionAnalysisStatus.STABLE

            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=status,
                details=details,
            )

        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) -> AutopkgtestWorkflowDynamicData:
        """
        Compute dynamic data for this workflow.

        :subject: package name of ``source_artifact``
        :parameter_summary: package name_version
        :source_artifact_id: id of the source artifact
        :binary_artifacts_ids: ids of the binary artifacts
        :context_artifacts_ids: ids of the context artifacts
        """
        source_artifact = workflow_utils.source_package(self)
        source_data = SourcePackage.create_data(source_artifact.data)

        binary_artifacts_results = self.work_request.lookup_multiple(
            self.data.binary_artifacts,
            expect_type=LookupChildType.ARTIFACT_OR_PROMISE,
        )
        context_artifacts_results = self.work_request.lookup_multiple(
            self.data.context_artifacts,
            expect_type=LookupChildType.ARTIFACT_OR_PROMISE,
        )

        return AutopkgtestWorkflowDynamicData(
            subject=source_data.name,
            parameter_summary=f"{source_data.name}_{source_data.version}",
            source_artifact_id=source_artifact.id,
            binary_artifacts_ids=artifact_ids(binary_artifacts_results),
            context_artifacts_ids=artifact_ids(context_artifacts_results),
        )

    @override
    def get_input_artifacts(self) -> list[InputArtifact]:
        """Return 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
                ),
            ),
            InputArtifactMultiple.from_ids(
                lookup=self.data.context_artifacts,
                label="context_artifacts",
                artifact_ids=(
                    self.dynamic_data.context_artifacts_ids
                    if self.dynamic_data is not None
                    else None
                ),
            ),
        ]
