"""The staged half — write-set attribution and the stop-time gate. **Disabled by default.**

Wave S3 ships this code and does **not** switch it on. Nothing here runs unless
``MGF_HOOKS_GATE=1`` arms it, and an armed gate still only *records* unless
``MGF_HOOKS_MODE=enforce`` is also set. Two independent keys, because **SHP-11**:
*no step may create a state that is only safe if a later step also happens.* The
step that installs the kit cannot, by itself, produce a session that can be blocked.

--------------------------------------------------------------------------------
THE OBSERVE / ENFORCE BOUNDARY — read this before arming anything
--------------------------------------------------------------------------------

``MGF_HOOKS_GATE`` unset (**default**)
    :func:`run_writeset` and :func:`run_gate` return ``0`` immediately, having read
    nothing and written nothing. This is what ``mgf-fed hooks install`` leaves
    behind: a SessionStart hook only. The gate is not wired into any harness event.

``MGF_HOOKS_GATE=1``, ``MGF_HOOKS_MODE`` unset or ``observe``
    Everything is evaluated and **nothing** blocks. Every finding the gate would have
    made is written to the pending file and replayed into the next session's context
    by the session-start hook — because a stop-time hook's output is discarded when
    it does not block, so a gate that speaks only when blocking is inaudible in
    exactly the mode built for *"this runs where nobody is watching."*

``MGF_HOOKS_GATE=1``, ``MGF_HOOKS_MODE=enforce``
    A **blocking** finding exits ``2`` with its reason on stderr — once. On the
    retry the harness sets ``stop_hook_active``, and the gate lets the session out:
    a gate that can trap a session is a gate that gets ripped out.

Which findings may block, and why the distinction is not cosmetic:

``seat_undeclared`` — **may block.**
    A session that never named its seat (SEAT-02) has a sanctioned, one-command exit:
    declare. **SHP-06**: the gate blocks *silence*, never *judgment*, and a gate with
    no sanctioned exit is a gate that gets bypassed.

``cross_repo_write`` — **advisory in BOTH modes, by construction.**
    Sooth's gate blocks a session that changed a sibling and left no handoff. mgf has
    **not ruled a cross-repo handoff channel** — ``FEEDBACK.md`` papers are the
    nearest thing and their grammar is not gate-shaped. Blocking on a finding whose
    discharge does not exist would violate SHP-06 the moment it fired, so this finding
    is recorded and reported and never blocks. **Ruling that channel is the named
    prerequisite for making it enforceable** — not a decision this module takes.

--------------------------------------------------------------------------------

**Attribution.** Adapted with attribution from the Sooth family's
``sooth-standard/shp/shp.py`` (``writeset`` / ``gate``, SHP-05's attribution rule and
SHP-07's audibility rule). A **copy, not a dependency** (Sooth's XF-03): no import
edge, no package dependency, no cross-family resolution path.

One deliberate divergence from the source: Sooth resolved sibling repos **from a
registry over a shared family parent** — its own rules *forbid* globbing that parent,
precisely because it holds dozens of non-family repos, so the registry is the
correction, not the flaw. This port asks a narrower question and so needs neither:
it walks each written path up to its **owning** git repo. That is exact rather than
enumerated — no registry, no ``MGF_ROOT``, no subprocess — and it cannot produce the
"zero siblings resolved, so the gate silently protects nothing" failure that Sooth
had to add a tripwire for.
"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Any

from mgf.fed.sessionhooks import _events
from mgf.fed.sessionhooks._session_start import PENDING_FILENAME
from mgf.fed.sessionhooks._state import (
    MODE_ENFORCE,
    PROTOCOL,
    append_jsonl,
    disabled_reason,
    gate_armed,
    mode,
    read_jsonl,
    repo_root,
    session_id,
    state_dir,
    utc_stamp,
)

#: Tools whose input carries an exact path. A path written by one of these is
#: *definitive* evidence that this session wrote that file.
EXACT_PATH_TOOLS: dict[str, str] = {
    "Edit": "file_path",
    "Write": "file_path",
    "MultiEdit": "file_path",
    "NotebookEdit": "notebook_path",
}

#: A shell command longer than this is truncated in the record. The write-set is
#: evidence, not an archive.
MAX_COMMAND_RECORDED = 4000


def writeset_path(root: Path, sid: str) -> Path:
    """Per-session write-set. Per-session because concurrent sessions are normal."""
    return state_dir(root) / f"mgf-hooks-writeset-{sid}.jsonl"


def pending_path(root: Path) -> Path:
    """Where a verdict waits to be replayed into the next session's context."""
    return state_dir(root) / PENDING_FILENAME


def run_writeset(payload: dict[str, Any] | None = None) -> int:
    """PostToolUse — record what THIS session actually wrote. Returns ``0``, always.

    Two kinds of evidence, and the difference is load-bearing:

    * **exact** — ``Edit`` / ``Write`` / ``MultiEdit`` / ``NotebookEdit`` carry a
      path. This session wrote *that* file. Definitive.
    * **command** — a shell command can write anywhere and says nothing directly. The
      command is recorded so a human can read it; it *implicates*, it never proves.

    Without attribution a gate compares a repo to its baseline and blames this session
    for any difference — including a concurrent session's legitimate work. Blaming an
    innocent session and ordering it to document work it never did is how a discipline
    gets ripped out.
    """
    data = payload or {}
    if not gate_armed():
        return 0
    root = repo_root(data)
    if disabled_reason(root) is not None:
        return 0

    tool = str(data.get("tool_name") or "")
    tool_input = data.get("tool_input")
    tool_input = tool_input if isinstance(tool_input, dict) else {}
    record: dict[str, Any] | None = None
    if tool in EXACT_PATH_TOOLS:
        raw = tool_input.get(EXACT_PATH_TOOLS[tool])
        if raw:
            record = {"tool": tool, "kind": "exact", "path": _resolve(str(raw))}
    elif tool == "Bash":
        command = str(tool_input.get("command") or "")
        if command:
            record = {"tool": "Bash", "kind": "command", "cmd": command[:MAX_COMMAND_RECORDED]}
    if record is None:
        return 0
    record["utc"] = utc_stamp()
    # Failure is swallowed on purpose: a PostToolUse hook that fails must not break
    # the tool call it followed.
    append_jsonl(writeset_path(root, session_id(data)), record)
    return 0


def read_writeset(root: Path, sid: str) -> tuple[set[str], list[str]]:
    """``(exact_paths, commands)`` for one session. A missing file is empty, not an error."""
    exact: set[str] = set()
    commands: list[str] = []
    for rec in read_jsonl(writeset_path(root, sid)):
        if rec.get("kind") == "exact" and rec.get("path"):
            exact.add(str(rec["path"]))
        elif rec.get("kind") == "command" and rec.get("cmd"):
            commands.append(str(rec["cmd"]))
    return exact, commands


def evaluate(root: Path, sid: str) -> list[dict[str, Any]]:
    """The gate's findings for one session. Pure — reads state, decides nothing.

    Separated from :func:`run_gate` so the decision can be tested without a process,
    a harness, or an exit code in the way.
    """
    findings: list[dict[str, Any]] = []

    if _events.declaration_for(root, sid) is None:
        findings.append(
            {
                "rule": "SEAT-02",
                "blocking": True,
                "finding": (
                    "this session never declared its seat. Declare it: "
                    "`mgf-fed hooks emit seat_declared seat=<designer|executor> "
                    "model=<class> task_class=<...>` — the hook cannot declare for you."
                ),
            }
        )

    exact, _commands = read_writeset(root, sid)
    owners = {_owning_repo(path) for path in exact}
    for other in sorted(o for o in owners if o is not None and o != str(root)):
        findings.append(
            {
                "rule": "SHP-05",
                "blocking": False,
                "finding": (
                    f"this session wrote into another repo ({other}) — recorded, not blocked. "
                    f"mgf has ruled no cross-repo handoff channel, so there is no sanctioned "
                    f"discharge for this finding and blocking on it would be a gate with no exit."
                ),
            }
        )
    return findings


def run_gate(payload: dict[str, Any] | None = None, err: Any = None) -> int:
    """Stop — the verdict. ``0`` unless an armed, enforcing gate has a blocking finding.

    ``SubagentStop`` is always a no-op: a subagent finishing is not the session
    ending, and blocking it would block legitimate mid-session work.
    """
    data = payload or {}
    stream = sys.stderr if err is None else err
    if not gate_armed():
        return 0
    if data.get("hook_event_name") == "SubagentStop":
        return 0
    root = repo_root(data)
    if disabled_reason(root) is not None:
        return 0

    sid = session_id(data)
    try:
        findings = evaluate(root, sid)
    except OSError:
        return 0  # fail open: the gate's own plumbing is never the reason a session stops
    _persist(root, sid, findings)

    blocking = [f for f in findings if f.get("blocking")]
    if not blocking or mode() != MODE_ENFORCE:
        return 0
    if data.get("stop_hook_active"):
        # Self-releasing: the session was already blocked once and told why. Trapping
        # it a second time converts a discipline into an outage.
        return 0
    for finding in blocking:
        print(f"MGF-HOOKS-BLOCK [{finding['rule']}]: {finding['finding']}", file=stream)
    return 2


def _persist(root: Path, sid: str, findings: list[dict[str, Any]]) -> None:
    """Write the verdict for the next session to replay; clear it when there is none.

    Always resolved, never only written: a finding that the session then RESOLVED must
    not leave a stale verdict behind for the next session to be told about.
    """
    path = pending_path(root)
    try:
        path.unlink()
    except OSError:
        pass
    if not findings:
        return
    current = mode()
    for finding in findings:
        append_jsonl(
            path,
            {
                **finding,
                "protocol": PROTOCOL,
                "session": sid,
                "mode": current,
                "utc": utc_stamp(),
            },
        )


def _resolve(raw: str) -> str:
    try:
        return str(Path(raw).resolve())
    except (OSError, ValueError):
        return raw


def _owning_repo(path_str: str) -> str | None:
    """The git repo a written path belongs to, or ``None`` when it belongs to none."""
    try:
        candidate = Path(path_str)
    except (OSError, ValueError):
        return None
    for parent in (candidate, *candidate.parents):
        try:
            if (parent / ".git").exists():
                return str(parent)
        except OSError:
            return None
    return None


__all__ = [
    "EXACT_PATH_TOOLS",
    "MAX_COMMAND_RECORDED",
    "evaluate",
    "pending_path",
    "read_writeset",
    "run_gate",
    "run_writeset",
    "writeset_path",
]
