"""Fish completion script generator.

Generates static fish completion scripts using `complete -c COMMAND` statements.
Completions auto-load from ~/.config/fish/completions/PROGNAME.fish.
"""

import re
from typing import TYPE_CHECKING

from cyclopts.annotations import is_iterable_type
from cyclopts.completion._base import (
    CompletionAction,
    CompletionData,
    clean_choice_text,
    extract_completion_data,
    get_completion_action,
    strip_markup,
)

if TYPE_CHECKING:
    from cyclopts import App
    from cyclopts.command_spec import CommandSpec


def generate_completion_script(app: "App", prog_name: str) -> str:
    """Generate fish completion script.

    Parameters
    ----------
    app : App
        The Cyclopts application to generate completion for.
    prog_name : str
        Program name for completion (alphanumeric with hyphens/underscores).

    Returns
    -------
    str
        Complete fish completion script.

    Raises
    ------
    ValueError
        If prog_name contains invalid characters.
    """
    if not prog_name or not re.match(r"^[a-zA-Z0-9_-]+$", prog_name):
        raise ValueError(f"Invalid prog_name: {prog_name!r}. Must be alphanumeric with hyphens/underscores.")

    completion_data = extract_completion_data(app)

    lines = [
        f"# Fish completion for {prog_name}",
        "# Generated by Cyclopts",
        "",
    ]

    has_nested_commands = any(len(path) > 0 for path in completion_data.keys())
    if has_nested_commands:
        lines.extend(_generate_helper_functions(prog_name, completion_data))
        lines.append("")

    if _any_nested_positional_choices(completion_data):
        lines.extend(_generate_positional_index_helper(prog_name, completion_data))
        lines.append("")

    help_flags = tuple(app.help_flags) if app.help_flags else ()
    version_flags = tuple(app.version_flags) if app.version_flags else ()

    lines.extend(_generate_completions(completion_data, prog_name, help_flags, version_flags))

    return "\n".join(lines) + "\n"


def _any_nested_positional_choices(completion_data: dict[tuple[str, ...], CompletionData]) -> bool:
    """Whether any nested command path has a positional argument with choices.

    The positional-index helper is only needed when there is at least one
    nested positional that emits a choice list — without choices, fish's
    default file fallback already produces sensible completions.
    """
    for path, data in completion_data.items():
        if not path:
            continue
        for argument in data.arguments:
            if argument.index is None or not argument.show:
                continue
            if argument.get_choices(force=True):
                return True
    return False


def _escape_fish_string(text: str) -> str:
    r"""Escape single quotes for fish strings."""
    return text.replace("'", r"'\''")


def _escape_fish_description(text: str) -> str:
    """Escape description text for fish."""
    text = text.replace("\n", " ")
    text = text.replace("\r", " ")
    return _escape_fish_string(text)


def _generate_helper_functions(
    prog_name: str,
    completion_data: dict[tuple[str, ...], CompletionData],
) -> list[str]:
    """Generate helper function for command path detection.

    Non-option words are classified by membership in a globally-aggregated
    set of registered command names (``all_commands``) — this matches the
    bash detector's behavior and prevents positional argument *values* from
    being mistaken for subcommands. Without this filter, typing any
    positional after the subcommand causes the helper to count it as a
    deeper command path, which then makes every ``-n '<helper> <path>'``
    rule (including option and positional-choice rules) stop firing.

    Note: ``all_commands`` is built globally across all command levels —
    a positional value that happens to equal a real subcommand name from
    *some* level can still be misclassified, but that represents poor CLI
    design and matches the bash detector's behavior.

    Parameters
    ----------
    prog_name : str
        Program name.
    completion_data : dict
        Completion data used to identify options that take values.

    Returns
    -------
    list[str]
        Lines defining the helper function.
    """
    options_with_values = set()
    all_commands = set()
    for data in completion_data.values():
        for argument in data.arguments:
            if not argument.is_flag() and argument.parameter.name:
                for name in argument.parameter.name:
                    if name.startswith("-"):
                        options_with_values.add(name)
        for registered_command in data.commands:
            for cmd_name in registered_command.names:
                if not cmd_name.startswith("-"):
                    all_commands.add(cmd_name)

    func_name = f"__fish_{prog_name}_using_command"
    lines = [
        "# Helper function to check exact command path sequence",
        f"function {func_name}",
        "    set -l cmd (commandline -opc)",
        "    set -l subcommands",
    ]

    if options_with_values:
        escaped_opts = " ".join(_escape_fish_string(opt) for opt in sorted(options_with_values))
        lines.append(f"    set -l options_with_values '{escaped_opts}'")
    else:
        lines.append("    set -l options_with_values ''")

    if all_commands:
        escaped_cmds = " ".join(_escape_fish_string(cmd) for cmd in sorted(all_commands))
        lines.append(f"    set -l all_commands '{escaped_cmds}'")
    else:
        lines.append("    set -l all_commands ''")

    lines.extend(
        [
            "    set -l skip_next 0",
            "    # Extract command words (only real subcommand names) from command line",
            "    for i in (seq 2 (count $cmd))",
            "        set -l word $cmd[$i]",
            "        if test $skip_next -eq 1",
            "            set skip_next 0",
            "            continue",
            "        end",
            "        if string match -qr -- '^-' $word",
            "            # Check if this option takes a value (exact match)",
            '            if string match -q -- "* $word *" " $options_with_values "',
            "                set skip_next 1",
            "            end",
            "        else",
            "            # Only add to subcommands if word is a registered command name",
            '            if string match -q -- "* $word *" " $all_commands "',
            "                set -a subcommands $word",
            "            end",
            "        end",
            "    end",
            "    # Check if subcommand sequence matches expected path",
            "    if test (count $subcommands) -ne (count $argv)",
            "        return 1",
            "    end",
            "    for i in (seq 1 (count $argv))",
            "        if test $subcommands[$i] != $argv[$i]",
            "            return 1",
            "        end",
            "    end",
            "    return 0",
            "end",
        ]
    )
    return lines


def _generate_positional_index_helper(
    prog_name: str,
    completion_data: dict[tuple[str, ...], CompletionData],
) -> list[str]:
    """Emit a fish function that returns the current positional slot index.

    The function takes one argument — the length of the active command path
    — and walks ``commandline -opc`` to count non-option, non-path tokens
    encountered before the cursor. Options that take a value are detected
    using a globally-aggregated list (same approach as the
    ``__fish_<prog>_using_command`` helper) so their value tokens don't get
    counted as positionals.
    """
    options_with_values = set()
    for data in completion_data.values():
        for argument in data.arguments:
            if not argument.is_flag() and argument.parameter.name:
                for name in argument.parameter.name:
                    if name.startswith("-"):
                        options_with_values.add(name)

    func_name = f"__fish_{prog_name}_positional_index"
    lines = [
        "# Helper: print the index of the next positional slot for the active command path.",
        f"function {func_name}",
        "    set -l cmd (commandline -opc)",
        "    set -l path_len $argv[1]",
    ]
    if options_with_values:
        escaped_opts = " ".join(_escape_fish_string(opt) for opt in sorted(options_with_values))
        lines.append(f"    set -l options_with_values '{escaped_opts}'")
    else:
        lines.append("    set -l options_with_values ''")

    lines.extend(
        [
            "    set -l skip_next 0",
            "    set -l consumed_path 0",
            "    set -l count 0",
            "    for i in (seq 2 (count $cmd))",
            "        set -l word $cmd[$i]",
            "        if test $skip_next -eq 1",
            "            set skip_next 0",
            "            continue",
            "        end",
            "        if string match -qr -- '^-' $word",
            '            if string match -q -- "* $word *" " $options_with_values "',
            "                set skip_next 1",
            "            end",
            "        else",
            "            if test $consumed_path -lt $path_len",
            "                set consumed_path (math $consumed_path + 1)",
            "            else",
            "                set count (math $count + 1)",
            "            end",
            "        end",
            "    end",
            "    echo $count",
            "end",
        ]
    )
    return lines


def _map_completion_action_to_fish(action: CompletionAction) -> str:
    """Map completion action to fish flags.

    Parameters
    ----------
    action : CompletionAction
        Completion action type.

    Returns
    -------
    str
        Fish completion flags ("-r -F" for files, "-r -a '(...)'" for directories, "" otherwise).
    """
    if action == CompletionAction.FILES:
        return "-r -F"
    if action == CompletionAction.DIRECTORIES:
        return "-r -a '(__fish_complete_directories)'"
    return ""


def _generate_completions(
    completion_data: dict[tuple[str, ...], CompletionData],
    prog_name: str,
    help_flags: tuple[str, ...],
    version_flags: tuple[str, ...],
) -> list[str]:
    """Generate all fish completion commands.

    Parameters
    ----------
    completion_data : dict
        Extracted completion data.
    prog_name : str
        Program name.
    help_flags : tuple[str, ...]
        Help flags.
    version_flags : tuple[str, ...]
        Version flags.

    Returns
    -------
    list[str]
        Completion command lines.
    """
    lines = []

    for command_path, _data in sorted(completion_data.items()):
        lines.extend(
            _generate_completions_for_path(
                completion_data,
                command_path,
                prog_name,
                help_flags,
                version_flags,
            )
        )
        if command_path != max(completion_data.keys(), key=len):
            lines.append("")

    return lines


def _generate_completions_for_path(
    completion_data: dict[tuple[str, ...], CompletionData],
    command_path: tuple[str, ...],
    prog_name: str,
    help_flags: tuple[str, ...],
    version_flags: tuple[str, ...],
) -> list[str]:
    """Generate completions for a specific command path.

    Parameters
    ----------
    completion_data : dict
        Extracted completion data.
    command_path : tuple[str, ...]
        Command path.
    prog_name : str
        Program name.
    help_flags : tuple[str, ...]
        Help flags.
    version_flags : tuple[str, ...]
        Version flags.

    Returns
    -------
    list[str]
        Completion command lines.
    """
    if command_path not in completion_data:
        return []

    data = completion_data[command_path]
    lines = []
    condition = _get_condition_for_path(command_path, prog_name)

    lines.extend(_generate_subcommand_completions(data, command_path, prog_name, condition))

    keyword_args = [arg for arg in data.arguments if not arg.is_positional_only() and arg.show]
    if keyword_args or help_flags or version_flags:
        lines.extend(_generate_option_section_header(command_path))
        lines.extend(_generate_help_version_completions(prog_name, condition, help_flags, version_flags))
        lines.extend(_generate_keyword_arg_completions(keyword_args, prog_name, condition, data.help_format))
        lines.extend(_generate_command_option_completions(data.commands, prog_name, condition, data.help_format))

    lines.extend(_generate_positional_completions(data, command_path, prog_name))

    return lines


def _generate_positional_completions(
    data: CompletionData,
    command_path: tuple[str, ...],
    prog_name: str,
) -> list[str]:
    """Emit per-position choice rules for positional args at a nested path.

    Only nested paths (``len(command_path) > 0``) are handled. At root, the
    natural fish gate ``__fish_use_subcommand`` flips false as soon as the
    first positional is typed, so a position-N rule for N>0 can't be
    expressed cleanly there; root-level positional values (rare in practice)
    fall back to fish's default behavior.

    Positionals without choices (e.g. ``Path``) emit nothing — fish's
    default file completion kicks in automatically at positions where no
    rule fires.

    Iterable positionals (``list[X]``, ``set[X]``, ``*args``) own every
    slot from their index onwards. To avoid emitting two competing
    rest-arg specs (mirrors the bash/zsh "first iterable wins" rule), only
    the first iterable contributes a rest rule; later iterables remain
    reachable via their ``--name`` keyword forms.
    """
    if not command_path:
        return []

    positional_args = [arg for arg in data.arguments if arg.index is not None and arg.show]
    if not positional_args:
        return []

    positional_args.sort(key=lambda a: a.index or 0)

    rest_idx = None
    for i, arg in enumerate(positional_args):
        if arg.is_var_positional():
            rest_idx = i
            break
    if rest_idx is None:
        for i, arg in enumerate(positional_args):
            if is_iterable_type(arg.hint):
                rest_idx = i
                break

    head = positional_args if rest_idx is None else positional_args[:rest_idx]
    rest_owner = None if rest_idx is None else positional_args[rest_idx]

    helper_fn = f"__fish_{prog_name}_positional_index"
    using_cmd_fn = f"__fish_{prog_name}_using_command"
    path_len = len(command_path)
    escaped_commands = " ".join(_escape_fish_string(cmd) for cmd in command_path)
    base_predicate = f"{using_cmd_fn} {escaped_commands}"

    lines: list[str] = []
    header_emitted = False

    def _ensure_header() -> None:
        nonlocal header_emitted
        if not header_emitted:
            lines.append(f"# Positionals for: {' '.join(command_path)}")
            header_emitted = True

    for slot_idx, argument in enumerate(head):
        choices = argument.get_choices(force=True)
        if not choices:
            continue
        escaped_choices = [_escape_fish_string(clean_choice_text(c)) for c in choices]
        choices_str = " ".join(escaped_choices)
        pos_cond = f"{base_predicate}; and test ({helper_fn} {path_len}) = {slot_idx}"
        _ensure_header()
        lines.append(f"complete -c {prog_name} -n '{pos_cond}' -f -a '{choices_str}'")

    if rest_owner is not None:
        choices = rest_owner.get_choices(force=True)
        if choices:
            rest_slot = rest_idx if rest_idx is not None else 0
            escaped_choices = [_escape_fish_string(clean_choice_text(c)) for c in choices]
            choices_str = " ".join(escaped_choices)
            pos_cond = f"{base_predicate}; and test ({helper_fn} {path_len}) -ge {rest_slot}"
            _ensure_header()
            lines.append(f"complete -c {prog_name} -n '{pos_cond}' -f -a '{choices_str}'")

    return lines


def _generate_subcommand_completions(
    data: CompletionData,
    command_path: tuple[str, ...],
    prog_name: str,
    condition: str,
) -> list[str]:
    """Generate completions for subcommands.

    Parameters
    ----------
    data : CompletionData
        Completion data.
    command_path : tuple[str, ...]
        Command path.
    prog_name : str
        Program name.
    condition : str
        Fish condition.

    Returns
    -------
    list[str]
        Completion command lines.
    """
    commands = [
        name for registered_command in data.commands for name in registered_command.names if not name.startswith("-")
    ]
    if not commands:
        return []

    lines = []
    if command_path:
        lines.append(f"# Subcommands for: {' '.join(command_path)}")
    else:
        lines.append("# Root-level commands")

    for registered_command in data.commands:
        for cmd_name in registered_command.names:
            if cmd_name.startswith("-"):
                continue

            desc = _get_description_from_app(registered_command.app, data.help_format)
            escaped_desc = _escape_fish_description(desc)
            escaped_cmd = _escape_fish_string(cmd_name)

            lines.append(f"complete -c {prog_name} {condition} -a '{escaped_cmd}' -d '{escaped_desc}'")

    return lines


def _generate_option_section_header(command_path: tuple[str, ...]) -> list[str]:
    """Generate section header comment for options.

    Parameters
    ----------
    command_path : tuple[str, ...]
        Command path.

    Returns
    -------
    list[str]
        Comment line.
    """
    if command_path:
        return [f"# Options for: {' '.join(command_path)}"]
    return ["# Root-level options"]


def _generate_help_version_completions(
    prog_name: str,
    condition: str,
    help_flags: tuple[str, ...],
    version_flags: tuple[str, ...],
) -> list[str]:
    """Generate completions for help and version flags.

    Parameters
    ----------
    prog_name : str
        Program name.
    condition : str
        Fish condition.
    help_flags : tuple[str, ...]
        Help flags.
    version_flags : tuple[str, ...]
        Version flags.

    Returns
    -------
    list[str]
        Completion command lines.
    """
    lines = []

    for flag in help_flags:
        if flag.startswith("--"):
            long_name = flag[2:]
            lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d 'Display this message and exit.'")
        elif flag.startswith("-") and len(flag) == 2:
            short_name = flag[1]
            lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d 'Display this message and exit.'")

    for flag in version_flags:
        if flag.startswith("--"):
            long_name = flag[2:]
            lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d 'Display application version.'")
        elif flag.startswith("-") and len(flag) == 2:
            short_name = flag[1]
            lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d 'Display application version.'")

    return lines


def _generate_keyword_arg_completions(
    keyword_args: list,
    prog_name: str,
    condition: str,
    help_format: str,
) -> list[str]:
    """Generate completions for keyword arguments.

    Parameters
    ----------
    keyword_args : list
        Keyword arguments.
    prog_name : str
        Program name.
    condition : str
        Fish condition.
    help_format : str
        Help text format.

    Returns
    -------
    list[str]
        Completion command lines.
    """
    lines = []

    for argument in keyword_args:
        desc = strip_markup(argument.parameter.help or "", format=help_format)
        escaped_desc = _escape_fish_description(desc)

        is_flag = argument.is_flag()
        choices = argument.get_choices(force=True)
        action = get_completion_action(argument.hint)

        for name in argument.parameter.name or []:
            if not name.startswith("-"):
                continue

            if name.startswith("--"):
                long_name = name[2:]
                line_parts = [f"complete -c {prog_name} {condition} -l {long_name}"]
            elif len(name) == 2:
                short_name = name[1]
                line_parts = [f"complete -c {prog_name} {condition} -s {short_name}"]
            else:
                continue

            if is_flag:
                line_parts.append(f"-d '{escaped_desc}'")
            elif choices:
                escaped_choices = [_escape_fish_string(clean_choice_text(c)) for c in choices]
                choices_str = " ".join(escaped_choices)
                line_parts.append(f"-x -a '{choices_str}' -d '{escaped_desc}'")
            else:
                action_flags = _map_completion_action_to_fish(action)
                if action_flags:
                    line_parts.append(f"{action_flags} -d '{escaped_desc}'")
                else:
                    line_parts.append(f"-r -d '{escaped_desc}'")

            lines.append(" ".join(line_parts))

        for name in argument.negatives:
            if not name.startswith("-"):
                continue

            if name.startswith("--"):
                long_name = name[2:]
                lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d '{escaped_desc}'")
            elif len(name) == 2:
                short_name = name[1]
                lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d '{escaped_desc}'")

    return lines


def _generate_command_option_completions(
    commands: list,
    prog_name: str,
    condition: str,
    help_format: str,
) -> list[str]:
    """Generate completions for commands that look like options.

    Parameters
    ----------
    commands : list
        List of RegisteredCommand tuples.
    prog_name : str
        Program name.
    condition : str
        Fish condition.
    help_format : str
        Help text format.

    Returns
    -------
    list[str]
        Completion command lines.
    """
    lines = []

    for registered_command in commands:
        for cmd_name in registered_command.names:
            if not cmd_name.startswith("-"):
                continue

            desc = _get_description_from_app(registered_command.app, help_format)
            escaped_desc = _escape_fish_description(desc)

            if cmd_name.startswith("--"):
                long_name = cmd_name[2:]
                lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d '{escaped_desc}'")
            elif len(cmd_name) == 2:
                short_name = cmd_name[1]
                lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d '{escaped_desc}'")

    return lines


def _get_condition_for_path(command_path: tuple[str, ...], prog_name: str) -> str:
    """Generate fish condition string for a command path.

    Parameters
    ----------
    command_path : tuple[str, ...]
        Command path (empty for root).
    prog_name : str
        Program name.

    Returns
    -------
    str
        Fish condition flag.
    """
    if not command_path:
        return "-n __fish_use_subcommand"

    func_name = f"__fish_{prog_name}_using_command"
    escaped_commands = " ".join(_escape_fish_string(cmd) for cmd in command_path)
    return f"-n '{func_name} {escaped_commands}'"


def _get_description_from_app(cmd_app: "App | CommandSpec", help_format: str) -> str:
    """Extract description from App.

    Parameters
    ----------
    cmd_app : App | CommandSpec
        Command app or spec.
    help_format : str
        Help text format.

    Returns
    -------
    str
        Description text.
    """
    from cyclopts.help.help import docstring_parse

    try:
        parsed = docstring_parse(cmd_app.help, "plaintext")
        text = parsed.short_description or ""
    except Exception:
        text = str(cmd_app.help or "")

    return strip_markup(text, format=help_format)
