"""Behavioral gate for orchestrate_guard.py — the deterministic heart of /orchestrate.
Covers the stop-rule, event-sourcing + rewind, gate-checks, and the fail-closed paths
hardened per guard_review_codex.md (2026-06-18)."""
import json
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import orchestrate_guard as G  # noqa: E402

EX = {"READY": 0, "CONTINUE": 10, "STOP": 20, "ERROR": 30, "BLOCK": 2}


def _init(run_dir):
    assert G.main(["init", "--run-dir", str(run_dir), "--run-id", "t", "--repo", "/tmp/r"]) == 0


def _findings(tmp, doc, name="f.json"):
    p = tmp / name
    p.write_text(json.dumps(doc))
    return str(p)


def _round(run_dir, findings, *, ready=False, loop="spec"):
    argv = ["record-round", "--run-dir", str(run_dir), "--loop", loop, "--findings", findings]
    if ready:
        argv.append("--verdict-ready")
    return G.main(argv)


# ----------------------------------------------------------------- stop-rule
def test_ready_clean(tmp_path):
    _init(tmp_path)
    f = _findings(tmp_path, {"verdict": "READY", "findings": []})
    assert _round(tmp_path, f, ready=True) == EX["READY"]
    st = json.loads(_capture(tmp_path))
    assert st["spec_selfgate"] == "READY"


def test_continue_then_decreasing(tmp_path):
    _init(tmp_path)
    f1 = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "one", "kind": "fixable"},
        {"severity": "CRITICAL", "file": "a.py", "surface": "two", "kind": "fixable"}]}, "f1.json")
    assert _round(tmp_path, f1) == EX["CONTINUE"]
    f2 = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "three", "kind": "fixable"}]}, "f2.json")
    assert _round(tmp_path, f2) == EX["CONTINUE"]  # 2 -> 1, decreasing, new surface


def test_drift_guard_blocks_ready_with_crit(tmp_path):
    _init(tmp_path)
    f = _findings(tmp_path, {"verdict": "READY", "findings": [
        {"severity": "CRITICAL", "file": "c.py", "surface": "y", "kind": "fixable"}]})
    assert _round(tmp_path, f, ready=True) == EX["STOP"]  # REC-178 prose-trace preservation


def test_human_kind_priority_over_drift(tmp_path, capsys):
    _init(tmp_path)
    # verdict READY + a scope crit: human-kind must win (STOP_SCOPE, not DRIFT_GUARD).
    f = _findings(tmp_path, {"verdict": "READY", "findings": [
        {"severity": "CRITICAL", "file": "c.py", "surface": "y", "kind": "scope"}]})
    assert _round(tmp_path, f, ready=True) == EX["STOP"]
    out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
    assert out["stop_code"] == "STOP_SCOPE"


def test_design_kind_auto_continues(tmp_path):
    # JT 2026-06-23 dogfood: `design` is NOT a hard human stop — the conductor auto-resolves
    # it (reversible decision) + re-gates. A design crit on a fresh surface -> CONTINUE.
    _init(tmp_path)
    f = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "return_contract", "kind": "design"}]})
    assert _round(tmp_path, f) == EX["CONTINUE"]


def test_design_still_hits_whackamole(tmp_path, capsys):
    # The non-convergence backstop still applies to design: same surface re-fails -> STOP.
    _init(tmp_path)
    f1 = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "ownership_model", "kind": "design"}]}, "f1.json")
    assert _round(tmp_path, f1) == EX["CONTINUE"]
    f2 = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "ownership_model", "kind": "design"}]}, "f2.json")
    assert _round(tmp_path, f2) == EX["STOP"]
    out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
    assert out["stop_code"] == "STOP_WHACKAMOLE"


def test_scope_still_hard_stops(tmp_path, capsys):
    # `scope` (genuine build-boundary decision) remains a hard human stop on round 1.
    _init(tmp_path)
    f = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "HIGH", "file": "a.py", "surface": "include_population", "kind": "scope"}]})
    assert _round(tmp_path, f) == EX["STOP"]
    out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
    assert out["stop_code"] == "STOP_SCOPE"


def test_whackamole_fires_with_surface_normalization(tmp_path, capsys):
    _init(tmp_path)
    f1 = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "src/foo.py", "surface": "Auth Redirect", "kind": "fixable"}]}, "f1.json")
    assert _round(tmp_path, f1) == EX["CONTINUE"]
    # Same surface, different reviewer wording (./ prefix, case, extra space) -> same key -> whackamole.
    f2 = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "./src/foo.py", "surface": "auth  redirect ", "kind": "fixable"}]}, "f2.json")
    assert _round(tmp_path, f2) == EX["STOP"]
    out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
    assert out["stop_code"] == "STOP_WHACKAMOLE"
    assert out["recommend"] == "SIMPLIFY_SPEC_FAIL_LOUD"


def test_stall(tmp_path, capsys):
    _init(tmp_path)
    doc = {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "one", "kind": "fixable"}]}
    assert _round(tmp_path, _findings(tmp_path, doc, "f1.json")) == EX["CONTINUE"]
    # round 2: same count (1), different surface -> not decreasing -> STALL.
    doc2 = {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "two", "kind": "fixable"}]}
    assert _round(tmp_path, _findings(tmp_path, doc2, "f2.json")) == EX["STOP"]
    out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
    assert out["stop_code"] == "STOP_STALL"


# ----------------------------------------------------------------- fail-closed
def test_malformed_finding_missing_surface_fails_closed(tmp_path):
    _init(tmp_path)
    f = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "kind": "fixable"}]})  # no surface
    assert _round(tmp_path, f) == EX["ERROR"]  # not silent '?:?'


def test_malformed_findings_shape_fails_closed(tmp_path):
    _init(tmp_path)
    f = _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": "oops"})
    assert _round(tmp_path, f) == EX["ERROR"]


def test_garbage_json_fails_closed(tmp_path):
    _init(tmp_path)
    p = tmp_path / "bad.json"; p.write_text("not json")
    assert _round(tmp_path, str(p)) == EX["ERROR"]


# ----------------------------------------------------------------- gate-checks
def test_dispatch_gate(tmp_path):
    _init(tmp_path)
    # NEEDS-FIXES -> block
    _round(tmp_path, _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "s", "kind": "fixable"}]}))
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "dispatch"]) == EX["BLOCK"]


def test_dispatch_gate_ready_allows_then_blocks_on_pending(tmp_path):
    _init(tmp_path)
    _round(tmp_path, _findings(tmp_path, {"verdict": "READY", "findings": []}), ready=True)
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "dispatch"]) == 0
    # open a human scope gate -> dispatch must block even though selfgate stayed READY
    G.main(["transition", "--run-dir", str(tmp_path), "--to-state", "AWAIT_HUMAN_SCOPE",
            "--pending-gate", "AWAIT_HUMAN_SCOPE"])
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "dispatch"]) == EX["BLOCK"]


def test_merge_gate_human_only(tmp_path):
    _init(tmp_path)
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "merge"]) == EX["BLOCK"]
    G.main(["transition", "--run-dir", str(tmp_path), "--to-state", "AWAIT_MERGE",
            "--pending-gate", "AWAIT_MERGE", "--verify-passed"])
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "merge"]) == 0


def test_spend_gate_rejects_malformed_timestamp(tmp_path):
    _init(tmp_path)
    # the money gate must never be fooled by a lexicographically-large junk string
    assert G.main(["transition", "--run-dir", str(tmp_path), "--to-state", "DISPATCH",
                   "--spend-authorized-until", "9999"]) == EX["ERROR"]
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "spend"]) == EX["BLOCK"]
    # a real future ISO allows
    G.main(["transition", "--run-dir", str(tmp_path), "--to-state", "DISPATCH",
            "--spend-authorized-until", "2099-01-01T00:00:00Z"])
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "spend"]) == 0


# ----------------------------------------------------------------- event-sourcing + rewind
def test_rewind_reverts_state(tmp_path):
    _init(tmp_path)
    _round(tmp_path, _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "s", "kind": "fixable"}]}))
    st = json.loads(_capture(tmp_path)); assert st["spec_round"] == 1
    G.main(["rewind", "--run-dir", str(tmp_path), "--to-seq", "0"])  # back to post-INIT
    st = json.loads(_capture(tmp_path))
    assert st["spec_round"] == 0 and st["state"] == "AUTHOR_SPEC"


def test_rewind_to_rewind_rejected_and_nested_terminates(tmp_path):
    _init(tmp_path)
    _round(tmp_path, _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "s", "kind": "fixable"}]}))
    assert G.main(["rewind", "--run-dir", str(tmp_path), "--to-seq", "1"]) == 0  # seq2 = REWIND
    # rewinding to the REWIND event (seq 2) must be rejected
    assert G.main(["rewind", "--run-dir", str(tmp_path), "--to-seq", "2"]) == EX["ERROR"]
    # a fresh round + another rewind to seq 0 must still materialize (no infinite loop)
    _round(tmp_path, _findings(tmp_path, {"verdict": "READY", "findings": []}, "g.json"), ready=True)
    assert G.main(["rewind", "--run-dir", str(tmp_path), "--to-seq", "0"]) == 0
    st = json.loads(_capture(tmp_path)); assert st["spec_round"] == 0


def test_clear_pending_gate(tmp_path):
    _init(tmp_path)
    _round(tmp_path, _findings(tmp_path, {"verdict": "READY", "findings": []}), ready=True)
    # a scope gate opens, then the human resolves it and the skill clears it
    G.main(["transition", "--run-dir", str(tmp_path), "--to-state", "AWAIT_HUMAN_SCOPE",
            "--pending-gate", "AWAIT_HUMAN_SCOPE"])
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "dispatch"]) == EX["BLOCK"]
    G.main(["transition", "--run-dir", str(tmp_path), "--to-state", "DISPATCH", "--clear-pending-gate"])
    st = json.loads(_capture(tmp_path)); assert st["pending_gate"] is None
    assert G.main(["gate-check", "--run-dir", str(tmp_path), "--gate", "dispatch"]) == 0


def test_rewind_rejects_non_live_target(tmp_path):
    _init(tmp_path)  # seq0
    _round(tmp_path, _findings(tmp_path, {"verdict": "NEEDS-FIXES", "findings": [
        {"severity": "CRITICAL", "file": "a.py", "surface": "s", "kind": "fixable"}]}))  # seq1
    G.main(["transition", "--run-dir", str(tmp_path), "--to-state", "AWAIT_HUMAN_SCOPE"])  # seq2
    assert G.main(["rewind", "--run-dir", str(tmp_path), "--to-seq", "1"]) == 0  # seq3 drops seq2
    # seq2 is no longer live -> rewinding to it must be rejected, not a silent no-op
    assert G.main(["rewind", "--run-dir", str(tmp_path), "--to-seq", "2"]) == EX["ERROR"]


def test_tamper_detected(tmp_path):
    _init(tmp_path)
    with open(tmp_path / "ORCH_LOG.jsonl", "a") as fh:
        fh.write(json.dumps({"seq": 99, "type": "INIT", "prev_hash": "x", "event_hash": "y"}) + "\n")
    out = json.loads(_capture(tmp_path, expect_err=True))
    assert "corrupt" in out.get("error", "")


# helper: capture `status` stdout
def _capture(run_dir, expect_err=False):
    import io, contextlib
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        G.main(["status", "--run-dir", str(run_dir)])
    return buf.getvalue().strip()


if __name__ == "__main__":
    raise SystemExit(pytest.main([__file__, "-q"]))
