"""Adoption-posture gate + its TS-29 seeded self-test.

Pins ``scripts/check_adoption_posture.py`` — the checker invoked by
``gen_rules_index.py --check`` — with a green live-repo run **plus** the
negative fixtures **TS-29** requires of any gate: a synthetic corpus where a
family carries a staged (``⏳``) rule and its doc explains nothing goes RED with
that doc named; adding the section turns it GREEN; a family with no staged rule
is not asked for one.

The vacuity pin (``test_a_reformatted_ledger_is_a_failure_not_a_pass``) is the
half that is easy to forget and the one that matters most here: this gate reads
a hand-maintained markdown table, so the realistic way it dies is that the
table's shape changes, the parser matches nothing, and every check above it
passes over an empty set. A gate that refuses vacuity in others owes the proof
that it refuses its own.
"""

from __future__ import annotations

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

_REPO_ROOT = Path(__file__).resolve().parents[2]
_SCRIPT = _REPO_ROOT / "scripts" / "check_adoption_posture.py"


def _load_module():
    spec = importlib.util.spec_from_file_location("check_adoption_posture", _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


cap = _load_module()

_POSTURE_SECTION = f"## 4. {cap.POSTURE_HEADING}\n\nSHP-01 is staged; nothing fails today.\n"


def _write_fixture(
    root: Path,
    *,
    ledger_rows: str = (
        "| AG-01 | AGENTS | ✅ | evidence |\n| SHP-01 | SESSION_HOOKS | ⏳ | staged |\n"
    ),
    hooks_doc: str = "# Session hooks\n\n## 1. Why\n\nprose\n" + _POSTURE_SECTION,
    agents_doc: str = "# Agents\n\n## 1. Why\n\nprose\n",
) -> None:
    """A minimal corpus: two rules, one of them staged in the ledger."""
    std = root / "docs" / "standards"
    std.mkdir(parents=True)
    (root / "docs" / "inprogress").mkdir(parents=True)
    rules = {
        "schema": 1,
        "counts": {"anchored": 2, "must": 2, "should": 0},
        "rules": [
            {"id": "AG-01", "family": "AG", "doc": "AGENTS.md", "kind": "MUST", "indexed": True},
            {
                "id": "SHP-01",
                "family": "SHP",
                "doc": "SESSION_HOOKS.md",
                "kind": "MUST",
                "indexed": True,
            },
        ],
    }
    (std / "rules.json").write_text(json.dumps(rules), encoding="utf-8")
    (std / "AGENTS.md").write_text(agents_doc, encoding="utf-8")
    (std / "SESSION_HOOKS.md").write_text(hooks_doc, encoding="utf-8")
    (root / "docs" / "inprogress" / "MGF_STANDARDS_CONFORMANCE.md").write_text(
        "# Conformance\n\n**1 ✅ · 1 ⏳** across all families.\n\n"
        "| Rule | Standard | Status | Evidence |\n|---|---|---|---|\n" + ledger_rows,
        encoding="utf-8",
    )


class TestGreenPath:
    def test_live_repo_carries_its_posture_sections(self) -> None:
        # The corpus that defines the convention must hold it.
        assert cap.check(_REPO_ROOT) == []

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

    def test_fixture_tree_is_green(self, tmp_path: Path) -> None:
        _write_fixture(tmp_path)
        assert cap.check(tmp_path) == []


class TestTheGateBites:
    """TS-29 — the seeded defect, its diagnostic, and the restore."""

    def test_a_staged_rule_with_no_posture_section_fails(self, tmp_path: Path) -> None:
        # The exact defect: the ledger says SHP-01 is not in force here, and the
        # doc that owns it says nothing about why that is conformant.
        _write_fixture(tmp_path, hooks_doc="# Session hooks\n\n## 1. Why\n\nprose\n")
        errors = cap.check(tmp_path)
        assert len(errors) == 1, errors
        assert "SESSION_HOOKS.md" in errors[0]
        assert "SHP-01" in errors[0]
        assert cap.POSTURE_HEADING in errors[0]

    def test_adding_the_section_clears_it(self, tmp_path: Path) -> None:
        _write_fixture(tmp_path, hooks_doc="# Session hooks\n\n## 1. Why\n\nprose\n")
        assert cap.check(tmp_path) != []
        doc = tmp_path / "docs" / "standards" / "SESSION_HOOKS.md"
        doc.write_text(doc.read_text(encoding="utf-8") + _POSTURE_SECTION, encoding="utf-8")
        assert cap.check(tmp_path) == []

    def test_the_heading_must_carry_the_parenthetical(self, tmp_path: Path) -> None:
        # A bare "## Adoption posture" drifts into advocacy; the parenthetical
        # is what tells a reader it is a conformance statement.
        _write_fixture(
            tmp_path,
            hooks_doc="# Session hooks\n\n## 4. Adoption posture\n\nthese rules are great\n",
        )
        assert cap.check(tmp_path) != []

    def test_prose_mentioning_the_phrase_does_not_satisfy_it(self, tmp_path: Path) -> None:
        # Only a HEADING counts — otherwise citing the convention in a footnote
        # would discharge it, which is how a gate becomes decoration.
        _write_fixture(
            tmp_path,
            hooks_doc=(
                "# Session hooks\n\nSee the recipe on "
                f"{cap.POSTURE_HEADING} for why this is staged.\n"
            ),
        )
        assert cap.check(tmp_path) != []


class TestItAsksOnlyWhereItShould:
    """The requirement is derived from the ledger, so it self-expires."""

    def test_a_family_with_no_staged_rule_is_not_asked(self, tmp_path: Path) -> None:
        # AGENTS.md has no posture section and never needed one: AG-01 is ✅.
        _write_fixture(tmp_path)
        assert cap.check(tmp_path) == []
        assert cap.POSTURE_HEADING not in (tmp_path / "docs" / "standards" / "AGENTS.md").read_text(
            encoding="utf-8"
        )

    def test_flipping_the_last_staged_row_to_done_retires_the_requirement(
        self, tmp_path: Path
    ) -> None:
        # The convention deletes its own reason to exist per family — the shape
        # mgf-fed's _PENDING_CORPUS uses for staged doc aliases.
        _write_fixture(
            tmp_path,
            ledger_rows="| AG-01 | AGENTS | ✅ | e |\n| SHP-01 | SESSION_HOOKS | ✅ | shipped |\n",
            hooks_doc="# Session hooks\n\n## 1. Why\n\nprose\n",
        )
        assert cap.check(tmp_path) == []

    def test_a_ledger_row_for_a_rule_the_corpus_does_not_anchor_fails(self, tmp_path: Path) -> None:
        _write_fixture(
            tmp_path,
            ledger_rows="| AG-01 | AGENTS | ✅ | e |\n| ZZ-99 | GHOSTS | ⏳ | staged |\n",
        )
        errors = cap.check(tmp_path)
        assert any("ZZ-99" in e and "anchors no such rule" in e for e in errors)


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

    def test_a_reformatted_ledger_is_a_failure_not_a_pass(self, tmp_path: Path) -> None:
        # The realistic death of this gate: the ledger's table shape changes,
        # the parser matches nothing, and every check above passes over an
        # empty set. That must be RED, not GREEN.
        _write_fixture(tmp_path)
        ledger = tmp_path / "docs" / "inprogress" / "MGF_STANDARDS_CONFORMANCE.md"
        ledger.write_text(
            "# Conformance\n\n- AG-01 (AGENTS): done\n- SHP-01 (SESSION_HOOKS): staged\n",
            encoding="utf-8",
        )
        errors = cap.check(tmp_path)
        assert len(errors) == 1
        assert "ZERO per-MUST rows" in errors[0]
        assert "vacuously" in errors[0]

    def test_the_live_ledger_parses_a_plausible_number_of_rows(self) -> None:
        # Guard the guard, the other way: the live run above is only meaningful
        # if it read the real ledger. CFM-03 requires one row per indexed MUST.
        rows = cap._ledger_rows(_REPO_ROOT)
        assert len(rows) > 250, f"only {len(rows)} ledger rows parsed — ledger shape changed?"
        assert any(marker == "⏳" for _, marker in rows), (
            "no staged rows in the live ledger — the live green run above would "
            "then prove nothing; re-aim this fixture (TESTING.md §20.4)."
        )


class TestWiring:
    def test_gen_rules_index_check_carries_the_posture_gate(self) -> None:
        # The gate reaches CI through the rules-index pytest channel, never a
        # hand-edited .woodpecker.yml step (WF-13 — the pipeline is generated).
        src = (_REPO_ROOT / "scripts" / "gen_rules_index.py").read_text(encoding="utf-8")
        assert "check_adoption_posture" in src
