#!/usr/bin/env python3
"""Regenerate the generated blocks in main/docs/, or check that they are current.

Sources of truth: app/ (module docstrings), app/models/ (SQLAlchemy metadata), app/cli.py
(argparse help). Nothing typed here can be typed by hand: the module map is each module's
first docstring line, the table map is the live metadata, the CLI reference is `--help`.
A doc that lists these by hand rotted within two weeks; this exists so it cannot again.

    python3 main/scripts/docmap.py --write   # rewrite the marked blocks in place
    python3 main/scripts/docmap.py --check   # exit 1 naming any block that is stale

Blocks are delimited by `<!-- docmap:<name>:start -->` / `<!-- docmap:<name>:end -->`.
Runs itself under the project venv when the system interpreter lacks SQLAlchemy.
"""
from __future__ import annotations

import argparse
import ast
import contextlib
import io
import os
import pathlib
import re
import sys

MAIN = pathlib.Path(__file__).resolve().parents[1]
ROOT = MAIN.parent
DOCS = MAIN / "docs"

try:
    import sqlalchemy  # noqa: F401
except ImportError:  # re-exec under the venv check.sh uses
    venv_py = ROOT / ".venv-dev" / "bin" / "python"
    # Compare unresolved paths: the venv's python is a symlink to the same binary.
    if venv_py.exists() and not os.environ.get("DOCMAP_REEXEC"):
        os.environ["DOCMAP_REEXEC"] = "1"
        os.execv(str(venv_py), [str(venv_py), *sys.argv])
    raise

sys.path.insert(0, str(MAIN))


def modules_block() -> str:
    """One line per module under app/: its first docstring line, or a visible gap."""
    lines = []
    for p in sorted((MAIN / "app").rglob("*.py")):
        if "__pycache__" in p.parts:
            continue
        rel = p.relative_to(MAIN).as_posix()
        try:
            doc = ast.get_docstring(ast.parse(p.read_text()))
        except SyntaxError as e:
            doc = f"(does not parse: {e.msg})"
        first = doc.strip().splitlines()[0].strip() if doc else "(no docstring)"
        lines.append(f"- `{rel}`: {first}")
    return "\n".join(lines)


def tables_block() -> str:
    """One line per table: the mapped class's first docstring line, then its columns.

    Column notation: `*` primary key, `->table` foreign key, `?` nullable. Sorted by
    name, not dependency order, so an unrelated new table does not reshuffle the list.
    """
    import importlib
    import pkgutil

    import app.models as models_pkg
    from app.models.base import Base

    for m in pkgutil.iter_modules(models_pkg.__path__):
        importlib.import_module(f"app.models.{m.name}")

    docs = {}
    for mapper in Base.registry.mappers:
        cls = mapper.class_
        d = (cls.__doc__ or "").strip().splitlines()
        docs[mapper.local_table.name] = d[0].strip() if d else ""

    lines = []
    for t in sorted(Base.metadata.tables.values(), key=lambda t: t.name):
        cols = []
        for c in t.columns:
            mark = "*" if c.primary_key else ""
            fk = f"->{next(iter(c.foreign_keys)).column.table.name}" if c.foreign_keys else ""
            opt = "?" if c.nullable and not c.primary_key else ""
            cols.append(f"{mark}{c.name}{fk}{opt}")
        head = f"- **{t.name}**"
        if docs.get(t.name):
            head += f": {docs[t.name]}"
        lines.append(head + "  \n  " + ", ".join(cols))
    return "\n".join(lines)


def cli_block() -> str:
    """`python -m app.cli --help` and every subcommand's help, in one fenced block."""
    from app import cli

    os.environ["COLUMNS"] = "96"  # argparse wraps to the terminal; pin it or the block flaps

    def helptext(argv: list[str]) -> str:
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf), contextlib.suppress(SystemExit):
            cli.main(argv)
        return buf.getvalue().rstrip()

    top = helptext(["--help"])
    m = re.search(r"\{([^}]+)\}", top)
    subs = m.group(1).split(",") if m else []
    parts = [top] + [helptext([s, "--help"]) for s in subs]
    return "```text\n" + "\n\n".join(parts) + "\n```"


BLOCKS = {
    "modules": (DOCS / "ARCHITECTURE.md", modules_block),
    "tables": (DOCS / "DATA-MODEL.md", tables_block),
    "cli": (DOCS / "RUNBOOK.md", cli_block),
}


def splice(text: str, name: str, body: str) -> str | None:
    start, end = f"<!-- docmap:{name}:start -->", f"<!-- docmap:{name}:end -->"
    pat = re.compile(re.escape(start) + r".*?" + re.escape(end), re.S)
    if not pat.search(text):
        return None
    return pat.sub(lambda _m: f"{start}\n{body}\n{end}", text, count=1)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("--write", action="store_true")
    g.add_argument("--check", action="store_true")
    a = ap.parse_args()

    stale = []
    for name, (path, gen) in BLOCKS.items():
        if not path.exists():
            print(f"docmap: {path.relative_to(ROOT)} missing (block {name})", file=sys.stderr)
            stale.append(name)
            continue
        old = path.read_text()
        new = splice(old, name, gen())
        if new is None:
            print(f"docmap: no <!-- docmap:{name} --> markers in {path.name}", file=sys.stderr)
            stale.append(name)
        elif new != old:
            if a.write:
                path.write_text(new)
                print(f"docmap: rewrote {name} in {path.name}")
            else:
                print(f"docmap: {name} block in {path.name} is stale; run "
                      f"python3 main/scripts/docmap.py --write", file=sys.stderr)
                stale.append(name)
    if a.check:
        return 1 if stale else 0
    return 1 if stale else 0


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