#!/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 and the relationship diagrams are 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. The one thing typed here is `AREAS`, which tables belong together for a
reader; a table named nowhere falls into its model module's group, so none is ever lost.
`dbmap.py` draws the same metadata as one page for a person.

    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)


# Which tables a reader sees together (the order is the reading order). Grouping only: the
# columns, keys and docstrings come from the metadata. A table named nowhere here is grouped
# under its model module, so a new table shows up without an edit here.
AREAS: dict[str, tuple[str, ...]] = {
    "Catalogue": ("retailers", "shops", "airport_hours", "brands", "product_lines", "attribute_aliases",
                  "product_variants", "listings", "awards"),
    "Collection": ("sources", "collection_runs", "raw_records", "price_observations", "rejected_observations"),
    "Decisions and merges": ("overrides", "suggestions", "merges", "reverifications"),
    "Quality": ("verification_runs", "verification_checks", "audit_snapshots"),
    "Accounts": ("accounts", "account_credentials", "account_levels", "account_members", "account_grants",
                 "sessions", "account_tokens", "audit_log", "account_preferences"),
    "Discussion": ("discussion_items", "threads", "discussion_comments", "thread_asks", "thread_reads",
                   "comment_acks", "comment_flags", "notifications", "email_sends"),
    "Client and owner surfaces": ("feature_priorities", "quote_selections", "quote_requests", "client_todos",
                                  "client_uploads", "owner_item_states"),
    "Editorial": ("articles", "subscribers"),
}


def load_models():
    """Import every model module and return (Base, docs, modules): the metadata, each table's
    docstring, and the model module each table's class lives in."""
    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, modules = {}, {}
    for mapper in Base.registry.mappers:
        cls = mapper.class_
        docs[mapper.local_table.name] = (cls.__doc__ or "").strip()
        modules[mapper.local_table.name] = cls.__module__.rsplit(".", 1)[-1]
    return Base, docs, modules


def areas_of(tables, modules) -> list[tuple[str, list[str]]]:
    """The reading order: each area's tables as named in AREAS, then any table AREAS does not
    name under its module's name, sorted; an area with no table present is skipped."""
    present = set(tables)
    placed: set[str] = set()
    out: list[tuple[str, list[str]]] = []
    for area, names in AREAS.items():
        got = [n for n in names if n in present]
        placed.update(got)
        if got:
            out.append((area, got))
    rest: dict[str, list[str]] = {}
    for t in sorted(present - placed):
        rest.setdefault(f"Other ({modules.get(t, '?')})", []).append(t)
    out.extend(sorted(rest.items()))
    return out


def relations_block() -> str:
    """One Mermaid entity-relationship diagram per area, keys only: every foreign key whose
    holder is in the area, drawn from the referenced table to the holder (`||` when the key is
    required, `|o` when it may be empty). The who-columns that point at `accounts` are listed
    under each diagram instead of drawn, so the picture stays about the structure."""
    Base, _docs, modules = load_models()
    tables = Base.metadata.tables
    parts = []
    for area, names in areas_of(tables, modules):
        edges, who = [], []
        for name in names:
            for c in tables[name].columns:
                if not c.foreign_keys:
                    continue
                target = next(iter(c.foreign_keys)).column.table.name
                if target == "accounts" and name != "accounts":
                    who.append(f"`{name}.{c.name}`")
                    continue
                left = "|o" if c.nullable else "||"
                edges.append(f"  {target} {left}--o{{ {name} : \"{c.name}\"")
        if not edges:
            edges = [f"  {n}" for n in names]  # entities alone: no key leaves the area
        parts.append(f"### {area}\n```mermaid\nerDiagram\n" + "\n".join(edges) + "\n```")
        if who:
            parts.append("Who-columns, to `accounts`: " + ", ".join(who) + ".")
    return "\n".join(parts)


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.
    """
    Base, fulldocs, _modules = load_models()
    docs = {t: (d.splitlines()[0].strip() if d else "") for t, d in fulldocs.items()}

    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),
    "relations": (DOCS / "DATA-MODEL.md", relations_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())
