# 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 SystemBootstrap class."""
import copy
import hashlib
import textwrap
from pathlib import Path
from unittest import TestCase, mock

try:
    import pydantic.v1 as pydantic
except ImportError:
    import pydantic as pydantic  # type: ignore

import responses

from debusine.client.exceptions import ContentValidationError
from debusine.tasks import TaskConfigError
from debusine.tasks.models import (
    SystemBootstrapData,
    SystemBootstrapRepository,
    SystemBootstrapRepositoryCheckSignatureWith,
    SystemBootstrapRepositoryKeyring,
    SystemBootstrapRepositoryType,
)
from debusine.tasks.systembootstrap import (
    SystemBootstrap,
)
from debusine.tasks.tests.helper_mixin import ExternalTaskHelperMixin
from debusine.test import TestHelpersMixin


class SystemBootstrapImpl(SystemBootstrap[SystemBootstrapData]):
    """Implementation of SystemBootstrap ontology."""

    def _cmdline(self) -> list[str]:
        return []  # pragma: no cover


class SystemBootstrapTests(
    TestHelpersMixin, ExternalTaskHelperMixin[SystemBootstrapImpl], TestCase
):
    """Tests SystemBootstrap class."""

    SAMPLE_TASK_DATA = {
        "bootstrap_options": {
            "architecture": "amd64",
            "variant": "buildd",
            "extra_packages": ["hello"],
        },
        "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": "no-check",
            },
        ],
    }

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

    def test_configure_task(self):
        """Ensure self.SAMPLE_TASK_DATA is valid."""
        self.configure_task(self.SAMPLE_TASK_DATA)

        # Test default options
        sample_repositories = self.SAMPLE_TASK_DATA["bootstrap_repositories"]
        actual_repositories = self.task.data.bootstrap_repositories

        # First sample repository does not have "types" nor
        # "signed_signature_with"
        self.assertNotIn("types", sample_repositories[0])
        self.assertNotIn("signed_signature_with", sample_repositories[0])
        # They are assigned to the default values
        self.assertEqual(actual_repositories[0].types, ["deb"])
        self.assertEqual(actual_repositories[0].check_signature_with, "system")

        #  Second sample repository has "types" / "signed_signature_with" as
        # per SAMPLE_TASK_DATA
        self.assertEqual(sample_repositories[1]["types"], ["deb-src"])
        self.assertEqual(
            actual_repositories[1].check_signature_with, "no-check"
        )

    def test_configure_task_raise_value_error_missing_keyring(self):
        """configure_task() raise ValueError: missing keyring parameter."""
        task_data = copy.deepcopy(self.SAMPLE_TASK_DATA)
        task_data["bootstrap_repositories"][0][
            "check_signature_with"
        ] = "external"

        msg = (
            r"repository requires 'keyring':"
            r" 'check_signature_with' is set to 'external'"
        )

        with self.assertRaisesRegex(TaskConfigError, msg):
            self.configure_task(task_data)

    @staticmethod
    def create_and_validate_repo(
        check_signature_with: SystemBootstrapRepositoryCheckSignatureWith,
        *,
        keyring_url: str | None = None,
        keyring_sha256sum: str = "",
    ) -> SystemBootstrapRepository:
        """Create a basic repository, validates and return it."""
        keyring: SystemBootstrapRepositoryKeyring | None = None
        if keyring_url is not None:
            keyring = SystemBootstrapRepositoryKeyring(
                url=pydantic.parse_obj_as(pydantic.AnyUrl, keyring_url),
                sha256sum=keyring_sha256sum,
            )
        repository = SystemBootstrapRepository(
            types=[SystemBootstrapRepositoryType.DEB],
            mirror="https://example.com",
            suite="bullseye",
            components=["main", "contrib"],
            check_signature_with=check_signature_with,
            keyring=keyring,
        )

        return repository

    def test_deb822_source(self):
        """Generate a correct Deb822 sources file."""
        repository = self.create_and_validate_repo(
            SystemBootstrapRepositoryCheckSignatureWith.SYSTEM
        )

        actual = self.task._deb822_source(
            repository,
            keyring_directory=self.create_temporary_directory(),
            use_signed_by=True,
        )

        expected = {
            "Types": "deb",
            "URIs": "https://example.com",
            "Suites": "bullseye",
            "Components": "main contrib",
        }

        self.assertEqual(actual, expected)

    def test_deb822_source_no_components(self):
        """Use components returned by _list_components_for_suite."""
        repository = self.create_and_validate_repo(
            SystemBootstrapRepositoryCheckSignatureWith.NO_CHECK
        )
        repository.components = None

        components = ["main", "contrib"]
        with mock.patch(
            "debusine.tasks.systembootstrap.SystemBootstrap."
            "_list_components_for_suite",
            return_value=components,
            autospec=True,
        ) as mocked:
            actual = self.task._deb822_source(
                repository,
                keyring_directory=self.create_temporary_directory(),
                use_signed_by=False,
            )

        self.assertEqual(actual["components"], " ".join(components))

        mocked.assert_called_with(repository.mirror, repository.suite)

    def test_deb822_source_signature_no_check(self):
        """Generate Deb822 with "trusted"."""
        repository = self.create_and_validate_repo(
            SystemBootstrapRepositoryCheckSignatureWith.NO_CHECK
        )

        actual = self.task._deb822_source(
            repository,
            keyring_directory=self.create_temporary_directory(),
            use_signed_by=True,
        )

        self.assertEqual(actual["Trusted"], "yes")

    @responses.activate
    def test_deb822_source_file_download_signature(self):
        """Generate Deb822 and downloads a signature file."""
        keyring_contents = b"some-keyring"
        keyring_url = "https://example.com/keyring_file.txt"
        keyring_directory = self.create_temporary_directory()

        repository = self.create_and_validate_repo(
            SystemBootstrapRepositoryCheckSignatureWith.EXTERNAL,
            keyring_url=keyring_url,
        )

        responses.add(
            responses.GET, repository.keyring.url, body=keyring_contents
        )

        actual = self.task._deb822_source(
            repository, keyring_directory=keyring_directory, use_signed_by=True
        )

        signed_by_keyring = Path(actual["Signed-By"])
        self.assertEqual(signed_by_keyring.read_bytes(), keyring_contents)

        # It was created in the correct directory
        self.assertTrue(signed_by_keyring.is_relative_to(keyring_directory))

    @responses.activate
    def test_deb822_source_keyring_hash_mismatch(self):
        """Method raise ValueError: keyring sha256 mismatch."""
        keyring_contents = b"some-keyring"
        keyring_url = "https://example.com/keyring_file.txt"
        keyring_directory = self.create_temporary_directory()

        expected_sha256 = "will be a mismatch"

        repository = self.create_and_validate_repo(
            SystemBootstrapRepositoryCheckSignatureWith.EXTERNAL,
            keyring_url=keyring_url,
            keyring_sha256sum=expected_sha256,
        )

        responses.add(
            responses.GET, repository.keyring.url, body=keyring_contents
        )

        actual_sha256 = hashlib.sha256(keyring_contents).hexdigest()

        expected = (
            f"sha256 mismatch for keyring repository {repository.mirror}. "
            f"Actual: {actual_sha256} expected: {expected_sha256}"
        )

        with self.assertRaisesRegex(ContentValidationError, expected):
            self.task._deb822_source(
                repository,
                keyring_directory=keyring_directory,
                use_signed_by=True,
            )

    @responses.activate
    def test_deb822_source_keyring_hash_correct(self):
        """Keyring sha256 is correct."""
        """Method raise ValueError: keyring sha256 mismatch."""
        keyring_contents = b"some-keyring"
        keyring_url = "https://example.com/keyring_file.txt"
        keyring_directory = self.create_temporary_directory()

        actual_sha256 = hashlib.sha256(keyring_contents).hexdigest()

        repository = self.create_and_validate_repo(
            SystemBootstrapRepositoryCheckSignatureWith.EXTERNAL,
            keyring_url=keyring_url,
            keyring_sha256sum=actual_sha256,
        )

        responses.add(
            responses.GET, repository.keyring.url, body=keyring_contents
        )

        self.task._deb822_source(
            repository, keyring_directory=keyring_directory, use_signed_by=True
        )

    @responses.activate
    def test_generate_deb822_sources_file(self):
        """Assert _generate_deb822_sources_file() return the expected files."""
        keyring_url = "https://example.com/keyring_1.gpg"
        responses.add(responses.GET, keyring_url)

        repository = SystemBootstrapRepository(
            types=["deb"],
            mirror="https://deb.debian.org/deb",
            suite="bookworm",
            components=["main", "contrib"],
            check_signature_with="external",
            keyring={"url": keyring_url},
        )

        for use_signed_by in [True, False]:
            with self.subTest(use_signed_by=use_signed_by):
                directory = self.create_temporary_directory()

                sources = self.task._generate_deb822_sources(
                    [repository],
                    keyrings_dir=directory,
                    use_signed_by=use_signed_by,
                )

                self.assertEqual(sources[0]["URIs"], repository.mirror)
                self.assertEqual(len(sources), 1)

                if use_signed_by:
                    self.assertEqual(
                        sources[0]["Signed-By"], str(next(directory.iterdir()))
                    )
                else:
                    self.assertNotIn("Signed-By", sources[0])

    @staticmethod
    def add_responses_release_file(
        components: list[str],
    ) -> tuple[str, str, str]:
        """
        Add a mock response for a Release file with Components: line.

        :returns: mirror_url, suite, release_url.
        """
        mirror_url = "http://deb.debian.org/debian"
        suite = "bookworm"

        release_url = f"{mirror_url}/dists/{suite}/Release"

        responses.add(
            responses.GET,
            release_url,
            body=textwrap.dedent(
                f"""\
                Origin: Debian
                Label: Debian
                Suite: stable
                Version: 12.4
                Codename: bookworm
                Date: Sun, 10 Dec 2023 17:43:24 UTC
                Acquire-By-Hash: yes
                No-Support-for-Architecture-all: Packages
                Components: {' '.join(components)}
                Description: Debian 12.4 Released 10 December 2023
                MD5Sum:
                 0ed6d4c8891eb86358b94bb35d9e4da4  1484322 contrib/Contents-all
                 d0a0325a97c42fd5f66a8c3e29bcea64    981 contrib/Contents-all.gz
                 58f32d515c66daafcdac2595fc984814   84179 contrib/Contents-amd64
                """
            ),
            status=200,
        )

        return mirror_url, suite, release_url

    @responses.activate
    def test_list_components_for_suite(self):
        """_list_components_for_suite get Release URL and return components."""
        expected_components = [
            "main",
            "updates/main",
            "updates/main/something",
            "contrib",
            "non-free-firmware",
            "non-free",
        ]

        mirror_url, suite, _ = self.add_responses_release_file(
            expected_components
        )

        actual_components = SystemBootstrap._list_components_for_suite(
            mirror_url, suite
        )
        self.assertEqual(actual_components, expected_components)

    @responses.activate
    def test_list_components_for_suite_raise_value_error(self):
        """
        _list_components_for_suite get Release URL and raise exception.

        The line containing "Components:" was not found.
        """
        mirror_url = "http://deb.debian.org/debian"

        url = mirror_url + "/dists/bookworm/Release"

        responses.add(
            responses.GET,
            url,
            body="Some text not containing line starts with Component: ",
            status=200,
        )

        msg = f"^Cannot find components in {url}"
        with self.assertRaisesRegex(ValueError, msg):
            SystemBootstrap._list_components_for_suite(mirror_url, "bookworm")

    @responses.activate
    def test_list_components_raise_value_error_invalid_character_dot(self):
        """
        _list_components_for_suite raise ValueError: invalid component name.

        Components cannot have a ".".
        """
        component = "non.free"

        mirror_url, suite, release_url = self.add_responses_release_file(
            [component]
        )

        msg = (
            rf'^Invalid component name from {release_url}: "{component}" '
            r'must start with \[A-Za-z\] and have only \[-/A-Za-z\] characters'
        )
        with self.assertRaisesRegex(ValueError, msg):
            SystemBootstrap._list_components_for_suite(mirror_url, suite)

    @responses.activate
    def test_list_components_raise_value_error_invalid_start_with_slash(self):
        """
        _list_components_for_suite raise ValueError: invalid component name.

        Components cannot start with "/".
        """
        component = "/main"

        mirror_url, suite, release_url = self.add_responses_release_file(
            [component]
        )

        msg = (
            rf'^Invalid component name from {release_url}: "{component}" '
            r'must start with \[A-Za-z\] and have only \[-/A-Za-z\] characters'
        )
        with self.assertRaisesRegex(ValueError, msg):
            SystemBootstrap._list_components_for_suite(mirror_url, suite)
