# 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 command common functions."""

import argparse
import http
import io
import logging
from collections.abc import Iterable
from functools import partial
from typing import Any, NoReturn, override
from unittest import mock
from unittest.mock import patch

import rich.console
import yaml

from debusine.client import exceptions
from debusine.client.cli import Cli
from debusine.client.commands.base import (
    Command,
    DebusineCommand,
    InputDataCommand,
    ModelCommand,
    WorkspaceCommand,
)
from debusine.client.commands.tests.base import BaseCliTests
from debusine.client.exceptions import DebusineError, NotFoundError
from debusine.client.models import StrictBaseModel


class CommandTests(BaseCliTests):
    """Test the :py:class:`Command` class."""

    @Command.preserve_registry()
    def build_command(self, **kwargs: Any) -> Command:
        """
        Build a Command for tests.

        :param kwargs: key-value pairs to set in the mock argparse namespace
          passed to the Command
        """

        class MockCommand(Command):
            """Concrete implementation of Command used for tests."""

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

        return self.command(MockCommand, **kwargs)

    def make_base_parser(
        self,
    ) -> tuple[argparse.ArgumentParser, "argparse._SubParsersAction[Any]"]:
        """Make a base ArgumentParser."""
        parser = argparse.ArgumentParser(
            prog='debusine',
            description='Interacts with a Debusine server.',
            formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        )
        subparsers = parser.add_subparsers(
            help='Sub command', dest='sub-command', required=True
        )
        return parser, subparsers

    @Command.preserve_registry()
    def test_default_name(self) -> None:
        class Cmd(Command):
            @override
            def run(self) -> None:
                pass  # pragma: no cover

        self.assertEqual(Cmd.name, "cmd")

    @Command.preserve_registry()
    def test_explicit_name(self) -> None:
        class Cmd(Command, name="test"):
            @override
            def run(self) -> None:
                pass  # pragma: no cover

        self.assertEqual(Cmd.name, "test")

    @Command.preserve_registry()
    def test_abstract_not_registered(self) -> None:
        class Concrete(Command):
            @override
            def run(self) -> None:
                pass  # pragma: no cover

        class Abstract(Command):
            pass

        self.assertIn(Concrete, Command.commands_by_group[None])
        self.assertNotIn(Abstract, Command.commands_by_group[None])

    def test_add_subparsers_group_not_found(self) -> None:
        _, subparsers = self.make_base_parser()
        with self.assertRaisesRegex(
            KeyError, r"Command group 'does-not-exist' not found in "
        ):
            Command.add_subparser(
                subparsers,
                "does-not-exist",
                argument_help="test",
                subparser_help="test",
            )

    @Command.preserve_registry()
    def test_add_subparsers_missing_docstring(self) -> None:
        class Cmd(Command, group="test"):
            @override
            def run(self) -> None:
                pass  # pragma: no cover

        _, subparsers = self.make_base_parser()
        with self.assertRaisesRegex(AssertionError, r".+ lacks a docstring"):
            Command.add_subparser(
                subparsers,
                "test",
                argument_help="test",
                subparser_help="test",
            )

    @Command.preserve_registry()
    def test_create_not_found(self) -> None:
        args = self.build_parsed_namespace()
        self.assertIsNone(Command.create(Cli.build_argument_parser(), args))

    def test_print_yaml(self) -> None:
        cmd = self.build_command()
        data = {"message": "x" * 100}
        _, stdout = self.capture_output(cmd._print_yaml, args=[data])
        self.assertEqual(stdout.count("\n"), 1)

    def test_print_yaml_stderr(self) -> None:
        data = {"key": "value"}
        stream = io.StringIO()

        Command._print_yaml(data, file=stream)
        self.assertEqual(stream.getvalue(), yaml.safe_dump(data))

    def test_show_error_debusineerror_yaml(self) -> None:
        sample = DebusineError(title="expected error")
        command = self.build_command()
        stderr, stdout = self.capture_output(
            partial(command._show_error_yaml, sample)
        )
        self.assertEqual(
            yaml.safe_load(stdout),
            {"result": "failure", "error": sample.asdict()},
        )
        self.assertNotEqual(stdout.splitlines()[0], "---")
        self.assertEqual(stderr, "")

    def test_show_error_exception_yaml(self) -> None:
        sample = Exception("expected error")
        command = self.build_command()
        stderr, stdout = self.capture_output(
            partial(command._show_error_yaml, sample)
        )
        self.assertEqual(
            yaml.safe_load(stdout),
            {"result": "failure", "error": "expected error"},
        )
        self.assertNotEqual(stdout.splitlines()[0], "---")
        self.assertEqual(stderr, "")

    def test_show_error_yaml_yaml_in_input(self) -> None:
        sample = DebusineError(title="expected error")
        command = self.build_command()
        command.yaml_in_input = True
        stderr, stdout = self.capture_output(
            partial(command._show_error_yaml, sample)
        )
        self.assertEqual(
            yaml.safe_load(stdout),
            {"result": "failure", "error": sample.asdict()},
        )
        self.assertEqual(stdout.splitlines()[0], "---")
        self.assertEqual(stderr, "")

    def test_show_error_debusineerror_rich(self) -> None:
        sample = DebusineError(title="expected error")
        command = self.build_command()
        with mock.patch("rich.console.Console.print") as console_print:
            stderr, stdout = self.capture_output(
                partial(command._show_error_rich, sample)
            )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertEqual(console_print.call_count, 2)
        self.assertEqual(console_print.call_args_list[0].args[0], "Error:")
        self.assertEqual(
            console_print.call_args_list[1].args[0], "expected error"
        )

    def test_show_error_debusineerror_rich_detail(self) -> None:
        sample = DebusineError(title="expected error", detail="detail")
        command = self.build_command()
        with mock.patch("rich.console.Console.print") as console_print:
            stderr, stdout = self.capture_output(
                partial(command._show_error_rich, sample)
            )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertEqual(console_print.call_count, 3)
        self.assertEqual(console_print.call_args_list[0].args[0], "Error:")
        self.assertEqual(
            console_print.call_args_list[1].args[0], "expected error"
        )
        self.assertEqual(console_print.call_args_list[2].args[0], "detail")

    def test_show_error_debusineerror_rich_validation_errors(self) -> None:
        validation_errors = ["test", {"value": True}]
        sample = DebusineError(
            title="expected error", validation_errors=validation_errors
        )
        command = self.build_command()
        with (
            mock.patch("rich.console.Console.print") as console_print,
            mock.patch(
                "rich.json.JSON.from_data",
                side_effect=lambda x, **kwargs: {"r": x},
            ) as from_data,
        ):
            stderr, stdout = self.capture_output(
                partial(command._show_error_rich, sample)
            )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertEqual(console_print.call_count, 3)
        self.assertEqual(console_print.call_args_list[0].args[0], "Error:")
        self.assertEqual(
            console_print.call_args_list[1].args[0], "expected error"
        )
        self.assertEqual(
            console_print.call_args_list[2].args[0], {"r": validation_errors}
        )
        from_data.assert_called_with(validation_errors, indent=2)

    def test_show_error_debusineerror_rich_status_code(self) -> None:
        sample = DebusineError(title="expected error", status_code=418)
        command = self.build_command()
        with mock.patch("rich.console.Console.print") as console_print:
            stderr, stdout = self.capture_output(
                partial(command._show_error_rich, sample)
            )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertEqual(console_print.call_count, 2)
        self.assertEqual(
            console_print.call_args_list[0].args[0], "Error (418):"
        )
        self.assertEqual(
            console_print.call_args_list[1].args[0], "expected error"
        )

    def test_show_error_exception_rich(self) -> None:
        sample = Exception("expected error")
        command = self.build_command()
        with mock.patch("rich.console.Console.print") as console_print:
            stderr, stdout = self.capture_output(
                partial(command._show_error_rich, sample)
            )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        console_print.assert_has_calls(
            (
                mock.call("[red]Command failed:[/]", end=" "),
                mock.call(str(sample), highlight=False, markup=False),
            ),
        )

    def test_show_error_select_yaml(self) -> None:
        sample = Exception("expected error")
        command = self.build_command(yaml=True)
        with (
            mock.patch.object(command, "_show_error_rich") as show_rich,
            mock.patch.object(command, "_show_error_yaml") as show_yaml,
            self.assertRaises(SystemExit),
        ):
            command.show_error(sample)
        show_yaml.assert_called_once()
        show_rich.assert_not_called()

    def test_show_error_select_rich(self) -> None:
        sample = Exception("expected error")
        command = self.build_command(yaml=False)
        with (
            mock.patch.object(command, "_show_error_rich") as show_rich,
            mock.patch.object(command, "_show_error_yaml") as show_yaml,
            self.assertRaises(SystemExit),
        ):
            command.show_error(sample)
        show_yaml.assert_not_called()
        show_rich.assert_called_once()

    def test_console_is_none_when_yaml(self) -> None:
        cmd = self.build_command(yaml=True)
        self.assertTrue(cmd.args.yaml)
        self.assertIsNone(cmd.console)

    def test_console_is_set_when_not_yaml(self) -> None:
        cmd = self.build_command()
        self.assertFalse(cmd.args.yaml)
        self.assertIsInstance(cmd.console, rich.console.Console)

    def test_feedback_when_yaml(self) -> None:
        cmd = self.build_command(yaml=True)
        with self.assertLogs(logger="debusine", level="INFO") as lg:
            stderr, stdout = self.capture_output(
                partial(cmd.feedback, "[red]test[/]")
            )
        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertEqual(lg.output, ['INFO:debusine:test'])

    def test_feedback_when_not_yaml(self) -> None:
        cmd = self.build_command()
        with mock.patch.object(cmd.console, "print") as console_print:
            stderr, stdout = self.capture_output(
                partial(cmd.feedback, "[red]test[/]")
            )
        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        console_print.assert_called_once_with(
            "[red]test[/]", style="feedback", markup=None, highlight=None
        )


class DebusineCommandTests(BaseCliTests):
    """Test the :py:class:`DebusineCommand` class."""

    @Command.preserve_registry()
    def build_debusine_command(self, **kwargs: Any) -> DebusineCommand:
        """
        Build a DebusineCommand for tests.

        :param kwargs: key-value pairs to set in the mock argparse namespace
          passed to the DebusineCommand
        """

        class MockCommand(DebusineCommand):
            """Concrete implementation of DebusineCommand used for tests."""

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

        return self.command(MockCommand, **kwargs)

    def test_debug(self) -> None:
        """Cli with --debug."""
        self.build_debusine_command(debug=True)
        self.assertEqual(http.client.HTTPConnection.debuglevel, 1)

        # Test the logger
        msg = "This is a test"
        mocked_sys_stderr = self.patch_sys_stderr_write()
        # http.client uses the print builtin, and debusine.client.cli
        # monkey-patches that, but it doesn't have an official type
        # annotation.
        http.client.print(msg)  # type: ignore[attr-defined]
        mocked_sys_stderr.assert_called_with("DEBUG:requests:" + msg + "\n")

    def assert_client_object_use_specific_server(
        self, server_config: dict[str, str], **kwargs: Any
    ) -> None:
        """Ensure DebusineCommand uses Debusine with the correct endpoint."""
        cmd = self.build_debusine_command(**kwargs)
        debusine = cmd._build_debusine_object()
        self.assertEqual(debusine.base_api_url, server_config['api-url'])
        self.assertEqual(debusine.token, server_config['token'])

    def test_use_default_server(self) -> None:
        """Ensure debusine object uses the default server."""
        self.assert_client_object_use_specific_server(
            self.servers[self.default_server]
        )

    def test_use_explicit_server(self) -> None:
        """Ensure debusine object uses the Kali server when requested."""
        self.assert_client_object_use_specific_server(
            self.servers["kali"], server="kali"
        )

    def test_use_explicit_server_via_env(self) -> None:
        """Ensure debusine object uses the Kali server when requested."""
        with mock.patch.dict("os.environ", {"DEBUSINE_SERVER_NAME": "kali"}):
            self.assert_client_object_use_specific_server(self.servers["kali"])

    def test_use_explicit_server_via_env_and_arg(self) -> None:
        """Ensure debusine object uses the Kali server when requested."""
        with mock.patch.dict("os.environ", {"DEBUSINE_SERVER_NAME": "debian"}):
            self.assert_client_object_use_specific_server(
                self.servers["kali"], server="kali"
            )

    def test_use_scope(self) -> None:
        """Ensure debusine object uses the default or specific scope."""
        cmd = self.build_debusine_command(server="kali")
        debusine = cmd._build_debusine_object()
        self.assertEqual(
            debusine.scope,
            self.servers["kali"]["scope"],
        )

        cmd = self.build_debusine_command(server="kali", scope="altscope")
        debusine = cmd._build_debusine_object()
        self.assertEqual(debusine.scope, "altscope")

    def test_no_server_found_by_fqdn_and_scope(self) -> None:
        """Cli fails if no matching server is found by FQDN/scope."""
        stderr, _ = self.capture_output(
            partial(
                self.build_debusine_command,
                server="nonexistent.example.org/scope",
            ),
            assert_system_exit_code=3,
        )
        self.assertEqual(
            stderr,
            "No Debusine client configuration for "
            "'nonexistent.example.org/scope'; "
            "run 'debusine setup' to configure it\n",
        )

    def test_no_server_found_by_name(self) -> None:
        """Cli fails if no matching server is found by name."""
        stderr, _ = self.capture_output(
            partial(self.build_debusine_command, server="nonexistent"),
            assert_system_exit_code=3,
        )
        self.assertEqual(
            stderr,
            "No Debusine client configuration for 'nonexistent'; "
            "run 'debusine setup' to configure it\n",
        )

    def test_build_debusine_object_logging_warning(self) -> None:
        """Cli with --silent create and pass logger level WARNING."""
        cmd = self.build_debusine_command(silent=True)
        mocked_sys_stderr = self.patch_sys_stderr_write()
        debusine = cmd._build_debusine_object()

        self.assertEqual(debusine._logger.level, logging.WARNING)
        self.assertFalse(debusine._logger.propagate)

        msg = "This is a test"

        # Test the logger
        debusine._logger.warning(msg)
        mocked_sys_stderr.assert_called_with(msg + "\n")

    def test_build_debusine_object_logging_info(self) -> None:
        """Cli without --silent create and pass logger level INFO."""
        cmd = self.build_debusine_command()
        debusine = cmd._build_debusine_object()
        self.assertEqual(debusine._logger.level, logging.INFO)

    def test_api_call_or_fail_not_found(self) -> None:
        """_api_call_or_fail print error message for not found and exit."""
        cmd = self.build_debusine_command(create_config=False)

        def raiseNotFound() -> NoReturn:
            with cmd._api_call_or_fail():
                raise NotFoundError("Not found")

        stderr, _ = self.capture_output(
            raiseNotFound, assert_system_exit_code=3
        )

        self.assertEqual(stderr, "Not found\n")

    def test_api_call_or_fail_unexpected_error(self) -> None:
        """_api_call_or_fail print error message for not found and exit."""
        cmd = self.build_debusine_command()

        def raiseUnexpectedResponseError() -> NoReturn:
            with cmd._api_call_or_fail():
                raise exceptions.UnexpectedResponseError("Not available")

        stderr, _ = self.capture_output(
            raiseUnexpectedResponseError, assert_system_exit_code=3
        )

        self.assertEqual(stderr, "Not available\n")

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

        ClientForbiddenError was raised.
        """
        cmd = self.build_debusine_command()

        def raiseClientForbiddenError() -> NoReturn:
            with cmd._api_call_or_fail():
                raise exceptions.ClientForbiddenError("Invalid token")

        stderr, _ = self.capture_output(
            raiseClientForbiddenError, assert_system_exit_code=3
        )

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

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

        ClientConnectionError was raised.
        """
        cmd = self.build_debusine_command()

        def raiseClientConnectionError() -> None:
            with cmd._api_call_or_fail():
                raise exceptions.ClientConnectionError("Connection refused")

        stderr, _ = self.capture_output(
            raiseClientConnectionError, assert_system_exit_code=3
        )

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

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

        No exception was raised.
        """
        cmd = self.build_debusine_command()
        with cmd._api_call_or_fail():
            data = "some data from the server"
        self.assertEqual(data, 'some data from the server')

    def test_print_server_error(self) -> None:
        error = DebusineError(
            title="Cannot set object",
            detail="Duplicated",
            validation_errors=["Duplicated name"],
            status_code=200,
        )

        stderr, stdout = self.capture_output(
            DebusineCommand.print_server_error, [error]
        )
        stderr_yaml = yaml.safe_load(stderr)
        self.assertEqual(
            stderr_yaml, {"result": "failure", "error": error.asdict()}
        )
        self.assertEqual(stdout, "")


class WorkspaceCommandTests(BaseCliTests):
    """Test the :py:class:`WorkspaceCommand` class."""

    @Command.preserve_registry()
    def build_workspace_command(self, **kwargs: Any) -> WorkspaceCommand:
        """
        Build a WorkspaceCommand for tests.

        :param kwargs: key-value pairs to set in the mock argparse namespace
          passed to the WorkspaceCommand
        """

        class MockWorkspaceCommand(WorkspaceCommand):
            """Concrete implementation of WorkspaceCommand used for tests."""

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

        return self.command(MockWorkspaceCommand, **kwargs)

    def test_workspace_default(self) -> None:
        config = self.create_temporary_file()
        config.write_text(
            "[server:unknown]\napi-url=123\nscope=debusine\ntoken=123"
        )
        cmd = self.build_workspace_command(
            workspace=None, config_file=config.as_posix(), server="unknown"
        )
        self.assertEqual(cmd.workspace, "System")

    def test_workspace_default_known_server(self) -> None:
        config = self.create_temporary_file()
        config.write_text(
            "[server:localhost]\napi-url=123\nscope=debusine\ntoken=123"
        )
        cmd = self.build_workspace_command(
            workspace=None, config_file=config.as_posix(), server="localhost"
        )
        self.assertEqual(cmd.workspace, "Playground")

    def test_workspace_default_configure(self) -> None:
        config = self.create_temporary_file()
        config.write_text(
            "[server:configured]\ndefault-workspace=test\napi-url=123\n"
            "scope=debusine\ntoken=123"
        )
        cmd = self.build_workspace_command(
            workspace=None, config_file=config.as_posix(), server="configured"
        )
        self.assertEqual(cmd.workspace, "test")

    def test_workspace_default_specified(self) -> None:
        cmd = self.build_workspace_command(workspace="explicit")
        self.assertEqual(cmd.workspace, "explicit")


class ModelCommandTests(BaseCliTests):
    """Test the :py:class:`ModelCommand` class."""

    @Command.preserve_registry()
    def build_command(self, **kwargs: Any) -> ModelCommand[StrictBaseModel]:
        """
        Build a ModelCommand for tests.

        :param kwargs: key-value pairs to set in the mock argparse namespace
          passed to the ModelCommand
        """

        class MockModelCommand(ModelCommand[StrictBaseModel]):
            """Concrete implementation of ModelCommand used for tests."""

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

            @override
            def _list_rich(
                self,
                instances: Iterable[StrictBaseModel],  # noqa: ARG002, U100
            ) -> None:
                print("LIST")

            @override
            def _show_rich(
                self,
                instance: StrictBaseModel,  # noqa: ARG002, U100
            ) -> None:
                print("SHOW")

        return self.command(MockModelCommand, **kwargs)

    def test_list_yaml(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command()
        stderr, stdout = self.capture_output(
            partial(command._list_yaml, [sample])
        )
        self.assertEqual(
            yaml.safe_load(stdout), [sample.model_dump(mode="json")]
        )
        self.assertEqual(stderr, "")
        self.assertEqual(stdout.splitlines()[0], "- {}")

    def test_list_yaml_yaml_in_input(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command()
        command.yaml_in_input = True
        stderr, stdout = self.capture_output(
            partial(command._list_yaml, [sample])
        )
        self.assertEqual(
            yaml.safe_load(stdout), [sample.model_dump(mode="json")]
        )
        self.assertEqual(stderr, "")
        self.assertEqual(stdout.splitlines()[0], "---")

    def test_list_rich(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command()
        stderr, stdout = self.capture_output(
            partial(command._list_rich, [sample])
        )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "LIST\n")

    def test_list_select_yaml(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command(yaml=True)
        with (
            mock.patch.object(command, "_list_rich") as list_rich,
            mock.patch.object(command, "_list_yaml") as list_yaml,
        ):
            command.list([sample])
        list_yaml.assert_called_once()
        list_rich.assert_not_called()

    def test_list_select_rich(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command(yaml=False)
        with (
            mock.patch.object(command, "_list_rich") as list_rich,
            mock.patch.object(command, "_list_yaml") as list_yaml,
        ):
            command.list([sample])
        list_yaml.assert_not_called()
        list_rich.assert_called_once()

    def test_show_yaml(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command()
        stderr, stdout = self.capture_output(
            partial(command._show_yaml, sample)
        )
        self.assertEqual(yaml.safe_load(stdout), sample.model_dump(mode="json"))
        self.assertNotEqual(stdout.splitlines()[0], "---")
        self.assertEqual(stderr, "")

    def test_show_yaml_yaml_in_input(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command()
        command.yaml_in_input = True
        stderr, stdout = self.capture_output(
            partial(command._show_yaml, sample)
        )
        self.assertEqual(yaml.safe_load(stdout), sample.model_dump(mode="json"))
        self.assertEqual(stdout.splitlines()[0], "---")
        self.assertEqual(stderr, "")

    def test_show_rich(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command()
        stderr, stdout = self.capture_output(
            partial(command._show_rich, sample)
        )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "SHOW\n")

    def test_show_select_yaml(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command(yaml=True)
        with (
            mock.patch.object(command, "_show_rich") as show_rich,
            mock.patch.object(command, "_show_yaml") as show_yaml,
        ):
            command.show(sample)
        show_yaml.assert_called_once()
        show_rich.assert_not_called()

    def test_show_select_rich(self) -> None:
        sample = StrictBaseModel()
        command = self.build_command(yaml=False)
        with (
            mock.patch.object(command, "_show_rich") as show_rich,
            mock.patch.object(command, "_show_yaml") as show_yaml,
        ):
            command.show(sample)
        show_yaml.assert_not_called()
        show_rich.assert_called_once()


class InputDataCommandTests(BaseCliTests):
    """Test the :py:class:`InputDataCommand` class."""

    @Command.preserve_registry()
    def build_command(self, **kwargs: Any) -> InputDataCommand:
        """
        Build a ModelCommand for tests.

        :param kwargs: key-value pairs to set in the mock argparse namespace
          passed to the ModelCommand
        """

        class MockCommand(InputDataCommand):
            """Concrete implementation of InputDataCommand used for tests."""

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

        return self.command(MockCommand, **kwargs)

    def test_read_file(self) -> None:
        path = self.create_temporary_file()
        path.write_text("test: 1")
        command = self.build_command()
        self.assertEqual(command.read_input_data(path.as_posix()), {"test": 1})

    def test_read_file_empty(self) -> None:
        path = self.create_temporary_file()
        command = self.build_command()
        stderr, _ = self.capture_output(
            partial(command.read_input_data, path.as_posix()),
            assert_system_exit_code=3,
        )
        self.assertEqual(
            stderr, "Error: data must be a dictionary. It is empty\n"
        )

    def test_show_read_stdin_message(self) -> None:
        for stdin_isatty, stdout_isatty, expected in (
            (False, False, False),
            (True, False, False),
            (False, True, False),
            (True, True, True),
        ):
            with self.subTest(
                stdin_isatty=stdout_isatty, stdout_isatty=stdout_isatty
            ):
                command = self.build_command()
                with (
                    mock.patch("sys.stdout.isatty", return_value=stdout_isatty),
                    self.patch_sys_stdin_read("test: 1") as mock_stdin,
                    mock.patch("rich.console.Console.print") as console_print,
                ):
                    mock_stdin.isatty.return_value = stdin_isatty
                    self.assertEqual(command.read_input_data("-"), {"test": 1})
                    if expected:
                        console_print.assert_called()
                    else:
                        console_print.assert_not_called()

    def test_read_stdin_pipe_empty(self) -> None:
        command = self.build_command()
        with (
            mock.patch("sys.stdout.isatty", return_value=False),
            self.patch_sys_stdin_read("") as mock_stdin,
            mock.patch("rich.console.Console.print") as console_print,
        ):
            mock_stdin.isatty.return_value = False
            self.assertEqual(command.read_input_data("-"), {})
        console_print.assert_not_called()

    def test_read_stdin_tty_empty(self) -> None:
        command = self.build_command()
        with (
            mock.patch("sys.stdout.isatty", return_value=True),
            self.patch_sys_stdin_read(""),
            mock.patch("rich.console.Console.print") as console_print,
        ):
            self.assertEqual(command.read_input_data("-"), {})
        console_print.assert_called()

    def test_deprecated_commands_skipped_during_completion(self) -> None:
        """Deprecated commands should not be registered during argcomplete."""
        # Simulate argcomplete shell-completion mode
        with patch.dict("os.environ", {"_ARGCOMPLETE": "1"}):
            parser = argparse.ArgumentParser()
            subparsers = parser.add_subparsers(dest="cmd")

            # Register commands in completion mode
            Command.add_parsers(subparsers)

            registered = set(subparsers._name_parser_map.keys())

            # Deprecated commands must not be offered as completion candidates
            self.assertNotIn("list-work-requests", registered)

            # Non-deprecated commands should still be registered
            self.assertGreater(len(registered), 0)
