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

"""qa workflow."""

from typing import override

from django.db.models import Q, QuerySet

from debusine.artifacts import SourcePackage
from debusine.artifacts.models import TaskTypes
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.models import (
    AutopkgtestWorkflowData,
    BlhcWorkflowData,
    DebDiffWorkflowData,
    LintianWorkflowData,
    PiupartsWorkflowData,
    QAFailOn,
    QAWorkflowData,
    QAWorkflowDynamicData,
    RegressionTrackingUpdateQAResults,
    ReverseDependenciesAutopkgtestWorkflowData,
    WorkRequestWorkflowData,
)
from debusine.server.workflows.regression_tracking import (
    RegressionTrackingWorkflow,
)
from debusine.tasks.models import (
    BackendType,
    InputArtifact,
    InputArtifactMultiple,
    InputArtifactSingle,
    LintianFailOnSeverity,
    LookupMultiple,
    LookupSingle,
    OutputData,
    RegressionAnalysis,
    RegressionAnalysisStatus,
)


class QAWorkflow(
    RegressionTrackingWorkflow[QAWorkflowData, QAWorkflowDynamicData]
):
    """QA workflow."""

    TASK_NAME = "qa"

    @override
    def compute_dynamic_data(self) -> QAWorkflowDynamicData:
        """
        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 or None
        :reference_source_artifact_id: id of the reference artifact or None
        """
        source_artifact = workflow_utils.source_package(self)
        source_data = SourcePackage.create_data(source_artifact.data)

        if self.data.reference_source_artifact is not None:
            reference_source_artifact = workflow_utils.source_package(
                self, configuration_key="reference_source_artifact"
            )
        else:
            reference_source_artifact = None

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

        return QAWorkflowDynamicData(
            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),
            reference_source_artifact_id=(
                reference_source_artifact.id
                if reference_source_artifact is not None
                else None
            ),
        )

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

        if self.data.reference_source_artifact is not None:
            artifacts.append(
                InputArtifactSingle(
                    lookup=self.data.reference_source_artifact,
                    label="reference_source_artifact",
                    artifact_id=(
                        None
                        if self.dynamic_data is None
                        else self.dynamic_data.reference_source_artifact_id
                    ),
                )
            )

        return artifacts

    @override
    def populate(self) -> None:
        """Create work requests."""
        if (data_archs := self.data.architectures) is not None:
            architectures = set(data_archs)
        else:
            architectures = workflow_utils.get_available_architectures(
                self, vendor=self.data.vendor, codename=self.data.codename
            )

        if (
            data_archs_allowlist := self.data.architectures_allowlist
        ) is not None:
            architectures.intersection_update(data_archs_allowlist)

        if (
            data_archs_denylist := self.data.architectures_denylist
        ) is not None:
            architectures.difference_update(data_archs_denylist)

        effective_architectures = sorted(architectures)

        filtered_binary_artifacts = (
            workflow_utils.filter_artifact_lookup_by_arch(
                self, self.data.binary_artifacts, architectures | {"all"}
            )
        )

        allow_failure = self.data.fail_on in {
            QAFailOn.NEVER,
            QAFailOn.REGRESSION,
        }

        if self.data.enable_autopkgtest:
            self._populate_autopkgtest(
                prefix=self.data.prefix,
                reference_prefix=self.data.reference_prefix,
                source_artifact=self.data.source_artifact,
                binary_artifacts=filtered_binary_artifacts,
                qa_suite=self.data.qa_suite,
                reference_qa_results=self.data.reference_qa_results,
                enable_regression_tracking=self.data.enable_regression_tracking,
                update_qa_results=self.data.update_qa_results,
                vendor=self.data.vendor,
                codename=self.data.codename,
                backend=self.data.autopkgtest_backend,
                architectures=effective_architectures,
                arch_all_build_architecture=(
                    self.data.arch_all_build_architecture
                ),
                allow_failure=allow_failure,
            )

        if self.data.enable_reverse_dependencies_autopkgtest:
            # Checked by
            # QAWorkflowData.check_reverse_dependencies_autopkgtest_consistency.
            assert self.data.qa_suite is not None
            self._populate_reverse_dependencies_autopkgtest(
                prefix=self.data.prefix,
                reference_prefix=self.data.reference_prefix,
                source_artifact=self.data.source_artifact,
                binary_artifacts=filtered_binary_artifacts,
                qa_suite=self.data.qa_suite,
                reference_qa_results=self.data.reference_qa_results,
                enable_regression_tracking=self.data.enable_regression_tracking,
                update_qa_results=self.data.update_qa_results,
                vendor=self.data.vendor,
                codename=self.data.codename,
                backend=self.data.autopkgtest_backend,
                architectures=effective_architectures,
                arch_all_build_architecture=(
                    self.data.arch_all_build_architecture
                ),
                allow_failure=allow_failure,
            )

        if self.data.enable_lintian:
            self._populate_lintian(
                prefix=self.data.prefix,
                reference_prefix=self.data.reference_prefix,
                source_artifact=self.data.source_artifact,
                binary_artifacts=filtered_binary_artifacts,
                qa_suite=self.data.qa_suite,
                reference_qa_results=self.data.reference_qa_results,
                enable_regression_tracking=self.data.enable_regression_tracking,
                update_qa_results=self.data.update_qa_results,
                vendor=self.data.vendor,
                codename=self.data.codename,
                backend=self.data.lintian_backend,
                architectures=effective_architectures,
                arch_all_build_architecture=(
                    self.data.arch_all_build_architecture
                ),
                fail_on_severity=self.data.lintian_fail_on_severity,
                allow_failure=allow_failure,
            )

        if self.data.enable_piuparts:
            self._populate_piuparts(
                prefix=self.data.prefix,
                reference_prefix=self.data.reference_prefix,
                source_artifact=self.data.source_artifact,
                binary_artifacts=filtered_binary_artifacts,
                qa_suite=self.data.qa_suite,
                reference_qa_results=self.data.reference_qa_results,
                enable_regression_tracking=self.data.enable_regression_tracking,
                update_qa_results=self.data.update_qa_results,
                vendor=self.data.vendor,
                codename=self.data.codename,
                architectures=effective_architectures,
                backend=self.data.piuparts_backend,
                environment=self.data.piuparts_environment,
                allow_failure=allow_failure,
                arch_all_expected_installable=self.data.arch_all_expected_installable,
            )

        if self.data.enable_debdiff:
            # Checked by
            # QAWorkflowData.enable_debdiff_consistency.
            assert self.data.qa_suite is not None
            self._populate_debdiff(
                source_artifact=self.data.source_artifact,
                binary_artifacts=filtered_binary_artifacts,
                vendor=self.data.vendor,
                codename=self.data.codename,
                original=self.data.qa_suite,
                arch_all_build_architecture=(
                    self.data.arch_all_build_architecture
                ),
                allow_failure=allow_failure,
            )

        if self.data.enable_blhc:
            self._populate_blhc(
                prefix=self.data.prefix,
                reference_prefix=self.data.reference_prefix,
                source_artifact=self.data.source_artifact,
                binary_artifacts=filtered_binary_artifacts,
                qa_suite=self.data.qa_suite,
                reference_qa_results=self.data.reference_qa_results,
                enable_regression_tracking=self.data.enable_regression_tracking,
                update_qa_results=self.data.update_qa_results,
                vendor=self.data.vendor,
                codename=self.data.codename,
                arch_all_build_architecture=(
                    self.data.arch_all_build_architecture
                ),
                allow_failure=allow_failure,
            )

        if self.data.fail_on == QAFailOn.REGRESSION:
            self._populate_regression_analysis_callback()

    def _populate_autopkgtest(
        self,
        *,
        prefix: str,
        reference_prefix: str,
        source_artifact: LookupSingle,
        binary_artifacts: LookupMultiple,
        qa_suite: LookupSingle | None,
        reference_qa_results: LookupSingle | None,
        enable_regression_tracking: bool,
        update_qa_results: RegressionTrackingUpdateQAResults,
        vendor: str,
        codename: str,
        backend: BackendType,
        architectures: list[str],
        arch_all_build_architecture: str,
        allow_failure: bool,
    ) -> None:
        """Create work request for autopkgtest workflow."""
        wr, _ = self.work_request_ensure_child_workflow(
            task_name="autopkgtest",
            task_data=AutopkgtestWorkflowData(
                prefix=prefix,
                reference_prefix=reference_prefix,
                source_artifact=source_artifact,
                binary_artifacts=binary_artifacts,
                qa_suite=qa_suite,
                reference_qa_results=reference_qa_results,
                enable_regression_tracking=enable_regression_tracking,
                update_qa_results=update_qa_results,
                vendor=vendor,
                codename=codename,
                backend=backend,
                architectures=architectures,
                arch_all_build_architecture=arch_all_build_architecture,
                extra_repositories=self.data.extra_repositories,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="autopkgtest",
                step="autopkgtest",
                allow_failure=allow_failure,
            ),
        )
        # The autopkgtest workflow's children will have dependencies on the
        # work requests creating source_artifact and binary_artifacts, but the
        # autopkgtest workflow itself doesn't need that in order to populate
        # itself.
        self.orchestrate_child(wr)

    def _populate_reverse_dependencies_autopkgtest(
        self,
        *,
        prefix: str,
        reference_prefix: str,
        source_artifact: LookupSingle,
        binary_artifacts: LookupMultiple,
        qa_suite: LookupSingle,
        reference_qa_results: LookupSingle | None,
        enable_regression_tracking: bool,
        update_qa_results: RegressionTrackingUpdateQAResults,
        vendor: str,
        codename: str,
        backend: BackendType,
        architectures: list[str],
        arch_all_build_architecture: str,
        allow_failure: bool,
    ) -> None:
        """Create work request for reverse_dependencies_autopkgtest workflow."""
        wr, _ = self.work_request_ensure_child_workflow(
            task_name="reverse_dependencies_autopkgtest",
            task_data=ReverseDependenciesAutopkgtestWorkflowData(
                prefix=prefix,
                reference_prefix=reference_prefix,
                source_artifact=source_artifact,
                binary_artifacts=binary_artifacts,
                qa_suite=qa_suite,
                reference_qa_results=reference_qa_results,
                enable_regression_tracking=enable_regression_tracking,
                update_qa_results=update_qa_results,
                vendor=vendor,
                codename=codename,
                backend=backend,
                architectures=architectures,
                arch_all_build_architecture=arch_all_build_architecture,
                extra_repositories=self.data.extra_repositories,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="autopkgtests of reverse-dependencies",
                step="reverse-dependencies-autopkgtest",
                allow_failure=allow_failure,
            ),
        )
        # The reverse_dependencies_autopkgtest workflow's descendants will have
        # dependencies on the work requests creating source_artifact and
        # binary_artifacts, but the reverse_dependencies_autopkgtest workflow
        # itself doesn't need that in order to populate itself.
        self.orchestrate_child(wr)

    def _populate_lintian(
        self,
        *,
        prefix: str,
        reference_prefix: str,
        source_artifact: LookupSingle,
        binary_artifacts: LookupMultiple,
        qa_suite: LookupSingle | None,
        reference_qa_results: LookupSingle | None,
        enable_regression_tracking: bool,
        update_qa_results: RegressionTrackingUpdateQAResults,
        vendor: str,
        codename: str,
        backend: BackendType,
        architectures: list[str],
        arch_all_build_architecture: str,
        fail_on_severity: LintianFailOnSeverity,
        allow_failure: bool,
    ) -> None:
        """Create work request for lintian workflow."""
        wr, _ = self.work_request_ensure_child_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                prefix=prefix,
                reference_prefix=reference_prefix,
                source_artifact=source_artifact,
                binary_artifacts=binary_artifacts,
                qa_suite=qa_suite,
                reference_qa_results=reference_qa_results,
                enable_regression_tracking=enable_regression_tracking,
                update_qa_results=update_qa_results,
                vendor=vendor,
                codename=codename,
                backend=backend,
                architectures=architectures,
                arch_all_build_architecture=arch_all_build_architecture,
                fail_on_severity=fail_on_severity,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="lintian",
                step="lintian",
                allow_failure=allow_failure,
            ),
        )
        # The lintian workflow's descendants will have dependencies on the work
        # requests creating source_artifact and binary_artifacts, but the
        # lintian workflow itself doesn't need that in order to populate
        # itself.
        self.orchestrate_child(wr)

    def _populate_piuparts(
        self,
        *,
        prefix: str,
        reference_prefix: str,
        source_artifact: LookupSingle,
        binary_artifacts: LookupMultiple,
        qa_suite: LookupSingle | None,
        reference_qa_results: LookupSingle | None,
        enable_regression_tracking: bool,
        update_qa_results: RegressionTrackingUpdateQAResults,
        vendor: str,
        codename: str,
        architectures: list[str],
        backend: BackendType,
        environment: LookupSingle | None,
        allow_failure: bool,
        arch_all_expected_installable: list[str] | None,
    ) -> None:
        data = PiupartsWorkflowData(
            prefix=prefix,
            reference_prefix=reference_prefix,
            source_artifact=source_artifact,
            binary_artifacts=binary_artifacts,
            qa_suite=qa_suite,
            reference_qa_results=reference_qa_results,
            enable_regression_tracking=enable_regression_tracking,
            update_qa_results=update_qa_results,
            vendor=vendor,
            codename=codename,
            architectures=architectures,
            backend=backend,
            extra_repositories=self.data.extra_repositories,
            arch_all_expected_installable=arch_all_expected_installable,
        )
        if environment is not None:
            data.environment = environment
        wr, _ = self.work_request_ensure_child_workflow(
            task_name="piuparts",
            task_data=data,
            workflow_data=WorkRequestWorkflowData(
                display_name="piuparts",
                step="piuparts",
                allow_failure=allow_failure,
            ),
        )
        # The piuparts workflow's descendants will have dependencies on the
        # work requests creating binary_artifacts, but the piuparts workflow
        # itself doesn't need that in order to populate itself.
        self.orchestrate_child(wr)

    def _populate_debdiff(
        self,
        *,
        vendor: str,
        codename: str,
        source_artifact: LookupSingle,
        binary_artifacts: LookupMultiple,
        original: LookupSingle,
        arch_all_build_architecture: str,
        allow_failure: bool,
    ) -> None:
        data = DebDiffWorkflowData(
            original=original,
            source_artifact=source_artifact,
            binary_artifacts=binary_artifacts,
            vendor=vendor,
            codename=codename,
            arch_all_build_architecture=arch_all_build_architecture,
        )

        wr, _ = self.work_request_ensure_child_workflow(
            task_name="debdiff",
            task_data=data,
            workflow_data=WorkRequestWorkflowData(
                display_name="DebDiff",
                step="debdiff",
                allow_failure=allow_failure,
            ),
        )

        # Do not mark as running: the status is BLOCKED because the
        # binary_artifacts do not exist yet and DebDiffWorkflow needs
        # the binary artifacts in order to create the tasks
        self.requires_artifact(wr, binary_artifacts)

        self.orchestrate_child(wr)

    def _populate_blhc(
        self,
        *,
        prefix: str,
        reference_prefix: str,
        source_artifact: LookupSingle,
        binary_artifacts: LookupMultiple,
        qa_suite: LookupSingle | None,
        reference_qa_results: LookupSingle | None,
        enable_regression_tracking: bool,
        update_qa_results: RegressionTrackingUpdateQAResults,
        vendor: str,
        codename: str,
        arch_all_build_architecture: str,
        allow_failure: bool,
    ) -> None:
        wr, _ = self.work_request_ensure_child_workflow(
            task_name="blhc",
            task_data=BlhcWorkflowData(
                prefix=prefix,
                reference_prefix=reference_prefix,
                source_artifact=source_artifact,
                binary_artifacts=binary_artifacts,
                qa_suite=qa_suite,
                reference_qa_results=reference_qa_results,
                enable_regression_tracking=enable_regression_tracking,
                update_qa_results=update_qa_results,
                vendor=vendor,
                codename=codename,
                arch_all_build_architecture=arch_all_build_architecture,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="build log hardening check",
                step="blhc",
                allow_failure=allow_failure,
            ),
        )

        # The blhc workflow needs the binary artifacts to exist with
        # relations to package build logs in order to populate itself.
        self.requires_artifact(wr, binary_artifacts)

        self.orchestrate_child(wr)

    def _find_regression_analysis_sources(self) -> QuerySet[WorkRequest]:
        """Find descendants that contribute regression analysis results."""
        return WorkRequest.objects.filter(
            Q(task_type=TaskTypes.WORKFLOW)
            & (
                # Most of the relevant tasks are children of this work
                # request.
                Q(parent=self.work_request)
                # Reverse-dependency autopkgtests are grandchildren of this
                # work request.
                | Q(parent__parent=self.work_request)
            )
        )

    def _populate_regression_analysis_callback(self) -> None:
        """Create a final callback to collate regression analysis results."""
        wr, _ = self.work_request_ensure_child_internal(
            task_name="workflow",
            workflow_data=WorkRequestWorkflowData(
                allow_failure=False,
                allow_dependency_failures=True,
                display_name="Regression analysis",
                step="regression-analysis",
                visible=True,
            ),
            unblock_strategy=WorkRequest.UnblockStrategy.INCREMENTAL,
        )
        for callback in WorkRequest.objects.workflow_callbacks().filter(
            workflow_data_json__step__startswith="regression-analysis",
            parent__in=self._find_regression_analysis_sources(),
        ):
            # Each individual regression analysis callback is a descendant
            # of this work request, and therefore must be in the same
            # workflow as the final callback.
            wr.add_dependency(callback, skip_same_workflow_check=True)

    @staticmethod
    def _summarize_status(
        statuses: set[RegressionAnalysisStatus],
    ) -> RegressionAnalysisStatus:
        if RegressionAnalysisStatus.REGRESSION in statuses:
            return RegressionAnalysisStatus.REGRESSION
        elif RegressionAnalysisStatus.ERROR in statuses:
            # Prefer REGRESSION if both exist, since it's probably more
            # informative: ERROR might be a temporary problem with a single
            # task.
            return RegressionAnalysisStatus.ERROR
        elif RegressionAnalysisStatus.IMPROVEMENT in statuses:
            return RegressionAnalysisStatus.IMPROVEMENT
        elif RegressionAnalysisStatus.STABLE in statuses:
            return RegressionAnalysisStatus.STABLE
        else:
            return RegressionAnalysisStatus.NO_RESULT

    def callback_regression_analysis(self) -> bool:
        """Collate regression analysis results."""
        assert self.dynamic_data is not None

        output_data = self.work_request.output_data or OutputData()
        output_data.regression_analysis = {}
        statuses: set[RegressionAnalysisStatus] = set()
        for (
            task_name,
            subject,
            analysis_by_architecture,
        ) in self._find_regression_analysis_sources().values_list(
            "task_name",
            "dynamic_task_data__subject",
            "output_data_json__regression_analysis",
        ):
            for key, analysis in (analysis_by_architecture or {}).items():
                # If we have output data, then dynamic task data must have
                # been populated, and all our sub-workflows have a subject.
                assert subject is not None
                output_data.regression_analysis[
                    f"{task_name}:{subject}:{key}"
                ] = RegressionAnalysis.model_validate(analysis)
                statuses.add(analysis["status"])

        # Top-level summary.
        overall_status = self._summarize_status(statuses)
        output_data.regression_analysis[""] = RegressionAnalysis(
            original_source_version=(
                SourcePackage.create_data(
                    Artifact.objects.get(
                        id=self.dynamic_data.reference_source_artifact_id
                    ).data
                ).version
                if self.dynamic_data.reference_source_artifact_id is not None
                else None
            ),
            new_source_version=(
                SourcePackage.create_data(
                    Artifact.objects.get(
                        id=self.dynamic_data.source_artifact_id
                    ).data
                ).version
                if self.dynamic_data.source_artifact_id is not None
                else None
            ),
            status=overall_status,
        )

        self.work_request.output_data = output_data
        self.work_request.save()
        return overall_status not in {
            RegressionAnalysisStatus.REGRESSION,
            RegressionAnalysisStatus.ERROR,
        }
