"""Module defines the ArgparseCompleter class which provides argparse-based completion to cmd2 apps.

See the header of argparse_utils.py for instructions on how to use these features.
"""

import argparse
import dataclasses
import inspect
from collections import deque
from collections.abc import (
    Mapping,
    MutableSequence,
    Sequence,
)
from typing import (
    IO,
    TYPE_CHECKING,
    Any,
    cast,
)

from rich.table import Column
from rich.text import Text

from .argparse_utils import (
    Cmd2ArgumentParser,
    build_range_error,
)
from .command_set import CommandSet
from .completion import (
    CompletionItem,
    Completions,
)
from .constants import INFINITY
from .exceptions import CompletionError
from .rich_utils import Cmd2SimpleTable
from .types import (
    CmdOrSetT,
    UnboundChoicesProvider,
    UnboundCompleter,
)

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


# Name of the choice/completer function argument that, if present, will be passed a dictionary of
# command line tokens up through the token being completed mapped to their argparse destination name.
ARG_TOKENS = "arg_tokens"


def _build_hint(parser: Cmd2ArgumentParser, arg_action: argparse.Action) -> str:
    """Build completion hint for a given argument."""
    # Check if hinting is disabled for this argument
    suppress_hint = arg_action.get_suppress_tab_hint()  # type: ignore[attr-defined]
    if suppress_hint or arg_action.help == argparse.SUPPRESS:
        return ""

    # Use the parser's help formatter to display just this action's help text
    formatter = parser._get_formatter()
    formatter.start_section("Hint")
    formatter.add_argument(arg_action)
    formatter.end_section()
    return formatter.format_help()


def _single_prefix_char(token: str, parser: Cmd2ArgumentParser) -> bool:
    """Is a token just a single flag prefix character."""
    return len(token) == 1 and token[0] in parser.prefix_chars


def _looks_like_flag(token: str, parser: Cmd2ArgumentParser) -> bool:
    """Determine if a token looks like a flag.

    Unless an argument has nargs set to argparse.REMAINDER, then anything that looks like a flag
    can't be consumed as a value for it.

    Based on argparse._parse_optional().
    """
    # Flags have to be at least characters
    if len(token) < 2:
        return False

    # Flags have to start with a prefix character
    if token[0] not in parser.prefix_chars:
        return False

    # If it looks like a negative number, it is not a flag unless there are negative-number-like flags
    if parser._negative_number_matcher.match(token) and not parser._has_negative_number_optionals:
        return False

    # Flags can't have a space
    return " " not in token


class _ArgumentState:
    """Keeps state of an argument being parsed."""

    def __init__(self, arg_action: argparse.Action) -> None:
        self.action = arg_action
        self.min: int
        self.max: float | int
        self.count = 0
        self.is_remainder = self.action.nargs == argparse.REMAINDER

        # Check if nargs is a range
        nargs_range: tuple[int, int | float] | None = self.action.get_nargs_range()  # type: ignore[attr-defined]
        if nargs_range is not None:
            self.min = nargs_range[0]
            self.max = nargs_range[1]

        # Otherwise check against argparse types
        elif self.action.nargs is None:
            self.min = 1
            self.max = 1
        elif self.action.nargs == argparse.OPTIONAL:
            self.min = 0
            self.max = 1
        elif self.action.nargs in (argparse.ZERO_OR_MORE, argparse.REMAINDER):
            self.min = 0
            self.max = INFINITY
        elif self.action.nargs == argparse.ONE_OR_MORE:
            self.min = 1
            self.max = INFINITY
        else:
            self.min = cast(int, self.action.nargs)
            self.max = cast(int, self.action.nargs)


class _UnfinishedFlagError(CompletionError):
    def __init__(self, flag_arg_state: _ArgumentState) -> None:
        """CompletionError which occurs when the user has not finished the current flag.

        :param flag_arg_state: information about the unfinished flag action.
        """
        arg = f"{argparse._get_action_name(flag_arg_state.action)}"
        err = f"{build_range_error(flag_arg_state.min, flag_arg_state.max)}"
        error = f"Error: argument {arg}: {err} ({flag_arg_state.count} entered)"
        super().__init__(error)


class _NoResultsError(CompletionError):
    def __init__(self, parser: Cmd2ArgumentParser, arg_action: argparse.Action) -> None:
        """CompletionError which occurs when there are no results.

        If hinting is allowed on this argument, then its hint text will display.

        :param parser: Cmd2ArgumentParser instance which owns the action being completed
        :param arg_action: action being completed.
        """
        # Set apply_style to False because we don't want hints to look like errors
        super().__init__(_build_hint(parser, arg_action), apply_style=False)


class ArgparseCompleter:
    """Automatic command line completion based on argparse parameters."""

    def __init__(
        self,
        parser: Cmd2ArgumentParser,
        cmd_app: "Cmd",
        *,
        parent_tokens: Mapping[str, MutableSequence[str]] | None = None,
    ) -> None:
        """Create an ArgparseCompleter.

        :param parser: Cmd2ArgumentParser instance
        :param cmd_app: reference to the cmd2.Cmd instance that owns this ArgparseCompleter
        :param parent_tokens: optional Mapping of parent parsers' arg names to their tokens
                              This is only used by ArgparseCompleter when recursing on subcommand parsers
                              Defaults to None
        """
        self._parser = parser
        self._cmd_app = cmd_app

        if parent_tokens is None:
            parent_tokens = {}
        self._parent_tokens = parent_tokens

        # All flags in this command
        self._flags: list[str] = []

        # Maps flags to the argparse action object
        self._flag_to_action: dict[str, argparse.Action] = {}

        # Actions for positional arguments (by position index)
        self._positional_actions: list[argparse.Action] = []

        # This will be set if self._parser has subcommands
        self._subcommand_action: argparse._SubParsersAction[Cmd2ArgumentParser] | None = None

        # Start digging through the argparse structures.
        # _actions is the top level container of parameter definitions
        for action in self._parser._actions:
            # if the parameter is flag based, it will have option_strings
            if action.option_strings:
                # record each option flag
                for option in action.option_strings:
                    self._flags.append(option)
                    self._flag_to_action[option] = action

            # Otherwise this is a positional parameter
            else:
                self._positional_actions.append(action)
                # Check if this action defines subcommands
                if isinstance(action, argparse._SubParsersAction):
                    self._subcommand_action = action

    def complete(
        self,
        text: str,
        line: str,
        begidx: int,
        endidx: int,
        tokens: Sequence[str],
        *,
        cmd_set: CommandSet[Any] | None = None,
    ) -> Completions:
        """Complete text using argparse metadata.

        :param text: the string prefix we are attempting to match (all matches must begin with it)
        :param line: the current input line with leading whitespace removed
        :param begidx: the beginning index of the prefix text
        :param endidx: the ending index of the prefix text
        :param tokens: Sequence of argument tokens being passed to the parser
        :param cmd_set: if completing a command, the CommandSet the command's function belongs to, if applicable.
                        Defaults to None.
        :return: a Completions object
        :raises CompletionError: for various types of completion errors
        """
        if not tokens:
            return Completions()

        # Positionals args that are left to parse
        remaining_positionals = deque(self._positional_actions)

        # This gets set to True when flags will no longer be processed as argparse flags
        # That can happen when -- is used or an argument with nargs=argparse.REMAINDER is used
        skip_remaining_flags = False

        # _ArgumentState of the current positional
        pos_arg_state: _ArgumentState | None = None

        # _ArgumentState of the current flag
        flag_arg_state: _ArgumentState | None = None

        # Non-reusable flags that we've parsed
        used_flags: set[str] = set()

        # Keeps track of arguments we've seen and any tokens they consumed
        consumed_arg_values: dict[str, list[str]] = {}

        # Completed mutually exclusive groups
        completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action] = {}

        def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None:
            """Consume token as an argument."""
            arg_state.count += 1
            consumed_arg_values.setdefault(arg_state.action.dest, []).append(arg_token)

        #############################################################################################
        # Parse all but the last token
        #############################################################################################
        for token_index, token in enumerate(tokens[:-1]):
            # If we're in a positional REMAINDER arg, force all future tokens to go to that
            if pos_arg_state is not None and pos_arg_state.is_remainder:
                consume_argument(pos_arg_state, token)
                continue

            # If we're in a flag REMAINDER arg, force all future tokens to go to that until a double dash is hit
            if flag_arg_state is not None and flag_arg_state.is_remainder:
                if token == "--":  # noqa: S105
                    flag_arg_state = None
                else:
                    consume_argument(flag_arg_state, token)
                continue

            # Handle '--' which tells argparse all remaining arguments are non-flags
            if token == "--" and not skip_remaining_flags:  # noqa: S105
                # Check if there is an unfinished flag
                if (
                    flag_arg_state is not None
                    and isinstance(flag_arg_state.min, int)
                    and flag_arg_state.count < flag_arg_state.min
                ):
                    raise _UnfinishedFlagError(flag_arg_state)

                # Otherwise end the current flag
                flag_arg_state = None
                skip_remaining_flags = True
                continue

            # Check if token is a flag
            if _looks_like_flag(token, self._parser) and not skip_remaining_flags:
                # Check if there is an unfinished flag
                if (
                    flag_arg_state is not None
                    and isinstance(flag_arg_state.min, int)
                    and flag_arg_state.count < flag_arg_state.min
                ):
                    raise _UnfinishedFlagError(flag_arg_state)

                # Reset flag arg state but not positional tracking because flags can be
                # interspersed anywhere between positionals
                flag_arg_state = None
                action = None

                # Does the token match a known flag?
                if token in self._flag_to_action:
                    action = self._flag_to_action[token]
                elif self._parser.allow_abbrev:
                    candidates_flags = [flag for flag in self._flag_to_action if flag.startswith(token)]
                    if len(candidates_flags) == 1:
                        action = self._flag_to_action[candidates_flags[0]]

                if action is not None:
                    self._update_mutex_groups(action, completed_mutex_groups, used_flags, remaining_positionals)

                    # Check if the action type allows the same flag to be provided multiple times.
                    # Reusable actions (append, count, extend) preserve their history so the
                    # completion logic knows which values have already been 'consumed'.
                    if not isinstance(
                        action,
                        (
                            argparse._AppendAction,
                            argparse._AppendConstAction,
                            argparse._CountAction,
                            argparse._ExtendAction,
                        ),
                    ):
                        # For standard 'overwrite' actions (e.g., --store), providing the flag
                        # again resets its state. We mark the flags as 'used' to potentially
                        # filter them from future completion results and clear any previously
                        # recorded values for this destination.
                        used_flags.update(action.option_strings)
                        consumed_arg_values[action.dest] = []

                    new_arg_state = _ArgumentState(action)

                    # Keep track of this flag if it can receive arguments
                    if new_arg_state.max > 0:
                        flag_arg_state = new_arg_state
                        skip_remaining_flags = flag_arg_state.is_remainder

            # Check if token is a flag's argument
            elif flag_arg_state is not None:
                consume_argument(flag_arg_state, token)

                # Check if we have finished with this flag
                if flag_arg_state.count >= flag_arg_state.max:
                    flag_arg_state = None

            # Otherwise treat token as a positional argument
            else:
                # If we aren't current tracking a positional, then get the next positional arg to handle this token
                if pos_arg_state is None and remaining_positionals:
                    action = remaining_positionals.popleft()

                    # Are we at a subcommand? If so, forward to the matching completer
                    if self._subcommand_action is not None and action == self._subcommand_action:
                        if token in self._subcommand_action.choices:
                            parent_tokens = {**self._parent_tokens, **consumed_arg_values}

                            # Include the subcommand name if its destination was set
                            if action.dest != argparse.SUPPRESS:
                                parent_tokens[action.dest] = [token]

                            parser = self._subcommand_action.choices[token]
                            completer = parser.completer_class(parser, self._cmd_app, parent_tokens=parent_tokens)
                            return completer.complete(text, line, begidx, endidx, tokens[token_index + 1 :], cmd_set=cmd_set)

                        # Invalid subcommand entered, so no way to complete remaining tokens
                        return Completions()

                    # Otherwise keep track of the argument
                    pos_arg_state = _ArgumentState(action)

                # Check if we have a positional to consume this token
                if pos_arg_state is not None:
                    self._update_mutex_groups(pos_arg_state.action, completed_mutex_groups, used_flags, remaining_positionals)
                    consume_argument(pos_arg_state, token)

                    # No more flags are allowed if this is a REMAINDER argument
                    if pos_arg_state.is_remainder:
                        skip_remaining_flags = True

                    # Check if we have finished with this positional
                    elif pos_arg_state.count >= pos_arg_state.max:
                        pos_arg_state = None

                        # Check if the next positional has nargs set to argparse.REMAINDER.
                        # At this point argparse allows no more flags to be processed.
                        if remaining_positionals and remaining_positionals[0].nargs == argparse.REMAINDER:
                            skip_remaining_flags = True

        #############################################################################################
        # We have parsed all but the last token and have enough information to complete it
        #############################################################################################
        return self._handle_last_token(
            text,
            line,
            begidx,
            endidx,
            flag_arg_state,
            pos_arg_state,
            remaining_positionals,
            consumed_arg_values,
            used_flags,
            skip_remaining_flags,
            cmd_set,
        )

    def _update_mutex_groups(
        self,
        arg_action: argparse.Action,
        completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action],
        used_flags: set[str],
        remaining_positionals: deque[argparse.Action],
    ) -> None:
        """Manage mutually exclusive group constraints and argument pruning for a given action.

        If an action belongs to a mutually exclusive group, this method ensures no other member
        has been used and updates the parser state to "consume" all remaining conflicting arguments.

        :raises CompletionError: if another member of the same mutually exclusive group
                                 has already been used.
        """
        # Check if this action is in a mutually exclusive group
        for group in self._parser._mutually_exclusive_groups:
            if arg_action in group._group_actions:
                # Check if the group this action belongs to has already been completed
                if group in completed_mutex_groups:
                    # If this is the action that completed the group, then there is no error
                    # since it's allowed to appear on the command line more than once.
                    completer_action = completed_mutex_groups[group]
                    if arg_action == completer_action:
                        return

                    arg_str = f"{argparse._get_action_name(arg_action)}"
                    completer_str = f"{argparse._get_action_name(completer_action)}"
                    error = f"Error: argument {arg_str}: not allowed with argument {completer_str}"
                    raise CompletionError(error)

                # Mark that this action completed the group
                completed_mutex_groups[group] = arg_action

                # Don't complete any of the other args in the group
                for group_action in group._group_actions:
                    if group_action == arg_action:
                        continue
                    if group_action in self._flag_to_action.values():
                        used_flags.update(group_action.option_strings)
                    elif group_action in remaining_positionals:
                        remaining_positionals.remove(group_action)

                # Arg can only be in one group, so we are done
                break

    def _handle_last_token(
        self,
        text: str,
        line: str,
        begidx: int,
        endidx: int,
        flag_arg_state: _ArgumentState | None,
        pos_arg_state: _ArgumentState | None,
        remaining_positionals: deque[argparse.Action],
        consumed_arg_values: dict[str, list[str]],
        used_flags: set[str],
        skip_remaining_flags: bool,
        cmd_set: CommandSet[Any] | None,
    ) -> Completions:
        """Perform final completion step handling positionals and flags."""
        # Check if we are completing a flag name. This check ignores strings with a length of one, like '-'.
        # This is because that could be the start of a negative number which may be a valid completion for
        # the current argument. We will handle the completion of flags that start with only one prefix
        # character (-f) at the end.
        if _looks_like_flag(text, self._parser) and not skip_remaining_flags:
            if (
                flag_arg_state is not None
                and isinstance(flag_arg_state.min, int)
                and flag_arg_state.count < flag_arg_state.min
            ):
                raise _UnfinishedFlagError(flag_arg_state)
            return self._complete_flags(text, line, begidx, endidx, used_flags)

        # Check if we are completing a flag's argument
        if flag_arg_state is not None:
            completions = self._complete_arg(text, line, begidx, endidx, flag_arg_state, consumed_arg_values, cmd_set=cmd_set)

            # If we have results, then return them
            if completions:
                return completions

            # Otherwise, print a hint if the flag isn't finished or text isn't possibly the start of a flag
            if (
                (isinstance(flag_arg_state.min, int) and flag_arg_state.count < flag_arg_state.min)
                or not _single_prefix_char(text, self._parser)
                or skip_remaining_flags
            ):
                raise _NoResultsError(self._parser, flag_arg_state.action)

        # Otherwise check if we have a positional to complete
        elif pos_arg_state is not None or remaining_positionals:
            # If we aren't current tracking a positional, then get the next positional arg to handle this token
            if pos_arg_state is None:
                action = remaining_positionals.popleft()
                pos_arg_state = _ArgumentState(action)

            completions = self._complete_arg(text, line, begidx, endidx, pos_arg_state, consumed_arg_values, cmd_set=cmd_set)

            # If we have results, then return them
            if completions:
                return completions

            # Otherwise, print a hint if text isn't possibly the start of a flag
            if not _single_prefix_char(text, self._parser) or skip_remaining_flags:
                raise _NoResultsError(self._parser, pos_arg_state.action)

        # If we aren't skipping remaining flags, then complete flag names if either is True:
        #   1. text is a single flag prefix character that didn't complete against any argument values
        #   2. there are no more positionals to complete
        if not skip_remaining_flags and (_single_prefix_char(text, self._parser) or not remaining_positionals):
            return self._complete_flags(text, line, begidx, endidx, used_flags)

        return Completions()

    def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_flags: set[str]) -> Completions:
        """Completion routine for a parsers unused flags."""
        # Build a list of flags that can be completed
        match_against: list[str] = []

        for flag in self._flags:
            # Make sure this flag hasn't already been used
            if flag not in used_flags:
                # Make sure this flag isn't considered hidden
                action = self._flag_to_action[flag]
                if action.help != argparse.SUPPRESS:
                    match_against.append(flag)

        # Build a dictionary linking actions with their matched flag names
        matched_actions: dict[argparse.Action, list[str]] = {}

        # Keep flags sorted in the order provided by argparse so our completion
        # suggestions display the same as argparse help text.
        matched_flags = self._cmd_app.basic_complete(text, line, begidx, endidx, match_against, sort=False)

        for flag in matched_flags.to_strings():
            action = self._flag_to_action[flag]
            matched_actions.setdefault(action, []).append(flag)

        # For completion suggestions, group matched flags by action
        items: list[CompletionItem] = []
        for action, option_strings in matched_actions.items():
            flag_text = ", ".join(option_strings)

            # Mark optional flags with brackets
            if not action.required:
                flag_text = "[" + flag_text + "]"

            # Use the first option string as the completion result for this action
            items.append(
                CompletionItem(
                    option_strings[0],
                    display=flag_text,
                    display_meta=action.help or "",
                )
            )

        return Completions(items)

    @staticmethod
    def _validate_table_data(arg_state: _ArgumentState, completions: Completions) -> None:
        """Verify the integrity of completion table data.

        :raises ValueError: if there is an error with the data.
        """
        table_columns = arg_state.action.get_table_columns()  # type: ignore[attr-defined]
        has_table_data = any(item.table_data for item in completions)

        if table_columns is None:
            if has_table_data:
                raise ValueError(
                    f"Argument '{arg_state.action.dest}' has CompletionItems with table_data, "
                    f"but no table_columns were defined in add_argument()."
                )
            return

        # If columns are defined, then every item must have data, and lengths must match
        for item in completions:
            if not item.table_data:
                raise ValueError(
                    f"Argument '{arg_state.action.dest}' has table_columns defined, "
                    f"but the CompletionItem for '{item.text}' is missing table_data."
                )
            if len(item.table_data) != len(table_columns):
                raise ValueError(
                    f"Argument '{arg_state.action.dest}': table_data length ({len(item.table_data)}) "
                    f"does not match table_columns length ({len(table_columns)}) for item '{item.text}'."
                )

    def _build_completion_table(self, arg_state: _ArgumentState, completions: Completions) -> Completions:
        """Build a rich.Table for completion results if applicable."""
        # Verify integrity of completion data
        self._validate_table_data(arg_state, completions)

        table_columns = cast(
            Sequence[str | Column] | None,
            arg_state.action.get_table_columns(),  # type: ignore[attr-defined]
        )

        # Skip table generation if results are outside thresholds or no columns are defined
        if (
            len(completions) < 2
            or len(completions) > self._cmd_app.max_completion_table_items
            or table_columns is None
        ):  # fmt: skip
            return completions

        # If a metavar was defined, use that instead of the dest field
        destination = arg_state.action.metavar or arg_state.action.dest

        # Handle case where metavar was a tuple
        if isinstance(destination, tuple):
            # Figure out what string in the tuple to use based on how many of the arguments have been completed.
            # Use min() to avoid going passed the end of the tuple to support nargs being ZERO_OR_MORE and
            # ONE_OR_MORE. In those cases, argparse limits metavar tuple to 2 elements but we may be completing
            # the 3rd or more argument here.
            destination = destination[min(len(destination) - 1, arg_state.count)]

        # Build header row
        rich_columns: list[Column] = []
        rich_columns.append(
            Column(
                destination.upper(),
                justify="right" if completions.numeric_display else "left",
                no_wrap=True,
            )
        )
        rich_columns.extend(
            column if isinstance(column, Column) else Column(column, overflow="fold") for column in table_columns
        )

        # Build the table
        table = Cmd2SimpleTable(*rich_columns)
        for item in completions:
            table.add_row(Text.from_ansi(item.display), *item.table_data)

        return dataclasses.replace(
            completions,
            table=table,
        )

    def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: Sequence[str]) -> Completions:
        """Supports cmd2's help command in the completion of subcommand names.

        :param text: the string prefix we are attempting to match (all matches must begin with it)
        :param line: the current input line with leading whitespace removed
        :param begidx: the beginning index of the prefix text
        :param endidx: the ending index of the prefix text
        :param tokens: arguments passed to command/subcommand
        :return: a Completions object
        """
        # If our parser has subcommands, we must examine the tokens and check if they are subcommands
        # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter.
        if self._subcommand_action is not None:
            for token_index, token in enumerate(tokens):
                if token in self._subcommand_action.choices:
                    parser = self._subcommand_action.choices[token]
                    completer = parser.completer_class(parser, self._cmd_app)
                    return completer.complete_subcommand_help(text, line, begidx, endidx, tokens[token_index + 1 :])

                if token_index == len(tokens) - 1:
                    # Since this is the last token, we will attempt to complete it
                    return self._cmd_app.basic_complete(text, line, begidx, endidx, self._subcommand_action.choices)
                break
        return Completions()

    def print_help(self, tokens: Sequence[str], file: IO[str] | None = None) -> None:
        """Supports cmd2's help command in the printing of help text.

        :param tokens: arguments passed to help command
        :param file: optional file object where the argparse should write help text
                     If not supplied, argparse will write to sys.stdout.
        """
        # If our parser has subcommands, we must examine the tokens and check if they are subcommands.
        # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter.
        if tokens and self._subcommand_action is not None:
            parser = self._subcommand_action.choices.get(tokens[0])
            if parser is not None:
                completer = parser.completer_class(parser, self._cmd_app)
                completer.print_help(tokens[1:], file)
                return
        self._parser.print_help(file)

    def _choices_to_items(self, arg_state: _ArgumentState) -> list[CompletionItem]:
        """Convert choices from action to list of CompletionItems."""
        if arg_state.action.choices is None:
            return []

        # If choices are subcommands, then get their help text to populate display_meta.
        if isinstance(arg_state.action, argparse._SubParsersAction):
            parser_help = {}
            for action in arg_state.action._choices_actions:
                if action.dest in arg_state.action.choices:
                    subparser = arg_state.action.choices[action.dest]
                    parser_help[subparser] = action.help or ""

            return [
                CompletionItem(name, display_meta=parser_help.get(subparser, ""))
                for name, subparser in arg_state.action.choices.items()
            ]

        # Standard choices
        return [
            choice if isinstance(choice, CompletionItem) else CompletionItem(choice) for choice in arg_state.action.choices
        ]

    def _prepare_callable_params(
        self,
        to_call: UnboundChoicesProvider[CmdOrSetT] | UnboundCompleter[CmdOrSetT],
        arg_state: _ArgumentState,
        text: str,
        consumed_arg_values: dict[str, list[str]],
        cmd_set: CommandSet[Any] | None,
    ) -> tuple[list[Any], dict[str, Any]]:
        """Resolve the instance and arguments required to call a choices/completer function."""
        args: list[Any] = []
        kwargs: dict[str, Any] = {}

        # Resolve the 'self' instance for the method
        self_arg = self._cmd_app._resolve_func_self(to_call, cmd_set)
        if self_arg is None:
            raise CompletionError("Could not find CommandSet instance matching defining type")

        args.append(self_arg)

        # Check if the function expects 'arg_tokens'
        to_call_params = inspect.signature(to_call).parameters
        if ARG_TOKENS in to_call_params:
            arg_tokens = {**self._parent_tokens, **consumed_arg_values}
            arg_tokens.setdefault(arg_state.action.dest, []).append(text)
            kwargs[ARG_TOKENS] = arg_tokens

        return args, kwargs

    def _complete_arg(
        self,
        text: str,
        line: str,
        begidx: int,
        endidx: int,
        arg_state: _ArgumentState,
        consumed_arg_values: dict[str, list[str]],
        *,
        cmd_set: CommandSet[Any] | None = None,
    ) -> Completions:
        """Completion routine for an argparse argument.

        :return: a Completions object
        :raises CompletionError: if the completer or choices function this calls raises one
        """
        # Check if the argument uses a completer
        completer = arg_state.action.get_completer()  # type: ignore[attr-defined]
        if completer is not None:
            args, kwargs = self._prepare_callable_params(
                completer,
                arg_state,
                text,
                consumed_arg_values,
                cmd_set,
            )
            args.extend([text, line, begidx, endidx])
            completions: Completions = completer(*args, **kwargs)

        # Otherwise it uses a choices provider or choices list
        else:
            choices_provider = arg_state.action.get_choices_provider()  # type: ignore[attr-defined]
            if choices_provider is not None:
                args, kwargs = self._prepare_callable_params(
                    choices_provider,
                    arg_state,
                    text,
                    consumed_arg_values,
                    cmd_set,
                )
                all_choices = list(choices_provider(*args, **kwargs))
            else:
                all_choices = self._choices_to_items(arg_state)

            # Filter used values and run basic completion
            used_values = consumed_arg_values.get(arg_state.action.dest, [])
            filtered = [choice for choice in all_choices if choice.text not in used_values]
            completions = self._cmd_app.basic_complete(text, line, begidx, endidx, filtered)

        return self._build_completion_table(arg_state, completions)


# The default ArgparseCompleter class for a cmd2 app
DEFAULT_ARGPARSE_COMPLETER: type[ArgparseCompleter] = ArgparseCompleter


def set_default_argparse_completer(completer_class: type[ArgparseCompleter]) -> None:
    """Set the default ArgparseCompleter class for a cmd2 app.

    :param completer_class: Type that is a subclass of ArgparseCompleter.
    """
    global DEFAULT_ARGPARSE_COMPLETER  # noqa: PLW0603
    DEFAULT_ARGPARSE_COMPLETER = completer_class
