#!/usr/bin/env python3
"""Regenerate the option tables embedded in the documentation.

The README and ``docs/`` carry a human-readable option table.  It is *generated*
from :data:`sr.options.OPTIONS` between marker comments, so adding a flag
updates the docs automatically and CI can fail when they drift.

Markers understood::

    <!-- BEGIN OPTIONS — generated; do not edit -->
    …
    <!-- END OPTIONS -->

Usage::

    python3 scripts/gen_options_table.py            # rewrite every marker in place
    python3 scripts/gen_options_table.py --check    # fail when out of date
    python3 scripts/gen_options_table.py --stdout   # print the table only
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from sr.options import GROUPS, OPTIONS, OptionKind
from sr.registry import SUBCOMMANDS
from sr.util import format_size

#: Files that carry a generated table.
TARGETS = (ROOT / "README.md", ROOT / "docs" / "OPTIONS.md")

#: Flags argparse adds itself; they are documented in prose, not in a table.
IMPLICIT_FLAGS = frozenset({"-h", "--help"})

BEGIN = "<!-- BEGIN OPTIONS — generated by scripts/gen_options_table.py; do not edit -->"
END = "<!-- END OPTIONS -->"

_BLOCK_RE = re.compile(
    re.escape(BEGIN) + r".*?" + re.escape(END),
    re.DOTALL,
)


def _default_text(spec) -> str:
    default = spec.default
    if default is True:
        return "on"
    if default is False or default in (None, "", ()):
        return "—"
    if spec.kind == OptionKind.SIZE and isinstance(default, int):
        return f"`{format_size(default)}`"
    return f"`{default}`"


def _subparsers(parser) -> dict:
    for action in parser._actions:
        choices = getattr(action, "choices", None)
        if isinstance(choices, dict) and choices:
            return choices
    return {}


def extra_flags_by_command() -> dict[str, list[tuple[str, str]]]:
    """Flags the parser accepts that are *not* in the option schema.

    These are the hand-written, command-local options (`find --count`,
    `rollback --session`, `update --channel`, …).  Listing them here keeps the
    documentation complete and keeps the coverage gate honest: a new hand-written
    flag shows up in the table automatically instead of being invisible.
    """
    from sr.cli import build_parser

    schema_flags = {flag for spec in OPTIONS for flag in spec.flag_names()}
    parser = build_parser()
    result: dict[str, list[tuple[str, str]]] = {}

    def harvest(target, bucket: str) -> None:
        seen: dict[str, str] = {}
        for action in target._actions:
            names = [name for name in action.option_strings if name]
            if not names:
                continue
            if any(name in schema_flags for name in names):
                continue
            if set(names) <= IMPLICIT_FLAGS:
                continue
            label = ", ".join(f"`{name}`" for name in names)
            if action.metavar:
                label += f" `{action.metavar}`"
            elif action.nargs is None and not isinstance(action.const, bool) and action.const is not None:
                pass
            help_text = (action.help or "").strip()
            if help_text == argparse.SUPPRESS:
                continue
            seen[label] = help_text or "—"
        if seen:
            result[bucket] = list(seen.items())

    harvest(parser, "(global)")
    for name, sub in _subparsers(parser).items():
        harvest(sub, name)
    return result


def render_extra_tables() -> list[str]:
    """Render the command-local options that the schema does not declare."""
    extras = extra_flags_by_command()
    if not extras:
        return []
    lines: list[str] = [
        "<details>",
        "<summary><b>Command-local options</b> (accepted by one command only)</summary>",
        "",
        "These are declared next to their subparser rather than in the shared",
        "schema, because they exist for exactly one command. They are generated",
        "into this table from the live parser, so they cannot go stale.",
        "",
    ]
    order = ["(global)", *[name for name, _aliases, _s in SUBCOMMANDS]]
    for bucket in order:
        entries = extras.get(bucket)
        if not entries:
            continue
        heading = "Global" if bucket == "(global)" else f"`sr {bucket}`"
        lines.append(f"**{heading}**")
        lines.append("")
        lines.append("| Flag | What it does |")
        lines.append("| --- | --- |")
        for label, help_text in entries:
            lines.append(f"| {label} | {help_text.rstrip('.')} |")
        lines.append("")
    lines.append("</details>")
    lines.append("")
    return lines


def render_table() -> str:
    """Render the full grouped option table as Markdown."""
    lines: list[str] = [BEGIN, ""]
    lines.append(
        f"{len(OPTIONS)} cross-command options. Each one is also a configuration key "
        "(see `sr config list`) and an `SR_*` environment variable."
    )
    lines.append("")
    for group_name, title in GROUPS:
        specs = [spec for spec in OPTIONS if spec.group == group_name and not spec.hidden]
        if not specs:
            continue
        lines.append("<details open>")
        lines.append(f"<summary><b>{title}</b> ({len(specs)})</summary>")
        lines.append("")
        lines.append("| Flag | Environment | Default | What it does |")
        lines.append("| --- | --- | --- | --- |")
        for spec in specs:
            flags = ", ".join(f"`{flag}`" for flag in spec.flag_names())
            if spec.takes_value:
                flags += f" `{spec.metavar or spec.kind.upper()}`"
            help_text = spec.help.rstrip(".")
            if spec.kind == OptionKind.CHOICE and spec.choices:
                help_text += f" — one of {', '.join(f'`{c}`' for c in spec.choices)}"
            commands = ", ".join(f"`{c}`" for c in spec.commands) if spec.commands else "all"
            lines.append(
                f"| {flags} | `{spec.env_var}` | {_default_text(spec)} | {help_text}. "
                f"*({commands})* |"
            )
        lines.append("")
        lines.append("</details>")
        lines.append("")
    lines.extend(render_extra_tables())
    lines.append(END)
    return "\n".join(lines)


def cli_coverage_gap(table: str) -> list[str]:
    """CLI flags that the rendered documentation does not mention.

    This is the gate that was missing: `--check` used to compare the generator's
    output with the committed file, which stays green when the *schema* and the
    *CLI* disagree — exactly how `--no-color` went undocumented in README,
    docs/OPTIONS.md and the man page while being present in the parser and in
    the completions.
    """
    from sr.cli import cli_flags

    documented = {
        chunk.strip()
        for chunk in table.replace("`", " ").replace(",", " ").split()
        if chunk.strip().startswith("-")
    }
    return sorted(
        flag
        for flag in cli_flags()
        if flag not in IMPLICIT_FLAGS and flag not in documented
    )


def rewrite(path: Path, table: str, *, check: bool) -> bool:
    """Replace the marked block in ``path``; returns True when it changed."""
    if not path.is_file():
        return False
    text = path.read_text(encoding="utf-8")
    if BEGIN not in text:
        return False
    updated = _BLOCK_RE.sub(lambda _match: table, text, count=1)
    if updated == text:
        return False
    if check:
        print(f"{path}: option table is out of date", file=sys.stderr)
        return True
    path.write_text(updated, encoding="utf-8")
    print(f"{path}: option table regenerated")
    return True


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--check", action="store_true", help="Fail when a table is out of date.")
    parser.add_argument("--stdout", action="store_true", help="Print the table and change nothing.")
    parser.add_argument(
        "files",
        nargs="*",
        type=Path,
        help="Files to update (default: README.md and docs/OPTIONS.md).",
    )
    args = parser.parse_args(argv)

    table = render_table()

    missing = cli_coverage_gap(table)
    if missing:
        print(
            "the option table does not cover every flag the CLI accepts:\n  "
            + ", ".join(missing)
            + "\nadd them to sr/options.py (preferred) or to the rendered table",
            file=sys.stderr,
        )
        return 1

    if args.stdout:
        print(table)
        return 0

    targets = [Path(item) for item in args.files] or list(TARGETS)
    stale = [path for path in targets if rewrite(path, table, check=args.check)]
    if args.check and stale:
        print("run: python3 scripts/gen_options_table.py", file=sys.stderr)
        return 1
    if not args.check:
        untouched = [path for path in targets if path.is_file() and BEGIN not in path.read_text(encoding="utf-8")]
        for path in untouched:
            print(f"{path}: no option-table markers found (skipped)")
    return 0


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