#!/usr/bin/env python3
"""Rules-index anti-drift gate + machine companion (PAPER-100 §suggested shape).

Every rule anchored (`<a id="XX-NN">`) in a topic doc's Rules box is accounted for: it is
either in the README *Index of MUSTs* (a MUST) or in the explicit ``SHOULD_ALLOWLIST`` below
(a SHOULD / DRAFT-staged rule). This makes "added a rule but forgot the Index" a hard CI error
instead of silent drift — the exact failure P-A documented (the Index had lost ~40 MUSTs).

Usage:
  gen_rules_index.py            # (re)write docs/standards/rules.json (the machine companion)
  gen_rules_index.py --check    # CI gate: exit 1 if the Index ⇎ Rules boxes drift

Promoting a SHOULD to a MUST = add its Index row AND remove it from SHOULD_ALLOWLIST, same commit.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

# The headline/version-coherence gate (B1/B2, Court pre-lock sweep) rides this
# script's --check so every context that runs the rules-index gate (CI's pytest
# steps via test_rules_index.py, the release/lock preconditions, a dev run)
# also defends the hand-maintained headline surfaces. Path shim so the sibling
# module resolves whether run as ``python scripts/...`` or by file path.
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))
import check_headline_coherence  # noqa: E402  (after the sys.path shim)

# Anchored rules deliberately NOT in the Index of MUSTs because they are SHOULDs (or DRAFT-staged).
# Verified SHOULD-modal in their Rules-box bold lead. Keep this list minimal + reviewed.
SHOULD_ALLOWLIST: dict[str, str] = {
    "NM-04": "local dev dir SHOULD match the slug",
    "NM-05": "shared modules SHOULD avoid junk-drawer names (PAPER-107)",
    "OB-06": "every SLI SHOULD have a target SLO",
    "PF-06": "public-API perf characteristics SHOULD be documented",
    "PR-02": "PRESENTATION_LAYER SHOULD (DRAFT)",
    "PR-06": "PRESENTATION_LAYER stdlib-allowlist SHOULD (DRAFT)",
    "TM-05": "user-facing datetime display SHOULD live at the presentation layer",
    "TM-06": "event timestamps SHOULD use ns Unix-epoch ints",
}

_ANCHOR = re.compile(r'<a id="([A-Z]{2,5}-\d+)">')
_INDEX_ROW = re.compile(r"\|\s*([A-Z]{2,5}-\d+)\s*\|")


def _standards_dir() -> Path:
    return Path(__file__).resolve().parents[1] / "docs" / "standards"


def _anchored(std: Path) -> dict[str, str]:
    """id -> source doc filename, for every anchored rule across the topic docs."""
    out: dict[str, str] = {}
    for md in sorted(std.glob("*.md")):
        if md.name == "README.md":
            continue
        for rid in _ANCHOR.findall(md.read_text(encoding="utf-8")):
            out.setdefault(rid, md.name)
    return out


def _indexed(std: Path) -> set[str]:
    """The rule ids in the README ## Index of MUSTs table."""
    idx: set[str] = set()
    in_block = False
    for line in (std / "README.md").read_text(encoding="utf-8").splitlines():
        if line.strip() == "## Index of MUSTs":
            in_block = True
            continue
        if in_block and line.startswith("## "):
            break
        if in_block:
            m = _INDEX_ROW.match(line)
            if m:
                idx.add(m.group(1))
    return idx


def _rules(std: Path) -> list[dict[str, object]]:
    anchored, idx = _anchored(std), _indexed(std)
    return [
        {
            "id": rid,
            "family": rid.split("-")[0],
            "doc": doc,
            "kind": "MUST" if rid in idx else "SHOULD",
            "indexed": rid in idx,
        }
        for rid, doc in sorted(anchored.items())
    ]


def check() -> int:
    std = _standards_dir()
    anchored, idx = set(_anchored(std)), _indexed(std)
    errors: list[str] = []
    unaccounted = anchored - idx - set(SHOULD_ALLOWLIST)
    if unaccounted:
        errors.append(
            "Rules-box rule(s) neither in the Index of MUSTs nor in SHOULD_ALLOWLIST — "
            f"index them (MUST) or allowlist them (SHOULD): {sorted(unaccounted)}"
        )
    phantom = idx - anchored
    if phantom:
        errors.append(f"Index of MUSTs row(s) with no backing Rules-box anchor: {sorted(phantom)}")
    stale = set(SHOULD_ALLOWLIST) - anchored
    if stale:
        errors.append(
            f"SHOULD_ALLOWLIST entr(ies) no longer anchored anywhere — remove: {sorted(stale)}"
        )
    promoted = set(SHOULD_ALLOWLIST) & idx
    if promoted:
        errors.append(
            f"Rule(s) in BOTH the Index and SHOULD_ALLOWLIST — a SHOULD can't be a MUST: {sorted(promoted)}"
        )
    # Headline/version coherence (B1/B2): the README footer count, SCOPE.md
    # literal, FEDERATION.md family table + contract/corpus version discipline
    # must agree with rules.json — hand-maintained mirrors are the universal
    # drift mode, so they are gated here alongside the index itself.
    errors.extend(check_headline_coherence.check(std.parent.parent))
    if errors:
        print("RULES-INDEX GATE: FAILED")
        for e in errors:
            print(f"  ✗ {e}")
        return 1
    fams = len({rid.split("-")[0] for rid in idx})
    print(
        f"RULES-INDEX GATE: GREEN — {len(idx)} MUSTs indexed across {fams} families; "
        f"{len(SHOULD_ALLOWLIST)} allowlisted SHOULDs; {len(anchored)} anchored rules total; "
        f"headline/version coherence GREEN."
    )
    return 0


def write_rules_json() -> int:
    std = _standards_dir()
    rules = _rules(std)
    must = sum(1 for r in rules if r["indexed"])
    payload = {
        "schema": 1,
        "counts": {"anchored": len(rules), "must": must, "should": len(rules) - must},
        "rules": rules,
    }
    out = std / "rules.json"
    out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
    print(f"wrote {out} — {len(rules)} rules ({must} MUST / {len(rules) - must} SHOULD)")
    return 0


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Rules-index anti-drift gate + rules.json generator.")
    ap.add_argument(
        "--check", action="store_true", help="CI gate: exit 1 on Index ⇎ Rules-box drift"
    )
    args = ap.parse_args(argv)
    return check() if args.check else write_rules_json()


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