"""Phase 17/19 stub routes — fixture-shaped GET endpoints.

GET surface only after Phase 19 — POST mutations migrated to
``mutation_routes.py``. The in-memory ``_acted`` map below is still the
single source of truth for "has this proposal been acted on?" — both this
module's GET filter (``_pending_items``) and ``mutation_routes`` read
and update it.

Lifecycle:
  • GET endpoints — return fixture-shaped JSON. Read-only; no in-memory
    state for these (the fixture data is static).
  • The ``_acted`` map persists across requests within one process, gets
    cleared on uvicorn restart, and is shared with ``mutation_routes``
    which writes to it.

Phase 20+ will replace the fixture-shaped GETs with real engine
projections (or first-class engine entities for queue/chat); for now they
remain seed data so the UI keeps rendering.

Bug-fix (Debug R2): each ``_acted`` bucket is now a bounded
``OrderedDict[str, None]`` capped at ``_ACTED_MAX_ENTRIES`` to prevent
unbounded growth from a misbehaving client looping POSTs against random
ids. Helpers ``_acted_add`` / ``_acted_remove`` / ``_acted_contains``
encapsulate the LRU-trim semantics so call sites stay simple.

Bug-fix (Debug R7 BUG-6): the per-bucket value is now the proposal kind
(or ``None`` when unknown). ``_proposal_kind`` in mutation_routes can fall
back to this map when a proposal is acted on twice (e.g. approve then
reject for testing) — without it, the second event payload's ``kind``
goes None because ``_pending_items`` already filtered the id out.
"""
from __future__ import annotations

import os
from collections import OrderedDict
from typing import Any, Optional

from fastapi import APIRouter

router = APIRouter()


# ── In-memory mutation tracking (Phase 17 scope; resets on restart) ─────────
# Bounded — each bucket holds at most _ACTED_MAX_ENTRIES ids. When full, the
# oldest insertion is dropped (FIFO via OrderedDict.popitem(last=False)).
#
# Bucket value is the proposal kind (or None when unknown / not applicable
# — take + memory buckets always store None). Stored so a repeat action on
# the same proposal can still surface a labelled summary on the EventBus.
_ACTED_MAX_ENTRIES = 1000
_acted: dict[str, "OrderedDict[str, Optional[str]]"] = {
    "approved_proposals": OrderedDict(),
    "rejected_proposals": OrderedDict(),
    "deferred_proposals": OrderedDict(),
    "primary_takes": OrderedDict(),
    "circled_takes": OrderedDict(),
    "rejected_takes": OrderedDict(),
    "toggled_memory": OrderedDict(),
}

# Buckets in which the stored value is the proposal kind. Other buckets
# store ``None`` (take/memory mutations have no proposal kind).
_PROPOSAL_BUCKETS: tuple[str, ...] = (
    "approved_proposals",
    "rejected_proposals",
    "deferred_proposals",
)


def _acted_add(category: str, id: str, kind: Optional[str] = None) -> None:
    """Insert ``id`` into the bounded ``_acted[category]`` bucket.

    Idempotent — re-inserting an existing id is a no-op (does not refresh
    insertion order, does not overwrite a previously-stored kind with
    ``None``). When the bucket is at cap, drop the oldest entry before
    insertion so total membership stays at or below the cap.

    ``kind`` is the proposal kind (e.g. ``"PromptRewriteProposal"``) — only
    meaningful for proposal buckets; takes/memory pass ``None``. Stored so
    ``_proposal_kind_in_acted`` can recover the kind on a repeat action.
    """
    bucket = _acted[category]
    if id in bucket:
        # Don't overwrite a known kind with a later None — but DO upgrade
        # an existing None if a kind is now available.
        if kind is not None and bucket.get(id) is None:
            bucket[id] = kind
        return
    if len(bucket) >= _ACTED_MAX_ENTRIES:
        bucket.popitem(last=False)
    bucket[id] = kind


def _acted_remove(category: str, id: str) -> None:
    """Remove ``id`` from ``_acted[category]`` if present (no-op otherwise)."""
    _acted[category].pop(id, None)


def _acted_contains(category: str, id: str) -> bool:
    return id in _acted[category]


def _proposal_kind_in_acted(proposal_id: str) -> Optional[str]:
    """Return the kind stored for ``proposal_id`` across the proposal buckets.

    Walks ``approved_proposals``, ``rejected_proposals``, ``deferred_proposals``
    in declaration order and returns the first non-None kind found. Used by
    ``mutation_routes._proposal_kind`` as a fallback when ``_pending_items``
    has already filtered the id out (Debug R7 BUG-6).
    """
    for bucket_name in _PROPOSAL_BUCKETS:
        bucket = _acted[bucket_name]
        if proposal_id in bucket:
            kind = bucket[proposal_id]
            if kind is not None:
                return kind
    return None


def _is_known_proposal_id(proposal_id: str) -> bool:
    """Return True iff ``proposal_id`` is a real proposal (fixture or
    previously-acted with a recovered kind).

    Used by ``proposal_dispatch.emit_decision`` (Phase 2 console-v2-fix)
    to detect approve/reject/defer against ids that were never pending —
    Law-4 fix for the silent-success of Anti-Pattern 2.

    Detection logic: an id is "known" iff
      (a) it appears in the current fixture list (``_pending_items()``,
          which already filters out acted ids), OR
      (b) ``_proposal_kind_in_acted`` recovers a non-None kind from any
          acted bucket — i.e. the proposal was previously approved /
          rejected / deferred AND its kind was successfully resolved at
          that time.

    Note on the route-handler ordering: ``mutation_routes`` calls
    ``_acted_add(category, id, kind)`` BEFORE ``emit_decision``. So by
    the time we reach this helper, a real proposal id has the resolved
    kind stored in the acted bucket and (b) returns True. An unknown id
    has kind=None stored in the acted bucket — (b) returns False because
    ``_proposal_kind_in_acted`` only returns non-None kinds. Hence the
    helper still correctly distinguishes known/unknown after the route
    has updated _acted.
    """
    try:
        items = _pending_items()
    except Exception:  # noqa: BLE001
        items = []
    for it in items:
        if it.get("id") == proposal_id:
            return True
    return _proposal_kind_in_acted(proposal_id) is not None


def _reset_acted_for_tests() -> None:  # pragma: no cover — test helper
    for v in _acted.values():
        v.clear()


# ── Seed data — mirrors @recoil/fixtures shapes exactly ─────────────────────
_SCHEMA_VERSION = 1


def _pending_items() -> list[dict[str, Any]]:
    """Mirror QUEUE_PENDING from @recoil/fixtures/src/data/queue.ts.

    Each item is a PendingItem (id, ts, kind, from, proposal). The proposal
    is a discriminated-union member that satisfies @recoil/contracts'
    ProposalSchema. We surface the eight canonical kinds — one per Phase 11
    proposal type — and filter out items whose id is in _acted (approved /
    rejected / deferred) so the UI sees the queue shrink after mutations.
    """
    items: list[dict[str, Any]] = [
        {
            "schemaVersion": _SCHEMA_VERSION,
            "id": "prop_001",
            "ts": "2026-05-03T15:03:00Z",
            "kind": "PromptRewriteProposal",
            "from": {
                "thread": "tartarus / ep02 / sc01",
                "selection": "b7",
                "ts": "15:03",
            },
            "proposal": {
                "schemaVersion": _SCHEMA_VERSION,
                "kind": "PromptRewriteProposal",
                "id": "prop_001",
                "ts": "2026-05-03T15:03:00Z",
                "title": "PromptRewrite — strip 'cinematic'",
                "target": "b7",
                "estCost": 0.42,
                "estTime": "~2:10",
                "from": {
                    "thread": "tartarus / ep02 / sc01",
                    "selection": "b7",
                    "ts": "15:03",
                },
                "diff": {
                    "add": ["warm tungsten, faint window neon bleed (red, off-frame R)"],
                    "remove": ["cinematic, dramatic lighting"],
                },
            },
        },
        {
            "schemaVersion": _SCHEMA_VERSION,
            "id": "prop_002",
            "ts": "2026-05-03T22:44:00Z",
            "kind": "BeatInsertionProposal",
            "from": {
                "thread": "tartarus / ep02 / sc01",
                "selection": "after b5",
                "ts": "22:44",
            },
            "proposal": {
                "schemaVersion": _SCHEMA_VERSION,
                "kind": "BeatInsertionProposal",
                "id": "prop_002",
                "ts": "2026-05-03T22:44:00Z",
                "title": "Insert reaction beat after b5",
                "target": "ep02 / sc01 (after b5)",
                "estCost": 0.84,
                "estTime": "~3:00",
                "from": {
                    "thread": "tartarus / ep02 / sc01",
                    "selection": "after b5",
                    "ts": "22:44",
                },
                "insert": {
                    "sceneId": "ep02_sc01",
                    "afterBeatId": "b5",
                    "text": "RAY exhales, sets the letter down. Looks at the door.",
                },
            },
        },
        {
            "schemaVersion": _SCHEMA_VERSION,
            "id": "prop_003",
            "ts": "2026-05-03T22:44:00Z",
            "kind": "ParameterChangeProposal",
            "from": {
                "thread": "tartarus / ep02 / sc01",
                "selection": "b5_t3",
                "ts": "22:44",
            },
            "proposal": {
                "schemaVersion": _SCHEMA_VERSION,
                "kind": "ParameterChangeProposal",
                "id": "prop_003",
                "ts": "2026-05-03T22:44:00Z",
                "title": "ParameterChange — camera_tilt",
                "target": "new take on b5",
                "estCost": 0.71,
                "estTime": "~2:05",
                "from": {
                    "thread": "tartarus / ep02 / sc01",
                    "selection": "b5_t3",
                    "ts": "22:44",
                },
                "params": [
                    {"key": "camera.tilt_degrees", "before": "0", "after": "-5"},
                    {"key": "model", "before": "veo-3.1", "after": "veo-3.1-hd"},
                ],
            },
        },
        {
            "schemaVersion": _SCHEMA_VERSION,
            "id": "prop_004",
            "ts": "2026-05-03T20:31:00Z",
            "kind": "ScriptEditProposal",
            "from": {
                "thread": "tartarus / ep02 / sc01",
                "selection": "b5#L4",
                "ts": "20:31",
            },
            "proposal": {
                "schemaVersion": _SCHEMA_VERSION,
                "kind": "ScriptEditProposal",
                "id": "prop_004",
                "ts": "2026-05-03T20:31:00Z",
                "title": "ScriptEdit — tighten Ray's line",
                "target": "ep02 / sc01 / b5 line 4",
                "estCost": 0,
                "estTime": "instant",
                "from": {
                    "thread": "tartarus / ep02 / sc01",
                    "selection": "b5#L4",
                    "ts": "20:31",
                },
                "edit": {
                    "locator": "ep02_sc01_b5#L4",
                    "before": "I should have known better than to trust him.",
                    "after": "I knew.",
                },
            },
        },
        {
            "schemaVersion": _SCHEMA_VERSION,
            "id": "prop_005",
            "ts": "2026-05-03T22:55:00Z",
            "kind": "MultiBeatDirectiveProposal",
            "from": {
                "thread": "tartarus / ep02 / sc01",
                "selection": "b5,b6,b7",
                "ts": "22:55",
            },
            "proposal": {
                "schemaVersion": _SCHEMA_VERSION,
                "kind": "MultiBeatDirectiveProposal",
                "id": "prop_005",
                "ts": "2026-05-03T22:55:00Z",
                "title": "MultiBeat — keep CU framing tight across b5–b7",
                "target": "b5, b6, b7",
                "estCost": 0,
                "estTime": "instant",
                "from": {
                    "thread": "tartarus / ep02 / sc01",
                    "selection": "b5,b6,b7",
                    "ts": "22:55",
                },
                "directive": {
                    "beatIds": ["b5", "b6", "b7"],
                    "note": (
                        "Hold tight CUs through the letter sequence — no "
                        "breakaways, eyeline locked on the prop."
                    ),
                },
            },
        },
        {
            "schemaVersion": _SCHEMA_VERSION,
            "id": "prop_006",
            "ts": "2026-05-03T22:43:00Z",
            "kind": "ExtractCutawayProposal",
            "from": {
                "thread": "tartarus / ep02 / sc01",
                "selection": "b5",
                "ts": "22:43",
            },
            "proposal": {
                "schemaVersion": _SCHEMA_VERSION,
                "kind": "ExtractCutawayProposal",
                "id": "prop_006",
                "ts": "2026-05-03T22:43:00Z",
                "title": "Extract cutaway — letter signature insert",
                "target": "b5",
                "estCost": 0.21,
                "estTime": "~1:00",
                "from": {
                    "thread": "tartarus / ep02 / sc01",
                    "selection": "b5",
                    "ts": "22:43",
                },
                "cutaway": {
                    "fromBeatId": "b5",
                    "description": (
                        "Extreme close-up on the letter's signature line, "
                        "paper grain, slight tremor in the hand."
                    ),
                },
            },
        },
        {
            "schemaVersion": _SCHEMA_VERSION,
            "id": "prop_007",
            "ts": "2026-05-03T14:22:00Z",
            "kind": "RefSwapProposal",
            "from": {
                "thread": "driver-beware / ep01 / sc04",
                "selection": "b6_t0",
                "ts": "14:22",
            },
            "proposal": {
                "schemaVersion": _SCHEMA_VERSION,
                "kind": "RefSwapProposal",
                "id": "prop_007",
                "ts": "2026-05-03T14:22:00Z",
                "title": "RefSwap — tighten eyeline anchor",
                "target": "b6_t0..t6 (7 takes)",
                "estCost": 2.94,
                "estTime": "~12:40",
                "from": {
                    "thread": "driver-beware / ep01 / sc04",
                    "selection": "b6_t0",
                    "ts": "14:22",
                },
                "swap": {
                    "before": "scene_anchor.png",
                    "after": "ep02_sc01_b5_t3_keyframe.png",
                },
                "promptAdd": [
                    "subject's gaze stays on the prop for the full duration; no breakaway glance",
                ],
            },
        },
        {
            "schemaVersion": _SCHEMA_VERSION,
            "id": "prop_008",
            "ts": "2026-05-03T22:56:00Z",
            "kind": "RetryStrategyEditProposal",
            "from": {
                "thread": "tartarus / ep02 / sc01",
                "selection": "b5",
                "ts": "22:56",
            },
            "proposal": {
                "schemaVersion": _SCHEMA_VERSION,
                "kind": "RetryStrategyEditProposal",
                "id": "prop_008",
                "ts": "2026-05-03T22:56:00Z",
                "title": "RetryStrategy — pin identity_recovery_v2",
                "target": "b5 (all future takes)",
                "estCost": 0,
                "estTime": "instant",
                "from": {
                    "thread": "tartarus / ep02 / sc01",
                    "selection": "b5",
                    "ts": "22:56",
                },
                "strategy": {
                    "name": "identity_recovery_v2",
                    "rationale": (
                        "Identity drift on b5_t4–t9; recovery loop has "
                        "cleared this on tartarus before."
                    ),
                },
            },
        },
    ]
    # Filter out proposals that have been acted on this process lifetime.
    # OrderedDict membership check is O(1); union via set() over keys works
    # for buckets of any size up to _ACTED_MAX_ENTRIES.
    seen = (
        set(_acted["approved_proposals"].keys())
        | set(_acted["rejected_proposals"].keys())
        | set(_acted["deferred_proposals"].keys())
    )
    return [it for it in items if it["id"] not in seen]


def _live_jobs() -> list[dict[str, Any]]:
    return [
        {
            "id": "job_001",
            "target": "tartarus / ep02 / sc01 / b5_t10",
            "model": "veo-3.1-hd",
            "step": "video_i2v",
            "progress": 0.62,
            "eta": "00:38",
            "cost": 0.44,
            "status": "rendering",
        },
        {
            "id": "job_002",
            "target": "tartarus / ep02 / sc01 / b5_t11",
            "model": "veo-3.1-hd",
            "step": "video_i2v",
            "progress": 0.18,
            "eta": "01:54",
            "cost": 0.13,
            "status": "rendering",
        },
        {
            "id": "job_003",
            "target": "tartarus / ep02 / sc02 / b12_t4",
            "model": "imagen-4",
            "step": "image_t2i",
            "progress": 0.91,
            "eta": "00:04",
            "cost": 0.04,
            "status": "rendering",
        },
        {
            "id": "job_004",
            "target": "tartarus / ep02 / sc01 / b6_t8",
            "model": "elevenlabs",
            "step": "audio_t2a",
            "progress": 1.0,
            "eta": "—",
            "cost": 0.02,
            "status": "evaluating",
        },
        {
            "id": "job_005",
            "target": "driver-beware / ep01 / sc04 / b2_t1",
            "model": "panel:gemini-3.1",
            "step": "eval_video",
            "progress": 0.34,
            "eta": "00:22",
            "cost": 0.018,
            "status": "evaluating",
        },
        {
            "id": "job_006",
            "target": "tartarus / ep02 / sc01 / b7_t0",
            "model": "veo-3.1",
            "step": "image_t2i",
            "progress": 0,
            "eta": "—",
            "cost": 0,
            "status": "queued",
        },
        {
            "id": "job_007",
            "target": "recoil-otf-demo / otf_demo_ep1 / otf_sc1 / otf_b2_t3",
            "model": "veo-3.1-hd",
            "step": "video_i2v",
            "progress": 0.34,
            "eta": "01:12",
            "cost": 0.24,
            "status": "rendering",
        },
    ]


def _recent_items() -> list[dict[str, Any]]:
    return [
        {
            "id": "recent_001",
            "outcome": "PASS",
            "target": "tartarus / ep02 / sc01 / b5_t9",
            "model": "veo-3.1-hd",
            "score": 0.78,
            "cost": 0.71,
            "duration": "2:32",
            "completedAt": "22:43:00",
            "ago": "12m ago",
            "note": "circled · primary candidate",
        },
        {
            "id": "recent_002",
            "outcome": "PASS",
            "target": "tartarus / ep02 / sc01 / b5_t8",
            "model": "imagen-4",
            "score": 0.62,
            "cost": 0.04,
            "duration": "0:42",
            "completedAt": "22:31:10",
            "ago": "24m ago",
            "note": "panel disagreement 0.31 — review",
        },
        {
            "id": "recent_003",
            "outcome": "FALLBACK",
            "target": "tartarus / ep02 / sc01 / b5_t1",
            "model": "veo-3.1",
            "score": 0.44,
            "cost": 0.42,
            "duration": "2:09",
            "completedAt": "22:46:11",
            "ago": "9m ago",
            "note": "ref-swap fallback engaged · scene_anchor → b4_t0_keyframe",
        },
        {
            "id": "recent_004",
            "outcome": "FAIL",
            "target": "tartarus / ep02 / sc01 / b5_t7",
            "model": "veo-3.1",
            "score": None,
            "cost": 0.31,
            "duration": "—",
            "completedAt": "22:18:02",
            "ago": "37m ago",
            "note": "TRANSIENT · judge_1 errored: HTTPStatusError 503",
        },
        {
            "id": "recent_005",
            "outcome": "PASS",
            "target": "tartarus / ep02 / sc01 / b5_t5",
            "model": "veo-3.1",
            "score": 0.71,
            "cost": 0.46,
            "duration": "2:13",
            "completedAt": "22:01:18",
            "ago": "54m ago",
            "note": "circled",
        },
        {
            "id": "recent_006",
            "outcome": "FAIL",
            "target": "tartarus / ep02 / sc01 / b5_t4",
            "model": "veo-3.1",
            "score": 0.27,
            "cost": 0.42,
            "duration": "2:12",
            "completedAt": "21:28:30",
            "ago": "1h 26m ago",
            "note": "MOTION_FAILURE · auto-hidden",
        },
        {
            "id": "recent_007",
            "outcome": "PASS",
            "target": "tartarus / ep02 / sc01 / b5_t3",
            "model": "veo-3.1",
            "score": 0.83,
            "cost": 0.46,
            "duration": "2:14",
            "completedAt": "21:21:55",
            "ago": "1h 33m ago",
            "note": "primary keeper",
        },
        {
            "id": "recent_008",
            "outcome": "FAIL",
            "target": "tartarus / ep02 / sc01 / b5_t0",
            "model": "imagen-4",
            "score": 0.31,
            "cost": 0.04,
            "duration": "0:38",
            "completedAt": "21:04:11",
            "ago": "1h 51m ago",
            "note": "IDENTITY_DRIFT · outlier judge",
        },
        {
            "id": "recent_009",
            "outcome": "PASS",
            "target": "recoil-otf-demo / otf_demo_ep1 / otf_sc1 / otf_b2_t1",
            "model": "veo-3.1-hd",
            "score": 0.78,
            "cost": 0.71,
            "duration": "2:18",
            "completedAt": "19:18:44",
            "ago": "3h 36m ago",
            "note": "primary keeper · 16:9",
        },
        {
            "id": "recent_010",
            "outcome": "PASS",
            "target": "recoil-otf-demo / otf_demo_ep1 / otf_sc1 / otf_b2_t2",
            "model": "veo-3.1",
            "score": 0.69,
            "cost": 0.42,
            "duration": "2:09",
            "completedAt": "19:25:11",
            "ago": "3h 30m ago",
            "note": "alt keeper",
        },
        {
            "id": "recent_011",
            "outcome": "FAIL",
            "target": "recoil-otf-demo / otf_demo_ep1 / otf_sc1 / otf_b2_t0",
            "model": "veo-3.1",
            "score": 0.41,
            "cost": 0.42,
            "duration": "2:11",
            "completedAt": "19:11:02",
            "ago": "3h 44m ago",
            "note": "WARDROBE_MISMATCH",
        },
    ]


def _chat_history() -> list[dict[str, Any]]:
    """Mirror @recoil/fixtures/src/data/chat.ts CHAT_HISTORY."""
    return [
        {
            "role": "system",
            "ts": "21:02",
            "text": "Switched context: tartarus / ep02 / sc01 — Diner. Resumed thread.",
        },
        {
            "role": "user",
            "ts": "21:24",
            "text": (
                "Watching B5. Take 3 is the keeper — the way she drops her hand mid-read. "
                "Mark circled. Her eyeline drifts in t0/t1/t2 though, she's not actually "
                "reading the letter, she's looking past it."
            ),
        },
        {
            "role": "assistant",
            "ts": "21:24",
            "text": (
                "Marked b5_t3 as circled and primary. Eyeline drift across t0–t2 is what "
                "the panel flagged too — Gemini scored those 0.18/0.31/0.44 with the judge "
                "note 'subject's gaze does not converge on prop'. Want me to retry those "
                "three with a tighter ref-frame on the letter?"
            ),
        },
        {
            "role": "user",
            "ts": "21:31",
            "text": (
                "Retry t1 and t2 with a ref swap. Use the keyframe from B4_t0 as the "
                "eyeline anchor. Add a note: 'character is reading text on paper at chest "
                "level, eyes tracking left-to-right.'"
            ),
        },
        {
            "role": "spec_proposal",
            "ts": "21:31",
            "proposal": {
                "title": "RefSwap + PromptRewrite",
                "target": "b5_t1, b5_t2",
                "diff": [
                    {
                        "kind": "ref_swap",
                        "before": "scene_anchor.png",
                        "after": "ep02_sc01_b4_t0_keyframe.png",
                    },
                    {
                        "kind": "prompt_add",
                        "text": (
                            "character is reading text on paper at chest level, eyes "
                            "tracking left-to-right"
                        ),
                    },
                ],
                "estCost": 0.84,
                "estTime": "~3:20",
            },
        },
    ]


_CHAT_CONTEXT_WINDOW: dict[str, Any] = {
    "used": 127400,
    "max": 200000,
    "breakdown": [
        {"k": "system + tools", "v": 18200},
        {"k": "memory + policy", "v": 8400},
        {"k": "thread history", "v": 99500},
        {"k": "current draft", "v": 1300},
    ],
}


_SLASH_COMMANDS: list[dict[str, Any]] = [
    {"group": "Dispatch", "cmd": "/retry", "args": "", "desc": "Retry selected take with same params"},
    {"group": "Dispatch", "cmd": "/retry-selected", "args": "", "desc": "Retry multiple selected takes"},
    {"group": "Dispatch", "cmd": "/retry-with-strategy", "args": "<strategy>", "desc": "Retry with a named strategy from registry"},
    {"group": "Dispatch", "cmd": "/generate", "args": "", "desc": "Generate new take for current beat"},
    {"group": "Dispatch", "cmd": "/regen", "args": "", "desc": "Regen with ref-swap or prompt-rewrite"},
    {"group": "Dispatch", "cmd": "/run-panel", "args": "", "desc": "Run eval panel on current take"},
    {"group": "Review", "cmd": "/primary", "args": "", "desc": "Mark current take as primary keeper"},
    {"group": "Review", "cmd": "/circle", "args": "", "desc": "Mark as circled (good candidate)"},
    {"group": "Review", "cmd": "/reject", "args": "", "desc": "Mark as rejected"},
    {"group": "Review", "cmd": "/unprimary", "args": "", "desc": "Remove primary mark"},
    {"group": "Review", "cmd": "/uncircle", "args": "", "desc": "Remove circled mark"},
    {"group": "Review", "cmd": "/unreject", "args": "", "desc": "Restore rejected take"},
    {"group": "Navigate", "cmd": "/goto", "args": "<beat-spec>", "desc": "Jump to a beat (e.g., /goto b5_t3)"},
    {"group": "Navigate", "cmd": "/find-blocked", "args": "", "desc": "Show all blocked takes in current project"},
    {"group": "Navigate", "cmd": "/find-failed", "args": "", "desc": "Show all failed takes in current project"},
    {"group": "Navigate", "cmd": "/show-pending", "args": "", "desc": "Open Queue scrolled to PENDING"},
    {"group": "Eval", "cmd": "/attach-judges", "args": "", "desc": "Attach judges to current take/beat"},
    {"group": "Eval", "cmd": "/show-coverage", "args": "", "desc": "Open EvalCoverageReport for current scope"},
    {"group": "Eval", "cmd": "/panel-status", "args": "", "desc": "Show panel health for current scope"},
    {"group": "Configure", "cmd": "/set-policy", "args": "<key=value>", "desc": "Set policy parameter for current scope"},
    {"group": "Configure", "cmd": "/set-strategy", "args": "<strategy>", "desc": "Pin retry strategy for this beat"},
    {"group": "Configure", "cmd": "/set-budget", "args": "<amount>", "desc": "Adjust per-project budget cap"},
    {"group": "Configure", "cmd": "/set-default-model", "args": "<model>", "desc": "Change default model for new takes"},
    {"group": "Workflow", "cmd": "/park", "args": "", "desc": "Park current context as a tab"},
    {"group": "Workflow", "cmd": "/switch-project", "args": "<name>", "desc": "Switch active project"},
    {"group": "Workflow", "cmd": "/save-state", "args": "", "desc": "Manual checkpoint (placeholder for durable memory)"},
    {"group": "Workflow", "cmd": "/help", "args": "", "desc": "Show help"},
]


_COMMANDS_REF: dict[str, list[dict[str, str]]] = {
    "Dispatch": [
        {"cmd": "/retry", "short": "⌘R", "desc": "Retry selected takes — same spec, fresh seeds"},
        {"cmd": "/retry-selected", "short": "", "desc": "Retry takes currently checked in browser"},
        {"cmd": "/generate", "short": "⌘G", "desc": "Generate new takes for current beat"},
        {"cmd": "/regen-with-ref-swap", "short": "", "desc": "Re-run with a different reference image"},
        {"cmd": "/promote-to-hd", "short": "", "desc": "Re-render circled takes on veo-3.1-hd"},
        {"cmd": "/cancel", "short": "⌘.", "desc": "Cancel in-flight job for selection"},
    ],
    "Review": [
        {"cmd": "/circle", "short": "C", "desc": "Mark take as primary candidate"},
        {"cmd": "/uncircle", "short": "⇧C", "desc": "Remove circled mark"},
        {"cmd": "/set-primary", "short": "P", "desc": "Lock take as the keeper for this beat"},
        {"cmd": "/reject", "short": "X", "desc": "Hide take + add to anti-pattern memory"},
        {"cmd": "/note", "short": "N", "desc": "Attach note to take or beat"},
    ],
    "Eval": [
        {"cmd": "/run-panel", "short": "", "desc": "Run panel-of-judges on selected takes"},
        {"cmd": "/attach-judges", "short": "", "desc": "Add or swap judges in the active panel"},
        {"cmd": "/show-coverage", "short": "", "desc": "EvalCoverageReport — what's been judged, by whom"},
        {"cmd": "/explain", "short": "", "desc": "Surface judge rationale + evidence frames"},
    ],
    "Retry strategy": [
        {"cmd": "/set-strategy", "short": "", "desc": "Pin retry strategy for this beat"},
        {"cmd": "/apply-default", "short": "", "desc": "Use project-default retry strategy"},
        {"cmd": "/escalate", "short": "", "desc": "Escalate beat to manual review queue"},
    ],
    "Navigate": [
        {"cmd": "/goto", "short": "⌘P", "desc": "Jump to project / episode / scene / beat"},
        {"cmd": "/find-blocked", "short": "", "desc": "List all beats with no keeper after >N takes"},
        {"cmd": "/show-failed", "short": "⌘⇧F", "desc": "Filter to takes below threshold across project"},
        {"cmd": "/lineage", "short": "⌘L", "desc": "Open lineage centered on current take"},
    ],
    "Configure": [
        {"cmd": "/set-policy", "short": "", "desc": "Edit project_policy.yaml inline"},
        {"cmd": "/set-budget", "short": "", "desc": "Adjust per-project budget cap"},
        {"cmd": "/memory", "short": "⌘M", "desc": "Open Engine Memory inspector"},
        {"cmd": "/events", "short": "⌘`", "desc": "Open Events drawer"},
    ],
}


# ── GET routes ──────────────────────────────────────────────────────────────
_RECOIL_STUB_QUEUE = os.environ.get("RECOIL_STUB_QUEUE", "1") == "1"


@router.get("/queue")
def get_queue() -> dict[str, Any]:
    """Queue snapshot — pending proposals + live jobs + recent outcomes.

    Phase 18: when RECOIL_STUB_QUEUE != "1" (i.e. real queue tracker not
    yet wired), return 501. When stub_mode active, the response payload
    carries `stub_mode: true` so the frontend can render an honest banner
    (Law 4 prong-3).
    """
    if not _RECOIL_STUB_QUEUE:
        from fastapi.responses import JSONResponse
        return JSONResponse(
            status_code=501,
            content={"detail": "Real queue tracker not yet wired."},
        )
    return {
        "pending": _pending_items(),
        "jobs": _live_jobs(),
        "recent": _recent_items(),
        "stub_mode": True,
    }


# Order matters: declare the literal "context-window" path BEFORE the
# `/chat/{project_id}` wildcard so FastAPI's first-match routing picks the
# specific path. (FastAPI does not order routes by specificity; it matches
# in declaration order.)
@router.get("/chat/context-window")
def get_chat_context_window() -> dict[str, Any]:
    return _CHAT_CONTEXT_WINDOW


@router.get("/chat/{project_id}")
def get_chat_history(project_id: str) -> list[dict[str, Any]]:
    """Chat thread history for a project. Phase 17 ignores project_id and
    returns the same fixture-shaped Tartarus thread for every project — Phase
    19+ will make this per-project."""
    _ = project_id  # currently unused; kept in signature for API parity
    return _chat_history()


@router.get("/slash-commands")
def get_slash_commands() -> list[dict[str, Any]]:
    return _SLASH_COMMANDS


@router.get("/commands-ref")
def get_commands_ref() -> dict[str, list[dict[str, str]]]:
    return _COMMANDS_REF


# Phase 19: POST handlers moved to mutation_routes.py. The _acted map
# above is still owned by this module — mutation_routes imports it.

__all__ = ["router"]
