"""Regenerate every docs/onboarding guard command surface from the justfile.

The repo ``justfile`` is the single source of truth for the ``guard-*``
recipes: each recipe's comment is its description and each recipe body is
the raw no-``just`` fallback command. This script renders that data into:

* ``src/easycat/cli/_guard_commands.py`` — the generated ``just guard-*``
  names imported by ``easycat.cli._app`` for shipped route command hints
  (shipped code cannot read the repo justfile).
* ``<!-- BEGIN auto:guard-commands format=... -->`` blocks in
  ``docs/validation.md``, ``CLAUDE.md``, ``AGENTS.md``, and
  ``CONTRIBUTING.md``. The raw ``uv run pytest ...`` fallback strings stay
  inside the generated blocks because agents in no-``just`` environments
  copy them verbatim.

Block formats:

* ``just-bash`` — bash fence of ``just guard-*`` lines with short comments.
* ``raw-bash`` — bash fence of the raw fallback commands.
* ``bash`` — bash fence with both ``just`` lines and raw fallback lines.
* ``bullets`` — Markdown bullets for ``just`` recipes and raw fallbacks.
* ``table`` — Markdown table of description / recipe / raw command rows.

Run after editing a ``guard-*`` recipe; ``--check`` exits non-zero if any
target would change, which is what the staleness guard test calls.
"""

from __future__ import annotations

import argparse
import difflib
import re
import sys
from collections.abc import Callable, Sequence
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from scripts._justfile import GuardRecipe, just_guard_recipes  # noqa: E402

BLOCK_RE = re.compile(
    r"(?P<begin><!-- BEGIN auto:guard-commands format=(?P<format>[a-z-]+) -->)"
    r"(?P<body>.*?)"
    r"(?P<end><!-- END auto:guard-commands -->)",
    re.DOTALL,
)

MARKDOWN_TARGETS = (
    "docs/validation.md",
    "CLAUDE.md",
    "AGENTS.md",
    "CONTRIBUTING.md",
    "docs/development/08-development-and-testing.md",
)
MODULE_TARGET = Path("src") / "easycat" / "cli" / "_guard_commands.py"


def _short_description(guard: GuardRecipe) -> str:
    return guard.description.rstrip(".").removeprefix("Guard ").strip()


def _sentence_description(guard: GuardRecipe) -> str:
    description = guard.description.rstrip(".")
    return description[:1].lower() + description[1:]


def _just_lines(guards: Sequence[GuardRecipe], *, comment: Callable[[GuardRecipe], str]) -> str:
    width = max(len(f"just {guard.name}") for guard in guards) + 2
    return "\n".join(f"just {guard.name}".ljust(width) + f"# {comment(guard)}" for guard in guards)


def _render_just_bash(guards: Sequence[GuardRecipe]) -> str:
    return "\n```bash\n" + _just_lines(guards, comment=_short_description) + "\n```\n"


def _render_raw_bash(guards: Sequence[GuardRecipe]) -> str:
    return "\n```bash\n" + "\n".join(guard.command for guard in guards) + "\n```\n"


def _render_bash(guards: Sequence[GuardRecipe]) -> str:
    just_lines = _just_lines(guards, comment=lambda guard: guard.description.rstrip("."))
    raw_lines = "\n".join(
        f"{guard.command}  # Raw fallback for just {guard.name}" for guard in guards
    )
    return "\n```bash\n" + just_lines + "\n" + raw_lines + "\n```\n"


def _render_bullets(guards: Sequence[GuardRecipe]) -> str:
    just_bullets = "\n".join(
        f"- `just {guard.name}`: {_sentence_description(guard)}." for guard in guards
    )
    raw_bullets = "\n".join(
        f"- Raw fallback for `just {guard.name}`: `{guard.command}`." for guard in guards
    )
    return "\n" + just_bullets + "\n" + raw_bullets + "\n"


def _render_table(guards: Sequence[GuardRecipe]) -> str:
    header = "| Docs guard | `just` recipe | Raw command |\n| --- | --- | --- |"
    rows = "\n".join(
        f"| {guard.description.rstrip('.')} | `just {guard.name}` | `{guard.command}` |"
        for guard in guards
    )
    return "\n" + header + "\n" + rows + "\n"


_RENDERERS: dict[str, Callable[[Sequence[GuardRecipe]], str]] = {
    "just-bash": _render_just_bash,
    "raw-bash": _render_raw_bash,
    "bash": _render_bash,
    "bullets": _render_bullets,
    "table": _render_table,
}


def _render_module(guards: Sequence[GuardRecipe]) -> str:
    for guard in guards:
        assert '"' not in guard.command and "\\" not in guard.command, guard.name
    lines = [
        '"""Docs/onboarding guard command hints for the ``easycat docs`` route map.',
        "",
        "AUTO-GENERATED by scripts/regen_guard_commands.py from the repo justfile.",
        "Do not edit by hand: change the ``guard-*`` recipe in the justfile, then",
        "run ``uv run python scripts/regen_guard_commands.py``.",
        '"""',
        "",
        "from __future__ import annotations",
        "",
        "DOCS_ONBOARDING_GUARD_COMMANDS: tuple[str, ...] = (",
        *(f'    "just {guard.name}",' for guard in guards),
        ")",
        "",
    ]
    return "\n".join(lines)


def render_targets(root: Path = ROOT) -> list[tuple[Path, str, str]]:
    """Return ``(path, original, regenerated)`` for every guard-command target."""
    guards = just_guard_recipes(root)
    targets: list[tuple[Path, str, str]] = []

    for name in MARKDOWN_TARGETS:
        path = root / name
        original = path.read_text(encoding="utf-8")
        if BLOCK_RE.search(original) is None:
            raise ValueError(f"{name} has no auto:guard-commands block")

        def _sub(match: re.Match[str]) -> str:
            renderer = _RENDERERS[match.group("format")]
            return match.group("begin") + renderer(guards) + match.group("end")

        targets.append((path, original, BLOCK_RE.sub(_sub, original)))

    module_path = root / MODULE_TARGET
    original = module_path.read_text(encoding="utf-8") if module_path.exists() else ""
    targets.append((module_path, original, _render_module(guards)))
    return targets


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--check",
        action="store_true",
        help="Exit non-zero if any target would change. Writes nothing.",
    )
    args = parser.parse_args(argv)

    drift = False
    for path, original, updated in render_targets():
        if original == updated:
            continue
        if args.check:
            drift = True
            print(f"would update {path.relative_to(ROOT)}", file=sys.stderr)
            sys.stderr.write(
                "".join(
                    difflib.unified_diff(
                        original.splitlines(keepends=True),
                        updated.splitlines(keepends=True),
                        fromfile=str(path),
                        tofile=f"{path} (regenerated)",
                    )
                )
            )
        else:
            path.write_text(updated, encoding="utf-8")
            print(f"updated {path.relative_to(ROOT)}")

    return 1 if drift else 0


if __name__ == "__main__":
    sys.exit(main())
