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

"""Common infrastructure for testing the debusine Cli class."""

import contextlib
import io
import logging
import signal
from collections.abc import Callable, Generator
from pathlib import Path
from typing import Any, override
from unittest import mock
from unittest.mock import MagicMock
from urllib.parse import urlsplit, urlunsplit

from debusine.client.cli import Cli
from debusine.client.commands.base import Command
from debusine.client.exceptions import DebusineError
from debusine.test import TestCase


class BaseCliTests(TestCase):
    """Basic functionality to implement tests for the Cli class."""

    @override
    def setUp(self) -> None:
        super().setUp()

        debian_server = {
            'api-url': 'https://debusine.debian.org/api',
            'scope': 'debian',
            'token': 'token-for-debian',
        }
        kali_server = {
            'api-url': 'https://debusine.kali.org/api',
            'scope': 'kali',
            'token': 'token-for-kali',
        }

        self.servers = {
            "debian": debian_server,
            "kali": kali_server,
        }
        self.default_server = "debian"

        self.default_sigint_handler = signal.getsignal(signal.SIGINT)
        self.default_sigterm_handler = signal.getsignal(signal.SIGTERM)

    @override
    def tearDown(self) -> None:
        # Restore signal handlers. Cli.execute() changes them
        signal.signal(signal.SIGINT, self.default_sigint_handler)
        signal.signal(signal.SIGTERM, self.default_sigterm_handler)

        # Cli._build_debusine_object and Cli._setup_http_logging reconfigure
        # the logging system.  Reset it.
        for logger in logging.Logger.manager.loggerDict.values():
            if not isinstance(logger, logging.PlaceHolder):
                for handler in list(logger.handlers):
                    logger.removeHandler(handler)
                    handler.close()
                logger.propagate = True

        super().tearDown()

    def create_cli(self, argv: list[str], create_config: bool = True) -> Cli:
        """
        Return a Cli object using argv, self.stdout and self.stderr.

        :param argv: arguments passed to the Cli class.
        :param create_config: True for creating a config file and adding
          --config config_file_path into CLI's argv.
        """
        if create_config:
            if '--config' in argv:  # pragma: no cover
                raise ValueError(
                    'Incompatible options: create_config cannot be True if '
                    '--config is in argv'
                )
            config = self.create_config_file()

            argv = ['--config', config.as_posix(), *argv]

        return Cli(argv)

    def create_command(
        self, argv: list[str], create_config: bool = True
    ) -> Command:
        """
        Return a Command object instantiated using argv.

        :param argv: arguments passed to the Cli class.
        :param create_config: True for creating a config file and adding
          --config config_file_path into CLI's argv.
        """
        cli = self.create_cli(argv, create_config=create_config)
        return cli.get_command()

    def create_config_file(self) -> Path:
        """Write a config file and returns the path."""
        config_directory = self.create_temp_config_directory(
            {
                'General': {'default-server': self.default_server},
                'server:debian': self.servers["debian"],
                'server:kali': self.servers["kali"],
            }
        )

        return config_directory / 'config.ini'

    def capture_output(
        self,
        func: Callable[..., Any],
        args: list[Any] | None = None,
        assert_system_exit_code: int | None = None,
    ) -> tuple[str, str]:
        """
        Execute func() and return stderr and stdout output.

        :param func: functor to be executed
        :param args: list of arguments to be passed to func() (or None if
          no arguments are passed when calling func()
        :param assert_system_exit_code: if not None assert that SystemExit
          is raised
        :return: tuple[stderr, stdout]
        """
        if args is None:
            args = []

        stderr = io.StringIO()
        stdout = io.StringIO()

        with (
            contextlib.redirect_stderr(stderr),
            contextlib.redirect_stdout(stdout),
        ):
            if assert_system_exit_code is not None:
                with self.assertRaisesSystemExit(assert_system_exit_code):
                    func(*args)
            else:
                func(*args)

        return stderr.getvalue(), stdout.getvalue()

    @contextlib.contextmanager
    def patch_sys_stdin_read(self, data: str) -> Generator[mock.MagicMock]:
        """Patch sys.stdin.read to return what a user might write / input."""
        with mock.patch('sys.stdin') as mock_stdin:
            mock_stdin.read.return_value = data
            yield mock_stdin

    def patch_sys_stderr_write(self) -> MagicMock:
        """Patch sys.stderr.write to check what was written."""
        patcher_sys_stderr = mock.patch("sys.stderr.write")
        mocked_sys_stderr = patcher_sys_stderr.start()
        self.addCleanup(patcher_sys_stderr.stop)

        return mocked_sys_stderr

    def get_base_url(self, server_name: str) -> str:
        """Return the base web URL for a given server."""
        split_server = urlsplit(self.servers[server_name]["api-url"])
        return urlunsplit(
            (split_server.scheme, split_server.netloc, "", "", "")
        )

    def assertDebusineError(
        self, exception: Exception, data: dict[str, Any]
    ) -> None:
        """Ensure the exception is a Debusine error with the given data."""
        assert isinstance(exception, DebusineError)
        self.assertEqual(exception.asdict(), data)
