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

"""Tests for the debusine Cli class."""
import contextlib
import io
import os
import signal
from unittest import TestCase, mock

import yaml

from debusine.client import exceptions
from debusine.client.cli import Cli
from debusine.client.config import ConfigHandler
from debusine.client.debusine import Debusine
from debusine.client.exceptions import DebusineError, NotFoundError
from debusine.client.models import WorkRequest
from debusine.test import TestHelpersMixin


class CliTests(TestHelpersMixin, TestCase):
    """Tests for the debusine command line interface."""

    def setUp(self):
        """Configure test object."""
        self.stdout = io.StringIO()
        self.stderr = io.StringIO()

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

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

    def tearDown(self):
        """Cleanup after executing a test."""
        # Restore signal handlers. Cli.execute() changes them
        signal.signal(signal.SIGINT, self.default_sigint_handler)
        signal.signal(signal.SIGTERM, self.default_sigterm_handler)

    def create_cli(self, argv, create_config=True):
        """
        Return a Cli object using argv, self.stdout and self.stderr.

        :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] + argv

        return Cli(argv, self.stdout, self.stderr)

    def create_config_file(self):
        """Write a config file and returns the path."""
        config_directory = self.create_temp_config_directory(
            {
                'General': {'default-server': 'debian'},
                'server:debian': self.debian_server,
                'server:kali': self.kali_server,
            }
        )

        return os.path.join(config_directory, 'config.ini')

    def test_client_without_parameters(self):
        """
        Executing the client without any parameter returns an error.

        At least one subcommands is required. argparse prints help
        and exit.
        """
        cli = self.create_cli([], create_config=False)
        stderr = io.StringIO()

        with contextlib.redirect_stderr(stderr):
            with self.assertRaisesSystemExit(2):
                # Argparse prints help and exits with exit_code=3
                cli.execute()

        self.assertTrue(len(stderr.getvalue()) > 80)

    def test_client_help_include_default_setting(self):
        """Cli.execute() help include ConfigHandler.DEFAULT_CONFIG_FILE_PATH."""
        stdout = io.StringIO()

        cli = self.create_cli(['--help'], create_config=False)

        with contextlib.redirect_stdout(stdout):
            with self.assertRaisesSystemExit(0):
                # Argparse prints help and exits with exit_code=0
                cli.execute()

        # argparse might add \n and spaces (for indentation) in the
        # output to align the text. In this case
        # ConfigHandler.DEFAULT_CONFIG_FILE_PATH could not be found
        output = stdout.getvalue().replace('\n', '').replace(' ', '')

        self.assertIn(
            str(ConfigHandler.DEFAULT_CONFIG_FILE_PATH).replace(' ', ''), output
        )

    def assert_client_object_use_specific_server(self, args, server_config):
        """Assert that Cli uses Debusine with the correct endpoint."""
        cli = self.create_cli(args)

        cli._parse_args()

        debusine = cli._build_debusine_object()

        self.assertEqual(debusine.api_url, server_config['url'])
        self.assertEqual(debusine.api_token, server_config['token'])

    def test_use_default_server(self):
        """Ensure debusine object uses the default server."""
        self.assert_client_object_use_specific_server(
            ['work-request-status', '10'], self.debian_server
        )

    def test_use_explicit_server(self):
        """Ensure debusine object uses the Kali server when requested."""
        self.assert_client_object_use_specific_server(
            ['--server', 'kali', 'work-request-status', '10'], self.kali_server
        )

    def test_work_request_status_success(self):
        """Cli use Debusine to fetch a WorkRequest and prints it to stdout."""
        patcher = mock.patch.object(Debusine, 'work_request_status')
        mocked_task_status = patcher.start()
        work_request_config = {
            'id': 10,
            'status': 'running',
            'task_name': 'sbuild',
            'task_data': {'architecture': 'amd64'},
        }
        mocked_task_status.return_value = WorkRequest(**work_request_config)
        self.addCleanup(patcher.stop)

        cli = self.create_cli(['work-request-status', '10'])

        cli.execute()

        expected = yaml.safe_dump(
            {
                'id': work_request_config['id'],
                'created_at': None,
                'started_at': None,
                'completed_at': None,
                'duration': None,
                'status': work_request_config['status'],
                'result': None,
                'worker': None,
                'task_name': work_request_config['task_name'],
                'task_data': work_request_config['task_data'],
            },
            sort_keys=False,
        )
        self.assertEqual(self.stdout.getvalue(), expected)

    def patch_sys_stdin_read(self) -> mock.MagicMock:
        """Patch sys.stdin.read to return what a user might write / input."""
        patcher_sys_stdin = mock.patch('sys.stdin.read')
        mocked_sys_stdin = patcher_sys_stdin.start()
        mocked_sys_stdin.return_value = (
            'distribution: jessie\npackage: http://..../package_1.2-3.dsc\n'
        )
        self.addCleanup(patcher_sys_stdin.stop)

        return mocked_sys_stdin

    def patch_work_request_create(self) -> mock.MagicMock:
        """
        Patch Debusine.work_request_create.

        Does not set return_value / side_effect.
        """
        patcher_work_request_create = mock.patch.object(
            Debusine, 'work_request_create'
        )
        mocked_work_request_create = patcher_work_request_create.start()

        self.addCleanup(patcher_work_request_create.stop)
        return mocked_work_request_create

    def test_create_work_request_success(self):
        """Cli parse the command line and stdin to create a WorkRequest."""
        mocked_post_work_request = self.patch_work_request_create()
        work_request_config = {
            'id': 11,
            'status': 'pending',
            'task_name': 'sbuild',
        }
        mocked_post_work_request.return_value = WorkRequest(
            **work_request_config
        )

        self.patch_sys_stdin_read()

        cli = self.create_cli(['create-work-request', 'sbuild'])

        cli.execute()

        expected = yaml.safe_dump(
            {
                'result': 'success',
                'message': 'Work request registered on '
                'https://debusine.debian.org with id 11.',
                'work_request_id': 11,
            },
            sort_keys=False,
        )
        self.assertEqual(self.stdout.getvalue(), expected)
        self.assertEqual(
            mocked_post_work_request.mock_calls[0].args[0].task_data,
            {
                'distribution': 'jessie',
                'package': 'http://..../package_1.2-3.dsc',
            },
        )

    def test_create_work_request_invalid_task_name(self):
        """Cli parse the CLI and stdin to create a WorkRequest bad task_name."""
        mocked_work_request_create = self.patch_work_request_create()
        mocked_work_request_create.side_effect = DebusineError(
            {"title": "invalid task-name"}
        )

        self.patch_sys_stdin_read()

        cli = self.create_cli(['create-work-request', 'task-name'])

        cli.execute()

        expected = yaml.safe_dump(
            {"result": "failure", "error": {"title": "invalid task-name"}},
            sort_keys=False,
        )
        self.assertEqual(self.stdout.getvalue(), expected)

    def test_create_work_request_yaml_errors_failed(self):
        """cli.execute() deal with different invalid task_data."""
        work_requests = [
            {
                "task_data": "test:\n  name: a-name\n"
                "    first-name: some first name",
                "comment": "yaml.safe_load raises ScannerError",
            },
            {
                "task_data": "input:\n  source_url: https://example.com\n"
                " sbuild_options:\n - --post=some_command\n",
                "comment": "yaml.safe_load raises ParserError",
            },
        ]

        mocked_sys_stdin = self.patch_sys_stdin_read()

        for work_request in work_requests:
            task_data = work_request["task_data"]
            with self.subTest(task_data):
                mocked_sys_stdin.return_value = task_data

                cli = self.create_cli(["create-work-request", "task-name"])
                with self.assertRaisesSystemExit(3):
                    cli.execute()

                self.assertRegex(self.stderr.getvalue(), "^Error parsing YAML:")
                self.assertRegex(
                    self.stderr.getvalue(),
                    "Work request not created. Fix the task data YAML.\n$",
                )

    def test_api_call_or_fail_not_found(self):
        """_api_call_or_fail print error message for not found ane exit."""
        cli = self.create_cli([], create_config=False)

        def raiseNotFound():
            raise NotFoundError('Not found')

        with self.assertRaisesSystemExit(3):
            cli._api_call_or_fail(raiseNotFound)

        self.assertEqual(self.stderr.getvalue(), 'Not found\n')

    def test_api_call_or_fail_unexpected_error(self):
        """_api_call_or_fail print error message for not found ane exit."""
        cli = self.create_cli([], create_config=False)

        def raiseUnexpectedResponseError():
            raise exceptions.UnexpectedResponseError('Not available')

        with self.assertRaisesSystemExit(3):
            cli._api_call_or_fail(raiseUnexpectedResponseError)

        self.assertEqual(
            self.stderr.getvalue(),
            'Not available\n',
        )

    def test_api_call_or_fail_client_forbidden_error(self):
        """
        _api_call_or_fail print error message and exit.

        ClientForbiddenError was raised.
        """
        cli = self.create_cli([], create_config=False)

        def raiseClientForbiddenError():
            raise exceptions.ClientForbiddenError('Invalid token')

        with self.assertRaisesSystemExit(3):
            cli._api_call_or_fail(raiseClientForbiddenError)

        self.assertEqual(
            self.stderr.getvalue(),
            'Server rejected connection: Invalid token\n',
        )

    def test_api_call_or_fail_client_connection_error(self):
        """
        _api_call_or_fail print error message and exit.

        ClientConnectionError was raised.
        """
        cli = self.create_cli([], create_config=False)

        def raiseClientConnectionError():
            raise exceptions.ClientConnectionError('Connection refused')

        with self.assertRaisesSystemExit(3):
            cli._api_call_or_fail(raiseClientConnectionError)

        self.assertEqual(
            self.stderr.getvalue(),
            'Error connecting to debusine: Connection refused\n',
        )

    def test_api_call_or_fail_success(self):
        """
        _api_call_or_fail print error message.

        ClientConnectionError was raised.
        """
        cli = self.create_cli([], create_config=False)

        data = cli._api_call_or_fail(lambda: 'some data from the server')

        self.assertEqual(data, 'some data from the server')

    def test_signal_handlers_set_on_exec(self):
        """Test that the signal handlers are set on exec()."""
        cli = self.create_cli(["work-request-status", "1"])

        # Cli.__init__() is not changing the signal handlers for SIG{INT,TERM}
        self.assertEqual(
            signal.getsignal(signal.SIGINT), self.default_sigint_handler
        )
        self.assertEqual(
            signal.getsignal(signal.SIGTERM), self.default_sigterm_handler
        )

        patcher = mock.patch.object(cli, "_print_work_request_status")
        mocked_print = patcher.start()
        self.addCleanup(mocked_print)

        cli.execute()

        # cli.execute() changed the signal handlers for SIG{INT,TERM}
        self.assertEqual(signal.getsignal(signal.SIGINT), cli._exit)
        self.assertEqual(signal.getsignal(signal.SIGTERM), cli._exit)

    def test_exit_raise_system_exit_0(self):
        """Cli._exit raise SystemExit(0)."""
        with self.assertRaisesSystemExit(0):
            Cli._exit(None, None)
