import inspect
import sys
from collections.abc import Iterable, Sequence
from enum import Enum
from functools import lru_cache
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    ForwardRef,
    Literal,
)

from attrs import define, evolve, field

from cyclopts.annotations import resolve_annotated
from cyclopts.core import _get_root_module_name, _iter_resolution_argument_collections
from cyclopts.field_info import get_field_infos
from cyclopts.group import Group
from cyclopts.help.inline_text import InlineText
from cyclopts.help.silent import SILENT, SilentRich
from cyclopts.utils import SortHelper, frozen, is_class_and_subclass, resolve_callables

if TYPE_CHECKING:
    from rich.console import RenderableType

    from cyclopts.argument import Argument, ArgumentCollection
    from cyclopts.core import App


@lru_cache(maxsize=16)
def docstring_parse(doc: str | None, format: str):
    """Addon to :func:`docstring_parser.parse` that supports multi-line `short_description`."""
    import docstring_parser

    if not doc:
        return docstring_parser.parse("")

    cleaned_doc = inspect.cleandoc(doc)
    short_description_and_maybe_remainder = cleaned_doc.split("\n\n", 1)

    # Place multi-line summary into a single line.
    # This kind of goes against PEP-0257, but any reasonable CLI command will
    # have either no description, or it will have both a short and long description.
    short = short_description_and_maybe_remainder[0].replace("\n", " ")
    if len(short_description_and_maybe_remainder) == 1:
        cleaned_doc = short
    else:
        cleaned_doc = short + "\n\n" + short_description_and_maybe_remainder[1]

    res = docstring_parser.parse(cleaned_doc)

    # Ensure a short description exists if there's a long description
    assert not res.long_description or res.short_description

    return res


def _text_factory():
    from rich.text import Text

    return Text()


def _description_converter(value: Any | None) -> Any:
    if value is None:
        return _text_factory()
    return value


@frozen(kw_only=True)
class HelpEntry:
    """Container for help table entry data."""

    positive_names: tuple[str, ...] = ()
    """Positive long option names (e.g., "--verbose", "--dry-run")."""

    positive_shorts: tuple[str, ...] = ()
    """Positive short option names (e.g., "-v", "-n")."""

    negative_names: tuple[str, ...] = ()
    """Negative long option names (e.g., "--no-verbose", "--no-dry-run")."""

    negative_shorts: tuple[str, ...] = ()
    """Negative short option names (e.g., "-N"). Rarely used."""

    @property
    def names(self) -> tuple[str, ...]:
        """All long option names (positive + negative). For backward compatibility."""
        return self.positive_names + self.negative_names

    @property
    def shorts(self) -> tuple[str, ...]:
        """All short option names (positive + negative). For backward compatibility."""
        return self.positive_shorts + self.negative_shorts

    @property
    def all_options(self) -> tuple[str, ...]:
        """All options in display order: positive longs, positive shorts, negative longs, negative shorts."""
        return self.positive_names + self.positive_shorts + self.negative_names + self.negative_shorts

    description: Any = None
    """Help text description for this entry.

    Typically a :class:`str` or a :obj:`~rich.console.RenderableType`
    """

    required: bool = False
    """Whether this parameter/command is required."""

    sort_key: Any = None
    """Custom sorting key for ordering entries."""

    type: Any | None = None
    """Type annotation of the parameter."""

    choices: tuple[str, ...] | None = None
    """Available choices for this parameter."""

    env_var: tuple[str, ...] | None = None
    """Environment variable names that can set this parameter."""

    default: str | None = None
    """Default value for this parameter to display. None means no default to show."""

    def copy(self, **kwargs):
        return evolve(self, **kwargs)


@define
class HelpPanel:
    """Data container for help panel information."""

    format: Literal["command", "parameter"]
    """Panel format type."""

    title: "RenderableType"
    """The title text displayed at the top of the help panel."""

    description: Any = field(
        default=None,
        converter=_description_converter,
    )
    """Optional description text displayed below the title.

    Typically a :class:`str` or a :obj:`~rich.console.RenderableType`
    """

    entries: list[HelpEntry] = field(factory=list)
    """List of help entries to display (in order) in the panel."""

    def copy(self, **kwargs):
        return evolve(self, **kwargs)

    def _remove_duplicates(self):
        seen, out = set(), []
        for item in self.entries:
            hashable = (item.names, item.shorts)
            if hashable not in seen:
                seen.add(hashable)
                out.append(item)
        self.entries = out

    def _sort(self):
        """Sort entries in-place."""
        if not self.entries:
            return

        if self.format == "command":
            sorted_sort_helper = SortHelper.sort(
                [
                    SortHelper(
                        entry.sort_key,
                        (
                            entry.names[0].startswith("-") if entry.names else False,
                            entry.names[0] if entry.names else "",
                        ),
                        entry,
                    )
                    for entry in self.entries
                ]
            )
            self.entries = [x.value for x in sorted_sort_helper]
        else:
            raise NotImplementedError


def _is_short(s):
    return not s.startswith("--") and s.startswith("-")


def _categorize_keyword_arguments(argument_collection: "ArgumentCollection") -> tuple[list, list]:
    """Categorize keyword arguments by requirement status for usage string formatting.

    Parameters
    ----------
    argument_collection : ArgumentCollection
        Collection of arguments to categorize.

    Returns
    -------
    tuple[list, list]
        (required_keyword, optional_keyword) where:
        - required_keyword: Required keyword-only parameters
        - optional_keyword: Optional keyword-only parameters and VAR_KEYWORD
    """
    required, optional = [], []

    for argument in argument_collection:
        if not argument.show:
            continue

        if argument.field_info.kind in (argument.field_info.VAR_KEYWORD,):
            optional.append(argument)
        elif argument.field_info.is_keyword_only:
            if argument.required:
                required.append(argument)
            else:
                optional.append(argument)

    return required, optional


def _categorize_positional_arguments(argument_collection: "ArgumentCollection") -> tuple[list, list]:
    """Categorize positional arguments by requirement status for usage string formatting.

    Parameters
    ----------
    argument_collection : ArgumentCollection
        Collection of arguments to categorize.

    Returns
    -------
    tuple[list, list]
        (required_positional, optional_positional) where:
        - required_positional: Required positional and VAR_POSITIONAL parameters
        - optional_positional: Optional positional and VAR_POSITIONAL parameters
    """
    required, optional = [], []

    for argument in argument_collection:
        if not argument.show:
            continue

        if argument.field_info.kind == argument.field_info.VAR_POSITIONAL:
            if argument.required:
                required.append(argument)
            else:
                optional.append(argument)
        elif argument.field_info.is_positional:
            if argument.required:
                required.append(argument)
            else:
                optional.append(argument)

    return required, optional


def format_usage(
    app: "App",
    command_chain: Iterable[str],
    execution_path: Sequence["App"] | None = None,
):
    from rich.text import Text

    from cyclopts.annotations import get_hint_name

    usage = []

    # If we're at the root level (no command chain), the app has a default_command,
    # and no explicit name was set, derive a better name from sys.argv[0]
    if not command_chain and app.default_command and not app._name:
        # Use the same logic as in App.name property for apps without default_command
        name = Path(sys.argv[0]).name
        if name == "__main__.py":
            name = _get_root_module_name()
        app_name = name
    else:
        app_name = app.name[0]

    usage.append(app_name)
    usage.extend(command_chain)

    for command in command_chain:
        app = app[command]

    # Check for visible non-help/version commands without resolving lazy CommandSpecs.
    help_version_flags = {*app.help_flags, *app.version_flags}
    if any(x not in help_version_flags and app._get_item(x, recurse_meta=True).show for x in app):
        usage.append("COMMAND")

    # Aggregate arguments across all apps that contribute parameters to this help page.
    # Shares the resolution logic with ``App._assemble_help_panels`` so the usage line and
    # the parameter panels always agree on which apps contribute.
    required_keyword_params: list = []
    optional_keyword_params: list = []
    required_positional_args: list = []
    optional_positional_args: list = []
    for _, argument_collection in _iter_resolution_argument_collections(
        execution_path, fallback_app=app, parse_docstring=False
    ):
        rkw, okw = _categorize_keyword_arguments(argument_collection)
        rpos, opos = _categorize_positional_arguments(argument_collection)
        required_keyword_params.extend(rkw)
        optional_keyword_params.extend(okw)
        required_positional_args.extend(rpos)
        optional_positional_args.extend(opos)

    for argument in required_keyword_params:
        param_name = argument.name
        type_name = get_hint_name(argument.hint).upper()
        usage.append(f"{param_name} {type_name}")

    if optional_keyword_params:
        usage.append("[OPTIONS]")

    for argument in required_positional_args:
        if argument.field_info.kind == argument.field_info.VAR_POSITIONAL:
            arg_name = argument.name.lstrip("-").upper()
            usage.append(f"{arg_name}...")
        else:
            arg_name = argument.name.lstrip("-").upper()
            usage.append(arg_name)

    if optional_positional_args:
        has_var_positional = any(
            arg.field_info.kind == arg.field_info.VAR_POSITIONAL for arg in optional_positional_args
        )
        if has_var_positional:
            usage.append("[ARGS...]")
        else:
            usage.append("[ARGS]")

    return Text(" ".join(usage) + "\n", style="bold")


def _smart_join(strings: Sequence[str]) -> str:
    """Joins strings with a space, unless the previous string ended in a newline."""
    if not strings:
        return ""

    result = [strings[0]]
    for s in strings[1:]:
        if result[-1].endswith("\n"):
            result.append(s)
        else:
            result.append(" " + s)

    return "".join(result)


def format_doc(app: "App", format: str) -> InlineText | SilentRich:
    raw_doc_string = app.help

    if not raw_doc_string:
        return SILENT

    parsed = docstring_parse(raw_doc_string, format)

    components: list[str] = []
    if parsed.short_description:
        components.append(parsed.short_description + "\n")

    if parsed.long_description:
        if parsed.short_description:
            components.append("\n")
        components.append(parsed.long_description + "\n")
    return InlineText.from_format(_smart_join(components), format=format, force_empty_end=True)


def _is_dynamic_structured_dict(argument: "Argument") -> bool:
    """True if ``argument`` is ``dict[str, StructuredType]`` eligible for help expansion.

    Covers pydantic, dataclass, attrs, TypedDict, NamedTuple via the shared
    ``get_field_infos`` dispatcher.  Uses the same indicators as the parser's
    dict branch in ``Argument.__attrs_post_init__``: ``_accepts_keywords`` is
    set, ``_lookup`` is empty (no pre-built children — keys are dynamic), and
    ``_default`` is the value type with structured fields.

    Also matches when ``_default`` is a string/``ForwardRef`` — an unresolved
    self-reference from something like ``dict[str, "Node"]``.  We can't walk
    into it, but we treat it as assumed-structured so the expansion still
    renders a ``.{NAME}`` layer before terminating.
    """
    default = argument._default
    if not (argument._accepts_keywords and not argument._lookup and default is not None):
        return False
    if isinstance(default, (str, ForwardRef)):
        return True
    try:
        return bool(get_field_infos(default))
    except Exception:
        return False


def _expand_structured_dict_for_help(
    argument: "Argument",
    format: str,
    *,
    seen: frozenset[int] = frozenset(),
) -> Iterable[HelpEntry]:
    """Yield help entries for every leaf field of a ``dict[str, StructuredType]``.

    Reuses :meth:`ArgumentCollection._from_type_preview` so synthesized entries
    carry the full metadata (choices, defaults, env_var, required propagation,
    ``Parameter.help`` precedence, ``name_transform``) that the normal
    per-argument path produces.
    """
    # NOTE: help output uses cyclopts' name_transform (e.g. ``my_field`` →
    # ``--models.{NAME}.my-field``).  The parser currently only accepts the raw
    # snake_case form for dict-nested paths; harmonizing the two is a separate
    # follow-up (touches ``_argument.py`` token routing).
    from cyclopts.argument import ArgumentCollection
    from cyclopts.field_info import FieldInfo
    from cyclopts.parameter import Parameter

    value_type = argument._default

    negatives = set(argument.negatives)
    outer_long_names = tuple(o for o in argument.names if o not in negatives and not _is_short(o))

    is_unresolvable = isinstance(value_type, (str, ForwardRef))
    is_cycle = id(value_type) in seen

    if is_cycle or is_unresolvable or not outer_long_names:
        # Cycle or unresolved forward-ref — stop expanding, but still indicate
        # the next level is another ``{NAME}`` layer by appending ``.{{NAME}}``
        # to the names.
        base = _make_help_entry(argument, format)
        if outer_long_names:
            suffixed_names = tuple(f"{n}.{{NAME}}" for n in base.positive_names)
            yield evolve(base, positive_names=suffixed_names)
        else:
            yield base
        return

    new_seen = seen | {id(value_type)}
    synthetic = FieldInfo(
        names=("_preview",),
        kind=FieldInfo.KEYWORD_ONLY,
        annotation=value_type,
        default=FieldInfo.empty,
        required=argument.required,
    )
    for outer in outer_long_names:
        preview = ArgumentCollection._from_type(
            synthetic,
            (),
            Parameter(name=(f"{outer}.{{NAME}}",)),
            group_lookup={},
            group_arguments=Group.create_default_arguments(),
            group_parameters=Group.create_default_parameters(),
            _resolve_groups=False,
        )
        for leaf in preview.filter_by(show=True):
            if _is_dynamic_structured_dict(leaf):
                yield from _expand_structured_dict_for_help(leaf, format, seen=new_seen)
            else:
                yield _make_help_entry(leaf, format)


def _make_help_entry(argument: "Argument", format: str) -> HelpEntry:
    """Build a single ``HelpEntry`` for one ``Argument``.

    Extracted from ``create_parameter_help_panel`` so it can also be applied
    to synthetic preview arguments (see ``_expand_structured_dict_for_help``).
    """
    assert argument.parameter.name_transform

    options = list(argument.names)

    seen: set[str] = set()
    options = [x for x in options if x not in seen and not seen.add(x)]

    if argument.index is not None:
        label_source = next((o for o in options if o.startswith("--")), options[0])
        arg_name = label_source.lstrip("-").upper()
        if arg_name != options[0]:
            options = [arg_name, *options]

    negatives = set(argument.negatives)
    positive_names = [o for o in options if o not in negatives and not _is_short(o)]
    positive_shorts = [o for o in options if o not in negatives and _is_short(o)]
    negative_names = [o for o in options if o in negatives and not _is_short(o)]
    negative_shorts = [o for o in options if o in negatives and _is_short(o)]

    help_description = InlineText.from_format(argument.parameter.help, format=format)

    choices = argument.get_choices()

    env_var = None
    if argument.parameter.show_env_var and argument.parameter.env_var:
        env_var = tuple(argument.parameter.env_var)

    default = None
    if argument.show_default:
        default_val = argument.field_info.default
        if is_class_and_subclass(argument.hint, Enum):
            default = argument.parameter.name_transform(default_val.name)
        elif isinstance(default_val, (list, tuple, set, frozenset)):
            formatted_items = []
            for item in default_val:
                if isinstance(item, Enum):
                    formatted_items.append(argument.parameter.name_transform(item.name))
                elif isinstance(item, str):
                    formatted_items.append(f"'{item}'")
                else:
                    formatted_items.append(str(item))
            if isinstance(default_val, tuple):
                if len(formatted_items) == 1:
                    default = "(" + formatted_items[0] + ",)"
                else:
                    default = "(" + ", ".join(formatted_items) + ")"
            elif isinstance(default_val, list):
                default = "[" + ", ".join(formatted_items) + "]"
            else:
                default = "{" + ", ".join(formatted_items) + "}"
        elif default_val == "":
            default = '""'
        else:
            default = str(default_val)
        if callable(argument.show_default):
            default = argument.show_default(default_val)

    return HelpEntry(
        positive_names=tuple(positive_names),
        positive_shorts=tuple(positive_shorts),
        negative_names=tuple(negative_names),
        negative_shorts=tuple(negative_shorts),
        description=help_description,
        required=argument.required,
        type=resolve_annotated(argument.field_info.annotation),
        choices=choices,
        env_var=env_var,
        default=default,
    )


def create_parameter_help_panel(
    group: "Group",
    argument_collection: "ArgumentCollection",
    format: str,
) -> HelpPanel:
    from rich.text import Text

    kwargs = {
        "format": "parameter",
        "title": group.name,
        "description": InlineText.from_format(group.help, format=format, force_empty_end=True)
        if group.help
        else Text(),
    }

    help_panel = HelpPanel(**kwargs)

    entries_positional, entries_kw = [], []
    for argument in argument_collection.filter_by(show=True):
        if _is_dynamic_structured_dict(argument):
            entries_kw.extend(_expand_structured_dict_for_help(argument, format))
            continue
        entry = _make_help_entry(argument, format)
        if argument.field_info.is_positional:
            entries_positional.append(entry)
        else:
            entries_kw.append(entry)

    help_panel.entries.extend(entries_positional)
    help_panel.entries.extend(entries_kw)

    return help_panel


def format_command_entries(apps_with_names: Iterable, format: str) -> list[HelpEntry]:
    """Format command entries for help display.

    Parameters
    ----------
    apps_with_names : Iterable[RegisteredCommand]
        Iterable of RegisteredCommand tuples.
    format : str
        Help text format.

    Returns
    -------
    list[HelpEntry]
        List of formatted help entries.
    """
    entries = []
    for registered_command in apps_with_names:
        app = registered_command.app
        if not app.show:
            continue
        names = registered_command.names
        # Commands don't have negative variants, so all names are "positive"
        short_names, long_names = [], []
        for name in names:
            short_names.append(name) if _is_short(name) else long_names.append(name)

        sort_key = resolve_callables(app.sort_key, app)

        entry = HelpEntry(
            positive_names=tuple(long_names),
            positive_shorts=tuple(short_names),
            description=InlineText.from_format(docstring_parse(app.help, format).short_description, format=format),
            sort_key=sort_key,
        )
        if entry not in entries:
            entries.append(entry)
    return entries
