# Copyright 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 MmDebstrap class."""
import copy
import shlex
from pathlib import Path
from unittest import TestCase, mock
from unittest.mock import call

from debian.deb822 import Deb822

import responses

from debusine.artifacts.local_artifact import DebianSystemTarballArtifact
from debusine.tasks.mmdebstrap import (
    MmDebstrap,
)
from debusine.tasks.models import WorkerType
from debusine.tasks.tests.helper_mixin import ExternalTaskHelperMixin
from debusine.test import TestHelpersMixin


class MmDebstrapTests(
    TestHelpersMixin, ExternalTaskHelperMixin[MmDebstrap], TestCase
):
    """Unit tests for MmDebstrap class."""

    SAMPLE_TASK_DATA = {
        "bootstrap_options": {
            "architecture": "amd64",
            "extra_packages": ["hello"],
            "use_signed_by": True,
        },
        "bootstrap_repositories": [
            {
                "mirror": "https://deb.debian.org/deb",
                "suite": "bookworm",
                "components": ["main", "contrib"],
                "check_signature_with": "system",
            },
            {
                "types": ["deb-src"],
                "mirror": "https://example.com",
                "suite": "bullseye",
                "components": ["main"],
                "check_signature_with": "system",
                "keyring": {"url": "https://example.com/keyring.gpg"},
            },
        ],
    }

    def setUp(self):
        """Initialize test."""
        self.configure_task()

    def tearDown(self):
        """Delete temporary directory if it exists."""
        if self.task._debug_log_files_directory:
            self.task._debug_log_files_directory.cleanup()

    def test_configure_task(self):
        """self.configure_task() does not raise any exception."""
        self.configure_task(self.SAMPLE_TASK_DATA)

    def test_configure_task_defaults(self):
        """self.configure_task() set default values."""
        bootstrap_options = {
            "architecture": "amd64",
            "variant": "essential",
            "extra_packages": ["hello", "bye"],
        }

        self.configure_task(override={"bootstrap_options": bootstrap_options})

        self.assertTrue(self.task.data.bootstrap_options.use_signed_by)

    def test_analyze_worker(self):
        """Test the analyze_worker() method."""
        self.mock_is_command_available({"mmdebstrap": True})
        metadata = self.task.analyze_worker()
        self.assertEqual(metadata["mmdebstrap:available"], True)

    def test_analyze_worker_mmdebstrap_not_available(self):
        """analyze_worker() handles mmdebstrap not being available."""
        self.mock_is_command_available({"mmdebstrap": False})
        metadata = self.task.analyze_worker()
        self.assertEqual(metadata["mmdebstrap:available"], False)

    def test_can_run_on(self):
        """can_run_on returns True if mmdebstrap is available."""
        self.assertTrue(
            self.task.can_run_on(
                {
                    "system:architectures": ["amd64"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "mmdebstrap:available": True,
                    "mmdebstrap:version": self.task.TASK_VERSION,
                }
            )
        )

    def test_can_run_on_mismatched_task_version(self):
        """can_run_on returns False for mismatched task versions."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    "system:architectures": ["amd64"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "mmdebstrap:available": True,
                    "mmdebstrap:version": self.task.TASK_VERSION + 1,
                }
            )
        )

    def test_can_run_on_missing_tool(self):
        """can_run_on returns False if mmdebstrap is not available."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    "system:architectures": ["amd64"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "mmdebstrap:available": False,
                    "mmdebstrap:version": self.task.TASK_VERSION,
                }
            )
        )

    def test_can_run_on_wrong_architecture(self):
        """can_run_on returns False if the task is for a different arch."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    "system:architectures": ["arm64"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "mmdebstrap:available": True,
                    "mmdebstrap:version": self.task.TASK_VERSION,
                }
            )
        )

    def test_cmdline_add_keyrings(self):
        """Command line has the keyring files."""
        self.task._host_sources_file = Path("/somewhere/some-file.sources")
        keyring_1 = self.create_temporary_file()
        keyring_2 = self.create_temporary_file()
        self.task._upload_keyrings = [keyring_1, keyring_2]
        self.task._keyrings = [keyring_1]

        cmdline = self.task._cmdline()
        self.assertIn(
            f"--customize-hook=upload {keyring_1} "
            f"/etc/apt/keyrings-debusine/{keyring_1.name}",
            cmdline,
        )
        self.assertIn(
            f"--customize-hook=upload {keyring_2} "
            f"/etc/apt/keyrings-debusine/{keyring_2.name}",
            cmdline,
        )

        self.assertIn(
            f"--keyring={keyring_1}",
            cmdline,
        )

        self.assertNotIn(
            f"--keyring={keyring_2}",
            cmdline,
        )

    def test_cmdline_minimum_options(self):
        """Command line has minimum options."""
        self.task._host_sources_file = Path("/somewhere/some-file.sources")
        self.task._chroot_sources_file = self.create_temporary_file()

        os_release_file = shlex.quote(self.task._OS_RELEASE_FILE)
        var_lib_dpkg = shlex.quote(self.task._VAR_LIB_DPKG)
        test_sbin_init = shlex.quote(self.task._TEST_SBIN_INIT_RETURN_CODE_FILE)

        expected = [
            "mmdebstrap",
            "--mode=unshare",
            "--format=tar",
            "--architectures=amd64",
            "--verbose",
            "--hook-dir=/usr/share/mmdebstrap/hooks/maybe-jessie-or-older",
            (
                '--customize-hook=cd "$1"; '
                "find etc/apt/sources.list.d -type f -delete"
            ),
            (
                f"--customize-hook=upload {self.task._chroot_sources_file} "
                "/etc/apt/sources.list.d/file.sources"
            ),
            '--customize-hook=mkdir "$1/etc/apt/keyrings-debusine"',
            f"--customize-hook=download /etc/os-release {os_release_file}",
            f"--customize-hook=copy-out /var/lib/dpkg {var_lib_dpkg}",
            '--customize-hook=rm -f "$1/etc/hostname"',
            '--customize-hook='
            'test -h "$1/etc/resolv.conf" || rm -f "$1/etc/resolv.conf"',
            '--customize-hook='
            '(test -x "$1/sbin/init" || test -h "$1/sbin/init") ; '
            'echo $? > "$1/test_sbin_init"',
            f'--customize-hook=download test_sbin_init {test_sbin_init}',
            '--customize-hook=rm "$1/test_sbin_init"',
            "--include=hello",
            "",
            "system.tar.xz",
            "",
            str(self.task._host_sources_file),
        ]

        cmdline = self.task._cmdline()
        self.assertEqual(cmdline, expected)

    def test_cmdline_variant(self):
        """_cmdline() include --variant=bootstrap_options['variant']."""
        bootstrap_options = {
            "architecture": "amd64",
            "variant": "essential",
        }

        self.configure_task(override={"bootstrap_options": bootstrap_options})
        self.task._host_sources_file = Path("some-file.sources")
        cmdline = self.task._cmdline()
        self.assertIn("--variant=essential", cmdline)

    def test_cmdline_no_extra_packages(self):
        """_cmdline() without extra_packages."""
        bootstrap_options = {
            "architecture": "amd64",
            "variant": "essential",
            "use_signed_by": True,
        }

        self.configure_task(override={"bootstrap_options": bootstrap_options})
        self.task._host_sources_file = Path("some-file.sources")
        cmdline = self.task._cmdline()
        for arg in cmdline:
            assert not arg.startswith("--include=")

    def test_cmdline_extra_packages(self):
        """_cmdline() include --include=extra_packages."""
        bootstrap_options = {
            "architecture": "amd64",
            "variant": "essential",
            "extra_packages": ["hello", "bye"],
            "use_signed_by": True,
        }

        self.configure_task(override={"bootstrap_options": bootstrap_options})
        self.task._host_sources_file = Path("some-file.sources")
        cmdline = self.task._cmdline()
        self.assertIn("--include=hello,bye", cmdline)

    def test_cmdline_keyring_package(self):
        """_cmdline() include --include=keyring_0 --include=keyring_1."""
        task_data = copy.deepcopy(self.SAMPLE_TASK_DATA)

        task_data["bootstrap_repositories"][0]["keyring_package"] = "keyring_0"
        task_data["bootstrap_repositories"][1]["keyring_package"] = "keyring_1"

        self.configure_task(task_data)
        self.task._host_sources_file = Path("some-file.sources")
        cmdline = self.task._cmdline()

        self.assertIn("--include=keyring_0", cmdline)
        self.assertIn("--include=keyring_1", cmdline)

    def test_cmdline_customization_script(self):
        """_cmdline() include customization_script arguments."""
        self.task._customization_script = (
            self.create_temporary_directory() / "customization_script"
        )
        cmdline = self.task._cmdline()

        customization_script = self.task._customization_script
        script_name = "customization_script"
        self.assertIn(
            f"--customize-hook=upload {customization_script} /{script_name}",
            cmdline,
        )
        self.assertIn(f'--customize-hook=chmod 555 "$1/{script_name}"', cmdline)
        self.assertIn(f'--customize-hook=chroot "$1" /{script_name}', cmdline)
        self.assertIn(f'--customize-hook=rm "$1/{script_name}"', cmdline)

        self.assertIn("", cmdline)

    def test_fetch_input(self):
        """Test fetch_input method."""
        # Directory does not need to exist: it is not used
        directory = Path()
        self.assertTrue(self.task.fetch_input(directory))

    @responses.activate
    def test_configure_for_execution(self):
        """Test configure_for_execution(): call _generate_deb822_sources."""
        download_dir = self.create_temporary_directory()

        # Assume that, in production code, the directory was created
        # with restrictive permissions
        download_dir.chmod(0o700)

        url_0 = "https://example.com/keyring1.asc"
        url_1 = "https://example.com/keyring2.asc"

        repositories = [
            {
                "mirror": "https://deb.debian.org/deb",
                "suite": "bookworm",
                "components": ["main", "contrib"],
                "check_signature_with": "system",
            },
            {
                "mirror": "https://deb.debian.org/deb",
                "suite": "bookworm",
                "components": ["main", "contrib"],
                "check_signature_with": "no-check",
            },
            {
                "mirror": "https://deb.debian.org/deb",
                "suite": "bookworm",
                "components": ["main", "contrib"],
                "check_signature_with": "external",
                "keyring": {"url": url_0, "install": True},
            },
            {
                "mirror": "https://deb.debian.org/deb",
                "suite": "bullseye",
                "components": ["main"],
                "check_signature_with": "external",
                "keyring": {"url": url_1, "install": False},
            },
        ]

        keyring_contents_0 = b"The contents of the keyring 0"
        keyring_contents_1 = b"The contents of the keyring 1"

        responses.add(
            responses.GET,
            url_0,
            body=keyring_contents_0,
        )
        responses.add(
            responses.GET,
            url_1,
            body=keyring_contents_1,
        )

        self.configure_task(override={"bootstrap_repositories": repositories})

        with mock.patch.object(
            self.task, "append_to_log_file", autospec=True
        ) as append_to_log_file:
            self.assertTrue(self.task.configure_for_execution(download_dir))

        # Files were generated
        sources_files = list(download_dir.glob("*.sources"))
        self.assertEqual(len(sources_files), 2)

        # Source file Signed-By changed to the correct chroot path
        with self.task._chroot_sources_file.open() as f:
            for actual, task_repository in zip(
                Deb822.iter_paragraphs(f), repositories
            ):
                if task_repository["check_signature_with"] in (
                    "system",
                    "no-check",
                ):
                    # For a "system" or "no-check": the chroot sources list
                    # does not have any "Signed-By"
                    self.assertNotIn("Signed-By", actual)
                else:
                    # It must be "external"
                    self.assertEqual(
                        task_repository["check_signature_with"], "external"
                    )

                    if task_repository["keyring"]["install"]:
                        # The path changed to one that will make sense in chroot
                        self.assertRegex(
                            actual["Signed-By"],
                            r"^/etc/apt/keyrings-debusine/.*\.asc",
                        )
                    else:
                        # No Signed-By in the chroot: the keyring is not
                        # uploaded (it is used for the initial setup only)
                        self.assertNotIn("Signed-By", actual)

        # check files were added in the log artifact
        expected_calls = []
        for source_file in [
            download_dir / "host.sources",
            download_dir / "chroot.sources",
        ]:
            expected_calls.append(
                call(source_file.name, source_file.read_text().splitlines())
            )
        append_to_log_file.assert_has_calls(expected_calls)

        host_repositories = list(
            Deb822.iter_paragraphs(self.task._host_sources_file.read_text())
        )

        # Both repositories with check_signature_external have Signed-By
        # in the host.sources
        self.assertEqual(
            Path(host_repositories[2]["Signed-By"]).read_bytes(),
            keyring_contents_0,
        )
        self.assertEqual(
            Path(host_repositories[3]["Signed-By"]).read_bytes(),
            keyring_contents_1,
        )

        # And the files were added in self.task._keyrings
        self.assertIn(
            Path(host_repositories[2]["Signed-By"]), self.task._keyrings
        )
        self.assertIn(
            Path(host_repositories[3]["Signed-By"]), self.task._keyrings
        )

        # With .asc names
        self.assertEqual(self.task._keyrings[0].suffix, ".asc")
        self.assertEqual(self.task._keyrings[1].suffix, ".asc")

        # Two keyrings added
        self.assertEqual(len(self.task._keyrings), 2)

        # Verify that self.task._upload_keyrings contains only one, correct
        # suffix, correct content
        upload_keyrings = self.task._upload_keyrings
        self.assertEqual(len(upload_keyrings), 1)
        self.assertEqual(upload_keyrings[0].suffix, ".asc")
        self.assertEqual(upload_keyrings[0].read_bytes(), keyring_contents_0)

        # The chroot sources file have one Signed-By and the other on
        # no Signed-By
        host_repositories = list(
            Deb822.iter_paragraphs(self.task._chroot_sources_file.read_text())
        )
        self.assertRegex(
            host_repositories[2]["Signed-By"],
            r"^/etc/apt/keyrings-debusine/keyring-repo-.*\.asc$",
        )
        self.assertNotIn("Signed-By", host_repositories[3])

        # Download directory can be read by anyone (needed for mmdebstrap to
        # use the keys from the subuid, in mode unshare as it is used)
        self.assertEqual(download_dir.stat().st_mode & 0o755, 0o755)

        keyrings_dir = download_dir / "keyrings"
        self.assertEqual(keyrings_dir.stat().st_mode & 0o755, 0o755)

    @responses.activate
    def test_configure_for_execution_no_signed_by_in_repositories(self):
        """Test configure_for_execution(): use_signed_by is False."""
        download_dir = self.create_temporary_directory()

        bootstrap_options = {"architecture": "amd64", "use_signed_by": False}

        url = "https://example.com/keyring1.asc"

        repositories = [
            {
                "mirror": "https://deb.debian.org/deb",
                "suite": "bookworm",
                "components": ["main", "contrib"],
                "check_signature_with": "external",
                "keyring": {"url": url},
            },
        ]

        responses.add(
            responses.GET,
            url,
        )

        self.configure_task(
            override={
                "bootstrap_repositories": repositories,
                "bootstrap_options": bootstrap_options,
            }
        )
        self.task.configure_for_execution(download_dir)

        # Source file Signed-By changed to the correct chroot path
        with self.task._chroot_sources_file.open() as chroot_sources:
            with self.task._host_sources_file.open() as host_sources:
                count = 0
                for repository in [chroot_sources, host_sources]:
                    self.assertNotIn("Signed-By", repository)
                    count += 1

                # Expect two: one from chroot, one from host sources
                self.assertEqual(count, 2)

        self.assertEqual(len(self.task._keyrings), 1)
        self.assertEqual(len(self.task._upload_keyrings), 0)

    def test_configure_for_execution_customization_script(self):
        """Test configure_for_execution() write customization_script."""
        download_dir = self.create_temporary_directory()

        script = "#!/bin/sh\n\necho 'something'\n"
        self.configure_task(
            override={
                "customization_script": script,
            }
        )

        self.task.configure_for_execution(download_dir)

        self.assertEqual(
            self.task._customization_script.name, "customization_script"
        )
        self.assertEqual(self.task._customization_script.read_text(), script)

    def write_os_release(
        self, directory: Path, codename: str = "bookworm", version: int = 12
    ) -> dict[str, str]:
        """
        Write in directory / self.task._OS_RELEASE_FILE a release file.

        :return: dictionary with keys to values.
        """
        data = {
            "ID": "debian",
            "NAME": "Debian GNU/Linux",
            "PRETTY_NAME": f"Debian GNU/Linux {version} ({codename})",
            "VERSION": f"{version} ({codename})",
            "VERSION_CODENAME": codename,
            "VERSION_ID": str(version),
        }

        if codename == "jessie":
            del data["VERSION_CODENAME"]
        if version >= 13:
            del data["VERSION"]
            del data["VERSION_ID"]

        (directory / self.task._OS_RELEASE_FILE).write_text(
            "\n".join(f"{k}={shlex.quote(v)}" for k, v in data.items()) + "\n"
        )
        return data

    def test_upload_artifacts(self):
        """Test upload_artifacts()."""
        directory = self.create_temporary_directory()

        system_tarball = directory / MmDebstrap._OUTPUT_SYSTEM_FILE
        system_tarball.write_bytes(b"Generated tarball")

        # Debusine.upload_artifact is mocked to verify the call only
        debusine_mock = self.mock_debusine()

        os_release_data = self.write_os_release(directory)
        (directory / self.task._TEST_SBIN_INIT_RETURN_CODE_FILE).write_text("0")

        packages = self.patch_subprocess_run_pkglist()

        mirror = self.SAMPLE_TASK_DATA["bootstrap_repositories"][0]["mirror"]

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

        calls = []

        expected_system_artifact = DebianSystemTarballArtifact.create(
            system_tarball,
            data={
                "variant": None,
                "architecture": self.SAMPLE_TASK_DATA["bootstrap_options"][
                    "architecture"
                ],
                "vendor": os_release_data["ID"],
                "codename": os_release_data["VERSION_CODENAME"],
                "pkglist": packages,
                "with_dev": True,
                "with_init": True,
                "mirror": mirror,
            },
        )

        calls.append(
            call(
                expected_system_artifact,
                workspace=self.task.workspace_name,
                work_request=self.task.work_request_id,
            )
        )

        debusine_mock.upload_artifact.assert_has_calls(calls)

    def test_upload_artifacts_jessie(self):
        """Test upload_artifacts() for a jessie tarball."""
        directory = self.create_temporary_directory()

        system_tarball = directory / MmDebstrap._OUTPUT_SYSTEM_FILE
        system_tarball.write_bytes(b"Generated tarball")

        # Debusine.upload_artifact is mocked to verify the call only
        debusine_mock = self.mock_debusine()

        os_release_data = self.write_os_release(
            directory, codename="jessie", version=8
        )
        (directory / self.task._TEST_SBIN_INIT_RETURN_CODE_FILE).write_text("0")

        packages = self.patch_subprocess_run_pkglist()

        mirror = self.SAMPLE_TASK_DATA["bootstrap_repositories"][0]["mirror"]
        self.task.data.bootstrap_repositories[0].suite = "jessie"

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

        calls = []

        expected_system_artifact = DebianSystemTarballArtifact.create(
            system_tarball,
            data={
                "variant": None,
                "architecture": self.SAMPLE_TASK_DATA["bootstrap_options"][
                    "architecture"
                ],
                "vendor": os_release_data["ID"],
                "codename": "jessie",
                "pkglist": packages,
                "with_dev": True,
                "with_init": True,
                "mirror": mirror,
            },
        )

        calls.append(
            call(
                expected_system_artifact,
                workspace=self.task.workspace_name,
                work_request=self.task.work_request_id,
            )
        )

        debusine_mock.upload_artifact.assert_has_calls(calls)

    def test_upload_artifacts_sid(self):
        """Test upload_artifacts() for a sid tarball."""
        directory = self.create_temporary_directory()

        system_tarball = directory / MmDebstrap._OUTPUT_SYSTEM_FILE
        system_tarball.write_bytes(b"Generated tarball")

        # Debusine.upload_artifact is mocked to verify the call only
        debusine_mock = self.mock_debusine()

        os_release_data = self.write_os_release(
            directory, codename="trixie", version=13
        )
        (directory / self.task._TEST_SBIN_INIT_RETURN_CODE_FILE).write_text("0")

        packages = self.patch_subprocess_run_pkglist()

        mirror = self.SAMPLE_TASK_DATA["bootstrap_repositories"][0]["mirror"]
        self.task.data.bootstrap_repositories[0].suite = "unstable"

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

        calls = []

        expected_system_artifact = DebianSystemTarballArtifact.create(
            system_tarball,
            data={
                "variant": None,
                "architecture": self.SAMPLE_TASK_DATA["bootstrap_options"][
                    "architecture"
                ],
                "vendor": os_release_data["ID"],
                "codename": "sid",
                "pkglist": packages,
                "with_dev": True,
                "with_init": True,
                "mirror": mirror,
            },
        )

        calls.append(
            call(
                expected_system_artifact,
                workspace=self.task.workspace_name,
                work_request=self.task.work_request_id,
            )
        )

        debusine_mock.upload_artifact.assert_has_calls(calls)

    def test_get_value_os_release(self):
        """get_value_os_release() get the correct value."""
        directory = self.create_temporary_directory()
        os_release_data = self.write_os_release(directory)

        actual = MmDebstrap._get_value_os_release(
            directory / self.task._OS_RELEASE_FILE, "PRETTY_NAME"
        )
        self.assertEqual(actual, os_release_data["PRETTY_NAME"])

    def test_get_value_os_release_key_error(self):
        """get_value_os_release() raise KeyError."""
        os_release = self.create_temporary_file()

        key = "PRETTY_NAME"
        with self.assertRaisesRegex(KeyError, key):
            MmDebstrap._get_value_os_release(os_release, key)

    def test_upload_artifacts_do_nothing(self):
        """Test upload_artifacts() doing nothing: execution_success=False."""
        self.mock_debusine()
        self.task.upload_artifacts(Path(), execution_success=False)

    def test_variant_enums(self):
        """Check MmDebstrap variants enums."""
        systembootstrap_variants = ["buildd", "minbase"]
        mmdebstrap_variants = [
            "-",
            "apt",
            "custom",
            "debootstrap",
            "essential",
            "extract",
            "important",
            "required",
            "standard",
        ]

        for variant in systembootstrap_variants + mmdebstrap_variants:
            with self.subTest(variant=variant):
                self.configure_task(
                    override={
                        "bootstrap_options": {
                            "architecture": "amd64",
                            "variant": variant,
                            "extra_packages": ["hello"],
                            "use_signed_by": True,
                        }
                    }
                )

    def patch_subprocess_run_pkglist(self) -> dict[str, str]:
        """Patch subprocess.run() and return dictionary with name -> version."""
        mock_result = mock.MagicMock()
        packages = {"aardvark-dns:amd64": "1.0.4", "abcde": "1:2.4.3"}

        mock_result.stdout = (
            "\n".join([f"{pkg}\t{ver}" for pkg, ver in packages.items()]) + "\n"
        )

        patch = mock.patch(
            "subprocess.run", autospec=True, return_value=mock_result
        )
        patch.start()
        self.addCleanup(patch.stop)

        return packages

    def test_get_with_init(self):
        """Test _get_with_init()."""
        file = self.create_temporary_file(contents=b"0\n")
        self.assertTrue(self.task._get_with_init(file))

        file.write_text("1\n")
        self.assertFalse(self.task._get_with_init(file))
