"""Shared plumbing for the session-hook kit — paths, session identity, kill-switch, mode.

Every hook entrypoint in this package starts here: it resolves *which repo* the
session was launched in, *which session* this is, whether the kit is switched off,
and which of the two staged modes is active. Nothing in this module raises — a hook
that dies on its own plumbing is worse than a hook that does nothing.

**Attribution.** Adapted with attribution from the Sooth family's session-hook kit
(``sooth-standard/shp/shp.py``, SHP-01..12). This is a **copy, not a dependency**
(Sooth's XF-03): mgf takes on no import edge, no package dependency, and no
cross-family resolution path. The names, paths, and environment prefixes are mgf's.

Environment (all optional; every one has a safe default):

===================================  ==========================================
``MGF_HOOKS_DISABLED``               non-empty and not ``0`` → the kit is OFF
``MGF_HOOKS_DISABLE_FILE``           override the fleet-wide kill-switch path
``MGF_HOOKS_STATE_DIR``              override ``<repo>/.claude/state`` (tests)
``MGF_HOOKS_MODE``                   ``observe`` (default) | ``enforce``
``MGF_HOOKS_GATE``                   ``1`` arms the staged gate half (default off)
``CLAUDE_SESSION_ID``                the harness's session id, when stdin has none
===================================  ==========================================

The kill-switch (**SHP-08**) is checked *before any other logic* at three scopes —
one session (env), the whole fleet (a file next to this package), one repo
(``<repo>/.claude/mgf-hooks-disabled``) — and while disabled nothing can act. The
disabled state is *announced* at session start, because an OFF protocol that looks
healthy is exactly the invisible-failure class the kit exists to end.

Stdlib only, deliberately: this module sits on the hook path, which must work on a
bare ``pip install mgf-fed`` with no extras resolved.
"""

from __future__ import annotations

import json
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

# The wire tag stamped into every record this kit writes. Bump the minor when a
# record grows a field; bump the major only if a reader would misread the old shape.
PROTOCOL = "mgf-sessionhooks/0.1"

MODE_OBSERVE = "observe"
MODE_ENFORCE = "enforce"

# The fleet-wide kill-switch file, next to this package. One file stops every repo,
# because every repo's settings.json shim reaches THIS one installed implementation
# (SHP-09, one implementation). The env override exists so tests can exercise the
# switch hermetically and can never create or trip a real one.
_DEFAULT_DISABLE_FILE = Path(__file__).resolve().parent / "DISABLED"

# Per-repo kill-switch, relative to the repo root.
REPO_DISABLE_REL = Path(".claude") / "mgf-hooks-disabled"

_SAFE_SESSION_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-")


def utc_stamp() -> str:
    """The one timestamp format this kit writes (TIME: UTC, second resolution)."""
    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


def hook_input(stream: Any = None) -> dict[str, Any]:
    """The harness's hook JSON, read from stdin exactly ONCE.

    Absent, unreadable, or unparseable stdin is **not** an error — it is a manual
    run. Returns ``{}`` in every one of those cases so the caller falls back to the
    process cwd.

    Read-once matters: chaining a second reader onto the same stream starves it, and
    a starved reader silently falls back to ``os.getcwd()`` — which is wherever the
    harness happened to leave us, not the launch repo. Callers pass the payload down
    rather than re-reading.
    """
    src = sys.stdin if stream is None else stream
    try:
        raw = src.read()
    except (OSError, ValueError, UnicodeDecodeError):
        return {}
    if not raw or not raw.strip():
        return {}
    try:
        parsed = json.loads(raw)
    except (json.JSONDecodeError, TypeError):
        return {}
    return parsed if isinstance(parsed, dict) else {}


def session_id(payload: dict[str, Any] | None = None) -> str:
    """The session identifier, sanitised so it can never escape the state dir.

    Order: the harness's contracted ``session_id`` field → ``$CLAUDE_SESSION_ID`` →
    ``"manual"``. Anything outside ``[A-Za-z0-9_.-]`` becomes ``_``; a value that
    sanitises to nothing falls back to ``"manual"`` rather than to an empty path
    component.
    """
    contracted = (payload or {}).get("session_id")
    raw = str(contracted or os.environ.get("CLAUDE_SESSION_ID") or "").strip()
    if not raw:
        return "manual"
    cleaned = "".join(c if c in _SAFE_SESSION_CHARS else "_" for c in raw)
    return cleaned.strip("_") or "manual"


def repo_root(payload: dict[str, Any] | None = None) -> Path:
    """The repo the session was launched in.

    The harness's hook JSON carries a **contracted** ``cwd``; ``$CLAUDE_PROJECT_DIR``
    appears in the docs' examples but is explicitly uncontracted, and ``os.getcwd()``
    is merely wherever the harness left us. Prefer the contracted field — if it is
    ever wrong, state lands in the wrong repo and every later read finds nothing and
    fails open, silently.

    From that starting point, walk up to the nearest directory holding a ``.git``
    entry (a *file* for a worktree, a *directory* for a primary clone). No subprocess:
    ``git`` need not be on PATH for a session to start, and a fork per session start
    is a cost with no payer.
    """
    start_raw = str((payload or {}).get("cwd") or "").strip()
    try:
        start = Path(start_raw).resolve() if start_raw else Path.cwd().resolve()
    except (OSError, ValueError):
        return Path.cwd()
    if not start.is_dir():
        start = start.parent
    for candidate in (start, *start.parents):
        if (candidate / ".git").exists():
            return candidate
    return start


def state_dir(root: Path) -> Path:
    """``<repo>/.claude/state`` — the launch repo's local, git-ignored state.

    Local on purpose: the record of what an agent did in a repo belongs *in* that
    repo, readable in realtime (``tail -f``) by whoever is sitting there.
    """
    override = os.environ.get("MGF_HOOKS_STATE_DIR", "").strip()
    return Path(override) if override else root / ".claude" / "state"


def mode() -> str:
    """``observe`` (the default) or ``enforce``.

    The default is ``observe`` **on purpose**: if this kit is ever installed by
    accident or a shim is mis-wired, the worst case is a log entry — never an
    unendable session. See :mod:`mgf.fed.sessionhooks._gate` for the boundary.
    """
    raw = os.environ.get("MGF_HOOKS_MODE", "").strip().lower()
    return MODE_ENFORCE if raw == MODE_ENFORCE else MODE_OBSERVE


def gate_armed() -> bool:
    """Is the staged gate half armed? Default **no**.

    Two independent keys stand between a fresh install and a session that can be
    blocked: this flag arms the gate at all, and :func:`mode` decides whether an
    armed gate blocks or merely records. SHP-11: no step may create a state that is
    only safe if a later step also happens — so the step that installs the kit
    cannot, by itself, produce a blocking gate.

    **Strict:** the literal ``"1"`` is the only arming value, which is exactly what
    the how-to documents. Anything else — ``true``, ``yes``, ``on``, a typo — reads
    as **unarmed**. A truthiness test would arm the gate on strings the operator was
    never told would arm it, and the direction of a mistake here has to be towards
    *not* blocking. :func:`mode` fails the same way for the same reason.
    """
    return os.environ.get("MGF_HOOKS_GATE", "").strip() == "1"


def disable_file() -> Path:
    """The fleet-wide kill-switch path (overridable for hermetic tests)."""
    override = os.environ.get("MGF_HOOKS_DISABLE_FILE", "").strip()
    return Path(override) if override else _DEFAULT_DISABLE_FILE


def disabled_reason(root: Path) -> str | None:
    """SHP-08 — the kill-switch. Three scopes, first match wins; ``None`` when live.

    Never raises: a broken switch check must not take the hooks down with it.
    """
    try:
        env = os.environ.get("MGF_HOOKS_DISABLED", "").strip()
        if env and env != "0":
            return f"MGF_HOOKS_DISABLED={env} in the environment"
        fleet = disable_file()
        if fleet.exists():
            return f"fleet-wide kill-switch file {fleet}"
        per_repo = root / REPO_DISABLE_REL
        if per_repo.exists():
            return f"per-repo kill-switch file {per_repo}"
    except OSError:
        return None
    return None


def append_jsonl(path: Path, record: dict[str, Any]) -> str | None:
    """Append one record as a single JSONL line. Returns a warning, or ``None``.

    Append-only, one small ``O_APPEND`` line at a time, because the harness may run
    tools in **parallel**: a read-modify-write of a JSON document would race and lose
    records, while two short appends cannot interleave destructively.

    Never raises. Observability must never become the failure — a hook whose
    behaviour changes because a disk filled up is a hook nobody can reason about.
    """
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(record, sort_keys=True) + "\n")
    except OSError as exc:
        return f"not persisted ({type(exc).__name__}: {exc})"
    return None


def read_jsonl(path: Path, *, max_bytes: int | None = None) -> list[dict[str, Any]]:
    """Read a JSONL file, skipping torn or non-object lines.

    A crash mid-write leaves a partial last line. That must never break the reader —
    an audit trail that becomes unreadable at the first bad byte is not an audit
    trail. Missing file → ``[]`` (the kit may simply never have run here).

    ``max_bytes`` reads only the **tail window** of that size, discarding the first
    (probably partial) line in it. This is what the *hook* path uses: an event log is
    append-only and unbounded, so an unbounded read means the cost of starting a
    session grows without limit with the age of the repo — a hook that gets slower
    forever is a hook that eventually gets uninstalled. The *operator* reader
    (``mgf-fed hooks log``) passes ``None`` and reads everything, because a human
    asking for the trail wants the trail.
    """
    try:
        if max_bytes is None:
            text = path.read_text(encoding="utf-8", errors="replace")
        else:
            with path.open("rb") as handle:
                handle.seek(0, os.SEEK_END)
                size = handle.tell()
                windowed = size > max_bytes
                handle.seek(max(0, size - max_bytes))
                text = handle.read().decode("utf-8", errors="replace")
            if windowed:
                # The window almost certainly starts mid-record. Drop that fragment
                # rather than let a half-line parse into something plausible.
                text = text.partition("\n")[2]
    except OSError:
        return []
    out: list[dict[str, Any]] = []
    for line in text.splitlines():
        if not line.strip():
            continue
        try:
            parsed = json.loads(line)
        except json.JSONDecodeError:
            continue
        if isinstance(parsed, dict):
            out.append(parsed)
    return out


def claim_once(path: Path) -> bool:
    """Atomically claim a one-time marker. ``True`` for the single winner.

    ``O_CREAT | O_EXCL`` is atomic on POSIX and Windows: exactly one caller creates
    the file and everybody else sees ``FileExistsError``. That is the whole
    mechanism, and it replaces a read-then-write check which is a race by
    construction — ``SessionStart`` fires on startup **and** resume **and** compact,
    and the harness can run several of those concurrently, so "have I already done
    this?" answered by reading is answered wrongly under exactly the load that
    matters.

    Never raises. A marker that cannot be created (unwritable state dir) returns
    ``False``: the caller then does nothing, which is the safe direction — a
    duplicate record is noise, but a hook that dies on its own bookkeeping is an
    outage.
    """
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
    except OSError:
        return False
    try:
        os.close(fd)
    except OSError:
        pass
    return True


__all__ = [
    "MODE_ENFORCE",
    "MODE_OBSERVE",
    "PROTOCOL",
    "REPO_DISABLE_REL",
    "append_jsonl",
    "claim_once",
    "disable_file",
    "disabled_reason",
    "gate_armed",
    "hook_input",
    "mode",
    "read_jsonl",
    "repo_root",
    "session_id",
    "state_dir",
    "utc_stamp",
]
