"""Every rule id this kit says out loud must be a real rule (F3), and none may be Sooth's.

Two obligations that pull in opposite directions, which is why they are pinned
together:

* **No residue.** The port carries no Sooth package names, paths, or URLs into
  anything an agent or operator reads. A copy is not a dependency, and a stray
  `sooth-standard/…` in a banner is a cross-family resolution path by another name.
* **…but the SHP ids stay.** They are enacted into mgf's own corpus as the
  `SESSION_HOOKS` family in the same adoption wave that lands this kit, so citing them
  is citing mgf law rather than borrowing someone else's. Stripping them would have
  cost the reader the only pointer from a runtime banner back to the rule that
  justifies it.

So the test is not "no SHP strings". It is: **every cited id resolves to a real mgf
rule** — checked against the installed corpus's own `rules.json`, with a staged,
self-expiring allowance for the three families enacting right now. A *foreign* id
(Sooth's `XF-03`) is tolerated only in provenance prose that names whose rule it is,
and never in runtime output at all.

The check hardens itself: as each family reaches a published corpus wheel,
`rules.json` starts vouching for it and this file's hand-kept range is required to be
deleted.
"""

from __future__ import annotations

import ast
import json
import re
from pathlib import Path

import pytest

from mgf.fed.sessionhooks import _events, _gate, _install, _session_start

_PKG = Path(__file__).resolve().parents[3] / "src" / "mgf" / "fed" / "sessionhooks"

#: A rule citation: two-to-four uppercase letters, a hyphen, two digits.
_RULE_ID = re.compile(r"\b([A-Z]{2,4})-(\d{2})\b")

#: Families being enacted in the Sooth→mgf adoption wave. They are mgf law, they are
#: simply not in a *published* corpus wheel yet, so `rules.json` cannot vouch for them
#: — the same staging `_standards._PENDING_CORPUS` records for the docs themselves.
#: `test_enacting_families_are_still_pending` deletes this table's reason to exist the
#: moment the pin advances.
_ENACTING: dict[str, frozenset[str]] = {}
#: EMPTY since mgf-standard 0.1.75 shipped SEATS / REVIEW / SESSION_HOOKS.
#: `rules.json` is the authority for SEAT / CRT / SHP now, which is the point: the
#: hand-kept range had already drifted — it declared CRT-01..16 while the corpus
#: enacted CRT-01..14, so this file vouched for two rules that do not exist.

#: Corpus doc per enacting family, for the hardening cross-check.
_FAMILY_DOC = {"SEAT": "SEATS", "SHP": "SESSION_HOOKS", "CRT": "REVIEW"}

#: Rule ids belonging to ANOTHER family. Permitted in provenance prose and nowhere
#: else, and only when the prose names whose they are — an unattributed `XF-03` reads
#: as an mgf rule that does not exist.
_FOREIGN_FAMILIES = frozenset({"XF"})
_ATTRIBUTION_MARKERS = ("sooth",)

#: Sooth residue that must never reach a reader: package names, paths, URLs.
_FOREIGN = ("sooth", "shp/", "shp.py", "soothfast", "first-light")


def _installed_ids() -> frozenset[str]:
    """Every rule id in the INSTALLED corpus — the authority, not a hand-kept list."""
    from mgf.fed.cli._standards import _resolve_standards_dir

    standards = _resolve_standards_dir()
    if standards is None:  # pragma: no cover - corpus always present in dev
        return frozenset()
    rules = standards / "rules.json"
    if not rules.is_file():  # pragma: no cover - shipped since 0.1.x
        return frozenset()
    data = json.loads(rules.read_text(encoding="utf-8"))
    return frozenset(str(rule["id"]) for rule in data.get("rules", []) if rule.get("id"))


def _allowed_ids() -> frozenset[str]:
    enacting: set[str] = set()
    for ids in _ENACTING.values():
        enacting |= ids
    return _installed_ids() | frozenset(enacting)


def _is_attributed(text: str) -> bool:
    """Does this string name the family whose rule it is citing?"""
    lowered = text.lower()
    return any(marker in lowered for marker in _ATTRIBUTION_MARKERS)


def _agent_facing_strings() -> dict[str, list[str]]:
    """Every string literal this package can put in front of a human or an agent.

    Harvested from the AST rather than by running the code, so a branch that only
    fires on a disk-full Tuesday is covered exactly like the happy path — the strings
    nobody sees in testing are precisely the ones that rot.
    """
    found: dict[str, list[str]] = {}
    for path in sorted(_PKG.rglob("*.py")):
        tree = ast.parse(path.read_text(encoding="utf-8"))
        strings: list[str] = []
        for node in ast.walk(tree):
            if isinstance(node, ast.Constant) and isinstance(node.value, str):
                strings.append(node.value)
        found[path.name] = strings
    return found


def _runtime_strings() -> list[str]:
    """The strings actually produced at runtime, as a session sees them."""
    out: list[str] = []
    out += _session_start.render_seat_block(None)
    out += _session_start.render_seat_block({"seat": "executor", "model": "opus-5"})
    out += [_install.HOOK_COMMAND, _install.HOOK_STALE_NOTICE]
    # Every refusal the closed vocabulary can produce.
    for etype, fields in (
        ("nonsense", {}),
        ("seat_declared", {"seat": "designer"}),
        ("seat_declared", {"seat": "vibes", "model": "m"}),
        ("escalation", {"trigger": "felt_like_it"}),
        ("journal_step", {"step": "x", "session": "forged"}),
    ):
        refusal = _events.validate(etype, fields)
        if refusal is not None:
            out.append(refusal)
    return out


class TestCitedRuleIdsAreRealMgfLaw:
    def test_source_citations_are_all_enacted(self) -> None:
        allowed = _allowed_ids()
        bad: list[str] = []
        for filename, strings in _agent_facing_strings().items():
            for text in strings:
                for family, number in _RULE_ID.findall(text):
                    rule = f"{family}-{number}"
                    if rule in allowed:
                        continue
                    if family in _FOREIGN_FAMILIES and _is_attributed(text):
                        continue  # provenance prose, and it says whose rule it is
                    bad.append(f"{filename}: {rule} in {text[:70]!r}")
        assert not bad, "rule citations that resolve to nothing in mgf's corpus:\n  " + "\n  ".join(
            bad
        )

    def test_runtime_output_citations_are_all_enacted(self) -> None:
        # The AST walk covers docstrings; this covers what a session literally reads,
        # where the FOREIGN-family allowance does not apply at all.
        allowed = _allowed_ids()
        bad: list[str] = []
        for text in _runtime_strings():
            for family, number in _RULE_ID.findall(text):
                rule = f"{family}-{number}"
                if rule not in allowed:
                    bad.append(f"{rule} in: {text[:80]}")
        assert not bad, "runtime rule citations that resolve to nothing:\n  " + "\n  ".join(bad)

    def test_a_foreign_id_is_refused_when_unattributed(self) -> None:
        # Seeded-bad: the allowance is narrow, and this proves it is not a hole.
        assert _is_attributed("adapted with attribution from the Sooth family's XF-03")
        assert not _is_attributed("see XF-03 for the no-dependency rule")

    def test_the_installed_corpus_is_actually_the_authority(self) -> None:
        # Guard the guard: an empty vocabulary would make every check above vacuous.
        installed = _installed_ids()
        assert len(installed) > 100, f"only {len(installed)} rule ids resolved — corpus broken?"
        assert "CF-04" in installed

    def test_enacting_families_are_still_pending(self) -> None:
        """Self-expiring, exactly like `_standards._PENDING_CORPUS`.

        Once a family reaches a published corpus wheel, `rules.json` vouches for it
        and its hand-kept range here is dead weight that could drift from the law.
        """
        installed = _installed_ids()
        shipped = sorted(family for family, ids in _ENACTING.items() if ids & installed)
        assert not shipped, (
            f"{shipped} now ship in the installed corpus — delete their entries from "
            f"_ENACTING in this file; rules.json is the authority for them now."
        )

    def test_the_disabled_banner_cites_the_kill_switch_rule(self) -> None:
        # F3(b): the banner stays SHP-cited — now legally, because SESSION_HOOKS
        # enacts before this branch merges.
        from mgf.fed.sessionhooks._session_start import _collect

        assert "SHP-08" in "\n".join(
            line for line in _kill_switched_banner(_collect) if "SHP" in line
        )

    def test_gate_records_carry_a_rule_field_and_it_is_enacted(self, tmp_path: Path) -> None:
        # F3(b): the pending-record `rule` field is the machine-readable half of the
        # same citation, and it is what the next session replays.
        repo = tmp_path / "repo"
        (repo / ".git").mkdir(parents=True)
        (repo / "other").mkdir()
        allowed = _allowed_ids()
        findings = _gate.evaluate(repo, "s1")
        assert findings, "expected at least the SEAT-02 finding"
        for finding in findings:
            rule = str(finding["rule"])
            assert rule in allowed, f"unenacted rule field: {rule}"


def _kill_switched_banner(collect: object) -> list[str]:
    """Render the disabled banner without touching the environment permanently."""
    import os
    import tempfile

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        (root / ".claude").mkdir()
        (root / ".claude" / "mgf-hooks-disabled").touch()
        previous = os.environ.get("MGF_HOOKS_DISABLE_FILE")
        os.environ["MGF_HOOKS_DISABLE_FILE"] = str(root / "no-such-kill-switch")
        try:
            return list(collect({"cwd": str(root), "session_id": "s1"}))  # type: ignore[operator]
        finally:
            if previous is None:
                os.environ.pop("MGF_HOOKS_DISABLE_FILE", None)
            else:
                os.environ["MGF_HOOKS_DISABLE_FILE"] = previous


class TestNoSoothResidue:
    """XF-03: a copy, not a dependency — and never a resolution path in a banner."""

    def test_no_runtime_string_names_the_other_family(self) -> None:
        for text in _runtime_strings():
            lowered = text.lower()
            for token in _FOREIGN:
                assert token not in lowered, f"Sooth residue {token!r} in runtime output: {text}"

    def test_no_urls_at_all_in_runtime_output(self) -> None:
        # A URL in a hook banner is a cross-family resolution path with extra steps.
        for text in _runtime_strings():
            assert "http://" not in text
            assert "https://" not in text

    def test_attribution_lives_in_docstrings_where_it_belongs(self) -> None:
        # The provenance MUST be recorded — just not in the agent's context window.
        sources = "\n".join((_PKG / name).read_text(encoding="utf-8") for name in _module_names())
        assert "Sooth" in sources, "the attribution note has gone missing"
        assert "copy, not a dependency" in sources

    def test_no_import_edge_to_the_other_family(self) -> None:
        for path in sorted(_PKG.rglob("*.py")):
            tree = ast.parse(path.read_text(encoding="utf-8"))
            for node in ast.walk(tree):
                if isinstance(node, ast.Import):
                    names = [a.name for a in node.names]
                elif isinstance(node, ast.ImportFrom):
                    names = [node.module or ""]
                else:
                    continue
                for name in names:
                    assert "sooth" not in name.lower(), f"{path.name} imports {name}"


def _module_names() -> list[str]:
    return sorted(p.name for p in _PKG.glob("*.py"))


@pytest.mark.integration
class TestVocabularyHardensWhenTheCorpusShips:
    """Once the docs are installed, check the ids against the real text, not this file.

    Until then the check is vacuous by design — and says so, rather than silently
    passing on an absent corpus.
    """

    def test_cross_check_against_the_installed_corpus(self) -> None:
        """Once a family's doc ships, its ids must appear in the doc's own text.

        Vacuous until the pin advances — and it says so, rather than passing silently
        on an absent corpus.
        """
        from mgf.fed.cli._standards import _resolve_standards_dir

        standards = _resolve_standards_dir()
        if standards is None:  # pragma: no cover - corpus always present in dev
            pytest.skip("standards corpus not installed")
        missing: list[str] = []
        present = 0
        for family, ids in _ENACTING.items():
            doc = standards / f"{_FAMILY_DOC[family]}.md"
            if not doc.is_file():
                continue  # the family has not reached a published wheel yet
            text = doc.read_text(encoding="utf-8")
            present += 1
            missing += [rule for rule in sorted(ids) if rule not in text]
        assert not missing, (
            f"ids cited by this kit are absent from the shipped corpus docs: {missing}"
        )
        # Recorded, not asserted: `present == 0` today is the expected, staged state.
        assert present >= 0
