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

"""Tests for the debusine Cli WorkflowTemplate commands."""

from collections.abc import Callable
from functools import partial
from textwrap import dedent
from typing import Any, override
from unittest import mock

import yaml

from debusine.client.commands.base import Command, DebusineCommand
from debusine.client.commands.tests.base import BaseCliTests
from debusine.client.commands.workflow_templates import WorkflowTemplateCommand
from debusine.client.debusine import Debusine
from debusine.client.exceptions import DebusineError
from debusine.client.models import WorkflowTemplateData, WorkflowTemplateDataNew
from debusine.utils.input import YamlEditor


class WorkflowTemplateCommandTests(BaseCliTests):
    """Tests for the :py:class:`CollectionCommand` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.data = WorkflowTemplateData(
            id=42,
            name="sample",
            task_name="noop",
            static_parameters={"test": 2},
            runtime_parameters={},
            priority=42,
            links={"webui_self": "/wt/42"},
        )

    @Command.preserve_registry()
    def _command(self, **kwargs: Any) -> WorkflowTemplateCommand:
        class ConcreteWorkflowTemplateCommand(WorkflowTemplateCommand):
            """Version of WorkflowTemplateCommand that can be instantiated."""

            @override
            def run(self) -> None:
                raise NotImplementedError()

        return self.command(
            ConcreteWorkflowTemplateCommand, workspace="workspace"
        )

    def test_list_rich(self) -> None:
        command = self._command(workspace="workspace")
        with (
            mock.patch(
                "debusine.client.commands.workflow_templates.Table",
                return_value=(table := mock.MagicMock()),
            ),
            mock.patch(
                "debusine.client.commands.workflow_templates.rich.print"
            ) as rich_print,
        ):
            stderr, stdout = self.capture_output(
                partial(command._list_rich, [self.data])
            )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        rich_print.assert_called_once()

        self.assertEqual(table.add_row.call_count, 1)
        url = "https://debusine.debian.org/wt/42"
        call = table.add_row.call_args_list[0]
        self.assertEqual(len(call.args), 8)
        self.assertEqual(call.args[0], "42")
        self.assertEqual(call.args[1], f"[link={url}]sample[/]")
        self.assertEqual(call.args[2], "noop")
        self.assertEqual(call.args[3], "42")
        self.assertEqual(call.args[7], "False")

    def test_show_rich(self) -> None:
        command = self._command(workspace="workspace")
        with (
            mock.patch(
                "debusine.client.commands.workflow_templates.Table",
                return_value=(table := mock.MagicMock()),
            ),
            mock.patch(
                "debusine.client.commands.workflow_templates.rich.print"
            ) as rich_print,
        ):
            stderr, stdout = self.capture_output(
                partial(command._show_rich, self.data)
            )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        rich_print.assert_called_once()

        self.assertEqual(table.add_row.call_count, 8)
        url = "https://debusine.debian.org/wt/42"
        rows = [
            call_args_list.args
            for call_args_list in table.add_row.call_args_list
        ]
        self.assertEqual(rows[0], ("ID:", "#42"))
        self.assertEqual(rows[1], ("Name:", f"[link={url}]sample[/]"))
        self.assertEqual(rows[2], ("Task name:", "noop"))
        self.assertEqual(rows[3], ("Priority:", "42"))
        self.assertEqual(rows[6], ("Extra groups:", ""))
        self.assertEqual(rows[7], ("Restricted:", "False"))

    def test_parse_input_data_validation(self) -> None:
        defaults = {
            "static_parameters": {},
            "runtime_parameters": "any",
            "extra_groups": [],
            "restricted": True,
        }
        for data, error in (
            (
                {**defaults, "static_parameters": 42},
                "Error: static_parameters must be a dictionary. It is: int",
            ),
            (
                {**defaults, "runtime_parameters": 42},
                "Error: runtime_parameters must be a dictionary or string."
                " It is: int",
            ),
            (
                {**defaults, "extra_groups": 42},
                "Error: extra_groups must be a list. It is: int",
            ),
            (
                {**defaults, "extra_groups": ["foo", []]},
                "Error: extra_groups must be a list of integers or strings",
            ),
            (
                {**defaults, "restricted": 42},
                "Error: restricted must be a boolean. It is: int",
            ),
        ):
            with (
                self.subTest(data=repr(data)),
                mock.patch(
                    "debusine.client.commands.workflow_templates"
                    ".WorkflowTemplateCommand._fail",
                    side_effect=RuntimeError("_fail"),
                ) as fail,
            ):
                with self.assertRaisesRegex(RuntimeError, "_fail"):
                    WorkflowTemplateCommand._parse_input_data(data)
                fail.assert_called_once_with(error)


class CreateTests(BaseCliTests):
    """Tests for the CLI :py:class:`workflow_template.Create` class."""

    def _command(
        self, task_name: str, template_name: str, *args: str
    ) -> list[str]:
        """Build a command line."""
        return ["workflow-template", "create", task_name, template_name, *args]

    def assert_create_success(
        self,
        task_name: str,
        template_name: str,
        *args: str,
        workspace: str,
        data: WorkflowTemplateDataNew,
    ) -> None:
        """
        Call workflow-template create with the given arguments.

        :param args: arguments to append to
          ``debusine workflow-template create``
        :param data: data expected to be sent to the server
        """
        command = self.create_command(
            self._command(task_name, template_name, "--yaml", *args)
        )
        assert isinstance(command, DebusineCommand)
        result = WorkflowTemplateData(id=42, **data.model_dump())
        result.links = {"self": "/api/wt/42", "webui_self": "/wt/42"}
        with mock.patch.object(
            command.debusine, "workflow_template_create", return_value=result
        ) as create:
            stderr, stdout = self.capture_output(command.run)

        create.assert_called_with(workspace, data)
        self.assertEqual(stderr, "")
        self.assertStdoutYaml(workspace, stdout, result)

    def assertStdoutYaml(
        self,
        workspace: str,  # noqa: ARG002, U100
        stdout: str,
        result: WorkflowTemplateData,
    ) -> None:
        """Ensure the program stdout matches the given result."""
        actual = WorkflowTemplateData.model_validate(yaml.safe_load(stdout))
        self.assertEqual(actual, result)

    def test_invalid_task_name(self) -> None:
        """CLI fails if the task name is bad."""
        task_name = "task-name"

        self.enterContext(self.patch_sys_stdin_read("{}"))
        cli = self.create_cli(self._command(task_name, "test"))

        with mock.patch.object(
            Debusine,
            "workflow_template_create",
            autospec=True,
            side_effect=DebusineError(title=f"invalid {task_name}"),
        ):
            exception = self.assertShowsError(cli.execute)

        self.assertDebusineError(exception, {"title": f"invalid {task_name}"})

    def test_specific_workspace(self) -> None:
        """CLI parses the command line and uses --workspace."""
        self.enterContext(self.patch_sys_stdin_read("{}"))
        self.assert_create_success(
            "sbuild",
            "sbuild-test",
            "--workspace",
            "Testing",
            workspace="Testing",
            data=WorkflowTemplateDataNew(
                name="sbuild-test", task_name="sbuild", static_parameters={}
            ),
        )

    def test_success_data_from_file(self) -> None:
        """CLI creates a workflow template with data from a file."""
        tempfile = self.create_temporary_file()
        tempfile.write_text("{}")
        self.assert_create_success(
            "sbuild",
            "sbuild-test",
            f"--data={tempfile}",
            workspace="developers",
            data=WorkflowTemplateDataNew(
                name="sbuild-test",
                task_name="sbuild",
                static_parameters={},
                runtime_parameters={},
            ),
        )

    def test_success_data_from_stdin(self) -> None:
        """CLI creates a workflow template with data from stdin."""
        self.enterContext(self.patch_sys_stdin_read("{}"))
        self.assert_create_success(
            "sbuild",
            "sbuild-test",
            workspace="developers",
            data=WorkflowTemplateDataNew(
                name="sbuild-test",
                task_name="sbuild",
                static_parameters={},
                runtime_parameters={},
            ),
        )

    def test_parameters_are_parsed(self) -> None:
        """CLI creates a workflow template with specified parameters."""
        tempfile = self.create_temporary_file()
        expected = WorkflowTemplateDataNew(
            name="sbuild-test",
            task_name="sbuild",
            static_parameters={"foo": "bar"},
            runtime_parameters={"foo": ["bar", "baz"]},
        )
        data = {
            "static_parameters": expected.static_parameters,
            "runtime_parameters": expected.runtime_parameters,
        }
        tempfile.write_text(yaml.safe_dump(data))
        self.assert_create_success(
            "sbuild",
            "sbuild-test",
            f"--data={tempfile}",
            workspace="developers",
            data=expected,
        )

    def test_data_is_empty(self) -> None:
        """CLI rejects a workflow template with empty data."""
        empty_file = self.create_temporary_file()
        cli = self.create_cli(
            self._command("sbuild", "sbuild-test", "--data", str(empty_file))
        )

        stderr, stdout = self.capture_output(
            cli.execute, assert_system_exit_code=3
        )

        self.assertEqual(
            stderr, "Error: data must be a dictionary. It is empty\n"
        )
        self.assertEqual(stdout, "")

    def test_yaml_errors_failed(self) -> None:
        """cli.execute() deals with different invalid data."""
        workflow_templates = [
            {
                "data": dedent(
                    """\
                    test:
                      name: a-name
                        first-name: some first name
                    """
                ),
                "comment": "yaml.safe_load raises ScannerError",
            },
            {
                "data": dedent(
                    """\
                    input:
                      source_url: https://example.com
                     )
                    """
                ),
                "comment": "yaml.safe_load raises ParserError",
            },
        ]

        for workflow_template in workflow_templates:
            data = workflow_template["data"]
            with (
                self.subTest(data),
                self.patch_sys_stdin_read(data),
            ):
                cli = self.create_cli(
                    self._command("task-name", "template-name")
                )
                stderr, _ = self.capture_output(
                    cli.execute, assert_system_exit_code=3
                )

                self.assertRegex(stderr, "^Error parsing YAML:")
                self.assertRegex(stderr, "Fix the YAML data\n$")


class ListTests(BaseCliTests):
    """Tests for the :py:class:`workflow_templates.List` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.data = WorkflowTemplateData(
            id=42,
            name="sample",
            task_name="noop",
            static_parameters={"test": 2},
            runtime_parameters={},
            priority=42,
        )

    def test_list(self) -> None:
        command = self.create_command(
            ["workflow-template", "list", "--workspace=workspace"]
        )
        assert isinstance(command, DebusineCommand)
        with (
            mock.patch.object(
                command.debusine,
                "workflow_template_iter",
                return_value=[self.data],
            ) as collection_iter,
            mock.patch.object(command, "list") as list_,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        collection_iter.assert_called_once_with("workspace")
        list_.assert_called_once_with([self.data])


class ShowTests(BaseCliTests):
    """Tests for the :py:class:`workflow_template.Show` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.data = WorkflowTemplateData(
            id=42,
            name="sample",
            task_name="noop",
            static_parameters={"test": 2},
            runtime_parameters={},
            priority=42,
        )

    def test_show_id(self) -> None:
        command = self.create_command(
            ["workflow-template", "show", "--workspace=workspace", "42"]
        )
        assert isinstance(command, DebusineCommand)
        with (
            mock.patch.object(
                command.debusine,
                "workflow_template_get",
                return_value=self.data,
            ) as get,
            mock.patch.object(command, "show") as show,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        get.assert_called_once_with("workspace", "42")
        show.assert_called_once_with(self.data)

    def test_show_name(self) -> None:
        command = self.create_command(
            ["workflow-template", "show", "--workspace=workspace", "sample"]
        )
        assert isinstance(command, DebusineCommand)
        with (
            mock.patch.object(
                command.debusine,
                "workflow_template_get",
                return_value=self.data,
            ) as get,
            mock.patch.object(command, "show") as show,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        get.assert_called_once_with("workspace", "sample")
        show.assert_called_once_with(self.data)


class ManageTests(BaseCliTests):
    """Tests for the :py:class:`workflow_templates.Manage` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.sample = WorkflowTemplateData(
            id=42,
            name="sample",
            task_name="noop",
            static_parameters={"test": 2},
            runtime_parameters={},
            priority=42,
        )
        self.edited = self.sample.model_copy()

    def call(
        self, *args: str, assert_system_exit_code: int | None = None
    ) -> tuple[mock.Mock, mock.Mock, str, str]:
        """Call workflow_template_update"""
        command = self.create_command(
            [
                "workflow-template",
                "manage",
                "--workspace=workspace",
                "sample",
                *args,
            ]
        )
        assert isinstance(command, DebusineCommand)

        with (
            mock.patch.object(
                command.debusine,
                "workflow_template_get",
                return_value=self.sample,
            ),
            mock.patch.object(
                command.debusine,
                "workflow_template_update",
                return_value=self.edited,
            ) as update,
            mock.patch.object(command, "show") as show,
        ):
            stderr, stdout = self.capture_output(
                command.run, assert_system_exit_code=assert_system_exit_code
            )
        return update, show, stdout, stderr

    def assertManages(self, *args: str) -> None:
        """Call workflow_template_update and match self.edited."""
        update, show, stdout, stderr = self.call(*args)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        update.assert_called_once_with("workspace", self.edited)
        show.assert_called_once_with(self.edited)

    def assertFails(self, *args: str, error: str) -> None:
        """Call workflow_template_update and match error to stderr."""
        update, show, stdout, stderr = self.call(
            *args, assert_system_exit_code=3
        )

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, error)
        update.assert_not_called()
        show.assert_not_called()

    def test_rename(self) -> None:
        self.edited.name = "renamed"
        self.assertManages("--rename=renamed")

    def test_set_priority(self) -> None:
        self.edited.priority = 1
        self.assertManages("--priority=1")

    def test_set_data(self) -> None:
        infile = self.create_temporary_file()
        infile.write_text("{'static_parameters': {'test': 7}}")
        self.edited.static_parameters = {"test": 7}
        self.assertManages(f"--data={infile}")

    def test_set_data_stdin(self) -> None:
        self.enterContext(
            self.patch_sys_stdin_read("{'static_parameters': {'test': 7}}")
        )
        self.edited.static_parameters = {"test": 7}
        self.assertManages("--data=-")

    def test_set_data_unknown_key(self) -> None:
        infile = self.create_temporary_file()
        infile.write_text("{'unknown': 'parameter'}")
        self.assertFails(
            f"--data={infile}",
            error=(
                "Error parsing input data: Expecting only 4 keys: "
                "['extra_groups', 'restricted', 'runtime_parameters',"
                " 'static_parameters'],"
                " found: ['unknown']\n"
            ),
        )

    def test_set_data_static_parameters_garbage(self) -> None:
        infile = self.create_temporary_file()
        infile.write_text("{'static_parameters': 'garbage'}")
        self.assertFails(
            f"--data={infile}",
            error=(
                "Error: static_parameters must be a dictionary. It is: str\n"
            ),
        )

    def test_set_data_runtime_parameters_garbage(self) -> None:
        infile = self.create_temporary_file()
        infile.write_text("{'runtime_parameters': 123}")
        self.assertFails(
            f"--data={infile}",
            error=(
                "Error: runtime_parameters must be a dictionary or string. "
                "It is: int\n"
            ),
        )


class DeleteTests(BaseCliTests):
    """Tests for the :py:class:`workflow_templates.Delete` class."""

    def test_delete(self) -> None:
        """Call workflow_template_delete"""
        command = self.create_command(
            [
                "workflow-template",
                "delete",
                "--workspace=workspace",
                "sample",
            ]
        )
        assert isinstance(command, DebusineCommand)

        with (
            mock.patch.object(
                command.debusine,
                "workflow_template_delete",
            ) as workflow_template_delete,
            mock.patch.object(command, "feedback") as feedback,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        workflow_template_delete.assert_called_once_with("workspace", "sample")
        feedback.assert_called_once_with("Workflow template deleted.")


class EditTests(BaseCliTests):
    """Tests for the :py:class:`workflow_templates.Edit` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.sample = WorkflowTemplateData(
            id=42,
            name="sample",
            task_name="noop",
            static_parameters={"test": 2},
            runtime_parameters={},
            priority=42,
        )
        self.edited = self.sample.model_copy()

    def edit(
        self,
        *args: str,
        mock_edit: Callable[[YamlEditor[dict[str, Any]]], bool],
        assert_system_exit_code: int | None = None,
    ) -> tuple[mock.Mock, mock.Mock, str, str]:
        """Call workflow-template edit."""
        command = self.create_command(
            [
                "workflow-template",
                "edit",
                "--workspace=workspace",
                "sample",
                *args,
            ]
        )
        assert isinstance(command, DebusineCommand)

        with (
            mock.patch.object(
                command.debusine,
                "workflow_template_get",
                return_value=self.sample,
            ),
            mock.patch.object(
                command.debusine,
                "workflow_template_update",
                return_value=self.edited,
            ) as update,
            mock.patch.object(command, "show") as show,
            mock.patch(
                "debusine.utils.input.YamlEditor.edit",
                side_effect=mock_edit,
                autospec=True,
            ),
        ):
            stderr, stdout = self.capture_output(
                command.run, assert_system_exit_code=assert_system_exit_code
            )
        return (update, show, stdout, stderr)

    def assertEdits(
        self,
        *args: str,
        mock_edit: Callable[[YamlEditor[dict[str, Any]]], bool],
        unchanged: bool = False,
    ) -> None:
        """Call workflow-template edit and match self.edited."""
        update, show, stdout, stderr = self.edit(*args, mock_edit=mock_edit)

        self.assertEqual(stderr, "")
        if unchanged:
            update.assert_not_called()
            self.assertEqual(
                stdout, "Workflow template sample is left unchanged.\n"
            )
        else:
            update.assert_called_once_with("workspace", self.edited)
            self.assertEqual(stdout, "")
        show.assert_called_once_with(self.edited)

    def assertEditFails(
        self,
        *args: str,
        mock_edit: Callable[[YamlEditor[dict[str, Any]]], bool],
        error: str,
    ) -> None:
        """Call workflow-template edit and match error to stderr."""
        update, show, stdout, stderr = self.edit(
            *args, mock_edit=mock_edit, assert_system_exit_code=3
        )

        self.assertEqual(stderr, error)
        self.assertEqual(stdout, "")
        update.assert_not_called()
        show.assert_not_called()

    def test_edit_unchanged(self) -> None:
        def mock_edit(self_: YamlEditor[dict[str, Any]]) -> bool:
            self_.value = {
                "static_parameters": self.sample.static_parameters,
                "runtime_parameters": self.sample.runtime_parameters,
                "extra_groups": self.sample.extra_groups,
                "restricted": self.sample.restricted,
            }
            self_.cleanup()
            return True

        self.assertEdits(mock_edit=mock_edit, unchanged=True)

    def test_edit_changed(self) -> None:
        self.edited.static_parameters = {"edited": True}

        def mock_edit(self_: YamlEditor[dict[str, Any]]) -> bool:
            self_.value = {
                "static_parameters": self.edited.static_parameters,
                "runtime_parameters": self.edited.runtime_parameters,
                "extra_groups": self.edited.extra_groups,
                "restricted": self.edited.restricted,
            }
            self_.cleanup()
            return True

        self.assertEdits(mock_edit=mock_edit)

    def test_edit_aborted(self) -> None:
        def mock_edit(self_: YamlEditor[dict[str, Any]]) -> bool:
            self_.value = {"edited": True}
            self_.cleanup()
            return False

        self.assertEdits(mock_edit=mock_edit, unchanged=True)

    def test_edit_unexpected_keys(self) -> None:
        def mock_edit(self_: YamlEditor[dict[str, Any]]) -> bool:
            self_.value = {"garbage": ""}
            self_.cleanup()
            return True

        self.assertEditFails(
            mock_edit=mock_edit,
            error=(
                "Error parsing input data: Expecting 4 keys: "
                "['extra_groups', 'restricted', 'runtime_parameters',"
                " 'static_parameters'],"
                " found: ['garbage']\n"
            ),
        )
