"""The SessionStart hook — the SEAT-02 re-teach golden, and fail-open on every path.

The two properties this file exists to hold:

* **Observe mode never blocks.** Every path returns ``0`` — including the paths that
  only exist because something broke.
* **The hook never declares for the agent.** A fabricated record would poison the
  audit trail the log exists to create, so the *absence* of a declaration must stay
  an absence.

Adapted with attribution from the Sooth kit's ``shp/gap_session_start.py`` self-test.
Copy, not a dependency.
"""

from __future__ import annotations

import io
import json
import os
import subprocess
import sys
import threading
from pathlib import Path
from typing import Any

import pytest

from mgf.fed.sessionhooks import _events, _session_start


@pytest.fixture(autouse=True)
def _hermetic(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    monkeypatch.setenv("MGF_HOOKS_DISABLE_FILE", str(tmp_path / "no-such-kill-switch"))
    for name in ("MGF_HOOKS_DISABLED", "MGF_HOOKS_MODE", "MGF_HOOKS_GATE", "CLAUDE_SESSION_ID"):
        monkeypatch.delenv(name, raising=False)


@pytest.fixture
def repo(tmp_path: Path) -> Path:
    """A git-shaped repo the contracted ``cwd`` can point at."""
    root = tmp_path / "repo"
    (root / ".git").mkdir(parents=True)
    return root


def _run(payload: dict[str, Any]) -> tuple[int, str]:
    buf = io.StringIO()
    code = _session_start.run(payload, out=buf)
    return code, buf.getvalue()


# --------------------------------------------------------------------------- #
# The SEAT-02 re-teach golden
# --------------------------------------------------------------------------- #

#: The exact undeclared-session block. This is a GOLDEN: divergent re-teach text is
#: how two repos come to believe different things about one protocol (SEAT-08), so
#: changing it is a deliberate act that shows up as a failing test, not a diff nobody
#: reviewed. Every rule id cited here is mgf's own (`SEATS.md`).
SEAT_BLOCK_GOLDEN = """\
## SEAT-02 — this session has NOT declared its seat. Declare it BEFORE you act.
   Fable / Mythos-class        -> designer  contracts, verdicts, rulings (SEAT-03)
   Opus / Sonnet / Haiku-class -> executor  implement against a ruling (SEAT-06)
   the founder (human)         -> enactor   the decision is his alone (SEAT-01)
   Declare:  mgf-fed hooks emit seat_declared seat=<designer|executor> model=<class> \
task_class=<...>
   SEAT-04: every subagent / workflow spawn this session MUST pin an explicit model.
   SEAT-05: end with your thinking in a COMMITTED document, committed as each artifact \
finishes.
   This hook CANNOT declare for you — it cannot read your system prompt, and a
   fabricated record would poison the audit trail. The MISSING record IS the finding."""


class TestSeatReteachGolden:
    def test_undeclared_block_is_byte_exact(self) -> None:
        assert "\n".join(_session_start.render_seat_block(None)) == SEAT_BLOCK_GOLDEN

    def test_golden_cites_only_mgf_rule_ids(self) -> None:
        # XF-03: the port carries no Sooth URLs, package names, or rule ids into the
        # text an agent actually reads.
        lowered = SEAT_BLOCK_GOLDEN.lower()
        for foreign in ("sooth", "shp-", "http://", "https://"):
            assert foreign not in lowered
        for cited in ("SEAT-01", "SEAT-02", "SEAT-03", "SEAT-04", "SEAT-05", "SEAT-06"):
            assert cited in SEAT_BLOCK_GOLDEN

    def test_declared_session_gets_one_quiet_line(self) -> None:
        lines = _session_start.render_seat_block({"seat": "executor", "model": "opus-5"})
        assert len(lines) == 1
        assert "declared (executor/opus-5)" in lines[0]
        # A re-teach that never shuts up is noise, and noise is what a reader learns
        # to scroll past.
        assert "Declare:" not in lines[0]

    def test_output_stays_within_the_line_budget(self, repo: Path) -> None:
        # Session-start output competes for the context it is trying to protect.
        _, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert len(out.splitlines()) <= _session_start.MAX_OUTPUT_LINES


class TestNeverDeclaresForTheAgent:
    def test_no_seat_declared_record_is_ever_written_by_the_hook(self, repo: Path) -> None:
        _run({"cwd": str(repo), "session_id": "s1"})
        _run({"cwd": str(repo), "session_id": "s1"})
        assert _events.read(repo, etype="seat_declared") == []
        assert _events.declaration_for(repo, "s1") is None

    def test_a_real_declaration_flips_the_block(self, repo: Path) -> None:
        _, before = _run({"cwd": str(repo), "session_id": "s1"})
        assert "NOT declared" in before
        _events.emit(
            repo,
            "seat_declared",
            {"seat": "designer", "model": "fable-5"},
            payload={"session_id": "s1"},
        )
        _, after = _run({"cwd": str(repo), "session_id": "s1"})
        assert "NOT declared" not in after
        assert "declared (designer/fable-5)" in after


class TestRecording:
    def test_session_start_is_recorded_once_per_session(self, repo: Path) -> None:
        # SessionStart also fires on resume and compact; three records for one
        # session would inflate every count derived from the log.
        for _ in range(3):
            _run({"cwd": str(repo), "session_id": "s1", "source": "resume"})
        assert len(_events.read(repo, session="s1", etype="session_start")) == 1

    def test_distinct_sessions_each_get_a_record(self, repo: Path) -> None:
        _run({"cwd": str(repo), "session_id": "s1"})
        _run({"cwd": str(repo), "session_id": "s2"})
        assert len(_events.read(repo, etype="session_start")) == 2

    def test_state_lands_in_the_contracted_repo(self, repo: Path, tmp_path: Path) -> None:
        # If the launch repo is ever resolved wrongly, state lands somewhere nobody
        # reads and every later check fails open, silently.
        _run({"cwd": str(repo / "src"), "session_id": "s1"})
        assert (repo / ".claude" / "state" / "agent-events.jsonl").is_file()
        assert not (tmp_path / ".claude").exists()


class TestKillSwitchIsAnnounced:
    def test_disabled_says_so_and_records_nothing(
        self, repo: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        # An OFF protocol that LOOKS healthy is the failure mode a kill-switch
        # creates if it is silent (SHP-08).
        monkeypatch.setenv("MGF_HOOKS_DISABLED", "1")
        code, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert code == 0
        assert "DISABLED" in out
        assert "SEAT-02" not in out
        assert _events.read(repo) == []

    def test_per_repo_switch(self, repo: Path) -> None:
        (repo / ".claude").mkdir(parents=True, exist_ok=True)
        (repo / ".claude" / "mgf-hooks-disabled").touch()
        code, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert code == 0
        assert "DISABLED" in out
        assert _events.read(repo) == []


class TestVerdictReplay:
    def test_a_pending_verdict_is_replayed_then_cleared(self, repo: Path) -> None:
        # A stop-time hook's output is discarded when it does not block, so the next
        # session's start is the only channel a gate can actually speak through.
        pending = repo / ".claude" / "state" / _session_start.PENDING_FILENAME
        pending.parent.mkdir(parents=True)
        pending.write_text(
            '{"finding": "a seeded finding", "mode": "observe", "rule": "SEAT-02"}\n',
            encoding="utf-8",
        )
        _, first = _run({"cwd": str(repo), "session_id": "s1"})
        assert "a seeded finding" in first
        assert not pending.exists()
        # Replayed once, not forever.
        _, second = _run({"cwd": str(repo), "session_id": "s2"})
        assert "a seeded finding" not in second

    def test_many_findings_are_capped_with_a_pointer(self, repo: Path) -> None:
        pending = repo / ".claude" / "state" / _session_start.PENDING_FILENAME
        pending.parent.mkdir(parents=True)
        pending.write_text(
            "".join(f'{{"finding": "f{i}", "mode": "observe"}}\n' for i in range(9)),
            encoding="utf-8",
        )
        _, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert "…and 6 more" in out

    def test_a_torn_pending_file_is_not_fatal(self, repo: Path) -> None:
        pending = repo / ".claude" / "state" / _session_start.PENDING_FILENAME
        pending.parent.mkdir(parents=True)
        pending.write_text('{"finding": "kept"}\n{"torn": \n', encoding="utf-8")
        code, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert code == 0
        assert "kept" in out


class TestFailsOpenOnEveryPath:
    @pytest.mark.parametrize(
        "payload",
        [
            {},
            {"cwd": ""},
            {"cwd": "/nonexistent/path/that/cannot/be"},
            {"session_id": "no-cwd"},
            {"cwd": None, "session_id": None},
        ],
    )
    def test_degenerate_payloads_still_exit_zero(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, payload: dict[str, Any]
    ) -> None:
        # chdir into the sandbox: a payload with no usable `cwd` falls back to the
        # process cwd, and without this the suite writes .claude/state/ into the
        # SOURCE TREE. It did, once — which is the whole reason this line is here.
        monkeypatch.chdir(tmp_path)
        code, _ = _run(payload)
        assert code == 0

    def test_missing_state_dir_is_created_not_fatal(self, repo: Path) -> None:
        assert not (repo / ".claude").exists()
        assert _run({"cwd": str(repo), "session_id": "s1"})[0] == 0

    def test_unwritable_state_dir_warns_and_still_starts(self, repo: Path) -> None:
        # `.claude` is a FILE — every write below it fails. The session still starts,
        # AND it is told it is not being recorded: a kit that looks healthy while
        # recording nothing is the exact failure class it exists to end (SHP-07).
        (repo / ".claude").write_text("not a directory\n", encoding="utf-8")
        code, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert code == 0
        assert "SEAT-02" in out
        assert "NOT being recorded" in out

    def test_losing_the_claim_normally_is_silent(self, repo: Path) -> None:
        # The other cause of a lost claim — someone else won — is ordinary, and must
        # not print a scary warning on every resume.
        _run({"cwd": str(repo), "session_id": "s1"})
        _, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert "NOT being recorded" not in out
        assert "WARN" not in out

    def test_an_internal_error_is_loud_and_still_exits_zero(
        self, monkeypatch: pytest.MonkeyPatch, repo: Path
    ) -> None:
        # A broken hook that says NOTHING is the failure this kit exists to end; a
        # broken hook that stops the session is worse. It must do neither.
        def _boom(_payload: dict[str, Any]) -> list[str]:
            raise RuntimeError("seeded")

        monkeypatch.setattr(_session_start, "_collect", _boom)
        code, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert code == 0
        assert "WARN mgf-fed hooks: RuntimeError: seeded" in out

    def test_malformed_event_log_never_breaks_the_hook(self, repo: Path) -> None:
        events = repo / ".claude" / "state" / "agent-events.jsonl"
        events.parent.mkdir(parents=True)
        events.write_text("}}}not json at all\n\x00\n", encoding="utf-8")
        code, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert code == 0
        assert "SEAT-02" in out


class TestOncePerSessionUnderConcurrency:
    """F4: `SessionStart` fires on startup AND resume AND compact, concurrently.

    The old dedup read the log and then wrote — a race by construction, which loses
    under exactly the load that produces duplicates. `O_CREAT|O_EXCL` has one winner.
    """

    def test_the_sentinel_is_what_dedups(self, repo: Path) -> None:
        _run({"cwd": str(repo), "session_id": "s1"})
        marker = repo / ".claude" / "state" / f"{_session_start.STARTED_PREFIX}s1"
        assert marker.is_file()

    def test_a_second_session_claims_its_own(self, repo: Path) -> None:
        _run({"cwd": str(repo), "session_id": "s1"})
        _run({"cwd": str(repo), "session_id": "s2"})
        state = repo / ".claude" / "state"
        markers = sorted(p.name for p in state.glob(f"{_session_start.STARTED_PREFIX}*"))
        assert markers == [
            f"{_session_start.STARTED_PREFIX}s1",
            f"{_session_start.STARTED_PREFIX}s2",
        ]

    @pytest.mark.integration
    def test_sixteen_concurrent_starts_record_exactly_one(self, repo: Path) -> None:
        # The barrier repro from the court finding, at the hook level.
        barrier = threading.Barrier(16)

        def start() -> None:
            barrier.wait()
            _session_start.run({"cwd": str(repo), "session_id": "race"}, out=io.StringIO())

        threads = [threading.Thread(target=start) for _ in range(16)]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()
        records = _events.read(repo, session="race", etype="session_start")
        assert len(records) == 1, f"expected exactly 1 session_start, got {len(records)}"

    @pytest.mark.integration
    def test_concurrent_starts_all_still_teach(self, repo: Path) -> None:
        # Dedup is about the RECORD, never about the re-teach: every start must print
        # the block, or a resumed session silently loses it.
        barrier = threading.Barrier(8)
        outputs: list[str] = []
        lock = threading.Lock()

        def start() -> None:
            buf = io.StringIO()
            barrier.wait()
            _session_start.run({"cwd": str(repo), "session_id": "race2"}, out=buf)
            with lock:
                outputs.append(buf.getvalue())

        threads = [threading.Thread(target=start) for _ in range(8)]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()
        assert len(outputs) == 8
        assert all("SEAT-02" in out for out in outputs)


class TestBoundedHookReads:
    """F5: starting a session must not cost more as a repo's log grows."""

    def _fat_log(self, repo: Path, *, size: int, first: dict[str, str]) -> None:
        """A log whose FIRST record is `first` and which is at least `size` bytes."""
        events = repo / ".claude" / "state" / "agent-events.jsonl"
        events.parent.mkdir(parents=True, exist_ok=True)
        head = json.dumps({"utc": "2020-01-01T00:00:00Z", "protocol": "x", **first}) + "\n"
        filler = json.dumps(
            {
                "utc": "2020-01-01T00:00:01Z",
                "protocol": "x",
                "event": "journal_step",
                "session": "other",
                "step": "z" * 400,
            }
        )
        with events.open("w", encoding="utf-8") as handle:
            handle.write(head)
            written = len(head)
            while written < size:
                handle.write(filler + "\n")
                written += len(filler) + 1

    def test_a_declaration_beyond_the_window_reads_as_absent(self, repo: Path) -> None:
        # The documented consequence, pinned so it stays a CHOICE and not a surprise:
        # the cost is one extra re-teach line; the alternative is an unbounded read on
        # every session, forever.
        self._fat_log(
            repo,
            size=_events.HOOK_READ_BYTES * 3,
            first={"event": "seat_declared", "session": "old", "seat": "executor", "model": "m"},
        )
        assert _events.declaration_for(repo, "old") is None
        # …while the operator reader still sees it, because a human asking for the
        # trail gets the trail.
        assert _events.read(repo, session="old", etype="seat_declared")

    def test_a_recent_declaration_is_still_found_in_a_fat_log(self, repo: Path) -> None:
        self._fat_log(repo, size=_events.HOOK_READ_BYTES * 3, first={"event": "session_start"})
        _events.emit(
            repo,
            "seat_declared",
            {"seat": "designer", "model": "fable-5"},
            payload={"session_id": "recent"},
        )
        declared = _events.declaration_for(repo, "recent")
        assert declared is not None
        assert declared["model"] == "fable-5"

    def test_session_start_over_a_fat_log_still_exits_zero_and_teaches(self, repo: Path) -> None:
        self._fat_log(repo, size=_events.HOOK_READ_BYTES * 4, first={"event": "session_start"})
        code, out = _run({"cwd": str(repo), "session_id": "s1"})
        assert code == 0
        assert "SEAT-02" in out

    @pytest.mark.perf
    def test_peak_memory_stays_flat_past_a_100mb_log(self, tmp_path: Path) -> None:
        """Tier-3 pin: a 100 MB trail must not become a 100 MB session start.

        Measured as the **difference between two child runs** — one against a trivial
        log, one against a 100 MB log — not as one run's absolute peak. That was the
        first draft, and it was measuring the wrong thing: a `python -m mgf.fed` child
        costs ~40 MB in interpreter and imports before it reads a single byte, which
        swamped the signal and would have failed on any machine while proving nothing.
        The delta isolates what the log actually costs. Today it is ~1 MB; an
        unbounded read would be ~100.

        `ru_maxrss` for `RUSAGE_CHILDREN` is a running maximum over all reaped
        children, so the second delta is exactly "how much did the fat run exceed the
        thin one" — and reads 0 when it did not exceed it at all.
        """
        import resource

        env = dict(os.environ)
        env["MGF_HOOKS_DISABLE_FILE"] = str(tmp_path / "no-such-kill-switch")

        def start(name: str, size: int) -> float:
            repo = tmp_path / name
            (repo / ".git").mkdir(parents=True)
            self._fat_log(repo, size=size, first={"event": "session_start"})
            before = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
            done = subprocess.run(
                [sys.executable, "-m", "mgf.fed", "hooks", "session-start"],
                input=json.dumps({"cwd": str(repo), "session_id": f"perf-{name}"}),
                capture_output=True,
                text=True,
                env=env,
                timeout=180,
                check=False,
            )
            after = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
            assert done.returncode == 0, done.stderr
            assert "SEAT-02" in done.stdout
            return max(after - before, 0) / 1024  # ru_maxrss is KiB on Linux

        baseline_mb = start("thin", 4 * 1024)
        fat_mb = start("fat", 100 * 1024 * 1024)
        assert fat_mb < 15, (
            f"a 100 MB log cost {fat_mb:.1f} MB more than a 4 KiB one "
            f"(baseline run {baseline_mb:.1f} MB) — the hook-path read is no longer "
            f"bounded to HOOK_READ_BYTES ({_events.HOOK_READ_BYTES} bytes)."
        )
