# api/routes/casting.py
"""Casting + pre-production endpoints — Phase 5.

Ports ~37 endpoints from review_server.py:
  - Casting pipeline (characters, expressions, locations, grids, hero, turnaround, etc.)
  - Wardrobe Intent Gate (propose/approve philosophy, theses, rewrite/apply)
  - Screen Test (get, generate, reroll, verdict, set-anchor, bible-synced)
"""

import json
import logging
import shutil
import sys
import time
import threading
from pathlib import Path

from fastapi import APIRouter, Body, Depends
from fastapi.responses import JSONResponse

from ..deps import get_paths, _paths_for_project
from ..state import PROJECT_ROOT
from recoil.core.paths import ProjectPaths
from recoil.pipeline._lib.taxonomy import slugify_asset_id


logger = logging.getLogger(__name__)

# ── Constants ──────────────────────────────────────────────────────
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}

router = APIRouter(tags=["casting"])


# ══════════════════════════════════════════════════════════════════════
# MODULE-LEVEL HELPERS (ported from review_server.py)
# ══════════════════════════════════════════════════════════════════════


def _resolve_output_rel(rel_path: str, paths: dict) -> Path:
    """Resolve an output/-relative path to absolute using project paths."""
    if rel_path.startswith("output/"):
        stripped = rel_path.replace("output/", "", 1)
        return paths["output_dir"] / stripped
    return Path(rel_path)


def _to_relative_output_path(abs_path, paths: dict) -> str:
    """Convert absolute path to output/-relative path for state storage."""
    abs_p = str(abs_path)
    project_out = str(paths["output_dir"])
    if abs_p.startswith(project_out):
        return "output/" + abs_p[len(project_out) :].lstrip("/")
    return abs_p


def _asset_type_dir(asset_type: str) -> str:
    """Map asset type to output subdirectory."""
    return {
        "character": "characters",
        "location": "locations",
        "wardrobe": "characters",
        "hair_makeup": "characters",
        "props": "props",
    }.get(asset_type, asset_type)


# ── Casting state helpers (from review_server.py lines 5883-5961) ──


def _casting_state_path(project_dir: Path) -> Path:
    """Path to casting_state.json for a project."""
    return ProjectPaths.from_root(project_dir).casting_state_path


def _load_casting_state(project_dir: Path) -> dict:
    """Load or initialize casting_state.json."""
    cs_path = _casting_state_path(project_dir)
    if cs_path.is_file():
        try:
            return json.loads(cs_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            return {"characters": {}, "locations": {}}
    return {"characters": {}, "locations": {}}


def _save_casting_state(project_dir: Path, state: dict):
    """Write casting_state.json."""
    cs_path = _casting_state_path(project_dir)
    cs_path.parent.mkdir(parents=True, exist_ok=True)
    cs_path.write_text(json.dumps(state, indent=2), encoding="utf-8")


# ── URSS GridSession CRUD (from review_server.py lines 5903-5961) ──


def _create_grid_session(
    project_dir: Path,
    asset_type: str,
    parent_context: dict,
    anchor_path=None,
    anchor_source=None,
    mood_text=None,
) -> dict:
    """Create a new GridSession and persist it."""
    import uuid as _uuid
    from recoil.pipeline._lib.ref_selector import load_descriptor, candidate_count

    descriptor = load_descriptor(asset_type)
    session_id = str(_uuid.uuid4())[:8]
    count = candidate_count(descriptor)

    session = {
        "session_id": session_id,
        "asset_type": asset_type,
        "parent_context": parent_context,
        "descriptor": descriptor,
        "anchor": {
            "path": anchor_path,
            "source": anchor_source,
            "mood_text": mood_text or "",
        },
        "candidates": [
            {"slot": i, "path": None, "state": "empty", "re_roll_generation": 0}
            for i in range(count)
        ],
        "re_roll_count": 0,
        "user_overrides": [],
        "collapsed_override": "",
        "hero_locked": False,
        "hero_path": None,
        "beauty_pass_path": None,
        "status": "created",
        "cost": 0.0,
    }

    state = _load_casting_state(project_dir)
    if "grid_sessions" not in state:
        state["grid_sessions"] = {}
    state["grid_sessions"][session_id] = session
    _save_casting_state(project_dir, state)

    return session


def _get_grid_session(project_dir: Path, session_id: str):
    """Load a GridSession by ID."""
    state = _load_casting_state(project_dir)
    return state.get("grid_sessions", {}).get(session_id)


def _update_grid_session(project_dir: Path, session_id: str, updates: dict):
    """Update fields on a GridSession and persist."""
    state = _load_casting_state(project_dir)
    session = state.get("grid_sessions", {}).get(session_id)
    if not session:
        return None
    session.update(updates)
    _save_casting_state(project_dir, state)
    return session


# ── URSS helper methods (from review_server.py lines 7434-7509) ──


def _get_description_for_session(project_dir: Path, session: dict) -> str:
    """Get the text description for a grid session from the bible."""
    parent = session.get("parent_context", {})
    asset_type = session["asset_type"]

    pp = _paths_for_project(project_dir.name)
    bible_path = pp["bible_path"]
    bible = {}
    if bible_path and bible_path.is_file():
        bible = json.loads(bible_path.read_text(encoding="utf-8"))

    if asset_type == "character":
        char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
        return char.get("casting_description") or char.get("visual_description", "")
    elif asset_type == "location":
        loc = bible.get("locations", {}).get(parent.get("location_id", ""), {})
        return loc.get("description", "")
    elif asset_type == "wardrobe":
        char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
        phases = char.get("phases", [])
        phase_id = parent.get("phase_id")
        for p in phases:
            if p.get("phase_id") == phase_id:
                return p.get("wardrobe_description", "")
        return phases[0].get("wardrobe_description", "") if phases else ""
    elif asset_type == "hair_makeup":
        char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
        phases = char.get("phases", [])
        phase_id = parent.get("phase_id")
        for p in phases:
            if p.get("phase_id") == phase_id:
                return p.get("hair_makeup", "")
        return phases[0].get("hair_makeup", "") if phases else ""
    elif asset_type == "props":
        return parent.get("prop_description", "")
    return ""


def _resolve_output_path(rel_path: str, paths: dict) -> str:
    """Resolve an output/-relative path to an absolute path string."""
    if rel_path.startswith("output/"):
        stripped = rel_path.replace("output/", "", 1)
        return str(paths["output_dir"] / stripped)
    return str(Path(rel_path).resolve())


def _canonicalize_hero(
    project_dir: Path, asset_type: str, asset_id: str, hero_path: str
) -> str:
    """Copy hero image to the canonical asset location.

    Writes to both:
      - assets/heroes/{asset}_hero.{ext} (legacy flat folder)
      - assets/<kind>/<slug>/hero.<ext> (v2 canonical, via ProjectPaths)

    Returns the canonical relative path for casting_state.json.
    """
    from recoil.core.paths import ProjectPaths as _ProjectPaths

    _LEGACY_TO_CLASS = {
        "character": "char",
        "characters": "char",
        "identity": "char",
        "location": "loc",
        "locations": "loc",
        "prop": "prop",
        "props": "prop",
    }

    asset_slug = slugify_asset_id(asset_id)
    src = project_dir / hero_path
    if not src.is_file():
        return hero_path

    pp_obj = _ProjectPaths.from_root(project_dir)

    # Legacy flat folder (backward compat, now under assets/heroes/)
    legacy_name = f"{asset_slug}_hero{src.suffix.lower()}"
    legacy_dir = pp_obj.assets_dir / "heroes"
    legacy_dir.mkdir(parents=True, exist_ok=True)
    legacy_dest = legacy_dir / legacy_name
    if src.resolve() != legacy_dest.resolve():
        shutil.copy2(str(src), str(legacy_dest))

    # Canonical v3 asset folder
    cls = _LEGACY_TO_CLASS.get(asset_type, asset_type)
    canonical_dir = pp_obj.asset_subject_dir(cls, asset_slug)
    canonical_dir.mkdir(parents=True, exist_ok=True)
    # Clean up old extensions to prevent masking
    for old_hero in canonical_dir.glob("hero.*"):
        old_hero.unlink()
    canonical_dest = canonical_dir / f"hero{src.suffix.lower()}"
    shutil.copy2(str(src), str(canonical_dest))

    rel = canonical_dest.relative_to(project_dir)
    return str(rel)


def _update_casting_hero(
    project_dir: Path, session: dict, session_id: str, hero_path: str
):
    """Update backward-compat casting_state.characters with hero path.

    Canonicalizes the hero filename to {char}_hero.{ext} so the keystone
    marker is embedded in the filename itself — single source of truth.

    After saving the hero, runs visual_sync to update the bible's
    visual_description to match the chosen hero image. This ensures
    text prompts and reference images reinforce each other.
    """
    parent = session.get("parent_context", {})
    char_id = parent.get("character_id", "").upper()
    if char_id and session["asset_type"] == "character":
        # Canonicalize filename → {char}_hero.{ext}
        canonical = _canonicalize_hero(
            project_dir,
            session["asset_type"],
            char_id,
            hero_path,
        )

        state = _load_casting_state(project_dir)
        if "characters" not in state:
            state["characters"] = {}
        if char_id not in state["characters"]:
            state["characters"][char_id] = {}
        state["characters"][char_id]["hero_path"] = canonical
        state["characters"][char_id]["status"] = "hero_selected"
        state["characters"][char_id]["hero_source"] = "grid_session"
        state["characters"][char_id]["bible_synced"] = False
        state["characters"][char_id]["grid_session_id"] = session_id
        _save_casting_state(project_dir, state)

        # ── Visual Sync: update bible to match hero image ──
        try:
            from recoil.pipeline._lib.visual_sync import propose_visual_sync

            hero_abs = project_dir / canonical
            if hero_abs.is_file():
                # Load current bible text for this character
                pp = _paths_for_project(project_dir.name)
                bible_path = pp.get("bible_path")
                if bible_path and bible_path.is_file():
                    bible = json.loads(bible_path.read_text(encoding="utf-8"))
                    char_data = bible.get("characters", {}).get(char_id, {})

                    current_text = {
                        "visual_description": char_data.get("visual_description", ""),
                        "wardrobe_description": "",
                    }
                    # Get wardrobe from first phase if available
                    phases = char_data.get("phases", [])
                    if phases:
                        current_text["wardrobe_description"] = phases[0].get(
                            "wardrobe_description", ""
                        )

                    # Run Flash vision on hero image
                    proposed = propose_visual_sync(
                        image_path=str(hero_abs),
                        current_text=current_text,
                        sync_type="hero",
                    )

                    # Write proposed updates back to bible
                    if proposed.get("visual_description"):
                        if "characters" not in bible:
                            bible["characters"] = {}
                        if char_id not in bible["characters"]:
                            bible["characters"][char_id] = {}
                        bible["characters"][char_id]["visual_description"] = proposed[
                            "visual_description"
                        ]
                        logger.info(
                            "Visual sync: updated %s visual_description from hero (%d chars)",
                            char_id,
                            len(proposed["visual_description"]),
                        )
                    if proposed.get("wardrobe_description") and phases:
                        phases[0]["wardrobe_description"] = proposed[
                            "wardrobe_description"
                        ]
                        logger.info(
                            "Visual sync: updated %s phase 0 wardrobe_description from hero",
                            char_id,
                        )
                    elif proposed.get("wardrobe_description") and not phases:
                        logger.warning(
                            "Visual sync: proposed wardrobe_description for %s discarded — "
                            "character has no phases in bible",
                            char_id,
                        )

                    # Atomic write
                    import tempfile
                    import os

                    fd, tmp = tempfile.mkstemp(
                        dir=str(bible_path.parent), suffix=".json"
                    )
                    try:
                        with os.fdopen(fd, "w", encoding="utf-8") as f:
                            json.dump(bible, f, indent=2, ensure_ascii=False)
                        os.replace(tmp, str(bible_path))
                    except Exception:
                        try:
                            os.unlink(tmp)
                        except OSError:
                            pass
                        raise

                    # Mark as synced in casting_state
                    state["characters"][char_id]["bible_synced"] = True
                    state["characters"][char_id]["sync_source"] = "visual_sync_hero"
                    _save_casting_state(project_dir, state)

                    logger.info("Bible synced for %s after hero selection", char_id)
                else:
                    logger.warning(
                        "Visual sync skipped: bible not found at %s", bible_path
                    )
            else:
                logger.warning(
                    "Visual sync skipped: hero image not found at %s", hero_abs
                )

        except Exception as e:
            logger.error("Visual sync failed for %s (non-blocking): %s", char_id, e)
            # Don't fail hero selection if sync fails — bible_synced stays False


def _get_gender_for_session(project_dir: Path, session: dict):
    """Get gender from bible for character sessions."""
    parent = session.get("parent_context", {})
    if session["asset_type"] not in ("character", "wardrobe", "hair_makeup"):
        return None
    pp = _paths_for_project(project_dir.name)
    bible_path = pp["bible_path"]
    bible = {}
    if bible_path and bible_path.is_file():
        bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
    return char.get("gender")


# ══════════════════════════════════════════════════════════════════════
# CASTING ENDPOINTS
# ══════════════════════════════════════════════════════════════════════

# ── GET /api/project/{project_name}/casting/characters ──────────────


@router.get("/api/project/{project_name}/casting/characters")
def casting_characters(
    project_name: str,
    paths: dict = Depends(get_paths),
):
    """Returns characters from Global Bible + casting_state + ref dirs."""
    pp = _paths_for_project(project_name)
    bible_path = pp["bible_path"]
    project_dir = pp["project_dir"]

    from recoil.core.paths import ProjectPaths as _ProjectPaths

    pp_obj = _ProjectPaths.from_root(project_dir)

    characters = {}
    locations = {}

    if bible_path.is_file():
        try:
            bible = json.loads(bible_path.read_text(encoding="utf-8"))

            # Extract characters
            for char_id, char_data in bible.get("characters", {}).items():
                characters[char_id] = {
                    "display_name": char_data.get("display_name", char_id),
                    "role": char_data.get("role", ""),
                    "visual_description": char_data.get(
                        "visual_description", char_data.get("description", "")
                    ),
                    "casting_description": char_data.get("casting_description", ""),
                    "phases": char_data.get("phases", []),
                    "wardrobe_arc_thesis": char_data.get("wardrobe_arc_thesis", ""),
                }

            # Extract locations
            for loc_id, loc_data in bible.get("locations", {}).items():
                loc_slug = slugify_asset_id(loc_id)
                refs = []
                location_folder = pp["location_refs_dir"] / loc_slug
                if location_folder.is_dir():
                    loc_subject_dir = pp_obj.asset_subject_dir("loc", loc_slug)
                    for f in sorted(location_folder.iterdir()):
                        if f.suffix.lower() in IMAGE_EXTS:
                            refs.append(str(loc_subject_dir.relative_to(project_dir) / f.name))

                locations[loc_id] = {
                    "description": loc_data.get("description", ""),
                    "refs": refs,
                }
        except json.JSONDecodeError:
            pass

    char_refs_dir = pp["character_refs_dir"]
    casting_state = _load_casting_state(project_dir)

    for char_id in characters:
        char_state = casting_state.get("characters", {}).get(char_id, {})
        char_slug = slugify_asset_id(char_id)
        char_ref_dir = char_refs_dir / char_slug if char_refs_dir.is_dir() else None

        # Auto-detect hero via canonical ref_resolver (v3 asset paths).
        if not char_state.get("hero_path"):
            from recoil.core.ref_resolver import resolve_character_refs

            canonical = resolve_character_refs(pp_obj, char_id)
            if canonical.get("hero"):
                hero_path = canonical["hero"]
                try:
                    char_state["hero_path"] = str(hero_path.relative_to(project_dir))
                except ValueError:
                    char_state["hero_path"] = str(hero_path)
                char_state.setdefault("status", "hero_selected")

        # Auto-detect turnaround angles (.png and .jpg) — check v3 assets/char/
        # first; always prefer canonical paths even if a legacy path already
        # exists in state.
        turnaround = char_state.get("turnaround", {})
        v3_char_dir = pp_obj.asset_subject_dir("char", char_slug)
        for angle in ("front", "three_quarter", "profile", "back"):
            found = False
            # Check v3 canonical first — always overwrite with canonical if found
            for ext in (".png", ".jpg", ".jpeg"):
                canonical_angle = v3_char_dir / f"{angle}{ext}"
                if canonical_angle.is_file():
                    try:
                        rel_path = str(canonical_angle.relative_to(project_dir))
                    except ValueError:
                        rel_path = str(canonical_angle)
                    if angle not in turnaround:
                        turnaround[angle] = {"path": rel_path, "approved": False}
                    else:
                        turnaround[angle]["path"] = rel_path
                    found = True
                    break
            # Legacy fallback — only if v3 canonical not found AND no path exists
            if not found and char_ref_dir and char_ref_dir.is_dir():
                if angle in turnaround and turnaround[angle].get("path"):
                    continue  # keep existing legacy path
                for ext in (".png", ".jpg", ".jpeg"):
                    angle_path = char_ref_dir / f"{char_slug}_{angle}{ext}"
                    if angle_path.is_file():
                        rel_path = str(
                            v3_char_dir.relative_to(project_dir) / f"{char_slug}_{angle}{ext}"
                        )
                        if angle not in turnaround:
                            turnaround[angle] = {"path": rel_path, "approved": False}
                        elif not turnaround[angle].get("path"):
                            turnaround[angle]["path"] = rel_path
                        break
        if turnaround:
            char_state["turnaround"] = turnaround

        if char_ref_dir and char_ref_dir.is_dir():
            # Auto-detect wardrobe turnarounds
            wardrobe_turnaround = char_state.get("wardrobe_turnaround", {})
            for angle in ("front", "three_quarter", "profile", "back"):
                if angle in wardrobe_turnaround and wardrobe_turnaround[angle].get(
                    "path"
                ):
                    continue
                for ext in (".png", ".jpg", ".jpeg"):
                    wp = char_ref_dir / f"{char_slug}_wardrobe_{angle}{ext}"
                    if wp.is_file():
                        rel_path = str(v3_char_dir.relative_to(project_dir) / f"{char_slug}_wardrobe_{angle}{ext}")
                        if angle not in wardrobe_turnaround:
                            wardrobe_turnaround[angle] = {"path": rel_path}
                        break
            if wardrobe_turnaround:
                char_state["wardrobe_turnaround"] = wardrobe_turnaround

        # Auto-detect grid images
        if char_ref_dir and char_ref_dir.is_dir():
            panels_dir = char_ref_dir / "concept_panels"
            if panels_dir.is_dir() and not char_state.get("grid_images"):
                grid_images = []
                for f in sorted(panels_dir.iterdir()):
                    if f.suffix.lower() in IMAGE_EXTS:
                        grid_images.append(
                            str(v3_char_dir.relative_to(project_dir) / "concept_panels" / f.name)
                        )
                if grid_images:
                    char_state["grid_images"] = grid_images

        casting_state.setdefault("characters", {})[char_id] = char_state

    casting_state["grid_sessions"] = casting_state.get("grid_sessions", {})
    casting_state["continuity_sessions"] = casting_state.get("continuity_sessions", {})

    # Auto-sync grid sessions: if hero detected but session not locked, mark it
    for sid, gs in casting_state["grid_sessions"].items():
        if gs.get("hero_locked"):
            continue
        gs_char = (gs.get("parent_context", {}).get("character_id") or "").upper()
        if not gs_char:
            continue
        char_hero = (
            casting_state.get("characters", {}).get(gs_char, {}).get("hero_path")
        )
        if char_hero:
            gs["hero_locked"] = True
            gs["hero_path"] = char_hero
            if gs.get("status") in ("created", "generating"):
                gs["status"] = "hero_locked"

    return JSONResponse(
        {
            "characters": characters,
            "locations": locations,
            "casting_state": casting_state,
        }
    )


# ── GET /api/project/{project_name}/casting/expressions ─────────────


@router.get("/api/project/{project_name}/casting/expressions/{char_id}")
@router.get("/api/project/{project_name}/casting/expressions")
def casting_expressions(
    project_name: str,
    char_id: str = None,
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    casting_state = _load_casting_state(project_dir)

    if char_id:
        char_state = casting_state.get("characters", {}).get(char_id, {})
        return JSONResponse(char_state.get("expressions", {}))
    else:
        all_expr = {}
        for cid, cstate in casting_state.get("characters", {}).items():
            if cstate.get("expressions"):
                all_expr[cid] = cstate["expressions"]
        return JSONResponse(all_expr)


# ── GET /api/project/{project_name}/casting/locations ────────────────


@router.get("/api/project/{project_name}/casting/locations")
def casting_locations(project_name: str):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    locations = {}

    from recoil.core.paths import ProjectPaths as _ProjectPaths

    pp_obj = _ProjectPaths.from_root(project_dir)

    state_path = ProjectPaths.from_root(project_dir).casting_state_path
    loc_heroes = {}
    if state_path.is_file():
        try:
            casting = json.loads(state_path.read_text(encoding="utf-8"))
            loc_heroes = casting.get("locations", {})
        except (json.JSONDecodeError, IOError):
            pass

    if pp["location_refs_dir"].is_dir():
        for location_entry in sorted(pp["location_refs_dir"].iterdir()):
            if location_entry.is_dir() and not location_entry.name.startswith(("_", ".")):
                loc_subject_dir = pp_obj.asset_subject_dir("loc", location_entry.name)
                refs = []
                for f in sorted(location_entry.iterdir()):
                    if f.is_file() and f.suffix.lower() in IMAGE_EXTS:
                        refs.append(str(loc_subject_dir.relative_to(project_dir) / f.name))
                # Look up casting state by both lowercase and uppercase keys
                loc_state = loc_heroes.get(
                    location_entry.name, loc_heroes.get(location_entry.name.upper(), {})
                )
                locations[location_entry.name] = {
                    "refs": refs,
                    "hero_path": loc_state.get("hero_path"),
                    "moodboard_picks": loc_state.get("moodboard_picks", []),
                }
    return JSONResponse({"locations": locations})


# ── GET /api/project/{project_name}/casting/grid-session/{session_id} ──


@router.get("/api/project/{project_name}/casting/grid-session/{session_id}")
def grid_session_get(project_name: str, session_id: str):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    session = _get_grid_session(project_dir, session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )
    return JSONResponse({"session": session})


# ── GET /api/project/{project_name}/casting/bin/{character_id}/{asset_type} ──


@router.get("/api/project/{project_name}/casting/bin/{character_id}/{asset_type}")
def bin_get(project_name: str, character_id: str, asset_type: str = "wardrobe"):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    char_slug = slugify_asset_id(character_id)
    candidates_dir = pp["character_refs_dir"] / char_slug / "candidates" / asset_type

    if not candidates_dir.is_dir():
        return JSONResponse({"bin_images": [], "total": 0})

    _IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
    all_images = set()
    for f in candidates_dir.rglob("*"):
        if f.is_file() and f.suffix.lower() in _IMG_EXTS:
            all_images.add(_to_relative_output_path(str(f), pp))

    state = _load_casting_state(project_dir)
    active_paths = set()
    for sid, cs in state.get("continuity_sessions", {}).items():
        if (
            cs.get("character_id", "").upper() == character_id.upper()
            and cs.get("asset_type") == asset_type
        ):
            for pid, phase in cs.get("phases", {}).items():
                for c in phase.get("candidates", []):
                    if c.get("path"):
                        active_paths.add(c["path"])

    bin_images = sorted(all_images - active_paths)
    return JSONResponse({"bin_images": bin_images, "total": len(bin_images)})


# ── GET /api/project/{project_name}/casting/continuity-session/{session_id} ──


@router.get("/api/project/{project_name}/casting/continuity-session/{session_id}")
def continuity_session_get(project_name: str, session_id: str):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    state = _load_casting_state(project_dir)
    session = state.get("continuity_sessions", {}).get(session_id)
    if not session:
        return JSONResponse(
            {"error": f"Continuity session {session_id} not found"}, status_code=404
        )
    return JSONResponse({"session": session})


# ── POST /api/project/{project_name}/casting/generate-grid ──────────


@router.post("/api/project/{project_name}/casting/generate-grid")
def casting_generate_grid(
    project_name: str,
    body: dict = Body(default={}),
):
    import subprocess as sp

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    char_id = body.get("character_id", "")
    description = body.get("description", "")

    if not char_id:
        return JSONResponse({"error": "Missing character_id"}, status_code=400)

    gender = body.get("gender", "")

    # Fallback: load full description from global bible
    if not description or not gender:
        bible_path = pp["bible_path"]
        if bible_path.is_file():
            try:
                bible = json.loads(bible_path.read_text(encoding="utf-8"))
                char_data = bible.get("characters", {}).get(char_id.upper(), {})
                if not description:
                    description = char_data.get(
                        "visual_description", char_data.get("description", "")
                    )
                if not gender:
                    gender = char_data.get("gender", "")
            except (json.JSONDecodeError, OSError):
                pass

    tool_path = PROJECT_ROOT / "tools" / "prep_character_angles.py"
    if not tool_path.is_file():
        return JSONResponse(
            {"error": "prep_character_angles.py not found"}, status_code=500
        )

    cmd = [
        sys.executable,
        str(tool_path),
        char_id,
        "--description",
        description or f"Character {char_id}",
        "--project",
        project_name,
    ]
    if gender:
        cmd.extend(["--gender", gender])

    def _run():
        try:
            result = sp.run(
                cmd, capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT)
            )
            if result.returncode == 0:
                try:
                    output = json.loads(result.stdout)
                    state = _load_casting_state(project_dir)
                    chars = state.setdefault("characters", {})
                    char_state = chars.setdefault(char_id.upper(), {})
                    if output.get("grid_result", {}).get("panels"):
                        proj_out = str(pp["output_dir"])
                        panels = []
                        for p in output["grid_result"]["panels"]:
                            p = str(p)
                            if p.startswith(proj_out):
                                p = "output/" + p[len(proj_out) :].lstrip("/")
                            panels.append(p)
                        char_state["grid_images"] = panels
                    _save_casting_state(project_dir, state)
                except json.JSONDecodeError:
                    pass
        except Exception as e:
            print(f"  [WARN] Grid generation failed for {char_id}: {e}")

    thread = threading.Thread(target=_run, daemon=True)
    thread.start()

    return JSONResponse(
        {
            "status": "generating",
            "character_id": char_id,
            "message": "Grid generation started. Poll /casting/characters to check status.",
        }
    )


# ── POST /api/project/{project_name}/casting/select-hero ────────────


@router.post("/api/project/{project_name}/casting/select-hero")
def casting_select_hero(
    project_name: str,
    body: dict = Body(default={}),
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    char_id = body.get("character_id", "").upper()
    panel_index = body.get("panel_index")

    if not char_id or panel_index is None:
        return JSONResponse(
            {"error": "Missing character_id or panel_index"}, status_code=400
        )

    state = _load_casting_state(project_dir)
    chars = state.setdefault("characters", {})
    char_state = chars.setdefault(char_id, {})

    grid_images = char_state.get("grid_images", [])
    if panel_index < 0 or panel_index >= len(grid_images):
        return JSONResponse(
            {"error": f"Invalid panel_index: {panel_index}"}, status_code=400
        )

    char_state["selected_panel"] = panel_index
    char_state["hero_path"] = grid_images[panel_index]
    char_state["hero_source"] = "grid"
    char_state["status"] = "hero_selected"
    char_state["bible_synced"] = False

    # Promote to v3 canonical asset folder and update hero_path
    try:
        from recoil.core.paths import ProjectPaths as _ProjectPaths

        char_slug = slugify_asset_id(char_id)
        pp_obj = _ProjectPaths.from_root(project_dir)
        selected_rel = grid_images[panel_index]
        selected_abs = _resolve_output_rel(selected_rel, pp)
        if selected_abs.is_file():
            # Legacy slug-prefixed hero (lives in the v3 asset_subject_dir for
            # back-compat — same directory as the canonical hero.<ext>).
            char_ref_dir = pp_obj.asset_subject_dir("char", char_slug)
            char_ref_dir.mkdir(parents=True, exist_ok=True)
            legacy_hero = (
                char_ref_dir / f"{char_slug}_hero{selected_abs.suffix.lower()}"
            )
            shutil.copy2(str(selected_abs), str(legacy_hero))

            # Canonical (v3) hero — assets/char/<slug>/hero.<ext>
            canonical_dir = char_ref_dir
            for old_hero in canonical_dir.glob("hero.*"):
                old_hero.unlink()
            canonical_hero = canonical_dir / f"hero{selected_abs.suffix.lower()}"
            shutil.copy2(str(selected_abs), str(canonical_hero))

            # Update hero_path to canonical
            try:
                char_state["hero_path"] = str(canonical_hero.relative_to(project_dir))
            except ValueError:
                char_state["hero_path"] = str(canonical_hero)
    except Exception as e:
        logger.warning("casting_select_hero: canonical promotion failed: %s", e)

    _save_casting_state(project_dir, state)
    return JSONResponse(
        {
            "status": "saved",
            "character_id": char_id,
            "hero_path": char_state["hero_path"],
        }
    )


# ── POST /api/project/{project_name}/casting/generate-turnaround ────


@router.post("/api/project/{project_name}/casting/generate-turnaround")
def casting_generate_turnaround(
    project_name: str,
    body: dict = Body(default={}),
):
    import subprocess as sp

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    from recoil.core.paths import ProjectPaths as _ProjectPaths

    pp_obj = _ProjectPaths.from_root(project_dir)

    char_id = body.get("character_id", "").upper()
    if not char_id:
        return JSONResponse({"error": "Missing character_id"}, status_code=400)

    state = _load_casting_state(project_dir)
    char_state = state.get("characters", {}).get(char_id, {})
    hero_rel = char_state.get("hero_path", "")

    if not hero_rel:
        return JSONResponse(
            {"error": "No hero selected — generate grid and select first"},
            status_code=400,
        )

    hero_abs = _resolve_output_rel(hero_rel, pp)
    if not hero_abs.is_file():
        return JSONResponse(
            {"error": f"Hero image not found: {hero_rel}"}, status_code=404
        )

    tool_path = PROJECT_ROOT / "tools" / "prep_character_angles.py"

    cmd = [
        sys.executable,
        str(tool_path),
        slugify_asset_id(char_id),
        "--hero",
        str(hero_abs),
        "--project",
        project_name,
    ]

    def _run():
        try:
            result = sp.run(
                cmd, capture_output=True, text=True, timeout=300, cwd=str(PROJECT_ROOT)
            )
            if result.returncode == 0:
                try:
                    output = json.loads(result.stdout)
                    st = _load_casting_state(project_dir)
                    chars = st.setdefault("characters", {})
                    cs = chars.setdefault(char_id, {})
                    char_slug_inner = slugify_asset_id(char_id)
                    turnaround = {}
                    angle_grid = output.get("angle_grid", {})
                    panels = angle_grid.get("panels", [])
                    angles = angle_grid.get(
                        "angles", output.get("angles_generated", [])
                    )
                    for i, angle in enumerate(angles):
                        if i < len(panels):
                            panel_path = str(panels[i])
                            proj_out = str(pp["output_dir"])
                            if panel_path.startswith(proj_out):
                                panel_path = "output/" + panel_path[
                                    len(proj_out) :
                                ].lstrip("/")
                            turnaround[angle] = {
                                "path": panel_path,
                                "approved": False,
                            }
                        else:
                            char_subj_dir = pp_obj.asset_subject_dir("char", char_slug_inner)
                            turnaround[angle] = {
                                "path": str(char_subj_dir.relative_to(project_dir) / "angle_panels" / f"{char_slug_inner}_{angle}.png"),
                                "approved": False,
                            }
                    if turnaround:
                        cs["turnaround"] = turnaround
                    _save_casting_state(project_dir, st)
                except json.JSONDecodeError:
                    pass
        except Exception as e:
            print(f"  [WARN] Turnaround generation failed for {char_id}: {e}")

    thread = threading.Thread(target=_run, daemon=True)
    thread.start()

    return JSONResponse(
        {
            "status": "generating",
            "character_id": char_id,
            "message": "Turnaround generation started. Poll /casting/characters to check status.",
        }
    )


# ── POST /api/project/{project_name}/casting/approve-ref ────────────


@router.post("/api/project/{project_name}/casting/approve-ref")
def casting_approve_ref(
    project_name: str,
    body: dict = Body(default={}),
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    char_id = body.get("character_id", "").upper()
    angle = body.get("angle", "")
    approved = body.get("approved", False)

    if not char_id or not angle:
        return JSONResponse({"error": "Missing character_id or angle"}, status_code=400)

    state = _load_casting_state(project_dir)
    chars = state.setdefault("characters", {})
    char_state = chars.setdefault(char_id, {})
    turnaround = char_state.setdefault("turnaround", {})

    if angle in turnaround:
        turnaround[angle]["approved"] = approved
        turnaround[angle]["rejected"] = not approved
    else:
        turnaround[angle] = {"approved": approved, "rejected": not approved}

    if approved:
        char_state["bible_synced"] = False

    _save_casting_state(project_dir, state)
    return JSONResponse(
        {
            "status": "saved",
            "character_id": char_id,
            "angle": angle,
            "approved": approved,
        }
    )


# ── POST /api/project/{project_name}/casting/generate-expressions ───


@router.post("/api/project/{project_name}/casting/generate-expressions")
def casting_generate_expressions(
    project_name: str,
    body: dict = Body(default={}),
):
    import subprocess as sp

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    # Check if expressions already exist
    expr_dir = PROJECT_ROOT / "assets" / "expressions"
    if expr_dir.is_dir() and any(expr_dir.glob("*_active.png")):
        return JSONResponse(
            {
                "status": "already_generated",
                "message": "Universal expression matrix already exists in assets/expressions/.",
                "output_dir": str(expr_dir),
            }
        )

    def _run():
        try:
            tool_path = PROJECT_ROOT / "tools" / "prep_expressions.py"
            cmd = [
                sys.executable,
                str(tool_path),
            ]
            result = sp.run(
                cmd, capture_output=True, text=True, timeout=300, cwd=str(PROJECT_ROOT)
            )
            if result.returncode == 0:
                try:
                    output = json.loads(result.stdout)
                    st = _load_casting_state(project_dir)
                    out_dir = str(output.get("output_dir", ""))
                    proj_out = str(pp["output_dir"])
                    if out_dir.startswith(proj_out):
                        out_dir = "output/" + out_dir[len(proj_out) :].lstrip("/")
                    st["universal_expressions"] = {
                        "generated": True,
                        "count": len(output.get("expressions", [])),
                        "cost": output.get("cost", 0),
                        "output_dir": out_dir,
                    }
                    _save_casting_state(project_dir, st)
                except json.JSONDecodeError:
                    pass
            else:
                print(f"  [WARN] Expression tool failed: {result.stderr}")
        except Exception as e:
            print(f"  [WARN] Expression generation failed: {e}")

    thread = threading.Thread(target=_run, daemon=True)
    thread.start()

    return JSONResponse(
        {
            "status": "generating",
            "message": "Universal expression matrix generation started (3 grids, ~$0.117).",
        }
    )


# ── POST /api/project/{project_name}/casting/generate-location ──────


@router.post("/api/project/{project_name}/casting/generate-location")
def casting_generate_location(
    project_name: str,
    body: dict = Body(default={}),
):
    import subprocess as sp

    loc_id = body.get("location_id", "")
    if not loc_id:
        return JSONResponse({"error": "Missing location_id"}, status_code=400)

    tool_path = PROJECT_ROOT / "tools" / "prep_location_refs.py"
    if not tool_path.is_file():
        tool_path = PROJECT_ROOT / "tools" / "generate_location_refs.py"

    if not tool_path.is_file():
        return JSONResponse(
            {"error": "Location ref generation tool not found"}, status_code=500
        )

    cmd = [
        sys.executable,
        str(tool_path),
        "--project",
        project_name,
    ]

    def _run():
        try:
            sp.run(
                cmd, capture_output=True, text=True, timeout=300, cwd=str(PROJECT_ROOT)
            )
        except Exception as e:
            print(f"  [WARN] Location ref generation failed for {loc_id}: {e}")

    thread = threading.Thread(target=_run, daemon=True)
    thread.start()

    return JSONResponse(
        {
            "status": "generating",
            "location_id": loc_id,
            "message": "Location ref generation started.",
        }
    )


# ── POST /api/project/{project_name}/casting/select-location-hero ───


@router.post("/api/project/{project_name}/casting/select-location-hero")
def casting_select_location_hero(
    project_name: str,
    body: dict = Body(default={}),
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    loc_id = body.get("location_id", "")
    ref_path = body.get("ref_path", "")
    if not loc_id or not ref_path:
        return JSONResponse(
            {"error": "Missing location_id or ref_path"}, status_code=400
        )

    abs_path = project_dir / ref_path
    if not abs_path.is_file():
        return JSONResponse(
            {"error": f"Ref file not found: {ref_path}"}, status_code=404
        )

    state_path = ProjectPaths.from_root(pp["project_dir"]).casting_state_path
    casting = {}
    if state_path.is_file():
        try:
            casting = json.loads(state_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError):
            pass

    # Canonicalize filename → {loc}_hero.{ext}
    canonical = _canonicalize_hero(project_dir, "location", loc_id, ref_path)

    locations = casting.setdefault("locations", {})
    loc_state = locations.setdefault(loc_id, {})
    loc_state["hero_path"] = canonical
    loc_state["hero_selected_at"] = time.time()

    state_path.parent.mkdir(parents=True, exist_ok=True)
    state_path.write_text(json.dumps(casting, indent=2), encoding="utf-8")

    return JSONResponse(
        {
            "location_id": loc_id,
            "hero_path": ref_path,
        }
    )


# ── POST /api/project/{project_name}/casting/update-location-moodboard ──


@router.post("/api/project/{project_name}/casting/update-location-moodboard")
def casting_update_location_moodboard(
    project_name: str,
    body: dict = Body(default={}),
):
    """Save moodboard picks for a location.

    Body: {"location_id": "int_lower_decks_corridor", "moodboard_picks": ["file1.png", "file2.png"]}
    Picks are filenames (not full paths) — relative to the location's refs directory.
    """
    pp = _paths_for_project(project_name)

    loc_id = body.get("location_id", "")
    picks = body.get("moodboard_picks", [])
    if not loc_id:
        return JSONResponse({"error": "Missing location_id"}, status_code=400)
    if not isinstance(picks, list):
        return JSONResponse(
            {"error": "moodboard_picks must be a list"}, status_code=400
        )

    state_path = ProjectPaths.from_root(pp["project_dir"]).casting_state_path
    casting = {}
    if state_path.is_file():
        try:
            casting = json.loads(state_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError):
            pass

    locations = casting.setdefault("locations", {})
    loc_state = locations.setdefault(loc_id, {})
    loc_state["moodboard_picks"] = picks

    state_path.parent.mkdir(parents=True, exist_ok=True)
    state_path.write_text(json.dumps(casting, indent=2), encoding="utf-8")

    return JSONResponse(
        {
            "location_id": loc_id,
            "moodboard_picks": picks,
        }
    )


# ── POST /api/project/{project_name}/casting/bible-synced ───────────


@router.post("/api/project/{project_name}/casting/bible-synced")
def casting_bible_synced(
    project_name: str,
    body: dict = Body(default={}),
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    char_id = body.get("character_id", "").upper()
    if not char_id:
        return JSONResponse({"error": "Missing character_id"}, status_code=400)

    state = _load_casting_state(project_dir)
    char_state = state.get("characters", {}).get(char_id)
    if not char_state:
        return JSONResponse(
            {"error": f"Character {char_id} not in casting state"}, status_code=404
        )

    char_state["bible_synced"] = True
    _save_casting_state(project_dir, state)
    return JSONResponse({"ok": True, "character_id": char_id, "bible_synced": True})


# ── POST /api/project/{project_name}/casting/grid-session (create) ──


@router.post("/api/project/{project_name}/casting/grid-session")
def grid_session_create(
    project_name: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.ref_selector import load_descriptor, extract_mood_text

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    asset_type = body.get("asset_type")
    if not asset_type:
        return JSONResponse({"error": "asset_type required"}, status_code=400)

    parent_context = body.get("parent_context", {})
    anchor_path = body.get("anchor_image_path")
    anchor_source = None

    if anchor_path:
        anchor_source = "provided"

    session = _create_grid_session(
        project_dir,
        asset_type,
        parent_context,
        anchor_path=anchor_path,
        anchor_source=anchor_source,
        mood_text="",
    )
    session_id = session["session_id"]

    # Run vision extraction in background if needed
    if anchor_path:
        descriptor = load_descriptor(asset_type)
        ref_strategy = descriptor.get("ref_handling", {}).get("strategy", "")

        if ref_strategy in ("vision_extraction", "hybrid"):
            abs_path = str(_resolve_output_rel(anchor_path, pp))

            if Path(abs_path).exists():

                def _extract():
                    try:
                        mt = extract_mood_text(abs_path)
                        _update_grid_session(
                            project_dir,
                            session_id,
                            {
                                "anchor": {**session["anchor"], "mood_text": mt},
                            },
                        )
                        print(f"[URSS] Vision extraction done ({len(mt)} chars)")
                    except Exception as e:
                        print(f"[URSS] Vision extraction failed: {e}")

                threading.Thread(target=_extract, daemon=True).start()

    return JSONResponse({"session": session})


# ── POST /api/project/{project_name}/casting/grid-session/{session_id}/{sub_action} ──


@router.post(
    "/api/project/{project_name}/casting/grid-session/{session_id}/{sub_action}"
)
def grid_session_sub_action(
    project_name: str,
    session_id: str,
    sub_action: str,
    body: dict = Body(default={}),
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    if sub_action == "action":
        return _grid_session_action(project_name, project_dir, session_id, body, pp)
    elif sub_action == "reroll":
        return _grid_session_reroll(project_name, project_dir, session_id, body, pp)
    elif sub_action == "lock-hero":
        return _grid_session_lock_hero(project_name, project_dir, session_id, body, pp)
    elif sub_action == "beauty-pass":
        return _grid_session_beauty_pass(
            project_name, project_dir, session_id, body, pp
        )
    elif sub_action == "unlock":
        return _grid_session_unlock(project_name, project_dir, session_id, pp)
    elif sub_action == "update-overrides":
        return _grid_session_update_overrides(
            project_name, project_dir, session_id, body
        )
    else:
        return JSONResponse(
            {"error": f"Unknown grid-session action: {sub_action}"}, status_code=404
        )


def _grid_session_action(project_name, project_dir, session_id, body, pp):
    session = _get_grid_session(project_dir, session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )

    slot = body.get("slot")
    action = body.get("action")
    if slot is None or action not in ("reject", "keep", "lock"):
        return JSONResponse(
            {"error": "slot (int) and action (reject|keep|lock) required"},
            status_code=400,
        )

    candidates = session.get("candidates", [])
    if slot < 0 or slot >= len(candidates):
        return JSONResponse({"error": f"Invalid slot {slot}"}, status_code=400)

    if action == "lock":
        candidates[slot]["state"] = "locked"
        session["hero_locked"] = True
        session["hero_path"] = candidates[slot]["path"]
        session["status"] = "hero_locked"
    else:
        candidates[slot]["state"] = action

    _update_grid_session(
        project_dir,
        session_id,
        {
            "candidates": candidates,
            "hero_locked": session.get("hero_locked", False),
            "hero_path": session.get("hero_path"),
            "status": session.get("status", "active"),
        },
    )

    return JSONResponse({"session": _get_grid_session(project_dir, session_id)})


def _grid_session_reroll(project_name, project_dir, session_id, body, pp):
    session = _get_grid_session(project_dir, session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )

    override_text = body.get("override_text", "").strip()
    edited_mood = body.get("mood_text")

    overrides = session.get("user_overrides", [])
    if override_text:
        overrides.append(override_text)
    collapsed = ", ".join(overrides) if overrides else ""

    update = {
        "status": "generating",
        "user_overrides": overrides,
        "collapsed_override": collapsed,
    }
    if edited_mood is not None:
        anchor = dict(session.get("anchor", {}))
        anchor["mood_text"] = edited_mood
        update["anchor"] = anchor
    _update_grid_session(project_dir, session_id, update)

    # Return response immediately, then run generation in background
    response = JSONResponse({"status": "generating", "session_id": session_id})

    def _run():
        try:
            from recoil.pipeline._lib.ref_selector import generate_candidates

            descriptor = dict(session["descriptor"])
            # ADR-LV09: clamp legacy high-temperature sessions for casting grids
            if (
                session.get("asset_type") == "character"
                and descriptor.get("temperature", 0) > 0.70
            ):
                descriptor["temperature"] = 0.68
            parent = session.get("parent_context", {})
            description = _get_description_for_session(project_dir, session)
            candidates = session.get("candidates", [])
            slots_to_fill = [
                c["slot"]
                for c in candidates
                if c["state"] in ("empty", "reject", "rejected", "new")
            ]

            if not slots_to_fill:
                _update_grid_session(project_dir, session_id, {"status": "active"})
                return

            char_id = (
                slugify_asset_id(parent.get("character_id", ""))
                or slugify_asset_id(parent.get("location_id", ""))
                or "unknown"
            )
            asset_type = session["asset_type"]
            out_dir = pp["refs_dir"] / _asset_type_dir(asset_type) / char_id

            result = generate_candidates(
                descriptor=descriptor,
                description=description,
                output_dir=out_dir,
                prefix=f"{char_id}_{asset_type}_r{session['re_roll_count'] + 1}",
                mood_text=session.get("anchor", {}).get("mood_text", ""),
                user_override=collapsed,
                anchor_image_path=session.get("anchor", {}).get("path"),
                gender=_get_gender_for_session(project_dir, session),
            )

            new_panels = result.get("panels", [])
            gen_num = session["re_roll_count"] + 1
            for i, slot_idx in enumerate(slots_to_fill):
                if i < len(new_panels):
                    rel_path = _to_relative_output_path(new_panels[i], pp)
                    candidates[slot_idx] = {
                        "slot": slot_idx,
                        "path": rel_path,
                        "state": "new",
                        "re_roll_generation": gen_num,
                    }

            _update_grid_session(
                project_dir,
                session_id,
                {
                    "candidates": candidates,
                    "re_roll_count": gen_num,
                    "cost": session.get("cost", 0) + result.get("cost", 0),
                    "status": "active",
                },
            )

        except Exception as e:
            print(f"[URSS] Grid session reroll failed: {e}")
            _update_grid_session(project_dir, session_id, {"status": "error"})

    threading.Thread(target=_run, daemon=True).start()

    return response


def _grid_session_lock_hero(project_name, project_dir, session_id, body, pp):
    session = _get_grid_session(project_dir, session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )

    # Allow locking the anchor image directly (no slot needed)
    if body.get("anchor"):
        hero_path = session.get("anchor", {}).get("path")
        if not hero_path:
            return JSONResponse(
                {"error": "Session has no anchor image"}, status_code=400
            )
        _update_grid_session(
            project_dir,
            session_id,
            {
                "hero_locked": True,
                "hero_path": hero_path,
                "status": "hero_locked",
            },
        )
    else:
        slot = body.get("slot")
        candidates = session.get("candidates", [])
        if slot is None or slot < 0 or slot >= len(candidates):
            return JSONResponse({"error": "Valid slot required"}, status_code=400)

        hero_path = candidates[slot]["path"]
        if not hero_path:
            return JSONResponse({"error": "Candidate has no image"}, status_code=400)

        candidates[slot]["state"] = "locked"
        _update_grid_session(
            project_dir,
            session_id,
            {
                "candidates": candidates,
                "hero_locked": True,
                "hero_path": hero_path,
                "status": "hero_locked",
            },
        )

    # Update backward-compat casting state (beauty pass is opt-in via /beauty-pass)
    _update_casting_hero(project_dir, session, session_id, hero_path)

    return JSONResponse(
        {
            "session": _get_grid_session(project_dir, session_id),
            "hero_path": hero_path,
        }
    )


def _grid_session_beauty_pass(project_name, project_dir, session_id, body, pp):
    """Run beauty pass on an already-locked hero."""
    session = _get_grid_session(project_dir, session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )
    if not session.get("hero_locked"):
        return JSONResponse(
            {"error": "Hero must be locked before running beauty pass"}, status_code=400
        )

    hero_path = session.get("hero_path")
    if not hero_path:
        return JSONResponse({"error": "No hero path set"}, status_code=400)

    from recoil.pipeline._lib.ref_selector import load_descriptor, run_beauty_pass

    descriptor = load_descriptor(session["asset_type"])
    if not descriptor.get("beauty_pass"):
        return JSONResponse(
            {"error": "Beauty pass not supported for this asset type"}, status_code=400
        )

    description = _get_description_for_session(project_dir, session)
    hero_abs = _resolve_output_path(hero_path, pp)
    bp_temp = descriptor.get("beauty_pass_temp", 0.2)
    bp_output = Path(hero_abs).parent / f"{Path(hero_abs).stem}_beauty.png"

    def _run_bp():
        try:
            result = run_beauty_pass(
                hero_image_path=str(hero_abs),
                description=description,
                output_path=bp_output,
                temperature=bp_temp,
            )
            if result["path"]:
                bp_rel = _to_relative_output_path(result["path"], pp)
                _update_grid_session(
                    project_dir,
                    session_id,
                    {
                        "beauty_pass_path": bp_rel,
                        "beauty_pass_cost": result["cost"],
                        "beauty_pass_model": result["model"],
                        "status": "beauty_complete",
                    },
                )
                _update_casting_hero(project_dir, session, session_id, bp_rel)
            else:
                _update_grid_session(
                    project_dir,
                    session_id,
                    {
                        "beauty_pass_error": "No image returned",
                        "status": "beauty_failed",
                    },
                )
        except Exception as e:
            print(f"[URSS] Beauty pass error: {e}")
            _update_grid_session(
                project_dir,
                session_id,
                {
                    "beauty_pass_error": str(e),
                    "status": "beauty_failed",
                },
            )

    threading.Thread(target=_run_bp, daemon=True).start()
    _update_grid_session(project_dir, session_id, {"status": "beauty_running"})

    return JSONResponse({"session": _get_grid_session(project_dir, session_id)})


def _grid_session_update_overrides(project_name, project_dir, session_id, body):
    session = _get_grid_session(project_dir, session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )

    new_text = body.get("collapsed_override", "").strip()
    if new_text:
        overrides = [s.strip() for s in new_text.split(",") if s.strip()]
    else:
        overrides = []
    collapsed = ", ".join(overrides)

    _update_grid_session(
        project_dir,
        session_id,
        {
            "user_overrides": overrides,
            "collapsed_override": collapsed,
        },
    )

    updated = _get_grid_session(project_dir, session_id)
    return JSONResponse({"session": updated})


def _grid_session_unlock(project_name, project_dir, session_id, pp):
    session = _get_grid_session(project_dir, session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )

    candidates = session.get("candidates", [])
    for c in candidates:
        if c["state"] == "locked":
            c["state"] = "new"

    _update_grid_session(
        project_dir,
        session_id,
        {
            "hero_locked": False,
            "hero_path": None,
            "status": "active",
            "candidates": candidates,
        },
    )

    # Restore original hero in casting state
    parent = session.get("parent_context", {})
    char_id = parent.get("character_id", "").upper()
    if char_id:
        state = _load_casting_state(project_dir)
        cs = state.get("characters", {}).get(char_id, {})
        anchor_path = session.get("anchor", {}).get("path")
        if anchor_path:
            cs["hero_path"] = anchor_path
            cs["status"] = "hero_selected"
            state.setdefault("characters", {})[char_id] = cs
            _save_casting_state(project_dir, state)

    updated = _get_grid_session(project_dir, session_id)
    return JSONResponse({"session": updated})


# ── POST /api/project/{project_name}/casting/generate-phases ────────


@router.post("/api/project/{project_name}/casting/generate-phases")
def casting_generate_phases(
    project_name: str,
    body: dict = Body(default={}),
):
    import uuid as _uuid

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    character_id = body.get("character_id", "").upper()
    asset_type = body.get("asset_type", "wardrobe")
    requested_phases = body.get("phases")
    user_override = body.get("user_override", "")
    phase_overrides = body.get("phase_overrides", {})

    if not character_id:
        return JSONResponse({"error": "character_id required"}, status_code=400)

    if asset_type not in ("wardrobe", "hair_makeup"):
        return JSONResponse(
            {"error": "asset_type must be wardrobe or hair_makeup"}, status_code=400
        )

    bible_path = pp["bible_path"]
    if not bible_path or not bible_path.is_file():
        return JSONResponse({"error": "Global bible not found"}, status_code=404)

    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(character_id, {})
    if not char_data:
        return JSONResponse(
            {"error": f"Character {character_id} not in bible"}, status_code=404
        )

    phases = char_data.get("phases", [])
    if not phases:
        return JSONResponse(
            {"error": f"No phases defined for {character_id}"}, status_code=400
        )

    if requested_phases:
        phases = [p for p in phases if p.get("phase_id") in requested_phases]

    casting_state = _load_casting_state(project_dir)
    char_cast = casting_state.get("characters", {}).get(character_id, {})
    hero_rel = char_cast.get("hero_path", "")
    if not hero_rel:
        return JSONResponse(
            {"error": "No hero selected — cast character first"}, status_code=400
        )

    hero_abs = str(_resolve_output_rel(hero_rel, pp)) if hero_rel else None
    if not hero_abs or not Path(hero_abs).is_file():
        return JSONResponse(
            {"error": f"Hero image not found: {hero_rel}"}, status_code=404
        )

    session_id = str(_uuid.uuid4())[:8]
    char_slug = slugify_asset_id(character_id)
    num_grid_candidates = 3

    session = {
        "session_id": session_id,
        "session_type": "continuity",
        "generation_mode": "grid",
        "character_id": character_id,
        "asset_type": asset_type,
        "grid_candidates": [
            {"slot": i, "path": None, "state": "empty"}
            for i in range(num_grid_candidates)
        ],
        "selected_grid": None,
        "phase_ids": [p.get("phase_id", "") for p in phases],
        "status": "generating",
        "cost": 0.0,
    }

    state = _load_casting_state(project_dir)
    if "continuity_sessions" not in state:
        state["continuity_sessions"] = {}
    state["continuity_sessions"][session_id] = session
    _save_casting_state(project_dir, state)

    char_desc = char_data.get("casting_description") or char_data.get(
        "visual_description", ""
    )
    gender = char_data.get("gender")
    out_dir = pp["character_refs_dir"] / char_slug

    def _run():
        try:
            from recoil.pipeline._lib.ref_selector import generate_continuity_grid

            def _on_grid_ready(slot_idx, grid_path):
                s = _load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs:
                    rel_path = _to_relative_output_path(grid_path, pp)
                    cs["grid_candidates"][slot_idx] = {
                        "slot": slot_idx,
                        "path": rel_path,
                        "state": "new",
                    }
                    _save_casting_state(project_dir, s)

            result = generate_continuity_grid(
                asset_type=asset_type,
                phases_data=phases,
                output_dir=out_dir,
                char_id=char_slug,
                hero_image_path=hero_abs,
                char_description=char_desc,
                gender=gender,
                user_override=user_override,
                phase_overrides=phase_overrides,
                num_candidates=num_grid_candidates,
                on_grid_ready=_on_grid_ready,
            )

            s = _load_casting_state(project_dir)
            cs = s.get("continuity_sessions", {}).get(session_id)
            if cs:
                cs["status"] = "complete"
                cs["cost"] = result.get("cost", 0)
                _save_casting_state(project_dir, s)

            print(
                f"[PHASE-GEN] {character_id} {asset_type}: "
                f"{len(phases)} phases, ${result.get('cost', 0):.3f}"
            )

        except Exception as e:
            print(f"[PHASE-GEN] Error: {e}")
            s = _load_casting_state(project_dir)
            cs = s.get("continuity_sessions", {}).get(session_id)
            if cs:
                cs["status"] = "error"
                cs["error"] = str(e)
                _save_casting_state(project_dir, s)

    threading.Thread(target=_run, daemon=True).start()

    return JSONResponse({"session": session})


# ── POST /api/project/{project_name}/casting/reroll-grid ────────────


@router.post("/api/project/{project_name}/casting/reroll-grid")
def casting_reroll_grid(
    project_name: str,
    body: dict = Body(default={}),
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    session_id = body.get("session_id")
    slot = body.get("slot")
    user_override = body.get("user_override", "")
    phase_overrides = body.get("phase_overrides", {})

    if session_id is None or slot is None:
        return JSONResponse({"error": "session_id and slot required"}, status_code=400)

    state = _load_casting_state(project_dir)
    session = state.get("continuity_sessions", {}).get(session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )

    if session.get("generation_mode") != "grid":
        return JSONResponse({"error": "Not a grid session"}, status_code=400)

    # Mark slot as generating
    if slot < len(session["grid_candidates"]):
        session["grid_candidates"][slot] = {
            "slot": slot,
            "path": None,
            "state": "generating",
        }
    session["status"] = "generating"
    _save_casting_state(project_dir, state)

    character_id = session["character_id"]
    asset_type = session["asset_type"]
    char_slug = slugify_asset_id(character_id)

    project_bible = ProjectPaths.from_root(project_dir).global_bible_path
    bible = json.loads(project_bible.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(character_id, {})
    char_desc = char_data.get("casting_description") or char_data.get(
        "visual_description", ""
    )
    gender = char_data.get("gender")
    phases = char_data.get("phases", [])

    char_cast = state.get("characters", {}).get(character_id, {})
    hero_rel = char_cast.get("hero_path", "")
    hero_abs = str(_resolve_output_rel(hero_rel, pp)) if hero_rel else None

    out_dir = pp["character_refs_dir"] / char_slug

    def _run():
        try:
            from recoil.pipeline._lib.ref_selector import generate_continuity_grid

            def _on_grid_ready(slot_idx, grid_path):
                s = _load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs:
                    rel_path = _to_relative_output_path(grid_path, pp)
                    cs["grid_candidates"][slot] = {
                        "slot": slot,
                        "path": rel_path,
                        "state": "new",
                    }
                    _save_casting_state(project_dir, s)

            result = generate_continuity_grid(
                asset_type=asset_type,
                phases_data=phases,
                output_dir=out_dir,
                char_id=char_slug,
                hero_image_path=hero_abs,
                char_description=char_desc,
                gender=gender,
                user_override=user_override,
                phase_overrides=phase_overrides,
                num_candidates=1,
                on_grid_ready=_on_grid_ready,
            )

            s = _load_casting_state(project_dir)
            cs = s.get("continuity_sessions", {}).get(session_id)
            if cs:
                cs["status"] = "complete"
                cs["cost"] = cs.get("cost", 0) + result.get("cost", 0)
                _save_casting_state(project_dir, s)

        except Exception as e:
            print(f"[GRID-REROLL] Error: {e}")
            s = _load_casting_state(project_dir)
            cs = s.get("continuity_sessions", {}).get(session_id)
            if cs:
                cs["status"] = "error"
                _save_casting_state(project_dir, s)

    threading.Thread(target=_run, daemon=True).start()

    return JSONResponse({"session": session})


# ── POST /api/project/{project_name}/casting/reroll-phase ───────────


@router.post("/api/project/{project_name}/casting/reroll-phase")
def casting_reroll_phase(
    project_name: str,
    body: dict = Body(default={}),
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    session_id = body.get("session_id")
    phase_id = body.get("phase_id")
    override_text = body.get("override", "").strip()

    if not session_id or not phase_id:
        return JSONResponse(
            {"error": "session_id and phase_id required"}, status_code=400
        )

    state = _load_casting_state(project_dir)
    session = state.get("continuity_sessions", {}).get(session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )

    phase = session.get("phases", {}).get(phase_id)
    if not phase:
        return JSONResponse(
            {"error": f"Phase {phase_id} not in session"}, status_code=404
        )

    phase["status"] = "generating"
    if override_text:
        phase["override"] = override_text
    _save_casting_state(project_dir, state)

    character_id = session["character_id"]
    asset_type = session["asset_type"]
    char_slug = slugify_asset_id(character_id)

    bible_path = pp["bible_path"]
    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(character_id, {})
    char_desc = char_data.get("casting_description") or char_data.get(
        "visual_description", ""
    )
    gender = char_data.get("gender")

    bible_phases = char_data.get("phases", [])
    target_phase = None
    for bp in bible_phases:
        if bp.get("phase_id") == phase_id:
            target_phase = bp
            break
    if not target_phase:
        return JSONResponse(
            {"status": "generating", "session_id": session_id, "phase_id": phase_id}
        )

    char_cast = state.get("characters", {}).get(character_id, {})
    hero_rel = char_cast.get("hero_path", "")
    hero_abs = str(_resolve_output_rel(hero_rel, pp)) if hero_rel else None

    out_dir = pp["character_refs_dir"] / char_slug

    def _run():
        try:
            from recoil.pipeline._lib.ref_selector import generate_phase_candidates

            slots_to_fill = [
                c["slot"]
                for c in phase["candidates"]
                if c.get("state") not in ("locked",)
            ]

            if not slots_to_fill:
                s = _load_casting_state(project_dir)
                cs = s["continuity_sessions"][session_id]
                cs["phases"][phase_id]["status"] = "ready"
                _save_casting_state(project_dir, s)
                return

            def _on_ready(pid, slot_idx, img_path):
                if pid != phase_id:
                    return
                s = _load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs and phase_id in cs["phases"]:
                    if slot_idx < len(slots_to_fill):
                        actual_slot = slots_to_fill[slot_idx]
                        rel_path = _to_relative_output_path(img_path, pp)
                        cs["phases"][phase_id]["candidates"][actual_slot] = {
                            "slot": actual_slot,
                            "path": rel_path,
                            "state": "new",
                        }
                    _save_casting_state(project_dir, s)

            result = generate_phase_candidates(
                asset_type=asset_type,
                phases_data=[target_phase],
                output_dir=out_dir,
                char_id=char_slug,
                hero_image_path=hero_abs,
                char_description=char_desc,
                gender=gender,
                candidates_per_phase=len(slots_to_fill),
                user_override=override_text,
                on_candidate_ready=_on_ready,
            )

            s = _load_casting_state(project_dir)
            cs = s.get("continuity_sessions", {}).get(session_id)
            if cs:
                cs["phases"][phase_id]["status"] = "ready"
                cs["cost"] = cs.get("cost", 0) + result.get("cost", 0)
                _save_casting_state(project_dir, s)

        except Exception as e:
            print(f"[PHASE-REROLL] Error: {e}")
            s = _load_casting_state(project_dir)
            cs = s.get("continuity_sessions", {}).get(session_id)
            if cs:
                cs["phases"][phase_id]["status"] = "error"
                _save_casting_state(project_dir, s)

    threading.Thread(target=_run, daemon=True).start()

    return JSONResponse(
        {"status": "generating", "session_id": session_id, "phase_id": phase_id}
    )


# ── POST /api/project/{project_name}/casting/bin-assign ─────────────


@router.post("/api/project/{project_name}/casting/bin-assign")
def casting_bin_assign(
    project_name: str,
    body: dict = Body(default={}),
):
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    session_id = body.get("session_id")
    phase_id = body.get("phase_id")
    slot = body.get("slot")
    bin_image_path = body.get("bin_image_path")

    if not all([session_id, phase_id, bin_image_path]) or slot is None:
        return JSONResponse(
            {"error": "session_id, phase_id, slot, bin_image_path required"},
            status_code=400,
        )

    state = _load_casting_state(project_dir)
    session = state.get("continuity_sessions", {}).get(session_id)
    if not session:
        return JSONResponse(
            {"error": f"Session {session_id} not found"}, status_code=404
        )

    phase = session.get("phases", {}).get(phase_id)
    if not phase:
        return JSONResponse(
            {"error": f"Phase {phase_id} not in session"}, status_code=404
        )

    candidates = phase.get("candidates", [])
    if slot < 0 or slot >= len(candidates):
        return JSONResponse({"error": f"Invalid slot {slot}"}, status_code=400)

    candidates[slot] = {
        "slot": slot,
        "path": bin_image_path,
        "state": "new",
    }

    _save_casting_state(project_dir, state)
    return JSONResponse({"session": session, "swapped_slot": slot})


# ══════════════════════════════════════════════════════════════════════
# WARDROBE INTENT ENDPOINTS
# ══════════════════════════════════════════════════════════════════════

# ── GET /api/project/{project_name}/wardrobe-intent ──────────────────


@router.get("/api/project/{project_name}/wardrobe-intent")
def wardrobe_intent_get(project_name: str):
    """Return wardrobe intent state from the bible.

    Reads wardrobe_philosophy and per-character wardrobe_arc_thesis data
    from global_bible.json so the UI can display current state.
    """
    pp = _paths_for_project(project_name)
    bible_path = pp["bible_path"]

    if not bible_path or not bible_path.is_file():
        return JSONResponse(
            {
                "wardrobe_philosophy": None,
                "wardrobe_philosophy_approved": False,
                "characters": {},
            }
        )

    try:
        bible = json.loads(bible_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return JSONResponse(
            {
                "wardrobe_philosophy": None,
                "wardrobe_philosophy_approved": False,
                "characters": {},
            }
        )

    # Extract per-character wardrobe intent data
    char_intents = {}
    for char_id, char_data in bible.get("characters", {}).items():
        thesis = char_data.get("wardrobe_arc_thesis", "")
        if thesis or char_data.get("wardrobe_arc_thesis_approved"):
            char_intents[char_id] = {
                "wardrobe_arc_thesis": thesis,
                "wardrobe_arc_thesis_approved": char_data.get(
                    "wardrobe_arc_thesis_approved", False
                ),
                "wardrobe_arc_thesis_source": char_data.get(
                    "wardrobe_arc_thesis_source", ""
                ),
                "wardrobe_arc_vision": char_data.get("wardrobe_arc_vision", ""),
                "phases": char_data.get("phases", []),
            }

    return JSONResponse(
        {
            "wardrobe_philosophy": bible.get("wardrobe_philosophy"),
            "wardrobe_philosophy_approved": bible.get(
                "wardrobe_philosophy_approved", False
            ),
            "characters": char_intents,
        }
    )


# ── POST /api/project/{project_name}/wardrobe-intent/propose-philosophy ──


@router.post("/api/project/{project_name}/wardrobe-intent/propose-philosophy")
def wi_propose_philosophy(
    project_name: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.ref_selector import propose_wardrobe_philosophy

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]

    treatment = ""
    series_bible = ""
    treatment_path = project_dir / "treatment.md"
    bible_text_path = project_dir / "bible" / "series_bible.md"
    if treatment_path.is_file():
        treatment = treatment_path.read_text(encoding="utf-8")
    if bible_text_path.is_file():
        series_bible = bible_text_path.read_text(encoding="utf-8")

    if not treatment and not series_bible:
        return JSONResponse(
            {"error": "No treatment or series bible found"}, status_code=400
        )

    options = propose_wardrobe_philosophy(treatment, series_bible)
    return JSONResponse({"options": options})


# ── POST /api/project/{project_name}/wardrobe-intent/approve-philosophy ──


@router.post("/api/project/{project_name}/wardrobe-intent/approve-philosophy")
def wi_approve_philosophy(
    project_name: str,
    body: dict = Body(default={}),
):
    philosophy = body.get("philosophy", "").strip()
    if not philosophy:
        return JSONResponse({"error": "philosophy required"}, status_code=400)

    pp = _paths_for_project(project_name)
    bible_path = pp["bible_path"]
    if not bible_path or not bible_path.is_file():
        return JSONResponse({"error": "Global bible not found"}, status_code=404)

    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    bible["wardrobe_philosophy"] = philosophy
    bible["wardrobe_philosophy_approved"] = True
    bible_path.write_text(json.dumps(bible, indent=2, default=str), encoding="utf-8")

    return JSONResponse(
        {
            "status": "approved",
            "wardrobe_philosophy": philosophy,
        }
    )


# ── POST /api/project/{project_name}/wardrobe-intent/propose-theses ──


@router.post("/api/project/{project_name}/wardrobe-intent/propose-theses")
def wi_propose_theses(
    project_name: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.ref_selector import propose_character_theses

    character_id = body.get("character_id", "").upper()
    if not character_id:
        return JSONResponse({"error": "character_id required"}, status_code=400)

    pp = _paths_for_project(project_name)
    bible_path = pp["bible_path"]
    if not bible_path or not bible_path.is_file():
        return JSONResponse({"error": "Global bible not found"}, status_code=404)

    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(character_id)
    if not char_data:
        return JSONResponse(
            {"error": f"Character {character_id} not in bible"}, status_code=404
        )

    series_philosophy = bible.get("wardrobe_philosophy", "")
    char_description = char_data.get("visual_description", "")
    phases = char_data.get("phases", [])

    phase_boundaries = [
        {
            "phase_id": p.get("phase_id", ""),
            "start_ep": p.get("start_ep"),
            "end_ep": p.get("end_ep"),
            "phase_trigger_event": p.get("phase_trigger_event", ""),
        }
        for p in phases
    ]

    episode_arc = ""
    arc_path = pp["project_dir"] / "bible" / "episode_arc.md"
    if arc_path.is_file():
        episode_arc = arc_path.read_text(encoding="utf-8")

    options = propose_character_theses(
        character_id=character_id,
        char_description=char_description,
        phase_boundaries=phase_boundaries,
        series_philosophy=series_philosophy,
        episode_arc=episode_arc,
    )
    return JSONResponse({"character_id": character_id, "options": options})


# ── POST /api/project/{project_name}/wardrobe-intent/approve-thesis ──


@router.post("/api/project/{project_name}/wardrobe-intent/approve-thesis")
def wi_approve_thesis(
    project_name: str,
    body: dict = Body(default={}),
):
    character_id = body.get("character_id", "").upper()
    thesis = body.get("thesis", "").strip()
    source = body.get("source", "auto")
    vision = body.get("vision", "").strip()

    if not character_id:
        return JSONResponse({"error": "character_id required"}, status_code=400)
    if not thesis:
        return JSONResponse({"error": "thesis required"}, status_code=400)

    pp = _paths_for_project(project_name)
    bible_path = pp["bible_path"]
    if not bible_path or not bible_path.is_file():
        return JSONResponse({"error": "Global bible not found"}, status_code=404)

    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(character_id)
    if not char_data:
        return JSONResponse(
            {"error": f"Character {character_id} not in bible"}, status_code=404
        )

    char_data["wardrobe_arc_thesis"] = thesis
    char_data["wardrobe_arc_thesis_approved"] = True
    char_data["wardrobe_arc_thesis_source"] = source
    if vision:
        char_data["wardrobe_arc_vision"] = vision

    bible_path.write_text(json.dumps(bible, indent=2, default=str), encoding="utf-8")

    return JSONResponse(
        {
            "status": "approved",
            "character_id": character_id,
            "thesis": thesis,
            "source": source,
        }
    )


# ── POST /api/project/{project_name}/wardrobe-intent/rewrite-phases ──


@router.post("/api/project/{project_name}/wardrobe-intent/rewrite-phases")
def wi_rewrite_phases(
    project_name: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.ref_selector import rewrite_wardrobe_phases

    character_id = body.get("character_id", "").upper()
    director_hint = body.get("director_hint", "").strip()

    if not character_id:
        return JSONResponse({"error": "character_id required"}, status_code=400)

    pp = _paths_for_project(project_name)
    bible_path = pp["bible_path"]
    if not bible_path or not bible_path.is_file():
        return JSONResponse({"error": "Global bible not found"}, status_code=404)

    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(character_id)
    if not char_data:
        return JSONResponse(
            {"error": f"Character {character_id} not in bible"}, status_code=404
        )

    thesis = char_data.get("wardrobe_arc_thesis", "")
    if not thesis:
        return JSONResponse(
            {
                "error": f"No thesis approved for {character_id}. Approve a thesis first."
            },
            status_code=400,
        )

    series_philosophy = bible.get("wardrobe_philosophy", "")
    char_description = char_data.get("visual_description", "")
    phases = char_data.get("phases", [])

    phase_boundaries = [
        {
            "phase_id": p.get("phase_id", ""),
            "start_ep": p.get("start_ep"),
            "end_ep": p.get("end_ep"),
            "phase_trigger_event": p.get("phase_trigger_event", ""),
        }
        for p in phases
    ]

    episode_arc = ""
    arc_path = pp["project_dir"] / "bible" / "episode_arc.md"
    if arc_path.is_file():
        episode_arc = arc_path.read_text(encoding="utf-8")

    result = rewrite_wardrobe_phases(
        character_id=character_id,
        char_description=char_description,
        phase_boundaries=phase_boundaries,
        thesis=thesis,
        series_philosophy=series_philosophy,
        episode_arc=episode_arc,
        director_hint=director_hint,
    )

    for rewritten in result.get("phases", []):
        pid = rewritten.get("phase_id", "")
        original = next((p for p in phases if p.get("phase_id") == pid), {})
        rewritten["original_description"] = original.get("wardrobe_description", "")

    return JSONResponse(result)


# ── POST /api/project/{project_name}/wardrobe-intent/apply-rewrite ──


@router.post("/api/project/{project_name}/wardrobe-intent/apply-rewrite")
def wi_apply_rewrite(
    project_name: str,
    body: dict = Body(default={}),
):
    character_id = body.get("character_id", "").upper()
    rewritten_phases = body.get("phases", [])

    if not character_id:
        return JSONResponse({"error": "character_id required"}, status_code=400)
    if not rewritten_phases:
        return JSONResponse({"error": "phases required"}, status_code=400)

    pp = _paths_for_project(project_name)
    bible_path = pp["bible_path"]
    if not bible_path or not bible_path.is_file():
        return JSONResponse({"error": "Global bible not found"}, status_code=404)

    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(character_id)
    if not char_data:
        return JSONResponse(
            {"error": f"Character {character_id} not in bible"}, status_code=404
        )

    bible_phases = char_data.get("phases", [])
    updated_count = 0
    for rewritten in rewritten_phases:
        pid = rewritten.get("phase_id", "")
        for bp in bible_phases:
            if bp.get("phase_id") == pid:
                if rewritten.get("wardrobe_description") is not None:
                    bp["wardrobe_description"] = rewritten["wardrobe_description"]
                if rewritten.get("wardrobe_arc_delta") is not None:
                    bp["wardrobe_arc_delta"] = rewritten["wardrobe_arc_delta"]
                if rewritten.get("wardrobe_arc_carries") is not None:
                    bp["wardrobe_arc_carries"] = rewritten["wardrobe_arc_carries"]
                updated_count += 1
                break

    bible_path.write_text(json.dumps(bible, indent=2, default=str), encoding="utf-8")

    return JSONResponse(
        {
            "status": "applied",
            "character_id": character_id,
            "phases_updated": updated_count,
        }
    )


# ══════════════════════════════════════════════════════════════════════
# SCREEN TEST ENDPOINTS
# ══════════════════════════════════════════════════════════════════════

# ── GET /api/project/{project_name}/screen-test/{character} ─────────


@router.get("/api/project/{project_name}/screen-test/{character}")
def screen_test_get(project_name: str, character: str):
    from recoil.pipeline._lib.screen_test import load_screen_test_state

    char_id = character.upper()
    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    bible_path = pp["bible_path"]

    char_info = {}
    phases_from_bible = []
    char_props = []

    if bible_path and bible_path.is_file():
        try:
            bible = json.loads(bible_path.read_text(encoding="utf-8"))
            char_data = bible.get("characters", {}).get(char_id, {})
            char_info = {
                "char_id": char_id,
                "display_name": char_data.get("display_name", char_id),
                "visual_description": char_data.get(
                    "visual_description", char_data.get("description", "")
                ),
            }
            phases_from_bible = char_data.get("phases", [])

            all_props = bible.get("props", {})
            for prop_id, prop_data in all_props.items():
                assoc = prop_data.get("associated_characters", [])
                if char_id in assoc or char_id.lower() in [c.lower() for c in assoc]:
                    char_props.append(
                        {
                            "prop_id": prop_id,
                            "description": prop_data.get("description", ""),
                            "state_notes": prop_data.get("state_notes", ""),
                        }
                    )
        except (json.JSONDecodeError, OSError):
            pass

    st_state = load_screen_test_state(project_dir)
    char_st = st_state.characters.get(char_id)

    casting_state = _load_casting_state(project_dir)
    char_cast = casting_state.get("characters", {}).get(char_id, {})
    hero_path = char_cast.get("hero_path", "")
    cast_status = char_cast.get("status", "")

    phases = []
    for p in phases_from_bible:
        phase_id = p.get("phase_id", "")
        phase_st = char_st.phases.get(phase_id) if char_st else None

        phase_entry = {
            "phase_id": phase_id,
            "start_ep": p.get("start_ep"),
            "end_ep": p.get("end_ep"),
            "wardrobe_description": p.get("wardrobe_description", ""),
            "hair_makeup": p.get("hair_makeup", ""),
            "distinguishing_marks": p.get("distinguishing_marks", ""),
            "status": phase_st.status if phase_st else "empty",
            "locked_image": phase_st.locked_image if phase_st else None,
            "held_images": phase_st.held_images if phase_st else [],
            "director_note": phase_st.director_note if phase_st else None,
            "history_count": len(phase_st.generation_history) if phase_st else 0,
            "bible_synced": phase_st.bible_synced
            if phase_st and hasattr(phase_st, "bible_synced")
            else False,
        }
        phases.append(phase_entry)

    return JSONResponse(
        {
            "character": char_info,
            "hero_path": hero_path,
            "cast_status": cast_status,
            "anchor_phase": char_st.anchor_phase if char_st else None,
            "phases": phases,
            "props": char_props,
        }
    )


# ── POST /api/project/{project_name}/screen-test/{character} ────────


@router.post("/api/project/{project_name}/screen-test/{character}")
def screen_test_generate(
    project_name: str,
    character: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.screen_test import (
        load_screen_test_state,
        save_screen_test_state,
        CharacterScreenTest,
        PhaseState,
        record_generation,
    )
    from tools.screen_test_gen import build_phase_prompt, generate_phase_image

    from recoil.core.paths import ProjectPaths as _ProjectPaths

    char_id = character.upper()
    char_slug = slugify_asset_id(character)

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    bible_path = pp["bible_path"]
    pp_obj = _ProjectPaths.from_root(project_dir)

    if not bible_path or not bible_path.is_file():
        return JSONResponse({"error": "Global bible not found"}, status_code=404)

    try:
        bible = json.loads(bible_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        return JSONResponse({"error": f"Failed to load bible: {e}"}, status_code=500)

    char_data = bible.get("characters", {}).get(char_id, {})
    if not char_data:
        return JSONResponse(
            {"error": f"Character not found in bible: {char_id}"}, status_code=404
        )

    casting_state = _load_casting_state(project_dir)
    char_cast = casting_state.get("characters", {}).get(char_id, {})
    hero_rel = char_cast.get("hero_path", "")

    if not hero_rel:
        return JSONResponse(
            {"error": "No hero selected — cast character first"}, status_code=400
        )

    hero_abs = _resolve_output_rel(hero_rel, pp)
    if not hero_abs.is_file():
        return JSONResponse(
            {"error": f"Hero image not found: {hero_rel}"}, status_code=404
        )

    # Resolve three-quarter path (optional)
    tq_path = None
    turnaround = char_cast.get("turnaround", {})
    tq_info = turnaround.get("three_quarter", {})
    if tq_info.get("path"):
        tq_candidate = _resolve_output_rel(tq_info["path"], pp)
        if tq_candidate.is_file():
            tq_path = tq_candidate

    st_state = load_screen_test_state(project_dir)
    char_st = st_state.characters.get(char_id, CharacterScreenTest())
    bible_phases = char_data.get("phases", [])

    # Gather props for this character
    all_props = bible.get("props", {})
    char_props = []
    for prop_id, prop_data in all_props.items():
        assoc = prop_data.get("associated_characters", [])
        if char_id in assoc or char_id.lower() in [c.lower() for c in assoc]:
            char_props.append(
                {
                    "prop_id": prop_id,
                    "description": prop_data.get("description", ""),
                    "state_notes": prop_data.get("state_notes", ""),
                }
            )

    phases_to_generate = []
    for p in bible_phases:
        phase_id = p.get("phase_id", "")
        phase_st = char_st.phases.get(phase_id)
        status = phase_st.status if phase_st else "empty"
        if status in ("empty", "rejected"):
            phases_to_generate.append(p)

    if not phases_to_generate:
        return JSONResponse(
            {
                "status": "nothing_to_generate",
                "character_id": char_id,
                "message": "All phases already have images (generated/held/locked).",
            }
        )

    # Mark phases as "generating" immediately
    for p in phases_to_generate:
        phase_id = p.get("phase_id", "")
        if phase_id not in char_st.phases:
            char_st.phases[phase_id] = PhaseState(phase_id=phase_id)
        char_st.phases[phase_id].status = "generating"

    st_state.characters[char_id] = char_st
    save_screen_test_state(project_dir, st_state)

    screen_test_dir = pp["character_refs_dir"] / char_slug / "screen_test"

    def _run():
        for p in phases_to_generate:
            phase_id = p.get("phase_id", "")
            try:
                prompt = build_phase_prompt(
                    char=char_data,
                    phase=p,
                    props=char_props,
                    aesthetic_directives=bible.get("aesthetic_directives"),
                )

                current_state = load_screen_test_state(project_dir)
                current_char = current_state.characters.get(
                    char_id, CharacterScreenTest()
                )
                current_phase = current_char.phases.get(
                    phase_id, PhaseState(phase_id=phase_id)
                )
                version = len(current_phase.generation_history) + 1

                output_path = screen_test_dir / f"{phase_id}_v{version}.png"

                # Get anchor path if set
                anchor_path = None
                if current_char.anchor_phase and current_char.anchor_phase != phase_id:
                    anchor_phase_st = current_char.phases.get(current_char.anchor_phase)
                    if anchor_phase_st and anchor_phase_st.locked_image:
                        anchor_candidate = _resolve_output_rel(
                            anchor_phase_st.locked_image, pp
                        )
                        if anchor_candidate.is_file():
                            anchor_path = anchor_candidate

                success = generate_phase_image(
                    hero_path=hero_abs,
                    three_quarter_path=tq_path,
                    prompt=prompt,
                    output_path=output_path,
                    anchor_path=anchor_path,
                )

                if success:
                    current_state = load_screen_test_state(project_dir)
                    current_char = current_state.characters.get(
                        char_id, CharacterScreenTest()
                    )
                    if phase_id not in current_char.phases:
                        current_char.phases[phase_id] = PhaseState(phase_id=phase_id)
                    char_subj_rel = pp_obj.asset_subject_dir("char", char_slug).relative_to(project_dir)
                    rel_path = str(char_subj_rel / "screen_test" / f"{phase_id}_v{version}.png")
                    record_generation(current_char.phases[phase_id], rel_path, prompt)
                    current_state.characters[char_id] = current_char
                    save_screen_test_state(project_dir, current_state)
                else:
                    current_state = load_screen_test_state(project_dir)
                    current_char = current_state.characters.get(
                        char_id, CharacterScreenTest()
                    )
                    if phase_id in current_char.phases:
                        current_char.phases[phase_id].status = "empty"
                    current_state.characters[char_id] = current_char
                    save_screen_test_state(project_dir, current_state)
                    print(
                        f"  [WARN] Screen test generation failed for {char_id}/{phase_id}"
                    )

            except Exception as e:
                print(
                    f"  [WARN] Screen test generation error for {char_id}/{phase_id}: {e}"
                )
                try:
                    current_state = load_screen_test_state(project_dir)
                    current_char = current_state.characters.get(
                        char_id, CharacterScreenTest()
                    )
                    if phase_id in current_char.phases:
                        current_char.phases[phase_id].status = "empty"
                    current_state.characters[char_id] = current_char
                    save_screen_test_state(project_dir, current_state)
                except Exception:
                    pass

    thread = threading.Thread(target=_run, daemon=True)
    thread.start()

    return JSONResponse(
        {
            "status": "generating",
            "character_id": char_id,
            "phases": [p.get("phase_id", "") for p in phases_to_generate],
            "message": f"Generating {len(phases_to_generate)} phase(s). Poll GET to check status.",
        }
    )


# ── POST /api/project/{project_name}/screen-test/{character}/set-anchor ──
# NOTE: Declared before {phase}/* routes so the literal "set-anchor" segment
# takes priority over the {phase} path parameter in FastAPI's route matching.


@router.post("/api/project/{project_name}/screen-test/{character}/set-anchor")
def screen_test_set_anchor(
    project_name: str,
    character: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.screen_test import (
        load_screen_test_state,
        save_screen_test_state,
    )

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    char_id = character.upper()
    phase_id = body.get("phase", "")

    if not phase_id:
        return JSONResponse(
            {"error": "Missing 'phase' in request body"}, status_code=400
        )

    st_state = load_screen_test_state(project_dir)
    char_st = st_state.characters.get(char_id)

    if not char_st:
        return JSONResponse(
            {"error": f"No screen test state for character: {char_id}"}, status_code=404
        )

    phase_st = char_st.phases.get(phase_id)
    if not phase_st:
        return JSONResponse({"error": f"Phase not found: {phase_id}"}, status_code=404)

    if phase_st.status != "locked":
        return JSONResponse(
            {
                "error": f"Phase '{phase_id}' is not locked (status: {phase_st.status}). Lock it first."
            },
            status_code=400,
        )

    char_st.anchor_phase = phase_id
    save_screen_test_state(project_dir, st_state)

    return JSONResponse(
        {
            "status": "saved",
            "character_id": char_id,
            "anchor_phase": phase_id,
            "message": f"Phase '{phase_id}' set as style anchor for {char_id}.",
        }
    )


# ── POST /api/project/{project_name}/screen-test/{character}/{phase}/reroll ──


@router.post("/api/project/{project_name}/screen-test/{character}/{phase}/reroll")
def screen_test_reroll(
    project_name: str,
    character: str,
    phase: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.screen_test import (
        load_screen_test_state,
        save_screen_test_state,
        CharacterScreenTest,
        PhaseState,
        record_generation,
    )
    from tools.screen_test_gen import (
        build_phase_prompt,
        enrich_director_note,
        generate_phase_image,
    )

    from recoil.core.paths import ProjectPaths as _ProjectPaths

    char_id = character.upper()
    char_slug = slugify_asset_id(character)
    phase_id = phase
    director_note = body.get("director_note", "")
    deep = body.get("deep", False)
    num_images = 4 if deep else 1

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    bible_path = pp["bible_path"]
    pp_obj = _ProjectPaths.from_root(project_dir)

    if not bible_path or not bible_path.is_file():
        return JSONResponse({"error": "Global bible not found"}, status_code=404)

    try:
        bible = json.loads(bible_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        return JSONResponse({"error": f"Failed to load bible: {e}"}, status_code=500)

    char_data = bible.get("characters", {}).get(char_id, {})
    if not char_data:
        return JSONResponse(
            {"error": f"Character not found in bible: {char_id}"}, status_code=404
        )

    bible_phase = None
    for p in char_data.get("phases", []):
        if p.get("phase_id") == phase_id:
            bible_phase = p
            break

    if not bible_phase:
        return JSONResponse(
            {"error": f"Phase not found in bible: {phase_id}"}, status_code=404
        )

    # Gather props for this character
    all_props = bible.get("props", {})
    char_props = []
    for prop_id, prop_data in all_props.items():
        assoc = prop_data.get("associated_characters", [])
        if char_id in assoc or char_id.lower() in [c.lower() for c in assoc]:
            char_props.append(
                {
                    "prop_id": prop_id,
                    "description": prop_data.get("description", ""),
                    "state_notes": prop_data.get("state_notes", ""),
                }
            )

    casting_state = _load_casting_state(project_dir)
    char_cast = casting_state.get("characters", {}).get(char_id, {})
    hero_rel = char_cast.get("hero_path", "")

    if not hero_rel:
        return JSONResponse(
            {"error": "No hero selected — cast character first"}, status_code=400
        )

    hero_abs = _resolve_output_rel(hero_rel, pp)
    if not hero_abs.is_file():
        return JSONResponse(
            {"error": f"Hero image not found: {hero_rel}"}, status_code=404
        )

    tq_path = None
    turnaround = char_cast.get("turnaround", {})
    tq_info = turnaround.get("three_quarter", {})
    if tq_info.get("path"):
        tq_candidate = _resolve_output_rel(tq_info["path"], pp)
        if tq_candidate.is_file():
            tq_path = tq_candidate

    # Mark as generating
    st_state = load_screen_test_state(project_dir)
    char_st = st_state.characters.get(char_id, CharacterScreenTest())
    if phase_id not in char_st.phases:
        char_st.phases[phase_id] = PhaseState(phase_id=phase_id)
    char_st.phases[phase_id].status = "generating"
    if director_note:
        char_st.phases[phase_id].director_note = director_note
    st_state.characters[char_id] = char_st
    save_screen_test_state(project_dir, st_state)

    screen_test_dir = pp["character_refs_dir"] / char_slug / "screen_test"

    def _run():
        try:
            enriched_note = None
            if director_note:
                try:
                    enriched_note = enrich_director_note(
                        char_data.get("visual_description", ""),
                        director_note,
                    )
                except Exception as e:
                    print(f"  [WARN] Director note enrichment failed: {e}")
                    enriched_note = director_note

            prompt = build_phase_prompt(
                char=char_data,
                phase=bible_phase,
                enriched_note=enriched_note,
                props=char_props,
                aesthetic_directives=bible.get("aesthetic_directives"),
            )

            current_state = load_screen_test_state(project_dir)
            current_char = current_state.characters.get(char_id, CharacterScreenTest())
            anchor_path = None
            if current_char.anchor_phase and current_char.anchor_phase != phase_id:
                anchor_phase_st = current_char.phases.get(current_char.anchor_phase)
                if anchor_phase_st and anchor_phase_st.locked_image:
                    anchor_candidate = _resolve_output_rel(
                        anchor_phase_st.locked_image, pp
                    )
                    if anchor_candidate.is_file():
                        anchor_path = anchor_candidate

            for i in range(num_images):
                current_state = load_screen_test_state(project_dir)
                current_char = current_state.characters.get(
                    char_id, CharacterScreenTest()
                )
                current_phase = current_char.phases.get(
                    phase_id, PhaseState(phase_id=phase_id)
                )
                version = len(current_phase.generation_history) + 1

                output_path = screen_test_dir / f"{phase_id}_v{version}.png"

                success = generate_phase_image(
                    hero_path=hero_abs,
                    three_quarter_path=tq_path,
                    prompt=prompt,
                    output_path=output_path,
                    anchor_path=anchor_path,
                )

                if success:
                    current_state = load_screen_test_state(project_dir)
                    current_char = current_state.characters.get(
                        char_id, CharacterScreenTest()
                    )
                    if phase_id not in current_char.phases:
                        current_char.phases[phase_id] = PhaseState(phase_id=phase_id)
                    char_subj_rel = pp_obj.asset_subject_dir("char", char_slug).relative_to(project_dir)
                    rel_path = str(char_subj_rel / "screen_test" / f"{phase_id}_v{version}.png")
                    record_generation(
                        current_char.phases[phase_id],
                        rel_path,
                        prompt,
                        note=director_note or None,
                    )
                    current_state.characters[char_id] = current_char
                    save_screen_test_state(project_dir, current_state)
                else:
                    print(
                        f"  [WARN] Screen test reroll failed for {char_id}/{phase_id} v{version}"
                    )

            # If all generations failed, reset status
            final_state = load_screen_test_state(project_dir)
            final_char = final_state.characters.get(char_id, CharacterScreenTest())
            final_phase = final_char.phases.get(phase_id)
            if final_phase and final_phase.status == "generating":
                final_phase.status = (
                    "empty" if not final_phase.generation_history else "generated"
                )
                final_state.characters[char_id] = final_char
                save_screen_test_state(project_dir, final_state)

        except Exception as e:
            print(f"  [WARN] Screen test reroll error for {char_id}/{phase_id}: {e}")
            try:
                current_state = load_screen_test_state(project_dir)
                current_char = current_state.characters.get(
                    char_id, CharacterScreenTest()
                )
                if phase_id in current_char.phases:
                    ph = current_char.phases[phase_id]
                    ph.status = "empty" if not ph.generation_history else "generated"
                current_state.characters[char_id] = current_char
                save_screen_test_state(project_dir, current_state)
            except Exception:
                pass

    thread = threading.Thread(target=_run, daemon=True)
    thread.start()

    return JSONResponse(
        {
            "status": "generating",
            "character_id": char_id,
            "phase_id": phase_id,
            "num_images": num_images,
            "director_note": director_note or None,
            "message": f"Re-rolling {phase_id} with {num_images} image(s). Poll GET to check status.",
        }
    )


# ── POST /api/project/{project_name}/screen-test/{character}/{phase}/verdict ──


@router.post("/api/project/{project_name}/screen-test/{character}/{phase}/verdict")
def screen_test_verdict(
    project_name: str,
    character: str,
    phase: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.screen_test import (
        load_screen_test_state,
        save_screen_test_state,
        apply_verdict,
    )

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    char_id = character.upper()
    phase_id = phase
    action = body.get("action", "")

    if action not in ("lock", "hold", "reject"):
        return JSONResponse(
            {"error": f"Invalid action: {action}. Must be lock, hold, or reject."},
            status_code=400,
        )

    st_state = load_screen_test_state(project_dir)
    char_st = st_state.characters.get(char_id)

    if not char_st:
        return JSONResponse(
            {"error": f"No screen test state for character: {char_id}"}, status_code=404
        )

    phase_st = char_st.phases.get(phase_id)
    if not phase_st:
        return JSONResponse(
            {"error": f"No screen test state for phase: {phase_id}"}, status_code=404
        )

    try:
        apply_verdict(phase_st, action)
    except ValueError as e:
        return JSONResponse({"error": str(e)}, status_code=400)

    save_screen_test_state(project_dir, st_state)

    return JSONResponse(
        {
            "status": "saved",
            "character_id": char_id,
            "phase_id": phase_id,
            "action": action,
            "new_status": phase_st.status,
        }
    )


# ── POST /api/project/{project_name}/screen-test/{character}/{phase}/bible-synced ──


@router.post("/api/project/{project_name}/screen-test/{character}/{phase}/bible-synced")
def screen_test_bible_synced(
    project_name: str,
    character: str,
    phase: str,
    body: dict = Body(default={}),
):
    from recoil.pipeline._lib.screen_test import (
        load_screen_test_state,
        save_screen_test_state,
    )

    pp = _paths_for_project(project_name)
    project_dir = pp["project_dir"]
    char_id = character.upper()
    phase_id = phase

    st_state = load_screen_test_state(project_dir)
    char_st = st_state.characters.get(char_id)

    if not char_st:
        return JSONResponse(
            {"error": f"No screen test state for character: {char_id}"}, status_code=404
        )

    phase_st = char_st.phases.get(phase_id)
    if not phase_st:
        return JSONResponse({"error": f"Phase not found: {phase_id}"}, status_code=404)

    phase_st.bible_synced = True
    save_screen_test_state(project_dir, st_state)
    return JSONResponse(
        {
            "ok": True,
            "character_id": char_id,
            "phase_id": phase_id,
            "bible_synced": True,
        }
    )
