"""
Stop hook — blocks Claude response based on current pipeline gate.

Reads pipeline-state.json to determine which gate to enforce.
If pipeline-state.json is absent, defaults to reference gate (fresh start).

Activation: only fires when a .ui-re-active marker exists in tmp/ref/*/.

Usage: python -m ui_clone.hooks.section_gate
Outputs {"decision": "block", "reason": "..."} to stdout to block, or exits 0 to allow.
"""

from __future__ import annotations

import json
import os
import sys
import time
from pathlib import Path
from typing import cast

from ui_clone.goal import build_goal_card
from ui_clone.hooks._common import find_project_root as _find_project_root
from ui_clone.hooks._common import run_gate as _run_gate
from ui_clone.hooks._common import session_id_from_payload as _session_id_from_payload
from ui_clone.hooks._common import should_enforce_ref_for_session as _should_enforce_ref_for_session
from ui_clone.state import GATE_ORDER, PipelineState

_DEFAULT_STALE_DAYS = 3
_DEFAULT_ACTIVE_MAX = 2


def _is_driver_session(project_root: Path, session_id_from_payload: str = "") -> bool:
    """Driver-session bypass — release Stop unconditionally when the current
    Claude Code session is registered as a loop driver for this repo.

    Why this exists:
      A maintainer driver session in this repo monitors parallel
      benchmark / sub-workspace sessions (each in their own tab with
      their own session id). When a sub-workspace creates impl/ at
      `<repo>/scratch/<dir>/impl/`, the Stop hook's search_root walk
      finds that ref dir as "active" and fires on the driver's response.
      The driver itself never claims to clone anything, so verify-stamp
      enforcement is a category mismatch.

    Activation:
      1. Marker file <project_root>/.driver-session.id exists, AND
      2. Its content matches the active session id. We check, in order:
           (a) `session_id_from_payload` — the `session_id` field on the
               Claude Code hook JSON stdin payload (canonical),
           (b) `os.environ.get("CLAUDE_CODE_SESSION_ID")` — fallback for
               hosts that propagate session-id via env instead of stdin
               (e.g. direct CLI invocation in tests).

    Production users never create the marker, so the gate works as before.
    Child sessions spawned by the driver have their own session_id; even
    if they could read the marker, no match → gate fires for them as
    expected.

    The marker is local-only state (gitignored). Register via
    `bash scripts/register-driver-session.sh` or
    `python -m ui_clone.driver_session register <id>` — both use the
    append-if-missing writer in `ui_clone.driver_session` which holds
    `fcntl.flock` across the read-modify-write so concurrent driver
    sessions don't stomp each other. Manual `echo > .driver-session.id`
    is single-writer and was the source of the 2026-05-24 multi-driver
    stomping incident.
    """
    marker = project_root / ".driver-session.id"
    if not marker.is_file():
        return False
    try:
        recorded_ids = {
            line.strip()
            for line in marker.read_text(encoding="utf-8").splitlines()
            if line.strip()
        }
    except OSError:
        return False
    if not recorded_ids:
        return False
    candidates = [
        session_id_from_payload.strip(),
        os.environ.get("CLAUDE_CODE_SESSION_ID", "").strip(),
    ]
    return any(c in recorded_ids for c in candidates if c)


def _get_stale_seconds() -> float:
    """Return stale threshold in seconds. Overridable via UI_RE_STALE_DAYS env var."""
    try:
        days = float(os.environ.get("UI_RE_STALE_DAYS", _DEFAULT_STALE_DAYS))
    except (ValueError, TypeError):
        days = _DEFAULT_STALE_DAYS
    return days * 24 * 3600


def _get_active_max() -> int:
    """Return max active refs kept by the Stop hook. 0 disables LRU pruning."""
    try:
        value = int(os.environ.get("UI_RE_ACTIVE_MAX", _DEFAULT_ACTIVE_MAX))
    except (ValueError, TypeError):
        return _DEFAULT_ACTIVE_MAX
    return value if value >= 0 else _DEFAULT_ACTIVE_MAX


def _is_cross_scratch_impl(ref_dir: Path, impl_dir: Path) -> bool:
    """Return True when `impl_dir` points at another scratch run.

    `implRoot` / `.impl-root` are explicit wires, but they are local state and
    can survive from interrupted sessions. A stale wire like
    `tmp/ref/project-a-main -> scratch/project-a-sustainability-04` makes the
    Stop hook demand verify evidence for the wrong clone. Keep arbitrary
    non-scratch impl locations valid, and keep env overrides above this guard;
    only reject repo-local `scratch/<slot>` paths whose slot is not correlated
    with this ref name.
    """
    if not (ref_dir.parent.name == "ref" and ref_dir.parent.parent.name == "tmp"):
        return False
    project_root = ref_dir.parent.parent.parent
    scratch_root = project_root / "scratch"
    try:
        rel = impl_dir.resolve().relative_to(scratch_root.resolve())
    except (OSError, ValueError):
        return False
    if not rel.parts:
        return False
    slot = rel.parts[0]
    ref_name = ref_dir.name
    return not (
        slot == ref_name
        or slot.startswith(f"{ref_name}-")
        or slot.startswith(f"{ref_name}_")
        or slot.startswith(f"{ref_name}.")
    )


def _resolve_impl_dir(ref_dir: Path, fallback_root: Path | None = None) -> Path | None:
    """Resolve the impl/ directory for `ref_dir`, preferring the per-ref-dir
    `impl_root` recorded in pipeline-state.json.

    False-positive cascade closure:
    `ref_dir.parent.parent.parent / "impl"` and `project_root / "impl"` both
    assume one canonical impl/ per project. When a stray `<repo>/impl`
    symlink lives at the repo root (rogue subagent state, a hand-symlink, a
    leftover convention), every prior `tmp/ref/<c>/` false-positives as
    "active" and the verify-stamp gate fires against unrelated months-old
    clones. Now that `PipelineState.impl_root` and the `.impl-root` marker
    are written at Phase 1 start, the resolver can be precise per ref dir.

    Priority order:
    1. UI_CLONE_IMPL_ROOT env (operator override / sub-workspace testing)
    2. `pipeline-state.json` → `impl_root` (set by Phase 1 of the pipeline)
    3. `<ref_dir>/.impl-root` marker file (set alongside the state field)
    4. Fallback to the convention path (`<fallback_root or repo>/impl`)
       — preserves legacy behavior for ref dirs that predate the impl_root
       field; the fallback still produces the same single-impl assumption
       failures as before, but only when the per-ref-dir resolution found
       nothing.

    Returns None when no resolution exists at all, so callers can branch
    on "no impl is wired up to this ref" instead of grabbing a rogue symlink.
    """
    env_root = os.environ.get("UI_CLONE_IMPL_ROOT", "").strip()
    if env_root:
        p = Path(env_root)
        if p.is_dir():
            return p

    try:
        state = PipelineState.load(ref_dir)
        if state.impl_root:
            p = Path(state.impl_root)
            if p.is_dir() and not _is_cross_scratch_impl(ref_dir, p):
                return p
    except OSError:
        pass

    marker = ref_dir / ".impl-root"
    if marker.is_file():
        try:
            marker_value = marker.read_text(encoding="utf-8").strip()
            if marker_value:
                p = Path(marker_value)
                if p.is_dir() and not _is_cross_scratch_impl(ref_dir, p):
                    return p
        except OSError:
            pass

    candidates: list[Path] = []
    # Planned agent-first layout: .ui-clone/runs/<id>/impl.
    candidates.append(ref_dir / "impl")
    if fallback_root is not None:
        candidates.append(fallback_root / "impl")
    # ref_dir.parent.parent.parent matches the legacy derivation
    # (tmp/ref/<c>/.. = tmp/ref/.. = tmp/.. = <project-root>).
    try:
        candidates.append(ref_dir.parent.parent.parent / "impl")
    except (IndexError, OSError):
        pass
    # Sub-workspace fallback: an agent may write `<repo>/scratch/<name>/impl/`
    # and `<repo>/tmp/ref/<name>/` as siblings but never set `.impl-root`
    # or `pipeline-state.impl_root`. The legacy `<repo>/impl` fallback
    # doesn't exist in that layout, so the resolver returns None and the
    # Stop hook's verify-stamp gate silently skips this ref dir. The
    # agent then declares success on build/render alone.
    # Heuristic: when ref dir lives at `<repo>/tmp/ref/<name>/`, also
    # try `<repo>/scratch/<name>/impl/`. This recovers the linkage
    # without trusting the agent to write the marker.
    try:
        repo = ref_dir.parent.parent.parent  # tmp/ref/<c>/ → <repo>
        candidates.append(repo / "scratch" / ref_dir.name / "impl")
    except (IndexError, OSError):
        pass
    for cand in candidates:
        if cand.is_dir() and not _is_cross_scratch_impl(ref_dir, cand):
            return cand
    return None


def _terminal_state_block_reason(ref_dir: Path, state: PipelineState) -> str | None:
    """Return a Stop block if explicit terminal state is stale.

    Terminal failed/incomplete/unclonable state is the canonical way to end an
    evidence run without forging verify-stamp.json. It should release Stop only
    for that exact impl snapshot. If the agent edits impl after the terminal
    state was recorded, the run is active again and must verify or terminalize
    anew.
    """
    terminal = state.terminal_state
    if not terminal:
        return None
    status = str(terminal.get("status") or "").lower()
    if status not in {"failed", "incomplete", "unclonable", "abandoned"}:
        return (
            f"⛔ UI-RE terminal-state gate: malformed terminalState for {ref_dir}\n\n"
            f"terminalState.status={terminal.get('status')!r} is not one of "
            f"failed/incomplete/unclonable/abandoned. Fix pipeline-state.json "
            f"or rerun canonical verify.\n"
        )

    impl_dir = _resolve_impl_dir(ref_dir)
    state_path = ref_dir / "pipeline-state.json"
    if impl_dir is not None and impl_dir.is_dir() and state_path.is_file():
        changed = _newer_impl_files(impl_dir, state_path)
        if changed:
            sample = "\n".join(f"  - {p}" for p in changed)
            return (
                f"⛔ UI-RE terminal-state gate: impl changed after terminal state for {ref_dir}\n\n"
                f"terminalState is {status!r} ({terminal.get('category', 'unknown')}) "
                f"but these implementation files are newer than pipeline-state.json:\n"
                f"{sample}\n\n"
                f"Rerun canonical verify or record a fresh explicit terminal state:\n"
                f"  npx ui-clone pipeline <url> <component> <session> verify --json\n"
            )
    return None


def _find_active_markers(search_root: Path) -> list[Path]:
    """Return ref dirs that should engage the Stop hook.

    Two activation paths:
    1. Explicit `.ui-re-active` marker — written by pre_generate.py on the
       first passing pre-generate gate. This is the canonical "I am in a
       ui-re flow" signal.
    2. impl/ alongside tmp/ref/<c>/ even without the explicit marker —
       audit incident post-mortem: nested agents that skip Phase 5/6 (spec) never
       pass pre-generate, the marker never gets written, and the Stop hook
       used to release silently. Treat the bare presence of impl/ as a
       sufficient signal that an agent is mid-clone, so the verify-stamp
       gate gets a chance to enforce.
    """
    if not search_root.is_dir():
        return []
    dirs: list[Path] = []
    # project_root is the parent of tmp/, which is the parent of search_root.
    project_root = search_root.parent.parent
    for d in sorted(search_root.iterdir()):
        if not d.is_dir():
            continue
        if (d / ".ui-re-active").is_file():
            dirs.append(d)
            continue
        # Implicit activation: impl/ exists but the canonical marker is missing.
        # Resolve impl per ref dir (impl_root / .impl-root marker / env),
        # not from a single repo-root convention.
        # A rogue <project_root>/impl symlink no longer false-positives every
        # prior tmp/ref/<c>/ — only ref dirs whose recorded impl_root exists
        # implicit-activate.
        impl_dir = _resolve_impl_dir(d, fallback_root=project_root)
        if impl_dir is not None and any(d.iterdir()):
            # Skip empty ref dirs to avoid false positives on totally cold
            # fresh starts.
            dirs.append(d)
    return dirs


def _pipeline_state_epoch(ref_dir: Path) -> float | None:
    """Authoritative activity timestamp: pipeline-state.json's last_updated,
    parsed to epoch seconds. Returns None when absent/unparseable.

    Filesystem mtime alone is unreliable for staleness: any read, scan, or the
    Stop hook itself touching a ref dir bumps its mtime, so an abandoned orphan
    (last advanced days ago) can masquerade as fresh and out-rank the genuinely
    active ref. pipeline-state.last_updated only moves when the pipeline does
    real work, so it is the correct activity signal.
    """
    try:
        state = PipelineState.load(ref_dir)
    except (OSError, ValueError):
        return None
    raw = (state.last_updated or "").strip()
    if not raw:
        return None
    import datetime

    iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
    try:
        dt = datetime.datetime.fromisoformat(iso)
    except ValueError:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=datetime.UTC)
    return dt.timestamp()


def _active_ref_mtime(ref_dir: Path) -> float | None:
    """Best-effort activity timestamp for implicit active refs without markers.

    Prefers pipeline-state.last_updated (authoritative) and only falls back to
    filesystem mtime when no pipeline-state timestamp exists — so a freshly
    touched but abandoned orphan is still correctly classified stale.
    """
    state_epoch = _pipeline_state_epoch(ref_dir)
    if state_epoch is not None:
        return state_epoch
    mtimes: list[float] = []
    try:
        mtimes.append(ref_dir.stat().st_mtime)
    except OSError:
        return None
    try:
        for child in ref_dir.iterdir():
            try:
                mtimes.append(child.stat().st_mtime)
            except OSError:
                continue
    except OSError:
        pass
    return max(mtimes) if mtimes else None


def _fresh_active_dirs(active_dirs: list[Path]) -> list[Path]:
    now = time.time()
    fresh_dirs: list[tuple[Path, float, bool]] = []
    for ref_dir in active_dirs:
        marker = ref_dir / ".ui-re-active"
        if not marker.is_file():
            activity_mtime = _active_ref_mtime(ref_dir)
            if activity_mtime is None:
                continue
            age = now - activity_mtime
            if age >= _get_stale_seconds():
                age_days = int(age // 86400)
                print(
                    f"ui-clone-skills: Stale implicit WIP ref ({age_days}d) at {ref_dir} — skipping.",
                    file=sys.stderr,
                )
                continue
            fresh_dirs.append((ref_dir, activity_mtime, False))
            continue
        try:
            marker_mtime = marker.stat().st_mtime
        except OSError:
            continue
        age = now - marker_mtime
        if age >= _get_stale_seconds():
            age_days = int(age // 86400)
            print(
                f"ui-clone-skills: Stale WIP marker ({age_days}d) at {marker} — removing.",
                file=sys.stderr,
            )
            try:
                marker.unlink()
            except OSError:
                pass
            continue
        fresh_dirs.append((ref_dir, marker_mtime, True))

    active_max = _get_active_max()
    if active_max > 0 and len(fresh_dirs) > active_max:
        newest = sorted(fresh_dirs, key=lambda item: (item[1], str(item[0])), reverse=True)[
            :active_max
        ]
        keep = {ref_dir for ref_dir, _, _ in newest}
        for ref_dir, _, has_marker in fresh_dirs:
            if ref_dir in keep:
                continue
            marker = ref_dir / ".ui-re-active"
            if has_marker:
                print(
                    f"ui-clone-skills: LRU-pruned WIP marker at {marker} — removing.",
                    file=sys.stderr,
                )
                try:
                    marker.unlink()
                except OSError:
                    pass
            else:
                print(
                    f"ui-clone-skills: LRU-pruned implicit WIP ref at {ref_dir} — skipping.",
                    file=sys.stderr,
                )
    else:
        keep = {ref_dir for ref_dir, _, _ in fresh_dirs}

    return [ref_dir for ref_dir, _, _ in fresh_dirs if ref_dir in keep]


def _emit_block(reason: str) -> None:
    print(json.dumps({"decision": "block", "reason": reason}, ensure_ascii=False))


def _block_reason_for_gate(gate_name: str, ref_dir: Path, gate_result: dict[str, object]) -> str:
    failures: list[dict[str, str]] = cast(list[dict[str, str]], gate_result.get("failures", []))
    fail_count = cast(int, gate_result.get("fail_count", len(failures)))
    missing_list = "\n  - ".join(f["label"] for f in failures[:10])
    return (
        f"⛔ UI-RE Gate: {gate_name} BLOCKED\n\n"
        f"Incomplete items ({fail_count}):\n  - {missing_list}\n\n"
        f"Run:\n"
        f"  python -m ui_clone.gate {ref_dir} {gate_name}\n"
        f"  → After passing, run python -m ui_clone.goal {ref_dir} for the next bounded goal\n\n"
        f"{build_goal_card(ref_dir)}"
    )


def _section_compare_block_reason(ref_dir: Path, gate_result: dict[str, object]) -> str:
    failures: list[dict[str, str]] = cast(list[dict[str, str]], gate_result.get("failures", []))
    fail_count = cast(int, gate_result.get("fail_count", len(failures)))
    parts = [f"⛔ UI-RE Gate: section-compare FAILED for {ref_dir} ({fail_count} issue(s))."]
    for f in failures[:5]:
        parts.append(f"  • {f['label']}: {f['reason']}")
        if f.get("fix"):
            parts.append(f"    → {f['fix']}")
    parts.append("\nAll sections must PASS before finishing.")
    parts.append(f"\nRun: python -m ui_clone.goal {ref_dir}")
    parts.append(build_goal_card(ref_dir))
    return "\n".join(parts)


def _unknown_gate_block_reason(current_gate: str, ref_dir: Path) -> str:
    valid_gates = ", ".join([*GATE_ORDER, "done"])
    return (
        f"⛔ UI-RE Gate: unknown current_gate BLOCKED for {ref_dir}\n\n"
        f"pipeline-state.json has unknown current_gate {current_gate!r}.\n"
        f"Valid current_gate values: {valid_gates}.\n\n"
        f"Run:\n"
        f"  python -m ui_clone.goal {ref_dir}\n\n"
        f"{build_goal_card(ref_dir)}"
    )


_VERIFY_STAMP_MAX_AGE_S = 1800  # 30 min — generous so the agent has time to
# finish the response after running verify, but short enough that stale
# stamps from a previous run don't satisfy the gate.
_VERIFY_STAMP_WATCH_EXTS = {
    ".css",
    ".gif",
    ".html",
    ".jpeg",
    ".jpg",
    ".js",
    ".json",
    ".jsx",
    ".mp4",
    ".png",
    ".scss",
    ".svg",
    ".ts",
    ".tsx",
    ".webm",
    ".webp",
}
_VERIFY_STAMP_SKIP_DIRS = {
    ".git",
    ".next",
    "build",
    "dist",
    "node_modules",
}


def _newer_impl_files(impl_dir: Path, stamp_path: Path, limit: int = 5) -> list[Path]:
    """Return impl files modified after verify-stamp.json.

    A fresh timestamp alone is not enough: agents can run `pipeline ... verify`,
    then patch JSX/CSS/assets and stop within the 30-minute stamp window. Scan
    the implementation surface and force a new verify when source changed.
    """
    try:
        stamp_mtime = stamp_path.stat().st_mtime
    except OSError:
        return []

    changed: list[Path] = []
    for path in impl_dir.rglob("*"):
        if not path.is_file():
            continue
        if any(part in _VERIFY_STAMP_SKIP_DIRS for part in path.parts):
            continue
        if path.suffix.lower() not in _VERIFY_STAMP_WATCH_EXTS:
            continue
        try:
            if path.stat().st_mtime > stamp_mtime + 0.001:
                changed.append(path)
        except OSError:
            continue
        if len(changed) >= limit:
            break
    return changed


def _enforce_verify_stamp(ref_dir: Path) -> str | None:
    """Block Stop unless pipeline.execute_verify wrote a fresh stamp.

    Prior audit finding: the SKILL.md mandate to run
    `pipeline ... verify` was bypassed because the agent invoked
    individual verification scripts directly. This check closes the
    bypass — Stop blocks unless `verify-stamp.json` exists AND is
    newer than _VERIFY_STAMP_MAX_AGE_S.

    Only fires when impl/ exists (post-generation). Pre-generation
    loops are governed by the regular current_gate enforcement.
    """
    # impl/ is resolved via the per-ref-dir impl_root field. The legacy
    # ref_dir.parent.parent.parent / "impl" walk is kept as a fallback
    # inside _resolve_impl_dir for ref dirs that predate the field, but
    # the prior single-impl-per-project assumption no longer false-
    # positives every ref dir when a rogue <repo>/impl symlink exists.
    impl_dir = _resolve_impl_dir(ref_dir)
    if impl_dir is None or not impl_dir.is_dir():
        return None  # pre-generation — no stamp required yet

    stamp_path = ref_dir / "verify-stamp.json"
    if not stamp_path.is_file():
        return (
            f"⛔ UI-RE Verify-stamp gate: BLOCKED for {ref_dir}\n\n"
            f"impl/ exists at {impl_dir} but no verify-stamp.json. The Stop hook\n"
            f"requires `python -m ui_clone.pipeline ... verify` to have run and\n"
            f"passed before the response can end.\n\n"
            f"Build success, HTTP 200/title checks, local render, and visual spot checks\n"
            f"are not completion evidence. Missing artifacts are hard failures, not\n"
            f"substitutes for canonical verification.\n\n"
            f"Fix:\n"
            f"  python -m ui_clone.pipeline <url> <component> <session> verify\n\n"
            f"Verify drives the post-impl gates in GATE_ORDER (post-implement,\n"
            f"boundary, font-parity, section-compare) and stamps {stamp_path.name}\n"
            f"on success.\n"
        )

    try:
        stamp = json.loads(stamp_path.read_text())
        import datetime
        stamped_at = datetime.datetime.strptime(
            stamp["verifiedAt"], "%Y-%m-%dT%H:%M:%SZ"
        ).replace(tzinfo=datetime.UTC)
        age_s = (datetime.datetime.now(datetime.UTC) - stamped_at).total_seconds()
    except (KeyError, ValueError, json.JSONDecodeError) as exc:
        return (
            f"⛔ UI-RE Verify-stamp gate: malformed stamp {stamp_path}\n\n"
            f"{exc}\n\n"
            f"Re-run `python -m ui_clone.pipeline ... verify` to regenerate.\n"
        )
    required_gates = {"spec", "post-implement", "boundary", "font-parity", "section-compare"}
    stamped_by = stamp.get("stampedBy")
    gates_passed = stamp.get("gatesPassed")
    missing_gates: list[str] = []
    if isinstance(gates_passed, list):
        passed_set = {str(g) for g in gates_passed}
        missing_gates = sorted(required_gates - passed_set)
    else:
        missing_gates = sorted(required_gates)
    if stamped_by != "pipeline.execute_verify" or missing_gates:
        missing = ", ".join(missing_gates) if missing_gates else "none"
        return (
            f"⛔ UI-RE Verify-stamp gate: non-canonical stamp for {ref_dir}\n\n"
            f"verify-stamp.json must be written by pipeline.execute_verify after the\n"
            f"canonical post-implementation gate suite passes. Current stampedBy={stamped_by!r};\n"
            f"missing required gate evidence: {missing}.\n\n"
            f"Build success, HTTP 200/title checks, local render, and visual spot checks\n"
            f"are not completion evidence.\n\n"
            f"Re-run:\n"
            f"  python -m ui_clone.pipeline <url> <component> <session> verify\n"
        )
    if age_s > _VERIFY_STAMP_MAX_AGE_S:
        return (
            f"⛔ UI-RE Verify-stamp gate: STALE stamp for {ref_dir}\n\n"
            f"verify-stamp.json is {int(age_s)}s old (max {_VERIFY_STAMP_MAX_AGE_S}s).\n"
            f"impl/ was likely modified after the last verify. Re-run:\n\n"
            f"  python -m ui_clone.pipeline <url> <component> <session> verify\n"
        )

    changed = _newer_impl_files(impl_dir, stamp_path)
    if changed:
        sample = "\n".join(f"  - {p}" for p in changed)
        return (
            f"⛔ UI-RE Verify-stamp gate: impl changed after verify for {ref_dir}\n\n"
            f"These implementation files are newer than verify-stamp.json:\n"
            f"{sample}\n\n"
            f"Re-run the canonical closeout after the last code/asset edit:\n\n"
            f"  python -m ui_clone.pipeline <url> <component> <session> verify\n"
        )
    return None


def _enforce_structural_convergence_stamp(ref_dir: Path) -> str | None:
    """Block Stop unless check-converged.sh wrote a fresh structural stamp.

    Parallel to _enforce_verify_stamp but for plans that opted into the
    structural closeout policy. The two stamps are
    distinct artifacts with distinct writers — section-staged plans satisfy
    Stop via structural-convergence-stamp.json from scripts/verify/check-
    converged.sh; canonical plans satisfy Stop via verify-stamp.json from
    pipeline.execute_verify. Mixing them is forbidden by closeoutPolicy
    routing in _enforce_ref_dir.

    Same anti-cheat invariants as canonical: fresh stamp, canonical writer,
    impl files no newer than the stamp, AND the underlying sections/result.txt
    must hash-match what was stamped (analogue of impl-freshness for the
    convergence evidence itself).
    """
    impl_dir = _resolve_impl_dir(ref_dir)
    if impl_dir is None or not impl_dir.is_dir():
        return None  # pre-generation — no stamp required yet

    stamp_path = ref_dir / "structural-convergence-stamp.json"
    if not stamp_path.is_file():
        return (
            f"⛔ UI-RE Structural-stamp gate: BLOCKED for {ref_dir}\n\n"
            f"closeoutPolicy=structural but no structural-convergence-stamp.json.\n"
            f"This run satisfies Stop via the convergence detector's stamp; it\n"
            f"is written on 0-FAIL by:\n\n"
            f"  bash scripts/verify/check-converged.sh {ref_dir} --write-stamp [--stage <A|B|C|D>]\n\n"
            f"Convergence definition: sections/result.txt's last `**Result: ...**` line\n"
            f"shows 0 FAIL (STRUCTURAL_ONLY counted as PASS, SKIP doesn't gate).\n"
        )

    try:
        stamp = json.loads(stamp_path.read_text())
        import datetime
        stamped_at = datetime.datetime.strptime(
            stamp["verifiedAt"], "%Y-%m-%dT%H:%M:%SZ"
        ).replace(tzinfo=datetime.UTC)
        age_s = (datetime.datetime.now(datetime.UTC) - stamped_at).total_seconds()
    except (KeyError, ValueError, json.JSONDecodeError) as exc:
        return (
            f"⛔ UI-RE Structural-stamp gate: malformed stamp {stamp_path}\n\n"
            f"{exc}\n\n"
            f"Re-run `bash scripts/verify/check-converged.sh {ref_dir} --write-stamp`.\n"
        )

    stamped_by = stamp.get("stampedBy")
    closeout_kind = stamp.get("closeoutKind")
    if stamped_by != "scripts/verify/check-converged.sh" or closeout_kind != "structural":
        return (
            f"⛔ UI-RE Structural-stamp gate: non-canonical stamp for {ref_dir}\n\n"
            f"structural-convergence-stamp.json must be written by\n"
            f"scripts/verify/check-converged.sh with closeoutKind=structural.\n"
            f"Current stampedBy={stamped_by!r}, closeoutKind={closeout_kind!r}.\n\n"
            f"Build success, HTTP 200/title checks, and hand-written stamps are\n"
            f"not completion evidence — only the convergence detector's stamp\n"
            f"counts.\n\n"
            f"Re-run:\n"
            f"  bash scripts/verify/check-converged.sh {ref_dir} --write-stamp\n"
        )

    if age_s > _VERIFY_STAMP_MAX_AGE_S:
        return (
            f"⛔ UI-RE Structural-stamp gate: STALE stamp for {ref_dir}\n\n"
            f"structural-convergence-stamp.json is {int(age_s)}s old "
            f"(max {_VERIFY_STAMP_MAX_AGE_S}s).\n"
            f"Re-run:\n\n"
            f"  bash scripts/verify/check-converged.sh {ref_dir} --write-stamp\n"
        )

    # Re-validate the sections/result.txt hash — detects the cheat of stamping
    # while converged then editing result.txt to claim more PASS rows.
    result_file = ref_dir / "sections" / "result.txt"
    stamped_sha = stamp.get("sectionsResultSha256")
    if stamped_sha and result_file.is_file():
        import hashlib
        current_sha = hashlib.sha256(result_file.read_bytes()).hexdigest()
        if current_sha != stamped_sha:
            return (
                f"⛔ UI-RE Structural-stamp gate: result.txt tampered after stamp for {ref_dir}\n\n"
                f"sections/result.txt sha256 mismatch: stamp recorded {stamped_sha[:12]}…\n"
                f"but the file now hashes to {current_sha[:12]}…\n\n"
                f"The convergence evidence the stamp attests to has changed.\n"
                f"Re-run the convergence detector:\n\n"
                f"  bash scripts/verify/check-converged.sh {ref_dir} --write-stamp\n"
            )

    changed = _newer_impl_files(impl_dir, stamp_path)
    if changed:
        sample = "\n".join(f"  - {p}" for p in changed)
        return (
            f"⛔ UI-RE Structural-stamp gate: impl changed after stamp for {ref_dir}\n\n"
            f"These implementation files are newer than structural-convergence-stamp.json:\n"
            f"{sample}\n\n"
            f"Re-converge after the last code/asset edit:\n\n"
            f"  bash scripts/verify/check-converged.sh {ref_dir} --write-stamp\n"
        )
    return None


def _enforce_canvas_replay_stamp(ref_dir: Path) -> str | None:
    """Block Stop unless scripts/verify/check-canvas-replay.sh wrote a fresh
    canvas-replay stamp AND the attestation it attests to still hashes
    identically.

    Parallel to _enforce_structural_convergence_stamp but for plans that
    opted into the canvas-replay closeout policy (v0.7.0). Canvas-replay
    is the opt-in escape from the 30-min canvas CSS replication cap for
    refs whose visual identity is canvas-driven (WebGL UnicornStudio
    scenes, generative scroll-driven plates). Review finding applied
    (2026-05-25):

      [1] No new GATE_ORDER entry — modifier inside post-implement.
      [2] Attestation file is operator's explicit license confirmation.
      [5] Stamp records sha256(attestation) so tampering is detected.
      [7] Schema: design doc says `kind: "canvas"` for section-compare relief.

    Invariants enforced here:
      - canvas-replay-stamp.json exists, is fresh (< _VERIFY_STAMP_MAX_AGE_S),
        was written by scripts/verify/check-canvas-replay.sh with
        closeoutKind=canvas-replay.
      - canvas-replay-attestation.json exists (the operator's license proof)
        and its sha256 matches what the stamp recorded.
      - impl files are not newer than the stamp (anti-tamper, mirrors
        canonical + structural).
    """
    impl_dir = _resolve_impl_dir(ref_dir)
    if impl_dir is None or not impl_dir.is_dir():
        return None  # pre-generation — no stamp required yet

    stamp_path = ref_dir / "canvas-replay-stamp.json"
    attestation_path = ref_dir / "canvas-replay-attestation.json"

    if not stamp_path.is_file():
        return (
            f"⛔ UI-RE Canvas-replay gate: BLOCKED for {ref_dir}\n\n"
            f"closeoutPolicy=canvas-replay but no canvas-replay-stamp.json.\n"
            f"This run satisfies Stop via the canvas-replay attestation stamp; it\n"
            f"is written by:\n\n"
            f"  bash scripts/verify/check-canvas-replay.sh {ref_dir} --write-stamp\n\n"
            f"Prerequisites:\n"
            f"  - {ref_dir}/canvas-replay-attestation.json must exist with\n"
            f"    license, disclaimer, attestedBy, attestedAt, ref_canvas_sources.\n"
            f"  - See skills/ui-reverse-engineering/canvas-replay-mode.md for\n"
            f"    the operator-facing opt-in workflow + scope boundary.\n"
        )

    try:
        stamp = json.loads(stamp_path.read_text())
        import datetime
        stamped_at = datetime.datetime.strptime(
            stamp["verifiedAt"], "%Y-%m-%dT%H:%M:%SZ"
        ).replace(tzinfo=datetime.UTC)
        age_s = (datetime.datetime.now(datetime.UTC) - stamped_at).total_seconds()
    except (KeyError, ValueError, json.JSONDecodeError) as exc:
        return (
            f"⛔ UI-RE Canvas-replay gate: malformed stamp {stamp_path}\n\n"
            f"{exc}\n\n"
            f"Re-run `bash scripts/verify/check-canvas-replay.sh {ref_dir} --write-stamp`.\n"
        )

    stamped_by = stamp.get("stampedBy")
    closeout_kind = stamp.get("closeoutKind")
    if (
        stamped_by != "scripts/verify/check-canvas-replay.sh"
        or closeout_kind != "canvas-replay"
    ):
        return (
            f"⛔ UI-RE Canvas-replay gate: non-canonical stamp for {ref_dir}\n\n"
            f"canvas-replay-stamp.json must be written by\n"
            f"scripts/verify/check-canvas-replay.sh with closeoutKind=canvas-replay.\n"
            f"Current stampedBy={stamped_by!r}, closeoutKind={closeout_kind!r}.\n\n"
            f"Hand-written stamps and stamps from other writers are not accepted.\n"
            f"Re-run:\n"
            f"  bash scripts/verify/check-canvas-replay.sh {ref_dir} --write-stamp\n"
        )

    if age_s > _VERIFY_STAMP_MAX_AGE_S:
        return (
            f"⛔ UI-RE Canvas-replay gate: STALE stamp for {ref_dir}\n\n"
            f"canvas-replay-stamp.json is {int(age_s)}s old "
            f"(max {_VERIFY_STAMP_MAX_AGE_S}s).\n"
            f"Re-run:\n\n"
            f"  bash scripts/verify/check-canvas-replay.sh {ref_dir} --write-stamp\n"
        )

    # Attestation file must exist + still hash to the same value the stamp
    # recorded. Tamper detection protects the operator's license confirmation
    # (adding ref_canvas_sources URLs after stamping would
    # silently expand which JS bundles the gate allows).
    if not attestation_path.is_file():
        return (
            f"⛔ UI-RE Canvas-replay gate: attestation missing for {ref_dir}\n\n"
            f"canvas-replay-stamp.json present but canvas-replay-attestation.json\n"
            f"is gone. The attestation is the operator's license confirmation;\n"
            f"the stamp attests to its content and cannot stand alone.\n\n"
            f"Restore the attestation file or re-run:\n"
            f"  bash scripts/verify/check-canvas-replay.sh {ref_dir} --write-stamp\n"
        )

    import hashlib
    stamped_sha = stamp.get("attestationSha256")
    current_sha = hashlib.sha256(attestation_path.read_bytes()).hexdigest()
    if stamped_sha and current_sha != stamped_sha:
        return (
            f"⛔ UI-RE Canvas-replay gate: attestation tampered after stamp for {ref_dir}\n\n"
            f"canvas-replay-attestation.json sha256 mismatch: stamp recorded "
            f"{stamped_sha[:12]}…\nbut the file now hashes to {current_sha[:12]}….\n\n"
            f"The license/disclaimer/ref_canvas_sources the stamp attests to\n"
            f"has changed. Re-attest:\n\n"
            f"  bash scripts/verify/check-canvas-replay.sh {ref_dir} --write-stamp\n"
        )

    changed = _newer_impl_files(impl_dir, stamp_path)
    if changed:
        sample = "\n".join(f"  - {p}" for p in changed)
        return (
            f"⛔ UI-RE Canvas-replay gate: impl changed after stamp for {ref_dir}\n\n"
            f"These implementation files are newer than canvas-replay-stamp.json:\n"
            f"{sample}\n\n"
            f"Re-stamp after the last code/asset edit:\n\n"
            f"  bash scripts/verify/check-canvas-replay.sh {ref_dir} --write-stamp\n"
        )
    return None


def _enforce_ref_dir(ref_dir: Path) -> str | None:
    # Load current gate from pipeline-state.json.
    # If absent, treat as fresh start at "reference" gate (not legacy section-compare fallback).
    state = PipelineState.load(ref_dir)
    current_gate = state.current_gate

    # Explicit terminal state is the only non-success release path. Do not
    # infer terminal lifecycle from `unclonable_reasons`; those are evidence
    # details, not a completion/staleness contract.
    if state.terminal_state:
        terminal_block = _terminal_state_block_reason(ref_dir, state)
        if terminal_block:
            return terminal_block
        return None

    if state.unclonable_reasons:
        # Legacy compatibility: old state files may contain blockers but no
        # terminalState. Keep them fail-closed so agents repair/migrate the
        # lifecycle state instead of silently relying on overloaded reasons.
        canonical_reasons = [
            r for r in state.unclonable_reasons
            if r.get("gate") in GATE_ORDER
        ]
        if canonical_reasons:
            return (
                f"⛔ UI-RE terminal-state gate: legacy blocker lacks terminalState for {ref_dir}\n\n"
                f"pipeline-state.json has unclonable_reasons but no terminalState. "
                f"Do not forge verify-stamp.json. Record the explicit lifecycle "
                f"state instead, for example:\n\n"
                f"  npx ui-clone state terminal {ref_dir} "
                f"--status incomplete --category harvested-failure "
                f"--gate {canonical_reasons[0].get('gate')} "
                f"--reason '<why this run is terminal>'\n"
            )
        if state.completed_steps:
            return None

    # Closeout policy routing (Task #11): structural plans (section-staged
    # convergence loops) satisfy Stop via structural-convergence-stamp.json
    # from check-converged.sh. Canonical plans (default) satisfy Stop via
    # verify-stamp.json from pipeline.execute_verify. The two stamps are
    # never interchangeable — the policy decides which writer counts.
    if state.closeout_policy == "structural":
        stamp_enforcer = _enforce_structural_convergence_stamp
    elif state.closeout_policy == "canvas-replay":
        # v0.7.0: canvas-replay opt-in escape from the 30-min canvas CSS
        # replication cap for refs whose visual identity is canvas-driven.
        # The stamp from check-canvas-replay.sh attests to an operator-
        # written canvas-replay-attestation.json (license + disclaimer +
        # ref_canvas_sources). See skills/ui-reverse-engineering/canvas-
        # replay-mode.md for the operator workflow.
        stamp_enforcer = _enforce_canvas_replay_stamp
    else:
        stamp_enforcer = _enforce_verify_stamp

    impl_dir = _resolve_impl_dir(ref_dir)
    if impl_dir is not None and impl_dir.is_dir():
        return stamp_enforcer(ref_dir)

    if current_gate in {"section-compare", "done"}:
        gate_result = _run_gate(ref_dir, "section-compare")
        if not gate_result.get("passed", True):
            return _section_compare_block_reason(ref_dir, gate_result)
        # Section-compare PASS but no stamp yet — point the agent at the
        # canonical entry instead of releasing silently.
        return stamp_enforcer(ref_dir)

    if current_gate not in GATE_ORDER:
        return _unknown_gate_block_reason(current_gate, ref_dir)

    gate_result = _run_gate(ref_dir, current_gate)
    if not gate_result.get("passed", True):
        return _block_reason_for_gate(current_gate, ref_dir, gate_result)
    # Per-gate PASS does NOT release the Stop hook on its own — verify
    # stamp is the canonical "agent finished cleanly" signal.
    return stamp_enforcer(ref_dir)


def _coerce_stop_hook_active(value: object) -> bool:
    """Codex LOW: the Stop payload may carry stop_hook_active as a real bool OR
    a JSON string. `bool("false")` is truthy, which would wrongly release the
    gate on the FIRST stop. Coerce string forms explicitly."""
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        return value.strip().lower() not in ("", "false", "0", "no", "off", "none")
    return bool(value)


def main() -> None:
    project_root = _find_project_root()
    search_root = project_root / "tmp" / "ref"

    # Claude Code Stop hooks receive a JSON payload on stdin with
    # `session_id`, `hook_event_name`, etc. We read it (best-effort, never
    # block on parse error) to extract the active session id for the
    # driver-session bypass check.
    session_id_from_payload = ""
    stop_scope_session_id = ""
    stop_hook_active = False
    payload: dict[str, object] | None = None
    if not sys.stdin.isatty():
        try:
            raw = sys.stdin.read()
            if raw.strip():
                parsed = json.loads(raw)
                if isinstance(parsed, dict):
                    payload = parsed
                    sid = payload.get("session_id") or payload.get("sessionId")
                    if isinstance(sid, str):
                        stop_scope_session_id = sid.strip()
                    session_id_from_payload = _session_id_from_payload(payload)
                    stop_hook_active = _coerce_stop_hook_active(payload.get("stop_hook_active"))
        except (json.JSONDecodeError, OSError):
            pass
    if not session_id_from_payload:
        session_id_from_payload = _session_id_from_payload(payload)

    # Stop-hook re-entrancy guard. Claude Code sets stop_hook_active=true when
    # the current stop is itself the result of a previous Stop-hook block. If we
    # block again the turn never ends — it loops until the consecutive-block cap
    # (observed on loop-112: 9 blocks -> ~2h18m wasted churn, then a forced
    # turn-end with the agent left idle and no STATUS marker, read as a stall).
    # Allow the stop: the agent was already nudged once, and the driver's STATUS
    # marker + stall watchdog own round closeout. Matches Claude Code guidance.
    if stop_hook_active:
        sys.exit(0)

    # Driver-session bypass — maintainer running parallel loops as an
    # observer/orchestrator, not a clone agent. Production users never
    # write the marker, so this is a no-op for them.
    if _is_driver_session(project_root, session_id_from_payload):
        sys.exit(0)

    active_dirs = _fresh_active_dirs(
        _find_active_markers(search_root)
        + _find_active_markers(project_root / ".ui-clone" / "runs")
    )
    if stop_scope_session_id:
        scoped_dirs = [
            ref_dir
            for ref_dir in active_dirs
            if _should_enforce_ref_for_session(ref_dir, stop_scope_session_id)
        ]
        skipped = [ref_dir for ref_dir in active_dirs if ref_dir not in set(scoped_dirs)]
        if skipped:
            names = ", ".join(str(p) for p in skipped[:3])
            more = f" (+{len(skipped) - 3} more)" if len(skipped) > 3 else ""
            print(
                "ui-clone-skills: Session-scoped Stop gate skipped "
                f"{len(skipped)} WIP ref(s) not touched by this session: {names}{more}.",
                file=sys.stderr,
            )
        active_dirs = scoped_dirs
    if not active_dirs:
        sys.exit(0)

    if len(active_dirs) > 1:
        print(
            f"ui-clone-skills: WARNING: {len(active_dirs)} concurrent WIP markers. Enforcing all.",
            file=sys.stderr,
        )

    for ref_dir in active_dirs:
        block_reason = _enforce_ref_dir(ref_dir)
        if block_reason:
            _emit_block(block_reason)
            sys.exit(0)

    sys.exit(0)


if __name__ == "__main__":
    main()
