"""The SessionStart hook — observe mode: it prints and it records. It never blocks.

This is the whole of what wave S3 installs. Session-start stdout is the one channel
the harness injects into a model's context, which makes it the only place a rule can
be re-taught to *every* session regardless of what any agent remembers — so this hook
does three things and stops:

1. **Announces the kill-switch** when the kit is off. An OFF protocol that looks
   healthy is precisely the invisible failure the kit exists to end (**SHP-08**).
2. **Records that a session existed** in this repo, once per session id.
3. **Re-teaches SEAT-02** — the declaration instruction, citing mgf's own rule ids.

And one thing it deliberately does **not** do:

    **The hook NEVER declares the seat for the agent.** It cannot: only the session
    can read its own system prompt (SEAT-02), and a fabricated ``model=unknown``
    record would poison the very audit trail the log exists to create. "Automatic"
    means the *instruction* reaches every session mechanically, and that
    NON-declaration is visible in the log — not that the record is invented.

**Fail-open, on every path.** Every branch returns ``0``. A session that cannot start
because observability broke is a worse defect than anything this hook measures. The
live path is wrapped whole, and the wrapper prints the failure rather than swallowing
it — a silent hook is the failure mode, not the safety.

**Attribution.** Adapted with attribution from the Sooth family's session-hook kit
(``sooth-standard/shp/gap_session_start.py`` + ``shp.py discover``). A **copy, not a
dependency** (Sooth's XF-03): no import edge, no package dependency, no cross-family
resolution path. The seat mapping, rule ids, and CLI surface printed here are mgf's.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import Any

from mgf.fed.sessionhooks import _events
from mgf.fed.sessionhooks._state import (
    claim_once,
    disabled_reason,
    gate_armed,
    mode,
    read_jsonl,
    repo_root,
    session_id,
    state_dir,
)

#: A hard ceiling on what this hook injects. Session-start output competes for the
#: context it is trying to protect; a re-teach that crowds out the work is a net
#: loss. Nothing here may grow past this without something else shrinking.
MAX_OUTPUT_LINES = 26

#: Where the staged gate leaves a verdict for the next session to replay. Named here
#: (not in ``_gate``) because the *reader* is this module and the reader owns the
#: contract: a stop-time hook's output is discarded when it does not block, so the
#: only channel a gate can actually speak through is the next session's start.
PENDING_FILENAME = "mgf-hooks-pending.jsonl"

#: Per-session "I have already recorded this session's start" marker, claimed with
#: ``O_CREAT | O_EXCL``. ``SessionStart`` fires on startup **and** resume **and**
#: compact, and the harness can run those concurrently — so a read-then-write
#: "have I already?" check is a race by construction, and it loses under exactly the
#: load that produces duplicates.
STARTED_PREFIX = "mgf-hooks-started-"

# The seat map, as SEATS.md's three-seat table states it. Printed verbatim every
# session: divergent re-teach text is how two repos come to believe different things
# about one protocol (SEAT-08).
_SEAT_MAP = (
    ("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)"),
)


def render_seat_block(declared: dict[str, Any] | None) -> list[str]:
    """The SEAT-02 re-teach. A declared session gets one quiet line; nobody else does.

    A re-teach that never shuts up is noise, and noise is what a reader learns to
    scroll past — which is how the original silent failures survived unnoticed.
    """
    if declared is not None:
        return [
            f"SEAT: declared ({declared.get('seat', '?')}/{declared.get('model', '?')}) "
            f"— this session's record exists (SEAT-02)."
        ]
    lines = [
        "## SEAT-02 — this session has NOT declared its seat. Declare it BEFORE you act.",
    ]
    width = max(len(tier) for tier, _, _ in _SEAT_MAP)
    lines += [f"   {tier:<{width}} -> {seat:<9} {note}" for tier, seat, note in _SEAT_MAP]
    lines += [
        "   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.",
    ]
    return lines


def _pending_path(root: Path) -> Path:
    return state_dir(root) / PENDING_FILENAME


def _replay_pending(root: Path) -> list[str]:
    """Read back what the staged gate last found, then clear it.

    A stop-time hook's output is discarded by the harness when the hook does not
    block, so a gate that speaks only at stop time is inaudible in exactly the mode
    built for *"this runs where nobody is watching"*. The gate writes here; this
    replays it into the one place it will actually be read — once, not forever.
    """
    path = _pending_path(root)
    records = read_jsonl(path)
    if not records:
        return []
    lines = [
        f"## MGF HOOKS ({records[-1].get('mode', 'observe')} mode) — the previous session's "
        f"gate recorded {len(records)} finding(s):"
    ]
    lines += [f"   - {str(rec.get('finding', rec))[:110]}" for rec in records[:3]]
    if len(records) > 3:
        lines.append(f"   …and {len(records) - 3} more: cat .claude/state/{PENDING_FILENAME}")
    try:
        path.unlink()
    except OSError:
        lines.append("   (could not clear the pending file; it will replay again)")
    return lines


def run(payload: dict[str, Any] | None = None, out: Any = None) -> int:
    """Run the SessionStart hook. Returns ``0`` on every path, always.

    ``payload`` is the harness's hook JSON (already read; this module never touches
    stdin). ``out`` defaults to stdout — the channel the harness injects into the
    model's context.
    """
    stream = sys.stdout if out is None else out
    try:
        lines = _collect(payload or {})
    except Exception as exc:  # a SessionStart hook may never raise: catch EVERYTHING
        # Loud, not silent: a broken hook that says nothing is the exact failure this
        # kit exists to end. But it still lets the session start.
        lines = [f"WARN mgf-fed hooks: {type(exc).__name__}: {exc} — session start continues"]
    return _emit_lines(lines, stream)


def _emit_lines(lines: list[str], stream: Any) -> int:
    """Write the hook's output. Returns ``0`` even when writing itself fails.

    The output channel is the last thing that can take a session down, and it does:
    a full or closed stdout raises ``OSError`` from ``print``. Catching that is not
    enough, which is the part worth writing down — **CPython flushes ``sys.stdout``
    again during interpreter shutdown, after ``main`` has returned its 0, and a
    failure there exits 120 regardless of what we returned.** So a swallowed write
    error still produced an unstartable-looking session. ``> /dev/full`` is the
    one-line repro, and it is a real disk-full session, not a hypothetical.

    The fix is to make shutdown's flush a no-op: on failure the exhausted stream is
    swapped out of ``sys.stdout`` for a sink that cannot fail. The broken object stays
    referenced by ``sys.__stdout__``, so it is never collected and never retried.

    Partial output is accepted on purpose. Losing the tail of a re-teach is a smaller
    harm than a session that will not start.
    """
    try:
        for line in lines[:MAX_OUTPUT_LINES]:
            print(line, file=stream)
        stream.flush()
    except (OSError, ValueError):
        # ValueError: writing to an already-closed stream. Same class of harm.
        _neutralise_stdout(stream)
    return 0


def _neutralise_stdout(stream: Any) -> None:
    """Replace an unwritable ``sys.stdout`` with a sink, so shutdown cannot fail."""
    if stream is not sys.stdout:
        return  # a caller-supplied stream is the caller's problem, not the session's
    try:
        sys.stdout = Path(os.devnull).open("w", encoding="utf-8")
    except OSError:  # pragma: no cover - devnull is unopenable only on a broken host
        pass


def _collect(payload: dict[str, Any]) -> list[str]:
    """The hook's whole output, as lines. Pure apart from the state it records."""
    root = repo_root(payload)
    sid = session_id(payload)

    reason = disabled_reason(root)
    if reason is not None:
        # Off means OFF — nothing recorded, nothing evaluated — and never silent.
        return [
            f"## MGF SESSION HOOKS ARE DISABLED — {reason}",
            "   Nothing is recorded and nothing can block this session. Re-enable by removing",
            "   that kill-switch (rm the file / unset MGF_HOOKS_DISABLED). This banner exists",
            "   because an OFF protocol that looks healthy is the failure mode (SHP-08).",
        ]

    lines: list[str] = []

    # Once per session id, claimed ATOMICALLY. SessionStart fires on startup, resume
    # AND compact, and the harness can run those concurrently — so the old
    # read-the-log-then-write check was a race that lost under exactly the load that
    # produces duplicates. O_EXCL has exactly one winner. It also removes an
    # unbounded log read from the hot path entirely (see _events.HOOK_READ_BYTES).
    marker = state_dir(root) / f"{STARTED_PREFIX}{sid}"
    if claim_once(marker):
        ok, why = _events.emit(
            root,
            "session_start",
            {"repo": root.name, "source": str(payload.get("source") or "startup")},
            payload=payload,
        )
        if not ok or why:
            lines.append(f"WARN mgf-fed hooks: session-start record {why or 'refused'}")
    elif not marker.exists():
        # Losing the claim has two very different causes, and conflating them is how a
        # kit ends up looking healthy while recording nothing. A marker that EXISTS
        # means someone else won — normal, silent. A marker that does not exist means
        # we could not write at all, and that has to be said (SHP-07: a no-op is loud).
        lines.append(
            f"WARN mgf-fed hooks: cannot write {state_dir(root)} — this session is "
            f"NOT being recorded (the re-teach below still stands)"
        )

    lines += _replay_pending(root)
    lines += render_seat_block(_events.declaration_for(root, sid))
    lines.append(
        f"   (observe mode: this hook records and prints; it never blocks. mode={mode()} "
        f"gate={'armed' if gate_armed() else 'off'}.)"
    )
    return lines


__all__ = [
    "MAX_OUTPUT_LINES",
    "PENDING_FILENAME",
    "STARTED_PREFIX",
    "render_seat_block",
    "run",
]
