# 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 piuparts workflow."""

import datetime as dt
from collections.abc import Sequence
from typing import ClassVar
from unittest import mock

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

from debusine.artifacts.models import (
    ArtifactCategory,
    CollectionCategory,
    DebianPiuparts,
    DebianSourcePackage,
    TaskTypes,
)
from debusine.client.models import LookupChildType
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    Collection,
    CollectionItem,
    WorkRequest,
)
from debusine.db.models.work_requests import SkipWorkRequest
from debusine.server.workflows import WorkflowValidationError
from debusine.server.workflows.base import orchestrate_workflow
from debusine.server.workflows.models import (
    BaseWorkflowData,
    PiupartsWorkflowData,
    PiupartsWorkflowDynamicData,
    RegressionTrackingUpdateQAResults,
    SbuildWorkflowData,
    WorkRequestWorkflowData,
)
from debusine.server.workflows.piuparts import PiupartsWorkflow
from debusine.server.workflows.tests.helpers import (
    SampleWorkflow,
    WorkflowTestCase,
)
from debusine.server.workflows.tests.test_regression_tracking import (
    RegressionTrackingWorkflowTestMixin,
)
from debusine.tasks.models import (
    ActionSkipIfLookupResultChanged,
    ActionUpdateCollectionWithArtifacts,
    BackendType,
    BaseDynamicTaskData,
    InputArtifactMultiple,
    InputArtifactSingle,
    LookupMultiple,
    OutputData,
    RegressionAnalysisStatus,
    SbuildData,
    SbuildInput,
)
from debusine.test.django import preserve_db_task_registry


class PiupartsWorkflowTests(
    RegressionTrackingWorkflowTestMixin, WorkflowTestCase[PiupartsWorkflow]
):
    """Unit tests for :py:class:`PiupartsWorkflow`."""

    SAMPLE_TASK_DATA = {
        "vendor": "debian",
        "codename": "trixie",
    }

    source_artifact: ClassVar[Artifact]
    binary_artifact1: ClassVar[Artifact]
    binary_artifact2: ClassVar[Artifact]
    binary_artifact3: 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"
        )
        cls.binary_artifact1 = (
            cls.playground.create_minimal_binary_package_artifact("hello1")
        )
        cls.binary_artifact2 = (
            cls.playground.create_minimal_binary_package_artifact("hello2")
        )
        cls.binary_artifact3 = (
            cls.playground.create_minimal_binary_package_artifact("hello3")
        )

    def setUp(self) -> None:
        super().setUp()
        self.task_data["source_artifact"] = self.source_artifact.pk
        self.task_data["binary_artifacts"] = [
            self.binary_artifact1.pk,
            self.binary_artifact2.pk,
            self.binary_artifact3.pk,
        ]

    def add_qa_result(
        self,
        qa_results: Collection,
        package: str,
        version: str,
        architecture: str,
        *,
        binary_packages: dict[str, str],
        distribution: str,
    ) -> CollectionItem:
        """Add a piuparts result to a ``debian:qa-results`` collection."""
        work_request = self.playground.create_worker_task(
            task_name="piuparts",
            result=WorkRequest.Results.SUCCESS,
            validate=False,
            use_data=False,
        )
        return qa_results.manager.add_artifact(
            self.playground.create_artifact(
                category=ArtifactCategory.PIUPARTS,
                data=DebianPiuparts(
                    piuparts_version="1.6.0",
                    binary_packages=binary_packages,
                    architecture=architecture,
                    distribution=distribution,
                ),
            )[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 orchestrate(
        self,
        *,
        task_data: PiupartsWorkflowData,
        source_artifact: Artifact,
        architectures: Sequence[str],
        parent: WorkRequest | None = None,
        pipeline_task_name: str = "examplepipeline",
    ) -> WorkRequest:
        """Create and orchestrate a PiupartsWorkflow."""

        class ExamplePipeline(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """
            Pipeline workflow that provides a promise (e.g. by Sbuild).

            Piuparts will use the promise.
            """

            TASK_NAME = pipeline_task_name

            def populate(self_) -> None:
                """Populate the pipeline."""
                source_data = source_artifact.create_data()
                assert isinstance(source_data, DebianSourcePackage)

                sbuild, _ = self_.work_request_ensure_child_workflow(
                    task_name="sbuild",
                    task_data=SbuildWorkflowData(
                        input=SbuildInput(source_artifact=source_artifact.id),
                        target_distribution="debian:sid",
                        architectures=list(architectures),
                    ),
                    workflow_data=WorkRequestWorkflowData(
                        display_name="sbuild", step="sbuild"
                    ),
                )
                self.playground.advance_work_request(sbuild, mark_running=True)
                for architecture in architectures:
                    child = sbuild.create_child_worker(
                        task_name="sbuild",
                        task_data=SbuildData(
                            input=SbuildInput(
                                source_artifact=source_artifact.id
                            ),
                            build_architecture=architecture,
                            environment="debian/match:codename=sid",
                        ),
                    )
                    self_.provides_artifact(
                        child,
                        ArtifactCategory.BINARY_PACKAGE,
                        f"build-{architecture}",
                        data={
                            "source_package_name": source_data.name,
                            "architecture": architecture,
                        },
                    )
                sbuild.unblock_workflow_children()

                piuparts, _ = self_.work_request_ensure_child_workflow(
                    task_name="piuparts",
                    task_data=task_data,
                    workflow_data=WorkRequestWorkflowData(
                        display_name="piuparts", step="piuparts"
                    ),
                )
                self_.orchestrate_child(piuparts)

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

        return root

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

    def test_validate_input_bad_qa_suite(self) -> None:
        """validate_input raises errors in looking up a suite."""
        self.task_data["qa_suite"] = "nonexistent@debian:suite"
        self.validate = False
        with self.assertRaisesRegex(
            WorkflowValidationError,
            "'nonexistent@debian:suite' does not exist or is hidden",
        ):
            self.task.validate_input()

    def test_validate_input_bad_reference_qa_results(self) -> None:
        """validate_input raises errors in looking up reference QA results."""
        self.task_data["reference_qa_results"] = "nonexistent@debian:qa-results"
        self.validate = False
        with self.assertRaisesRegex(
            WorkflowValidationError,
            "'nonexistent@debian:qa-results' does not exist or is hidden",
        ):
            self.task.validate_input()

    def test_has_current_reference_qa_result_no_match(self) -> None:
        """_has_current_reference_qa_result: no matching result."""
        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,
            "other",
            "1.0-1",
            "amd64",
            binary_packages={"other": "1.0-1"},
            distribution="debian:sid",
        )
        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello", srcpkg_version="1.0-1"
            )
        )

        wr = self.playground.create_workflow(
            task_name="piuparts",
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple((binary_artifact.id,)),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
            ),
        )

        self.assertFalse(
            self.get_workflow(wr)._has_current_reference_qa_result("amd64")
        )

    def test_has_current_reference_qa_result_different_version(self) -> None:
        """_has_current_reference_qa_result: result for different 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",
            binary_packages={"hello": "1.0-1"},
            distribution="debian:sid",
        )
        source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-2"
        )
        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello", srcpkg_version="1.0-2"
            )
        )

        wr = self.playground.create_workflow(
            task_name="piuparts",
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=source_artifact.id,
                binary_artifacts=LookupMultiple((binary_artifact.id,)),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
            ),
        )

        self.assertFalse(
            self.get_workflow(wr)._has_current_reference_qa_result("amd64")
        )

    def test_has_current_reference_qa_result_current(self) -> None:
        """_has_current_reference_qa_result: current result."""
        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",
            binary_packages={"hello": "1.0-1"},
            distribution="debian:sid",
        )
        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello", srcpkg_version="1.0-1"
            )
        )

        wr = self.playground.create_workflow(
            task_name="piuparts",
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple((binary_artifact.id,)),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
            ),
        )

        self.assertTrue(
            self.get_workflow(wr)._has_current_reference_qa_result("amd64")
        )

    def _create_package_artifacts(self) -> tuple[Artifact, dict[str, Artifact]]:
        """Return source artifact and dictionary with binary artifacts."""
        return (
            self.playground.create_source_artifact(
                name="hello", version="1.0.0"
            ),
            {
                "all": self.playground.create_minimal_binary_package_artifact(
                    architecture="all"
                ),
                "amd64": self.playground.create_minimal_binary_package_artifact(
                    architecture="amd64"
                ),
                "i386": self.playground.create_minimal_binary_package_artifact(
                    architecture="i386"
                ),
            },
        )

    def _create_debian_environments(
        self, *, codename: str, architectures: list[str]
    ) -> None:
        for architecture in architectures:
            self.playground.create_debian_environment(
                architecture=architecture,
                codename=codename,
            )

    @preserve_db_task_registry()
    def test_populate(self) -> None:
        """Test populate."""
        # Expected architectures as per intersection of architectures of
        # binary_artifacts and requested architectures
        self._create_debian_environments(
            codename="bookworm", architectures=["amd64", "i386"]
        )
        source, binaries = self._create_package_artifacts()

        o = self.playground.create_workflow(
            task_name="piuparts",
            task_data={
                "source_artifact": source.id,
                "binary_artifacts": [
                    binaries["all"].id,
                    binaries["amd64"].id,
                    binaries["i386"].id,
                ],
                "vendor": "debian",
                "codename": "bookworm",
                "extra_repositories": [
                    {
                        "url": "http://example.com/",
                        "suite": "bookworm",
                        "components": ["main"],
                    }
                ],
            },
        )
        self.get_workflow(o).populate()

        piuparts_wrs = WorkRequest.objects.filter(
            task_type=TaskTypes.WORKER,
            task_name="piuparts",
            parent=o,
        )
        self.assertEqual(piuparts_wrs.count(), 2)

        for architecture in ("amd64", "i386"):
            with self.subTest(architecture=architecture):
                piuparts_wr = piuparts_wrs.get(
                    task_data__input__binary_artifacts__contains=(
                        binaries["amd64"].id
                    )
                )
                self.assertEqual(
                    piuparts_wr.task_data,
                    {
                        "backend": BackendType.UNSHARE,
                        "input": {
                            "binary_artifacts": sorted(
                                [binaries["all"].id, binaries["amd64"].id]
                            )
                        },
                        "build_architecture": "amd64",
                        "base_tgz": "debian/match:codename=bookworm",
                        "environment": "debian/match:codename=bookworm",
                        "extra_repositories": [
                            {
                                "url": "http://example.com/",
                                "suite": "bookworm",
                                "components": ["main"],
                            }
                        ],
                    },
                )

                self.assert_work_request_event_reactions(
                    piuparts_wr,
                    on_success=[
                        ActionUpdateCollectionWithArtifacts(
                            artifact_filters={
                                "category": ArtifactCategory.PIUPARTS
                            },
                            collection="internal@collections",
                            name_template="piuparts-amd64",
                        )
                    ],
                )

    def test_populate_architecture_only_i386(self) -> None:
        """Test "architectures=["i386"]: do not build on amd64."""
        self._create_debian_environments(
            codename="trixie", architectures=["amd64", "i386"]
        )
        source, binaries = self._create_package_artifacts()

        o = self.playground.create_workflow(
            task_name="piuparts",
            task_data={
                "source_artifact": source.id,
                "binary_artifacts": [
                    binaries["all"].id,
                    binaries["amd64"].id,
                    binaries["i386"].id,
                ],
                "vendor": "debian",
                "codename": "trixie",
                "architectures": ["i386"],
            },
        )
        self.get_workflow(o).populate()

        piuparts_wrs = WorkRequest.objects.filter(
            task_type=TaskTypes.WORKER,
            task_name="piuparts",
            parent=o,
        )

        self.assertEqual(piuparts_wrs.count(), 1)

        piuparts_i386_wr = piuparts_wrs.get(
            task_data__input__binary_artifacts__contains=(
                binaries["all"].id,
                binaries["i386"].id,
            ),
            task_data__build_architecture="i386",
        )
        self.assertEqual(
            piuparts_i386_wr.task_data,
            {
                "backend": BackendType.UNSHARE,
                "input": {
                    "binary_artifacts": [
                        binaries["all"].id,
                        binaries["i386"].id,
                    ]
                },
                "build_architecture": "i386",
                "base_tgz": "debian/match:codename=trixie",
                "environment": "debian/match:codename=trixie",
                "extra_repositories": None,
            },
        )

    def test_populate_experimental(self) -> None:
        """The workflow handles overlay distributions."""
        self.playground.create_debian_environment(
            codename="experimental", variant="piuparts"
        )
        self.task_data["binary_artifacts"] = [self.binary_artifact1.pk]
        self.task_data["codename"] = "experimental"
        self.task.populate()
        children = list(self.work_request.children.all())
        self.assertEqual(len(children), 1)
        child = children[0]
        self.assertEqual(len(child.task_data["extra_repositories"]), 1)
        repo = child.task_data["extra_repositories"][0]
        self.assertEqual(repo["suite"], "experimental")

    def test_populate_uploads(self) -> None:
        """The workflow accepts debian:upload artifacts in all inputs."""
        artifacts = self.playground.create_upload_artifacts(
            binaries=[
                ("hello", "amd64"),
                ("hello", "i386"),
                ("hello-doc", "all"),
            ]
        )
        architectures = ["amd64", "i386"]
        codename = "sid"

        for architecture in architectures:
            self.playground.create_debian_environment(
                codename=codename,
                architecture=architecture,
            )

        wr = self.playground.create_workflow(
            task_name="piuparts",
            task_data=PiupartsWorkflowData(
                source_artifact=artifacts.upload.id,
                binary_artifacts=LookupMultiple((artifacts.upload.id,)),
                architectures=architectures,
                vendor="debian",
                codename=codename,
            ),
        )

        self.get_workflow(wr).populate()
        children = list(wr.children.order_by("id").all())
        self.assertEqual(len(children), len(architectures))
        for architecture, child in zip(architectures, children):
            with self.subTest(architecture=architecture):
                self.assertEqual(
                    child.task_data["build_architecture"], architecture
                )
                self.assertEqual(
                    child.task_data["input"],
                    {
                        "binary_artifacts": sorted(
                            f"{artifact.id}@artifacts"
                            for artifact in artifacts.binaries
                            if artifact.data["deb_fields"]["Architecture"]
                            in (architecture, "all")
                        ),
                    },
                )

    def test_populate_has_current_reference_qa_result(self) -> None:
        """The workflow does nothing with a current reference QA result."""
        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",
            binary_packages={"hello": "1.0-1"},
            distribution="debian:sid",
        )

        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="piuparts",
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple((binary_artifact.id,)),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
            ),
        )

        self.get_workflow(wr).populate()

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

    def test_populate_has_current_reference_qa_result_force(self) -> None:
        """The workflow can be forced despite a current reference QA result."""
        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",
            binary_packages={"hello": "1.0-1"},
            distribution="debian:sid",
        )

        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="piuparts",
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple((binary_artifact.id,)),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.FORCE,
            ),
        )

        self.get_workflow(wr).populate()

        self.assertTrue(
            wr.children.filter(
                task_type=TaskTypes.WORKER, task_name="piuparts"
            ).exists()
        )

    def test_populate_no_previous_reference_qa_result(self) -> None:
        """The workflow produces a reference QA result if there is none."""
        sid_suite = self.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        self.playground.create_collection("sid", CollectionCategory.QA_RESULTS)

        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello",
                srcpkg_version="1.0-1",
                architecture="amd64",
            )
        )
        source_item = sid_suite.manager.add_artifact(
            self.source_artifact,
            user=self.playground.get_default_user(),
            variables={"component": "main", "section": "devel"},
        )
        wr = self.playground.create_workflow(
            task_name="piuparts",
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple((binary_artifact.id,)),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
            ),
        )

        self.get_workflow(wr).populate()

        [child] = wr.children.all()
        self.assertEqual(child.status, WorkRequest.Statuses.BLOCKED)
        self.assertEqual(child.task_type, TaskTypes.WORKER)
        self.assertEqual(child.task_name, "piuparts")
        self.assertEqual(
            child.task_data,
            {
                "backend": BackendType.UNSHARE,
                "base_tgz": "debian/match:codename=sid",
                "build_architecture": "amd64",
                "environment": "debian/match:codename=sid",
                "extra_repositories": None,
                "input": {"binary_artifacts": [binary_artifact.id]},
            },
        )
        provide_action = ActionUpdateCollectionWithArtifacts(
            artifact_filters={"category": ArtifactCategory.PIUPARTS},
            collection="internal@collections",
            name_template="reference-qa-result|piuparts-amd64",
        )
        skip_action = ActionSkipIfLookupResultChanged(
            lookup=(
                f"sid@{CollectionCategory.QA_RESULTS}/"
                f"latest:piuparts_hello_amd64"
            ),
            collection_item_id=None,
            promise_name=None,
        )
        qa_result_action = ActionUpdateCollectionWithArtifacts(
            artifact_filters={"category": ArtifactCategory.PIUPARTS},
            collection=f"sid@{CollectionCategory.QA_RESULTS}",
            variables={
                "package": "hello",
                "version": "1.0-1",
                "architecture": "amd64",
                "timestamp": int(source_item.created_at.timestamp()),
                "work_request_id": child.id,
            },
        )
        self.assert_work_request_event_reactions(
            child,
            on_assignment=[skip_action],
            on_failure=[qa_result_action],
            on_success=[provide_action, qa_result_action],
        )

        # Completing the work request stores the QA result.
        result, _ = self.playground.create_artifact(
            category=ArtifactCategory.PIUPARTS,
            data=DebianPiuparts(
                piuparts_version="1.6.0",
                binary_packages={"hello": "1.0-1"},
                architecture="amd64",
                distribution="debian:sid",
            ),
            work_request=child,
        )
        self.playground.advance_work_request(
            child, result=WorkRequest.Results.SUCCESS, use_data=False
        )
        self.assertEqual(
            child.lookup_single(
                f"sid@{CollectionCategory.QA_RESULTS}/"
                f"latest:piuparts_hello_amd64",
                expect_type=LookupChildType.ARTIFACT,
            ).artifact,
            result,
        )

        # Population is idempotent, even if qa_suite changes.
        with (
            mock.patch.object(
                PiupartsWorkflow,
                "_has_current_reference_qa_result",
                return_value=False,
            ),
            mock.patch.object(
                PiupartsWorkflow,
                "qa_suite_changed",
                mock.PropertyMock(
                    return_value=timezone.now() + dt.timedelta(hours=1)
                ),
            ),
        ):
            self.get_workflow(wr).populate()
        self.assertEqual(wr.children.count(), 1)
        child.refresh_from_db()
        self.assert_work_request_event_reactions(
            child,
            on_assignment=[skip_action],
            on_failure=[qa_result_action],
            on_success=[provide_action, qa_result_action],
        )

    def test_populate_reference_qa_result_backs_off(self) -> None:
        """Reference tasks are skipped if another workflow got there first."""
        self.playground.create_collection("sid", CollectionCategory.SUITE)
        sid_qa_results = self.playground.create_collection(
            "sid", CollectionCategory.QA_RESULTS
        )
        self.playground.create_debian_environment(
            codename="sid", variant="piuparts"
        )

        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="piuparts",
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple((binary_artifact.id,)),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
            ),
        )

        self.get_workflow(wr).populate()

        racing_qa_result = self.add_qa_result(
            sid_qa_results,
            "hello",
            "1.0-1",
            "amd64",
            binary_packages={"hello": "1.0-1"},
            distribution="debian:sid",
        )
        [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"'sid@{CollectionCategory.QA_RESULTS}/"
                    f"latest:piuparts_hello_amd64' changed"
                )
            ),
        )
        self.assertEqual(
            child.lookup_single(
                f"sid@{CollectionCategory.QA_RESULTS}/"
                f"latest:piuparts_hello_amd64",
                expect_type=LookupChildType.ARTIFACT,
            ).collection_item,
            racing_qa_result,
        )

    def test_populate_reference_qa_result_uses_build_architecture(self) -> None:
        sid_suite = self.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        sid_qa_results = self.playground.create_collection(
            "sid", CollectionCategory.QA_RESULTS
        )
        # Add a spurious QA result for "all" to make sure it isn't considered.
        self.add_qa_result(
            sid_qa_results,
            "hello",
            "1.0-1",
            "all",
            binary_packages={"hello": "1.0-1"},
            distribution="debian:sid",
        )

        binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello",
                srcpkg_version="1.0-1",
                architecture="all",
            )
        )
        # At least one debian_environment must exist to create the
        # Piuparts work request for the "all" package
        self.playground.create_debian_environment(
            architecture="amd64",
            codename="sid",
        )

        source_item = sid_suite.manager.add_artifact(
            self.source_artifact,
            user=self.playground.get_default_user(),
            variables={"component": "main", "section": "devel"},
        )
        wr = self.playground.create_workflow(
            task_name="piuparts",
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                source_artifact=self.source_artifact.id,
                binary_artifacts=LookupMultiple((binary_artifact.id,)),
                vendor="debian",
                codename="sid",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
            ),
        )

        self.get_workflow(wr).populate()

        [child] = wr.children.all()
        self.assertEqual(child.status, WorkRequest.Statuses.BLOCKED)
        self.assertEqual(child.task_type, TaskTypes.WORKER)
        self.assertEqual(child.task_name, "piuparts")
        self.assertEqual(
            child.task_data,
            {
                "backend": BackendType.UNSHARE,
                "base_tgz": "debian/match:codename=sid",
                "build_architecture": "amd64",
                "environment": "debian/match:codename=sid",
                "extra_repositories": None,
                "input": {"binary_artifacts": [binary_artifact.id]},
            },
        )
        qa_result_action = ActionUpdateCollectionWithArtifacts(
            artifact_filters={"category": ArtifactCategory.PIUPARTS},
            collection=f"sid@{CollectionCategory.QA_RESULTS}",
            variables={
                "package": "hello",
                "version": "1.0-1",
                "architecture": "amd64",
                "timestamp": int(source_item.created_at.timestamp()),
                "work_request_id": child.id,
            },
        )
        self.assert_work_request_event_reactions(
            child,
            on_assignment=[
                ActionSkipIfLookupResultChanged(
                    lookup=(
                        f"sid@{CollectionCategory.QA_RESULTS}/"
                        f"latest:piuparts_hello_amd64"
                    ),
                    collection_item_id=None,
                    promise_name=None,
                )
            ],
            on_failure=[qa_result_action],
            on_success=[
                ActionUpdateCollectionWithArtifacts(
                    artifact_filters={"category": ArtifactCategory.PIUPARTS},
                    collection="internal@collections",
                    name_template="reference-qa-result|piuparts-amd64",
                ),
                qa_result_action,
            ],
        )

        # Completing the work request stores the QA result.
        result, _ = self.playground.create_artifact(
            category=ArtifactCategory.PIUPARTS,
            data=DebianPiuparts(
                piuparts_version="1.6.0",
                binary_packages={"hello": "1.0-1"},
                architecture="amd64",
                distribution="debian:sid",
            ),
            work_request=child,
        )
        self.playground.advance_work_request(
            child, result=WorkRequest.Results.SUCCESS, use_data=False
        )
        self.assertEqual(
            child.lookup_single(
                f"sid@{CollectionCategory.QA_RESULTS}/"
                f"latest:piuparts_hello_amd64",
                expect_type=LookupChildType.ARTIFACT,
            ).artifact,
            result,
        )

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=True)
    @preserve_db_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", "arm64", "i386", "ppc64el", "s390x"]

        reference_source_artifact = self.source_artifact
        reference_binary_artifacts = {
            arch: self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello", srcpkg_version="1.0-1", architecture=arch
            )
            for arch in architectures
            if arch != "ppc64el"
        }
        new_source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-2"
        )
        new_binary_artifacts = {
            arch: self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello", srcpkg_version="1.0-2", architecture=arch
            )
            for arch in architectures
        }

        root = self.playground.create_workflow(mark_running=True)
        reference = self.orchestrate(
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
                source_artifact=reference_source_artifact.id,
                binary_artifacts=LookupMultiple.model_validate(
                    [
                        artifact.id
                        for artifact in reference_binary_artifacts.values()
                    ]
                ),
                vendor="debian",
                codename="sid",
            ),
            source_artifact=reference_source_artifact,
            architectures=architectures,
            parent=root,
            pipeline_task_name="referencepipeline",
        )
        new = self.orchestrate(
            task_data=PiupartsWorkflowData(
                reference_prefix="reference-qa-result|",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                enable_regression_tracking=True,
                source_artifact=new_source_artifact.id,
                binary_artifacts=LookupMultiple.model_validate(
                    [
                        f"internal@collections/name:build-{arch}"
                        for arch in architectures
                    ]
                ),
                vendor="debian",
                codename="sid",
            ),
            source_artifact=new_source_artifact,
            architectures=architectures,
            parent=root,
        )

        # Unblock the piuparts 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, use_data=False
            )
        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, use_data=False
            )

        reference_piuparts_workflow = reference.children.get(
            task_type=TaskTypes.WORKFLOW, task_name="piuparts"
        )
        reference_tasks = {
            wr.task_data["build_architecture"]: wr
            for wr in reference_piuparts_workflow.children.filter(
                task_type=TaskTypes.WORKER, task_name="piuparts"
            )
        }
        reference_results = {
            architecture: self.playground.create_artifact(
                category=ArtifactCategory.PIUPARTS,
                data=DebianPiuparts(
                    piuparts_version="1.6.0",
                    binary_packages={"hello": "1.0-1"},
                    architecture=architecture,
                    distribution="debian:sid",
                ),
                work_request=reference_tasks[architecture],
            )[0]
            for architecture in architectures
            if architecture != "ppc64el"
        }
        for arch, artifact in reference_results.items():
            self.playground.create_artifact_relation(
                artifact,
                reference_binary_artifacts[arch],
                ArtifactRelation.Relations.RELATES_TO,
            )
        new_piuparts_workflow = new.children.get(
            task_type=TaskTypes.WORKFLOW, task_name="piuparts"
        )
        new_tasks = {
            wr.task_data["build_architecture"]: wr
            for wr in new_piuparts_workflow.children.filter(
                task_type=TaskTypes.WORKER, task_name="piuparts"
            )
        }
        new_results = {
            architecture: self.playground.create_artifact(
                category=ArtifactCategory.PIUPARTS,
                data=DebianPiuparts(
                    piuparts_version="1.6.0",
                    binary_packages={"hello": "1.0-1"},
                    architecture=architecture,
                    distribution="debian:sid",
                ),
                work_request=new_tasks[architecture],
            )[0]
            for architecture in architectures
        }
        for arch, artifact in new_results.items():
            self.playground.create_artifact_relation(
                artifact,
                new_binary_artifacts[arch],
                ArtifactRelation.Relations.RELATES_TO,
            )

        self.assertIsNone(new_piuparts_workflow.output_data)
        with self.schedule_and_run() as work_requests:
            self.assertEqual(work_requests, [])
        self.playground.advance_work_request(
            reference_tasks["amd64"],
            result=WorkRequest.Results.SUCCESS,
            use_data=False,
        )
        with self.schedule_and_run() as work_requests:
            self.assertEqual(work_requests, [])
        self.playground.advance_work_request(
            new_tasks["amd64"],
            result=WorkRequest.Results.SUCCESS,
            use_data=False,
        )

        self.schedule_and_run_workflow_callback(new_piuparts_workflow)

        expected_analysis: list[tuple[str, RegressionAnalysisStatus, None]] = [
            ("amd64", RegressionAnalysisStatus.STABLE, None),
        ]
        new_piuparts_workflow.refresh_from_db()
        self.assert_regression_analysis(
            new_piuparts_workflow,
            "1.0-1",
            reference_results,
            "1.0-2",
            new_results,
            expected_analysis,
        )

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

        for architecture, reference_result, new_result, status in (
            (
                "arm64",
                WorkRequest.Results.SUCCESS,
                WorkRequest.Results.FAILURE,
                RegressionAnalysisStatus.REGRESSION,
            ),
            (
                "i386",
                WorkRequest.Results.FAILURE,
                WorkRequest.Results.FAILURE,
                RegressionAnalysisStatus.STABLE,
            ),
            (
                "ppc64el",
                None,
                WorkRequest.Results.SUCCESS,
                RegressionAnalysisStatus.NO_RESULT,
            ),
            (
                "s390x",
                WorkRequest.Results.FAILURE,
                WorkRequest.Results.SUCCESS,
                RegressionAnalysisStatus.IMPROVEMENT,
            ),
        ):
            if reference_result is not None:
                self.playground.advance_work_request(
                    reference_tasks[architecture],
                    result=reference_result,
                    use_data=False,
                )
            self.playground.advance_work_request(
                new_tasks[architecture], result=new_result, use_data=False
            )
            expected_analysis.append((architecture, status, None))
            expected_callback_statuses[architecture] = (
                WorkRequest.Statuses.PENDING
            )

        self.assert_callback_statuses(
            new_piuparts_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 ("arm64", "i386", "ppc64el", "s390x"):
            self.schedule_and_run_workflow_callback(new_piuparts_workflow)

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

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=True)
    @preserve_db_task_registry()
    def test_callback_regression_analysis_build_architecture(self) -> None:
        self.playground.create_collection("sid", CollectionCategory.SUITE)
        self.playground.create_collection("sid", CollectionCategory.QA_RESULTS)

        reference_source_artifact = self.source_artifact
        reference_binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello", srcpkg_version="1.0-1", architecture="all"
            )
        )
        new_source_artifact = self.playground.create_source_artifact(
            name="hello", version="1.0-2"
        )
        new_binary_artifact = (
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello", srcpkg_version="1.0-2", architecture="all"
            )
        )
        # At least one debian_environment must exist to create the
        # Piuparts work request for the "all" package
        self.playground.create_debian_environment(
            architecture="amd64",
            codename="sid",
        )

        root = self.playground.create_workflow(mark_running=True)
        reference = self.orchestrate(
            task_data=PiupartsWorkflowData(
                prefix="reference-qa-result|",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                update_qa_results=RegressionTrackingUpdateQAResults.YES,
                source_artifact=reference_source_artifact.id,
                binary_artifacts=LookupMultiple(
                    (reference_binary_artifact.id,)
                ),
                vendor="debian",
                codename="sid",
            ),
            source_artifact=reference_source_artifact,
            architectures=["all"],
            parent=root,
            pipeline_task_name="referencepipeline",
        )
        new = self.orchestrate(
            task_data=PiupartsWorkflowData(
                reference_prefix="reference-qa-result|",
                qa_suite=f"sid@{CollectionCategory.SUITE}",
                reference_qa_results=f"sid@{CollectionCategory.QA_RESULTS}",
                enable_regression_tracking=True,
                source_artifact=new_source_artifact.id,
                binary_artifacts=LookupMultiple(
                    ("internal@collections/name:build-all",)
                ),
                vendor="debian",
                codename="sid",
            ),
            source_artifact=new_source_artifact,
            architectures=["all"],
            parent=root,
        )

        # Unblock the piuparts 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, use_data=False
            )
        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, use_data=False
            )

        reference_piuparts_workflow = reference.children.get(
            task_type=TaskTypes.WORKFLOW, task_name="piuparts"
        )
        reference_task = reference_piuparts_workflow.children.get(
            task_type=TaskTypes.WORKER, task_name="piuparts"
        )
        reference_result, _ = self.playground.create_artifact(
            category=ArtifactCategory.PIUPARTS,
            data=DebianPiuparts(
                piuparts_version="1.6.0",
                binary_packages={"hello": "1.0-1"},
                architecture="amd64",
                distribution="debian:sid",
            ),
            work_request=reference_task,
        )
        self.playground.create_artifact_relation(
            reference_result,
            reference_binary_artifact,
            ArtifactRelation.Relations.RELATES_TO,
        )
        new_piuparts_workflow = new.children.get(
            task_type=TaskTypes.WORKFLOW, task_name="piuparts"
        )
        new_task = new_piuparts_workflow.children.get(
            task_type=TaskTypes.WORKER, task_name="piuparts"
        )
        new_result, _ = self.playground.create_artifact(
            category=ArtifactCategory.PIUPARTS,
            data=DebianPiuparts(
                piuparts_version="1.6.0",
                binary_packages={"hello": "1.0-1"},
                architecture="amd64",
                distribution="debian:sid",
            ),
            work_request=new_task,
        )
        self.playground.create_artifact_relation(
            new_result,
            new_binary_artifact,
            ArtifactRelation.Relations.RELATES_TO,
        )

        self.assertIsNone(new_piuparts_workflow.output_data)
        with self.schedule_and_run() as work_requests:
            self.assertEqual(work_requests, [])
        self.playground.advance_work_request(
            reference_task, result=WorkRequest.Results.SUCCESS, use_data=False
        )
        with self.schedule_and_run() as work_requests:
            self.assertEqual(work_requests, [])
        self.playground.advance_work_request(
            new_task, result=WorkRequest.Results.SUCCESS, use_data=False
        )

        self.schedule_and_run_workflow_callback(new_piuparts_workflow)

        new_piuparts_workflow.refresh_from_db()
        self.assert_regression_analysis(
            new_piuparts_workflow,
            "1.0-1",
            {"amd64": reference_result},
            "1.0-2",
            {"amd64": new_result},
            [("amd64", RegressionAnalysisStatus.STABLE, None)],
        )

        self.assert_callback_statuses(
            new_piuparts_workflow, {"amd64": WorkRequest.Statuses.COMPLETED}
        )

    def test_compute_scheduler_tags(self) -> None:
        self.assertComputesSchedulerTags()

    def test_compute_dynamic_data_binary_package(self) -> None:
        self.task_data["binary_artifacts"] = [self.binary_artifact1.pk]
        self.assertEqual(
            self.compute_dynamic_data(self.task),
            PiupartsWorkflowDynamicData(
                subject="hello1",
                parameter_summary="hello1 in debian:trixie",
                source_artifact_id=self.source_artifact.id,
                binary_artifacts_ids=[self.binary_artifact1.id],
            ),
        )

    def test_compute_dynamic_data_multiple_source_packages(self) -> None:
        self.task_data["binary_artifacts"] = [
            self.binary_artifact1.id,
            self.binary_artifact2.id,
        ]
        self.assertEqual(
            self.compute_dynamic_data(self.task),
            PiupartsWorkflowDynamicData(
                subject="hello1 hello2",
                parameter_summary="hello1 hello2 in debian:trixie",
                source_artifact_id=self.source_artifact.id,
                binary_artifacts_ids=[
                    self.binary_artifact1.id,
                    self.binary_artifact2.id,
                ],
            ),
        )

    def test_compute_dynamic_data_collection_raise_error(self) -> None:
        binary_artifact = (
            self.playground.create_debian_environments_collection()
        )
        self.task_data["binary_artifacts"] = [
            f"{binary_artifact.id}@collections"
        ]
        with self.assertRaisesRegex(
            LookupError,
            f"^'{binary_artifact.id}@collections' is of type 'collection' "
            f"instead of expected 'artifact_or_promise'",
        ):
            self.task

    def test_compute_dynamic_data_binary_promises(self) -> None:
        source_artifact = self.source_artifact

        with preserve_db_task_registry():

            class ExamplePipeline(
                SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
            ):
                """
                Pipeline workflow that provides a promise (e.g. by Sbuild).

                Piuparts will use the promise.
                """

                def populate(self) -> None:
                    """Populate the pipeline."""
                    sbuild = self.work_request.create_child_workflow(
                        task_name="sbuild",
                        task_data=SbuildWorkflowData(
                            input=SbuildInput(
                                source_artifact=source_artifact.id
                            ),
                            target_distribution="debian:sid",
                            architectures=["amd64"],
                        ),
                    )
                    sbuild = sbuild.create_child_worker(
                        task_name="sbuild",
                        task_data=SbuildData(
                            input=SbuildInput(
                                source_artifact=source_artifact.id
                            ),
                            build_architecture="amd64",
                            environment="debian/match:codename=sid",
                        ),
                    )
                    self.provides_artifact(
                        sbuild,
                        ArtifactCategory.BINARY_PACKAGE,
                        "build-arm64",
                        data={"source_package_name": "linux-base"},
                    )
                    self.provides_artifact(
                        sbuild,
                        ArtifactCategory.BINARY_PACKAGE,
                        "build-amd64",
                        data={
                            "architecture": "amd64",
                            "source_package_name": "hello",
                        },
                    )
                    self.provides_artifact(
                        sbuild,
                        ArtifactCategory.BINARY_PACKAGE,
                        "build-i386",
                        data={
                            "architecture": "i386",
                        },
                    )

                    self.work_request.create_child_workflow(
                        task_name="piuparts",
                        task_data=PiupartsWorkflowData(
                            source_artifact=source_artifact.id,
                            binary_artifacts=LookupMultiple(
                                (
                                    "internal@collections/name:build-amd64",
                                    "internal@collections/name:build-arm64",
                                    "internal@collections/name:build-i386",
                                )
                            ),
                            vendor="debian",
                            codename="sid",
                        ),
                    )

            root = self.playground.create_workflow(task_name="examplepipeline")
            self.assertTrue(orchestrate_workflow(root))

            piuparts = WorkRequest.objects.get(
                task_type=TaskTypes.WORKFLOW,
                task_name="piuparts",
            )

            computed_data = self.get_workflow(piuparts).compute_dynamic_data()
            self.assertEqual(
                computed_data,
                PiupartsWorkflowDynamicData(
                    subject="hello linux-base",
                    parameter_summary="hello linux-base in debian:sid",
                    source_artifact_id=source_artifact.id,
                    binary_artifacts_ids=[],
                ),
            )

    def test_get_input_artifacts(self) -> None:
        self.assertEqual(
            self.task.get_input_artifacts(),
            [
                InputArtifactSingle(
                    lookup=self.task.data.source_artifact,
                    label="source_artifact",
                    artifact_id=None,
                ),
                InputArtifactMultiple.from_ids(
                    lookup=self.task.data.binary_artifacts,
                    label="binary_artifacts",
                    artifact_ids=None,
                ),
            ],
        )
