"""PAPER-100 anti-drift gate: the Index of MUSTs must stay in lockstep with the Rules boxes.

Runs ``scripts/gen_rules_index.py --check`` — fails if any Rules-box rule is neither indexed
nor allowlisted as a SHOULD (the exact silent-omission P-A documented), and verifies the
committed ``rules.json`` companion is current.

**TS-29 (added by adoption wave S5).** Until this file grew the fixtures below,
the *oldest* gate in this repo had exactly two tests and both were live-tree
green runs — which is precisely what a gate that had stopped looking would also
produce. `gen_rules_index.index_errors(std)` is the extracted decision, and
``TestTheGateBites`` plants each of the four drift classes on a synthetic
``docs/standards/`` tree. The composed halves (headline coherence, TESTING.md
doc↔code, adoption posture) carry their own negative fixtures in their own
files; ``TestTheChainIsWired`` only pins that they are still in the chain.
"""

from __future__ import annotations

import importlib.util
import json
import subprocess
import sys
from pathlib import Path

_ROOT = Path(__file__).resolve().parents[2]
_SCRIPT = _ROOT / "scripts" / "gen_rules_index.py"
_RULES_JSON = _ROOT / "docs" / "standards" / "rules.json"


def _load_module():
    spec = importlib.util.spec_from_file_location("gen_rules_index", _SCRIPT)
    assert spec and spec.loader
    mod = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = mod
    spec.loader.exec_module(mod)
    return mod


gri = _load_module()


def _write_standards(
    std: Path,
    *,
    anchors: str = '<a id="AG-01"></a>**AG-01** rule text\n<a id="AG-02"></a>**AG-02** rule text\n',
    index_rows: str = "| AG-01 | AGENTS | x |\n| AG-02 | AGENTS | y |\n",
) -> None:
    """A minimal ``docs/standards/`` with one topic doc and the README index."""
    std.mkdir(parents=True, exist_ok=True)
    (std / "AGENTS.md").write_text(f"# Agents\n\n## Rules box\n\n{anchors}", encoding="utf-8")
    (std / "README.md").write_text(
        f"# Standards\n\n## Index of MUSTs\n\n| ID | Source | Rule |\n|---|---|---|\n{index_rows}\n"
        "## Release history\n",
        encoding="utf-8",
    )


class TestGreenPath:
    def test_rules_index_gate_is_green(self) -> None:
        result = subprocess.run(
            [sys.executable, str(_SCRIPT), "--check"], capture_output=True, text=True, check=False
        )
        assert result.returncode == 0, result.stdout + result.stderr

    def test_fixture_tree_is_green(self, tmp_path: Path) -> None:
        _write_standards(tmp_path)
        assert gri.index_errors(tmp_path, allowlist={}) == []


class TestTheGateBites:
    """TS-29 — the four drift classes, each planted and each caught."""

    def test_an_unindexed_rule_is_caught(self, tmp_path: Path) -> None:
        # THE PAPER-100 defect itself: a Rules box grew and the Index did not.
        _write_standards(tmp_path, index_rows="| AG-01 | AGENTS | x |\n")
        errors = gri.index_errors(tmp_path, allowlist={})
        assert len(errors) == 1, errors
        assert "AG-02" in errors[0]
        assert "neither in the Index of MUSTs nor in SHOULD_ALLOWLIST" in errors[0]

    def test_indexing_it_clears(self, tmp_path: Path) -> None:
        _write_standards(tmp_path, index_rows="| AG-01 | AGENTS | x |\n")
        assert gri.index_errors(tmp_path, allowlist={}) != []
        _write_standards(tmp_path)  # restore the row
        assert gri.index_errors(tmp_path, allowlist={}) == []

    def test_a_phantom_index_row_is_caught(self, tmp_path: Path) -> None:
        # The mirror defect: a rule was removed or renumbered and the Index kept
        # pointing at it — a citation that resolves to nothing.
        _write_standards(
            tmp_path, index_rows="| AG-01 | AGENTS | x |\n| AG-99 | AGENTS | ghost |\n"
        )
        errors = gri.index_errors(tmp_path, allowlist={})
        assert any("AG-99" in e and "no backing Rules-box anchor" in e for e in errors)

    def test_a_stale_should_allowlist_entry_is_caught(self, tmp_path: Path) -> None:
        _write_standards(tmp_path)
        errors = gri.index_errors(tmp_path, allowlist={"AG-77": "gone"})
        assert any("AG-77" in e and "no longer anchored" in e for e in errors)

    def test_a_rule_in_both_index_and_allowlist_is_caught(self, tmp_path: Path) -> None:
        # The promotion-half-done defect: a SHOULD was indexed as a MUST but
        # never removed from the allowlist, so it reads as both.
        _write_standards(tmp_path)
        errors = gri.index_errors(tmp_path, allowlist={"AG-02": "still a SHOULD?"})
        assert any("BOTH the Index and SHOULD_ALLOWLIST" in e for e in errors)

    def test_the_diagnostic_names_the_rule_not_just_the_count(self, tmp_path: Path) -> None:
        # TS-16 / TS-29: a gate that reds without naming the defect has failed
        # at the moment it matters most.
        _write_standards(tmp_path, index_rows="")
        errors = gri.index_errors(tmp_path, allowlist={})
        assert "AG-01" in errors[0] and "AG-02" in errors[0]


class TestVacuity:
    """The non-empty pin TS-29 requires of any gate over a discovered set."""

    def test_an_empty_standards_dir_is_a_failure_not_a_pass(self, tmp_path: Path) -> None:
        # If the anchor regex or the *.md glob ever stops matching, every
        # set-comparison above becomes trivially true. That must be RED.
        (tmp_path / "README.md").parent.mkdir(parents=True, exist_ok=True)
        _write_standards(tmp_path)
        (tmp_path / "AGENTS.md").write_text("# Agents\n\nno anchors here\n", encoding="utf-8")
        errors = gri.index_errors(tmp_path, allowlist={})
        assert len(errors) == 1
        assert "no anchored rules found at all" in errors[0]
        assert "vacuously" in errors[0]

    def test_the_live_corpus_anchors_a_plausible_number_of_rules(self) -> None:
        # Guard the guard: the green run above is only meaningful if it read
        # the real corpus.
        anchored = gri._anchored(_ROOT / "docs" / "standards")
        assert len(anchored) > 300, f"only {len(anchored)} rules anchored — parser broken?"


class TestTheChainIsWired:
    """--check composes three sibling gates; each owns its own negatives."""

    def test_all_three_composed_gates_are_still_in_the_chain(self) -> None:
        src = _SCRIPT.read_text(encoding="utf-8")
        for gate in (
            "check_headline_coherence",
            "check_testing_doc",
            "check_adoption_posture",
        ):
            assert gate in src, f"{gate} dropped out of gen_rules_index --check"


def test_rules_json_is_current() -> None:
    """The committed companion must equal a fresh generation (no stale rules.json).

    Computed IN MEMORY and compared. This test used to run the generator as a
    subprocess and re-read the file — so the suite rewrote a tracked file in the
    workspace on every run.

    That went red in CI (mgf-standard #491) and reproduces on no bench, which is
    why it was carried for a fortnight as "a race between two tests, passes on
    re-run". The concurrency is not between tests. `test-3.11`, `test-3.12` and
    `test-3.13` declare no `depends_on`, so Woodpecker runs all three AT ONCE in
    ONE shared workspace: three generators truncating and rewriting the same
    file, and this test's re-read landed in another step's write window and got
    `''`. A bench runs the suite once and can never see it.

    The general rule the failure teaches: **in this fleet, a test that writes
    into the workspace is racing two other Python versions of itself.**
    """
    before = json.loads(_RULES_JSON.read_text(encoding="utf-8"))
    after = gri.build_payload()
    assert before == after, "rules.json is stale — run `python scripts/gen_rules_index.py`"
    assert after["counts"]["must"] == 306, (
        "expected 306 MUSTs — this is a deliberate same-commit snapshot canary "
        "(DP-14 targets SILENT drift, not a loud change-detector). Bump it in the "
        "same commit that adds/removes a MUST. History: 264 + AP-16 (2026-07-01) = "
        "265 at LOCK v1; + REL-14/DP-14/WF-14 (2026-07-18, CI-support DP-3/4/5) = 268; "
        "+ SC-19..23 (2026-07-22, Foundation Secrets Phase 5 credential plane) = 273; "
        "+ SEAT-01..08 (2026-07-30, the SEAT family — Sooth→mgf adoption wave S1; "
        "SEAT-09 is a SHOULD and is allowlisted, not indexed) = 281; "
        "+ CRT-01..12 (2026-07-30, the court family — Sooth→mgf adoption wave S2, "
        "merged second and reconciled per SOOTH_ADOPTION.md §2.1; 23 -> 24 families) = 293; "
        "+ SHP-01 and SHP-05..13 (2026-07-30, the session-hook family — Sooth→mgf "
        "adoption wave S3, merged third and reconciled per SOOTH_ADOPTION.md §2.1; "
        "24 -> 25 families; ten rules, not twelve — SHP-02/03/04 are deliberately "
        "vacant non-adoptions and SHP-13 is mgf-native) = 303; "
        "+ AG-21/AG-22 (2026-07-31, the validation-entrypoint + resumable-journal "
        "rules — Sooth->mgf adoption wave S4, merged fourth and reconciled per "
        "SOOTH_ADOPTION.md 2.1; the first wave that GROWS an existing family, so "
        "the family count stays 25; AG-23 is a SHOULD and is allowlisted, not "
        "indexed) = 305; "
        "+ TS-29 (2026-07-31, seeded gate self-tests — Sooth->mgf adoption wave S5, "
        "the FINAL wave, merged fifth and reconciled per SOOTH_ADOPTION.md 2.1; the "
        "second intra-family EXTEND, so 25 families again — and the first wave "
        "whose TS-01..TS-29 range cell was caught by the checker S4 built) = 306."
    )
