"""Generate MDX documentation pages from the Kitaru CLI (cyclopts) command tree.

Introspects the cyclopts App object, extracts command metadata, and writes
structured MDX files with frontmatter + meta.json files for FumaDocs navigation.

Output directory: docs/content/docs/cli/
Generated files are tracked in git and should be regenerated after CLI changes.

Usage:
    uv run python scripts/generate_cli_docs.py
"""

from __future__ import annotations

import inspect
import json
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

REPO_ROOT = Path(__file__).resolve().parent.parent
OUTPUT_DIR = REPO_ROOT / "docs" / "content" / "docs" / "cli"


# ---------------------------------------------------------------------------
# Normalized data model — all rendering works from these, never raw cyclopts
# ---------------------------------------------------------------------------


@dataclass
class ParameterDoc:
    """A single CLI parameter (argument or option)."""

    names: list[str]
    help: str
    type_name: str
    required: bool
    default: str | None
    is_flag: bool
    positional_token: str | None = None
    option_names: list[str] = field(default_factory=list)

    @property
    def names_display(self) -> str:
        return ", ".join(f"`{n}`" for n in self.names)


@dataclass
class CommandDoc:
    """A single CLI command or subcommand."""

    slug: str
    name: str
    invocation: str
    summary: str
    body: str
    usage: str
    parameters: list[ParameterDoc] = field(default_factory=list)
    subcommands: list[CommandDoc] = field(default_factory=list)
    docs_path: tuple[str, ...] = field(default_factory=tuple)

    @property
    def docs_url(self) -> str:
        """Docs-app-relative URL for this command page.

        Source MDX is rendered inside the Next docs app, which already has
        ``basePath: "/docs"``. Keep generated links relative to that app
        root so built HTML publishes them with exactly one public docs
        prefix instead of a doubled docs prefix.
        """
        path_parts = self.docs_path
        if not path_parts:
            # Tests and small call sites often construct CommandDoc directly.
            # In that case, derive the docs path from the command invocation.
            invocation_parts = tuple(self.invocation.split())
            path_parts = (
                invocation_parts[1:]
                if invocation_parts[:1] == ("kitaru",)
                else invocation_parts
            )

        if not path_parts:
            return "/cli/"
        return f"/cli/{'/'.join(path_parts)}/"


# ---------------------------------------------------------------------------
# Extraction — cyclopts introspection
# ---------------------------------------------------------------------------


def _type_display(hint: Any) -> str:
    """Human-readable type name from a Python type hint."""
    if hint is inspect.Parameter.empty or hint is None:
        return ""
    origin = getattr(hint, "__origin__", None)
    if origin is not None:
        args = getattr(hint, "__args__", ())
        arg_strs = ", ".join(_type_display(a) for a in args)
        origin_name = getattr(origin, "__name__", str(origin))
        return f"{origin_name}[{arg_strs}]" if arg_strs else origin_name
    name = getattr(hint, "__name__", None)
    if name:
        return name
    return str(hint)


def _format_default(value: Any) -> str | None:
    """Format a default value for display, or None if no meaningful default."""
    if value is inspect.Parameter.empty:
        return None
    sentinel_names = {"UNSET", "MISSING", "empty"}
    type_name = type(value).__name__
    if type_name in sentinel_names:
        return None
    if isinstance(value, str):
        return f'`"{value}"`'
    return f"`{value!r}`"


def _supports_positional(arg: Any, *, has_keyword_boundary: bool = False) -> bool:
    """Return whether a cyclopts argument should be documented positionally."""
    kind = getattr(arg.field_info, "kind", None)

    if kind is None:
        # cyclopts reports POSITIONAL_ONLY params (after /) with kind=None.
        # Detect by checking that every auto-assigned name lacks a -- prefix.
        return all(not n.startswith("-") for n in (arg.names or ()))

    if kind not in {
        inspect.Parameter.POSITIONAL_ONLY,
        inspect.Parameter.POSITIONAL_OR_KEYWORD,
    }:
        return False

    if arg.required or _is_variadic_hint(arg.hint):
        return True

    # Optional POSITIONAL_OR_KEYWORD: treat as positional only when the
    # command uses a keyword boundary (*,) — any optional non-flag param
    # placed before the boundary was intentionally positional.
    if not has_keyword_boundary:
        return False
    return not arg.is_flag()


def _is_variadic_hint(hint: Any) -> bool:
    """Return whether a positional parameter consumes multiple values."""
    origin = getattr(hint, "__origin__", None)
    return origin in {list, tuple, set}


def _positional_token(arg: Any, *, has_keyword_boundary: bool = False) -> str | None:
    """Build a positional usage token from the underlying Python parameter."""
    if not _supports_positional(arg, has_keyword_boundary=has_keyword_boundary):
        return None

    field_name = getattr(arg.field_info, "name", None)
    if not field_name:
        return None

    token = field_name.rstrip("_").replace("_", "-").upper()
    if _is_variadic_hint(arg.hint):
        token += "..."
    return token


def _format_usage_token(parameter: ParameterDoc) -> str | None:
    """Render one positional token for the usage line."""
    token = parameter.positional_token
    if token is None:
        return None
    if parameter.required:
        return token
    return f"[{token}]"


def _build_usage(
    invocation: str,
    parameters: list[ParameterDoc],
    *,
    has_subcommands: bool,
) -> str:
    """Render a command usage string from normalized parameter docs."""
    usage_parts = [invocation]
    if has_subcommands:
        usage_parts.append("COMMAND")

    for parameter in parameters:
        usage_token = _format_usage_token(parameter)
        if usage_token is not None:
            usage_parts.append(usage_token)

    if any(parameter.option_names for parameter in parameters):
        usage_parts.append("[OPTIONS]")

    return " ".join(usage_parts)


def _extract_parameters(app: Any) -> list[ParameterDoc]:
    """Extract parameter docs from a cyclopts App's argument collection."""
    try:
        args = app.assemble_argument_collection(parse_docstring=True)
    except Exception:
        return []

    # Detect whether the command uses a keyword boundary (*,).  When present,
    # optional POSITIONAL_OR_KEYWORD params before the boundary are positional.
    has_keyword_boundary = any(
        getattr(a.field_info, "kind", None) == inspect.Parameter.KEYWORD_ONLY
        for a in args
        if a.show
    )

    params: list[ParameterDoc] = []
    for arg in args:
        if not arg.show:
            continue

        positional_token = _positional_token(
            arg, has_keyword_boundary=has_keyword_boundary
        )
        explicit_aliases = list(getattr(arg.parameter, "alias", ()) or ())
        if positional_token is not None:
            names = [positional_token, *explicit_aliases]
            option_names = explicit_aliases
        else:
            option_names = (
                list(arg.parameter.name) if arg.parameter.name else list(arg.names)
            )
            names = option_names
        help_text = arg.parameter.help or ""
        type_name = _type_display(arg.hint)
        required = arg.required
        default = _format_default(arg.field_info.default)
        is_flag = arg.is_flag()

        # Deduplicate while preserving order — handles cyclopts duplicates
        # from overlapping auto-name and alias (e.g. from_ + alias=["--from"]).
        names = list(dict.fromkeys(names))
        option_names = list(dict.fromkeys(option_names))

        params.append(
            ParameterDoc(
                names=names,
                help=help_text,
                type_name=type_name,
                required=required,
                default=default,
                is_flag=is_flag,
                positional_token=positional_token,
                option_names=option_names,
            )
        )
    return params


def _get_description(app: Any) -> tuple[str, str]:
    """Extract (summary, body) from a cyclopts App's docstring.

    Google-style docstrings lead with a one-line summary, followed by a
    blank line and then optional detail paragraphs.  We surface the
    summary as the page's frontmatter description (short subtitle) and
    keep the remaining paragraphs as body prose.
    """
    raw = str(app.help) if app.help else ""
    if not raw and app.default_command and callable(app.default_command):
        raw = inspect.getdoc(app.default_command) or ""
    if not raw:
        return ("", "")

    # Drop Args:/Returns:/Raises: sections — cyclopts renders these as
    # parameter help separately.
    kept_lines: list[str] = []
    for line in raw.splitlines():
        if (
            line.strip()
            .lower()
            .startswith(("args:", "arguments:", "returns:", "raises:"))
        ):
            break
        kept_lines.append(line)

    text = "\n".join(kept_lines).strip()
    if not text:
        return ("", "")

    # Split on the first blank line: summary vs detail paragraphs.
    parts = text.split("\n\n", 1)
    summary = " ".join(parts[0].split()).strip()
    body = ""
    if len(parts) == 2:
        body_paragraphs = [
            " ".join(para.split()).strip()
            for para in parts[1].split("\n\n")
            if para.strip()
        ]
        body = "\n\n".join(body_paragraphs)
    return (summary, body)


def build_command_tree(
    app: Any,
    parent_invocation: str = "",
    command_name: str | tuple[str, ...] | None = None,
    parent_docs_path: tuple[str, ...] = (),
) -> CommandDoc:
    """Recursively build a normalized command tree from a cyclopts App."""
    raw_name = app.name if command_name is None else command_name
    name_parts: tuple[str, ...] = (
        raw_name if isinstance(raw_name, tuple) else (str(raw_name),)
    )
    name = name_parts[-1]
    invocation = f"{parent_invocation} {name}".strip() if parent_invocation else name
    slug = name
    docs_path = parent_docs_path + ((slug,) if parent_invocation else ())

    summary, body = _get_description(app)

    params = _extract_parameters(app)
    registered = getattr(app, "_registered_commands", {})
    has_subcommands = bool(registered)
    usage = _build_usage(invocation, params, has_subcommands=has_subcommands)

    # Recurse into subcommands
    subcommands: list[CommandDoc] = []
    for cmd_name, sub_app in sorted(registered.items()):
        subcommands.append(
            build_command_tree(
                sub_app,
                parent_invocation=invocation,
                command_name=cmd_name,
                parent_docs_path=docs_path,
            )
        )

    return CommandDoc(
        slug=slug,
        name=name,
        invocation=invocation,
        summary=summary,
        body=body,
        usage=usage,
        parameters=params,
        subcommands=subcommands,
        docs_path=docs_path,
    )


# ---------------------------------------------------------------------------
# Rendering — normalized model to MDX strings
# ---------------------------------------------------------------------------


def _escape_mdx(text: str) -> str:
    """Escape characters that MDX treats specially in prose."""
    text = text.replace("{", "\\{")
    text = text.replace("}", "\\}")
    text = text.replace("<", "&lt;")
    text = text.replace(">", "&gt;")
    return text


def render_command_page(cmd: CommandDoc, *, is_root: bool = False) -> str:
    """Render a single command's MDX page content."""
    lines: list[str] = []

    # Frontmatter
    title = "CLI Reference" if is_root else cmd.invocation
    desc = cmd.summary or f"Reference for the `{cmd.invocation}` command."
    # Escape YAML special characters in description
    safe_desc = desc.replace('"', '\\"')
    lines.append("---")
    lines.append(f'title: "{title}"')
    lines.append(f'description: "{safe_desc}"')
    lines.append("---")
    lines.append("")

    # FumaDocs renders the frontmatter description as the page subtitle.
    # Detail paragraphs (if any) go in the body as normal prose so long
    # descriptions don't become a wall of text under the h1.
    if cmd.body:
        lines.append(_escape_mdx(cmd.body))
        lines.append("")

    # Usage
    lines.append("## Usage")
    lines.append("")
    lines.append("```bash")
    lines.append(cmd.usage)
    lines.append("```")
    lines.append("")

    # Global flags (root only)
    if is_root:
        lines.append("## Global Flags")
        lines.append("")
        lines.append("| Flag | Description |")
        lines.append("| --- | --- |")
        lines.append("| `--help`, `-h` | Display help and exit |")
        lines.append("| `--version`, `-V` | Display the installed version and exit |")
        lines.append("")
        lines.append("## Output formats")
        lines.append("")
        lines.append(
            "Most agent-facing commands support `--output json` "
            "(or `-o json`) in addition to the default text output."
        )
        lines.append("")
        lines.append(
            "- **Text output** is designed for people reading the terminal directly."
        )
        lines.append(
            "- **JSON output** is designed for agents and scripts "
            "that need a stable structure."
        )
        lines.append("- Single-item commands emit `{command, item}`.")
        lines.append("- List commands emit `{command, items, count}`.")
        lines.append(
            "- `kitaru executions logs --follow --output json` is the "
            "special case: it emits one JSON event per line while following "
            "the stream."
        )
        lines.append("")

    # Parameters table
    if cmd.parameters:
        lines.append("## Parameters")
        lines.append("")
        lines.append("| Name | Type | Required | Default | Description |")
        lines.append("| --- | --- | --- | --- | --- |")
        for p in cmd.parameters:
            names_str = p.names_display
            type_str = f"`{p.type_name}`" if p.type_name else ""
            req_str = "Yes" if p.required else "No"
            default_str = p.default or ""
            desc_str = _escape_mdx(p.help)
            lines.append(
                f"| {names_str} | {type_str} | {req_str} | {default_str} | {desc_str} |"
            )
        lines.append("")

    # Subcommands list
    if cmd.subcommands:
        lines.append("## Commands")
        lines.append("")
        lines.append("| Command | Description |")
        lines.append("| --- | --- |")
        for sub in cmd.subcommands:
            # Use summary only: markdown tables can't contain block-level
            # content, so a body with lists or code fences would break the
            # table. The detail body is already on the subcommand's own page.
            desc_text = _escape_mdx(sub.summary) if sub.summary else ""
            lines.append(f"| [`{sub.name}`]({sub.docs_url}) | {desc_text} |")
        lines.append("")

    return "\n".join(lines)


def render_meta(
    title: str, children: list[CommandDoc], *, default_open: bool = False
) -> dict[str, Any]:
    """Build a meta.json dict for a directory."""
    pages: list[str] = [child.slug for child in children]
    meta: dict[str, Any] = {"title": title}
    if default_open:
        meta["defaultOpen"] = True
    meta["pages"] = pages
    return meta


# ---------------------------------------------------------------------------
# Filesystem — write the generated tree
# ---------------------------------------------------------------------------


def write_docs_tree(root: CommandDoc, output_dir: Path) -> list[str]:
    """Write the full CLI docs tree to output_dir. Returns list of created files."""
    created: list[str] = []

    def _write_command(
        cmd: CommandDoc, parent_dir: Path, *, is_root: bool = False
    ) -> None:
        if is_root or cmd.subcommands:
            # Directory node: has children, so needs index.mdx + meta.json
            dir_path = parent_dir if is_root else parent_dir / cmd.slug
            dir_path.mkdir(parents=True, exist_ok=True)

            page_path = dir_path / "index.mdx"
            page_path.write_text(render_command_page(cmd, is_root=is_root))
            created.append(str(page_path.relative_to(output_dir)))

            title = "CLI Reference" if is_root else cmd.invocation
            meta = render_meta(title, cmd.subcommands)
            meta_path = dir_path / "meta.json"
            meta_path.write_text(json.dumps(meta, indent=2) + "\n")
            created.append(str(meta_path.relative_to(output_dir)))

            for sub in cmd.subcommands:
                _write_command(sub, dir_path)
        else:
            # Leaf node: no children, write as flat .mdx file
            parent_dir.mkdir(parents=True, exist_ok=True)
            page_path = parent_dir / f"{cmd.slug}.mdx"
            page_path.write_text(render_command_page(cmd, is_root=False))
            created.append(str(page_path.relative_to(output_dir)))

    _write_command(root, output_dir, is_root=True)
    return created


def _clean_output(output_dir: Path, flat_file: Path) -> None:
    """Remove previous output (both directory and flat file forms)."""
    if output_dir.exists():
        shutil.rmtree(output_dir)
    if flat_file.exists():
        flat_file.unlink()


def main() -> int:
    """Generate CLI reference docs from the Kitaru cyclopts app."""
    from kitaru.cli import app

    print("Extracting CLI command tree...")
    tree = build_command_tree(app)

    flat_file = OUTPUT_DIR.with_suffix(".mdx")
    _clean_output(OUTPUT_DIR, flat_file)

    if not tree.subcommands:
        # No subcommands: generate a single flat file (avoids nested sidebar)
        flat_file.write_text(render_command_page(tree, is_root=True))
        print(f"Generated {flat_file.relative_to(REPO_ROOT)} (flat, no subcommands)")
    else:
        # Has subcommands: generate a directory tree
        tmp_dir = Path(tempfile.mkdtemp(prefix="kitaru-cli-docs-"))
        try:
            files = write_docs_tree(tree, tmp_dir)
            shutil.copytree(tmp_dir, OUTPUT_DIR)
            print(
                f"Generated {len(files)} files in {OUTPUT_DIR.relative_to(REPO_ROOT)}/"
            )
            for f in sorted(files):
                print(f"  {f}")
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
