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

"""Package with test-helper code and utilities."""

import contextlib
import hashlib
import os
import re
import shutil
import subprocess
import tempfile
import textwrap
from collections.abc import Set
from configparser import ConfigParser
from pathlib import Path
from typing import (
    Any,
    Protocol,
    TYPE_CHECKING,
    cast,
    runtime_checkable,
)
from unittest import TestCase, mock
from unittest.util import safe_repr

import debian.deb822 as deb822

import requests

import responses

from debusine.artifacts.local_artifact import deb822dict_to_dict

if TYPE_CHECKING:
    from debusine.server.views import ProblemResponse


@runtime_checkable
class JSONResponseProtocol(Protocol):
    """A Django test client response with a monkey-patched json() method."""

    def json(self) -> Any:
        """Return the body of the response, parsed as JSON."""


class _BaseTestHelpersMixin:
    """
    Collection of methods to help write unit tests.

    This mixin-class provides different assert statements that can be handy
    when writing unit tests.

    In unit tests use TestHelpersMixin class.
    """

    def create_temp_config_directory(self, config: dict[str, Any]):
        """
        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)

        cast(TestCase, self).addCleanup(shutil.rmtree, temp_directory)

        return temp_directory

    def assertDictContainsAll(
        self,
        dictionary: dict[Any, Any],
        subset: dict[Any, Any],
        msg: Any = 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.
        """
        cast(TestCase, self).assertIsInstance(
            dictionary, dict, 'First argument is not a dictionary'
        )
        cast(TestCase, self).assertIsInstance(
            subset, dict, 'Second argument is not a dictionary'
        )

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

            raise cast(TestCase, self).failureException(msg)

    def assert_token_key_included_in_all_requests(self, expected_token):
        """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)

        cast(TestCase, 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
        )

        cast(TestCase, self).addCleanup(temp_dir.cleanup)

        return Path(temp_dir.name)

    @contextlib.contextmanager
    def assertRaisesSystemExit(self, exit_code):
        """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, expected_count=1, **assert_logs_kwargs
    ):
        """
        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, message, expected_count):
            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)

    def assertResponse400(self, response, contents: str):
        """Assert that response is Http400 and contents is in response."""
        from django.test import SimpleTestCase

        cast(SimpleTestCase, self).assertContains(
            response,
            contents,
            status_code=400,
            html=True,
        )

    def assertResponseProblem(
        self,
        response: "ProblemResponse",
        title: str,
        detail_pattern: str | None = None,
        status_code: int = requests.codes.bad_request,
    ):
        """
        Assert that response is a valid application/problem+json.

        Assert that the content_type is application/problem+json and the
        title exist and matches title.

        :param response: response that it is asserting
        :param status_code: assert response.status_code == status_code
        :param title: exact match with response.data["title"]
        :param detail_pattern: if not None: assertRegex with
           response.data["detail"]. If None checks that response.data does not
           contain "detail".
        """
        cast(TestCase, self).assertEqual(
            response.status_code,
            status_code,
            f"response status {response.status_code} != {status_code}",
        )

        content_type = response.headers["Content-Type"]
        cast(TestCase, self).assertEqual(
            content_type,
            "application/problem+json",
            f'content_type "{content_type}" != ' f'"application/problem+json"',
        )

        assert isinstance(response, JSONResponseProtocol)
        data = response.json()

        cast(TestCase, self).assertIn(
            "title", data, '"title" not found in response'
        )

        response_title = data["title"]
        cast(TestCase, self).assertEqual(
            response_title, title, f'title "{response_title}" != "{title}"'
        )

        if detail_pattern is not None:
            cast(TestCase, self).assertIn(
                "detail", data, '"detail" not found in response'
            )

            response_detail = str(data["detail"])
            cast(TestCase, self).assertRegex(
                response_detail,
                detail_pattern,
                f'Detail regexp "{detail_pattern}" did not '
                f'match "{response_detail}"',
            )
        else:
            cast(TestCase, self).assertNotIn(
                "detail", data, '"detail" is in the response'
            )

    @staticmethod
    def write_dsc_example_file(path: Path) -> dict[str, str]:
        """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]:
        return cls.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]:
        build_directory = self.create_temporary_directory()
        (build_directory / "DEBIAN").mkdir()
        if m := re.match(
            r"([a-z][a-z0-9+-.]+)_([0-9][A-Za-z0-9+-.~:]*)_([a-z0-9-]+).u?deb$",
            path.name,
        ):
            package, version, architecture = m.groups()
        else:  # pragma: no cover
            raise ValueError(f"Badly-formed .deb file name: {path}")
        source = source_name if source_name is not None else package
        if source_version is not None and source_version != version:
            source += f" ({source_version})"

        control_contents = textwrap.dedent(
            f"""\
            Package: {package}
            Version: {version}
            Architecture: {architecture}
            Maintainer: Example Person <person@example.org>
            Description: Example description
            """
        )
        if source != package:
            control_contents += f"Source: {source}\n"
        (build_directory / "DEBIAN" / "control").write_text(control_contents)
        for name in control_file_names or []:
            (build_directory / "DEBIAN" / name).touch()
        subprocess.run(
            ["dpkg-deb", "--build", build_directory, path],
            stdout=subprocess.DEVNULL,
        )

        return deb822dict_to_dict(deb822.Deb822(control_contents.encode()))

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

    @classmethod
    def write_deb822_file(
        cls,
        file_type: type[deb822.Deb822],
        path: Path,
        files: list[Path],
        version: str,
        binnmu: bool = False,
    ) -> dict[str, Any]:
        """Write changes file with files information."""
        architectures = set()
        if file_type == deb822.Dsc:
            architectures.add("any")
        elif file_type == deb822.Changes:
            for file in files:
                name = file.name
                if name.endswith(".dsc"):
                    architectures.add("source")
                elif name.endswith((".deb", ".udeb")):
                    if m := re.match(r".*_([a-z0-9-]+).u?deb$", name):
                        architectures.add(m.group(1))
        else:  # pragma: no cover
            raise NotImplementedError("Only Dsc and Changes are supported")

        source = "hello-traditional"
        if binnmu:
            source += f" ({version})"
            version += "+b1"
        changes_contents = (
            textwrap.dedent(
                f"""\
                Format: 3.0 (quilt)
                Source: {source}
                Binary: hello-traditional
                Architecture: {' '.join(sorted(architectures))}
                Version: {version}
                Maintainer: Santiago Vila <sanvila@debian.org>
                Homepage: http://www.gnu.org/software/hello/
                Standards-Version: 4.3.0
                Package-List:
                 hello-traditional deb devel optional arch=any
                """
            )
            + cls.hash_deb822_files(
                file_type, {file.name: file.read_bytes() for file in files}
            )
        )

        path.write_text(changes_contents)

        return deb822dict_to_dict(file_type(path.read_bytes()))

    @staticmethod
    def hash_deb822_files(
        file_type: type[deb822.Deb822],
        file_contents: dict[str, bytes],
        break_hashes: Set[str] = frozenset(),
        break_sizes: Set[str] = frozenset(),
    ) -> str:
        """
        Build the file-manifest parts of a Deb822 file that we're generating.

        Hash the files in file_contents, a dict of {filename: file_contents}.
        file_type refers to the kind of Deb822 object that we're generating.
        For any hash named in break_hashes, the relevant hashes will be
        incorrect. break_sizes works similarly, but alters the size of the
        file, in the named hash block.

        The returned string is a block of deb822-formatted file content.
        """
        output = []
        for hash_ in ("sha256", "sha1", "md5"):
            if hash_ == "md5":
                output.append("Files:")
            else:
                output.append(f"Checksums-{hash_.title()}:")
            for filename, contents in file_contents.items():
                hasher = hashlib.new(hash_)
                hasher.update(contents)
                size = len(contents)
                if hash_ in break_hashes:
                    hasher.update(b"oops!")
                if hash_ in break_sizes:
                    size += 1
                if hash_ == "md5" and file_type == deb822.Changes:
                    output.append(
                        f" {hasher.hexdigest()} {size} unknown optional "
                        f"{filename}"
                    )
                else:
                    output.append(f" {hasher.hexdigest()} {size} {filename}")
        return "\n".join(output)

    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()
        cast(TestCase, self).addCleanup(patcher.stop)


if "DJANGO_SETTINGS_MODULE" in os.environ:
    from debusine.test.django import _DatabaseHelpersMixin

    class TestHelpersMixin(_DatabaseHelpersMixin):
        """
        Collection of methods to help write unit tests.

        This mixin-class provides different methods that can be handy
        when writing unit tests.
        """

else:

    class TestHelpersMixin(_BaseTestHelpersMixin):  # type: ignore
        """
        Collection of methods to help write unit tests.

        This mixin-class provides different methods that can be handy
        when writing unit tests.
        """
