# Copyright 2021-2023 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 sbuild task support on the worker client."""
import itertools
import textwrap
from pathlib import Path
from typing import Any
from unittest import TestCase, mock
from unittest.mock import MagicMock, call

from debusine import utils
from debusine.artifacts import (
    BinaryPackage,
    BinaryPackages,
    PackageBuildLog,
    Upload,
)
from debusine.artifacts.models import CollectionCategory
from debusine.client.debusine import Debusine
from debusine.client.models import ArtifactResponse, RemoteArtifact
from debusine.tasks import TaskConfigError
from debusine.tasks.executors import ExecutorInterface
from debusine.tasks.models import SbuildDynamicData, WorkerType
from debusine.tasks.sbuild import Sbuild
from debusine.tasks.tests.helper_mixin import (
    ExternalTaskHelperMixin,
    FakeTaskDatabase,
)
from debusine.test import TestHelpersMixin
from debusine.test.utils import create_artifact_response


class SbuildTaskTests(
    TestHelpersMixin, ExternalTaskHelperMixin[Sbuild], TestCase
):
    """Test the SbuildTask class."""

    SAMPLE_TASK_DATA = {
        "input": {
            "source_artifact": 5,
        },
        "distribution": "bullseye",
        "host_architecture": "amd64",
        "build_components": [
            "any",
            "all",
        ],
        "backend": "schroot",
    }

    def test_compute_dynamic_data(self) -> None:
        """Dynamic data receives relevant artifact IDs."""
        self.configure_task(
            override={
                "backend": "unshare",
                "environment": "debian/match:codename=bookworm",
            },
            remove=["distribution"],
        )
        task_db = FakeTaskDatabase(
            single_lookups={
                # environment
                (
                    "debian/match:codename=bookworm:architecture=amd64:"
                    "format=tarball:backend=unshare",
                    CollectionCategory.ENVIRONMENTS,
                ): 1,
                # input.source_artifact
                (5, None): 5,
            }
        )

        self.assertEqual(
            self.task.compute_dynamic_data(task_db),
            SbuildDynamicData(
                environment_id=1,
                input_source_artifact_id=5,
                input_extra_binary_artifacts_ids=[],
            ),
        )

    def test_compute_dynamic_data_environment(self) -> None:
        """Dynamic data includes environment ID if relevant."""
        task_db = FakeTaskDatabase(
            single_lookups={
                # input.source_artifact
                (5, None): 5
            }
        )

        self.assertEqual(
            self.task.compute_dynamic_data(task_db),
            SbuildDynamicData(input_source_artifact_id=5),
        )

    def setUp(self):
        """
        Set a path to the ontology files used in the debusine tests.

        If the worker is moved to a separate source package, this will
        need to be updated.
        """
        self.configure_task(self.configuration("amd64", ["any"]))

        task_call_schroot_list_patcher = mock.patch.object(
            Sbuild, "_call_schroot_list"
        )
        self.schroot_list_mock = task_call_schroot_list_patcher.start()
        self.schroot_list_mock.return_value = "chroot:bullseye-amd64-sbuild"
        self.addCleanup(task_call_schroot_list_patcher.stop)

        task_call_dpkg_architecture_patcher = mock.patch.object(
            Sbuild, "_call_dpkg_architecture"
        )
        self.dpkg_architecture_mock = (
            task_call_dpkg_architecture_patcher.start()
        )
        self.dpkg_architecture_mock.return_value = "amd64"
        self.addCleanup(task_call_dpkg_architecture_patcher.stop)

    def configure_task(
        self,
        task_data: dict[str, Any] | None = None,
        override: dict[str, Any] | None = None,
        remove: list[str] | None = None,
    ):
        """Perform further setup."""
        if hasattr(self, "task"):
            self._cleanup_debug_log_files_directory()

        super().configure_task(task_data, override, remove)

        self.task.dynamic_data = SbuildDynamicData(
            input_source_artifact_id=int(self.task.data.input.source_artifact)
        )
        self.task.logger.disabled = True

    def _cleanup_debug_log_files_directory(self):
        """Delete self.task._debug_log_files_directory."""
        if self.task._debug_log_files_directory is not None:
            self.task._debug_log_files_directory.cleanup()

    def tearDown(self):
        """Cleanup at the end of a task."""
        self._cleanup_debug_log_files_directory()

    def mock_cmdline(self, cmdline: list[str]):
        """Patch self.task to return cmdline."""
        patcher = mock.patch.object(self.task, "_cmdline")
        self.cmdline_mock = patcher.start()
        self.cmdline_mock.return_value = cmdline
        self.addCleanup(patcher.stop)

    def test_configure_with_unknown_data(self):
        """Configure fails if a non-recognised key is in task_data."""
        with self.assertRaises(TaskConfigError):
            self.configure_task(override={"extra_field": "extra_value"})

    def test_configure_fails_with_missing_required_data(self):
        """Configure fails with missing required keys in task_data."""
        for key in ("input", "distribution", "host_architecture"):
            with self.subTest(f"Configure with key {key} missing"):
                with self.assertRaises(TaskConfigError):
                    self.configure_task(remove=[key])

    def test_configure_fails_required_input(self):
        """Configure fails: missing source_artifact in task_data["input"]."""
        with self.assertRaises(TaskConfigError):
            self.configure_task(override={"input": {}})

    def test_configure_fails_extra_properties_input(self):
        """Configure fails: extra properties in task_data["input"]."""
        with self.assertRaises(TaskConfigError):
            self.configure_task(
                override={
                    "input": {
                        "source_artifact": 5,
                        "source_environment": 4,
                        "source_artifact_url": "https://deb.debian.org/f.dsc",
                    }
                }
            )

    def test_configure_sets_default_values(self):
        """Optional task data have good default values."""
        self.configure_task(remove=["build_components"])

        self.assertEqual(self.task.data.build_components, ["any"])

    def test_configure_fails_with_bad_build_components(self):
        """Configure fails with invalid build components."""
        with self.assertRaises(TaskConfigError):
            self.configure_task(override={"build_components": ["foo", "any"]})

    def test_configure_fails_distribution_and_environment(self):
        """Configure fails: "distribution" and "environment" both set."""
        error_msg = 'The backend "auto" requires "distribution" to be unset'
        with self.assertRaisesRegex(TaskConfigError, error_msg):
            self.configure_task(
                override={
                    "backend": "auto",
                    "distribution": "bookworm",
                    "environment": 42,
                }
            )

        error_msg = 'The backend "schroot" requires "environment" to be unset'
        with self.assertRaisesRegex(TaskConfigError, error_msg):
            self.configure_task(
                override={
                    "backend": "schroot",
                    "distribution": "bookworm",
                    "environment": 42,
                }
            )

        error_msg = 'The backend "schroot" requires "distribution" to be set'
        with self.assertRaisesRegex(TaskConfigError, error_msg):
            self.configure_task(
                override={
                    "backend": "schroot",
                    "distribution": None,
                    "environment": None,
                }
            )

        error_msg = 'The backend "auto" requires "environment" to be set'
        with self.assertRaisesRegex(TaskConfigError, error_msg):
            self.configure_task(
                override={
                    "backend": "auto",
                    "distribution": None,
                    "environment": None,
                }
            )

    def test_configure_fails_backend_schroot_no_distribution(self):
        """Configure fails: backend "schroot" requires "distribution"."""
        msg = 'The backend "schroot" requires "distribution" to be set'
        with self.assertRaisesRegex(TaskConfigError, msg):
            self.configure_task(
                override={"environment": 42, "backend": "schroot"},
                remove=["distribution"],
            )

    def test_configure_fails_backend_unshare_no_environment(self):
        """Configure fails: backend "unshare" requires "environment"."""
        backend = "unshare"
        msg = f'The backend "{backend}" requires "environment" to be set'
        with self.assertRaisesRegex(TaskConfigError, msg):
            self.configure_task(
                override={"distribution": "bookworm", "backend": backend},
                remove=["environment"],
            )

    def test_update_chroots_list_skips_non_desired_chroots(self):
        """source:foo and non foo-sbuild chroots are ignored."""
        self.schroot_list_mock.return_value = (
            "source:jessie-amd64-sbuild\nchroot:buster-amd64\n"
            "chroot:wanted-sbuild"
        )

        self.task._update_chroots_list()
        self.task._update_chroots_list()  # second call triggers no-op branch

        self.assertEqual(self.task.chroots, ["wanted"])

    def test_fetch_input(self):
        """Test fetch_input: call fetch_artifact(artifact_id, directory)."""
        directory = self.create_temporary_directory()
        source_artifact = self.fake_debian_source_package_artifact()
        self.configure_task(
            override={
                "input": {
                    "source_artifact": source_artifact.id,
                },
            }
        )
        self.task.work_request_id = 5
        self.task.dynamic_data = SbuildDynamicData(
            input_source_artifact_id=source_artifact.id
        )
        debusine_mock = self.mock_debusine()
        debusine_mock.artifact_get.return_value = source_artifact

        with mock.patch.object(
            self.task, "fetch_artifact", autospec=True, return_value=True
        ) as fetch_artifact_mocked:
            result = self.task.fetch_input(directory)

        self.assertTrue(result)
        fetch_artifact_mocked.assert_called_once_with(
            source_artifact.id, directory
        )

    def test_fetch_input_wrong_category(self):
        """Test fetch_input when input isn't a source package."""
        directory = self.create_temporary_directory()
        source_artifact = create_artifact_response(id=12)
        self.configure_task(
            override={
                "input": {
                    "source_artifact": source_artifact.id,
                },
            }
        )
        self.task.work_request_id = 5
        self.task.dynamic_data = SbuildDynamicData(
            input_source_artifact_id=source_artifact.id
        )
        debusine_mock = self.mock_debusine()
        debusine_mock.artifact_get.return_value = source_artifact

        with mock.patch.object(
            self.task, "fetch_artifact", autospec=True
        ) as fetch_artifact_mocked:
            result = self.task.fetch_input(directory)

        self.assertFalse(result)

        log_file_contents = (
            Path(self.task._debug_log_files_directory.name) / "fetch_input.log"
        ).read_text()
        self.assertEqual(
            log_file_contents,
            (
                "input.source_artifact points to a Testing, not the "
                "expected debian:source-package.\n"
            ),
        )

        fetch_artifact_mocked.assert_not_called()
        self.assertIsNone(self.task._dsc_file)

    def test_fetch_input_no_dsc(self):
        """
        Test fetch_input on a source package that has no .dsc.

        We never expect this situation to arise in production, but it exercises
        the file list searching loop.
        """
        directory = self.create_temporary_directory()
        source_artifact = self.fake_debian_source_package_artifact()
        source_artifact.files["foo_1.0-1.tar"] = source_artifact.files.pop(
            "foo_1.0-1.dsc"
        )
        self.configure_task(
            override={
                "input": {
                    "source_artifact": source_artifact.id,
                },
            }
        )
        self.task.work_request_id = 5
        self.task.dynamic_data = SbuildDynamicData(
            input_source_artifact_id=source_artifact.id
        )
        debusine_mock = self.mock_debusine()
        debusine_mock.artifact_get.return_value = source_artifact

        with mock.patch.object(
            self.task, "fetch_artifact", autospec=True, return_value=True
        ) as fetch_artifact_mocked, self.assertRaisesRegex(
            AssertionError, r"No \.dsc file found in source package\."
        ):
            self.task.fetch_input(directory)

        fetch_artifact_mocked.assert_not_called()

    def test_fetch_input_extra_packages(self):
        """Test fetch_input: downloads extra_binary_artifacts."""
        directory = self.create_temporary_directory()
        source_artifact = self.fake_debian_source_package_artifact()
        binary_artifact = self.fake_debian_binary_package_artifact()
        self.configure_task(
            override={
                "input": {
                    "source_artifact": source_artifact.id,
                    "extra_binary_artifacts": [binary_artifact.id],
                },
            }
        )
        self.task.work_request_id = 5
        self.task.dynamic_data = SbuildDynamicData(
            input_source_artifact_id=source_artifact.id,
            input_extra_binary_artifacts_ids=[binary_artifact.id],
        )
        debusine_mock = self.mock_debusine()

        def artifact_get(artifact_id: int) -> ArtifactResponse:
            """Fake for debusine client artifact_get."""
            if artifact_id == source_artifact.id:
                return source_artifact
            if artifact_id == binary_artifact.id:
                return binary_artifact
            raise AssertionError("Unknown artifact requested.")

        debusine_mock.artifact_get.side_effect = artifact_get

        with mock.patch.object(
            self.task, "fetch_artifact", autospec=True, return_value=True
        ) as fetch_artifact_mocked:
            result = self.task.fetch_input(directory)

        self.assertTrue(result)
        fetch_artifact_mocked.assert_has_calls(
            [
                call(source_artifact.id, directory),
                call(binary_artifact.id, directory),
            ]
        )

    def test_fetch_input_extra_binaries_wrong_category(self):
        """Test fetch_input: checks the categories of extra_binary_artifacts."""
        directory = self.create_temporary_directory()
        source_artifact = self.fake_debian_source_package_artifact()
        self.configure_task(
            override={
                "input": {
                    "source_artifact": source_artifact.id,
                    "extra_binary_artifacts": [source_artifact.id],
                },
            }
        )
        self.task.work_request_id = 5
        self.task.dynamic_data = SbuildDynamicData(
            input_source_artifact_id=source_artifact.id,
            input_extra_binary_artifacts_ids=[source_artifact.id],
        )
        debusine_mock = self.mock_debusine()
        debusine_mock.artifact_get.return_value = source_artifact

        with mock.patch.object(
            self.task, "fetch_artifact", autospec=True, return_value=True
        ):
            result = self.task.fetch_input(directory)

        self.assertFalse(result)
        log_file_contents = (
            Path(self.task._debug_log_files_directory.name) / "fetch_input.log"
        ).read_text()
        self.assertEqual(
            log_file_contents,
            (
                "input.extra_binary_artifacts includes artifact 6, a "
                "debian:source-package, not the expected "
                "debian:binary-package or debian:binary-packages.\n"
            ),
        )

    def test_cmdline_starts_with_sbuild_no_clean(self):
        """Test fixed command line parameters."""
        self.configure_task()
        self.patch_executor()

        cmdline = self.task._cmdline()

        self.assertEqual(cmdline[0], "sbuild")
        self.assertEqual(cmdline[1], "--no-clean")

    def test_cmdline_contains_arch_parameter(self):
        """Ensure --arch parameter is computed from data."""
        self.schroot_list_mock.return_value = "chroot:bullseye-mipsel-sbuild"
        self.configure_task(override={"host_architecture": "mipsel"})
        self.patch_executor()
        cmdline = self.task._cmdline()

        self.assertIn("--arch=mipsel", cmdline)

    def test_cmdline_contains_dist_parameter(self):
        """Ensure --dist parameter is computed from data."""
        self.schroot_list_mock.return_value = "chroot:jessie-amd64-sbuild"
        self.configure_task(override={"distribution": "jessie"})
        self.patch_executor()

        cmdline = self.task._cmdline()

        self.assertIn("--dist=jessie", cmdline)

    def test_cmdline_translation_of_build_components(self):
        """Test handling of build components."""
        self.patch_executor()
        option_mapping = {
            "any": ["--arch-any", "--no-arch-any"],
            "all": ["--arch-all", "--no-arch-all"],
            "source": ["--source", "--no-source"],
        }
        keywords = option_mapping.keys()
        for combination in itertools.chain(
            itertools.combinations(keywords, 1),
            itertools.combinations(keywords, 2),
            itertools.combinations(keywords, 3),
        ):
            with self.subTest(f"Test build_components={combination}"):
                self.configure_task(
                    override={"build_components": list(combination)}
                )
                cmdline = self.task._cmdline()
                for key, value in option_mapping.items():
                    if key in combination:
                        self.assertIn(value[0], cmdline)
                        self.assertNotIn(value[1], cmdline)
                    else:
                        self.assertNotIn(value[0], cmdline)
                        self.assertIn(value[1], cmdline)

    def test_cmdline_contains_chmod_schroot_build_args(self):
        """Ensure cmdline has the arguments for schroot backend."""
        distribution = "bookworm"
        self.configure_task(
            override={"backend": "schroot", "distribution": distribution}
        )
        cmdline = self.task._cmdline()
        self.assertIn(f"--dist={distribution}", cmdline)

    def test_cmdline_contains_extra_packages(self):
        """Ensure cmdline has the arguments --extra-package=."""
        distribution = "bookworm"
        self.configure_task(
            override={"backend": "schroot", "distribution": distribution}
        )
        self.task._extra_packages = [Path("/tmp/extra.deb")]
        cmdline = self.task._cmdline()
        self.assertIn("--extra-package=/tmp/extra.deb", cmdline)

    def test_cmdline_contains_executor_unshare_build_args(self):
        """Ensure cmdline builds correct arguments for unshare executor."""
        self.configure_task(
            override={
                "backend": "unshare",
                "environment": "debian/match:codename=bookworm",
            },
            remove=["distribution"],
        )

        debusine_mock = self.mock_debusine()
        self.mock_image_download(debusine_mock)

        self.task._prepare_executor()
        cmdline = self.task._cmdline()

        expected_args = [
            "--dist=bookworm",
            "--chroot-mode=unshare",
            f"--chroot={self.image_cache_path}/42/system.tar.xz",
        ]

        start_index = cmdline.index(expected_args[0])

        self.assertEqual(
            cmdline[start_index : start_index + len(expected_args)],
            expected_args,
        )

    def test_cmdline_uses_autopkgtest_virtserver_build_args(self):
        """Ensure cmdline supports autopkgtest-virt-server executors."""
        data = self.configuration("amd64", ["any"])
        data["backend"] = "incus-lxc"
        data["environment"] = 1
        del data["distribution"]
        self.configure_task(data)

        executor_mocked = self.patch_executor()

        autopkgtest_args = [
            "--abc=123",
            "--def=1%3",
        ]
        executor_mocked.autopkgtest_virt_args.return_value = autopkgtest_args
        executor_mocked.autopkgtest_virt_server.return_value = "fake"
        executor_mocked.system_image = self.fake_system_tarball_artifact()

        cmdline = self.task._cmdline()

        expected_args = [
            "--dist=bookworm",
            "--chroot-mode=autopkgtest",
            "--autopkgtest-virt-server=fake",
            "--autopkgtest-virt-server-opt=--abc=123",
            "--autopkgtest-virt-server-opt=--def=1%%3",
        ]

        start_index = cmdline.index(expected_args[0])

        self.assertEqual(
            cmdline[start_index : start_index + len(expected_args)],
            expected_args,
        )

    def test_cmdline_unshare_without_configure_for_execution(self):
        """_cmdline for unshare asserts that self.executor is set."""
        self.configure_task(
            override={"backend": "unshare", "environment": 42},
            remove=["distribution"],
        )
        self.assertRaisesRegex(
            AssertionError, r"^self\.executor not set$", self.task._cmdline
        )

    def test_execute_fails_with_unsupported_schroot_distribution(self):
        """execute() fails when the requested distribution is unsupported."""
        # Default mocked setup only support bullseye
        self.configure_task(override={"distribution": "jessie"})
        with self.assertRaises(TaskConfigError):
            self.task.execute()

    def test_execute_fails_with_no_available_chroots(self):
        """execute() fails when no sbuild chroots are available."""
        self.schroot_list_mock.return_value = ""
        self.configure_task()
        with self.assertRaises(TaskConfigError):
            self.task.execute()

    def test_execute_succeeds_backend_unshare(self):
        """execute() backend "unshare": does not call _verify_schroots()."""
        self.configure_task(
            task_data=self.configuration("amd64", ["any"]),
            override={"backend": "unshare", "environment": 42},
            remove=["distribution"],
        )
        self.task.work_request_id = 5
        self.patch_sbuild_debusine()

        with mock.patch.object(
            self.task, "_verify_schroot", autospec=True
        ) as mocked:
            self.run_execute(["true"])

        mocked.assert_not_called()

    def patch_temporary_directory(self, directories=1) -> list[Path]:
        """
        Patch sbuild.tempfile.TemporaryDirectory to return fixed directories.

        :param directories: number of directories that will be returned.
        :return: list with the Paths that have been created and will be
          returned.
        """
        temporary_directories = []
        for _ in range(0, directories):
            temporary_directories.append(self.create_temporary_directory())

        class MockedPathContextManager:
            def __init__(self, directory_paths: list[Path]):
                self.paths = iter(directory_paths)

            def __enter__(self):
                return next(self.paths)

            def __exit__(self, exc_type, exc_val, exc_tb):
                pass

        patcher = mock.patch.object(
            self.task, "_temporary_directory", autospec=True
        )
        temporary_directory_mock = patcher.start()
        temporary_directory_mock.return_value = MockedPathContextManager(
            temporary_directories
        )
        self.addCleanup(patcher.stop)

        return temporary_directories

    def run_execute(
        self,
        cmdline: list[str],
    ) -> bool:
        """Run Sbuild.execute() mocking cmdline. Return execute()."""
        self.mock_cmdline(cmdline)

        temporary_directory, *_ = self.patch_temporary_directory(3)
        self.write_dsc_example_file(temporary_directory / "hello.dsc")
        (temporary_directory / "log.build").write_text("log file")

        self.patch_upload_artifacts()
        self.patch_upload_work_request_debug_logs()

        return self.task.execute()

    def test_execute_returns_true(self):
        """
        The execute method returns True when cmd returns 0.

        Also check that check_directory_for_consistency_errors() didn't
        return errors.

        Assert that EXECUTE_LOG file is created.
        """
        self.patch_sbuild_debusine()
        self.patch_fetch_input(return_value=True)
        self.patch_configure_for_execution(return_value=True)

        check_directory_for_consistency_errors_mocked = (
            self.patch_check_directory_for_consistency_errors()
        )
        check_directory_for_consistency_errors_mocked.return_value = []
        stdout_output = "stdout output of the command"
        stderr_output = "stderr output of the command"
        cmd = ["sh", "-c", f"echo {stdout_output}; echo {stderr_output} >&2"]
        cmd_quoted = (
            "sh -c 'echo stdout output of the command; "
            "echo stderr output of the command >&2'"
        )

        self.assertTrue(self.run_execute(cmd))

        expected = textwrap.dedent(
            f"""\
                cmd: {cmd_quoted}
                output (contains stdout and stderr):
                {stdout_output}
                {stderr_output}

                aborted: False
                returncode: 0

                Files in working directory:
                hello.dsc
                log.build
                {Sbuild.CMD_LOG_SEPARATOR}
                """
        )

        # debug_log_file contains the expected text
        self.assertEqual(
            (
                Path(self.task._debug_log_files_directory.name)
                / self.task.CMD_LOG_FILENAME
            ).read_text(),
            expected,
        )

        check_directory_for_consistency_errors_mocked.assert_called()

    def test_execute_return_false_no_fetch_required_input_files(self):
        """execute() method returns False: cannot fetch required input files."""
        self.patch_fetch_input(return_value=False)
        self.assertFalse(self.run_execute(["true"]))

    def test_execute_check_build_consistency_error_returns_errors(self):
        """
        execute() method returns False: check_build_consistency returned errors.

        Assert check_build_consistency() was called.
        """
        self.patch_sbuild_debusine()
        self.patch_fetch_input(return_value=True)
        self.patch_configure_for_execution(return_value=True)

        check_directory_for_consistency_errors_mocked = (
            self.patch_check_directory_for_consistency_errors()
        )
        check_directory_for_consistency_errors_mocked.return_value = [
            "some error"
        ]

        self.assertFalse(self.run_execute(["true"]))

        check_directory_for_consistency_errors_mocked.assert_called()

    def test_execute_returns_false(self):
        """
        The execute method returns False when cmd returns 1.

        Assert check_build_consistency() was not called.
        """
        check_build_consistency_mocked = (
            self.patch_check_directory_for_consistency_errors()
        )

        self.patch_fetch_input(return_value=True)

        self.assertFalse(self.run_execute(["false"]))

        check_build_consistency_mocked.assert_not_called()

    def patch_sbuild_debusine(self) -> MagicMock:
        """Patch self.task.debusine. Return mock."""
        patcher = mock.patch.object(self.task, "debusine", autospec=Debusine)
        mocked = patcher.start()
        self.addCleanup(patcher.stop)

        return mocked

    def patch_fetch_input(self, *, return_value: bool) -> MagicMock:
        """
        Patch self.task.debusine.fetch_input().

        :param return_value: set mocked.return_value = return_value
        :return: the mock
        """
        patcher = mock.patch.object(self.task, "fetch_input", autospec=True)
        mocked = patcher.start()
        mocked.return_value = return_value
        self.addCleanup(patcher.stop)

        return mocked

    def patch_configure_for_execution(self, *, return_value: bool) -> MagicMock:
        """
        Patch self.task.debusine.configure_for_execution().

        :param return_value: set mocked.return_value = return_value
        :return: the mock
        """
        patcher = mock.patch.object(
            self.task, "configure_for_execution", autospec=True
        )
        mocked = patcher.start()
        mocked.return_value = return_value
        self.addCleanup(patcher.stop)

        return mocked

    def patch_upload_artifacts(self):
        """Patch self.task.upload_artifacts."""
        patcher = mock.patch.object(
            self.task, "upload_artifacts", autospec=True
        )
        mocked = patcher.start()
        self.addCleanup(patcher.stop)

        return mocked

    def patch_upload_work_request_debug_logs(self):
        """Patch self.task._upload_work_request_debug_logs."""
        patcher = mock.patch.object(
            self.task, "_upload_work_request_debug_logs", autospec=True
        )
        mocked = patcher.start()
        self.addCleanup(patcher.stop)

        return mocked

    def patch_executor(self):
        """Patch self.task._executor."""
        patcher = mock.patch.object(
            self.task, "executor", autospec=ExecutorInterface
        )
        mocked = patcher.start()
        self.addCleanup(patcher.stop)
        return mocked

    def patch_check_directory_for_consistency_errors(self):
        """Patch self.task.check_directory_for_consistency_errors."""
        patcher = mock.patch.object(
            self.task, "check_directory_for_consistency_errors", autospec=True
        )
        mocker = patcher.start()
        self.addCleanup(patcher.stop)

        return mocker

    def test_execute_call_upload_artifacts(self):
        """
        execute() call methods to upload artifacts.

        It calls: upload_artifacts() and _upload_work_request_debug_logs().
        """
        self.configure_task()

        self.mock_cmdline(["true"])

        self.patch_sbuild_debusine()
        self.patch_fetch_input(return_value=True)
        self.patch_configure_for_execution(return_value=True)

        execute_directory, download_directory = self.patch_temporary_directory(
            2
        )

        upload_artifacts_mocked = self.patch_upload_artifacts()
        self.patch_check_directory_for_consistency_errors().return_value = []

        upload_work_request_debug_logs_patcher = mock.patch.object(
            self.task, "_upload_work_request_debug_logs", autospec=True
        )
        upload_work_request_debug_logs_mocked = (
            upload_work_request_debug_logs_patcher.start()
        )
        self.addCleanup(upload_work_request_debug_logs_patcher.stop)

        self.task.configure_for_execution(download_directory)
        self.task.execute()

        self.assertEqual(upload_artifacts_mocked.call_count, 1)
        upload_artifacts_mocked.assert_called_with(
            execute_directory, execution_success=True
        )

        self.assertEqual(upload_work_request_debug_logs_mocked.call_count, 1)
        upload_work_request_debug_logs_mocked.assert_called_with()

    def test_execute_run_cmd_with_capture_stdout(self):
        """execute() call run_cmd with capture_stdout."""
        self.task.CAPTURE_OUTPUT_FILENAME = "cmd.out"

        self.patch_fetch_input(return_value=True)
        run_cmd_mock = self.patch_run_cmd()
        run_cmd_mock.return_value = False

        self.patch_configure_for_execution(return_value=True)
        self.patch_upload_artifacts()
        self.patch_check_directory_for_consistency_errors().return_value = []
        self.patch_upload_work_request_debug_logs()
        self.patch_executor()

        self.task.execute()

        # run_cmd was ran with:
        # capture_stdout_filename=self.task.CAPTURE_OUTPUT_FILENAME)
        self.assertEqual(
            run_cmd_mock.call_args.kwargs["capture_stdout_filename"],
            self.task.CAPTURE_OUTPUT_FILENAME,
        )

    def test_execute_does_not_run_cmd(self):
        """execute(), no call run_cmd(): configure_for_execution() failed."""
        self.configure_task()

        self.patch_sbuild_debusine()
        self.patch_fetch_input(return_value=True)

        temporary_directory, *_ = self.patch_temporary_directory(3)

        patcher_configure = mock.patch.object(
            self.task, "configure_for_execution"
        )
        configure_for_execution_mocked = patcher_configure.start()
        configure_for_execution_mocked.return_value = False
        self.addCleanup(patcher_configure.stop)

        run_cmd = self.patch_run_cmd()

        self.assertFalse(self.task.execute())

        run_cmd.assert_not_called()

    def test_analyze_worker(self):
        """Test the analyze_worker() method."""
        self.mock_is_command_available({"sbuild": True, "schroot": True})
        self.schroot_list_mock.return_value = "chroot:jessie-arm64-sbuild"
        self.dpkg_architecture_mock.return_value = "mipsel"

        metadata = self.task.analyze_worker()

        self.assertEqual(metadata["sbuild:available"], True)
        self.assertEqual(metadata["sbuild:chroots"], ["jessie-arm64"])
        self.assertEqual(metadata["sbuild:host_architecture"], "mipsel")

    def test_analyze_worker_sbuild_not_available(self):
        """analyze_worker() handles sbuild not being available."""
        self.mock_is_command_available({"sbuild": False, "schroot": True})
        self.schroot_list_mock.return_value = "chroot:jessie-arm64-sbuild"
        self.dpkg_architecture_mock.return_value = "mipsel"

        metadata = self.task.analyze_worker()

        self.assertEqual(metadata["sbuild:available"], False)
        self.assertEqual(metadata["sbuild:chroots"], ["jessie-arm64"])
        self.assertEqual(metadata["sbuild:host_architecture"], "mipsel")

    def test_analyze_worker_schroot_not_available(self):
        """analyze_worker() handles schroot not being available."""
        self.mock_is_command_available({"sbuild": True, "schroot": False})
        self.schroot_list_mock.return_value = "chroot:jessie-arm64-sbuild"
        self.dpkg_architecture_mock.return_value = "mipsel"

        metadata = self.task.analyze_worker()

        self.assertEqual(metadata["sbuild:available"], True)
        self.assertNotIn("sbuild:chroots", metadata)
        self.assertEqual(metadata["sbuild:host_architecture"], "mipsel")

    def worker_metadata(self):
        """Return worker_metadata with sbuild:version=self.task.TASK_VERSION."""
        return {
            "system:worker_type": WorkerType.EXTERNAL,
            "system:architectures": ["amd64"],
            "sbuild:version": self.task.TASK_VERSION,
            "sbuild:available": True,
            "sbuild:chroots": ["bullseye-amd64"],
            "sbuild:host_architecture": "amd64",
        }

    def test_can_run_schroot_exists(self):
        """Ensure can_run_on returns True: schroot and exists."""
        worker_metadata = self.worker_metadata()
        self.configure_task(override={"backend": "schroot"})

        self.assertTrue(self.task.can_run_on(worker_metadata))

    def test_can_run_mismatched_task_version(self):
        """Ensure can_run_on returns False for mismatched versions."""
        worker_metadata = self.worker_metadata()
        worker_metadata["sbuild:version"] += 1
        self.configure_task()

        self.assertFalse(self.task.can_run_on(worker_metadata))

    def test_can_run_on_sbuild_not_available(self):
        """can_run_on returns False if sbuild is not available."""
        worker_metadata = self.worker_metadata()
        worker_metadata["sbuild:available"] = False
        self.configure_task()

        self.assertFalse(self.task.can_run_on(worker_metadata))

    def test_can_run_chroot_not_available(self):
        """Ensure can_run_on returns False when needed chroot is not there."""
        worker_metadata = self.worker_metadata()
        worker_metadata["sbuild:chroots"] = ["jessie-arm64"]
        self.configure_task(override={"backend": "schroot"})

        self.assertFalse(self.task.can_run_on(worker_metadata))

    def test_can_run_return_true_missing_schroot_but_unshare(self):
        """can_run_on returns True: missing distribution but using unshare."""
        worker_metadata = self.worker_metadata()
        worker_metadata["sbuild:chroots"] = ["jessie-arm64"]
        worker_metadata["executor:unshare:available"] = True
        self.configure_task(
            override={"backend": "unshare", "environment": 42},
            remove=["distribution"],
        )

        self.assertTrue(self.task.can_run_on(worker_metadata))

    def test_can_run_on_unshare_requires_unshare(self):
        """can_run_on returns False: missing unshare executor."""
        worker_metadata = self.worker_metadata()
        worker_metadata["sbuild:chroots"] = ["bookworm-arm64"]
        self.configure_task(
            override={"backend": "unshare", "environment": 42},
            remove=["distribution"],
        )

        self.assertFalse(self.task.can_run_on(worker_metadata))

    def test_can_run_on_incus(self):
        """can_run_on returns True: with incus and autopkgtest."""
        worker_metadata = self.worker_metadata()
        worker_metadata["executor:incus-lxc:available"] = True
        worker_metadata["autopkgtest:available"] = True
        worker_metadata["sbuild:chroots"] = ["bookworm-arm64"]
        self.configure_task(
            override={"backend": "incus-lxc", "environment": 42},
            remove=["distribution"],
        )

        self.assertTrue(self.task.can_run_on(worker_metadata))

    def test_can_run_on_incus_requires_autopkgtest(self):
        """can_run_on returns False: without autopkgtest."""
        worker_metadata = self.worker_metadata()
        worker_metadata["executor:incus-lxc:available"] = True
        worker_metadata["sbuild:chroots"] = ["bookworm-arm64"]
        self.configure_task(
            override={"backend": "incus-lxc", "environment": 42},
            remove=["distribution"],
        )

        self.assertFalse(self.task.can_run_on(worker_metadata))

    def test_can_run_missing_distribution(self):
        """can_run_on returns False if sbuild:chroots is not in the metadata."""
        worker_metadata = self.worker_metadata()
        del worker_metadata["sbuild:chroots"]
        self.configure_task(override={"backend": "schroot"})

        self.assertFalse(self.task.can_run_on(worker_metadata))

    def test_upload_artifacts_no_deb_files(self):
        """upload_artifacts() does not try to upload deb files."""
        temp_directory = self.create_temporary_directory()

        debusine_mock = self.patch_sbuild_debusine()

        # Add log.build file: the PackageBuildLog is created
        self.task._dsc_file = self.create_temporary_file(suffix=".dsc")
        dsc = self.write_dsc_example_file(self.task._dsc_file)
        (log_file := temp_directory / "log.build").write_text("log file")

        # Add .changes file: Upload is created
        changes_file = temp_directory / "python-network.changes"

        (file1 := temp_directory / "package.deb").write_text("test")

        self.write_changes_file(changes_file, [file1])

        self.task.upload_artifacts(temp_directory, execution_success=True)

        self.assertEqual(debusine_mock.upload_artifact.call_count, 2)

        debusine_mock.upload_artifact.assert_has_calls(
            [
                call(
                    PackageBuildLog.create(
                        file=log_file,
                        source=dsc["Source"],
                        version=dsc["Version"],
                    ),
                    workspace=self.task.workspace_name,
                    work_request=None,
                ),
                call(
                    Upload.create(
                        changes_file=changes_file, exclude_files=set()
                    ),
                    workspace=self.task.workspace_name,
                    work_request=None,
                ),
            ]
        )

    def test_upload_artifacts_build_failure(self):
        """upload_artifacts() uploads only the .build file: build failed."""
        temp_directory = self.create_temporary_directory()

        debusine_mock = self.patch_sbuild_debusine()

        # Add log.build file
        self.task._dsc_file = self.create_temporary_file(suffix=".dsc")
        dsc = self.write_dsc_example_file(self.task._dsc_file)
        (log_file := temp_directory / "log.build").write_text("log file")

        self.task.work_request_id = 5

        self.task.upload_artifacts(temp_directory, execution_success=False)

        # Only one artifact is uploaded: PackageBuildLog
        # (because execution_success=False)
        self.assertEqual(debusine_mock.upload_artifact.call_count, 1)

        debusine_mock.upload_artifact.assert_has_calls(
            [
                call(
                    PackageBuildLog.create(
                        file=log_file,
                        source=dsc["Source"],
                        version=dsc["Version"],
                    ),
                    workspace=self.task.workspace_name,
                    work_request=self.task.work_request_id,
                )
            ]
        )

    def test_upload_validation_errors(self):
        """upload_artifacts() does not try to upload deb files."""
        build_directory = self.create_temporary_directory()

        debusine_mock = self.patch_sbuild_debusine()

        # Add log.build file: the PackageBuildLog is created
        self.task._dsc_file = self.create_temporary_file(suffix=".dsc")
        dsc_data = self.write_dsc_example_file(self.task._dsc_file)

        (build_file := build_directory / "file.build").write_text("the build")

        # Add .changes file: Upload is created
        changes_file = build_directory / "python-network.changes"

        # Add *.deb file
        (file1 := build_directory / "package.deb").write_text("test")
        self.write_changes_file(changes_file, [file1])

        file1.write_text("Hash will be unexpected")

        validation_failed_log_remote_id = 25
        debusine_mock.upload_artifact.return_value = RemoteArtifact(
            id=validation_failed_log_remote_id, workspace="Test"
        )

        # Upload the artifacts
        self.task.upload_artifacts(build_directory, execution_success=True)

        source = dsc_data["Source"]
        version = dsc_data["Version"]

        calls = [
            call(
                PackageBuildLog.create(
                    file=build_file, source=source, version=version
                ),
                workspace=self.task.workspace_name,
                work_request=None,
            ),
            call(
                Upload.create(changes_file=changes_file, exclude_files=set()),
                workspace=self.task.workspace_name,
                work_request=None,
            ),
        ]

        debusine_mock.upload_artifact.assert_has_calls(calls)
        self.assertEqual(debusine_mock.upload_artifact.call_count, len(calls))

    def _test_configure_for_execution(
        self, *, configure_task: dict[str, Any], configure_for_execution_called
    ):
        """Test configure_for_execution()."""
        # Set the backend
        self.configure_task(**configure_task)

        # Create temporary directory and example file
        directory = self.create_temporary_directory()
        self.task._dsc_file = dsc_file = directory / "hello.dsc"
        self.write_dsc_example_file(dsc_file)

        # Perform the test with mocking
        with mock.patch.object(
            self.task, "_prepare_executor", autospec=True
        ) as mock_prepare:
            self.assertTrue(self.task.configure_for_execution(directory))

        # Assert
        if configure_for_execution_called:
            mock_prepare.assert_called_with()
        else:
            mock_prepare.assert_not_called()

    def test_configure_for_execution_unshare(self):
        """configure_for_execution() with unshare: call _prepare_executor."""
        configure_task = {
            "override": {"backend": "unshare", "environment": 42},
            "remove": ["distribution"],
        }
        self._test_configure_for_execution(
            configure_task=configure_task, configure_for_execution_called=True
        )

    def test_configure_for_execution_schroot(self):
        """configure_for_execution() with schroot: no call _prepare_executor."""
        configure_task = {
            "override": {"backend": "schroot", "distribution": "bookworm"},
            "remove": ["environment"],
        }
        self._test_configure_for_execution(
            configure_task=configure_task, configure_for_execution_called=False
        )

    def test_configure_for_execution_requires_dsc_file_set(self):
        """configure_for_execution() requires self._dsc_file to be set."""
        download_directory = self.create_temporary_directory()

        with mock.patch.object(
            self.task, "_prepare_executor", autospec=True
        ) as mock_prepare:
            self.assertFalse(
                self.task.configure_for_execution(download_directory)
            )

        mock_prepare.assert_not_called()

        log_file_contents = (
            Path(self.task._debug_log_files_directory.name)
            / "configure_for_execution.log"
        ).read_text()

        self.assertEqual(log_file_contents, "Input source package not found.\n")

    def test_configure_for_execution_requires_dsc_file_exists(self):
        """configure_for_execution() requires self._dsc_file to exist."""
        download_directory = self.create_temporary_directory()
        self.task._dsc_file = download_directory / "foo.dsc"

        with mock.patch.object(
            self.task, "_prepare_executor", autospec=True
        ) as mock_prepare:
            self.assertFalse(
                self.task.configure_for_execution(download_directory)
            )

        mock_prepare.assert_not_called()

        log_file_contents = (
            Path(self.task._debug_log_files_directory.name)
            / "configure_for_execution.log"
        ).read_text()

        self.assertEqual(log_file_contents, "Input source package not found.\n")

    def patch_run_cmd(self) -> MagicMock:
        """Patch self.task.run_cmd. Return its mock."""
        patcher_build = mock.patch.object(self.task, "run_cmd")
        mocked = patcher_build.start()
        self.addCleanup(patcher_build.stop)

        return mocked

    def test_check_directory_for_consistency_errors(self):
        """The build directory is checked for consistency errors."""

        def populate_sbuild_directory(
            cmd: list[str], working_directory: Path, **kwargs: Any  # noqa: U100
        ) -> int:
            (dsc := working_directory / "hello.dsc").touch()
            self.write_changes_file(working_directory / "hello.changes", [dsc])
            dsc.unlink()
            return 0

        self.task.CAPTURE_OUTPUT_FILENAME = "cmd.out"
        self.patch_fetch_input(return_value=True)
        run_cmd_mock = self.patch_run_cmd()
        run_cmd_mock.side_effect = populate_sbuild_directory
        self.patch_configure_for_execution(return_value=True)
        self.patch_upload_work_request_debug_logs()
        self.patch_executor()
        self.assertFalse(self.task.execute())

    def patch_upload_package_build_log(self) -> MagicMock:
        """Patch self.task._upload_package_build_log. Return its mock."""
        patcher = mock.patch.object(
            self.task, "_upload_package_build_log", autospec=True
        )
        mocked = patcher.start()
        self.addCleanup(patcher.stop)
        return mocked

    def test_upload_artifacts_create_relation_build_log_to_source(self):
        """upload_artifacts() create relation from build log to source."""
        temp_directory = self.create_temporary_directory()

        # Add log.build file
        self.task._dsc_file = self.create_temporary_file(suffix=".dsc")
        self.write_dsc_example_file(self.task._dsc_file)

        source_artifact_id = 8
        self.task._source_artifacts_ids = [source_artifact_id]

        upload_package_build_log_mocked = self.patch_upload_package_build_log()
        upload_package_build_log_mocked.return_value = RemoteArtifact(
            id=7, workspace="not-relevant"
        )

        (temp_directory / "log.build").write_text("the log file")

        debusine_mock = self.patch_sbuild_debusine()

        self.task.upload_artifacts(temp_directory, execution_success=False)

        debusine_mock.relation_create.assert_called_with(
            upload_package_build_log_mocked.return_value.id,
            source_artifact_id,
            "relates-to",
        )

    def test_upload_artifacts_search_for_dsc_file(self):
        """
        upload_artifacts() is called with _dsc_file already set.

        The .dsc file was set by configure_for_execution().
        """
        build_directory = self.create_temporary_directory()
        dsc_file = self.create_temporary_file(suffix=".dsc")

        self.patch_sbuild_debusine()

        self.task._dsc_file = dsc_file

        dsc_data = self.write_dsc_example_file(dsc_file)

        upload_package_build_log_mocked = self.patch_upload_package_build_log()

        self.task.upload_artifacts(build_directory, execution_success=False)

        upload_package_build_log_mocked.assert_called_with(
            build_directory, dsc_data["Source"], dsc_data["Version"]
        )

    def test_upload_artifacts_no_dsc_file(self):
        """upload_artifacts() called without an existing .dsc file."""
        build_directory = self.create_temporary_directory()

        debusine_mock = self.patch_sbuild_debusine()

        self.task.upload_artifacts(build_directory, execution_success=False)

        debusine_mock.upload_artifact.assert_not_called()

    def test_upload_artifacts_no_build_log(self):
        """upload_artifacts() called without an existing build log."""
        build_directory = self.create_temporary_directory()
        self.task._dsc_file = self.create_temporary_file(suffix=".dsc")
        self.write_dsc_example_file(self.task._dsc_file)
        debusine_mock = self.patch_sbuild_debusine()

        self.task.upload_artifacts(build_directory, execution_success=False)

        debusine_mock.upload_artifact.assert_not_called()

    def test_upload_artifacts_deb_but_no_changes(self):
        """upload_artifacts() called with a .deb but no .changes file."""
        build_directory = self.create_temporary_directory()
        self.task._dsc_file = self.create_temporary_file(suffix=".dsc")
        dsc_data = self.write_dsc_example_file(self.task._dsc_file)
        self.write_deb_file(build_directory / "hello_1.0_amd64.deb")
        debusine_mock = self.patch_sbuild_debusine()
        self.task.work_request_id = 8

        self.task.upload_artifacts(build_directory, execution_success=True)

        workspace_name = self.task.workspace_name
        expected_upload_artifact_calls = [
            call(
                BinaryPackage.create(
                    file=build_directory / "hello_1.0_amd64.deb",
                ),
                workspace=workspace_name,
                work_request=self.task.work_request_id,
            ),
            call(
                BinaryPackages.create(
                    srcpkg_name=dsc_data["Source"],
                    srcpkg_version=dsc_data["Version"],
                    version=dsc_data["Version"],
                    architecture="amd64",
                    files=[build_directory / "hello_1.0_amd64.deb"],
                ),
                workspace=workspace_name,
                work_request=self.task.work_request_id,
            ),
        ]
        self.assertEqual(
            debusine_mock.upload_artifact.call_count,
            len(expected_upload_artifact_calls),
        )
        debusine_mock.upload_artifact.assert_has_calls(
            expected_upload_artifact_calls
        )

    def test_upload_artifacts(self):
        """upload_artifacts() call upload_artifact() with the artifacts."""
        temp_directory = self.create_temporary_directory()

        # Add log.build file: the PackageBuildLog is uploaded
        self.task._dsc_file = self.create_temporary_file(suffix=".dsc")

        dsc_data = self.write_dsc_example_file(self.task._dsc_file)

        (build_log_file := temp_directory / "log.build").write_text(
            "the log file"
        )

        debusine_mock = self.patch_sbuild_debusine()

        # Add two files: BinaryPackages is uploaded with them
        self.write_deb_file(file1 := temp_directory / "hello_1.0_amd64.deb")
        self.write_deb_file(file2 := temp_directory / "hello_1.0_amd64.udeb")

        # Add .changes file: Upload is uploaded
        changes_file = temp_directory / "python-network.changes"
        self.write_changes_file(changes_file, [file1, file2])

        self.task.work_request_id = 8

        self.task.upload_artifacts(temp_directory, execution_success=True)

        workspace_name = self.task.workspace_name

        expected_upload_artifact_calls = [
            call(
                PackageBuildLog.create(
                    file=build_log_file,
                    source=dsc_data["Source"],
                    version=dsc_data["Version"],
                ),
                workspace=workspace_name,
                work_request=self.task.work_request_id,
            ),
            call(
                BinaryPackage.create(file=file1),
                workspace=workspace_name,
                work_request=self.task.work_request_id,
            ),
            call(
                BinaryPackage.create(file=file2),
                workspace=workspace_name,
                work_request=self.task.work_request_id,
            ),
            call(
                BinaryPackages.create(
                    srcpkg_name=dsc_data["Source"],
                    srcpkg_version=dsc_data["Version"],
                    version=dsc_data["Version"],
                    architecture="amd64",
                    files=[file1, file2],
                ),
                workspace=workspace_name,
                work_request=self.task.work_request_id,
            ),
            call(
                Upload.create(
                    changes_file=changes_file,
                ),
                workspace=workspace_name,
                work_request=self.task.work_request_id,
            ),
        ]
        self.assertEqual(
            debusine_mock.upload_artifact.call_count,
            len(expected_upload_artifact_calls),
        )
        debusine_mock.upload_artifact.assert_has_calls(
            expected_upload_artifact_calls
        )

        # Ensure that _upload_artifacts() tried to create relations
        # (the exact creation of relations is tested in
        # test_create_relations).
        self.assertGreaterEqual(debusine_mock.relation_create.call_count, 1)

    def create_remote_binary_packages_relations(
        self,
    ) -> dict[str, RemoteArtifact]:
        """
        Create RemoteArtifacts and call Sbuild method to create relations.

        Call self.task._create_remote_binary_packages_relations().

        :return: dictionary with the remote artifacts.
        """  # noqa: D402
        workspace = "debian"
        artifacts = {
            "build_log": RemoteArtifact(id=1, workspace=workspace),
            "binary_upload": RemoteArtifact(id=2, workspace=workspace),
            "binary_package": RemoteArtifact(id=3, workspace=workspace),
        }
        self.task._create_remote_binary_packages_relations(
            artifacts["build_log"],
            artifacts["binary_upload"],
            [artifacts["binary_package"]],
        )

        return artifacts

    def assert_create_remote_binary_packages_relations(
        self,
        debusine_mock,
        artifacts: dict[str, RemoteArtifact],
        *,
        source_artifacts_ids: list[int],
    ):
        """Assert that debusine_mock.relation_create was called correctly."""
        expected_relation_create_calls = [
            call(
                artifacts["build_log"].id,
                artifacts["binary_package"].id,
                "relates-to",
            ),
            call(
                artifacts["binary_upload"].id,
                artifacts["binary_package"].id,
                "extends",
            ),
            call(
                artifacts["binary_upload"].id,
                artifacts["binary_package"].id,
                "relates-to",
            ),
        ]

        for source_artifact_id in source_artifacts_ids:
            expected_relation_create_calls.append(
                call(
                    artifacts["binary_package"].id,
                    source_artifact_id,
                    "built-using",
                )
            )

        self.assertEqual(
            debusine_mock.relation_create.call_count,
            len(expected_relation_create_calls),
        )

        debusine_mock.relation_create.assert_has_calls(
            expected_relation_create_calls, any_order=True
        )

    def test_create_relations(self):
        """create_relations() call relation_create()."""
        for source_artifacts_ids in [[], [9, 10]]:
            with self.subTest(source_artifacts_ids=source_artifacts_ids):
                debusine_mock = self.patch_sbuild_debusine()

                self.task._source_artifacts_ids = source_artifacts_ids

                artifacts = self.create_remote_binary_packages_relations()

                self.assert_create_remote_binary_packages_relations(
                    debusine_mock,
                    artifacts,
                    source_artifacts_ids=source_artifacts_ids,
                )

    @staticmethod
    def configuration(host_architecture: str, build_components: list[str]):
        """Return configuration with host_architecture and build_components."""
        return {
            "input": {
                "source_artifact": 5,
            },
            "distribution": "bullseye",
            "host_architecture": host_architecture,
            "build_components": build_components,
            "backend": "schroot",
        }

    def create_deb_udeb_files(
        self, directory: Path, *, architecture: str
    ) -> None:
        """Create deb and udeb files for architecture."""
        self.write_deb_file(directory / f"hello_1.0_{architecture}.deb")
        self.write_deb_file(directory / f"hello_1.0_{architecture}.udeb")

    def create_and_upload_binary_packages(
        self,
        host_architecture: str,
        build_components: list[str],
        *,
        create_debs_for_architectures: list[str],
    ) -> tuple[MagicMock, Path]:
        """
        Set environment and call Sbuild._upload_binary_packages().

        Return debusine.upload_artifact mock and Path with the files.
        """
        temp_directory = self.create_temporary_directory()

        dsc_file = temp_directory / "file.dsc"

        self.write_dsc_example_file(dsc_file)

        for architecture in create_debs_for_architectures:
            self.create_deb_udeb_files(
                temp_directory, architecture=architecture
            )

        self.configure_task(
            self.configuration(host_architecture, build_components)
        )

        dsc = utils.read_dsc(dsc_file)
        assert dsc is not None

        debusine_mock = self.patch_sbuild_debusine()

        self.task._upload_binary_packages(temp_directory, dsc)

        return debusine_mock.upload_artifact, temp_directory

    def assert_upload_binary_packages_with_files(
        self, upload_artifact_mock: MagicMock, expected_files: list[list[Path]]
    ):
        """
        Assert debusine.upload_artifact() was called with expected artifacts.

        Assert that there is one call in upload_artifact_mock for each
        individual path in expected_files, and one call for each of the
        sub-lists of paths; and assert that the file names on each
        upload_artifact() are the expected ones (expected_files[i]).
        """
        self.assertEqual(
            upload_artifact_mock.call_count,
            sum(len(files) for files in expected_files) + len(expected_files),
        )

        for call_args, files in zip(
            upload_artifact_mock.call_args_list,
            itertools.chain.from_iterable(
                [[file] for file in files] + [files] for files in expected_files
            ),
        ):
            expected_filenames = {file.name for file in files}
            self.assertEqual(call_args[0][0].files.keys(), expected_filenames)

    def test_upload_binary_packages_build_components_any(self):
        r"""
        upload_binary_packages() upload \*_host.deb packages.

        build_components()
        """
        (
            upload_artifact_mock,
            temp_directory,
        ) = self.create_and_upload_binary_packages(
            "amd64", ["any"], create_debs_for_architectures=["amd64", "all"]
        )

        expected_files = sorted(temp_directory.glob("*_amd64.*deb"))

        self.assert_upload_binary_packages_with_files(
            upload_artifact_mock, [expected_files]
        )

    def test_upload_binary_packages_build_components_all(self):
        r"""
        upload_binary_packages() upload \*_all.deb packages.

        build_components is "all". All packages are created (host and all)
        but only \*_all.deb are uploaded.
        """
        (
            upload_artifact_mock,
            temp_directory,
        ) = self.create_and_upload_binary_packages(
            "amd64", ["all"], create_debs_for_architectures=["amd64", "all"]
        )

        expected_files = sorted(temp_directory.glob("*_all.*deb"))

        self.assert_upload_binary_packages_with_files(
            upload_artifact_mock, [expected_files]
        )

    def test_upload_binary_packages_build_components_any_all(self):
        r"""upload_binary_packages() upload \*_host.deb and \*_all.deb files."""
        (
            upload_artifact_mock,
            temp_directory,
        ) = self.create_and_upload_binary_packages(
            "amd64",
            ["any", "all"],
            create_debs_for_architectures=["amd64", "all"],
        )

        expected_files_amd64 = sorted(temp_directory.glob("*_amd64.*deb"))
        expected_files_all = sorted(temp_directory.glob("*_all.*deb"))

        self.assert_upload_binary_packages_with_files(
            upload_artifact_mock, [expected_files_amd64, expected_files_all]
        )

    def test_upload_binary_packages_build_components_any_all_without_all(self):
        r"""
        upload_binary_packages() upload \*_host.deb packages.

        Only host architecture packages exist: only one package created.
        """
        (
            upload_artifact_mock,
            temp_directory,
        ) = self.create_and_upload_binary_packages(
            "amd64", ["any", "all"], create_debs_for_architectures=["amd64"]
        )

        expected_files_amd64 = sorted(temp_directory.glob("*_amd64.*deb"))

        self.assert_upload_binary_packages_with_files(
            upload_artifact_mock, [expected_files_amd64]
        )

    def test_create_binary_package_local_artifacts(self):
        """
        _create_binary_package_local_artifact(): return BinaryPackage(s).

        Assert that it returns the expected BinaryPackage and BinaryPackages
        artifacts.
        """
        build_directory = self.create_temporary_directory()

        dsc_file = build_directory / "package.dsc"

        dsc_data = self.write_dsc_example_file(dsc_file)

        package_file = build_directory / "package_1.0_amd64.deb"
        self.write_deb_file(
            package_file,
            source_name=dsc_data["Source"],
            source_version=dsc_data["Version"],
        )

        artifacts = self.task._create_binary_package_local_artifacts(
            build_directory,
            utils.read_dsc(dsc_file),
            architecture="amd64",
            suffixes=[".deb"],
        )

        self.assertEqual(
            artifacts,
            [
                BinaryPackage(
                    category=BinaryPackage._category,
                    files={package_file.name: package_file},
                    data={
                        "srcpkg_name": dsc_data["Source"],
                        "srcpkg_version": dsc_data["Version"],
                        "deb_fields": {
                            "Package": "package",
                            "Version": "1.0",
                            "Architecture": "amd64",
                            "Maintainer": "Example Person <person@example.org>",
                            "Description": "Example description",
                            "Source": (
                                f"{dsc_data['Source']} ({dsc_data['Version']})"
                            ),
                        },
                        "deb_control_files": ["control"],
                    },
                ),
                BinaryPackages(
                    category=BinaryPackages._category,
                    files={package_file.name: package_file},
                    data={
                        "srcpkg_name": dsc_data["Source"],
                        "srcpkg_version": dsc_data["Version"],
                        "version": dsc_data["Version"],
                        "architecture": "amd64",
                        "packages": ["package"],
                    },
                ),
            ],
        )
