"""Decorators for ``cmd2`` commands."""

import argparse
import functools
from collections.abc import (
    Callable,
    Sequence,
)
from typing import (
    TYPE_CHECKING,
    Any,
    TypeAlias,
    TypeVar,
    overload,
)

from . import constants
from .argparse_utils import (
    ArgparseCommandSpec,
    ClassParamParserFactory,
    Cmd2ArgumentParser,
    NoParamParserFactory,
    ParserSource,
    SubcommandSpec,
)
from .command_set import CommandSet
from .exceptions import Cmd2ArgparseError
from .parsing import Statement
from .types import (
    CmdOrSetT,
    UnboundCommandFunc,
)

if TYPE_CHECKING:  # pragma: no cover
    from .cmd2 import Cmd

F = TypeVar("F", bound=Callable[..., Any])


def with_category(
    category: str,
) -> Callable[[F], F]:
    """Decorate a ``do_*`` command function to apply a category.

    Permissive type hints allow this decorator to be stacked in any order, even
    when other decorators in the chain transform the signature or return type of
    the command function.

    :param category: the name of the category in which this command should
                     be grouped when displaying the list of commands.
    :return: a decorator that assigns the specified category to the command function

    Example:
    ```py
    class MyApp(cmd2.Cmd):
        @cmd2.with_category("Text Functions")
        def do_echo(self, args: cmd2.Statement) -> None:
            self.poutput(args)
    ```

    For an alternative approach to categorizing commands using a function, see
    [cmd2.utils.categorize][]

    """

    def cat_decorator(func: F) -> F:
        from .utils import categorize

        categorize(func, category)
        return func

    return cat_decorator


##########################
# The _parse_positionals and _arg_swap functions allow for additional positional args to be preserved
# in cmd2 command functions/callables. As long as the 2-ple of arguments we expect to be there can be
# found we can swap out the statement with each decorator's specific parameters
##########################
def _parse_positionals(args: tuple[Any, ...]) -> tuple["Cmd", Statement | str]:
    """Inspect the positional arguments until the cmd2.Cmd argument is found.

    Assumes that we will find cmd2.Cmd followed by the command statement object or string.

    :arg args: The positional arguments to inspect
    :return: The cmd2.Cmd reference and the command line statement.
    """
    for pos, arg in enumerate(args):
        from .cmd2 import Cmd

        if isinstance(arg, (Cmd, CommandSet)) and len(args) > pos + 1:
            if isinstance(arg, CommandSet):
                arg = arg._cmd  # noqa: PLW2901
            next_arg = args[pos + 1]
            if isinstance(next_arg, (Statement, str)):
                return arg, args[pos + 1]

    # This shouldn't happen unless we forget to pass statement in `Cmd.onecmd` or
    # somehow call the unbound class method.
    raise TypeError("Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found")


def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[Any]:
    """Swap the Statement parameter with one or more decorator-specific parameters.

    :param args: The original positional arguments
    :param search_arg: The argument to search for (usually the Statement)
    :param replace_arg: The arguments to substitute in
    :return: The new set of arguments to pass to the command function
    """
    index = args.index(search_arg)
    args_list = list(args)
    args_list[index : index + 1] = replace_arg
    return args_list


# The standard cmd2 command function signature (e.g. do_command(self, statement))
RawCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [Statement | str]]


# Function signature for a command function that accepts a pre-processed argument list from user input
ArgListCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [list[str]]]


# Overload for: @with_argument_list
@overload
def with_argument_list(
    cmd_func: ArgListCommandFunc[CmdOrSetT],
    *,
    preserve_quotes: bool = False,
) -> RawCommandFunc[CmdOrSetT]: ...


# Overload for: @with_argument_list(preserve_quotes=True)
@overload
def with_argument_list(
    cmd_func: None = None,
    *,
    preserve_quotes: bool = False,
) -> Callable[[ArgListCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: ...


def with_argument_list(
    cmd_func: ArgListCommandFunc[CmdOrSetT] | None = None,
    *,
    preserve_quotes: bool = False,
) -> RawCommandFunc[CmdOrSetT] | Callable[[ArgListCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]:
    """Decorate a ``do_*`` command function to receive a list of parsed arguments.

    This decorator can be used either directly (``@with_argument_list``) or as a
    factory with arguments (``@with_argument_list(preserve_quotes=True)``).

    :param cmd_func: The command function being decorated.
    :param preserve_quotes: If ``True``, argument quotes will not be stripped from the input.
    :return: A command function that accepts a list of strings instead of a raw string.

    Example:
    ```py
    class MyApp(cmd2.Cmd):
        # Basic usage: receives a list of words with quotes stripped
        @cmd2.with_argument_list
        def do_echo(self, arglist: list[str]) -> None:
            self.poutput(" ".join(arglist))

        # Factory usage: preserves quotes in the argument list
        @cmd2.with_argument_list(preserve_quotes=True)
        def do_print_raw(self, arglist: list[str]) -> None:
            self.poutput(" ".join(arglist))
    ```

    """

    def arg_decorator(func: ArgListCommandFunc[CmdOrSetT]) -> RawCommandFunc[CmdOrSetT]:
        """Decorate function that ingests an Argument List function and returns a raw command function.

        The returned function will process the raw input into an argument list to be passed to the wrapped function.

        :param func: The defined argument list command function
        :return: Function that takes raw input and converts to an argument list to pass to the wrapped function.
        """

        @functools.wraps(func)
        def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
            """Command function wrapper which translates command line into an argument list and calls actual command function.

            :param args: All positional arguments to this function.  We're expecting there to be:
                            cmd_app, statement: Union[Statement, str]
                            contiguously somewhere in the list
            :param kwargs: any keyword arguments being passed to command function
            :return: return value of command function
            """
            cmd_app, statement = _parse_positionals(args)
            _, command_arg_list = cmd_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes)
            func_arg_list = _arg_swap(args, statement, command_arg_list)
            return func(*func_arg_list, **kwargs)

        command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
        cmd_wrapper.__doc__ = func.__doc__
        return cmd_wrapper

    if callable(cmd_func):
        return arg_decorator(cmd_func)
    return arg_decorator


# Function signatures for command functions that use a Cmd2ArgumentParser to process user input
ArgparseCommandFunc: TypeAlias = (
    # (self, args: argparse.Namespace)
    UnboundCommandFunc[CmdOrSetT, [argparse.Namespace]]
    # (self, args: argparse.Namespace, unknown_args: list[str])
    | UnboundCommandFunc[CmdOrSetT, [argparse.Namespace, list[str]]]
)


# Overload for: existing parser instance
@overload
def with_argparser(
    parser_source: Cmd2ArgumentParser,
    *,
    ns_provider: Callable[..., argparse.Namespace] | None = None,
    preserve_quotes: bool = False,
    with_unknown_args: bool = False,
) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: ...


# Overload for: factory with no arguments (including staticmethod)
@overload
def with_argparser(
    parser_source: NoParamParserFactory,
    *,
    ns_provider: Callable[..., argparse.Namespace] | None = None,
    preserve_quotes: bool = False,
    with_unknown_args: bool = False,
) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: ...


# Overload for: factory with a class argument (including classmethod)
@overload
def with_argparser(
    parser_source: ClassParamParserFactory[CmdOrSetT],
    *,
    ns_provider: Callable[..., argparse.Namespace] | None = None,
    preserve_quotes: bool = False,
    with_unknown_args: bool = False,
) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: ...


def with_argparser(
    parser_source: ParserSource[CmdOrSetT],
    *,
    ns_provider: Callable[..., argparse.Namespace] | None = None,
    preserve_quotes: bool = False,
    with_unknown_args: bool = False,
) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]:
    """Decorate a ``do_*`` command function to populate its ``args`` argument with a Cmd2ArgumentParser.

    :param parser_source: an existing Cmd2ArgumentParser instance or a factory
                          (callable, staticmethod, or classmethod) that returns one.
    :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an
                        argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that
                        affects parsing.
    :param preserve_quotes: if ``True``, then arguments passed to argparse maintain their quotes
    :param with_unknown_args: if true, then capture unknown args
    :return: function that gets passed argparse-parsed args in a ``Namespace``
             A ``cmd2_statement`` attribute is included in the ``Namespace`` to provide access to the
             [cmd2.Statement][] object that was created when parsing the command line. This can be useful
             if the command function needs to know the command line.

    Example:
    ```py
    parser = cmd2.Cmd2ArgumentParser()
    parser.add_argument("-p", "--piglatin", action="store_true", help="atinLay")
    parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
    parser.add_argument("-r", "--repeat", type=int, help="output [n] times")
    parser.add_argument("words", nargs="+", help="words to print")


    class MyApp(cmd2.Cmd):
        @cmd2.with_argparser(parser, preserve_quotes=True)
        def do_argprint(self, args: argparse.Namespace) -> None:
            "Print the options and argument list this options command was called with."
            self.poutput(f"args: {args!r}")
    ```

    Example with unknown args:

    ```py
    parser = cmd2.Cmd2ArgumentParser()
    parser.add_argument("-p", "--piglatin", action="store_true", help="atinLay")
    parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
    parser.add_argument("-r", "--repeat", type=int, help="output [n] times")


    class MyApp(cmd2.Cmd):
        @cmd2.with_argparser(parser, with_unknown_args=True)
        def do_argprint(self, args: argparse.Namespace, unknown_args: list[str]):
            "Print the options and argument list this options command was called with."
            self.poutput(f"args: {args!r}")
            self.poutput(f"unknown_args: {unknown_args}")
    ```

    """

    def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> RawCommandFunc[CmdOrSetT]:
        """Decorate function that ingests an Argparse Command Function and returns a raw command function.

        The returned function will process the raw input into an argparse Namespace to be passed to the wrapped function.

        :param func: The defined argparse command function
        :return: Function that takes raw input and converts to an argparse Namespace to passed to the wrapped function.
        """

        @functools.wraps(func)  # type: ignore[arg-type, ty:invalid-argument-type]
        def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
            """Command function wrapper which translates command line into argparse Namespace and call actual command function.

            :param args: All positional arguments to this function.  We're expecting there to be:
                            cmd_app, statement: Union[Statement, str]
                            contiguously somewhere in the list
            :param kwargs: any keyword arguments being passed to command function
            :return: return value of command function
            :raises Cmd2ArgparseError: if argparse has error parsing command line
            """
            cmd_app, statement_arg = _parse_positionals(args)
            statement, command_arg_list = cmd_app.statement_parser.get_command_arg_list(
                command_name, statement_arg, preserve_quotes
            )

            # Pass cmd_wrapper instead of func, since it contains the parser info.
            arg_parser = cmd_app.command_parsers.get(cmd_wrapper)
            if arg_parser is None:
                # This shouldn't be possible to reach
                raise ValueError(f"No argument parser found for {command_name}")  # pragma: no cover

            if ns_provider is None:
                initial_namespace = None
            else:
                # The namespace provider may or may not be defined in the same class as the command. Since provider
                # functions are registered with the command argparser before anything is instantiated, we
                # need to find an instance at runtime that matches the types during declaration
                provider_self = cmd_app._resolve_func_self(ns_provider, args[0])
                initial_namespace = ns_provider(provider_self if provider_self is not None else cmd_app)

            try:
                parsing_results: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]]
                with arg_parser.output_to(cmd_app.stdout):
                    if with_unknown_args:
                        parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace)  # type: ignore[assignment, ty:invalid-assignment]
                    else:
                        parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),)  # type: ignore[assignment, ty:invalid-assignment]
            except SystemExit as exc:
                raise Cmd2ArgparseError from exc

            # Add cmd2-specific attributes to the Namespace
            parsed_namespace = parsing_results[0]

            # Include the Statement object created from the command line
            setattr(parsed_namespace, constants.NS_ATTR_STATEMENT, statement)

            # Ensure subcommand function attribute is always present.
            if not hasattr(parsed_namespace, constants.NS_ATTR_SUBCOMMAND_FUNC):
                setattr(parsed_namespace, constants.NS_ATTR_SUBCOMMAND_FUNC, None)

            func_arg_list = _arg_swap(args, statement_arg, *parsing_results)
            return func(*func_arg_list, **kwargs)

        command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]

        spec = ArgparseCommandSpec(
            parser_source=parser_source,
            preserve_quotes=preserve_quotes,
        )
        setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, spec)

        return cmd_wrapper

    return arg_decorator


# Overload for: existing parser instance
@overload
def as_subcommand_to(
    command: str,
    subcommand: str,
    parser_source: Cmd2ArgumentParser,
    *,
    help: str | None = None,
    aliases: Sequence[str] = (),
    deprecated: bool = False,
) -> Callable[[F], F]: ...


# Overload for: factory with no arguments (including staticmethod)
@overload
def as_subcommand_to(
    command: str,
    subcommand: str,
    parser_source: NoParamParserFactory,
    *,
    help: str | None = None,
    aliases: Sequence[str] = (),
    deprecated: bool = False,
) -> Callable[[F], F]: ...


# Overload for: factory with a class argument (including classmethod)
@overload
def as_subcommand_to(
    command: str,
    subcommand: str,
    parser_source: ClassParamParserFactory[CmdOrSetT],
    *,
    help: str | None = None,
    aliases: Sequence[str] = (),
    deprecated: bool = False,
) -> Callable[[F], F]: ...


def as_subcommand_to(
    command: str,
    subcommand: str,
    parser_source: ParserSource[CmdOrSetT],
    *,
    help: str | None = None,  # noqa: A002
    aliases: Sequence[str] = (),
    deprecated: bool = False,
) -> Callable[[F], F]:
    """Tag a function as a subcommand to an existing argparse decorated command.

    Permissive type hints allow this decorator to be stacked in any order, even
    when other decorators in the chain transform the signature or return type of
    the subcommand function.

    While this decorator has permissive type hints, the subcommand function's signature
    must match the root command's signature. For example, if the root command uses
    `with_unknown_args=True`, then the subcommand function must also accept the
    unknown arguments list.

    :param command: Command Name. Space-delimited subcommands may optionally be specified
    :param subcommand: Subcommand name
    :param parser_source: an existing Cmd2ArgumentParser instance or a factory
                          (callable, staticmethod, or classmethod) that returns one.
    :param help: optional help message for this subcommand which displays in the list of subcommands
                 of the command we are adding to.
    :param aliases: optional alternative names for this subcommand.
    :param deprecated: whether this subcommand is deprecated (requires Python 3.13+).
    :return: a decorator which configures the target function to be a subcommand handler

    Example:
    ```py
    base_parser = cmd2.Cmd2ArgumentParser()
    base_parser.add_subparsers(title="subcommands", metavar="SUBCOMMAND", required=True)
    sub_parser = cmd2.Cmd2ArgumentParser()


    class MyApp(cmd2.Cmd):
        @cmd2.with_argparser(base_parser)
        def do_base(self, args: argparse.Namespace) -> None:
            args.cmd2_subcommand_func(args)

        @cmd2.as_subcommand_to("base", "sub", sub_parser, help="the subcommand")
        def sub_func(self, args: argparse.Namespace) -> None:
            self.poutput("Subcommand executed")
    ```

    """

    def arg_decorator(func: F) -> F:
        spec = SubcommandSpec(
            name=subcommand,
            command=command,
            help=help,
            aliases=tuple(aliases),
            deprecated=deprecated,
            parser_source=parser_source,
        )
        setattr(func, constants.SUBCOMMAND_ATTR_SPEC, spec)
        return func

    return arg_decorator
