#!/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
from typing import Any

# 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_adoption_posture  # noqa: E402  (after the sys.path shim)
import check_headline_coherence  # noqa: E402  (after the sys.path shim)
import check_testing_doc  # 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] = {
    "AG-23": "cross-model resume drills SHOULD be periodic (no drill mechanism yet — "
    "promotion trigger written into the rule)",
    "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)",
    "SEAT-09": "spend SHOULD be planned from measured burn, not extrapolation",
    "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 index_errors(std: Path, allowlist: dict[str, str] | None = None) -> list[str]:
    """The index-drift half of the gate, as a pure decision over a tree.

    Extracted so it can be fed a synthetic ``docs/standards/`` and proved to
    bite (**TS-29**): the four failure classes below are the whole of the
    PAPER-100 defect (the Index had silently lost ~40 MUSTs), and until this
    seam existed the only evidence they were caught was a green run over a
    clean tree — which is exactly what a gate that had stopped looking would
    also produce. Negative fixtures: ``tests/unit/test_rules_index.py``.
    """
    allow = SHOULD_ALLOWLIST if allowlist is None else allowlist
    anchored, idx = set(_anchored(std)), _indexed(std)
    errors: list[str] = []
    if not anchored:
        # The vacuity pin (TS-29): a glob that matches nothing, or a Rules-box
        # anchor format change, would make every comparison below trivially
        # true. An empty corpus is a broken gate, not a compliant one.
        errors.append(
            f"{std}: no anchored rules found at all — the '<a id=\"XX-NN\">' anchor "
            f"shape or the docs/standards/*.md glob changed, and every check below "
            f"would pass vacuously. Fix the parser before trusting a green run."
        )
        return errors
    unaccounted = anchored - idx - set(allow)
    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(allow) - anchored
    if stale:
        errors.append(
            f"SHOULD_ALLOWLIST entr(ies) no longer anchored anywhere — remove: {sorted(stale)}"
        )
    promoted = set(allow) & idx
    if promoted:
        errors.append(
            f"Rule(s) in BOTH the Index and SHOULD_ALLOWLIST — a SHOULD can't be a MUST: {sorted(promoted)}"
        )
    return errors


def check() -> int:
    std = _standards_dir()
    idx = _indexed(std)
    anchored = set(_anchored(std))
    errors: list[str] = index_errors(std)
    # 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))
    # TESTING.md doc↔code tier truth (M2 Wave-2 §1.3): the §13.11
    # MARKER_TIER block + taxonomy table must stay internally consistent
    # (and match mgf.common.testing where importable) — the drift class
    # that produced three answers for `integration`'s tier. Same channel
    # as the coherence gate: every --check context defends it.
    if check_testing_doc.check() != 0:
        errors.append(
            "TESTING.md doc↔code tier truth drifted (see TESTING-DOC GATE output above; "
            "run `python scripts/check_testing_doc.py` for detail)"
        )
    # Adoption posture (S5): a family with a staged rule — a ⏳ row in the
    # DOC-15 ledger — must tell a reader why nothing fails today. Derived from
    # the ledger + rules.json, so it self-expires per family as ⏳ flips to ✅.
    errors.extend(check_adoption_posture.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; TESTING.md doc↔code GREEN; "
        f"adoption posture GREEN."
    )
    return 0


def build_payload(std: Path | None = None) -> dict[str, Any]:
    """The rules.json payload, computed and returned — nothing written.

    Split out of ``write_rules_json`` so a caller can ask *"is the committed file
    current?"* WITHOUT rewriting it. `test_rules_json_is_current` used to answer
    that by running the generator and re-reading the file, which meant the test
    suite mutated a tracked file in the workspace.

    That is unsafe in this fleet's pipeline and it went red for it (mgf-standard
    #491): `test-3.11`, `test-3.12` and `test-3.13` declare no `depends_on`, so
    Woodpecker runs all three CONCURRENTLY **in one shared workspace**. Each ran
    this generator; one truncated `rules.json` in the window between another's
    generate and its re-read, and the reader got `''`.

    It reproduces on no bench, because a bench runs the suite once. The failure
    was previously recorded as "a read/write race between two tests, passed on
    re-run" — the concurrency is between PIPELINE STEPS, and the fix is for the
    test not to write at all.
    """
    std = std or _standards_dir()
    rules = _rules(std)
    must = sum(1 for r in rules if r["indexed"])
    return {
        "schema": 1,
        "counts": {"anchored": len(rules), "must": must, "should": len(rules) - must},
        "rules": rules,
    }


def write_rules_json() -> int:
    std = _standards_dir()
    payload = build_payload(std)
    rules = payload["rules"]
    must = payload["counts"]["must"]
    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())
