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

"""Unit tests for the lintian workflow."""

from collections.abc import Sequence
from typing import Any, ClassVar

from django.test import override_settings
from django.utils import timezone

from debusine.artifacts.models import (
    ArtifactCategory,
    CollectionCategory,
    DebianLintian,
    DebianLintianSummary,
    TaskTypes,
)
from debusine.client.models import LookupChildType
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    Collection,
    CollectionItem,
    TaskDatabase,
    WorkRequest,
)
from debusine.db.models.work_requests import SkipWorkRequest
from debusine.server.collections.lookup import lookup_single
from debusine.server.scheduler import schedule
from debusine.server.workflows import LintianWorkflow, WorkflowValidationError
from debusine.server.workflows.base import orchestrate_workflow
from debusine.server.workflows.models import (
    BaseWorkflowData,
    LintianWorkflowData,
    SbuildWorkflowData,
)
from debusine.server.workflows.tests.helpers import (
    SampleWorkflow,
    WorkflowTestBase,
)
from debusine.server.workflows.tests.test_regression_tracking import (
    RegressionTrackingWorkflowTestMixin,
)
from debusine.tasks.lintian import Lintian
from debusine.tasks.models import (
    ActionSkipIfLookupResultChanged,
    ActionUpdateCollectionWithArtifacts,
    BackendType,
    BaseDynamicTaskData,
    LintianFailOnSeverity,
    LintianOutput,
    LookupMultiple,
    OutputData,
    RegressionAnalysisStatus,
    SbuildData,
    SbuildInput,
)
from debusine.test.test_utils import preserve_task_registry


class LintianWorkflowTests(
    RegressionTrackingWorkflowTestMixin, WorkflowTestBase[LintianWorkflow]
):
    """Unit tests for :py:class:`LintianWorkflow`."""

    source_artifact: ClassVar[Artifact]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common data."""
        super().setUpTestData()
        cls.source_artifact = cls.playground.create_source_artifact(
            name="hello", version="1.0-1"
        )

    def create_lintian_workflow(
        self,
        *,
        extra_task_data: dict[str, Any] | None = None,
        validate: bool = True,
    ) -> LintianWorkflow:
        """Create a lintian workflow."""
        task_data = {
            "source_artifact": self.source_artifact.pk,
            "binary_artifacts": [
                "internal@collections/name:build-amd64",
                "internal@collections/name:build-i386",
            ],
            "vendor": "debian",
            "codename": "bookworm",
        }
        if extra_task_data is not None:
            task_data.update(extra_task_data)
        wr = self.playground.create_workflow(
            task_name="lintian", task_data=task_data, validate=validate
        )
        return self.get_workflow(wr)

    def orchestrate(
        self,
        task_data: LintianWorkflowData,
        architectures: Sequence[str],
        parent: WorkRequest | None = None,
        pipeline_task_name: str = "examplepipeline",
    ) -> WorkRequest:
        """Create and orchestrate a LintianWorkflow."""

        class ExamplePipeline(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Lintian workflow."""

            TASK_NAME = pipeline_task_name

            def populate(self_) -> None:
                """Populate the pipeline."""
                sbuild = self_.work_request.create_child_workflow(
                    task_name="sbuild",
                    task_data=SbuildWorkflowData(
                        input=SbuildInput(
                            source_artifact=task_data.source_artifact
                        ),
                        target_distribution="debian:sid",
                        architectures=list(architectures) or ["all"],
                    ),
                )
                self.playground.advance_work_request(sbuild, mark_running=True)

                for arch in architectures:
                    child = sbuild.create_child_worker(
                        task_name="sbuild",
                        task_data=SbuildData(
                            input=SbuildInput(
                                source_artifact=task_data.source_artifact
                            ),
                            host_architecture=arch,
                            environment="debian/match:codename=sid",
                        ),
                    )

                    self_.provides_artifact(
                        child,
                        ArtifactCategory.BINARY_PACKAGE,
                        f"build-{arch}",
                        data={"architecture": arch},
                    )
                sbuild.unblock_workflow_children()

                lintian = self_.work_request.create_child_workflow(
                    task_name="lintian",
                    task_data=task_data,
                )
                self.playground.advance_work_request(lintian, mark_pending=True)
                self_.orchestrate_child(lintian)

        root = self.playground.create_workflow(
            task_name=pipeline_task_name, parent=parent
        )
        self.assertTrue(orchestrate_workflow(root))

        return root

    def add_lintian_binary(
        self, suite: Collection, version: str, architecture: str = "all"
    ) -> None:
        """Add a lintian binary package to a suite."""
        artifact = self.playground.create_minimal_binary_package_artifact(
            "lintian", version, "lintian", version, architecture
        )
        suite.manager.add_artifact(
            artifact,
            user=self.playground.get_default_user(),
            variables={
                "component": "main",
                "section": "devel",
                "priority": "optional",
            },
        )

    def add_qa_result(
        self,
        qa_results: Collection,
        package: str,
        version: str,
        architecture: str,
        lintian_version: str,
    ) -> CollectionItem:
        """Add a lintian result to a ``debian:qa-results`` collection."""
        work_request = self.playground.create_worker_task(
            task_name="lintian",
            result=WorkRequest.Results.SUCCESS,
            validate=False,
        )
        return qa_results.manager.add_artifact(
            self.playground.create_artifact(
                category=ArtifactCategory.LINTIAN,
                data=DebianLintian(
                    architecture=architecture,
                    summary=DebianLintianSummary(
                        tags_count_by_severity={},
                        package_filename={},
                        tags_found=[],
                        overridden_tags_found=[],
                        lintian_version=lintian_version,
                        distribution="debian:sid",
                    ),
                ),
            )[0],
            user=self.playground.get_default_user(),
            variables={
                "package": package,
                "version": version,
                "architecture": architecture,
                "timestamp": int(timezone.now().timestamp()),
                "work_request_id": work_request.id,
            },
        )

    def test_validate_input(self) -> None:
        """validate_input passes a valid case."""
        w = self.create_lintian_workflow()

        w.validate_input()

    def test_validate_input_bad_qa_suite(self) -> None:
        """validate_input raises errors in looking up a suite."""
        w = self.create_lintian_workflow(
            extra_task_data={"qa_suite": "nonexistent@debian:suite"},
            validate=False,
        )

        with self.assertRaisesRegex(
            WorkflowValidationError,
            "'nonexistent@debian:suite' does not exist or is hidden",
        ):
            w.validate_input()

    def test_validate_input_bad_reference_qa_results(self) -> None:
        """validate_input raises errors in looking up reference QA results."""
        w = self.create_lintian_workflow(
            extra_task_data={
                "reference_qa_results": "nonexistent@debian:qa-results"
            },
            validate=False,
        )

        with self.assertRaisesRegex(
            WorkflowValidationError,
            "'nonexistent@debian:qa-results' does not exist or is hidden",
        ):
            w.validate_input()

    def test_has_current_reference_qa_result_no_match(self) -> None:
        """_has_current_reference_qa_result: no matching result."""
        sid_suite = self.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        sid_qa_results = self.playground.create_collection(
            "sid", CollectionCategory.QA_RESULTS
        )
        self.add_lintian_binary(sid_suite, "2.116.3")
        self.add_qa_result(sid_qa_results, "other", "1.0-1", "amd64", "2.116.3")
        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-1"
        )

        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([]),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
        )

        self.assertFalse(
            self.get_workflow(wr)._has_current_reference_qa_result(
                architecture="amd64", host_architecture="amd64"
            )
        )

    def test_has_current_reference_qa_result_different_version(self) -> None:
        """_has_current_reference_qa_result: result for different version."""
        sid_suite = self.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        sid_qa_results = self.playground.create_collection(
            "sid", CollectionCategory.QA_RESULTS
        )
        self.add_lintian_binary(sid_suite, "2.116.3")
        self.add_qa_result(sid_qa_results, "hello", "1.0-1", "amd64", "2.116.3")
        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-2"
        )

        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([]),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
        )

        self.assertFalse(
            self.get_workflow(wr)._has_current_reference_qa_result(
                architecture="amd64", host_architecture="amd64"
            )
        )

    def test_has_current_reference_qa_result_old_lintian_version(self) -> None:
        """_has_current_reference_qa_result: run with old lintian version."""
        sid_suite = self.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        sid_qa_results = self.playground.create_collection(
            "sid", CollectionCategory.QA_RESULTS
        )
        self.add_lintian_binary(sid_suite, "2.116.3")
        self.add_qa_result(sid_qa_results, "hello", "1.0-1", "amd64", "2.15.0")
        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-1"
        )

        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([]),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
        )

        self.assertFalse(
            self.get_workflow(wr)._has_current_reference_qa_result(
                architecture="amd64", host_architecture="amd64"
            )
        )

    def test_has_current_reference_qa_result_no_lintian_version(self) -> None:
        """_has_current_reference_qa_result: no reference lintian version."""
        self.playground.create_collection("sid", CollectionCategory.SUITE)
        sid_qa_results = self.playground.create_collection(
            "sid", CollectionCategory.QA_RESULTS
        )
        self.add_qa_result(sid_qa_results, "hello", "1.0-1", "amd64", "2.116.3")
        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-1"
        )

        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([]),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
        )

        self.assertTrue(
            self.get_workflow(wr)._has_current_reference_qa_result(
                architecture="amd64", host_architecture="amd64"
            )
        )

    def test_has_current_reference_qa_result_current(self) -> None:
        """_has_current_reference_qa_result: current result."""
        sid_suite = self.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        sid_qa_results = self.playground.create_collection(
            "sid", CollectionCategory.QA_RESULTS
        )
        self.add_lintian_binary(sid_suite, "2.116.3")
        self.add_qa_result(sid_qa_results, "hello", "1.0-1", "amd64", "2.116.3")
        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-1"
        )

        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([]),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
        )

        self.assertTrue(
            self.get_workflow(wr)._has_current_reference_qa_result(
                architecture="amd64", host_architecture="amd64"
            )
        )

    def test_has_current_reference_qa_result_honours_host_architecture(
        self,
    ) -> None:
        """_has_current_reference_qa_result: lintian checked by host arch."""
        sid_suite = self.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        sid_qa_results = self.playground.create_collection(
            "sid", CollectionCategory.QA_RESULTS
        )
        self.add_lintian_binary(sid_suite, "2.116.3")
        self.add_qa_result(sid_qa_results, "hello", "1.0-1", "all", "2.116.3")
        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-1"
        )

        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([]),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
        )

        self.assertTrue(
            self.get_workflow(wr)._has_current_reference_qa_result(
                architecture="all", host_architecture="amd64"
            )
        )

        self.add_lintian_binary(sid_suite, "2.117.0", architecture="amd64")

        self.assertFalse(
            self.get_workflow(wr)._has_current_reference_qa_result(
                architecture="all", host_architecture="amd64"
            )
        )

    @preserve_task_registry()
    def test_populate(self) -> None:
        """Test populate."""
        architectures = ["amd64", "i386", "all"]

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                architectures=architectures,
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [
                        f"internal@collections/name:build-{arch}"
                        for arch in architectures
                    ]
                ),
                vendor="debian",
                codename="trixie",
            ),
            architectures=architectures,
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        lintians = workflow.children.filter(task_name="lintian")

        # architecture "all" does not have a lintian job
        self.assertEqual(lintians.count(), len(architectures) - 1)

        for architecture in architectures:
            if architecture == "all":
                continue

            lintian = lintians.get(
                workflow_data_json__step=f"lintian-{architecture}"
            )

            self.assertEqual(
                lintian.task_data,
                {
                    "backend": Lintian.DEFAULT_BACKEND,
                    "environment": "debian/match:codename=trixie",
                    "exclude_tags": [],
                    "fail_on_severity": LintianFailOnSeverity.ERROR,
                    "host_architecture": architecture,
                    "include_tags": [],
                    "input": {
                        "binary_artifacts": sorted(
                            [
                                f"internal@collections/name:build-{arch}"
                                for arch in (architecture, "all")
                            ]
                        ),
                        "source_artifact": self.source_artifact.id,
                    },
                    "output": (
                        {"source_analysis": False, "binary_all_analysis": False}
                        if architecture != "amd64"
                        else {}
                    ),
                    "target_distribution": "debian:trixie",
                },
            )

            self.assertEqual(
                lintian.workflow_data_json,
                {
                    "display_name": f"Lintian for {architecture}",
                    "step": f"lintian-{architecture}",
                },
            )

            self.assertQuerySetEqual(
                lintian.dependencies.all(),
                set(
                    WorkRequest.objects.filter(
                        task_name="sbuild",
                        task_data__host_architecture__in=(architecture, "all"),
                    )
                ),
                ordered=False,
            )

    @preserve_task_registry()
    def test_populate_source_only(self) -> None:
        """Test populate with no binary artifacts."""
        root = self.orchestrate(
            task_data=LintianWorkflowData(
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([]),
                vendor="debian",
                codename="trixie",
            ),
            architectures=("all",),
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        lintian = workflow.children.get(task_name="lintian")

        self.assertEqual(
            lintian.task_data,
            {
                "backend": BackendType.UNSHARE,
                "environment": "debian/match:codename=trixie",
                "exclude_tags": [],
                "fail_on_severity": LintianFailOnSeverity.ERROR,
                "host_architecture": "amd64",
                "include_tags": [],
                "input": {
                    "binary_artifacts": [],
                    "source_artifact": self.source_artifact.id,
                },
                "output": {},
                "target_distribution": "debian:trixie",
            },
        )

    @preserve_task_registry()
    def test_populate_without_architectures(self) -> None:
        """Test populate with only arch-indep packages."""
        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "hello", "1.0.0", "all"
            )
        )

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([binary_artifact.id]),
                vendor="debian",
                codename="trixie",
            ),
            architectures=("all",),
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        lintian = workflow.children.get(task_name="lintian")

        self.assertEqual(
            lintian.task_data,
            {
                "backend": BackendType.UNSHARE,
                "environment": "debian/match:codename=trixie",
                "exclude_tags": [],
                "fail_on_severity": LintianFailOnSeverity.ERROR,
                "host_architecture": "amd64",
                "include_tags": [],
                "input": {
                    "binary_artifacts": [f"{binary_artifact.id}@artifacts"],
                    "source_artifact": self.source_artifact.id,
                },
                "output": {},
                "target_distribution": "debian:trixie",
            },
        )

    @preserve_task_registry()
    def test_populate_without_architectures_arch_all_host_architecture(
        self,
    ) -> None:
        """Workflow honours `arch_all_host_architecture`."""
        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "hello", "1.0.0", "all"
            )
        )

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([binary_artifact.id]),
                vendor="debian",
                codename="trixie",
                arch_all_host_architecture="s390x",
            ),
            architectures=("all",),
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        lintian = workflow.children.get(task_name="lintian")

        self.assertEqual(
            lintian.task_data,
            {
                "backend": BackendType.UNSHARE,
                "environment": "debian/match:codename=trixie",
                "exclude_tags": [],
                "fail_on_severity": LintianFailOnSeverity.ERROR,
                "host_architecture": "s390x",
                "include_tags": [],
                "input": {
                    "binary_artifacts": [f"{binary_artifact.id}@artifacts"],
                    "source_artifact": self.source_artifact.id,
                },
                "output": {},
                "target_distribution": "debian:trixie",
            },
        )

    @preserve_task_registry()
    def test_populate_mixed_without_arch_all_host_architecture(self) -> None:
        """Mixed all/any binaries but without the usual arch-all arch."""
        binary_artifacts = {
            "all": self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "hello", "1.0.0", "all"
            ),
            "i386": self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "libhello1", "1.0.0", "i386"
            ),
        }

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [binary_artifacts["all"].id, binary_artifacts["i386"].id]
                ),
                vendor="debian",
                codename="trixie",
                arch_all_host_architecture="amd64",
            ),
            architectures=("all", "i386"),
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        self.assertQuerySetEqual(
            workflow.children.filter(task_name="lintian").values_list(
                "task_data", flat=True
            ),
            [
                {
                    "backend": BackendType.UNSHARE,
                    "environment": "debian/match:codename=trixie",
                    "exclude_tags": [],
                    "fail_on_severity": LintianFailOnSeverity.ERROR,
                    "host_architecture": "i386",
                    "include_tags": [],
                    "input": {
                        "binary_artifacts": sorted(
                            [
                                f"{binary_artifacts['all'].id}@artifacts",
                                f"{binary_artifacts['i386'].id}@artifacts",
                            ]
                        ),
                        "source_artifact": self.source_artifact.id,
                    },
                    "output": {},
                    "target_distribution": "debian:trixie",
                }
            ],
        )

    @preserve_task_registry()
    def test_populate_arch_dep_without_arch_all_host_architecture(self) -> None:
        """``Architecture: any`` binaries without the usual arch-all arch."""
        binary_artifacts = {
            "i386": self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "libhello1", "1.0.0", "i386"
            ),
            "s390x": self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "libhello1", "1.0.0", "s390x"
            ),
        }

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [binary_artifacts["i386"].id, binary_artifacts["s390x"].id]
                ),
                vendor="debian",
                codename="trixie",
                arch_all_host_architecture="amd64",
            ),
            architectures=("i386", "s390x"),
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        self.assertQuerySetEqual(
            workflow.children.filter(task_name="lintian")
            .order_by("id")
            .values_list("task_data", flat=True),
            [
                {
                    "backend": BackendType.UNSHARE,
                    "environment": "debian/match:codename=trixie",
                    "exclude_tags": [],
                    "fail_on_severity": LintianFailOnSeverity.ERROR,
                    "host_architecture": architecture,
                    "include_tags": [],
                    "input": {
                        "binary_artifacts": [
                            f"{binary_artifacts[architecture].id}@artifacts"
                        ],
                        "source_artifact": self.source_artifact.id,
                    },
                    "output": (
                        {"source_analysis": False, "binary_all_analysis": False}
                        if architecture != "i386"
                        else {}
                    ),
                    "target_distribution": "debian:trixie",
                }
                for architecture in ("i386", "s390x")
            ],
        )

    @preserve_task_registry()
    def test_populate_with_no_architecture_overlap(self) -> None:
        """If architectures and binary artifacts are disjoint, do nothing."""
        root = self.orchestrate(
            task_data=LintianWorkflowData(
                architectures=["i386"],
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    ["internal@collections/name:build-amd64"]
                ),
                vendor="debian",
                codename="trixie",
            ),
            architectures=["amd64"],
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        self.assertFalse(workflow.children.filter(task_name="lintian").exists())

    @preserve_task_registry()
    def test_populate_no_binary_any_analysis(self) -> None:
        """Disabling binary-any analysis skips no-op work requests."""
        binary_artifacts = {
            "all": self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "hello", "1.0.0", "all"
            ),
            "amd64": self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "libhello1", "1.0.0", "amd64"
            ),
            "i386": self.playground.create_minimal_binary_package_artifact(
                "hello", "1.0.0", "libhello1", "1.0.0", "i386"
            ),
        }

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [
                        binary_artifacts["all"].id,
                        binary_artifacts["amd64"].id,
                        binary_artifacts["i386"].id,
                    ]
                ),
                vendor="debian",
                codename="trixie",
                arch_all_host_architecture="amd64",
                output=LintianOutput(binary_any_analysis=False),
            ),
            architectures=("all", "amd64", "i386"),
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        self.assertQuerySetEqual(
            workflow.children.filter(task_name="lintian").values_list(
                "task_data", flat=True
            ),
            [
                {
                    "backend": BackendType.UNSHARE,
                    "environment": "debian/match:codename=trixie",
                    "exclude_tags": [],
                    "fail_on_severity": LintianFailOnSeverity.ERROR,
                    "host_architecture": "amd64",
                    "include_tags": [],
                    "input": {
                        "binary_artifacts": sorted(
                            [
                                f"{binary_artifacts['all'].id}@artifacts",
                                f"{binary_artifacts['amd64'].id}@artifacts",
                            ]
                        ),
                        "source_artifact": self.source_artifact.id,
                    },
                    "output": {"binary_any_analysis": False},
                    "target_distribution": "debian:trixie",
                }
            ],
        )

    @preserve_task_registry()
    def test_populate_upload(self) -> None:
        """The workflow accepts debian:upload source artifacts."""
        architectures = ["amd64", "i386", "all"]
        upload_artifacts = self.playground.create_upload_artifacts()

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                architectures=architectures,
                source_artifact=upload_artifacts.upload.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [
                        f"internal@collections/name:build-{arch}"
                        for arch in architectures
                    ]
                ),
                vendor="debian",
                codename="trixie",
            ),
            architectures=architectures,
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        lintians = workflow.children.filter(task_name="lintian")

        # architecture "all" does not have a lintian job
        self.assertEqual(lintians.count(), len(architectures) - 1)

        for architecture in architectures:
            if architecture == "all":
                continue

            lintian = lintians.get(
                workflow_data_json__step=f"lintian-{architecture}"
            )

            self.assertEqual(
                lintian.task_data,
                {
                    "backend": Lintian.DEFAULT_BACKEND,
                    "environment": "debian/match:codename=trixie",
                    "exclude_tags": [],
                    "fail_on_severity": LintianFailOnSeverity.ERROR,
                    "host_architecture": architecture,
                    "include_tags": [],
                    "input": {
                        "binary_artifacts": sorted(
                            [
                                f"internal@collections/name:build-{arch}"
                                for arch in (architecture, "all")
                            ]
                        ),
                        "source_artifact": (
                            f"{upload_artifacts.source.id}@artifacts"
                        ),
                    },
                    "output": (
                        {"source_analysis": False, "binary_all_analysis": False}
                        if architecture != "amd64"
                        else {}
                    ),
                    "target_distribution": "debian:trixie",
                },
            )

            self.assertEqual(
                lintian.workflow_data_json,
                {
                    "display_name": f"Lintian for {architecture}",
                    "step": f"lintian-{architecture}",
                },
            )

            self.assertQuerySetEqual(
                lintian.dependencies.all(),
                set(
                    WorkRequest.objects.filter(
                        task_name="sbuild",
                        task_data__host_architecture__in=(architecture, "all"),
                    )
                ),
                ordered=False,
            )

    @preserve_task_registry()
    def test_populate_has_current_reference_qa_result(self) -> None:
        """The workflow does nothing with a current reference QA result."""
        trixie_suite = self.playground.create_collection(
            "trixie", CollectionCategory.SUITE
        )
        trixie_qa_results = self.playground.create_collection(
            "trixie", CollectionCategory.QA_RESULTS
        )
        self.add_lintian_binary(trixie_suite, "2.116.3")

        architectures = ["amd64", "i386", "all"]
        for architecture in architectures:
            self.add_qa_result(
                trixie_qa_results,
                "hello",
                self.source_artifact.data["version"],
                architecture,
                "2.116.3",
            )

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                prefix="reference-qa-result|",
                architectures=architectures,
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [
                        f"internal@collections/name:build-{arch}"
                        for arch in architectures
                    ]
                ),
                vendor="debian",
                codename="trixie",
                qa_suite=f"trixie@{CollectionCategory.SUITE}",
                reference_qa_results=f"trixie@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
            architectures=architectures,
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        self.assertQuerySetEqual(workflow.children.all(), [])

    @preserve_task_registry()
    def test_populate_no_previous_reference_qa_result(self) -> None:
        """The workflow produces a reference QA result if there is none."""
        trixie_suite = self.playground.create_collection(
            "trixie", CollectionCategory.SUITE
        )
        self.playground.create_collection(
            "trixie", CollectionCategory.QA_RESULTS
        )
        self.add_lintian_binary(trixie_suite, "2.116.3")

        architectures = ["amd64", "i386", "all"]

        source_item = trixie_suite.manager.add_artifact(
            self.source_artifact,
            user=self.playground.get_default_user(),
            variables={"component": "main", "section": "devel"},
        )

        root = self.orchestrate(
            task_data=LintianWorkflowData(
                prefix="reference-qa-result|",
                architectures=architectures,
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [
                        f"internal@collections/name:build-{arch}"
                        for arch in architectures
                    ]
                ),
                vendor="debian",
                codename="trixie",
                qa_suite=f"trixie@{CollectionCategory.SUITE}",
                reference_qa_results=f"trixie@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
            architectures=architectures,
        )

        workflow = WorkRequest.objects.get(
            task_type=TaskTypes.WORKFLOW,
            task_name="lintian",
            parent=root,
        )

        lintians = workflow.children.filter(task_name="lintian")

        # architecture "all" does not have a lintian job
        self.assertEqual(lintians.count(), len(architectures) - 1)

        results: dict[str, Artifact] = {}
        for architecture in architectures:
            if architecture == "all":
                continue

            lintian = lintians.get(
                workflow_data_json__step=f"lintian-{architecture}"
            )

            self.assertEqual(
                lintian.task_data,
                {
                    "backend": Lintian.DEFAULT_BACKEND,
                    "environment": "debian/match:codename=trixie",
                    "exclude_tags": [],
                    "fail_on_severity": LintianFailOnSeverity.ERROR,
                    "host_architecture": architecture,
                    "include_tags": [],
                    "input": {
                        "binary_artifacts": sorted(
                            [
                                f"internal@collections/name:build-{arch}"
                                for arch in (architecture, "all")
                            ]
                        ),
                        "source_artifact": self.source_artifact.id,
                    },
                    "output": (
                        {"source_analysis": False, "binary_all_analysis": False}
                        if architecture != "amd64"
                        else {}
                    ),
                    "target_distribution": "debian:trixie",
                },
            )

            self.assertEqual(
                lintian.workflow_data_json,
                {
                    "allow_failure": True,
                    "display_name": f"Lintian for {architecture}",
                    "step": f"lintian-{architecture}",
                },
            )

            self.assertQuerySetEqual(
                lintian.dependencies.all(),
                set(
                    WorkRequest.objects.filter(
                        task_name="sbuild",
                        task_data__host_architecture__in=(architecture, "all"),
                    )
                ),
                ordered=False,
            )

            qa_result_action = ActionUpdateCollectionWithArtifacts(
                collection=f"trixie@{CollectionCategory.QA_RESULTS}",
                variables={
                    "package": "hello",
                    "version": self.source_artifact.data["version"],
                    "$architecture": "architecture",
                    "timestamp": int(source_item.created_at.timestamp()),
                    "work_request_id": lintian.id,
                },
                artifact_filters={"category": ArtifactCategory.LINTIAN},
            )
            self.assert_work_request_event_reactions(
                lintian,
                on_assignment=[
                    ActionSkipIfLookupResultChanged(
                        lookup=(
                            f"trixie@{CollectionCategory.QA_RESULTS}/"
                            f"latest:lintian_hello_{architecture}"
                        ),
                        collection_item_id=None,
                        promise_name=(
                            f"reference-qa-result|lintian-{architecture}"
                        ),
                    )
                ],
                on_failure=[qa_result_action],
                on_success=[
                    ActionUpdateCollectionWithArtifacts(
                        collection="internal@collections",
                        name_template=(
                            f"reference-qa-result|lintian-{architecture}"
                        ),
                        artifact_filters={"category": ArtifactCategory.LINTIAN},
                    ),
                    qa_result_action,
                ],
            )

            # Completing the work request stores the QA result.
            artifact_architectures = (
                ["source", "all", architecture]
                if architecture == "amd64"
                else [architecture]
            )
            for artifact_architecture in artifact_architectures:
                results[artifact_architecture] = (
                    self.playground.create_artifact(
                        category=ArtifactCategory.LINTIAN,
                        data={"architecture": artifact_architecture},
                        work_request=lintian,
                    )[0]
                )
            self.playground.advance_work_request(
                lintian, mark_pending=True, result=WorkRequest.Results.SUCCESS
            )

        for architecture, result in results.items():
            self.assertEqual(
                lookup_single(
                    f"trixie@{CollectionCategory.QA_RESULTS}/"
                    f"latest:lintian_hello_{architecture}",
                    lintian.workspace,
                    user=lintian.created_by,
                    expect_type=LookupChildType.ARTIFACT,
                ).artifact,
                result,
            )

    def test_populate_reference_qa_result_backs_off(self) -> None:
        """Reference tasks are skipped if another workflow got there first."""
        trixie_suite = self.playground.create_collection(
            "trixie", CollectionCategory.SUITE
        )
        trixie_qa_results = self.playground.create_collection(
            "trixie", CollectionCategory.QA_RESULTS
        )
        self.add_lintian_binary(trixie_suite, "2.116.3")
        self.playground.create_debian_environment(
            codename="trixie", variant="lintian"
        )

        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-1"
        )
        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello",
                srcpkg_version="1.0-1",
                architecture="amd64",
            )
        )
        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([binary_artifact.id]),
                vendor="debian",
                codename="trixie",
                qa_suite=f"trixie@{CollectionCategory.SUITE}",
                reference_qa_results=f"trixie@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
            ),
        )

        self.get_workflow(wr).populate()

        racing_qa_result = self.add_qa_result(
            trixie_qa_results, "hello", "1.0-1", "amd64", "2.116.3"
        )
        [child] = wr.children.all()
        self.playground.advance_work_request(child, mark_pending=True)

        with self.assertRaises(SkipWorkRequest):
            child.assign_worker(self.playground.create_worker())

        self.assertEqual(child.status, WorkRequest.Statuses.COMPLETED)
        self.assertEqual(child.result, WorkRequest.Results.SKIPPED)
        self.assertEqual(
            child.output_data,
            OutputData(
                skip_reason=(
                    f"Result of lookup "
                    f"'trixie@{CollectionCategory.QA_RESULTS}/"
                    f"latest:lintian_hello_amd64' changed"
                )
            ),
        )
        self.assertEqual(
            lookup_single(
                f"trixie@{CollectionCategory.QA_RESULTS}/"
                f"latest:lintian_hello_amd64",
                child.workspace,
                user=child.created_by,
                expect_type=LookupChildType.ARTIFACT,
            ).collection_item,
            racing_qa_result,
        )

    @preserve_task_registry()
    def test_orchestrate_idempotent(self) -> None:
        """Calling orchestrate twice does not create new work requests."""
        binary = self.playground.create_minimal_binary_package_artifact(
            "hello", "1.0.0", "hello", "1.0.0", "all"
        )

        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([binary.id]),
                vendor="debian",
                codename="trixie",
            ),
        )

        self.get_workflow(wr).populate()

        children = set(wr.children.all())

        self.get_workflow(wr).populate()

        self.assertQuerySetEqual(wr.children.all(), children, ordered=False)

    def test_compute_dynamic_data(self) -> None:
        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-1"
        )
        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact()
        )

        wr = self.playground.create_workflow(
            task_name="lintian",
            task_data=LintianWorkflowData(
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj([binary_artifact.id]),
                vendor="debian",
                codename="trixie",
            ),
        )
        workflow = self.get_workflow(wr)

        self.assertEqual(
            workflow.compute_dynamic_data(TaskDatabase(wr)),
            BaseDynamicTaskData(
                subject="hello", parameter_summary="hello_1.0-1"
            ),
        )

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=True)
    @preserve_task_registry()
    def test_callback_regression_analysis(self) -> None:
        self.playground.create_collection("sid", CollectionCategory.SUITE)
        self.playground.create_collection("sid", CollectionCategory.QA_RESULTS)
        architectures = ["amd64", "i386", "ppc64el", "s390x"]
        reference_source_artifact = self.playground.create_source_artifact(
            name="hello", version="0.9-1"
        )

        root = self.playground.create_workflow(mark_running=True)
        reference = self.orchestrate(
            task_data=LintianWorkflowData(
                prefix="reference-qa-result|",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=True,
                architectures=architectures,
                source_artifact=reference_source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [
                        f"internal@collections/name:build-{arch}"
                        for arch in ("all", *architectures)
                        if arch != "ppc64el"
                    ]
                ),
                vendor="debian",
                codename="sid",
            ),
            architectures=["all", *architectures],
            parent=root,
            pipeline_task_name="referencepipeline",
        )
        new = self.orchestrate(
            task_data=LintianWorkflowData(
                reference_prefix="reference-qa-result|",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                enable_regression_tracking=True,
                architectures=architectures,
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple.parse_obj(
                    [
                        f"internal@collections/name:build-{arch}"
                        for arch in ("all", *architectures)
                    ]
                ),
                vendor="debian",
                codename="sid",
            ),
            architectures=["all", *architectures],
            parent=root,
        )

        # Unblock the lintian tasks.
        for sbuild in reference.children.get(
            task_type=TaskTypes.WORKFLOW, task_name="sbuild"
        ).children.filter(task_type=TaskTypes.WORKER, task_name="sbuild"):
            self.playground.advance_work_request(
                sbuild, result=WorkRequest.Results.SUCCESS
            )
        for sbuild in new.children.get(
            task_type=TaskTypes.WORKFLOW, task_name="sbuild"
        ).children.filter(task_type=TaskTypes.WORKER, task_name="sbuild"):
            self.playground.advance_work_request(
                sbuild, result=WorkRequest.Results.SUCCESS
            )

        reference_lintian_workflow = reference.children.get(
            task_type=TaskTypes.WORKFLOW, task_name="lintian"
        )
        reference_tasks = {
            wr.task_data["host_architecture"]: wr
            for wr in reference_lintian_workflow.children.filter(
                task_type=TaskTypes.WORKER, task_name="lintian"
            )
        }
        reference_results = {
            architecture: self.playground.create_artifact(
                category=ArtifactCategory.LINTIAN,
                data={
                    "architecture": architecture,
                    "summary": {
                        "tags_count_by_severity": tags_count_by_severity,
                        "tags_found": tags_found,
                    },
                },
                workspace=reference.workspace,
                work_request=reference_tasks[
                    "amd64" if architecture == "all" else architecture
                ],
            )[0]
            for architecture, tags_count_by_severity, tags_found in (
                ("all", {"warning": 1, "error": 0}, ["warning-1"]),
                (
                    "amd64",
                    {"warning": 0, "error": 0},
                    [],
                ),
                (
                    "i386",
                    {"warning": 2, "error": 1},
                    ["warning-1", "warning-2", "error-1"],
                ),
                ("s390x", {"warning": 1, "error": 1}, ["warning-1", "error-1"]),
            )
        }
        for artifact in reference_results.values():
            self.playground.create_artifact_relation(
                artifact,
                reference_source_artifact,
                ArtifactRelation.Relations.RELATES_TO,
            )
        new_lintian_workflow = new.children.get(
            task_type=TaskTypes.WORKFLOW, task_name="lintian"
        )
        new_tasks = {
            wr.task_data["host_architecture"]: wr
            for wr in new_lintian_workflow.children.filter(
                task_type=TaskTypes.WORKER, task_name="lintian"
            )
        }
        new_results = {
            architecture: self.playground.create_artifact(
                category=ArtifactCategory.LINTIAN,
                data={
                    "architecture": architecture,
                    "summary": {
                        "tags_count_by_severity": tags_count_by_severity,
                        "tags_found": tags_found,
                    },
                },
                workspace=new.workspace,
                work_request=new_tasks[
                    "amd64" if architecture == "all" else architecture
                ],
            )[0]
            for architecture, tags_count_by_severity, tags_found in (
                ("all", {"warning": 1, "error": 0}, ["warning-1"]),
                (
                    "amd64",
                    {"warning": 0, "error": 0},
                    [],
                ),
                (
                    "i386",
                    {"warning": 1, "error": 2},
                    ["warning-1", "error-1", "error-2"],
                ),
                ("ppc64el", {"warning": 1, "error": 0}, ["warning-1"]),
                ("s390x", {"warning": 1, "error": 0}, ["warning-1"]),
            )
        }
        for artifact in new_results.values():
            self.playground.create_artifact_relation(
                artifact,
                self.source_artifact,
                ArtifactRelation.Relations.RELATES_TO,
            )

        self.assertIsNone(new_lintian_workflow.output_data)
        self.assertEqual(schedule(), [])
        self.playground.advance_work_request(
            reference_tasks["amd64"], result=WorkRequest.Results.SUCCESS
        )
        self.assertEqual(schedule(), [])
        self.playground.advance_work_request(
            new_tasks["amd64"], result=WorkRequest.Results.SUCCESS
        )

        self.schedule_and_run_workflow_callback(new_lintian_workflow)

        expected_analysis: list[
            tuple[str, RegressionAnalysisStatus, dict[str, list[str]]]
        ] = [
            (
                "all",
                RegressionAnalysisStatus.STABLE,
                {"added_tags": [], "removed_tags": []},
            ),
            (
                "amd64",
                RegressionAnalysisStatus.STABLE,
                {"added_tags": [], "removed_tags": []},
            ),
        ]
        new_lintian_workflow.refresh_from_db()
        self.assert_regression_analysis(
            new_lintian_workflow,
            "0.9-1",
            reference_results,
            "1.0-1",
            new_results,
            expected_analysis,
        )

        expected_callback_statuses = {
            "amd64": WorkRequest.Statuses.COMPLETED,
            "i386": WorkRequest.Statuses.BLOCKED,
            "ppc64el": WorkRequest.Statuses.BLOCKED,
            "s390x": WorkRequest.Statuses.BLOCKED,
        }
        self.assert_callback_statuses(
            new_lintian_workflow, expected_callback_statuses
        )

        for architecture, reference_result, new_result, status, details in (
            (
                "i386",
                WorkRequest.Results.FAILURE,
                WorkRequest.Results.FAILURE,
                RegressionAnalysisStatus.REGRESSION,
                {"added_tags": ["error-2"], "removed_tags": ["warning-2"]},
            ),
            (
                "ppc64el",
                None,
                WorkRequest.Results.SUCCESS,
                RegressionAnalysisStatus.NO_RESULT,
                {"added_tags": ["warning-1"], "removed_tags": []},
            ),
            (
                "s390x",
                WorkRequest.Results.FAILURE,
                WorkRequest.Results.SUCCESS,
                RegressionAnalysisStatus.IMPROVEMENT,
                {"added_tags": [], "removed_tags": ["error-1"]},
            ),
        ):
            if reference_result is not None:
                self.playground.advance_work_request(
                    reference_tasks[architecture], result=reference_result
                )
            self.playground.advance_work_request(
                new_tasks[architecture], result=new_result
            )
            expected_analysis.append((architecture, status, details))
            expected_callback_statuses[architecture] = (
                WorkRequest.Statuses.PENDING
            )

        self.assert_callback_statuses(
            new_lintian_workflow, expected_callback_statuses
        )

        # Running any one of the pending callbacks is enough, but in fact
        # one is scheduled for each architecture and the scheduler will
        # catch up with them over multiple runs.  Simulate this and check
        # that the result is correct at each stage.
        for architecture in ("i386", "ppc64el", "s390x"):
            self.schedule_and_run_workflow_callback(new_lintian_workflow)

            new_lintian_workflow.refresh_from_db()
            self.assert_regression_analysis(
                new_lintian_workflow,
                "0.9-1",
                reference_results,
                "1.0-1",
                new_results,
                expected_analysis,
            )
            expected_callback_statuses[architecture] = (
                WorkRequest.Statuses.COMPLETED
            )
            self.assert_callback_statuses(
                new_lintian_workflow, expected_callback_statuses
            )
