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

"""Tests for the debusine Cli Artifact commands."""

import datetime as dt
import math
import os
import textwrap
from itertools import count
from pathlib import Path
from typing import Any, Literal
from unittest import mock
from unittest.mock import MagicMock
from urllib.parse import quote, urljoin

import yaml

from debusine.artifacts import LocalArtifact
from debusine.artifacts.models import ArtifactCategory
from debusine.client import exceptions
from debusine.client.commands.base import DebusineCommand
from debusine.client.commands.tests.base import BaseCliTests
from debusine.client.debusine import Debusine
from debusine.client.exceptions import DebusineError
from debusine.client.models import (
    ArtifactResponse,
    RelationResponse,
    RelationType,
    RelationsResponse,
    RemoteArtifact,
    model_to_json_serializable_dict,
)
from debusine.test.test_utils import (
    create_artifact_response,
    create_remote_artifact,
)


class CliCreateArtifactTests(BaseCliTests):
    """Tests for the Cli functionality related to create artifacts."""

    def create_files_to_upload(self) -> list[Path]:
        """Create three files to upload in the temp directory."""
        return [
            self.create_temporary_file(prefix="pkg_1.0.dsc", contents=b"test"),
            self.create_temporary_file(
                prefix="pkg_1.0.tar.gz", contents=b"test2"
            ),
            self.create_temporary_file(prefix="empty.txt", contents=b""),
        ]

    def patch_create_artifact(self) -> MagicMock:
        """Patch Debusine.create_artifact."""
        patcher_create_artifact = mock.patch.object(
            Debusine, "artifact_create", autospec=True
        )
        mocked_create_artifact = patcher_create_artifact.start()

        self.addCleanup(patcher_create_artifact.stop)
        return mocked_create_artifact

    def assert_artifact_is_created(
        self,
        mocked_create_artifact: MagicMock,
        expected_workspace: str,
        expected_artifact_id: int,
        expected_files_to_upload_count: int,
        stdout: str,
        stderr: str,
        expected_expire_at: dt.datetime | None = None,
        data_from_stdin: bool = False,
    ) -> LocalArtifact[Any]:
        """
        Assert that mocked_create_artifact was called only once and stdout.

        In stdout expects that CLI printed into stdout the information
        about the recently created artifact.

        Return LocalArtifact that would be sent to the server.

        :param mocked_create_artifact: mock to assert that was called only once.
        :param expected_workspace: expected created workspace.
        :param expected_artifact_id: expected created artifact id.
        :param expected_files_to_upload_count: expected files to upload.
        :param stdout: contains Cli.execute() output to verify expected output.
        :param stderr: contains Cli.execute() output to verify expected output.
        :param expected_expire_at: expected artifact expiry time.
        :param data_from_stdin: if True, input data came from stdin.
        """
        mocked_create_artifact.assert_called_once()

        base_url = self.get_base_url(self.default_server)
        scope = self.servers[self.default_server]["scope"]
        artifact_url = urljoin(
            base_url,
            f"{quote(scope)}/{quote(expected_workspace)}/"
            f"artifact/{expected_artifact_id}/",
        )

        expected = yaml.safe_dump(
            {
                "result": "success",
                "message": f"New artifact created: {artifact_url}",
                "artifact_id": expected_artifact_id,
                "files_to_upload": expected_files_to_upload_count,
                "expire_at": expected_expire_at,
            },
            sort_keys=False,
            width=math.inf,
        )
        if data_from_stdin:
            expected = f"---\n{expected}"

        self.assertEqual(stdout, expected)
        self.assertEqual(stderr, "")

        local_artifact = mocked_create_artifact.call_args[0][1]
        assert isinstance(local_artifact, LocalArtifact)

        return local_artifact

    def create_artifact_via_cli(
        self,
        category: str,
        *,
        workspace: str | None = None,
        files_to_upload: list[str] | None = None,
        upload_base_directory: Path | None = None,
        expire_at: dt.datetime | None = None,
        data: bytes | None = None,
        data_file: Path | Literal["-"] | None = None,
        stdin: dict[str, Any] | None = None,
        create_artifact_return_value: ArtifactResponse | None = None,
        create_artifact_side_effect: Any = None,
        assert_system_exit_code: int | None = None,
    ) -> tuple[str, str, MagicMock]:
        """
        Call CLI create-artifact with the correct parameters.

        Patch Debusine.create_artifact before executing the command.

        :param category: category of the artifact.
        :param workspace: add ["--workspace", workspace] options.
        :param files_to_upload: list of file paths to upload.
        :param expire_at: datetime (for --expire-at option).
        :param data: data passed to the artifact (creates a temporary file
          and passes the temporary file path via --data).
        :param data_file: data file (for --data options).
        :param stdin: dump stdin into YAML and writes it into stdin.
        :param create_artifact_return_value: return value for the
          Debusine.create_artifact mock.
        :param create_artifact_side_effect: side effect for the
          Debusine.create_artifact mock.
        :param assert_system_exit_code: assert that the create-artifact
          command raises SystemExit(X).

        Return tuple with stderr, stdout and the patched
        Debusine.create_artifact
        """
        mocked_create_artifact = self.patch_create_artifact()

        if create_artifact_return_value is not None:
            mocked_create_artifact.return_value = create_artifact_return_value

        if create_artifact_side_effect is not None:
            mocked_create_artifact.side_effect = create_artifact_side_effect

        create_cli_options = [category, "--yaml"]

        if workspace is not None:
            create_cli_options.extend(["--workspace", workspace])

        if expire_at is not None:
            create_cli_options.extend([f"--expire-at={expire_at}"])

        if data is not None:
            data_file_temp = self.create_temporary_file(
                prefix="create-artifact", contents=data
            )
            create_cli_options.extend(["--data", str(data_file_temp)])

        if data_file is not None:
            if data is not None:
                raise ValueError(
                    "'data_file' parameter cannot be used with 'data'"
                )  # pragma: no cover
            create_cli_options.extend(["--data", str(data_file)])

        if files_to_upload is not None:
            create_cli_options.extend(["--upload", *files_to_upload])

        if upload_base_directory is not None:
            create_cli_options.extend(
                ["--upload-base-directory", upload_base_directory.as_posix()]
            )

        if stdin is not None:
            self.enterContext(self.patch_sys_stdin_read(yaml.safe_dump(stdin)))

        cli = self.create_cli(["artifact", "create", *create_cli_options])

        if assert_system_exit_code is None:
            stderr, stdout = self.capture_output(cli.execute)
        else:
            stderr, stdout = self.capture_output(
                cli.execute, assert_system_exit_code=assert_system_exit_code
            )

        return stderr, stdout, mocked_create_artifact

    def assert_create_artifact_success(
        self,
        files_to_upload: list[Path],
        upload_base_directory: Path | None = None,
        expire_at: dt.datetime | None = None,
    ) -> None:
        """
        Cli parse the command line and calls Debusine.create_artifact.

        Verify that the correct files_to_upload has been used.
        """
        artifact_category = ArtifactCategory.WORK_REQUEST_DEBUG_LOGS
        artifact_id = 2
        workspace = "test"

        # Mock Debusine.upload_files: will be used to verify the arguments
        # that debusine client used to try to upload the files
        upload_files_patcher = mock.patch.object(
            Debusine, "upload_files", autospec=True
        )
        upload_files_patcher.start()
        self.addCleanup(upload_files_patcher.stop)

        # Prepare dictionary with the paths in the artifact and the
        # FileModelRequest objects
        paths_in_artifact_to_file_models = {}
        for file_to_upload in files_to_upload:
            if file_to_upload.is_absolute():
                local_file_path = file_to_upload
                artifact_path = file_to_upload.name
            else:
                local_file_path = Path(os.getcwd()).joinpath(file_to_upload)
                artifact_path = str(file_to_upload.name)

            paths_in_artifact_to_file_models[artifact_path] = local_file_path

        create_artifact_return_value = create_artifact_response(
            base_url=self.get_base_url(self.default_server),
            scope=self.servers[self.default_server]["scope"],
            id=artifact_id,
            workspace=workspace,
            files_to_upload=list(paths_in_artifact_to_file_models.keys()),
        )

        (
            stderr_actual,
            stdout_actual,
            mocked_create_artifact,
        ) = self.create_artifact_via_cli(
            artifact_category,
            workspace=workspace,
            create_artifact_return_value=create_artifact_return_value,
            files_to_upload=list(map(str, files_to_upload)),
            upload_base_directory=upload_base_directory,
            expire_at=expire_at,
        )

        # Assert the call happened as expected
        artifact_request = self.assert_artifact_is_created(
            mocked_create_artifact,
            workspace,
            artifact_id,
            len(paths_in_artifact_to_file_models),
            stdout_actual,
            stderr_actual,
            expire_at,
        )
        self.assertEqual(artifact_request.category, artifact_category)
        self.assertEqual(
            artifact_request.files, paths_in_artifact_to_file_models
        )

    def test_create_artifact_absolute_file_paths(self) -> None:
        """Test create an artifact uploading files by absolute path."""
        self.assert_create_artifact_success(self.create_files_to_upload())

    def test_create_artifact_relative_file_path(self) -> None:
        """Test create an artifact uploading a file by relative to cwd."""
        file_path = self.create_temporary_file(directory=os.getcwd())

        file_name = file_path.name

        self.assert_create_artifact_success([Path(file_name)])

    def test_create_artifact_relative_file_path_in_subdirectory(self) -> None:
        """Test create an artifact uploading a file from a subdir of cwd."""
        temp_directory = self.create_temporary_directory(directory=os.getcwd())
        file_path = temp_directory / "file1.data"
        file_path.write_bytes(b"test")

        self.assert_create_artifact_success(
            [file_path.relative_to(os.getcwd())]
        )

    def test_create_artifact_upload_base_directory(self) -> None:
        """Create artifact with an abs file path in a abs path directory."""
        temp_directory = self.create_temporary_directory()

        (file := temp_directory / "file1.data").write_bytes(b"")

        self.assert_create_artifact_success([file], temp_directory)

    def test_create_artifact_expire_at(self) -> None:
        """Test create an artifact uploading files by absolute path."""
        expire_at = dt.datetime.now() + dt.timedelta(days=1)
        self.assert_create_artifact_success(
            self.create_files_to_upload(), expire_at=expire_at
        )

    def test_create_artifact_debusine_upload_files_fail(self) -> None:
        """Debusine.create_artifact fails: cannot connect to the server."""
        files_to_upload = self.create_files_to_upload()

        upload_files_patcher = mock.patch.object(
            Debusine, "upload_files", autospec=True
        )
        mocked_upload_files = upload_files_patcher.start()

        # Debusine.upload_files cannot connect to the server
        mocked_upload_files.side_effect = exceptions.ClientConnectionError
        self.addCleanup(upload_files_patcher.stop)

        workspace = "workspace"
        create_artifact_return_value = create_artifact_response(
            base_url=self.get_base_url(self.default_server),
            scope=self.servers[self.default_server]["scope"],
            id=2,
            workspace=workspace,
            files_to_upload=list(map(lambda p: p.name, files_to_upload)),
        )

        stderr, stdout, _ = self.create_artifact_via_cli(
            ArtifactCategory.WORK_REQUEST_DEBUG_LOGS,
            workspace=workspace,
            create_artifact_return_value=create_artifact_return_value,
            files_to_upload=list(map(str, files_to_upload)),
            assert_system_exit_code=3,
        )
        self.assertTrue(stderr.startswith("Error connecting to debusine:"))

    def test_create_artifact_read_stdin(self) -> None:
        """Cli create an artifact reading the data from stdin."""
        artifact_id = 2
        artifact_category = ArtifactCategory.WORK_REQUEST_DEBUG_LOGS
        workspace = "default"
        data: dict[str, Any] = {}

        create_artifact_return_value = create_artifact_response(
            base_url=self.get_base_url(self.default_server),
            scope=self.servers[self.default_server]["scope"],
            id=artifact_id,
            workspace=workspace,
        )

        stderr, stdout, mocked_create_artifact = self.create_artifact_via_cli(
            artifact_category,
            workspace=workspace,
            create_artifact_return_value=create_artifact_return_value,
            stdin=data,
            data_file="-",
        )

        artifact_request = self.assert_artifact_is_created(
            mocked_create_artifact,
            workspace,
            artifact_id,
            0,
            stdout,
            stderr,
            data_from_stdin=True,
        )
        self.assertEqual(artifact_request.data, data)

    def test_create_artifact_invalid_data_file_type(self) -> None:
        """Cli try to create an artifact, data file does not contain a dict."""
        stderr, stdout, mocked_create_artifact = self.create_artifact_via_cli(
            ArtifactCategory.SOURCE_PACKAGE,
            data=b"some-text",
            assert_system_exit_code=3,
        )

        self.assertEqual(
            stderr, "Error: data must be a dictionary. It is: str\n"
        )

    def test_create_artifact_invalid_yaml_in_data_file(self) -> None:
        """Cli try to create an artifact, data file contains invalid YAML."""
        stderr, stdout, mocked_create_artifact = self.create_artifact_via_cli(
            ArtifactCategory.SOURCE_PACKAGE,
            data=b'"',
            assert_system_exit_code=3,
        )

        self.assertRegex(stderr, "^Error parsing YAML:")
        self.assertRegex(stderr, r"Fix the YAML data\n$")

    def test_create_artifact_cannot_read_data(self) -> None:
        """Cli try to create an artifact with a data file cannot be read."""
        data_file = "/tmp/debusine-test-file-does-not-exist"
        stderr, stdout, mocked_create_artifact = self.create_artifact_via_cli(
            ArtifactCategory.SOURCE_PACKAGE,
            data_file=Path(data_file),
            assert_system_exit_code=2,
        )

        # argparse will show an error message containing the file path
        # (the rest of the message is up to argparse)
        self.assertRegex(stderr, data_file)

    def test_create_artifact_without_workspace_success(self) -> None:
        """Cli try to create an artifact with workspace=None."""
        artifact_id = 2
        expected_workspace = "default"
        data = {
            "name": "name",
            "version": "1.0",
            "type": "dpkg",
            "dsc_fields": {},
        }
        files_to_upload: list[str] = []

        create_artifact_return_value = create_artifact_response(
            base_url=self.get_base_url(self.default_server),
            scope=self.servers[self.default_server]["scope"],
            id=artifact_id,
            workspace=expected_workspace,
            files_to_upload=files_to_upload,
        )
        artifact_category = ArtifactCategory.SOURCE_PACKAGE
        (
            stderr_actual,
            stdout_actual,
            mocked_create_artifact,
        ) = self.create_artifact_via_cli(
            artifact_category,
            data=yaml.safe_dump(data).encode("utf-8"),
            create_artifact_return_value=create_artifact_return_value,
        )

        self.assert_artifact_is_created(
            mocked_create_artifact,
            expected_workspace,
            artifact_id,
            len(files_to_upload),
            stdout_actual,
            stderr_actual,
        )

    def test_create_artifact_workspace_not_found(self) -> None:
        """Cli try to create an artifact for a non-existing workspace."""
        workspace_name = "does-not-exist"
        title_error = f'Workspace "{workspace_name}" cannot be found'

        create_artifact_side_effect = DebusineError(title=title_error)

        (
            stderr_actual,
            stdout_actual,
            mocked_create_artifact,
        ) = self.create_artifact_via_cli(
            ArtifactCategory.WORK_REQUEST_DEBUG_LOGS,
            workspace=workspace_name,
            create_artifact_side_effect=create_artifact_side_effect,
            assert_system_exit_code=3,
        )

        stdout_expected = yaml.safe_dump(
            {"result": "failure", "error": {"title": title_error}},
            sort_keys=False,
        )

        self.assertEqual(stderr_actual, "")
        self.assertEqual(stdout_actual, stdout_expected)

    def test_create_artifact_with_two_files_of_same_name(self) -> None:
        """Cannot create an artifact with two files having the same path."""
        file_name = "file1"
        # Create a file to be uploaded in a directory
        directory_to_upload1 = self.create_temporary_directory()
        file_in_dir1 = Path(directory_to_upload1) / Path(file_name)
        file_in_dir1.write_bytes(b"test")

        # And create another file, same name, in another directory
        directory_to_upload2 = self.create_temporary_directory()
        file_in_dir2 = Path(directory_to_upload2) / Path(file_name)
        file_in_dir2.write_bytes(b"test")

        paths_to_upload = [str(file_in_dir1), str(file_in_dir2)]

        stderr, stdout, _ = self.create_artifact_via_cli(
            ArtifactCategory.WORK_REQUEST_DEBUG_LOGS,
            workspace="some-workspace",
            files_to_upload=paths_to_upload,
            assert_system_exit_code=3,
        )

        self.assertEqual(
            stderr,
            f"Cannot create artifact: File with the same path ({file_name}) "
            f"is already in the artifact "
            f'("{file_in_dir1}" and "{file_in_dir2}")\n',
        )

    def test_create_artifact_invalid_category(self) -> None:
        """Cli try to create an artifact with a category that does not exist."""
        stderr, stdout, mocked_create_artifact = self.create_artifact_via_cli(
            "missing:does-not-exist",
            assert_system_exit_code=2,
        )


class CliImportDebianArtifactTests(BaseCliTests):
    """
    Tests for the debian artifact import CLI.

    Mocking at the upload_artifact level.

    Remote sources are tested through the DGet test suite, so not repeated here.
    """

    def create_dsc(self, directory: Path | None = None) -> list[Path]:
        """
        Create a simple Debian source package in a temporary directory.

        Return a list of the created Paths. The first item is the dsc.
        """
        if not directory:
            directory = self.create_temporary_directory()
        orig = directory / "pkg_1.0.orig.tar.gz"
        orig.write_bytes(b"I'm a tarball")
        dsc = directory / "pkg_1.0.dsc"
        self.write_dsc_file(dsc, [orig], version="1.0")
        return [dsc, orig]

    def create_upload(
        self,
        source: bool,
        binary: bool,
        extra_dsc: bool = False,
        binnmu: bool = False,
    ) -> list[Path]:
        """
        Create a simple Debian upload in a temporary directory.

        Return a list of the created Paths. The first item is the changes.
        """
        directory = self.create_temporary_directory()
        files = []
        if source:
            files += self.create_dsc(directory)
        if extra_dsc:
            extra = directory / "foo_1.0.dsc"
            self.write_dsc_file(extra, [], version="1.0")
            files.append(extra)
        if binary:
            deb_version = "1.0"
            if binnmu:
                deb_version += "+b1"
            deb = directory / f"pkg_{deb_version}_all.deb"
            self.write_deb_file(deb, source_version="1.0")
            files.append(deb)
        changes = directory / "pkg.changes"
        self.write_changes_file(changes, files, version="1.0", binnmu=binnmu)
        return [changes] + files

    def patch_upload_artifact(self) -> MagicMock:
        """
        Patch Debusine.upload_artifact.

        Return different RemoteArtifacts for each request.
        """
        patcher_upload_artifact = mock.patch.object(
            Debusine, "upload_artifact", autospec=True
        )
        mocked_upload_artifact = patcher_upload_artifact.start()

        id_counter = count(10)

        def upload_artifact(
            _self: Debusine,
            local_artifact: LocalArtifact[Any],  # noqa: U100
            *,
            workspace: str | None,  # noqa: U100
            expire_at: dt.datetime | None,  # noqa: U100
        ) -> RemoteArtifact:
            if workspace is None:  # pragma: no cover
                workspace = "System"
            artifact_id = next(id_counter)
            return create_remote_artifact(
                base_url=self.get_base_url(self.default_server),
                scope=self.servers[self.default_server]["scope"],
                id=artifact_id,
                workspace=workspace,
            )

        mocked_upload_artifact.side_effect = upload_artifact

        self.addCleanup(patcher_upload_artifact.stop)
        return mocked_upload_artifact

    def patch_relation_create(self) -> MagicMock:
        """Patch Debusine.relation_create."""
        patcher_relation_create = mock.patch.object(
            Debusine, "relation_create", autospec=True
        )
        mocked_relation_create = patcher_relation_create.start()
        self.addCleanup(patcher_relation_create.stop)
        return mocked_relation_create

    def assert_artifacts_are_uploaded(
        self,
        mocked_upload_artifact: MagicMock,
        expected_workspace: str,
        expected_artifacts: int,
        expected_files: int,
        stdout: str,
        stderr: str,
        extended_artifacts: list[int] | None = None,
        related_artifacts: list[int] | None = None,
        expected_expire_at: dt.datetime | None = None,
    ) -> list[LocalArtifact[Any]]:
        """
        Assert that mocked_upload_artifact uploaded expected_artifacts only.

        In stdout we expect that CLI printed the uploaded artifacts.

        Return LocalArtifacts that would have been uploaded.

        :param mocked_upload_artifact: mock of upload_artifact.
        :param expected_workspace: expected created workspace.
        :param expected_artifacts: expected number of artifacts created.
        :param expected_files: expected number of files uploaded.
        :param extended_artifacts: expected artifact IDs extending the main
            artifact.
        :param related_artifacts: expected artifact IDs relating to the main
            artifact.
        :param stdout: contains Cli.execute() output to verify expected output.
        :param stderr: contains Cli.execute() output to verify expected output.
        """
        self.assertEqual(mocked_upload_artifact.call_count, expected_artifacts)

        base_url = self.get_base_url(self.default_server)
        scope = self.servers[self.default_server]["scope"]
        artifact_url = urljoin(
            base_url, f"{quote(scope)}/{quote(expected_workspace)}/artifact/10/"
        )

        expected = {
            "result": "success",
            "message": f"New artifact created: {artifact_url}",
            "artifact_id": 10,
        }
        if extended_artifacts:
            expected["extends"] = [
                {"artifact_id": id_} for id_ in extended_artifacts
            ]
        if related_artifacts:
            expected["relates_to"] = [
                {"artifact_id": id_} for id_ in related_artifacts
            ]
        expected_str = yaml.safe_dump(
            expected,
            sort_keys=False,
            width=math.inf,
        )

        self.assertEqual(stdout, expected_str)
        self.assertEqual(stderr, "")

        local_artifacts = []
        uploaded_files = set()
        for args, kwargs in mocked_upload_artifact.call_args_list:
            self.assertEqual(len(args), 2)
            debusine, local_artifact = args
            self.assertEqual(kwargs["workspace"], expected_workspace)
            self.assertEqual(kwargs["expire_at"], expected_expire_at)
            self.assertIsInstance(local_artifact, LocalArtifact)
            for file in local_artifact.files:
                uploaded_files.add(file)
            local_artifacts.append(local_artifact)

        self.assertEqual(len(uploaded_files), expected_files)

        return local_artifacts

    def import_debian_artifact(
        self,
        *,
        workspace: str | None = None,
        upload: str | os.PathLike[str] | None = None,
        expire_at: dt.datetime | None = None,
        upload_artifact_side_effect: (
            BaseException | type[BaseException] | None
        ) = None,
        assert_system_exit_code: int | None = None,
        debusine_error: dict[str, Any] | None = None,
        exception_str: str | None = None,
    ) -> tuple[str, str, MagicMock]:
        """
        Call CLI import-debian-artifact with the correct parameters.

        Patch Debusine.upload_artifact before executing the command.

        :param workspace: add ["--workspace", workspace] options.
        :param upload: file path / URL to upload.
        :param expire_at: datetime (for --expire-at option).
        :param upload_artifact_side_effect: side effect for the
          Debusine.create_artifact mock.
        :param assert_system_exit_code: assert that the import-debian-artifact
          command raises SystemExit(X).

        Return tuple with stderr, stdout and the patched
        Debusine.upload_artifact
        """
        mocked_upload_artifact = self.patch_upload_artifact()

        if upload_artifact_side_effect is not None:
            mocked_upload_artifact.side_effect = upload_artifact_side_effect

        args = []

        if workspace is not None:
            args += ["--workspace", workspace]

        if expire_at is not None:
            args += ["--expire-at", str(expire_at)]

        args.append(str(upload))

        cli = self.create_cli(["artifact", "import-debian", *args])

        if debusine_error is not None:
            exception = self.assertShowsError(cli.execute)
            self.assertDebusineError(exception, debusine_error)
            stdout, stderr = "", ""
        elif exception_str is not None:
            exception = self.assertShowsError(cli.execute)
            self.assertEqual(str(exception), exception_str)
            stdout, stderr = "", ""
        elif assert_system_exit_code is not None:
            stderr, stdout = self.capture_output(
                cli.execute, assert_system_exit_code=assert_system_exit_code
            )
        else:
            stderr, stdout = self.capture_output(cli.execute)

        return stderr, stdout, mocked_upload_artifact

    def assert_import_dsc(
        self,
        dsc: Path,
        files: list[Path],
        workspace: str = "test",
        expire_at: dt.datetime | None = None,
    ) -> None:
        """
        Call import-debian-artifact to import a source package.

        Verify that the right artifacts are created and files uploaded.

        Return the uploaded artifacts.
        """
        # Make the request
        (
            stderr_actual,
            stdout_actual,
            mocked_upload_artifact,
        ) = self.import_debian_artifact(
            workspace=workspace,
            upload=str(dsc),
            expire_at=expire_at,
        )

        # Assert the call happened as expected
        artifacts = self.assert_artifacts_are_uploaded(
            mocked_upload_artifact,
            expected_workspace=workspace,
            expected_artifacts=1,
            expected_files=len(files),
            stdout=stdout_actual,
            stderr=stderr_actual,
            expected_expire_at=expire_at,
        )
        self.assertEqual(len(artifacts), 1)
        self.assertSourcePackage(artifacts[0])

    def assertSourcePackage(self, artifact: LocalArtifact[Any]) -> None:
        """Assert that artifact is a .dsc with expected metadata."""
        self.assertEqual(artifact.category, ArtifactCategory.SOURCE_PACKAGE)
        self.assertEqual(artifact.data.name, "hello-traditional")
        self.assertEqual(artifact.data.type, "dpkg")
        self.assertEqual(artifact.data.version, "1.0")
        self.assertEqual(
            artifact.data.dsc_fields["Source"], "hello-traditional"
        )
        self.assertEqual(
            set(artifact.files), {"pkg_1.0.dsc", "pkg_1.0.orig.tar.gz"}
        )

    def assertBinaryPackage(
        self, artifact: LocalArtifact[Any], binnmu: bool = False
    ) -> None:
        """Assert that artifact is a .deb with expected metadata."""
        self.assertEqual(artifact.category, ArtifactCategory.BINARY_PACKAGE)
        version = "1.0"
        if binnmu:
            version += "+b1"
        expected_deb_fields = {
            "Package": "pkg",
            "Version": version,
            "Architecture": "all",
            "Maintainer": "Example Maintainer <example@example.org>",
            "Description": "Example description",
        }
        if binnmu:
            expected_deb_fields["Source"] = "pkg (1.0)"
        expected = {
            "srcpkg_name": "pkg",
            "srcpkg_version": "1.0",
            "deb_fields": expected_deb_fields,
            "deb_control_files": ["control"],
        }
        self.assertEqual(artifact.data, expected)
        self.assertEqual(set(artifact.files), {f"pkg_{version}_all.deb"})

    def assertBinaryPackages(
        self, artifact: LocalArtifact[Any], binnmu: bool = False
    ) -> None:
        """Assert that artifact is a deb set with expected metadata."""
        self.assertEqual(artifact.category, ArtifactCategory.BINARY_PACKAGES)
        deb_version = "1.0"
        if binnmu:
            deb_version += "+b1"
        expected = {
            "srcpkg_name": "hello-traditional",
            "srcpkg_version": "1.0",
            "version": deb_version,
            "architecture": "all",
            "packages": ["pkg"],
        }
        self.assertEqual(artifact.data, expected)
        self.assertEqual(set(artifact.files), {f"pkg_{deb_version}_all.deb"})

    def assertUpload(
        self,
        artifact: LocalArtifact[Any],
        file_names: set[str],
        binnmu: bool = False,
    ) -> None:
        """Assert that artifact is an Upload with expected metadata."""
        self.assertEqual(artifact.category, ArtifactCategory.UPLOAD)
        self.assertEqual(artifact.data.type, "dpkg")
        expected = "hello-traditional"
        if binnmu:
            expected += " (1.0)"
        self.assertEqual(artifact.data.changes_fields["Source"], expected)
        self.assertEqual(set(artifact.files), file_names)

    def test_import_debian_artifact_dsc(self) -> None:
        """Test import a .dsc from a local path."""
        files = self.create_dsc()
        self.assert_import_dsc(files[0], files)

    def test_import_debian_artifact_expire_at(self) -> None:
        """Test import a .dsc from a local path, with expiry."""
        expire_at = dt.datetime.now() + dt.timedelta(days=1)
        files = self.create_dsc()
        self.assert_import_dsc(files[0], files, expire_at=expire_at)

    def test_import_debian_artifact_workspace(self) -> None:
        """Test import a .dsc from a local path, with a workspace."""
        files = self.create_dsc()
        self.assert_import_dsc(files[0], files, workspace="different")

    def test_import_debian_artifact_source_changes(self) -> None:
        """Test import a source upload from a local path."""
        files = self.create_upload(source=True, binary=False)
        workspace = "test"
        mocked_relation_create = self.patch_relation_create()
        stderr, stdout, mocked_upload_artifact = self.import_debian_artifact(
            upload=str(files[0]),
            workspace=workspace,
        )

        # Assert the call happened as expected
        artifacts = self.assert_artifacts_are_uploaded(
            mocked_upload_artifact,
            expected_workspace=workspace,
            expected_artifacts=2,
            expected_files=len(files),
            stdout=stdout,
            stderr=stderr,
            extended_artifacts=[11],
            related_artifacts=[11],
        )
        self.assertUpload(artifacts[0], {file.name for file in files})
        self.assertSourcePackage(artifacts[1])
        mocked_relation_create.assert_has_calls(
            [
                mock.call(
                    mock.ANY,
                    artifact_id=10,
                    target_id=11,
                    relation_type="extends",
                ),
                mock.call(
                    mock.ANY,
                    artifact_id=10,
                    target_id=11,
                    relation_type="relates-to",
                ),
            ]
        )

    def test_import_debian_artifact_binary_changes(self) -> None:
        """Test import a binary upload from a local path."""
        files = self.create_upload(source=False, binary=True)
        workspace = "test"
        mocked_relation_create = self.patch_relation_create()
        stderr, stdout, mocked_upload_artifact = self.import_debian_artifact(
            upload=str(files[0]),
            workspace=workspace,
        )

        # Assert the call happened as expected
        artifacts = self.assert_artifacts_are_uploaded(
            mocked_upload_artifact,
            expected_workspace=workspace,
            expected_artifacts=3,
            expected_files=len(files),
            stdout=stdout,
            stderr=stderr,
            extended_artifacts=[11, 12],
            related_artifacts=[11, 12],
        )
        self.assertUpload(artifacts[0], {file.name for file in files})
        self.assertBinaryPackage(artifacts[1])
        self.assertBinaryPackages(artifacts[2])
        mocked_relation_create.assert_has_calls(
            [
                mock.call(
                    mock.ANY,
                    artifact_id=10,
                    target_id=11,
                    relation_type="extends",
                ),
                mock.call(
                    mock.ANY,
                    artifact_id=10,
                    target_id=11,
                    relation_type="relates-to",
                ),
                mock.call(
                    mock.ANY,
                    artifact_id=10,
                    target_id=12,
                    relation_type="extends",
                ),
                mock.call(
                    mock.ANY,
                    artifact_id=10,
                    target_id=12,
                    relation_type="relates-to",
                ),
            ]
        )

    def test_import_debian_artifact_binnmu_changes(self) -> None:
        """Test import a binnmu binary upload from a local path."""
        files = self.create_upload(source=False, binary=True, binnmu=True)
        workspace = "test"
        self.patch_relation_create()
        stderr, stdout, mocked_upload_artifact = self.import_debian_artifact(
            upload=str(files[0]),
            workspace=workspace,
        )

        # Assert the call happened as expected
        artifacts = self.assert_artifacts_are_uploaded(
            mocked_upload_artifact,
            expected_workspace=workspace,
            expected_artifacts=3,
            expected_files=len(files),
            stdout=stdout,
            stderr=stderr,
            extended_artifacts=[11, 12],
            related_artifacts=[11, 12],
        )
        self.assertUpload(
            artifacts[0], {file.name for file in files}, binnmu=True
        )
        self.assertBinaryPackage(artifacts[1], binnmu=True)
        self.assertBinaryPackages(artifacts[2], binnmu=True)

    def test_import_debian_artifact_mixed_changes(self) -> None:
        """Test import a source+binary upload from a local path."""
        files = self.create_upload(source=True, binary=True)
        workspace = "test"
        self.patch_relation_create()
        stderr, stdout, mocked_upload_artifact = self.import_debian_artifact(
            upload=str(files[0]),
            workspace=workspace,
        )

        # Assert the call happened as expected
        artifacts = self.assert_artifacts_are_uploaded(
            mocked_upload_artifact,
            expected_workspace=workspace,
            expected_artifacts=4,
            expected_files=len(files),
            stdout=stdout,
            stderr=stderr,
            extended_artifacts=[11, 12, 13],
            related_artifacts=[11, 12, 13],
        )
        self.assertUpload(artifacts[0], {file.name for file in files})
        self.assertBinaryPackage(artifacts[1])
        self.assertBinaryPackages(artifacts[2])
        self.assertSourcePackage(artifacts[3])

    def test_import_debian_artifact_deb(self) -> None:
        """import-debian-artifact imports a .deb directly."""
        directory = self.create_temporary_directory()
        workspace = "test"
        deb = directory / "pkg_1.0_all.deb"
        self.write_deb_file(deb)
        stderr, stdout, mocked_create_artifact = self.import_debian_artifact(
            upload=deb,
            workspace=workspace,
        )
        # Assert the call happened as expected
        artifacts = self.assert_artifacts_are_uploaded(
            mocked_create_artifact,
            expected_workspace=workspace,
            expected_artifacts=1,
            expected_files=1,
            stdout=stdout,
            stderr=stderr,
        )
        self.assertBinaryPackage(artifacts[0])

    def test_import_debian_artifact_fails_to_connect(self) -> None:
        """Debusine.upload_artifact fails: cannot connect to the server."""
        files = self.create_dsc()

        stderr, stdout, mocked_upload_artifact = self.import_debian_artifact(
            workspace="test",
            upload=str(files[0]),
            upload_artifact_side_effect=exceptions.ClientConnectionError,
            assert_system_exit_code=3,
        )
        self.assertTrue(stderr.startswith("Error connecting to debusine:"))

    def test_import_debian_artifact_multiple_dsc(self) -> None:
        """import-debian-artifact fails when given two .dscs."""
        files = self.create_upload(source=True, binary=False, extra_dsc=True)
        stderr, stdout, mocked_create_artifact = self.import_debian_artifact(
            upload=files[0], assert_system_exit_code=3
        )

        self.assertRegex(
            stderr, r"^Expecting exactly 1 \.dsc in source package, found 2$"
        )

    def test_import_debian_artifact_binary_changes_without_debs(self) -> None:
        """import-debian-artifact fails with a binary upload without debs."""
        changes = self.create_temporary_file(suffix=".changes")
        changes.write_bytes(
            textwrap.dedent(
                """\
                Format: 3.0 (quilt)
                Source: hello-traditional
                Binary: hello-traditional
                Version: 2.10-5
                Maintainer: Santiago Vila <sanvila@debian.org>
                Homepage: http://www.gnu.org/software/hello/
                Standards-Version: 4.3.0
                Architecture: all
                Package-List:
                 hello-traditional deb devel optional arch=any
                Files:
                """
            ).encode("utf-8")
        )
        stderr, stdout, mocked_create_artifact = self.import_debian_artifact(
            upload=changes, assert_system_exit_code=3
        )

        self.assertRegex(
            stderr,
            r"^Expecting at least one \.deb per arch in binary packages$",
        )

    def test_import_debian_artifact_unknown_extension(self) -> None:
        """import-debian-artifact fails when given a .foo file."""
        file = self.create_temporary_file(suffix=".foo")
        self.import_debian_artifact(
            upload=file,
            exception_str=(
                "Only source packages (.dsc), binary packages (.deb), "
                "and source/binary uploads (.changes) can be directly "
                f"imported with this command. {file} is not supported."
            ),
        )

    def test_import_debian_artifact_missing_file(self) -> None:
        """import-debian-artifact fails when a named file is missing."""
        file = "/tmp/nonexistent.dsc"
        stderr, stdout, mocked_create_artifact = self.import_debian_artifact(
            upload=file,
            exception_str=(
                "[Errno 2] No such file or directory: '/tmp/nonexistent.dsc'"
            ),
        )

    def test_import_debian_artifact_missing_referenced_file(self) -> None:
        """import-debian-artifact fails when a referenced file is missing."""
        files = self.create_dsc()
        files[1].unlink()
        self.import_debian_artifact(
            upload=files[0], exception_str=f'"{files[1]}" does not exist'
        )

    def test_import_debian_artifact_workspace_not_found(self) -> None:
        """import-debian-artifact fails when the named workspace is missing."""
        workspace_name = "does-not-exist"
        title_error = f'Workspace "{workspace_name}" cannot be found'
        files = self.create_dsc()

        stderr, stdout, mocked_upload_artifact = self.import_debian_artifact(
            workspace="test",
            upload=str(files[0]),
            upload_artifact_side_effect=DebusineError(title=title_error),
            debusine_error={"title": title_error},
        )


class CliShowArtifactTests(BaseCliTests):
    """Tests for Cli show-artifact."""

    def test_show_artifact(self) -> None:
        """Test show-artifact shows the information."""
        artifact_id = 11
        debusine = self.patch_build_debusine_object()
        debusine.return_value.artifact_get.return_value = (
            create_artifact_response(id=artifact_id)
        )
        cmd = self.create_command(["artifact", "show", str(artifact_id)])
        assert isinstance(cmd, DebusineCommand)

        with mock.patch(
            "debusine.client.commands.base.DebusineCommand._api_call_or_fail",
            side_effect=cmd._api_call_or_fail,
        ) as api_call_or_fail_mocked:
            stderr, stdout = self.capture_output(cmd.run)

        api_call_or_fail_mocked.assert_called_once_with()
        debusine.return_value.artifact_get.assert_called_once_with(
            int(artifact_id)
        )

        self.assertEqual(stderr, "")

        _, expected_output = self.capture_output(
            DebusineCommand._print_yaml,
            [
                model_to_json_serializable_dict(
                    debusine.return_value.artifact_get.return_value
                )
            ],
        )

        self.assertEqual(stdout, expected_output)


class CliShowArtifactRelationsTests(BaseCliTests):
    """Tests for Cli show-artifact-relations."""

    def create_relation_response(self, **kwargs: Any) -> RelationResponse:
        """Return a RelationResponse. Use defaults for certain fields."""
        defaults: dict[str, Any] = {
            "id": 1,
            "artifact": 10,
            "target": 11,
            "type": "relates-to",
        }
        defaults.update(kwargs)

        return RelationResponse(**defaults)

    def create_relations_response(
        self, *args: RelationResponse
    ) -> RelationsResponse:
        """Return a RelationsResponse containing args."""
        return RelationsResponse(__root__=args)

    def test_show_artifact_relations(self) -> None:
        """Test show-artifact-relations shows all relations."""
        artifact_id = 11
        debusine = self.patch_build_debusine_object()
        debusine.return_value.relation_list.return_value = (
            self.create_relations_response(
                self.create_relation_response(
                    id=1, artifact=artifact_id, target=15
                ),
                self.create_relation_response(
                    id=2, artifact=artifact_id, target=16
                ),
            )
        )
        cmd = self.create_command(
            ["artifact", "show-relations", str(artifact_id)]
        )
        assert isinstance(cmd, DebusineCommand)

        with mock.patch(
            "debusine.client.commands.base.DebusineCommand._api_call_or_fail",
            side_effect=cmd._api_call_or_fail,
        ) as api_call_or_fail_mocked:
            stderr, stdout = self.capture_output(cmd.run)

        api_call_or_fail_mocked.assert_called_once_with()
        debusine.return_value.relation_list.assert_called_once_with(
            artifact_id=int(artifact_id)
        )

        self.assertEqual(stderr, "")

        output = RelationsResponse(__root__=yaml.safe_load(stdout))
        expected = RelationsResponse(
            __root__=[
                RelationResponse(
                    artifact=11,
                    target=15,
                    type=RelationType.RELATES_TO,
                    id=1,
                ),
                RelationResponse(
                    artifact=11,
                    target=16,
                    type=RelationType.RELATES_TO,
                    id=2,
                ),
            ]
        )

        self.assertEqual(output, expected)

    def test_show_artifact_relations_reverse(self) -> None:
        """Test show-artifact-relations shows reverse relations."""
        artifact_id = 11
        debusine = self.patch_build_debusine_object()
        debusine.return_value.relation_list.return_value = (
            self.create_relations_response(
                self.create_relation_response(
                    id=3, artifact=8, target=artifact_id
                ),
                self.create_relation_response(
                    id=4, artifact=9, target=artifact_id
                ),
            )
        )
        cmd = self.create_command(
            ["artifact", "show-relations", "--reverse", str(artifact_id)]
        )
        assert isinstance(cmd, DebusineCommand)

        with mock.patch(
            "debusine.client.commands.base.DebusineCommand._api_call_or_fail",
            side_effect=cmd._api_call_or_fail,
        ) as api_call_or_fail_mocked:
            stderr, stdout = self.capture_output(cmd.run)

        api_call_or_fail_mocked.assert_called_once_with()
        debusine.return_value.relation_list.assert_called_once_with(
            target_id=int(artifact_id)
        )

        self.assertEqual(stderr, "")

        output = RelationsResponse(__root__=yaml.safe_load(stdout))
        expected = RelationsResponse(
            __root__=[
                RelationResponse(
                    artifact=8,
                    target=11,
                    type=RelationType.RELATES_TO,
                    id=3,
                ),
                RelationResponse(
                    artifact=9,
                    target=11,
                    type=RelationType.RELATES_TO,
                    id=4,
                ),
            ]
        )
        self.assertEqual(output, expected)


class CliDownloadArtifact(BaseCliTests):
    """Tests for the Cli functionality related to downloading an artifact."""

    def test_download_artifact(self) -> None:
        """download-artifact call debusine.download_artifact()."""
        artifact_id = str(10)
        destination = Path.cwd()

        debusine = self.patch_build_debusine_object()

        for args, tarball in (
            (["artifact", "download", artifact_id, "--tarball"], True),
            (["artifact", "download", artifact_id], False),
            (["-s", "artifact", "download", artifact_id, "--tarball"], True),
            (["-s", "artifact", "download", artifact_id], False),
        ):
            with self.subTest(args=args):
                cli = self.create_cli(args)

                cli.execute()

                debusine.return_value.download_artifact.assert_called_with(
                    int(artifact_id), destination=destination, tarball=tarball
                )

    def test_download_artifact_target_directory_no_write_access(self) -> None:
        """_download_artifact target_directory no write access."""
        # Similar as other tests: avoid building the debusine object
        # (not relevant for this test)
        self.patch_build_debusine_object()

        target_directory = self.create_temporary_directory()
        os.chmod(target_directory, 0)

        cli = self.create_cli(
            [
                "artifact",
                "download",
                "10",
                "--target-directory",
                str(target_directory),
            ],
        )

        stderr, stdout = self.capture_output(
            cli.execute, assert_system_exit_code=3
        )

        self.assertEqual(stderr, f"Error: Cannot write to {target_directory}\n")
        self.assertEqual(stdout, "")
