# Copyright 2022-2024 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.

"""Debusine extensions to Python's standard unittest.TestCase."""

import contextlib
import hashlib
import os
import shutil
import tempfile
import textwrap
import unittest
from collections.abc import Generator
from configparser import ConfigParser
from pathlib import Path
from typing import Any, TYPE_CHECKING
from unittest import mock
from unittest.util import safe_repr

import debian.deb822 as deb822
import responses

from debusine.artifacts.local_artifact import deb822dict_to_dict
from debusine.artifacts.models import DebianSourcePackage
from debusine.artifacts.playground import ArtifactPlayground

if TYPE_CHECKING:
    from unittest._log import _LoggingWatcher


class TestCase(unittest.TestCase):
    """
    Collection of methods to help write unit tests.

    This augments unittest.TestCase with assert statements and factory
    functions to support Debusine unit tests.

    This is intended to be used as the TestCase for tests that do not depend on
    Django code.
    """

    def create_temp_config_directory(self, config: dict[str, Any]) -> str:
        """
        Create a temp directory with a config.ini file inside.

        The method also register the automatic removal of said directory.
        """
        temp_directory = tempfile.mkdtemp()
        config_file_name = os.path.join(temp_directory, 'config.ini')
        with open(config_file_name, 'w') as config_file:
            config_writer = ConfigParser()

            for section, values in config.items():
                config_writer[section] = values

            config_writer.write(config_file)

        self.addCleanup(shutil.rmtree, temp_directory)

        return temp_directory

    def assertDictContainsAll(
        self,
        dictionary: dict[Any, Any],
        subset: dict[Any, Any],
        msg: Any = None,
    ) -> None:
        """
        Implement a replacement of deprecated TestCase.assertDictContainsSubset.

        Assert that the keys and values of subset is in dictionary.

        The order of the arguments in TestCase.assertDictContainsSubset
        and this implementation differs.
        """
        self.assertIsInstance(
            dictionary, dict, 'First argument is not a dictionary'
        )
        self.assertIsInstance(
            subset, dict, 'Second argument is not a dictionary'
        )

        if dictionary != dictionary | subset:
            msg = self._formatMessage(
                msg,
                '%s does not contain the subset %s'
                % (safe_repr(dictionary), safe_repr(subset)),
            )

            raise self.failureException(msg)

    def assert_token_key_included_in_all_requests(
        self, expected_token: str
    ) -> None:
        """Assert that the requests in responses.calls had the Token."""
        for call in responses.calls:
            headers = call.request.headers

            if 'Token' not in headers:
                raise self.failureException(
                    'Token missing in the headers for '
                    'the request %s' % (safe_repr(call.request.url))
                )

            if (actual_token := headers['Token']) != expected_token:
                raise self.failureException(
                    'Unexpected token. In the request: %s Actual: %s '
                    'Expected: %s'
                    % (
                        safe_repr(call.request.url),
                        safe_repr(actual_token),
                        safe_repr(expected_token),
                    )
                )

    def create_temporary_file(
        self,
        *,
        prefix: str | None = None,
        suffix: str | None = None,
        contents: bytes | None = None,
        directory: Path | str | None = None,
    ) -> Path:
        """
        Create a temporary file and schedules the deletion via self.addCleanup.

        :param prefix: prefix is "debusine-tests-" + prefix or "debusine-tests-"
        :param suffix: suffix for the created file
        :param contents: contents is written into the file. If it's none the
          file is left empty
        :param directory: the directory that the file is created into.
        """
        if prefix is None:
            prefix = "debusine-tests-"
        else:
            prefix = "debusine-tests-" + prefix

        suffix = suffix or ""

        file = tempfile.NamedTemporaryFile(
            prefix=f"{prefix}-", suffix=suffix, delete=False, dir=directory
        )

        if contents is not None:
            file.write(contents)
            file.close()

        file.close()
        file_path = Path(file.name)

        self.addCleanup(file_path.unlink, missing_ok=True)

        return file_path

    def create_temporary_directory(
        self, *, directory: Path | str | None = None
    ) -> Path:
        """
        Create and return a temporary directory. Schedules deletion.

        :param directory: directory to create the temporary directory. If None,
          use the default for tempfile.TemporaryDirectory.
        """
        temp_dir = tempfile.TemporaryDirectory(
            prefix="debusine-tests-", dir=directory
        )

        self.addCleanup(temp_dir.cleanup)

        return Path(temp_dir.name)

    @contextlib.contextmanager
    def assertRaisesSystemExit(self, exit_code) -> Generator[None, None, None]:
        """Assert that raises SystemExit with the specific exit_code."""
        with self.assertRaisesRegex(
            SystemExit,
            rf'^{exit_code}$',
            msg=f'Did not raise SystemExit with exit_code=^{exit_code}$',
        ):
            yield

    @contextlib.contextmanager
    def assertLogsContains(
        self, message: str, expected_count: int = 1, **assert_logs_kwargs: Any
    ) -> "Generator[_LoggingWatcher, None, None]":
        """
        Raise failureException if message is not in the logs.

        Yields the same context manager as self.assertLogs(). This allows
        further checks in the logs.

        :param message: message to find in the logs
        :param expected_count: expected times that the message
          must be in the logs
        :param assert_logs_kwargs: arguments for self.assertLogs()
        """

        def failure_exception_if_needed(
            logs: "_LoggingWatcher", message: str, expected_count: int
        ) -> None:
            all_logs = '\n'.join(logs.output)

            actual_times = all_logs.count(message)

            if actual_times != expected_count:
                raise self.failureException(
                    'Expected: "%s"\n'
                    'Actual: "%s"\n'
                    'Expected msg found %s times, expected %s times'
                    % (message, all_logs, actual_times, expected_count)
                )

        with self.assertLogs(**assert_logs_kwargs) as logs:
            try:
                yield logs
            except BaseException as exc:
                failure_exception_if_needed(logs, message, expected_count)
                raise exc

        failure_exception_if_needed(logs, message, expected_count)

    @staticmethod
    def write_dsc_example_file(path: Path) -> dict[str, Any]:
        """Write a DSC file into file. Files in .dsc are not created."""
        metadata = {
            "source": "hello",
            "version": "2.10-2",
        }
        text = textwrap.dedent(
            f"""\
            -----BEGIN PGP SIGNED MESSAGE-----
            Hash: SHA256

            Format: 3.0 (quilt)
            Source: {metadata['source']}
            Binary: hello
            Architecture: any
            Version: {metadata['version']}
            Maintainer: Santiago Vila <sanvila@debian.org>
            Homepage: http://www.gnu.org/software/hello/
            Standards-Version: 4.3.0
            Build-Depends: debhelper-compat (= 9)
            Package-List:
             hello deb devel optional arch=any
            Checksums-Sha1:
             f7bebf6f9c62a2295e889f66e05ce9bfaed9ace3 725946 hello_2.10.orig.tar.gz
             a35d97bd364670b045cdd86d446e71b171e915cc 6132 hello_2.10-2.debian.tar.xz
            Checksums-Sha256:
             31e066137a962676e89f69d1b65382de95a7ef7d914b8cb956f41ea72e0f516b 725946 hello_2.10.orig.tar.gz
             811ad0255495279fc98dc75f4460da1722f5c1030740cb52638cb80d0fdb24f0 6132 hello_2.10-2.debian.tar.xz
            Files:
             6cd0ffea3884a4e79330338dcc2987d6 725946 hello_2.10.orig.tar.gz
             e522e61c27eb0401c86321b9d8e137ae 6132 hello_2.10-2.debian.tar.xz

            -----BEGIN PGP SIGNATURE-----
            """  # noqa: E501
        )
        path.write_text(text)

        dsc = deb822.Dsc(text.encode("utf-8"))

        return deb822dict_to_dict(dsc)

    @classmethod
    def write_dsc_file(
        cls, path: Path, files: list[Path], version: str = "2.10-5"
    ) -> dict[str, Any]:
        """Write a debian .dsc file."""
        return ArtifactPlayground.write_deb822_file(
            deb822.Dsc, path, files, version=version
        )

    def write_deb_file(
        self,
        path: Path,
        *,
        source_name: str | None = None,
        source_version: str | None = None,
        control_file_names: list[str] | None = None,
    ) -> dict[str, Any]:
        """Write a debian control file."""
        return ArtifactPlayground.write_deb_file(
            path,
            source_name=source_name,
            source_version=source_version,
            control_file_names=control_file_names,
        )

    @classmethod
    def write_changes_file(
        cls,
        path: Path,
        files: list[Path],
        version: str = "2.10-5",
        binnmu: bool = False,
    ) -> dict[str, Any]:
        """Write a debian .changes file."""
        return ArtifactPlayground.write_deb822_file(
            deb822.Changes,
            path,
            files,
            format_version="1.8",
            version=version,
            binnmu=binnmu,
        )

    def mock_is_command_available(self, commands: dict[str, bool]) -> None:
        """
        Configure a fake is_command_available.

        It responds by looking up the requested command in the given
        `commands` dictionary.
        """
        patcher = mock.patch(
            "debusine.utils.is_command_available",
            side_effect=lambda cmd: commands.get(cmd, False),
        )
        patcher.start()
        self.addCleanup(patcher.stop)

    def assert_source_artifact_equal(
        self,
        source: DebianSourcePackage,
        name: str = "hello",
        version: str = "1.0-1",
    ) -> None:
        """
        Verify the contents of a source package artifact.

        File contents are assumed to have been autogenerated using playground
        defaults.
        """
        files: list[str]
        if "-" in version:
            files = [
                f"{name}_{version}.debian.tar.xz",
                f"{name}_{version.split('-')[0]}.orig.tar.gz",
            ]
        else:
            files = [
                f"{name}_{version}.tar.gz",
            ]
        self.assertEqual(source.name, name)
        self.assertEqual(source.version, version)
        self.assertEqual(source.type, "dpkg")
        self.maxDiff = None

        def csum(name: str, text: str) -> str:
            hasher = hashlib.new(name)
            hasher.update(text.encode())
            return hasher.hexdigest()

        checksums_sha1: list[dict[str, str]] = [
            {"name": name, "sha1": csum("sha1", name), "size": str(len(name))}
            for name in files
        ]
        checksums_sha256: list[dict[str, str]] = [
            {
                "name": name,
                "sha256": csum("sha256", name),
                "size": str(len(name)),
            }
            for name in files
        ]
        checksums_md5: list[dict[str, str]] = [
            {"name": name, "md5sum": csum("md5", name), "size": str(len(name))}
            for name in files
        ]
        self.assertEqual(
            source.dsc_fields,
            {
                'Architecture': 'any',
                'Binary': 'hello',
                'Checksums-Sha1': checksums_sha1,
                'Checksums-Sha256': checksums_sha256,
                'Files': checksums_md5,
                'Format': '3.0 (quilt)',
                'Homepage': f'http://www.gnu.org/software/{name}/',
                'Maintainer': 'Example Maintainer <example@example.org>',
                "Package-List": f"\n {name} deb devel optional arch=any",
                'Source': name,
                'Standards-Version': '4.3.0',
                'Version': version,
            },
        )
