# 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 Workspace commands."""

import math
from typing import Any
from unittest import mock

import yaml

from debusine.client.commands.base import WorkspaceCommand
from debusine.client.commands.tests.base import BaseCliTests
from debusine.client.exceptions import DebusineError
from debusine.client.models import (
    WorkspaceInheritanceChain,
    WorkspaceInheritanceChainElement,
)
from debusine.utils.input import YamlEditor


class CliWorkspaceInheritanceTests(BaseCliTests):
    """Tests for Cli workspace-inheritance."""

    def setUp(self) -> None:
        super().setUp()
        build_debusine = self.patch_build_debusine_object()
        self.debusine = build_debusine.return_value

    def cli(
        self,
        args: list[str],
        *,
        current: WorkspaceInheritanceChain,
        expected_stderr: str = "",
    ) -> WorkspaceInheritanceChain | None:
        """
        Run the command with the given arguments.

        :returns: the inheritance chain submitted to the server, or None if
            nothing was submitted
        """
        with (
            mock.patch.object(
                self.debusine,
                "get_workspace_inheritance",
                return_value=current or WorkspaceInheritanceChain(),
            ),
            mock.patch.object(
                self.debusine,
                "set_workspace_inheritance",
                side_effect=lambda self, chain: chain,
            ) as set_inheritance,
        ):
            cli = self.create_cli(
                ["workspace", "inheritance", "workspace"] + args
            )
            stderr, stdout = self.capture_output(cli.execute)
            self.assertEqual(stderr, expected_stderr)
            if set_inheritance.called:
                submitted = set_inheritance.call_args.kwargs["chain"]
                assert isinstance(submitted, WorkspaceInheritanceChain)
                self.assertEqual(
                    stdout,
                    yaml.safe_dump(
                        submitted.dict(),
                        sort_keys=False,
                        width=math.inf,
                    ),
                )
                return submitted
            else:
                self.assertEqual(
                    stdout,
                    yaml.safe_dump(
                        current.dict(),
                        sort_keys=False,
                        width=math.inf,
                    ),
                )
                return None

    def chain(
        self,
        *args: WorkspaceInheritanceChainElement,
    ) -> WorkspaceInheritanceChain:
        """Shortcut to build an inheritance chain."""
        return WorkspaceInheritanceChain(chain=list(args))

    def test_get(self) -> None:
        El = WorkspaceInheritanceChainElement
        self.assertIsNone(self.cli([], current=self.chain(El(id=1), El(id=2))))

    def test_get_invalid_workspace(self) -> None:
        with (
            mock.patch.object(
                self.debusine,
                "get_workspace_inheritance",
                side_effect=DebusineError(title="expected error"),
            ),
        ):
            cli = self.create_cli(["workspace", "inheritance", "workspace"])
            stderr, stdout = self.capture_output(
                cli.execute, assert_system_exit_code=3
            )
            self.assertEqual(
                stderr, "result: failure\nerror:\n  title: expected error\n"
            )
            self.assertEqual(stdout, "")

    def test_change(self) -> None:
        El = WorkspaceInheritanceChainElement
        el1, el2, el3 = El(id=1), El(id=2), El(id=3)
        for current, args, expected in (
            (
                self.chain(el2),
                ["--append", "1", "3"],
                self.chain(el2, el1, el3),
            ),
            (self.chain(), ["--append", "1", "3"], self.chain(el1, el3)),
            (
                self.chain(el2),
                ["--prepend", "1", "3"],
                self.chain(el1, el3, el2),
            ),
            (self.chain(), ["--prepend", "1", "3"], self.chain(el1, el3)),
            (
                self.chain(el1, el2, el3),
                ["--remove", "1"],
                self.chain(el2, el3),
            ),
            (
                self.chain(el1, el2, el3),
                ["--set", "3", "1"],
                self.chain(el3, el1),
            ),
            (
                self.chain(el1),
                ["--append", "2", "--prepend", "3"],
                self.chain(el3, el1, el2),
            ),
            (
                self.chain(el1),
                ["--append", "2", "--set", "3"],
                self.chain(el3),
            ),
            (
                self.chain(el2),
                ["--prepend", "1", "3"],
                self.chain(el1, el3, el2),
            ),
        ):
            with self.subTest(current=repr(current), args=args):
                self.assertEqual(
                    self.cli(args, current=current),
                    expected,
                )

    def test_change_invalid_chain(self) -> None:
        El = WorkspaceInheritanceChainElement
        with (
            mock.patch.object(
                self.debusine,
                "get_workspace_inheritance",
                return_value=WorkspaceInheritanceChain(),
            ),
            mock.patch.object(
                self.debusine,
                "set_workspace_inheritance",
                side_effect=DebusineError(title="expected error"),
            ) as set_inheritance,
        ):
            cli = self.create_cli(
                ["workspace", "inheritance", "workspace", "--set", "1", "1"]
            )
            stderr, stdout = self.capture_output(
                cli.execute, assert_system_exit_code=3
            )
            self.assertEqual(stdout, "")
            self.assertEqual(
                stderr,
                "Error setting inheritance:\n\n"
                "result: failure\n"
                "error:\n"
                "  title: expected error\n",
            )
            set_inheritance.assert_called_with(
                "workspace", chain=self.chain(El(id=1), El(id=1))
            )

    def test_edit(self) -> None:
        def mock_edit(self_: YamlEditor[list[Any]]) -> bool:
            self_.value = self.chain(el3, el2, el1).dict()["chain"]
            self_.cleanup()
            return True

        El = WorkspaceInheritanceChainElement
        el1, el2, el3 = El(id=1), El(id=2), El(id=3)
        with mock.patch(
            "debusine.utils.input.YamlEditor.edit",
            side_effect=mock_edit,
            autospec=True,
        ):
            self.assertEqual(
                self.cli(["--edit"], current=self.chain(el1, el2, el3)),
                self.chain(el3, el2, el1),
            )

    def test_edit_old_new_same(self) -> None:
        def mock_edit(self_: YamlEditor[list[Any]]) -> bool:
            self_.value = self.chain(el1, el2, el3).dict()["chain"]
            self_.cleanup()
            return True

        El = WorkspaceInheritanceChainElement
        el1, el2, el3 = El(id=1), El(id=2), El(id=3)
        with mock.patch(
            "debusine.utils.input.YamlEditor.edit",
            side_effect=mock_edit,
            autospec=True,
        ):
            # self.cli returns None if Debusine.set_inheritance is not called
            # (it's not called because the user didn't try changing the
            # inheritance chain)
            self.assertIsNone(
                self.cli(["--edit"], current=self.chain(el1, el2, el3)),
            )

    def test_edit_failed_editor_returns_error(self) -> None:
        def mock_edit(self_: YamlEditor[dict[str, Any]]) -> bool:
            self_.cleanup()
            # Editor failed (e.g. user never wrote the expected YAML data
            # structure)
            return False

        El = WorkspaceInheritanceChainElement
        el1, el2, el3 = El(id=1), El(id=2), El(id=3)
        with mock.patch(
            "debusine.utils.input.YamlEditor.edit",
            side_effect=mock_edit,
            autospec=True,
        ):
            self.assertIsNone(
                self.cli(["--edit"], current=self.chain(el1, el2, el3))
            )

    def test_edit_failed_apply_inheritance_failed_one_retry(self) -> None:
        def mock_edit(self_: YamlEditor[dict[str, Any]]) -> bool:
            self_.value = self.chain(El(workspace="does-not-exist")).dict()[
                "chain"
            ]
            self_.cleanup()
            return True

        El = WorkspaceInheritanceChainElement
        with (
            mock.patch(
                "debusine.utils.input.YamlEditor.edit",
                side_effect=mock_edit,
                autospec=True,
            ),
            mock.patch(
                "debusine.client.commands."
                "workspaces.Inheritance._apply_inheritance",
                side_effect=DebusineError(
                    title="Workspace name does not exist"
                ),
                autospec=True,
            ),
            mock.patch(
                "debusine.utils.input.YamlEditor.confirm",
                # Re-try once, then give up
                side_effect=[True, False],
                autospec=True,
            ),
        ):
            expected_stderr = (
                "Error setting inheritance:\n"
                "\n"
                "result: failure\n"
                "error:\n"
                "  title: Workspace name does not exist\n"
            )
            self.assertIsNone(
                self.cli(
                    ["--edit"],
                    current=self.chain(El(workspace="devel")),
                    expected_stderr=expected_stderr * 2,
                )
            )

    def test_edit_api_call_failed(self) -> None:
        def mock_edit(self_: YamlEditor[dict[str, Any]]) -> bool:
            self_.value = self.chain(el3, el2, el1).dict()["chain"]
            self_.cleanup()
            return True

        El = WorkspaceInheritanceChainElement
        el1, el2, el3 = El(id=1), El(id=2), El(id=3)
        with (
            mock.patch(
                "debusine.utils.input.YamlEditor.edit",
                side_effect=mock_edit,
                autospec=True,
            ),
            mock.patch(
                "debusine.client.commands."
                "workspaces.Inheritance._apply_inheritance",
                side_effect=[
                    DebusineError(title="expected error"),
                    WorkspaceInheritanceChain(),
                ],
                autospec=True,
            ),
            mock.patch(
                "debusine.utils.input.YamlEditor.confirm",
                side_effect=[True, False],
                autospec=True,
            ),
        ):
            expected_stderr = (
                "Error setting inheritance:\n"
                "\n"
                "result: failure\n"
                "error:\n"
                "  title: expected error\n"
            )
            self.assertIsNone(
                self.cli(
                    ["--edit"],
                    current=self.chain(),
                    expected_stderr=expected_stderr,
                )
            )

    def test_missing_legacy_workspace_uses_standard_one(self) -> None:
        command = self.create_command(
            ["workspace", "inheritance", "--workspace=name"]
        )
        assert isinstance(command, WorkspaceCommand)
        self.assertEqual(command.workspace, "name")

    def test_missing_legacy_workspace_uses_standard_one_default(self) -> None:
        command = self.create_command(["workspace", "inheritance"])
        assert isinstance(command, WorkspaceCommand)
        self.assertEqual(command.workspace, "developers")
