"""The agent-compliance event log — one structured JSONL record per agent event.

The question this file answers: *when an AI session follows (or silently skips) the
federation's own law, what is left behind that a human can audit afterwards?* Today
the answer for every mgf repo is "nothing". This is the substrate that changes that,
and it is deliberately small: an append-only ``.claude/state/agent-events.jsonl`` in
the launch repo, a **closed** event vocabulary, and a reader.

**The vocabulary is CLOSED, and growth is a smell.** A log that accepts any event
name is a log nobody can query and nobody trusts; the refusal path is as much of a
feature as the append path. Adding a type is a deliberate act with a rule behind it.

=============================  ===============================================
``session_start``              a session opened in this repo (**mgf addition**)
``seat_declared``              SEAT-02: the session named its seat before acting
``seat_override``              SEAT-03/06: out-of-seat work, with the reason
``escalation``                 a standard-defined trigger fired
``journal_step``               SEAT-05: a completed step a resumer can pick up
``context_ambiguity_filed``    ambiguous auto-loaded context FILED, not absorbed
=============================  ===============================================

``session_start`` is mgf's addition to the ported vocabulary, and it is
load-bearing. The whole point of ``seat_declared`` is that its **absence** is the
finding — but an absence is only visible against a record that the session existed
at all. Sooth got that from its per-session ``preflight`` event; mgf has no
preflight on this path, so the session-start record carries it. Sooth's ``preflight``
type is deliberately **not** ported: an event type nobody emits is dead vocabulary,
which is the same smell from the other side.

**Attribution.** Adapted with attribution from the Sooth family's
``sooth-standard/shp/eventlog.py``. This is a **copy, not a dependency** (Sooth's
XF-03) — no import edge, no package dependency, no cross-family resolution path.
The rule IDs cited here are **mgf's** (`SEATS.md` SEAT-01..09); Sooth's are cited
only where this file inherits a design note verbatim in spirit.

Refusal is loud, but the log never blocks work: :func:`emit` returns ``(False,
why)`` for a refused record and the CALLER decides what to do. A persistence failure
is reported as a warning and still returns success — observability must never become
the failure.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from mgf.fed.sessionhooks._state import (
    PROTOCOL,
    append_jsonl,
    read_jsonl,
    session_id,
    state_dir,
    utc_stamp,
)

if TYPE_CHECKING:
    from pathlib import Path

EVENTS_FILENAME = "agent-events.jsonl"

#: **256 KiB** — the tail window every *hook-path* read is bounded to.
#:
#: The log is append-only and unbounded, so an unbounded read makes the cost of
#: starting a session grow forever with the age of the repo, and a hook that gets
#: slower forever is a hook that eventually gets uninstalled. 256 KiB is roughly
#: 1,500 records at the shapes this kit writes — comfortably more than any single
#: session produces, which is all a hook-path read needs to see. Consequence, stated
#: plainly: a declaration older than the last 256 KiB of a repo's log reads as
#: *absent*. That direction is deliberate — a missed old declaration costs one extra
#: re-teach line, whereas an unbounded read costs every session, forever.
HOOK_READ_BYTES = 256 * 1024

#: The closed vocabulary. See the module docstring for what each one means.
EVENT_TYPES: frozenset[str] = frozenset(
    {
        "session_start",
        "seat_declared",
        "seat_override",
        "escalation",
        "journal_step",
        "context_ambiguity_filed",
    }
)

#: The three seats of ``SEATS.md`` SEAT-01. A declaration outside this set is
#: refused: a seat the corpus does not define cannot be reasoned about later, and
#: silently accepting it would make the audit trail's own vocabulary drift.
SEATS: frozenset[str] = frozenset({"designer", "executor", "enactor"})

#: Escalation triggers. Carried over from the Sooth kit with attribution — mgf has
#: not ruled its own trigger list, so this stays a closed set rather than a free
#: string, and widening it is a corpus decision, not a code decision.
ESCALATION_TRIGGERS: frozenset[str] = frozenset(
    {"no_playbook", "telemetry_unreachable", "hypotheses_refuted", "cross_repo_blast"}
)

#: Fields each event type requires. An event that cannot answer its own question is
#: not a record, it is noise.
REQUIRED_FIELDS: dict[str, frozenset[str]] = {
    "session_start": frozenset(),
    "seat_declared": frozenset({"seat", "model"}),
    "seat_override": frozenset({"seat", "reason"}),
    "escalation": frozenset({"trigger"}),
    "journal_step": frozenset({"step"}),
    "context_ambiguity_filed": frozenset({"surface", "ambiguity"}),
}

# Reserved keys the record itself owns; a caller field may never shadow one, or the
# log's own frame becomes forgeable from the outside.
_RESERVED = frozenset({"utc", "protocol", "event", "session"})


def events_path(root: Path) -> Path:
    """The launch repo's event log."""
    return state_dir(root) / EVENTS_FILENAME


def validate(etype: str, fields: dict[str, str]) -> str | None:
    """Return the refusal reason for ``(etype, fields)``, or ``None`` if valid."""
    if etype not in EVENT_TYPES:
        return f"unknown event type {etype!r} — the vocabulary is closed: {sorted(EVENT_TYPES)}"
    missing = REQUIRED_FIELDS[etype] - fields.keys()
    if missing:
        return f"{etype!r} requires fields {sorted(missing)}"
    shadowed = _RESERVED & fields.keys()
    if shadowed:
        return f"{etype!r} may not set the reserved field(s) {sorted(shadowed)}"
    if etype in ("seat_declared", "seat_override") and fields["seat"] not in SEATS:
        return f"seat must be one of {sorted(SEATS)} (SEAT-01), got {fields['seat']!r}"
    if etype == "escalation" and fields["trigger"] not in ESCALATION_TRIGGERS:
        return f"escalation trigger must be one of {sorted(ESCALATION_TRIGGERS)}"
    return None


def emit(
    root: Path,
    etype: str,
    fields: dict[str, str] | None = None,
    *,
    payload: dict[str, Any] | None = None,
) -> tuple[bool, str]:
    """Validate, then append one record. Returns ``(ok, why)``; never raises.

    ``ok=False`` means the record was **refused** — a closed-vocabulary violation,
    which is a caller bug worth surfacing. ``ok=True`` with a non-empty ``why`` means
    the record was accepted but could not be persisted; that is reported and then
    ignored, because a hook whose behaviour changes when the disk is full is a hook
    nobody can reason about.
    """
    data = dict(fields or {})
    refusal = validate(etype, data)
    if refusal is not None:
        return False, refusal
    record: dict[str, Any] = {
        "utc": utc_stamp(),
        "protocol": PROTOCOL,
        "event": etype,
        "session": session_id(payload),
        **data,
    }
    warning = append_jsonl(events_path(root), record)
    return True, f"WARN: event accepted but {warning}" if warning else ""


def read(
    root: Path,
    *,
    tail: int | None = None,
    session: str | None = None,
    etype: str | None = None,
    max_bytes: int | None = None,
) -> list[dict[str, Any]]:
    """Read records, oldest first, optionally filtered and tailed.

    ``max_bytes`` bounds the read to that tail window — see :data:`HOOK_READ_BYTES`
    and :func:`mgf.fed.sessionhooks._state.read_jsonl`. The default is unbounded,
    because the operator reader wants the whole trail; hook-path callers pass the
    window explicitly so the choice is visible at every call site.
    """
    records = read_jsonl(events_path(root), max_bytes=max_bytes)
    if session is not None:
        records = [r for r in records if r.get("session") == session]
    if etype is not None:
        records = [r for r in records if r.get("event") == etype]
    if tail is not None and tail > 0:
        records = records[-tail:]
    return records


def declaration_for(root: Path, session: str) -> dict[str, Any] | None:
    """This session's ``seat_declared`` record, or ``None`` if it never declared.

    ``None`` is the SEAT-02 finding. The hook never manufactures one — see
    :mod:`mgf.fed.sessionhooks._session_start`.

    Bounded to :data:`HOOK_READ_BYTES`: this runs on the hook path, and a session's
    own records are by definition recent.
    """
    declared = read(root, session=session, etype="seat_declared", max_bytes=HOOK_READ_BYTES)
    return declared[-1] if declared else None


def summarise(records: list[dict[str, Any]]) -> dict[str, Any]:
    """A compact roll-up of ``records`` — counts by type, and per-session seats."""
    by_type: dict[str, int] = {}
    sessions: dict[str, dict[str, Any]] = {}
    for rec in records:
        etype = str(rec.get("event", "?"))
        by_type[etype] = by_type.get(etype, 0) + 1
        sid = str(rec.get("session", "?"))
        seen = sessions.setdefault(sid, {"events": 0, "seat": None, "model": None, "last": ""})
        seen["events"] = int(seen["events"]) + 1
        seen["last"] = rec.get("utc", "")
        if etype == "seat_declared":
            seen["seat"] = rec.get("seat")
            seen["model"] = rec.get("model")
    undeclared = sorted(sid for sid, s in sessions.items() if s["seat"] is None)
    return {
        "records": len(records),
        "sessions": len(sessions),
        "events": by_type,
        "per_session": sessions,
        "undeclared_sessions": undeclared,
    }


__all__ = [
    "ESCALATION_TRIGGERS",
    "EVENTS_FILENAME",
    "EVENT_TYPES",
    "HOOK_READ_BYTES",
    "REQUIRED_FIELDS",
    "SEATS",
    "declaration_for",
    "emit",
    "events_path",
    "read",
    "summarise",
    "validate",
]
