"""Build argparse parsers from type-annotated function signatures.

!!! warning "Experimental"

    This module is experimental and its behavior may change in future releases.

The [`with_annotated`][cmd2.annotated.with_annotated] decorator inspects a command function's type hints and
default values to build a ``Cmd2ArgumentParser``.  [`Argument`][cmd2.annotated.Argument] and
[`Option`][cmd2.annotated.Option] metadata classes give finer per-parameter control via
``typing.Annotated``.

Parameters without defaults become positional arguments; parameters with defaults
become ``--option`` flags; keyword-only parameters (after ``*``) are always options.
A ``bool`` option is a flag, not a value: when absent it means ``False`` (or ``None``
for ``bool | None``), so it defaults to that and is never ``required``.  A ``*args``
parameter becomes a variadic positional accepting zero or more values (``nargs='*'``),
collected into a tuple.  Underscores in a parameter name become dashes in the generated
flag (``dry_run`` -> ``--dry-run``); pass an explicit ``Option("--my_flag")`` to opt out.
Positional-only parameters (before ``/``) and ``**kwargs`` raise ``TypeError``.  The parameter
names ``dest`` and ``subcommand`` are reserved; ``cmd2_statement`` receives the parsed
``Statement`` and (with ``base_command=True``) ``cmd2_subcommand_func`` receives the subcommand handler:

    class MyApp(cmd2.Cmd):
        @cmd2.with_annotated
        def do_greet(self, name: str, count: int = 1, loud: bool = False):
            for _ in range(count):
                msg = f"Hello {name}"
                self.poutput(msg.upper() if loud else msg)

Use ``Annotated`` with [`Argument`][cmd2.annotated.Argument] or [`Option`][cmd2.annotated.Option] for finer
control over individual parameters:

    from typing import Annotated


    class MyApp(cmd2.Cmd):
        def color_choices(self) -> cmd2.Choices:
            return cmd2.Choices.from_values(["red", "green", "blue"])

        @cmd2.with_annotated
        def do_paint(
            self,
            item: str,
            color: Annotated[str, Option("--color", "-c", choices_provider=color_choices, help_text="Color to use")] = "blue",
        ):
            self.poutput(f"Painting {item} {color}")

How annotations map to argparse settings:

- ``str`` -- default string argument
- ``int``, ``float`` -- sets ``type=``
- ``bool`` option -- ``--flag / --no-flag`` via ``BooleanOptionalAction``; defaults to
  ``False`` (or ``None`` for ``bool | None``) when omitted, so it is never ``required``
- positional ``bool`` -- parsed from ``true/false``, ``yes/no``, ``on/off``, ``1/0``
- ``pathlib.Path`` -- sets ``type=Path``
- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values
- a union of Enums (e.g. ``EnumA | EnumB``) -- each member keeps its own converter; a token resolves
  to the first member that accepts it, and the merged ``choices`` are the concatenation of each
  member's choices
- ``decimal.Decimal`` -- sets ``type=Decimal``
- ``Literal[...]`` -- ``type=converter`` and ``choices`` from the literal values
- ``list[T]`` / ``set[T]`` / ``frozenset[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` with a default or ``| None``)
- ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T``
- ``*args: T`` -- variadic positional (``nargs='*'``); ``T`` is each value's type, not the
  collected tuple.  ``Annotated[T, Argument(...)]`` metadata is honored
- ``T | None`` (no default) -- positional with ``nargs='?'`` (0-or-1 tokens)
- ``T | None = None`` -- ``--flag`` option with ``default=None``

A value option with no default is made ``required`` (omitting it would pass ``None``,
violating a non-Optional hint); annotate it ``T | None`` or give it a default to make it
omittable.

Explicit ``Option(action=...)`` is type-checked so the parsed result matches the
declared type:

- ``store_true`` / ``store_false`` -- require a ``bool`` parameter (``type=`` is dropped;
  argparse supplies the ``False``/``True`` default)
- ``count`` -- requires an ``int`` parameter; defaults to ``0`` (``None`` for ``int | None``)
- ``append`` / ``extend`` -- require a ``list[T]`` parameter and default to ``[]``
  (``append`` takes one value per flag; ``extend`` takes ``nargs`` values per flag)
- ``store_const`` / ``append_const`` -- store the ``Option(const=...)`` value (``type=`` is dropped).
  The action is inferred from the type when ``action=`` is omitted: a scalar ``Option(const=X)`` becomes
  ``store_const`` (present -> ``const``, absent -> the default, which must exist or be ``T | None``); a
  ``list[T]`` ``Option(const=X)`` becomes ``append_const`` (each flag appends ``const``; defaults to ``[]``).
  A scalar ``Option(const=X)`` given an explicit ``nargs`` (e.g. ``nargs='?'``) instead keeps the ``store``
  action for argparse's optional-value idiom (absent -> default, bare flag -> ``const``, ``flag VALUE`` ->
  converted ``VALUE``); the ``const`` is stored verbatim and must match the declared type.
  ``const`` is validated against the declared type and is rejected on a positional ``Argument`` (argparse
  ignores it there)
- a custom `argparse.Action` subclass -- passed straight through to ``add_argument``.
  The user's class owns storage, so the collection-casting wrapper is dropped and the action-specific
  type/const/collection-shape constraints are skipped.  The type-inferred converter, default, and
  ``required`` are still applied; the action receives them like any hand-built ``add_argument`` call.
  ``action='help'`` and ``action='version'`` are not supported.

The zero-argument actions above (``store_true`` / ``store_false`` / ``count`` / ``store_const`` /
``append_const``) take no value from the command line, so the value-oriented metadata inferred from
the type is dropped before ``add_argument`` is called: the ``type=`` converter, the static
``choices``, and any inferred tab-completer (e.g. the path completer for ``Path``).  There is nothing
to complete or convert on a value-less action.  A completer that was only *inferred* from the type is
dropped silently, but a ``completer`` / ``choices_provider`` you supply *explicitly* on such an action
is a contradiction and raises ``TypeError`` (matching argparse, which rejects it outright).  Actions
that *do* consume values (``append`` / ``extend`` on a ``list[T]``, or a plain value option) keep the
inferred converter and completer unchanged.

The metadata classes refuse a handful of ``add_argument`` kwargs that the decorator derives from the
signature itself, so passing them through ``Argument(...)`` / ``Option(...)`` raises ``TypeError``:
``type`` (from the annotation), ``dest`` (from the parameter name), ``help`` (use the ``help_text``
parameter, which maps to it -- a raw ``help`` would silently shadow it), and -- on ``Argument`` only
-- ``action`` / ``required`` (which have no meaning on a positional).  Every other ``add_argument``
parameter, including those registered via
[`register_argparse_argument_parameter`][cmd2.argparse_utils.register_argparse_argument_parameter], passes through unchanged.

A ``default`` may be supplied either as the function-signature default (``param: T = v``) or as
``Argument(default=v)`` / ``Option(default=v)`` -- the two forms are equivalent.  Specifying both at
once raises ``TypeError`` (the value would have two sources of truth), and ``argparse.SUPPRESS`` is
rejected as a default from either source because it would remove the keyword argument the function
expects.

Parser-level customization is forwarded to [`Cmd2ArgumentParser`][cmd2.argparse_utils.Cmd2ArgumentParser]'s constructor via PEP
692 ``**parser_kwargs: Unpack[Cmd2ParserKwargs]``.  Anything the parser ctor accepts -- ``description``,
``epilog``, ``prog``, ``usage``, ``parents``, ``argument_default``, ``prefix_chars``,
``fromfile_prefix_chars``, ``conflict_handler``, ``add_help``, ``allow_abbrev``, ``exit_on_error``,
``formatter_class``, ``completer_class``, and on Python >= 3.14 ``suggest_on_error`` / ``color`` --
flows straight through; the [`Cmd2ParserKwargs`][cmd2.annotated.Cmd2ParserKwargs] ``TypedDict`` is the single source of truth
and gives type-checkers/IDEs autocomplete on the decorator's call site.  ``parser_class`` stays as
its own explicit kwarg because it selects the class itself, not a value passed to it.  Two
behaviors layer on top of the raw passthrough: if ``description`` is omitted, the first paragraph
of ``func.__doc__`` (up to the first blank line) is used so docstrings double as help text without
leaking ``:param:`` directives; and ``prog`` is rejected with ``subcommand_to`` because cmd2
rewrites it from the parent command's hierarchy.  Mutually exclusive groups accept
``Group(required=True)`` to require exactly one member; the same flag on a plain ``groups=`` entry
raises ``ValueError`` (argparse's ``add_argument_group`` has no ``required``).  Give a
``mutually_exclusive_groups`` entry a ``title``/``description`` to render it as a titled help section
(argparse's one supported nesting -- a mutex *inside* an argument group), and use
``Option(action='store_true')`` for any ``bool`` member so the mutex reads as ``[--foo | --bar]``
instead of expanding to ``--no-*`` variants.  To put non-mutex parameters in the same section, list
its members in a ``groups=`` entry instead and leave the title off the mutex; declaring the section in
both places, a mutex that sits only partly in a ``groups=`` entry, or one that spans two of them all
raise ``ValueError``.  The other three nesting directions (an argument group in an argument group or
in a mutex, and a mutex in a mutex) are removed in argparse on Python 3.14 and cannot be expressed
here.  These group-spec rules (and member references, double-assignment, and the ``required=True``
rejection) are checked at decoration time from parameter names alone -- type hints are not resolved,
so forward-referenced annotations still decorate -- meaning a misconfigured group raises when the
class is defined rather than on first command use.  The one group rule that needs the annotations
(a required member in a mutually exclusive group) fires when the parser is built.

A parameter annotated with a ``@dataclass`` that subclasses [`ArgumentBlock`][cmd2.annotated.ArgumentBlock] is a
reusable *argument block*: each of the dataclass's ``init`` fields is expanded into a flat command-line
argument (field name == argument name) at the parameter's position, and the parsed values are
reconstructed into a dataclass instance passed to the command.  This lets several commands share one
block without duplicating parameters, and a base block shared by inheritance is the reuse mechanism
(a subclass of a block is itself a block):

    @dataclass
    class CommonArgs(cmd2.ArgumentBlock):
        verbose: Annotated[bool, Option("-v", "--verbose")] = False
        output: Annotated[Path | None, Option("--output")] = None


    class MyApp(cmd2.Cmd):
        @cmd2.with_annotated
        def do_build(self, target: str, common: CommonArgs):
            if common.verbose:
                self.poutput(common.output)

The [`ArgumentBlock`][cmd2.annotated.ArgumentBlock] trait -- not "is a dataclass" -- is the trigger, so a plain
``@dataclass`` is never expanded and can still be used as an ordinary single argument value (e.g. with an
``Argument(converter=...)``).  Each field carries the usual ``Annotated[T, Option(...)]`` /
``Annotated[T, Argument(...)]`` metadata and behaves exactly as a top-level parameter of the same shape
(type inference, completion, choices).  The dataclass is the single source of truth for defaults: a field
with a default (``default`` or ``default_factory``) is emitted with ``argparse.SUPPRESS`` and filled by the
dataclass constructor at reconstruction time, so a ``default_factory`` yields a fresh value per invocation
(no shared-mutable default) and ``__post_init__`` runs.  A field with no default becomes a required
argument.  A field whose type is itself a block is not expanded (no recursion) and raises the
unsupported-type error.  Because fields expand flat, every field name must be unique across the command's
own parameters and every other block's fields -- a collision (which would put two argparse actions on one
namespace dest) raises ``TypeError`` when the parser is built.  Block fields can participate in
``groups=`` / ``mutually_exclusive_groups=``: a group names arguments, and because a block expands to one
argument per field, you name its *field* names.  Since resolving those field names needs the block's
type hints, this membership is checked when the parser is built rather than at decoration time.  A block
must be the *bare* annotation of a regular parameter:
wrapping it in ``Annotated``/``Optional``/a union, or using it as ``*args`` / ``**kwargs``, raises
``TypeError`` (to use a dataclass as a single value instead, make it a plain ``@dataclass`` with an
``Argument(converter=...)``).  An ``ArgumentBlock`` subclass that is not a ``@dataclass`` has no fields and
also raises ``TypeError``.

Unsupported patterns (raise ``TypeError``):

- a non-Optional type with a ``None`` default (e.g. ``name: str = None``); annotate it
  ``T | None`` or use a non-None default.  ``Any``/``object``/unannotated are exempt
- a scalar type with no converter (e.g. ``datetime.datetime``, ``uuid.UUID``, ``bytes``,
  or any custom class), which would silently arrive as a plain string.  Supported scalars
  are ``str``, ``int``, ``float``, ``bool``, ``decimal.Decimal``, ``pathlib.Path``,
  ``enum.Enum`` subclasses, and ``Literal[...]`` (``str``/``Any``/``object`` pass through raw)
- ``str | int`` -- a union of multiple non-None types is ambiguous (unless every member is an
  ``enum.Enum`` subclass, which resolves by trying each member's converter in turn)
- ``tuple[int, str, float]`` -- mixed element types (argparse applies one ``type=`` per argument)
- ``*args: tuple[T, ...]`` (or any collection element) -- the annotation is each value's type,
  so a collection element means a tuple-of-collections; annotate the element, e.g. ``*args: str``
- ``*args: Annotated[T, Option(...)]`` -- ``*args`` is always positional; use ``Argument()``
- ``*args: Annotated[T, Argument(nargs=N)]`` -- ``*args`` arity is fixed to ``nargs='*'``
- a keyword-only parameter annotated with ``Argument()`` -- it marks a positional; use ``Option()``
- a required option (no default, not ``T | None``) in a ``mutually_exclusive_groups`` group --
  only one member is supplied, so the others arrive as ``None``; give it a default or ``T | None``
- ``Annotated[T, Argument(nargs=N)]`` producing a list (``'*'``, ``'+'``, integer ``>= 1``)
  on a non-collection ``T``; use ``list[T]`` or ``tuple[T, ...]`` to match the runtime shape
- ``Annotated[tuple[T, T], Argument(nargs=N)]`` where ``N`` differs from the tuple's arity
- ``Option(action=...)`` whose result type mismatches the declared type, an unsupported action,
  or a non-list action on a collection (use ``append``/``extend``/``append_const`` with ``list[T]``)
- a variable-arity positional (``T | None``, ``list[T]``, ``tuple[T, ...]``) followed by another
  positional -- it must come last (``def f(self, a: str, *rest: str)`` is fine)

When combining ``Annotated`` with ``Optional``, the union should go *inside*:
``Annotated[T | None, meta]``.  ``Annotated[T, meta] | None`` is ambiguous and raises -- unless the
inner type already carries the ``None`` (``Annotated[T | None, meta] | None``), in which case the
redundant outer ``| None`` is accepted as equivalent to ``Annotated[T | None, meta]``.

``Path`` and ``Enum`` annotations also get automatic tab completion.  A user-supplied
``choices_provider`` or ``completer`` drives completion in place of the inferred static
``choices``, while the inferred ``type`` converter is kept so values still coerce to the
declared type (an ``Enum`` to its member, ``Literal[1, 2]`` to ``int``) and out-of-type
values are rejected at parse time.  An ``Enum`` accepts both member values and member names on the
command line (completion and ``--help`` show the values).

An explicit ``choices=`` is reconciled with the inferred type rather than fighting it: its values are
run through the inferred ``type`` converter so they match argparse's post-conversion comparison
(``Annotated[int, Option('--n', choices=['1', '2'])]`` becomes ``choices=[1, 2]``, so ``--n 1``
matches; a value the converter rejects is a build-time ``TypeError``), and an explicit ``choices=``
takes precedence over a *type-inferred* completer (the ``Path`` completer is dropped so the choices
drive both validation and completion).  A ``choices_provider`` / ``completer`` you supply yourself
still wins over ``choices=``.

Two hooks customize the string -> value conversion, parity with a hand-built ``add_argument(type=...)``
(a raw ``type=`` in the metadata is rejected; use these instead):

- ``converter`` -- a ``Callable[[str], Any]`` that *replaces* the inferred ``type=`` converter.  Because
  the converter owns the conversion, the annotation is no longer required to be a supported scalar -- any
  type is legal (``Annotated[datetime, Argument(converter=parse_iso)]``), and the "unsupported type" error
  is suppressed.  The inferred ``choices`` and completer (which described the *inferred* value-space) are
  dropped; supply ``choices=`` / ``completer`` / ``choices_provider`` to re-add them (an explicit
  ``choices=`` is still run through your converter).  argparse applies it per token, so on a ``list[T]`` it
  converts each value; a non-collection annotation such as ``Any`` keeps a single token, so the converter
  may itself return a collection (``Annotated[Any, Option('--idx', converter=parse_intset)]``).
- ``preprocess`` -- a ``Callable[[str], str]`` that runs *before* the inferred converter, transforming the
  raw token while *keeping* the inferred ``type=``, ``choices``, completer, and coercion.  Use it to
  normalize input for a type that already has rich inference, e.g. ``Annotated[Color, Argument(preprocess=
  str.lower)]`` accepts ``RED`` while still showing the ``Color`` choices, or ``Annotated[Path,
  Argument(preprocess=os.path.expanduser)]`` keeps the path completer.  With a plain ``str`` (no inferred
  converter) it becomes the ``type=`` directly.

``converter`` and ``preprocess`` are mutually exclusive on one parameter (fold the preprocessing into the
converter, which already receives the raw token), and neither may be combined with a value-less action
(``store_true`` / ``store_false`` / ``count`` / ``store_const`` / ``append_const``), which consumes no
token to convert.
"""

import argparse
import decimal
import enum
import functools
import inspect
import operator
import types
from collections.abc import (
    Callable,
    Container,
    Iterable,
    Sequence,
)
from dataclasses import (
    MISSING,
    dataclass,
    field,
    fields,
    is_dataclass,
)
from pathlib import Path
from typing import (
    Annotated,
    Any,
    ClassVar,
    Literal,
    NamedTuple,
    ParamSpec,
    Protocol,
    TypedDict,
    TypeGuard,
    TypeVar,
    Union,
    Unpack,
    get_args,
    get_origin,
    get_type_hints,
    overload,
)

from rich.table import Column

from . import constants
from .argparse_completer import ArgparseCompleter
from .argparse_utils import (
    ArgparseCommandSpec,
    Cmd2ArgumentParser,
    SubcommandSpec,
)
from .completion import CompletionItem
from .decorators import _parse_positionals
from .exceptions import Cmd2ArgparseError
from .rich_utils import Cmd2HelpFormatter, HelpContent
from .types import (
    CmdOrSetT,
    UnboundChoicesProvider,
    UnboundCompleter,
)

#: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples).
_NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float]


class Cmd2ParserKwargs(TypedDict, total=False):
    """Forwarded ctor kwargs for [`Cmd2ArgumentParser`][cmd2.argparse_utils.Cmd2ArgumentParser] (PEP 692 ``Unpack``).

    Single source of truth mirroring the parser's ``__init__``: add a field here to expose a new
    ctor kwarg on the decorator's call site.  All optional (``total=False``); ``suggest_on_error``
    and ``color`` only take effect on Python >= 3.14.
    """

    prog: str | None
    usage: str | None
    description: HelpContent | None
    epilog: HelpContent | None
    parents: Sequence[argparse.ArgumentParser]
    formatter_class: type[Cmd2HelpFormatter]
    prefix_chars: str
    fromfile_prefix_chars: str | None
    argument_default: Any
    conflict_handler: str
    add_help: bool
    allow_abbrev: bool
    exit_on_error: bool
    suggest_on_error: bool
    color: bool
    completer_class: type[ArgparseCompleter] | None


# ---------------------------------------------------------------------------
# Metadata classes
# ---------------------------------------------------------------------------

#: Sentinel marking "no value assigned" (a builder slot, or an unset ``const``).
#: Defined here so the metadata classes can use it as a default before the builder section.
_UNSET: Any = object()


class _BaseArgMetadata:
    """Shared fields for ``Argument`` and ``Option`` metadata."""

    _KWARGS_MAP: ClassVar[dict[str, str]] = {
        "help_text": "help",
        "metavar": "metavar",
        "choices": "choices",
        "choices_provider": "choices_provider",
        "completer": "completer",
        "table_columns": "table_columns",
        "suppress_tab_hint": "suppress_tab_hint",
        "nargs": "nargs",
    }

    #: ``add_argument`` kwargs the decorator derives from the signature (or exposes under another
    #: name), so the metadata classes refuse them as ``extra_kwargs``: ``type`` (from the annotation),
    #: ``dest`` (parameter name), ``action``/``required`` (named ``Option`` args), ``help`` (use
    #: ``help_text``).  Per-key remediation hints are in :meth:`__init__`.
    _RESERVED_EXTRA_KWARGS: ClassVar[frozenset[str]] = frozenset(
        {
            "type",
            "dest",
            "action",
            "required",
            "help",
        }
    )

    def __init__(
        self,
        *,
        help_text: str | None = None,
        metavar: str | tuple[str, ...] | None = None,
        nargs: _NargsValue | None = None,
        choices: Iterable[Any] | None = None,
        choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None,
        completer: UnboundCompleter[CmdOrSetT] | None = None,
        table_columns: Sequence[str | Column] | None = None,
        suppress_tab_hint: bool | None = None,
        const: Any = _UNSET,
        default: Any = _UNSET,
        allow_unknown_entry: bool = False,
        converter: Callable[[str], Any] | None = None,
        preprocess: Callable[[str], str] | None = None,
        **extra_kwargs: Any,
    ) -> None:
        """Initialise shared metadata fields.

        ``const`` is the value stored on a present flag with no argument (``Option`` only:
        ``store_const``/``append_const``); ``_UNSET`` distinguishes "no const" from ``const=None``.
        ``default`` mirrors the signature default (``Option(default=v)`` == ``... = v``); supplying
        both, or ``argparse.SUPPRESS``, is rejected.  ``allow_unknown_entry`` only affects ``Enum``
        annotations: when set, a token matched by neither a member value nor name is routed through
        the enum's ``_missing_`` hook (for aliases / special keywords) instead of being rejected
        outright.  ``converter`` replaces the inferred ``type=`` converter (and makes any annotation
        type legal); ``preprocess`` runs before the inferred converter to transform the raw token while
        keeping the inferred choices/completer.  The two are mutually exclusive and neither combines with
        a value-less action (see the module docstring).  ``extra_kwargs`` forwards any other
        ``add_argument`` parameter (incl. those from
        [`register_argparse_argument_parameter`][cmd2.argparse_utils.register_argparse_argument_parameter]) straight through.
        """
        reserved = self._RESERVED_EXTRA_KWARGS & extra_kwargs.keys()
        if reserved:
            name = min(reserved)
            # Per-key remediation hint for the reserved kwarg.
            hint = {
                "type": (
                    "The converter is derived from the parameter annotation; change the annotation, or pass "
                    "converter= for a custom string -> value callable (preprocess= to transform the token first)."
                ),
                "dest": "The dest is the parameter name; rename the parameter instead.",
                "action": "Use Option(action=...) (only Option supports an action; Argument is always positional).",
                "required": (
                    "Use Option(required=True); a positional Argument is always required unless it has "
                    "a default or is annotated as `T | None`."
                ),
                "help": "Use the help_text= parameter instead; it maps to argparse's help= and would otherwise be shadowed.",
            }[name]
            raise TypeError(f"{type(self).__name__}({name}=...) is not accepted by @with_annotated. {hint}")
        self.help_text = help_text
        self.metavar = metavar
        self.nargs = nargs
        self.choices = choices
        self.choices_provider = choices_provider
        self.completer = completer
        self.table_columns = table_columns
        self.suppress_tab_hint = suppress_tab_hint
        self.const = const
        self.default = default
        self.allow_unknown_entry = allow_unknown_entry
        self.converter = converter
        self.preprocess = preprocess
        self.extra_kwargs = extra_kwargs

    def to_kwargs(self) -> dict[str, Any]:
        """Return non-None mapped fields, an explicit ``const``, and any passthrough ``extra_kwargs``."""
        kwargs = {kwarg: val for attr, kwarg in self._KWARGS_MAP.items() if (val := getattr(self, attr)) is not None}
        if self.const is not _UNSET:
            kwargs["const"] = self.const
        kwargs.update(self.extra_kwargs)
        return kwargs


class Argument(_BaseArgMetadata):
    """Metadata for a positional argument in an ``Annotated`` type hint."""


class Option(_BaseArgMetadata):
    """Metadata for an optional/flag argument in an ``Annotated`` type hint.

    Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``); when omitted
    the decorator generates ``--param-name`` (underscores become dashes).
    """

    def __init__(
        self,
        *names: str,
        action: str | type[argparse.Action] | None = None,
        required: bool = False,
        **kwargs: Any,
    ) -> None:
        """Initialise Option metadata.

        ``action`` is a supported string action (``store_true``/``store_false``/``count``/
        ``append``/``extend``/``store_const``/``append_const``) or a custom
        `argparse.Action` subclass (passed through; it owns storage, so the inferred
        action and the action-specific constraints are skipped).
        """
        super().__init__(**kwargs)
        self.names = names
        self.action = action
        self.required = required

    def to_kwargs(self) -> dict[str, Any]:
        """Return non-None fields as an argparse kwargs dict."""
        kwargs = super().to_kwargs()
        if self.action is not None:
            kwargs["action"] = self.action
        if self.required:
            kwargs["required"] = True
        return kwargs


class ArgumentBlock:
    """Marker base class for a reusable ``@with_annotated`` argument block.

    Subclass it on a ``@dataclass`` to have [`with_annotated`][cmd2.annotated.with_annotated] expand the
    dataclass's fields into flat command-line arguments and reconstruct an instance at call time::

        @dataclass
        class CommonArgs(ArgumentBlock):
            verbose: Annotated[bool, Option("-v", "--verbose")] = False


        class MyApp(cmd2.Cmd):
            @cmd2.with_annotated
            def do_build(self, target: str, common: CommonArgs): ...

    Only a class that inherits ``ArgumentBlock`` is treated as a block, so a plain ``@dataclass`` is left
    alone and can still be used as an ordinary single argument value (e.g. via an ``Argument(converter=...)``).
    Inheritance doubles as the reuse mechanism: a subclass of a block is itself a block, so a shared base
    block can be extended per command without repeating its fields.  A block must be the *bare* annotation
    of a regular parameter -- wrapping it in ``Annotated``/``Optional``/a union, or using it as ``*args`` /
    ``**kwargs``, raises ``TypeError``.

    To share a block between a command and its subcommands, name the parameter ``cmd2_base_args`` on the
    command and ``cmd2_parent_args`` on each subcommand that should receive it::

        @dataclass
        class SharedOpts(ArgumentBlock):
            verbose: Annotated[bool, Option("-v", "--verbose")] = False


        class MyApp(cmd2.Cmd):
            @cmd2.with_annotated(base_command=True)
            def do_root(self, cmd2_subcommand_func, cmd2_base_args: SharedOpts): ...

            @cmd2.with_annotated(subcommand_to="root")
            def root_show(self, cmd2_parent_args: SharedOpts):
                self.poutput(cmd2_parent_args.verbose)  # parsed on `root`, read here

    A base command and its subcommands parse into one shared ``argparse.Namespace``.  ``cmd2_base_args``
    adds the block's flags to the command's *own* parser; ``cmd2_parent_args`` adds *no* arguments and is
    reconstructed from the values an ancestor parsed (``root --verbose show``, not ``root show --verbose``).
    A ``cmd2_parent_args`` subcommand whose ancestors never declare a matching ``cmd2_base_args`` raises a
    clear error the first time it runs.
    """


class Group:
    """Argument-group definition for ``with_annotated(groups=...)`` / ``mutually_exclusive_groups=...``."""

    def __init__(
        self,
        *members: str,
        title: str | None = None,
        description: str | None = None,
        required: bool = False,
    ) -> None:
        """Initialise an argument group definition.

        :param members: argument names to place in the group (at least one).  A plain parameter's name is its
                        argument name; for an ``ArgumentBlock`` name its fields.
        :param title: group title shown as a help section header
        :param description: group description shown under the title
        :param required: ``mutually_exclusive_groups`` only -- require exactly one member.
                         On a ``groups=`` entry it raises ``ValueError``.
        """
        if not members:
            raise ValueError("Group requires at least one member argument name")
        self.members = members
        self.title = title
        self.description = description
        self.required = required


#: Metadata extracted from ``Annotated[T, meta]``, or ``None`` for plain types.
ArgMetadata = Argument | Option | None

_NormalizedAnnotation = tuple[Any, ArgMetadata, bool]
_ArgumentTarget = argparse.ArgumentParser | argparse._MutuallyExclusiveGroup | argparse._ArgumentGroup


@dataclass
class _TypeResult:
    """How a declared type maps onto argparse settings."""

    converter: Callable[[str], Any] | None = None
    choices: Iterable[Any] | None = None
    action: str | type[argparse.Action] | None = None
    completer: Any = None
    is_collection: bool = False
    container_factory: Callable[[list[Any]], Any] | None = None
    fixed_arity: int | None = None


# ---------------------------------------------------------------------------
# Type resolvers
# ---------------------------------------------------------------------------
#
# Each resolver has the signature ``(tp, args, *, is_positional) -> _TypeResult`` and is
# registered in ``_TYPE_TABLE``.  ``_resolve_base_type`` looks one up by type; ``_apply_type``
# copies the resulting ``_TypeResult`` onto the builder's slots and context scratch.
# ---------------------------------------------------------------------------

_BOOL_TRUE_VALUES = ["1", "true", "t", "yes", "y", "on"]
_BOOL_FALSE_VALUES = ["0", "false", "f", "no", "n", "off"]
_BOOL_CHOICES = [CompletionItem(True, text=text) for text in _BOOL_TRUE_VALUES] + [
    CompletionItem(False, text=text) for text in _BOOL_FALSE_VALUES
]


def _parse_bool(value: str) -> bool:
    """Parse a string into a boolean value for argparse type conversion."""
    lowered = value.strip().lower()
    if lowered in _BOOL_TRUE_VALUES:
        return True
    if lowered in _BOOL_FALSE_VALUES:
        return False
    raise argparse.ArgumentTypeError(f"invalid boolean value: {value!r} (choose from: 1, 0, true, false, yes, no, on, off)")


def _choice_text(choice: Any) -> str:
    """Command-line spelling of a choice (the ``CompletionItem`` text, else ``str``)."""
    return choice.text if isinstance(choice, CompletionItem) else str(choice)


def _invalid_choice(value: str, choices: Iterable[Any]) -> argparse.ArgumentTypeError:
    """Build the standard 'invalid choice' rejection, de-duplicating the listed choices."""
    valid = ", ".join(dict.fromkeys(_choice_text(c) for c in choices))
    return argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})")


def _dedupe_choices(choices: Iterable[Any]) -> list[Any]:
    """Drop choices that share a command-line spelling, keeping the first occurrence."""
    by_text: dict[str, Any] = {}
    for choice in choices:
        by_text.setdefault(_choice_text(choice), choice)
    return list(by_text.values())


def _make_literal_type(literal_values: list[Any]) -> Callable[[str], Any]:
    """Create an argparse converter for a Literal's exact values."""
    value_map: dict[str, Any] = {}
    for value in literal_values:
        key = str(value)
        if key in value_map and value_map[key] is not value:
            raise TypeError(
                f"Literal values {value_map[key]!r} and {value!r} have the same string "
                f"representation {key!r} and cannot be distinguished on the command line."
            )
        value_map[key] = value

    def _convert(value: str) -> Any:
        if value in value_map:
            return value_map[value]
        if value.lower() in _BOOL_TRUE_VALUES:
            bool_value = True
        elif value.lower() in _BOOL_FALSE_VALUES:
            bool_value = False
        else:
            bool_value = None

        if bool_value is not None:
            for v in literal_values:
                if type(v) is bool and v == bool_value:
                    return bool_value

        raise _invalid_choice(value, literal_values)

    _convert.__name__ = "literal"
    return _convert


def _make_enum_type(enum_class: type[enum.Enum], *, allow_unknown_entry: bool = False) -> Callable[[str], enum.Enum]:
    """Create an argparse *type* converter for an Enum class.

    Accepts both member *values* and member *names*.  When ``allow_unknown_entry`` is set, a token
    matched by neither is passed to the enum's own ``_missing_`` hook so it can resolve aliases,
    alternate spellings, or special keywords; a token ``_missing_`` declines to claim (returns
    ``None``) is still rejected.  An enum that does not override ``_missing_`` inherits the default
    (which returns ``None``), so the flag is simply inert for it.
    """
    _value_map = {str(m.value): m for m in enum_class}

    def _convert(value: str) -> enum.Enum:
        member = _value_map.get(value)
        if member is not None:
            return member
        try:
            return enum_class[value]
        except KeyError:
            pass
        if allow_unknown_entry:
            # Call _missing_ directly so its return is honored and any error it raises propagates
            # (rather than being masked as an "invalid choice"); a None return falls through below.
            resolved = enum_class._missing_(value)
            if isinstance(resolved, enum_class):
                return resolved
        raise _invalid_choice(value, _value_map)

    _convert.__name__ = enum_class.__name__
    _convert._cmd2_enum_class = enum_class  # type: ignore[attr-defined]
    return _convert


class _CollectionCastingAction(argparse._StoreAction):
    """Store action that can coerce parsed collection values to a container type."""

    def __init__(self, *args: Any, container_factory: Callable[[list[Any]], Any] | None = None, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self._container_factory = container_factory

    def __call__(
        self,
        parser: argparse.ArgumentParser,  # noqa: ARG002
        namespace: argparse.Namespace,
        values: Any,
        option_string: str | None = None,  # noqa: ARG002
    ) -> None:
        result = values
        if self._container_factory is not None and isinstance(values, list):
            result = self._container_factory(values)
        setattr(namespace, self.dest, result)


# -- Individual resolvers -----------------------------------------------------


def _make_simple_resolver(converter: Callable[..., Any] | type) -> Callable[..., _TypeResult]:
    """Create a resolver for types that just need ``type=converter``."""

    def _resolve(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
        return _TypeResult(converter=converter)

    return _resolve


def _resolve_path(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
    """Resolve Path and add completer."""
    from .cmd2 import Cmd

    return _TypeResult(converter=Path, completer=Cmd.path_complete)


def _resolve_bool(_tp: Any, _args: tuple[Any, ...], *, is_positional: bool = False, **_ctx: Any) -> _TypeResult:
    """Resolve bool: a positional gets a converter, an option a flag action.

    A user ``Option(action=...)`` overrides this later; here we set only the default option action.
    """
    if not is_positional:
        return _TypeResult(action=argparse.BooleanOptionalAction)
    return _TypeResult(converter=_parse_bool, choices=list(_BOOL_CHOICES))


def _resolve_element(tp: Any, *, allow_unknown_entry: bool = False, has_converter: bool = False) -> _TypeResult:
    """Resolve a collection element type and reject nested collections."""
    inner = _resolve_base_type(tp, is_positional=True, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter)
    if inner.is_collection:
        raise TypeError("Nested collections are not supported")
    return inner


def _make_collection_resolver(collection_type: type) -> Callable[..., _TypeResult]:
    """Create a resolver for single-arg collections (list[T], set[T], frozenset[T])."""

    def _resolve(
        _tp: Any,
        args: tuple[Any, ...],
        *,
        allow_unknown_entry: bool = False,
        has_converter: bool = False,
        **_ctx: Any,
    ) -> _TypeResult:
        if len(args) == 0:
            # Bare list/set/frozenset without type args -- treat as list[str]/set[str]/frozenset[str].
            return _TypeResult(is_collection=True, container_factory=collection_type)
        if len(args) != 1:
            raise TypeError(
                f"{collection_type.__name__}[...] with {len(args)} type arguments is not supported; "
                f"use {collection_type.__name__}[T] with a single element type."
            )
        element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry, has_converter=has_converter)
        return _TypeResult(
            converter=element.converter,
            choices=element.choices,
            completer=element.completer,
            is_collection=True,
            container_factory=collection_type,
        )

    return _resolve


def _resolve_tuple(
    _tp: Any,
    args: tuple[Any, ...],
    *,
    allow_unknown_entry: bool = False,
    has_converter: bool = False,
    **_ctx: Any,
) -> _TypeResult:
    """Resolve tuple[T, ...] (variable) and tuple[T, T] (fixed arity)."""
    if not args:
        # Bare tuple without type args -- treat as tuple[str, ...].
        return _TypeResult(is_collection=True, container_factory=tuple)

    if len(args) == 2 and args[1] is Ellipsis:
        element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry, has_converter=has_converter)
        return _TypeResult(
            converter=element.converter,
            choices=element.choices,
            completer=element.completer,
            is_collection=True,
            container_factory=tuple,
        )

    if Ellipsis not in args:
        first = args[0]
        if not all(a == first for a in args[1:]):
            raise TypeError(
                f"tuple[{', '.join(_type_name(a) for a in args)}] "
                f"has mixed element types which is not currently supported because argparse "
                f"can only apply a single type= converter per argument. "
                f"Use tuple[T, T] (same type) or tuple[T, ...] instead."
            )
        element = _resolve_element(first, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter)
        return _TypeResult(
            converter=element.converter,
            choices=element.choices,
            completer=element.completer,
            is_collection=True,
            container_factory=tuple,
            fixed_arity=len(args),
        )

    raise TypeError(
        "tuple with Ellipsis in an unexpected position is not supported; "
        "use tuple[T, ...] for variable-length or tuple[T, T] for fixed-arity."
    )


def _resolve_literal(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
    """Resolve Literal["a", "b", ...] into converter + choices."""
    literal_values = list(args)
    return _TypeResult(converter=_make_literal_type(literal_values), choices=literal_values)


def _resolve_enum(tp: Any, _args: tuple[Any, ...], *, allow_unknown_entry: bool = False, **_ctx: Any) -> _TypeResult:
    """Resolve Enum subclasses into converter + choices."""
    return _TypeResult(
        converter=_make_enum_type(tp, allow_unknown_entry=allow_unknown_entry),
        choices=[CompletionItem(m, text=str(m.value), display_meta=m.name) for m in tp],
    )


def _is_enum(tp: Any) -> bool:
    """Whether *tp* is an ``enum.Enum`` subclass."""
    return isinstance(tp, type) and issubclass(tp, enum.Enum)


def _resolve_union(
    _tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, has_converter: bool = False, **_ctx: Any
) -> _TypeResult:
    """Resolve a union whose non-``None`` members are all Enums by trying each member's converter.

    Each member keeps its own converter, so member values, member names, and any ``_missing_``
    behavior (via ``allow_unknown_entry``) are preserved.  A token is resolved by the first member
    that accepts it, so when two members share a representation the earlier union member wins.  A
    union with any non-Enum member (including a ``Literal``) is rejected as ambiguous.

    A member declines a token by raising -- a clean ``ArgumentTypeError`` or anything a strict
    ``_missing_`` raises -- and the next member is tried, so a raising member never pre-empts those
    after it.  Only when every member declines is the merged-choices rejection raised.

    With ``has_converter`` the user's ``converter=`` owns the conversion, so the ambiguity rejection
    is suppressed and an empty result is returned (any union, Enum or not, is legal).
    """
    if has_converter:
        return _TypeResult()
    non_none = [a for a in args if a is not type(None)]
    if not all(_is_enum(a) for a in non_none):
        type_names = " | ".join(_type_name(a) for a in non_none)
        raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution.")

    parts = [_resolve_base_type(member, allow_unknown_entry=allow_unknown_entry) for member in non_none]
    # Every part is an Enum (guarded above), so each has a converter; the None-filter keeps the type checker happy.
    converters = [part.converter for part in parts if part.converter is not None]
    choices = _dedupe_choices(choice for part in parts for choice in (part.choices or []))

    def _convert(value: str) -> Any:
        for converter in converters:
            try:
                return converter(value)
            except Exception:  # noqa: BLE001, S112 - any raise means "not mine"; try the next member
                continue
        raise _invalid_choice(value, choices)

    _convert.__name__ = "union"
    return _TypeResult(converter=_convert, choices=choices)


# -- Registry -----------------------------------------------------------------

_TYPE_TABLE: dict[Any, Callable[..., _TypeResult]] = {
    # Subclass-matchable entries first -- iteration order matters for the
    # issubclass fallback. enum.Enum must precede int (IntEnum <: int).
    enum.Enum: _resolve_enum,
    Path: _resolve_path,
    # Exact-match entries (order among these doesn't affect subclass lookup).
    bool: _resolve_bool,
    decimal.Decimal: _make_simple_resolver(decimal.Decimal),
    float: _make_simple_resolver(float),
    int: _make_simple_resolver(int),
    Literal: _resolve_literal,
    Union: _resolve_union,
    types.UnionType: _resolve_union,
    frozenset: _make_collection_resolver(frozenset),
    list: _make_collection_resolver(list),
    set: _make_collection_resolver(set),
    tuple: _resolve_tuple,
}


# -- Helpers ------------------------------------------------------------------


def _type_name(tp: Any) -> str:
    """Best-effort type name for diagnostic messages."""
    return tp.__name__ if hasattr(tp, "__name__") else str(tp)


#: Scalar annotations that argparse stores as the raw string (no converter needed).
_PASSTHROUGH_TYPES = frozenset({str, object, Any, inspect.Parameter.empty})


def _resolve_base_type(
    tp: Any, *, is_positional: bool = False, allow_unknown_entry: bool = False, has_converter: bool = False
) -> _TypeResult:
    """Resolve a declared type into a :class:`_TypeResult` via the registry.

    Lookup order: ``get_origin(tp)`` -> ``tp`` -> ``issubclass`` fallback -> passthrough.
    Raises ``TypeError`` for a scalar with no converter, unless ``has_converter`` is set -- then an
    unresolvable type yields an empty result, because the user's ``converter=`` owns the conversion
    and only the collection *shape* (if any) is read from the resolved entry.
    """
    args = get_args(tp)
    resolver = _TYPE_TABLE.get(get_origin(tp)) or _TYPE_TABLE.get(tp)

    # Subclass fallback (e.g. MyEnum -> enum.Enum, MyPath -> pathlib.Path).
    if resolver is None and isinstance(tp, type):
        for parent, candidate in _TYPE_TABLE.items():
            if isinstance(parent, type) and issubclass(tp, parent):
                resolver = candidate
                break

    if resolver is not None:
        return resolver(
            tp, args, is_positional=is_positional, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter
        )
    if tp in _PASSTHROUGH_TYPES or has_converter:
        return _TypeResult()
    raise TypeError(
        f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter "
        f"for it, so command-line values would silently arrive as plain strings. Supported scalar types "
        f"are str, int, float, bool, decimal.Decimal, pathlib.Path, enum.Enum subclasses, and Literal[...]; "
        f"use one of these (optionally in list/set/frozenset/tuple) or a subclass of one."
    )


def _unwrap_optional(tp: Any) -> tuple[Any, bool]:
    """If *tp* is ``T | None``, return ``(T, True)``.  Otherwise ``(tp, False)``.

    Only the ``None`` is stripped here.  A multi-member union (with ``None`` removed) is handed back
    intact for :func:`_resolve_union` to accept (all-Enum) or reject (ambiguous); that decision lives
    there alone, so this helper never validates union members itself.
    """
    origin = get_origin(tp)
    if origin is Union or origin is types.UnionType:  # type: ignore[comparison-overlap]
        all_args = get_args(tp)
        non_none = [a for a in all_args if a is not type(None)]
        has_none = len(non_none) < len(all_args)
        if len(non_none) == 1:
            if has_none:
                return non_none[0], True
            raise TypeError(
                f"Unexpected single-element Union without None: Union[{non_none[0]}]. "
                f"Use the type directly instead of wrapping in Union."
            )
        # Rebuild the union without its None member and let _resolve_union judge it.
        return functools.reduce(operator.or_, non_none), has_none
    return tp, False


def _normalize_annotation(annotation: Any) -> _NormalizedAnnotation:
    """Normalize an annotation into its inner type, metadata, and optionality."""
    tp = annotation
    metadata: ArgMetadata = None
    is_optional = False

    tp, unwrapped = _unwrap_optional(tp)
    if unwrapped:
        is_optional = True
        if get_origin(tp) is Annotated:  # type: ignore[comparison-overlap]
            inner_tp = get_args(tp)[0]
            inner_origin = get_origin(inner_tp)
            inner_is_union = inner_origin is Union or inner_origin is types.UnionType  # type: ignore[comparison-overlap]
            if not (inner_is_union and type(None) in get_args(inner_tp)):
                raise TypeError("Annotated[T, meta] | None is ambiguous. Use Annotated[T | None, meta] instead.")

    if get_origin(tp) is Annotated:  # type: ignore[comparison-overlap]
        args = get_args(tp)
        tp = args[0]
        for meta in args[1:]:
            if isinstance(meta, (Argument, Option)):
                metadata = meta
                break

    tp, inner_unwrapped = _unwrap_optional(tp)
    if inner_unwrapped:
        is_optional = True

    return tp, metadata, is_optional


# ---------------------------------------------------------------------------
# Annotation resolution -- builder anchored to argparse's add_argument schema
# ---------------------------------------------------------------------------


class _NargsMode(enum.Enum):
    """How an explicit action sets ``nargs`` in the final override pass."""

    ARITY = "arity"  # keep the nargs resolved by the arity table
    CLEAR = "clear"  # take one value per flag, so emit no nargs


@dataclass(frozen=True)
class _ActionPolicy:
    """Declarative data for an explicit ``Option(action=...)``.

    Applied verbatim by :meth:`_ArgparseArgument._apply_action`; the type
    compatibility check (``requires``) is enforced by the constraint table.
    """

    requires: Callable[["_ArgparseArgument"], bool] | None = None  # declared type the result needs
    requires_label: str = ""  # human name of the required type (for the error message)
    drop_converter: bool = False  # action does not accept type=
    nargs_mode: _NargsMode = _NargsMode.ARITY  # whether to keep or clear the arity-table nargs
    default_factory: Callable[[], Any] | None = None  # default when omitted (also -> None for T | None)


#: Actions that accumulate values into a list via argparse's native action (replace the casting action).
_LIST_ACTIONS = frozenset({"append", "extend", "append_const"})
#: Actions that store a fixed ``const`` value: ``store_const`` (scalar) and ``append_const`` (list[T]).
_CONST_ACTIONS = frozenset({"store_const", "append_const"})

#: Metadata kwargs the builder reasons about itself (the override facts read on demand via the
#: ``_meta_*`` properties); every other ``to_kwargs()`` entry passes straight through to
#: ``extras`` for argparse (help / metavar / choices_provider / completer / ...).
_METADATA_SPECIAL_KEYS = frozenset({"choices", "action", "required", "nargs", "const"})

#: A rule table maps a subject ``_S`` (a single :class:`_ArgparseArgument`) to a result ``_R``.
_S = TypeVar("_S")
_R = TypeVar("_R")

#: One rule-table row: ``(predicate, producer)``.  A table is scanned top-to-bottom and the first
#: row whose ``predicate(subject)`` holds yields ``producer(subject)`` (see :func:`_first_match`).
#: Derivation tables produce an output slot's value; constraint tables produce an error or ``None``.
#: (Keyed dispatch -- ``_TYPE_TABLE`` / ``_ACTION_TABLE`` -- is separate: a payload looked up by key.)
_Rule = tuple[Callable[[_S], bool], Callable[[_S], _R]]


def _always(_subject: object) -> bool:
    """Predicate for a catch-all row -- always matches (every table ends with one)."""
    return True


def _const(value: _R) -> Callable[[object], _R]:
    """Return a producer that ignores its subject and always yields *value*."""
    return lambda _subject: value


def _first_match(rules: list[_Rule[_S, _R]], subject: _S) -> _R:
    """Return ``producer(subject)`` for the first rule whose predicate matches *subject*.

    Every table ends with an ``_always`` catch-all, so a match is guaranteed and only the
    matching producer runs (a producer may assume its predicate held).
    """
    return next(produce(subject) for predicate, produce in rules if predicate(subject))


def _compose_preprocess(preprocess: Callable[[str], str], converter: Callable[[str], Any] | None) -> Callable[[str], Any]:
    """Return a ``type=`` callable that runs *preprocess* on the raw token before *converter*.

    With no inferred converter (a ``str`` passthrough) the preprocess callable becomes the converter
    itself.  The wrapper copies the inner converter's ``__name__`` and ``_cmd2_enum_class`` so argparse
    error messages and enum introspection keep working through the wrap.
    """
    if converter is None:
        return preprocess

    def _convert(value: str) -> Any:
        return converter(preprocess(value))

    _convert.__name__ = getattr(converter, "__name__", "preprocess")
    enum_class = getattr(converter, "_cmd2_enum_class", None)
    if enum_class is not None:
        _convert._cmd2_enum_class = enum_class  # type: ignore[attr-defined]
    return _convert


class _ArgparseArgument:
    """Builder whose output fields mirror ``parser.add_argument(...)``'s schema."""

    def __init__(
        self,
        *,
        name: str,
        func_qualname: str,
        has_default: bool,
        param_default: Any,
        is_kw_only: bool,
        is_variadic: bool,
        inner_type: Any,
        metadata: ArgMetadata,
        is_optional: bool,
        kind: inspect._ParameterKind,
        is_base_command: bool,
        is_block_field: bool = False,
        dest_override: str | None = None,
    ) -> None:
        # signature-derived inputs (never emitted):
        self.name = name
        self.func_qualname = func_qualname
        self.has_default = has_default
        self.param_default = param_default  # the function's own default, not the argparse `default` slot
        self.is_block_field = is_block_field
        self.dest_override = dest_override
        self.is_kw_only = is_kw_only
        self.is_variadic = is_variadic
        self.inner_type = inner_type  # peeled type (after Annotated + Optional)
        self.metadata = metadata
        self.is_optional = is_optional
        self.kind = kind  # unsupported kinds (positional-only, **kwargs) are rejected by _CONSTRAINTS
        self.is_base_command = is_base_command
        # scratch filled by the type table (_apply_type):
        self.is_collection = False
        self.fixed_arity: int | None = None
        # output slots (1:1 with add_argument):
        self.is_positional = False
        self.flags: list[str] = []
        self.action: str | type[argparse.Action] | None = None
        self.nargs: _NargsValue | None = None
        self.type: Callable[[str], Any] | None = None
        self.choices: Iterable[Any] | None = None
        self.default: Any = _UNSET  # _UNSET until a default rule/action sets one
        self.required: bool = False
        self.container_factory: Callable[[list[Any]], Any] | None = None
        self.extras: dict[str, Any] = {}
        # first type-resolution error, captured so _CONSTRAINTS (not build order) picks the message:
        self.build_error: Exception | None = None
        # cross-argument facts, linked by _resolve_parameters once the whole list is built:
        self.has_following_positional = False
        # 1-based indices of the mutually_exclusive_groups= entries this parameter belongs to
        # (spec-shaped rules live in _validate_group_specs; this fact feeds the required-member row):
        self.mutex_group_indices: list[int] = []
        # Derive every output slot now; validation stays deferred to _check_constraints.
        self._apply()

    @property
    def omittable(self) -> bool:
        """Whether the argument may be absent (drives ``nargs`` and required).

        A metadata default (``Option(default=...)``) counts the same as a signature default here.
        """
        return self._effective_has_default or self.is_kw_only or self.is_optional or self.is_variadic

    @property
    def _is_list(self) -> bool:
        """Whether the declared type is ``list``/``list[T]`` -- the shape the list actions need.

        Distinct from :attr:`is_collection` (also true for ``set``/``frozenset``/``tuple``): ``append``/``extend``/
        ``append_const`` accumulate specifically into a ``list``.
        """
        return get_origin(self.inner_type) is list or self.inner_type is list

    # -- ``*args`` element facts, derived from the ``tuple[element, ...]`` wrapper that
    #    _resolve_parameters builds for a variadic parameter (only meaningful when ``is_variadic``) --

    @property
    def _var_positional_element(self) -> Any:
        """The ``*args`` element type ``T`` (``inner_type`` is the variadic ``tuple[T, ...]``)."""
        return get_args(self.inner_type)[0]

    @property
    def _var_positional_element_display(self) -> str:
        """Display name of the ``*args`` element type (for the collection-element constraint message)."""
        element = self._var_positional_element
        return str(element) if get_origin(element) is not None else _type_name(element)

    @property
    def _var_positional_element_is_collection(self) -> bool:
        """Whether the ``*args`` element is itself a collection (``list``/``set``/``frozenset``/``tuple``).

        Mirrors the collection entries in :data:`_TYPE_TABLE`; a collection element means ``*args``
        would collect a tuple of collections, which the constraint table rejects.
        """
        element = self._var_positional_element
        origin = get_origin(element)
        return (origin if origin is not None else element) in (list, set, frozenset, tuple)

    # -- the user's metadata overrides, derived read-only from ``metadata`` (consulted by the
    #    choices/action/nargs/required tables, the action phase, and the constraints) --

    @property
    def _meta_nargs(self) -> _NargsValue | None:
        """An explicit ``Argument/Option(nargs=)``, else ``None``."""
        return self.metadata.nargs if self.metadata is not None else None

    @property
    def _meta_nargs_yields_optional_single(self) -> bool:
        """Whether an explicit ``nargs`` yields at most one value that argparse won't wrap in a list.

        ``'?'`` and the ranged ``(0, 1)`` both make argparse store a bare scalar (or ``None`` when
        absent) instead of a list -- so they neither cast into a declared collection nor give a
        non-Optional scalar a value when omitted.
        """
        return self._meta_nargs == "?" or (isinstance(self._meta_nargs, tuple) and tuple(self._meta_nargs) == (0, 1))

    @property
    def _meta_choices(self) -> Iterable[Any] | None:
        """Explicit ``Argument/Option(choices=)``, else ``None``."""
        return self.metadata.choices if self.metadata is not None else None

    @property
    def _has_user_completion(self) -> bool:
        """Whether the user supplied a ``choices_provider`` / ``completer`` on the metadata."""
        if self.metadata is None:
            return False
        return self.metadata.choices_provider is not None or self.metadata.completer is not None

    @property
    def _meta_converter(self) -> Callable[[str], Any] | None:
        """An explicit ``Argument/Option(converter=)`` callable, else ``None``.

        When present it replaces the inferred ``type=`` converter and the annotation is no longer
        required to be a built-in scalar (the converter owns string -> value).
        """
        return self.metadata.converter if self.metadata is not None else None

    @property
    def _meta_preprocess(self) -> Callable[[str], str] | None:
        """An explicit ``Argument/Option(preprocess=)`` callable, else ``None``.

        When present it runs before the inferred converter (``str -> str``), so the inferred
        ``type=``/``choices``/completer are all kept and only the raw token is transformed.
        """
        return self.metadata.preprocess if self.metadata is not None else None

    @property
    def _meta_action(self) -> str | type[argparse.Action] | None:
        """An explicit ``Option(action=)`` value, else ``None`` (only ``Option`` carries one)."""
        return self.metadata.action if isinstance(self.metadata, Option) else None

    @property
    def _meta_action_is_class(self) -> bool:
        """Whether ``Option(action=)`` is a custom :class:`argparse.Action` subclass."""
        return isinstance(self._meta_action, type)

    @property
    def _meta_required(self) -> bool:
        """Whether the user set ``Option(required=True)``."""
        return self.metadata.required if isinstance(self.metadata, Option) else False

    @property
    def _const_value(self) -> Any:
        """The explicit ``Argument/Option(const=)`` value, or :data:`_UNSET` when none was given."""
        return self.metadata.const if self.metadata is not None else _UNSET

    @property
    def _has_const(self) -> bool:
        """Whether a ``const`` value was supplied (drives const-action inference and validation)."""
        return self._const_value is not _UNSET

    @property
    def _meta_default(self) -> Any:
        """The explicit ``Argument/Option(default=)`` value, or :data:`_UNSET` when none was given."""
        return self.metadata.default if self.metadata is not None else _UNSET

    @property
    def _has_meta_default(self) -> bool:
        """Whether a metadata default was supplied (treated like a signature default)."""
        return self._meta_default is not _UNSET

    @property
    def _effective_has_default(self) -> bool:
        """Whether any default applies (signature or metadata).

        A metadata default is the equivalent of a signature default:
        ``Annotated[T, Option('--x', default=v)]`` behaves the same as
        ``Annotated[T, Option('--x')] = v``.  Specifying both is a conflict (see ``_CONSTRAINTS``).
        """
        return self.has_default or self._has_meta_default

    @property
    def _effective_param_default(self) -> Any:
        """The default to emit -- signature default if present, else the metadata default, else ``None``."""
        if self.has_default:
            return self.param_default
        if self._has_meta_default:
            return self._meta_default
        return None

    @property
    def _effective_action(self) -> str | None:
        """The resolved action as a string -- explicit ``Option(action=)`` or const-inferred.

        ``None`` for the type-inferred class actions (``BooleanOptionalAction`` /
        ``_CollectionCastingAction``), which carry no :data:`_ACTION_TABLE` policy.  Reads the
        resolved ``action`` slot, so it is only meaningful after :data:`_ACTION_RULES` run.
        """
        return self.action if isinstance(self.action, str) else None

    @property
    def _policy(self) -> _ActionPolicy | None:
        """The action policy for the effective action (``None`` for no/an unknown/a class action).

        Keyed on :attr:`_effective_action` so a const-inferred ``store_const``/``append_const``
        gets the same policy treatment as an explicit one.
        """
        return _ACTION_TABLE.get(self._effective_action) if self._effective_action else None

    @property
    def _is_inferred_bool_flag(self) -> bool:
        """Whether this is a bool option using the inferred ``BooleanOptionalAction`` (no explicit action)."""
        return self.action is argparse.BooleanOptionalAction

    @property
    def _consumes_value(self) -> bool:
        """Whether this option takes a command-line value (so argparse derives/shows a metavar for it).

        A flag-style action (``store_true``/``count``/``BooleanOptionalAction``/...) takes none, so it has
        no metavar -- and some of those actions even reject a ``metavar`` kwarg.
        """
        return not self._is_inferred_bool_flag and not (self._policy is not None and self._policy.drop_converter)

    def _apply(self) -> None:
        """Build this argument by deriving each output slot."""
        self.is_positional = _first_match(_ROLE_RULES, self)
        self._apply_targets()
        self._apply_type()
        if self.build_error is not None:
            # Type unresolved: the remaining phases assume a resolved type, so stop here and let
            # _CONSTRAINTS raise the captured error.
            return
        self._apply_metadata_extras()
        self.action = _first_match(_ACTION_RULES, self)
        self.choices = _first_match(_CHOICES_RULES, self)
        if self._meta_choices is not None and self.choices is not None:
            # The choices the user wrote in source are compared by argparse *after* the type
            # converter runs, so run them through that converter to land in the same value-space
            # (Annotated[int, Option(choices=['1','2'])] -> [1, 2], so `--x 1` matches), and drop a
            # type-inferred completer (e.g. Path's) so completion is driven by these choices.
            if self.type is not None:
                self.choices = self._convert_choices(self.choices, self.type)
            if not self._has_user_completion:
                self.extras.pop("completer", None)
                self.extras.pop("choices_provider", None)
        self.nargs = _first_match(_NARGS_RULES, self)
        self.default = _first_match(_DEFAULT_RULES, self)
        self.required = _first_match(_REQUIRED_RULES, self)
        self._apply_action()

    def _convert_choices(self, choices: Iterable[Any], converter: Callable[[str], Any]) -> list[Any]:
        """Run string ``choices`` through the inferred ``type`` *converter* so they match post-conversion.

        Non-string choices are assumed already in the target type and left untouched (the simple
        converters are idempotent on them).  A choice the converter rejects is a build-time error.
        """
        converted: list[Any] = []
        for choice in choices:
            if not isinstance(choice, str):
                converted.append(choice)
                continue
            try:
                converted.append(converter(choice))
            except (ValueError, TypeError, ArithmeticError, argparse.ArgumentTypeError) as exc:
                raise TypeError(f"choice {choice!r} on '{self.name}' is not a valid '{_type_name(self.inner_type)}'.") from exc
        return converted

    # -- construction (fill output slots from the tables; no business rules here) --

    def _apply_targets(self) -> None:
        """Set ``flags`` for options (positionals keep the empty default)."""
        if self.is_positional:
            return
        self.flags = (
            list(self.metadata.names)
            if isinstance(self.metadata, Option) and self.metadata.names
            else [f"--{self.name.replace('_', '-')}"]
        )

    def _apply_type(self) -> None:
        """Copy the type table's result onto the output slots + type scratch.

        Type resolution is the only build step that can fail (unsupported type, nested collection, ...).
        Rather than raise here -- which would let build order decide the message -- the error is captured
        so :data:`_CONSTRAINTS` can rank it against more specific rules and raise the winner.
        """
        allow_unknown_entry = self.metadata.allow_unknown_entry if self.metadata is not None else False
        try:
            result = _resolve_base_type(
                self.inner_type,
                is_positional=self.is_positional,
                allow_unknown_entry=allow_unknown_entry,
                has_converter=self._meta_converter is not None,
            )
        except TypeError as exc:
            self.build_error = exc
            return
        if self._meta_converter is not None:
            # An explicit converter replaces the inferred type= and owns the value-space, so the
            # inferred choices/completer (derived from the inferred converter) no longer apply.
            self.type = self._meta_converter
            self.choices = None
        else:
            self.type = result.converter
            self.choices = result.choices
            if result.completer is not None:
                self.extras["completer"] = result.completer
        if self._meta_preprocess is not None:
            # Transform the raw token before the (inferred) converter, keeping its choices/completer.
            self.type = _compose_preprocess(self._meta_preprocess, self.type)
        # A collection coerces its parsed list into the declared container type; option bool
        # gets ``--flag/--no-flag``.  Either may be overridden by an explicit ``Option(action=)``.
        self.action = _CollectionCastingAction if result.is_collection else result.action
        self.container_factory = result.container_factory
        self.is_collection = result.is_collection
        self.fixed_arity = result.fixed_arity

    def _apply_metadata_extras(self) -> None:
        """Pass the user's display/completion metadata straight through to ``extras``."""
        if self.metadata is None:
            return
        kwargs = self.metadata.to_kwargs()
        self.extras.update({key: value for key, value in kwargs.items() if key not in _METADATA_SPECIAL_KEYS})

    def _apply_action(self) -> None:
        """Apply an explicit ``Option(action=...)`` as the final override pass.

        Sets slots only; validity (type match, unknown action) is enforced by the constraints.  A
        custom :class:`argparse.Action` has no policy -- it owns storage, so the casting wrapper is
        dropped while the inferred converter/default/required are kept.
        """
        if self._meta_action_is_class:
            # The user's class owns storage; drop the casting wrapper's container_factory kwarg.
            self.container_factory = None
            return
        policy = self._policy
        if policy is None:
            return
        if policy.drop_converter:
            # The action takes no command-line value, so drop the converter, choices, and any
            # value-completion metadata (argparse rejects them on a zero-argument action).
            self.type = None
            self.choices = None
            self.extras.pop("completer", None)
            self.extras.pop("choices_provider", None)
        if self._effective_action in _LIST_ACTIONS:
            # append/extend/append_const use argparse's native list action, not the casting action.
            self.container_factory = None
        if policy.nargs_mode is _NargsMode.CLEAR:
            self.nargs = None  # append collects one value per flag, so it takes no nargs
        if self.default is _UNSET and policy.default_factory is not None:
            # The action carries its own default (count -> 0, append/extend -> []),
            # except T | None, where None is the natural absence value.
            self.default = None if self.is_optional else policy.default_factory()
        if not self._meta_required:
            # A supported action supplies a value when absent, so it is never required by default.
            self.required = False

    def _check_constraints(self) -> None:
        """Raise for the first violated constraint (declarative validation)."""
        error = _first_match(_CONSTRAINTS, self)
        if error is not None:
            raise error

    # -- emission ------------------------------------------------------------

    def _emit(self) -> tuple[tuple[Any, ...], dict[str, Any]]:
        """Return ``(args, kwargs)`` for ``target.add_argument(*args, **kwargs)``."""
        kwargs: dict[str, Any] = dict(self.extras)
        if self.type is not None:
            kwargs["type"] = self.type
        if self.choices is not None:
            # Materialize so argparse can re-iterate it (a one-shot generator would be exhausted).
            kwargs["choices"] = list(self.choices)
        if self.action is not None:
            kwargs["action"] = self.action
        if self._has_const:
            kwargs["const"] = self._const_value
        if self.nargs is not None:
            kwargs["nargs"] = self.nargs
        if self.container_factory is not None:
            kwargs["container_factory"] = self.container_factory
        if self.default is not _UNSET:
            kwargs["default"] = self.default
        if self.required:
            kwargs["required"] = True
        dest = self.dest_override or self.name
        if self.is_positional:
            if self.dest_override is not None:
                kwargs.setdefault("metavar", self.name)
            return (dest,), kwargs
        if self.dest_override is not None and self._consumes_value:
            kwargs.setdefault("metavar", self.name.upper())
        kwargs["dest"] = dest
        return tuple(self.flags), kwargs

    def add_to(self, target: _ArgumentTarget) -> None:
        """Add this argument to *target* (a parser, group, or mutex group)."""
        args, kwargs = self._emit()
        target.add_argument(*args, **kwargs)


#: Explicit ``Option(action=...)`` policies.  Defined after :class:`_ArgparseArgument` so the
#: ``requires`` predicates can read its ``inner_type`` slot (like the other rule tables below).
_ACTION_TABLE: dict[str, _ActionPolicy] = {
    "store_true": _ActionPolicy(
        requires=lambda a: a.inner_type is bool,
        requires_label="bool",
        drop_converter=True,
        default_factory=lambda: False,
    ),
    "store_false": _ActionPolicy(
        requires=lambda a: a.inner_type is bool,
        requires_label="bool",
        drop_converter=True,
        default_factory=lambda: True,
    ),
    "count": _ActionPolicy(
        requires=lambda a: a.inner_type is int, requires_label="int", drop_converter=True, default_factory=lambda: 0
    ),
    "append": _ActionPolicy(
        requires=lambda a: a._is_list,
        requires_label="list[T]",
        nargs_mode=_NargsMode.CLEAR,
        default_factory=list,
    ),
    "extend": _ActionPolicy(
        requires=lambda a: a._is_list,
        requires_label="list[T]",
        default_factory=list,
    ),
    # const actions: shape (scalar vs list[T]) and the const value itself are validated by dedicated
    # _CONSTRAINTS rows, so requires=None here.  Both store a fixed value, so the converter/choices are
    # dropped (drop_converter) and they take no command-line value (nargs CLEAR).
    "store_const": _ActionPolicy(drop_converter=True, nargs_mode=_NargsMode.CLEAR),
    "append_const": _ActionPolicy(drop_converter=True, nargs_mode=_NargsMode.CLEAR, default_factory=list),
}


#: Role table: the first matching predicate decides positional (``True``) vs option (``False``).
_ROLE_RULES: list[_Rule[_ArgparseArgument, bool]] = [
    (lambda a: a.is_variadic, _const(True)),  # *args is always positional
    (lambda a: isinstance(a.metadata, Argument), _const(True)),  # Argument() forces positional
    (lambda a: isinstance(a.metadata, Option), _const(False)),  # Option() forces option
    (lambda a: a.is_kw_only, _const(False)),  # keyword-only -> option
    (lambda a: a.has_default, _const(False)),  # a signature default -> option (metadata-only
    # default never reaches this row: Argument/Option metadata already pinned the role above)
    (_always, _const(True)),  # otherwise positional
]

#: Action table: an explicit ``Option(action=)`` overrides the type-inferred action
#: (collection-casting / ``BooleanOptionalAction`` / none).  The action *policy* is applied later.
_ACTION_RULES: list[_Rule[_ArgparseArgument, str | type[argparse.Action] | None]] = [
    (lambda a: a._meta_action is not None, lambda a: a._meta_action),  # explicit Option(action=)
    # const with no explicit action infers the const action by shape: list[T] -> append_const,
    # scalar -> store_const.  Exception: a scalar that also sets nargs (e.g. '?') keeps the inferred
    # ``store`` for argparse's optional-value idiom (bare flag -> const, flag VALUE -> value).
    (
        lambda a: a._has_const and (a.is_collection or a._meta_nargs is None),
        lambda a: "append_const" if a.is_collection else "store_const",
    ),
    (_always, lambda a: a.action),  # the type-inferred action baseline
]

#: Choices table, in precedence order: a *user* choices_provider/completer drives completion (drop
#: static choices); otherwise an explicit metadata ``choices=`` wins (even over a type-inferred
#: completer such as ``Path``'s -- see ``_apply``, which then drops that completer); otherwise a
#: type-inferred completer drives completion (drop choices); otherwise the type-inferred choices.
_CHOICES_RULES: list[_Rule[_ArgparseArgument, Iterable[Any] | None]] = [
    (lambda a: a._has_user_completion, _const(None)),  # user completer/provider overrides choices
    (lambda a: a._meta_choices is not None, lambda a: a._meta_choices),  # explicit Argument/Option choices
    (lambda a: bool(a.extras.get("choices_provider") or a.extras.get("completer")), _const(None)),  # inferred completer
    (_always, lambda a: a.choices),  # the type-inferred choices baseline
]

#: ``nargs`` table -- the sole source of arity.  An explicit ``Argument(nargs=)`` wins; otherwise the
#: value shape decides (fixed tuple -> its arity, collection -> ``'+'``/``'*'``, optional scalar
#: positional -> ``'?'``).  Action effects (append clears nargs) are applied later by the action phase.
_NARGS_RULES: list[_Rule[_ArgparseArgument, _NargsValue | None]] = [
    (lambda a: a._meta_nargs is not None, lambda a: a._meta_nargs),  # an explicit Argument(nargs=) wins
    (lambda a: a.fixed_arity is not None, lambda a: a.fixed_arity),  # tuple[T, T] pins nargs to its arity
    (lambda a: a.is_collection and a.omittable, _const("*")),  # list/set/frozenset/tuple[T, ...] that may be empty
    (lambda a: a.is_collection, _const("+")),  # collection requiring >= 1 value
    (lambda a: a.is_positional and a.omittable, _const("?")),  # an optional scalar positional
    (_always, _const(None)),  # required scalar / any option scalar
]

#: Default-value table.  Either source (signature or metadata) feeds the parser default;
#: explicit-action defaults (count -> 0, append/extend -> []) are added later by the action phase.
_DEFAULT_RULES: list[_Rule[_ArgparseArgument, Any]] = [
    # A dataclass-block field with a field default emits SUPPRESS so the absent field stays out of the
    # namespace and the dataclass constructor supplies the default (fresh per call for default_factory).
    (lambda a: a.is_block_field and a.has_default, _const(argparse.SUPPRESS)),
    (lambda a: a._effective_has_default, lambda a: a._effective_param_default),
    # A bool option is a flag: when absent it means ``False`` (not a missing value), so -- like
    # store_true -- it carries its own default.  ``bool | None`` keeps the catch-all ``_UNSET``
    # (argparse's ``None``), the natural absence value for the Optional case.
    (lambda a: a._is_inferred_bool_flag and not a.is_optional, _const(False)),
    (_always, _const(_UNSET)),  # nothing to set (the action may still add one)
]

#: Required table.  Positionals never carry ``required=``; the action phase may relax it.
_REQUIRED_RULES: list[_Rule[_ArgparseArgument, bool]] = [
    (lambda a: a.is_positional, _const(False)),
    (lambda a: a._meta_required, _const(True)),  # explicit Option(required=True)
    (lambda a: a._effective_has_default or a.is_optional, _const(False)),  # omittable
    # A bool option is a flag that supplies its own value (False/None) when absent, so it is never
    # required without an explicit Option(required=True) -- same reasoning as the flag actions below.
    (lambda a: a._is_inferred_bool_flag, _const(False)),
    (_always, _const(True)),  # an option with no default and no ``| None`` must be supplied
]


#: ``nargs`` values that let a positional consume a variable number of tokens.
_VARIABLE_NARGS = frozenset({"?", "*", "+", argparse.REMAINDER})


# Parameter names that conflict with argparse internals and cannot be used as annotated
# parameter names (checked by _CONSTRAINTS).
_RESERVED_PARAM_NAMES = frozenset({"dest", "subcommand"})


def _const_element_type(a: _ArgparseArgument) -> Any:
    """Return the type a const must match: the element ``T`` for ``list[T]``, else the scalar itself."""
    if a.is_collection:
        args = get_args(a.inner_type)
        element = args[0] if args else str
    else:
        element = a.inner_type
    element, _ = _unwrap_optional(element)
    return element


def _const_mismatches_type(a: _ArgparseArgument) -> bool:
    """Whether a supplied ``const`` is incompatible with the declared (element) type.

    Best-effort: ``Literal``/``Enum`` checked for membership, concrete scalars by ``isinstance``;
    open types (``str``/``Any``/``object``/bool flag), unresolved types, and class actions are not checked.
    """
    if not a._has_const or a._meta_action_is_class:
        return False
    try:
        result = _resolve_base_type(_const_element_type(a))
    except TypeError:
        return False  # an unresolved element type is reported by the build_error row instead
    const = a._const_value
    if result.choices is not None:
        accepted = [c.value if isinstance(c, CompletionItem) else c for c in result.choices]
        return const not in accepted
    converter = result.converter
    if converter is int:  # bool is an int subclass but not a valid int const
        return type(const) is not int
    if converter is float:
        return not (isinstance(const, (int, float)) and not isinstance(const, bool))
    if converter is decimal.Decimal:
        return not isinstance(const, decimal.Decimal)
    if converter is Path:
        return not isinstance(const, Path)
    if _const_element_type(a) is str:  # a str parameter stores the const verbatim, so it must be a str
        return not isinstance(const, str)
    return False  # Any / object / unannotated / bool flag: genuinely untyped, nothing to validate


# The single validity table, evaluated by :func:`_resolve_parameters` once every argument is built
# and its cross-argument facts are linked.
_CONSTRAINTS: list[_Rule[_ArgparseArgument, Exception | None]] = [
    (
        # Signature shape: positional-only parameters cannot be passed by keyword, which is how
        # the decorator forwards parsed values.
        lambda a: a.kind == inspect.Parameter.POSITIONAL_ONLY,
        lambda a: TypeError(
            f"Parameter {a.name!r} in {a.func_qualname} is positional-only, "
            "which is not supported by @with_annotated because parameters are passed as keyword arguments."
        ),
    ),
    (
        # Signature shape: **kwargs has no fixed names to map command-line arguments onto.
        lambda a: a.kind == inspect.Parameter.VAR_KEYWORD,
        lambda a: TypeError(
            f"Parameter '**{a.name}' in {a.func_qualname} is variadic keyword (**kwargs), "
            "which is not supported by @with_annotated because there is no native way to map "
            "command-line arguments onto arbitrary keyword names."
        ),
    ),
    (
        # A name argparse reserves on the namespace; raised as ValueError (a bad name value).
        lambda a: a.name in _RESERVED_PARAM_NAMES,
        lambda a: ValueError(
            f"Parameter name {a.name!r} in {a.func_qualname} is reserved by argparse "
            f"and cannot be used as an annotated parameter name."
        ),
    ),
    (
        # *args (is_variadic) is always a plain positional, so Option() metadata is contradictory.
        lambda a: a.is_variadic and isinstance(a.metadata, Option),
        lambda a: TypeError(
            f"Parameter '*{a.name}' in {a.func_qualname} uses Option() metadata, but *args is "
            f"always a positional argument. Use Argument() metadata instead."
        ),
    ),
    (
        # *args is fixed at nargs='*'; Argument(nargs=...) cannot override it.
        lambda a: a.is_variadic and a._meta_nargs is not None,
        lambda a: TypeError(
            f"Parameter '*{a.name}' in {a.func_qualname} sets nargs={a._meta_nargs!r} via Argument(), "
            f"but *args always accepts zero or more values (nargs='*') and its arity cannot be overridden."
        ),
    ),
    (
        # For *args the annotation is the element type; a collection element would mean a tuple of
        # collections
        lambda a: a.is_variadic and a._var_positional_element_is_collection,
        lambda a: TypeError(
            f"Parameter '*{a.name}' in {a.func_qualname} is annotated with the collection type "
            f"'{a._var_positional_element_display}'. For *args the annotation is the type of each "
            f"value, not the collected tuple, so '*{a.name}: {a._var_positional_element_display}' "
            f"would mean a tuple of '{a._var_positional_element_display}'. Annotate the element type "
            f"instead (e.g. '*{a.name}: str'); values are always collected into a tuple."
        ),
    ),
    (
        lambda a: a.is_kw_only and isinstance(a.metadata, Argument),
        lambda a: TypeError(
            f"Parameter '{a.name}' in {a.func_qualname} is keyword-only but uses Argument() metadata, "
            f"which marks it as a positional argument. Keyword-only parameters always become options; "
            f"use Option() metadata (or omit the metadata) instead."
        ),
    ),
    (
        # const is meaningless on a positional: argparse rejects store_const/append_const there and
        # ignores const with nargs='?'. A positional only ever gets its command-line value or its default.
        lambda a: a._has_const and a.is_positional,
        lambda a: TypeError(
            f"Parameter '{a.name}' in {a.func_qualname} sets const=, but const is not supported on a "
            f"positional argument (argparse ignores it). Use a default value or '{_type_name(a.inner_type)} | None' "
            f"for the absent value, or use Option() to make it a flag."
        ),
    ),
    (
        # const only goes with the const actions.  Limited to *known* actions so an unsupported one
        # (e.g. 'store') falls through to the "not supported" row.  Class actions own const, so exempt.
        lambda a: (
            isinstance(a._meta_action, str)
            and a._has_const
            and a._meta_action in _ACTION_TABLE
            and a._meta_action not in _CONST_ACTIONS
        ),
        lambda a: TypeError(
            f"Option(const=...) on '{a.name}' cannot be combined with action={a._meta_action!r}. "
            f"const is only valid with store_const or append_const (or omit action= to infer it from the type)."
        ),
    ),
    (
        # An explicit const action with no const value: argparse would store None on presence.
        lambda a: a._meta_action in _CONST_ACTIONS and not a._has_const,
        lambda a: TypeError(f"Option(action={a._meta_action!r}) on '{a.name}' needs a const value. Pass Option(const=...)."),
    ),
    (
        # append_const accumulates into list[T]; reject it (explicit or inferred) on a non-list type.
        lambda a: a._effective_action == "append_const" and not a._is_list,
        lambda a: TypeError(
            f"const on '{a.name}' accumulates into list[T] (append_const), but '{_type_name(a.inner_type)}' "
            f"is not a list. Use list[T] to accumulate, or a scalar type to store a single value (store_const)."
        ),
    ),
    (
        # store_const stores a single value, so a list/collection annotation is the wrong shape.
        lambda a: a._effective_action == "store_const" and a.is_collection,
        lambda a: TypeError(
            f"const on '{a.name}' stores a single value (store_const), but '{_type_name(a.inner_type)}' is a "
            f"collection. Use a scalar type, or list[T] to accumulate the const on each flag (append_const)."
        ),
    ),
    (
        # A store_const flag falls back to its default when absent; without a default (and not Optional)
        # that absent value is None, violating the declared type.
        lambda a: a._effective_action == "store_const" and not (a._effective_has_default or a.is_optional),
        lambda a: TypeError(
            f"store_const flag '{a.name}' has no value when absent: give it a default or annotate it as "
            f"'{_type_name(a.inner_type)} | None'."
        ),
    ),
    (
        # action='store' + const is ambiguous: 'store' ignores const unless paired with nargs='?'.
        # Placed before the const-type-mismatch row so the ambiguity wins even on a mistyped const.
        lambda a: a._meta_action == "store" and a._has_const,
        lambda a: TypeError(
            f"Option(action='store', const=...) on '{a.name}' is ambiguous: 'store' ignores const unless "
            f"combined with nargs='?', so the intent cannot be inferred. Use action='store_const' for a "
            f"value-less const flag, or Option(nargs='?', const=...) (no action=) for an optional value."
        ),
    ),
    (
        # A supplied const must match the declared (element) type, keeping the parsed-value guarantee.
        _const_mismatches_type,
        lambda a: TypeError(
            f"const={a._const_value!r} on '{a.name}' does not match the declared type '{_type_name(_const_element_type(a))}'."
        ),
    ),
    (
        # An unknown string action (class actions pass through verbatim).  Checked before the
        # collection-shape row below so an unsupported action reports "not supported", not the
        # misleading "cannot be combined with a collection type".
        lambda a: isinstance(a._meta_action, str) and a._meta_action not in _ACTION_TABLE,
        lambda a: TypeError(
            f"Option(action={a._meta_action!r}) is not supported by @with_annotated. Supported actions are "
            f"store_true, store_false, count, append, extend, store_const, and append_const."
        ),
    ),
    (
        # A (known) string action on a collection type must be one of the list actions; a class action
        # owns storage and is exempt.
        lambda a: isinstance(a._meta_action, str) and a.is_collection and a._meta_action not in _LIST_ACTIONS,
        lambda a: TypeError(
            f"Option(action={a._meta_action!r}) cannot be combined with a collection type, which installs its "
            f"own action. Use action='append'/'extend'/'append_const' with list[T], or drop action= to collect via nargs."
        ),
    ),
    (
        # A user completer/choices_provider on a value-less action (store_true/false, count,
        # store_const, append_const) has nothing to complete.  Fail loud (raw cmd2 raises too) rather
        # than drop it silently.  Inferred completers (e.g. Path's) are still dropped; class actions exempt.
        lambda a: a._policy is not None and a._policy.drop_converter and a._has_user_completion,
        lambda a: TypeError(
            f"completer=/choices_provider= on '{a.name}' cannot be used with action={a._effective_action!r}, "
            f"which takes no value from the command line, so there is nothing to tab-complete. Remove the "
            f"completer/choices_provider, or use a value-consuming action."
        ),
    ),
    (
        # converter= replaces the conversion; preprocess= composes with the inferred one. They are two
        # ways to set the same thing, so supplying both is ambiguous -- fold the preprocessing into the
        # converter, which already receives the raw token.
        lambda a: a._meta_converter is not None and a._meta_preprocess is not None,
        lambda a: TypeError(
            f"converter= and preprocess= on '{a.name}' cannot be combined; a converter receives the raw "
            f"token, so fold the preprocessing into it."
        ),
    ),
    (
        # A converter/preprocess on a value-less action (store_true/false, count, store_const,
        # append_const) has no command-line value to convert.
        lambda a: (
            a._policy is not None
            and a._policy.drop_converter
            and (a._meta_converter is not None or a._meta_preprocess is not None)
        ),
        lambda a: TypeError(
            f"converter=/preprocess= on '{a.name}' cannot be used with action={a._effective_action!r}, "
            f"which takes no value from the command line, so there is nothing to convert. Remove the "
            f"converter/preprocess, or use a value-consuming action."
        ),
    ),
    (
        lambda a: a._policy is not None and a._policy.requires is not None and not a._policy.requires(a),
        lambda a: TypeError(
            f"Option(action={a._meta_action!r}) yields {a._policy.requires_label if a._policy else ''}; "
            f"annotate the parameter as {a._policy.requires_label if a._policy else ''}."
        ),
    ),
    (
        lambda a: a._meta_action == "append" and a._meta_nargs is not None,
        _const(
            TypeError(
                "Option(action='append') collects one value per flag and cannot set nargs; "
                "use action='extend' to take multiple values per flag."
            )
        ),
    ),
    (
        lambda a: a._meta_nargs is not None and a.fixed_arity is not None and a._meta_nargs != a.fixed_arity,
        lambda a: TypeError(
            f"nargs={a._meta_nargs!r} conflicts with the fixed arity of "
            f"'{_type_name(a.inner_type)}' (expected nargs={a.fixed_arity})."
        ),
    ),
    (
        # A user nargs that collects a list on a non-collection annotation.  A nargs yields a list
        # when it is '*'/'+'/REMAINDER, an int >= 1 (argparse returns [value] even for nargs=1), or a
        # cmd2 ranged tuple other than (0, 1)
        lambda a: (
            a._meta_nargs is not None
            and not a.is_collection
            and (
                a._meta_nargs in ("*", "+", argparse.REMAINDER)
                or (isinstance(a._meta_nargs, int) and a._meta_nargs >= 1)
                or (isinstance(a._meta_nargs, tuple) and tuple(a._meta_nargs) != (0, 1))
            )
        ),
        lambda a: TypeError(
            f"nargs={a._meta_nargs!r} produces a list of values, but the annotation "
            f"'{_type_name(a.inner_type)}' is not a collection type. "
            f"Use list[T], tuple[T, ...], set[T], or frozenset[T] (optionally with | None) to match."
        ),
    ),
    (
        # An explicit '?' / (0, 1) on a collection yields a single value (or None), which the
        # collection-casting action cannot wrap into the declared list/set/frozenset/tuple.
        lambda a: a.is_collection and a._meta_nargs_yields_optional_single,
        lambda a: TypeError(
            f"parameter '{a.name}' in {a.func_qualname} sets nargs={a._meta_nargs!r} on the collection "
            f"type '{_type_name(a.inner_type)}', but that yields at most a single value, which argparse "
            f"does not wrap in a list -- so it cannot be cast to the declared collection. Use nargs='*' or "
            f"'+' (optionally with a default or '| None'), or drop the explicit nargs."
        ),
    ),
    (
        lambda a: a.is_positional and a.omittable and isinstance(a.nargs, int),
        lambda a: TypeError(
            f"A fixed-arity positional (nargs={a.nargs}) cannot be optional; argparse always "
            f"requires it. Drop the default or '| None', make it an option (give it a default without "
            f"Argument()), or use a variable-arity type such as tuple[T, ...]."
        ),
    ),
    (
        # Conflict: both the signature and the metadata supplied a default.  These are two
        # sources of truth for the same value; refuse rather than silently pick a winner.
        lambda a: a.has_default and a._has_meta_default,
        lambda a: TypeError(
            f"parameter '{a.name}' in {a.func_qualname} has a default in both the "
            f"{'dataclass field' if a.is_block_field else f'function signature ({a.param_default!r})'} "
            f"and the metadata ({a._meta_default!r}); specify it in only one place."
        ),
    ),
    (
        # argparse.SUPPRESS removes the attribute from the parsed namespace when absent, so the
        # function would be called without the kwarg it expects.  Reject from either source.
        lambda a: a._effective_has_default and a._effective_param_default == argparse.SUPPRESS,
        lambda a: TypeError(
            f"parameter '{a.name}' in {a.func_qualname} uses argparse.SUPPRESS as a default, which is "
            f"not supported by @with_annotated: SUPPRESS removes '{a.name}' from the parsed namespace "
            f"when absent, but the function expects it as a keyword argument. Use a real default or "
            f"annotate the type as '{_type_name(a.inner_type)} | None'."
        ),
    ),
    (
        # A block field's default must live on the dataclass field: a field default emits SUPPRESS (see
        # _DEFAULT_RULES) so the dataclass constructor produces the value fresh on every call.
        lambda a: a.is_block_field and not a.has_default and a.default is not _UNSET,
        lambda a: TypeError(
            f"ArgumentBlock field '{a.name}' in {a.func_qualname} would take its default ({a.default!r}) from "
            f"the option metadata or its action, but a block field's default must live on the dataclass field "
            f"so the constructor produces it fresh on every call. Put it on the field instead -- a plain "
            f"default for an immutable value (e.g. '{a.name}: ... = False'), or 'field(default_factory=...)' "
            f"for a mutable one (e.g. '= field(default_factory=list)')."
        ),
    ),
    (
        lambda a: (
            a._effective_has_default
            and a._effective_param_default is None
            and not a.is_optional
            # A block field carries a placeholder None default here; its real default lives in the dataclass
            # (emitted as SUPPRESS), so this signature-default check does not apply.
            and not a.is_block_field
            and a.inner_type not in (object, Any, inspect.Parameter.empty)
        ),
        lambda a: TypeError(
            f"parameter '{a.name}' in {a.func_qualname} declared as '{_type_name(a.inner_type)}' has a "
            f"default of None, but '{_type_name(a.inner_type)}' is not Optional, so omitting it would pass None "
            f"and violate the type hint. Annotate it as '{_type_name(a.inner_type)} | None' to allow None, or "
            f"give it a non-None default."
        ),
    ),
    (
        # Cross-argument: a variable-arity positional must be last, else argparse cannot split
        # tokens unambiguously (``def f(self, a: str, *rest: str)`` is fine -- the variadic is last).
        # ``has_following_positional`` is linked by _resolve_parameters before this table runs.
        lambda a: (
            a.is_positional and a.has_following_positional and (a.nargs in _VARIABLE_NARGS or isinstance(a.nargs, tuple))
        ),
        lambda a: TypeError(
            f"Parameter '{a.name}' in {a.func_qualname} has variable arity (nargs={a.nargs!r}) "
            f"but is followed by another positional argument, so argparse cannot assign command-line "
            f"tokens unambiguously. Make it the last positional, give the following positional(s) a "
            f"default, or make them options."
        ),
    ),
    (
        # base_command only: its parameters become subcommand-level options, so a positional
        # conflicts with subcommand dispatch. ``is_base_command`` is set from the decorator's role;
        # non-base arguments skip these rows on the first conjunct.
        lambda a: a.is_base_command and a.is_positional and not isinstance(a.metadata, Argument),
        lambda a: TypeError(
            f"Parameter '{a.name}' in {a.func_qualname} is positional, "
            f"which conflicts with subcommand parsing. "
            f"Use a keyword-only parameter (after *) or give it a default value."
        ),
    ),
    (
        lambda a: a.is_base_command and isinstance(a.metadata, Argument),
        lambda a: TypeError(
            f"Parameter '{a.name}' in {a.func_qualname} uses Argument() metadata, "
            f"which creates a positional argument that conflicts with subcommand parsing."
        ),
    ),
    (
        # Cross-config: a required member is incompatible with a mutex group -- only one member is
        # supplied, so the others arrive as None (violating its non-Optional type), and argparse forbids
        # it.  This is an argument-typing rule (required-ness comes from the annotation), so it lives here
        # rather than with the group graph construction.
        lambda a: bool(a.mutex_group_indices) and (a.required or (a.is_positional and not a.omittable)),
        lambda a: ValueError(
            f"parameter {a.name!r} in mutually exclusive group {a.mutex_group_indices[0]} is required (no default "
            f"and not Optional), but mutually exclusive group members must be optional because "
            f"only one is supplied on the command line and the others arrive as None. "
            f"Give it a default or annotate it as 'T | None'."
        ),
    ),
    (
        # Type resolution failed during build (captured by _apply_type so build order does not pick
        # the message).  Raised last, so a more specific rule above wins; otherwise the raw type error.
        lambda a: a.build_error is not None,
        lambda a: a.build_error,
    ),
    (_always, _const(None)),  # no violation
]


# ---------------------------------------------------------------------------
# Signature → Parser conversion
# ---------------------------------------------------------------------------


# Parameters handled specially by the decorator and not added to the parser.  The first positional
# parameter (self/cls) is always skipped by position; these cover additional decorator-managed names.
_SKIP_PARAMS = frozenset({constants.NS_ATTR_SUBCOMMAND_FUNC, constants.NS_ATTR_STATEMENT})

NS_ATTR_BASE_ARGS = constants.cmd2_public_attr_name("base_args")
NS_ATTR_PARENT_ARGS = constants.cmd2_public_attr_name("parent_args")


def _base_args_marker_dest(dc_type: type) -> str:
    """Namespace dest of the parse-time presence marker for a ``cmd2_base_args`` block of *dc_type*.

    Keyed by ``id()`` of the block type: ``cmd2_base_args`` and ``cmd2_parent_args`` referencing the same
    block class share the exact type object, so the dest a parent stamps matches the one a child checks,
    and a child inheriting the wrong type sees its marker absent (a reliable mismatch error).
    """
    return constants.cmd2_private_attr_name(f"base_args_{id(dc_type):x}")


def _shared_field_dest(dc_type: type, field_name: str) -> str:
    """Namespace dest for a shared-block (``cmd2_base_args``/``cmd2_parent_args``) field, qualified by type.

    Keyed by ``id()`` of the block type like the presence marker, so a parent's ``cmd2_base_args`` and a
    child's ``cmd2_parent_args`` of the same class agree on the dest while two different block types that
    share a field name get distinct dests -- they never collide on one attribute in the shared namespace.
    """
    return constants.cmd2_private_attr_name(f"shared_{id(dc_type):x}_{field_name}")


def _check_group_members_exist(
    by_name: dict[str, _ArgparseArgument],
    block_param_names: set[str],
    func_qualname: str,
    *specs: tuple[Group, ...] | None,
) -> None:
    """Reject a ``Group`` member that names no command-line argument, once every argument is built.

    :func:`_validate_group_specs` cannot do this at decoration time: an ArgumentBlock's arguments are its
    dataclass fields, which only resolving the block's type hint would reveal, and resolving hints there
    would break forward-referenced annotations.  So this is where a bad member name is finally caught.

    Naming the block parameter itself is the likely mistake -- it reads natural but the parameter is expanded
    away and has no argument -- so it gets its own message pointing at the fields to name instead.
    """
    for spec_group in specs:
        for spec in spec_group or ():
            for name in spec.members:
                if name in by_name:
                    continue
                if name in block_param_names:
                    raise ValueError(
                        f"group in {func_qualname} references {name!r}, which is an ArgumentBlock parameter "
                        f"and not a command-line argument: a block is expanded into one argument per field, "
                        f"so name those fields instead."
                    )
                raise ValueError(
                    f"group in {func_qualname} references {name!r}, which is not a command-line argument. "
                    f"Members name arguments: a plain parameter is its own argument, and an ArgumentBlock's "
                    f"arguments are its field names."
                )


def _link_mutex_group_membership(
    by_name: dict[str, _ArgparseArgument],
    mutually_exclusive_groups: tuple[Group, ...] | None,
) -> None:
    """Append each mutex group's 1-based index to its member arguments' ``mutex_group_indices``.

    This membership is the fact behind the required-member constraint row.  Member references are
    resolved to built arguments upstream by :func:`_check_group_members_exist` before this runs.
    """
    if not mutually_exclusive_groups:
        return
    for index, spec in enumerate(mutually_exclusive_groups, start=1):
        for name in spec.members:
            by_name[name].mutex_group_indices.append(index)


def _resolve_func_hints(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]:
    """Resolve the type hints for the parameters that become arguments.

    The bound first parameter (self/cls), the injected ``skip_params``, and the ``return`` annotation
    never become arguments, so they are dropped before resolution.  Forward references resolve against
    the *original* function's module so a ``functools.wraps`` wrapper still resolves correctly.
    """
    sig = inspect.signature(func)
    ignored = {next(iter(sig.parameters), None), "return", *skip_params}
    ignored.discard(None)
    relevant_annotations = {name: ann for name, ann in getattr(func, "__annotations__", {}).items() if name not in ignored}
    unwrapped = inspect.unwrap(func)
    try:
        return get_type_hints(
            types.SimpleNamespace(__annotations__=relevant_annotations),
            globalns=getattr(unwrapped, "__globals__", {}),
            include_extras=True,
        )
    except (NameError, AttributeError, TypeError) as exc:
        raise TypeError(
            f"Failed to resolve type hints for {func.__qualname__}. Ensure all annotations use valid, importable types."
        ) from exc


def _is_argument_block(hint: Any, param: inspect.Parameter) -> TypeGuard[type[ArgumentBlock]]:
    """Whether a parameter is a bare [`ArgumentBlock`][cmd2.annotated.ArgumentBlock] whose fields expand flat.

    Only a by-keyword-passable parameter (positional-or-keyword or keyword-only) annotated with a bare
    ``ArgumentBlock`` subclass qualifies.  Membership -- not "is a dataclass" -- is the trigger, so a plain
    ``@dataclass`` is never captured; the block must still be a ``@dataclass`` to have fields to expand,
    which :func:`_expand_dataclass_block` enforces with a clear message.
    """
    return (
        param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
        and isinstance(hint, type)
        and issubclass(hint, ArgumentBlock)
    )


def _require_magic_block(name: str, hint: Any, param: inspect.Parameter, func_qualname: str) -> type[ArgumentBlock]:
    """Return the ``ArgumentBlock`` subclass annotating a ``cmd2_base_args``/``cmd2_parent_args`` parameter.

    Both magic parameters must be a bare ``ArgumentBlock`` subclass, the same form a regular block uses.  A
    wrong annotation (a plain value, or a block wrapped in ``Annotated``/``Optional``/a union) is rejected
    here with a clear message rather than being silently treated as an ordinary argument.
    """
    if _is_argument_block(hint, param):
        return hint
    raise TypeError(
        f"Parameter '{name}' in {func_qualname} must be annotated with a bare ArgumentBlock subclass "
        f"(e.g. '{name}: SharedOpts'); it shares a block between a command and its subcommands."
    )


def _find_argument_block(hint: Any) -> type[ArgumentBlock] | None:
    """Return an [`ArgumentBlock`][cmd2.annotated.ArgumentBlock] subclass found anywhere within *hint*, else ``None``.

    Used to reject a block that is not the bare annotation: an ``ArgumentBlock`` nested in ``Annotated`` /
    ``Optional`` / a union is ambiguous (an optional block? a union of blocks? a single value?), so it is
    rejected rather than silently mishandled.  A bare block is detected by :func:`_is_argument_block` first,
    so this only fires for the wrapped/misused forms.
    """
    if isinstance(hint, type) and issubclass(hint, ArgumentBlock):
        return hint
    for arg in get_args(hint):
        found = _find_argument_block(arg)
        if found is not None:
            return found
    return None


def _init_field_names(dc_type: Any) -> list[str]:
    """Names of a dataclass's ``init`` fields in definition order (the flat argument names of a block)."""
    return [f.name for f in fields(dc_type) if f.init]


def _reject_field_shadowing_block_param(dc_type: type, param_name: str, func_qualname: str) -> None:
    """Reject a block whose field name equals the block parameter name.

    The field's flat argument and the parameter that receives the block instance would share one
    keyword-argument key, so a parsed value and a directly-supplied instance become indistinguishable.
    """
    if param_name in _init_field_names(dc_type):
        raise TypeError(
            f"ArgumentBlock '{dc_type.__name__}' field {param_name!r} collides with the block parameter "
            f"name '{param_name}' in {func_qualname}; rename the field so its command-line argument does not "
            f"shadow the parameter that receives the block instance."
        )


def _expand_dataclass_block(
    dc_type: type,
    *,
    func_qualname: str,
    base_command: bool,
    shared: bool = False,
) -> list[_ArgparseArgument]:
    """Expand a dataclass block's ``init`` fields into flat ``_ArgparseArgument`` builders.

    Each field maps to one argument named after the field (flat: field name == argument name).  The
    field's ``Annotated[T, Option/Argument]`` metadata and its default (``default`` or ``default_factory``)
    drive the argument exactly as a top-level parameter of the same shape would.

    ``shared`` marks a ``cmd2_base_args`` block: its fields parse into a type-qualified dest (the flag is
    unchanged) so blocks of different types never collide on one attribute in the shared subcommand
    namespace; the matching ``cmd2_parent_args`` reads the same qualified dest.
    """
    if not is_dataclass(dc_type):
        raise TypeError(
            f"ArgumentBlock subclass '{dc_type.__name__}' must be decorated with @dataclass so it has fields "
            f"to expand into command-line arguments."
        )
    try:
        field_hints = get_type_hints(dc_type, include_extras=True)
    except (NameError, AttributeError, TypeError) as exc:
        raise TypeError(
            f"Failed to resolve type hints for ArgumentBlock '{dc_type.__name__}'. "
            f"Ensure all field annotations use valid, importable types."
        ) from exc
    # An InitVar is a constructor parameter that dataclasses.fields() omits, so it would never become a
    # command-line argument yet the constructor still requires it.  Reject it up front with a clear message
    # rather than letting reconstruction fail later with an opaque "missing argument" TypeError.
    init_field_names = set(_init_field_names(dc_type))
    init_only = [name for name in list(inspect.signature(dc_type.__init__).parameters)[1:] if name not in init_field_names]
    if init_only:
        raise TypeError(
            f"ArgumentBlock '{dc_type.__name__}' has InitVar field(s) {sorted(init_only)}, which @with_annotated "
            f"cannot expand into command-line arguments; use a regular field instead."
        )
    expanded: list[_ArgparseArgument] = []
    for f in fields(dc_type):
        if not f.init:
            continue  # field(init=False) is not a constructor argument, so it has no command-line value
        # A field whose name matches a cmd2-injected namespace attribute (e.g. cmd2_statement) would be
        # silently overwritten by cmd2 at parse time, so its value could never reach the constructor.
        if f.name in _SKIP_PARAMS:
            raise TypeError(
                f"ArgumentBlock '{dc_type.__name__}' field {f.name!r} collides with a reserved cmd2 namespace "
                f"attribute; rename the field."
            )
        inner_type, metadata, is_optional = _normalize_annotation(field_hints[f.name])
        # The dataclass owns its own defaults: a field with any default (default or default_factory) emits
        # SUPPRESS (see _DEFAULT_RULES) and is filled by the constructor, so the value is never read here.
        has_default = f.default is not MISSING or f.default_factory is not MISSING
        expanded.append(
            _ArgparseArgument(
                name=f.name,
                func_qualname=func_qualname,
                has_default=has_default,
                param_default=None,
                is_kw_only=False,
                is_variadic=False,
                inner_type=inner_type,
                metadata=metadata,
                is_optional=is_optional,
                kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
                is_base_command=base_command,
                is_block_field=True,
                dest_override=_shared_field_dest(dc_type, f.name) if shared else None,
            )
        )
    return expanded


class _BlockSpec(NamedTuple):
    """How to reconstruct one dataclass-block parameter from the parsed namespace at call time."""

    dc_type: type[ArgumentBlock]
    field_names: list[str]
    inherited: bool  # True for a cmd2_parent_args block (inherited from an ancestor's cmd2_base_args)
    shared: bool = False  # cmd2_base_args/cmd2_parent_args: fields live under a type-qualified dest


def _block_field_dest(spec: _BlockSpec, field_name: str) -> str:
    """Namespace dest a block field lands under: type-qualified for a shared block, else the field name."""
    return _shared_field_dest(spec.dc_type, field_name) if spec.shared else field_name


def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]:
    """Map each dataclass-block parameter name to its :class:`_BlockSpec`.

    Used by the runtime handler to reconstruct the dataclass instance from the parsed namespace.  A
    ``cmd2_parent_args`` parameter is a bare block too, so it is detected the same way and flagged
    ``inherited`` (its fields live in the shared namespace, populated by an ancestor's ``cmd2_base_args``).
    """
    hints = _resolve_func_hints(func, skip_params=skip_params)
    blocks: dict[str, _BlockSpec] = {}
    for name, param in list(inspect.signature(func).parameters.items())[1:]:
        if name in skip_params:
            continue
        if _is_argument_block(hints.get(name), param):
            hint = hints[name]
            blocks[name] = _BlockSpec(
                hint,
                _init_field_names(hint),
                inherited=name == NS_ATTR_PARENT_ARGS,
                shared=name in (NS_ATTR_BASE_ARGS, NS_ATTR_PARENT_ARGS),
            )
    return blocks


def _lazy_block_resolver(
    func: Callable[..., Any],
    *,
    base_accepted: set[str],
    skip_params: frozenset[str],
) -> Callable[[], tuple[dict[str, _BlockSpec], set[str]]]:
    """Return a zero-arg callable yielding ``(blocks, accepted)``, computed once and cached.

    Detecting dataclass blocks resolves type hints, which the decorator defers so forward-referenced
    annotations still decorate.  The first invocation runs after the class is fully defined, so the
    hints resolve safely; the result (the blocks and the namespace ``accepted`` set widened to the
    expanded field names) is cached for subsequent calls.
    """

    @functools.cache
    def resolve() -> tuple[dict[str, _BlockSpec], set[str]]:
        blocks = _dataclass_blocks(func, skip_params=skip_params)
        accepted = set(base_accepted)
        for block_name, spec in blocks.items():
            accepted.discard(block_name)
            accepted.update(_block_field_dest(spec, name) for name in spec.field_names)
        return blocks, accepted

    return resolve


def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str, _BlockSpec], ns: Any) -> None:
    """Fold expanded field values in ``func_kwargs`` back into their dataclass-block instances, in place.

    A block already present in ``func_kwargs`` (e.g. a direct method call passing the instance) is left
    untouched -- only the namespace-derived field values are gathered and the instance is built.

    An inherited (``cmd2_parent_args``) block is only valid when an ancestor command declared a matching
    ``cmd2_base_args`` block: that parent stamps a presence marker into the shared namespace at parse time,
    so its absence here means no ancestor declared the block.  Reconstructing anyway would hand back an
    all-defaults instance and hide the misconfiguration, so raise instead.
    """
    for block_name, spec in blocks.items():
        # The expanded field values always come out of func_kwargs (keyed by their namespace dest, which is
        # type-qualified for a shared block)
        field_values = {
            field: func_kwargs.pop(dest)
            for field in spec.field_names
            if (dest := _block_field_dest(spec, field)) in func_kwargs
        }
        if block_name in func_kwargs:
            # A block instance is already present (e.g. a programmatic call passing it directly); use it as-is.
            continue
        if spec.inherited and not getattr(ns, _base_args_marker_dest(spec.dc_type), False):
            raise TypeError(
                f"Parameter '{block_name}' inherits the ArgumentBlock '{spec.dc_type.__name__}' from a parent "
                f"command, but no ancestor command declares a matching 'cmd2_base_args: {spec.dc_type.__name__}'. "
                f"Add that parameter to the parent command so its fields are parsed into the shared namespace."
            )
        func_kwargs[block_name] = spec.dc_type(**field_values)


def _resolve_parameters(
    func: Callable[..., Any],
    *,
    skip_params: frozenset[str] = _SKIP_PARAMS,
    base_command: bool = False,
    groups: tuple[Group, ...] | None = None,
    mutually_exclusive_groups: tuple[Group, ...] | None = None,
) -> tuple[list[_ArgparseArgument], set[type]]:
    """Resolve a function signature into argparse-argument builders and inheritable-block types.

    Returns ``(resolved, base_args_types)`` where ``base_args_types`` are the ``ArgumentBlock`` types of
    any ``cmd2_base_args`` parameter.  The build path stamps a parse-time presence marker for each so a
    descendant's ``cmd2_parent_args`` can tell, at call time, that the block was actually declared.

    ``base_command`` marks each argument's context for the base-command :data:`_CONSTRAINTS` rows and
    drives the function-level ``cmd2_subcommand_func`` check below.  ``mutually_exclusive_groups``
    membership is linked onto each argument as the fact behind the required-member constraint row;
    the spec-shaped group rules live in :func:`_validate_group_specs`, which runs before this.
    """
    sig = inspect.signature(func)
    # Function-level check (not a per-argument _CONSTRAINTS row): a base command dispatches through
    # cmd2_subcommand_func, so it must exist.  Here so it also fires when the function has zero parameters.
    if base_command and constants.NS_ATTR_SUBCOMMAND_FUNC not in sig.parameters:
        raise TypeError(
            f"with_annotated(base_command=True) requires a '{constants.NS_ATTR_SUBCOMMAND_FUNC}' "
            f"parameter in {func.__qualname__}"
        )
    hints = _resolve_func_hints(func, skip_params=skip_params)

    resolved: list[_ArgparseArgument] = []
    inherited_field_names: set[str] = set()
    base_args_types: set[type] = set()
    block_param_names: set[str] = set()

    # Skip the first parameter by position (self/cls for methods)
    params = list(sig.parameters.items())
    if params:
        params = params[1:]

    for name, param in params:
        if name in skip_params:
            continue

        block_hint = hints.get(name)
        if name == NS_ATTR_PARENT_ARGS:
            inherited = _require_magic_block(name, block_hint, param, func.__qualname__)
            _reject_field_shadowing_block_param(inherited, name, func.__qualname__)
            if param.default is not inspect.Parameter.empty:
                raise TypeError(
                    f"Parameter '{name}' in {func.__qualname__} inherits its block from a parent command and "
                    f"cannot have a default value; remove the default."
                )
            inherited_field_names.update(_init_field_names(inherited))
            block_param_names.add(name)
            continue

        # An ArgumentBlock-typed parameter is an argument block: expand its fields in place (flat) instead of
        # building a single argument for the container, so the fields keep their signature-order position.
        if _is_argument_block(block_hint, param):
            if param.default is not inspect.Parameter.empty:
                raise TypeError(
                    f"Parameter '{name}' in {func.__qualname__} is an ArgumentBlock and cannot have a default "
                    f"value; the dataclass is the single source of truth for its fields' defaults. Remove the default."
                )
            if name == NS_ATTR_BASE_ARGS:
                base_args_types.add(block_hint)
            # Expand first so its @dataclass-ness check runs before we read field names for the next guard.
            # A cmd2_base_args block is shared down the chain: its fields use a type-qualified dest.
            expanded = _expand_dataclass_block(
                block_hint, func_qualname=func.__qualname__, base_command=base_command, shared=name == NS_ATTR_BASE_ARGS
            )
            _reject_field_shadowing_block_param(block_hint, name, func.__qualname__)
            block_param_names.add(name)
            resolved.extend(expanded)
            continue
        # The magic name requires a bare ArgumentBlock; a non-block annotation on it is a clear mistake.
        if name == NS_ATTR_BASE_ARGS:
            _require_magic_block(name, block_hint, param, func.__qualname__)
        wrapped_block = _find_argument_block(block_hint)
        if wrapped_block is not None:
            raise TypeError(
                f"Parameter '{name}' in {func.__qualname__} uses the ArgumentBlock '{wrapped_block.__name__}' in a "
                f"wrapped position (Annotated/Optional/union, or *args/**kwargs), which @with_annotated does not "
                f"support: a block must be the bare annotation of a regular parameter (e.g. "
                f"'{name}: {wrapped_block.__name__}') so its fields can expand. To use a dataclass as a single "
                f"value instead, make it a plain @dataclass (not an ArgumentBlock) with an Argument(converter=...)."
            )

        # *args has no default and is never keyword-only; its hint is the element type (default str).
        is_variadic = param.kind == inspect.Parameter.VAR_POSITIONAL
        has_default = param.default is not inspect.Parameter.empty
        # Peel Annotated then Optional.  For *args the annotation is the element type T, modeled as a
        # variadic tuple[T, ...]; its own optionality is dropped (*args is always a possibly-empty tuple).
        inner_type, metadata, is_optional = _normalize_annotation(hints.get(name, str if is_variadic else param.annotation))
        if is_variadic:
            inner_type = types.GenericAlias(tuple, (inner_type, ...))
            is_optional = False
        arg = _ArgparseArgument(
            name=name,
            func_qualname=func.__qualname__,
            has_default=has_default,
            param_default=param.default if has_default else None,
            is_kw_only=param.kind == inspect.Parameter.KEYWORD_ONLY,
            is_variadic=is_variadic,
            inner_type=inner_type,
            metadata=metadata,
            is_optional=is_optional,
            kind=param.kind,
            is_base_command=base_command,
        )
        resolved.append(arg)

    seen: dict[str, int] = {}
    for arg in resolved:
        seen[arg.name] = seen.get(arg.name, 0) + 1
    duplicates = sorted(name for name, count in seen.items() if count > 1)
    if duplicates:
        raise TypeError(
            f"{func.__qualname__} declares the argument name(s) {duplicates} more than once. A dataclass "
            f"block expands its fields into flat arguments (field name == argument name), so each field "
            f"name must be unique across the command's own parameters and every other block's fields."
        )

    inherited_collisions = sorted(inherited_field_names & seen.keys())
    if inherited_collisions:
        raise TypeError(
            f"{func.__qualname__} has inherited ArgumentBlock field(s) {inherited_collisions} that collide with the "
            f"command's own argument(s). A cmd2_parent_args block reuses the parent's fields by name, so those "
            f"names must not also be declared on the subcommand."
        )

    # Validate the whole list at once (per-argument + cross-argument rules) now that every
    # argument is built and its cross-argument facts can be linked.
    positionals = [arg for arg in resolved if arg.is_positional]
    for arg in positionals[:-1]:  # every positional except the last has a following positional
        arg.has_following_positional = True
    by_name = {arg.name: arg for arg in resolved}
    _check_group_members_exist(by_name, block_param_names, func.__qualname__, groups, mutually_exclusive_groups)
    _link_mutex_group_membership(by_name, mutually_exclusive_groups)
    for arg in resolved:
        arg._check_constraints()
    return resolved, base_args_types


def _var_positional_call_plan(func: Callable[..., Any]) -> tuple[list[str], str | None]:
    """Return ``(leading_positional_names, var_positional_name)`` for unpacking ``*args``.

    ``leading_positional_names`` are the positional-or-keyword parameters that
    precede ``*args`` (they must be passed positionally, in order, so ``*args``
    can follow). ``var_positional_name`` is the ``*args`` parameter name, or
    ``None`` when the function has no ``*args``.
    """
    params = list(inspect.signature(func).parameters.values())[1:]  # skip self/cls
    leading: list[str] = []
    for param in params:
        if param.kind is inspect.Parameter.VAR_POSITIONAL:
            return leading, param.name
        if param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD:
            leading.append(param.name)
    return leading, None


def _invoke_command_func(
    func: Callable[..., Any],
    self_arg: Any,
    func_kwargs: dict[str, Any],
    *,
    leading_names: list[str],
    var_positional_name: str | None,
) -> Any:
    """Call *func* from parsed kwargs, unpacking ``*args`` positionally when present."""
    if var_positional_name is None:
        return func(self_arg, **func_kwargs)
    positional = [func_kwargs.pop(name) for name in leading_names]
    var_values = func_kwargs.pop(var_positional_name)
    return func(self_arg, *positional, *var_values, **func_kwargs)


def _filtered_namespace_kwargs(
    ns: argparse.Namespace,
    *,
    accepted: Container[str] | None = None,
    exclude_subcommand: bool = False,
) -> dict[str, Any]:
    """Filter a parsed Namespace down to user-visible kwargs."""
    filtered: dict[str, Any] = {}
    for key, value in vars(ns).items():
        if accepted is not None and key not in accepted:
            continue
        if exclude_subcommand and key == "subcommand":
            continue
        filtered[key] = value

    return filtered


def _validate_group_specs(
    groups: tuple[Group, ...] | None,
    mutually_exclusive_groups: tuple[Group, ...] | None,
) -> None:
    """Validate the *shape* of ``groups=`` / ``mutually_exclusive_groups=`` specs.

    Runs at decoration time (from both the regular-command and subcommand decoration paths, and again from
    :func:`build_parser_from_function` for direct callers), so a misshapen group hard-fails when the class is
    defined instead of on first command use, where cmd2's runtime handler turns the error into a printed
    message.  Reads only the ``Group`` specs -- never the signature or the type hints -- so
    forward-referenced annotations still decorate.
    """
    if not groups and not mutually_exclusive_groups:
        return

    group_entry_for: dict[str, int] = {}
    for index, spec in enumerate(groups or (), start=1):
        if spec.required:
            raise ValueError(
                "Group(required=True) is only valid in mutually_exclusive_groups; "
                "argparse's add_argument_group has no 'required' flag"
            )
        for name in spec.members:
            previous = group_entry_for.get(name)
            if previous == index:
                raise ValueError(f"parameter {name!r} is listed more than once in argument group {index}")
            if previous is not None:
                raise ValueError(
                    f"parameter {name!r} cannot be assigned to both argument group {previous} and argument group {index}"
                )
            group_entry_for[name] = index

    mutex_entry_for: dict[str, int] = {}
    for index, spec in enumerate(mutually_exclusive_groups or (), start=1):
        for name in spec.members:
            previous = mutex_entry_for.get(name)
            if previous == index:
                raise ValueError(f"parameter {name!r} is listed more than once in mutually exclusive group {index}")
            if previous is not None:
                raise ValueError(f"parameter {name!r} cannot be assigned to multiple mutually exclusive groups")
            mutex_entry_for[name] = index
        parent_entries = {group_entry_for[name] for name in spec.members if name in group_entry_for}
        if len(parent_entries) > 1:
            raise ValueError(
                f"mutually exclusive group {index} spans parameters in different argument groups, "
                "which argparse cannot represent cleanly"
            )
        if parent_entries:
            # Members already sit in a titled groups= entry, so the mutex nests there.  A section
            # declared on both sides is ambiguous, and nesting a mutex that only partly overlaps the
            # group would pull the ungrouped members into that group's help section.
            if spec.title is not None or spec.description is not None:
                raise ValueError(
                    f"mutually exclusive group {index} sets title/description, but its members already "
                    "belong to a groups= entry; declare the titled section in one place only"
                )
            ungrouped = [name for name in spec.members if name not in group_entry_for]
            if ungrouped:
                raise ValueError(
                    f"mutually exclusive group {index} mixes members in a titled argument group with "
                    f"members that are not ({ungrouped!r}); list all of its members in the same groups= "
                    "entry to nest the mutex inside that group, or none of them to keep it top-level"
                )


def _build_argument_group_targets(
    parser: argparse.ArgumentParser,
    *,
    groups: tuple[Group, ...] | None,
) -> tuple[dict[str, _ArgumentTarget], dict[str, argparse._ArgumentGroup]]:
    """Build argument groups and return add_argument targets for their members.

    The specs are validated upstream by :func:`_validate_group_specs` (member references,
    double-assignment, ``required=True`` rejection), so construction can assign each member
    unconditionally.
    """
    target_for: dict[str, _ArgumentTarget] = {}
    argument_group_for: dict[str, argparse._ArgumentGroup] = {}

    if not groups:
        return target_for, argument_group_for

    for spec in groups:
        group = parser.add_argument_group(title=spec.title, description=spec.description)
        for name in spec.members:
            argument_group_for[name] = group
            target_for[name] = group

    return target_for, argument_group_for


def _apply_mutex_group_targets(
    parser: argparse.ArgumentParser,
    *,
    target_for: dict[str, _ArgumentTarget],
    argument_group_for: dict[str, argparse._ArgumentGroup],
    mutually_exclusive_groups: tuple[Group, ...] | None,
) -> None:
    """Build mutually exclusive groups and update add_argument targets for their members.

    The specs are validated upstream by :func:`_validate_group_specs` (member references,
    double-assignment, and the group-shaped rules: spanning, partial overlap, a section declared in
    two places) and :data:`_CONSTRAINTS` (the required-member rule), so construction only chooses
    each mutex group's parent: the argument group all its members share, a new titled section when
    the spec carries ``title``/``description``, or the parser itself.
    """
    if not mutually_exclusive_groups:
        return

    for spec in mutually_exclusive_groups:
        parent_groups = {argument_group_for[name] for name in spec.members if name in argument_group_for}
        if parent_groups:
            # All members sit in one titled groups= entry, so the mutex nests there.
            mutex_parent: _ArgumentTarget = next(iter(parent_groups))
        elif spec.title is not None or spec.description is not None:
            # title/description on the mutex create the titled section and nest the mutex inside it,
            # so a titled exclusive group needs only its own declaration -- no paired groups= entry.
            mutex_parent = parser.add_argument_group(title=spec.title, description=spec.description)
        else:
            mutex_parent = parser

        mutex_group = mutex_parent.add_mutually_exclusive_group(required=spec.required)
        for name in spec.members:
            target_for[name] = mutex_group


def _docstring_first_paragraph(doc: str | None) -> str | None:
    """Return the first paragraph of *doc* (everything before the first blank line), or ``None``.

    Used to auto-fill ``description`` from ``func.__doc__`` when the caller didn't pass one.
    Subsequent paragraphs are intentionally ignored: rst field directives (``:param:``, ``:return:``)
    routinely live below the summary and would render as nonsense in ``--help``.
    """
    if not doc:
        return None
    cleaned = inspect.cleandoc(doc).strip()
    if not cleaned:
        return None
    # Stop at the first blank line OR the first rst field directive (``:param:``, ``:return:``, ...);
    # a directive that immediately follows the summary with no blank line would otherwise leak into --help.
    summary_lines: list[str] = []
    for line in cleaned.splitlines():
        if not line.strip() or line.lstrip().startswith(":"):
            break
        summary_lines.append(line)
    return "\n".join(summary_lines).strip() or None


def build_parser_from_function(
    func: Callable[..., Any],
    *,
    skip_params: frozenset[str] = _SKIP_PARAMS,
    groups: tuple[Group, ...] | None = None,
    mutually_exclusive_groups: tuple[Group, ...] | None = None,
    parser_class: type[Cmd2ArgumentParser] | None = None,
    **parser_kwargs: Unpack[Cmd2ParserKwargs],
) -> Cmd2ArgumentParser:
    """Inspect a function's signature and build a ``Cmd2ArgumentParser``.

    The lower-level entry point behind [`with_annotated`][cmd2.annotated.with_annotated].  ``parser_kwargs`` is forwarded to
    the parser ctor (see [`Cmd2ParserKwargs`][cmd2.annotated.Cmd2ParserKwargs]); when ``description`` is omitted, the first
    paragraph of ``func.__doc__`` is used.

    :param func: the command function to inspect
    :param skip_params: parameter names to exclude from the parser
    :param groups: [`Group`][cmd2.annotated.Group] instances assigning parameters to argument groups
    :param mutually_exclusive_groups: [`Group`][cmd2.annotated.Group] instances of mutually exclusive parameters
    :param parser_class: custom parser class (defaults to the configured default)
    :param parser_kwargs: forwarded [`Cmd2ParserKwargs`][cmd2.annotated.Cmd2ParserKwargs]
    :return: a fully configured ``Cmd2ArgumentParser``
    """
    from . import argparse_utils

    # The decorator already ran this at decoration time; direct callers get the same checks here.
    _validate_group_specs(groups, mutually_exclusive_groups)

    parser_cls = parser_class or argparse_utils.DEFAULT_ARGUMENT_PARSER
    if "description" not in parser_kwargs:
        auto_description = _docstring_first_paragraph(func.__doc__)
        if auto_description is not None:
            parser_kwargs["description"] = auto_description
    parser = parser_cls(**parser_kwargs)

    # _resolve_parameters validates each argument and the cross-argument rules (e.g. a variable-arity
    # positional must be last; a required member in a mutex group) once the whole list is built.
    resolved, base_args_types = _resolve_parameters(
        func,
        skip_params=skip_params,
        groups=groups,
        mutually_exclusive_groups=mutually_exclusive_groups,
    )

    # ``argument_default=argparse.SUPPRESS`` drops an absent argument from the parsed namespace.
    # @with_annotated builds the call from the function signature, so every declared parameter is
    # expected at invocation -- an argument vanishing from the namespace can never be valid here.
    # Reject it outright, mirroring the per-argument ``default=argparse.SUPPRESS`` rejection.
    if parser_kwargs.get("argument_default") is argparse.SUPPRESS:
        raise TypeError(
            f"argument_default=argparse.SUPPRESS is not supported by @with_annotated for {func.__qualname__}: "
            f"it drops absent arguments from the parsed namespace, but every parameter built from the "
            f"signature is expected at invocation. Drop argument_default=argparse.SUPPRESS."
        )

    # Build the group lookup (specs already validated by _validate_group_specs above).
    target_for, argument_group_for = _build_argument_group_targets(parser, groups=groups)
    _apply_mutex_group_targets(
        parser,
        target_for=target_for,
        argument_group_for=argument_group_for,
        mutually_exclusive_groups=mutually_exclusive_groups,
    )

    # Add each argument to its target (its group/mutex group if assigned, else the parser).
    for arg in resolved:
        arg.add_to(target_for.get(arg.name, parser))

    # A cmd2_base_args block is inheritable: stamp a parse-time presence marker so a descendant's
    # cmd2_parent_args can confirm an ancestor actually declared the block.
    for base_args_type in base_args_types:
        parser.set_defaults(**{_base_args_marker_dest(base_args_type): True})

    return parser


def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str:
    """Derive the subcommand name from the function name and validate the naming convention.

    ``subcommand_to='team member'`` + ``func.__name__='team_member_add'`` -> ``'add'``.
    """
    expected_prefix = subcommand_to.replace(" ", "_") + "_"
    if not func.__name__.startswith(expected_prefix):
        raise TypeError(
            f"Function '{func.__name__}' must be named '{expected_prefix}<subcommand>' "
            f"when using subcommand_to='{subcommand_to}'"
        )
    return func.__name__[len(expected_prefix) :]


@dataclass(frozen=True)
class _ParserBuildOptions:
    """The parser/subcommand configuration shared by every ``with_annotated`` build path.

    These options are a data clump threaded identically through the regular-command and subcommand
    builders; bundling them lets :func:`_make_parser_builder` host the single deferred build flow.
    """

    groups: tuple[Group, ...] | None = None
    mutually_exclusive_groups: tuple[Group, ...] | None = None
    parser_class: type[Cmd2ArgumentParser] | None = None
    #: Forwarded :class:`Cmd2ParserKwargs` (description, epilog, prog, ...).
    #: Stored as a plain ``dict`` so missing keys yield argparse's defaults
    #: rather than this layer second-guessing them.
    parser_kwargs: dict[str, Any] = field(default_factory=dict)
    subcommand_required: bool = True
    subcommand_metavar: str = "SUBCOMMAND"
    subcommand_title: str | None = None
    subcommand_description: str | None = None


def _make_parser_builder(
    func: Callable[..., Any],
    *,
    skip_params: frozenset[str],
    base_command: bool,
    options: _ParserBuildOptions,
) -> Callable[[], Cmd2ArgumentParser]:
    """Return the deferred builder for *func*'s parser (adds the subcommands group when ``base_command``).

    Shared by the regular-command and subcommand decorators so the build flow lives in one place.
    """

    def parser_builder() -> Cmd2ArgumentParser:
        parser = build_parser_from_function(
            func,
            skip_params=skip_params,
            groups=options.groups,
            mutually_exclusive_groups=options.mutually_exclusive_groups,
            parser_class=options.parser_class,
            **options.parser_kwargs,
        )
        if base_command:
            # dict[str, Any] is load-bearing: title/description are added conditionally below,
            # which the typeshed overloads for add_subparsers cannot express.
            kwargs: dict[str, Any] = {
                "dest": "subcommand",
                "metavar": options.subcommand_metavar,
                "required": options.subcommand_required,
            }
            if options.subcommand_title is not None:
                kwargs["title"] = options.subcommand_title
            if options.subcommand_description is not None:
                kwargs["description"] = options.subcommand_description
            parser.add_subparsers(**kwargs)
        return parser

    return parser_builder


def _build_subcommand_handler(
    func: Callable[..., Any],
    subcommand_to: str,
    *,
    base_command: bool = False,
    options: _ParserBuildOptions,
) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]:
    """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs.

    :param func: the subcommand handler function
    :param subcommand_to: parent command name (space-delimited for nesting)
    :param base_command: if True, the parser also gets ``add_subparsers()``
    :param options: shared parser/subcommand configuration
    :return: ``(handler, subcommand_name, parser_builder)``
    """
    subcmd_name = _derive_subcommand_name(func, subcommand_to)

    # Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
    # the class is defined; the name-only checks never resolve type hints, so forward-referenced
    # annotations still decorate and the parser build stays deferred.
    _validate_group_specs(options.groups, options.mutually_exclusive_groups)
    if base_command:
        # Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
        # skip_params is spelled out so this call cannot silently diverge from the parser build below.
        _resolve_parameters(func, skip_params=_SKIP_PARAMS, base_command=True)

    _accepted = set(list(inspect.signature(func).parameters.keys())[1:])
    # A dataclass-block parameter's expanded fields land in the namespace
    _resolve_blocks = _lazy_block_resolver(func, base_accepted=_accepted, skip_params=_SKIP_PARAMS)
    _leading_names, _var_positional_name = _var_positional_call_plan(func)

    @functools.wraps(func)
    def handler(self_arg: Any, ns: Any) -> Any:
        """Unpack Namespace into typed kwargs for the subcommand handler."""
        _blocks, _eff_accepted = _resolve_blocks()
        filtered = _filtered_namespace_kwargs(ns, accepted=_eff_accepted)
        if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered:
            cmd2_h = filtered[constants.NS_ATTR_SUBCOMMAND_FUNC]
            if isinstance(cmd2_h, functools.partial) and getattr(cmd2_h.func, "__func__", cmd2_h.func) is handler:
                filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None
        _reconstruct_dataclass_blocks(filtered, _blocks, ns)
        return _invoke_command_func(
            func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name
        )

    parser_builder = _make_parser_builder(func, skip_params=_SKIP_PARAMS, base_command=base_command, options=options)
    return handler, subcmd_name, parser_builder


_CommandParams = ParamSpec("_CommandParams")
_CommandReturn = TypeVar("_CommandReturn")


class _WithAnnotatedDecorator(Protocol):
    """The signature-preserving decorator ``with_annotated(...)`` returns (generic per call)."""

    def __call__(self, fn: Callable[_CommandParams, _CommandReturn], /) -> Callable[_CommandParams, _CommandReturn]: ...


@overload
def with_annotated(func: Callable[_CommandParams, _CommandReturn]) -> Callable[_CommandParams, _CommandReturn]: ...


@overload
def with_annotated(
    func: None = ...,
    *,
    ns_provider: Callable[..., argparse.Namespace] | None = ...,
    preserve_quotes: bool = ...,
    with_unknown_args: bool = ...,
    base_command: bool = ...,
    subcommand_to: str | None = ...,
    help: str | None = ...,
    aliases: Sequence[str] = ...,
    deprecated: bool = ...,
    groups: tuple[Group, ...] | None = ...,
    mutually_exclusive_groups: tuple[Group, ...] | None = ...,
    parser_class: type[Cmd2ArgumentParser] | None = ...,
    subcommand_required: bool = ...,
    subcommand_metavar: str = ...,
    subcommand_title: str | None = ...,
    subcommand_description: str | None = ...,
    **parser_kwargs: Unpack[Cmd2ParserKwargs],
) -> _WithAnnotatedDecorator: ...


def with_annotated(
    func: Callable[..., Any] | None = None,
    *,
    ns_provider: Callable[..., argparse.Namespace] | None = None,
    preserve_quotes: bool = False,
    with_unknown_args: bool = False,
    base_command: bool = False,
    subcommand_to: str | None = None,
    help: str | None = None,  # noqa: A002
    aliases: Sequence[str] = (),
    deprecated: bool = False,
    groups: tuple[Group, ...] | None = None,
    mutually_exclusive_groups: tuple[Group, ...] | None = None,
    parser_class: type[Cmd2ArgumentParser] | None = None,
    subcommand_required: bool = True,
    subcommand_metavar: str = "SUBCOMMAND",
    subcommand_title: str | None = None,
    subcommand_description: str | None = None,
    **parser_kwargs: Unpack[Cmd2ParserKwargs],
) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]:
    """Decorate a ``do_*`` method to build its argparse parser from type annotations.

    :param func: the command function (when used without parentheses)
    :param ns_provider: callable returning a prepopulated Namespace (not with ``subcommand_to``)
    :param preserve_quotes: preserve quotes in arguments (not with ``subcommand_to``)
    :param with_unknown_args: capture unknown args as the ``_unknown`` kwarg (not with ``subcommand_to``)
    :param base_command: add ``add_subparsers()``; requires a ``cmd2_subcommand_func`` param and no positionals
    :param subcommand_to: parent command name; function must be named ``{parent_underscored}_{subcommand}``
    :param help: subcommand help text (only with ``subcommand_to``)
    :param aliases: alternative subcommand names (only with ``subcommand_to``)
    :param deprecated: mark the subcommand deprecated in ``--help`` (only with ``subcommand_to``)
    :param groups: [`Group`][cmd2.annotated.Group] instances assigning parameters to titled argument groups
    :param mutually_exclusive_groups: [`Group`][cmd2.annotated.Group] instances of mutually exclusive parameters
    :param parser_class: custom parser class (defaults to the configured default)
    :param subcommand_required: whether a subcommand must be supplied (``base_command`` only)
    :param subcommand_metavar: metavar for the subcommands group (``base_command`` only)
    :param subcommand_title: title for the subcommands ``--help`` section (``base_command`` only)
    :param subcommand_description: description for that section (``base_command`` only)
    :param parser_kwargs: any [`Cmd2ArgumentParser`][cmd2.argparse_utils.Cmd2ArgumentParser] ctor kwarg
                          (see [`Cmd2ParserKwargs`][cmd2.annotated.Cmd2ParserKwargs]).
                          ``description`` defaults to the docstring's first paragraph when omitted;
                          ``prog`` is rejected with ``subcommand_to`` (cmd2 rewrites it from the parent).
    """
    if aliases is None:
        raise TypeError(
            "aliases must be a sequence of subcommand-name strings (e.g. ('co', 'ci')), not None; "
            "omit it or pass an empty tuple for no aliases."
        )
    if (help is not None or aliases or deprecated) and subcommand_to is None:
        raise TypeError("'help', 'aliases', and 'deprecated' are only valid with subcommand_to")
    if subcommand_to is not None:
        unsupported: list[str] = []
        if ns_provider is not None:
            unsupported.append("ns_provider")
        if preserve_quotes:
            unsupported.append("preserve_quotes")
        if with_unknown_args:
            unsupported.append("with_unknown_args")
        if "prog" in parser_kwargs:
            # cmd2's subcommand machinery (``update_prog``) rewrites prog from the parent
            # command hierarchy, so any value supplied here would be silently overwritten.
            unsupported.append("prog")
        if unsupported:
            names = ", ".join(unsupported)
            raise TypeError(
                f"{names} {'is' if len(unsupported) == 1 else 'are'} not supported with subcommand_to. "
                "Configure these behaviors on the base command instead."
            )

    options = _ParserBuildOptions(
        groups=groups,
        mutually_exclusive_groups=mutually_exclusive_groups,
        parser_class=parser_class,
        parser_kwargs=dict(parser_kwargs),
        subcommand_required=subcommand_required,
        subcommand_metavar=subcommand_metavar,
        subcommand_title=subcommand_title,
        subcommand_description=subcommand_description,
    )

    def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
        if with_unknown_args:
            unknown_param = inspect.signature(fn).parameters.get("_unknown")
            if unknown_param is None:
                raise TypeError("with_annotated(with_unknown_args=True) requires a parameter named _unknown")
            if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY:
                raise TypeError("Parameter _unknown must be keyword-compatible when with_unknown_args=True")

        if not base_command and constants.NS_ATTR_SUBCOMMAND_FUNC in inspect.signature(fn).parameters:
            raise TypeError(
                f"Parameter '{constants.NS_ATTR_SUBCOMMAND_FUNC}' in {fn.__qualname__} "
                "is only valid when with_annotated(base_command=True) is used."
            )

        if subcommand_to is not None:
            handler, subcmd_name, subcmd_parser_builder = _build_subcommand_handler(
                fn,
                subcommand_to,
                base_command=base_command,
                options=options,
            )
            subcommand_spec = SubcommandSpec(
                name=subcmd_name,
                command=subcommand_to,
                help=help,
                aliases=tuple(aliases),
                deprecated=deprecated,
                parser_source=subcmd_parser_builder,
            )
            setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec)
            return handler

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

        skip_params = _SKIP_PARAMS | ({"_unknown"} if with_unknown_args else frozenset())
        # Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
        # the class is defined; the name-only checks never resolve type hints, so forward-referenced
        # annotations still decorate and the parser build stays deferred.
        _validate_group_specs(options.groups, options.mutually_exclusive_groups)
        if base_command:
            # Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
            _resolve_parameters(fn, skip_params=skip_params, base_command=True)

        # Cache signature introspection at decoration time, not per-invocation
        accepted = set(list(inspect.signature(fn).parameters.keys())[1:])
        resolve_blocks = _lazy_block_resolver(fn, base_accepted=accepted, skip_params=skip_params)
        leading_names, var_positional_name = _var_positional_call_plan(fn)

        parser_builder = _make_parser_builder(fn, skip_params=skip_params, base_command=base_command, options=options)

        @functools.wraps(fn)
        def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
            cmd2_app, statement_arg = _parse_positionals(args)
            owner = args[0]  # Cmd or CommandSet instance
            statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
                command_name, statement_arg, preserve_quotes
            )

            arg_parser = cmd2_app.command_parsers.get(cmd_wrapper)
            if arg_parser is None:
                raise ValueError(f"No argument parser found for {command_name}")

            if ns_provider is None:
                namespace = None
            else:
                provider_self = cmd2_app._resolve_func_self(ns_provider, args[0])
                namespace = ns_provider(provider_self if provider_self is not None else cmd2_app)

            try:
                if with_unknown_args:
                    ns, unknown = arg_parser.parse_known_args(parsed_arglist, namespace)
                else:
                    ns = arg_parser.parse_args(parsed_arglist, namespace)
                    unknown = None
            except SystemExit as exc:
                raise Cmd2ArgparseError from exc

            if ns is None:
                raise ValueError("ns is None")

            setattr(ns, constants.NS_ATTR_STATEMENT, statement)
            handler = getattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, None)
            if base_command and handler is not None:
                handler = functools.partial(handler, ns)
            setattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, handler)

            blocks, eff_accepted = resolve_blocks()
            func_kwargs = _filtered_namespace_kwargs(ns, accepted=eff_accepted, exclude_subcommand=base_command)

            if with_unknown_args:
                func_kwargs["_unknown"] = unknown

            func_kwargs.update(kwargs)
            _reconstruct_dataclass_blocks(func_kwargs, blocks, ns)
            result: bool | None = _invoke_command_func(
                fn, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name
            )
            return result

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

        return cmd_wrapper

    # Support both @with_annotated and @with_annotated(...)
    if func is not None:
        return decorator(func)
    return decorator
