# api/routes/console.py
"""Console endpoints — Phase 1 scaffold + Phase 2 board/budget/bible/content/launch-batch."""

import hashlib
import json
import os
import time
from pathlib import Path

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

from .. import state
from ..deps import (
    get_project, get_paths, get_store, _paths_for_project,
    get_project_aspect_ratio,
)
from ..state import PROJECT_ROOT, task_registry, task_lock, prune_task_registry

from recoil.core.paths import projects_root, get_config

# ── Media extension sets (from review_server.py line 338) ──────────
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
VIDEO_EXTS = {".mp4", ".webm", ".mov"}
MEDIA_EXTS = IMAGE_EXTS | VIDEO_EXTS

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


# ── Utility functions (from review_server.py lines 359-480) ────────

def get_episode_dirs(frames_dir=None):
    """Scan output/frames/ for ep_* directories, return sorted list."""
    fdir = frames_dir
    if not fdir or not fdir.is_dir():
        return []
    dirs = []
    for d in sorted(fdir.iterdir()):
        if d.is_dir() and d.name.startswith("ep_"):
            dirs.append(d.name)
    return dirs


def get_episode_number(ep_dir_name):
    """Extract episode number from directory name like 'ep_001'."""
    try:
        return int(ep_dir_name.replace("ep_", ""))
    except ValueError:
        return 0


def scan_frames(ep_dir, frames_dir=None, output_dir=None):
    """Recursively scan an episode directory for image files."""
    fdir = frames_dir
    odir = output_dir
    ep_path = fdir / ep_dir
    if not ep_path.is_dir():
        return []

    frames = []
    for root, _dirs, files in os.walk(ep_path):
        for f in sorted(files):
            fpath = Path(root) / f
            if fpath.suffix.lower() in MEDIA_EXTS:
                rel = fpath.relative_to(odir)
                rel_to_ep = fpath.relative_to(ep_path)
                subdir = str(rel_to_ep.parent) if str(rel_to_ep.parent) != "." else "root"
                media_type = "video" if fpath.suffix.lower() in VIDEO_EXTS else "image"
                frames.append({
                    "filename": f,
                    "path": f"output/{rel}",
                    "subdir": subdir,
                    "shot_name": fpath.stem,
                    "size_bytes": fpath.stat().st_size,
                    "media_type": media_type,
                })
    return frames


def load_shot_data(ep_dir, frames_dir=None, plans_dir=None):
    """Load shot data for an episode (generation log or plan)."""
    fdir = frames_dir
    pdir = plans_dir

    if fdir:
        gen_path = fdir / ep_dir / "log.json"
        if gen_path.exists():
            try:
                with open(gen_path) as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError):
                pass

    if pdir:
        plan_name = ep_dir if ep_dir.startswith("ep_") else f"ep_{ep_dir}"
        plan_path = pdir / f"{plan_name}_plan.json"
        if plan_path.exists():
            try:
                with open(plan_path) as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError):
                pass

    return None


def load_cost_log(ep_dir, frames_dir=None):
    """Load cost_log.json for an episode, return dict or None."""
    fdir = frames_dir
    cost_path = fdir / ep_dir / "cost_log.json"
    if not cost_path.exists():
        return None
    try:
        with open(cost_path) as f:
            return json.load(f)
    except (json.JSONDecodeError, IOError):
        return None


# ── Endpoints ──────────────────────────────────────────────────────

@router.get("/api/health")
def health():
    return {"status": "ok", "ts": time.time()}


@router.get("/api/projects")
def list_projects():
    """List available projects (scans projects_root() from constants)."""
    projects = []
    if projects_root().exists():
        projects = sorted([
            d.name for d in projects_root().iterdir()
            if d.is_dir() and not d.name.startswith((".", "_"))
            and ((d / "episodes").is_dir() or (d / "treatment.md").is_file()
                 or list(d.glob("*.fountain")))
        ])
    if not projects:
        projects = [state.default_project]
    return {"projects": projects, "active": state.default_project}


@router.get("/api/project-config")
def project_config(project: str = Query(None)):
    """Project config with aspect ratio."""
    p = get_project(project)
    pp = _paths_for_project(p)
    config_path = pp["project_dir"] / "project_config.json"
    config = {}
    if config_path.exists():
        try:
            config = json.loads(config_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            pass
    ar = str(config.get("aspect_ratio", "9:16"))
    css_ar = ar.replace(":", " / ") if ":" in ar else ar
    return {
        "aspect_ratio": ar,
        "aspect_ratio_css": css_ar,
        "project": p,
    }


@router.get("/api/config/model-capabilities")
def model_capabilities():
    """Return model capabilities from project config."""
    try:
        config = get_config()
        return config.get("model_capabilities", {})
    except Exception as exc:
        return JSONResponse({"error": f"Failed to load config: {exc}"}, status_code=500)


@router.get("/api/episodes")
def list_episodes(project: str = Query(None)):
    """List available episodes — merges Starsend output with Recoil episode scripts."""
    import re
    p = get_project(project)
    pp = _paths_for_project(p)

    ep_dirs = get_episode_dirs(frames_dir=pp["frames_dir"])
    seen = set()
    episodes = []
    for ep_dir in ep_dirs:
        ep_num = get_episode_number(ep_dir)
        shot_data = load_shot_data(ep_dir, frames_dir=pp["frames_dir"], plans_dir=pp["plans_dir"])
        cost_log = load_cost_log(ep_dir, frames_dir=pp["frames_dir"])
        frames = scan_frames(ep_dir, frames_dir=pp["frames_dir"], output_dir=pp["output_dir"])

        info = {
            "dir": ep_dir,
            "number": ep_num,
            "frame_count": len(frames),
            "total_cost": shot_data.get("total_cost", 0) if shot_data else 0,
            "total_calls": shot_data.get("total_calls", 0) if shot_data else 0,
            "has_plan": shot_data is not None,
            "has_cost_log": cost_log is not None,
        }
        episodes.append(info)
        seen.add(ep_dir)

    # Episodes from project scripts (may not have output yet)
    project_episodes_dir = projects_root() / p / "episodes"
    if project_episodes_dir.is_dir():
        for f in sorted(project_episodes_dir.glob("ep_*.md")):
            m = re.match(r"ep_(\d+)\.md$", f.name)
            if m:
                ep_dir = f"ep_{m.group(1)}"
                if ep_dir not in seen:
                    episodes.append({
                        "dir": ep_dir,
                        "number": int(m.group(1)),
                        "frame_count": 0,
                        "total_cost": 0,
                        "total_calls": 0,
                        "has_plan": False,
                        "has_cost_log": False,
                    })
                    seen.add(ep_dir)

    episodes.sort(key=lambda e: e["number"])
    return {"episodes": episodes}


@router.get("/api/tasks")
def list_tasks():
    """List all tasks in the registry."""
    prune_task_registry()
    with task_lock:
        tasks = dict(task_registry)
    return {"tasks": tasks}


@router.get("/api/recent-activity")
def recent_activity(
    since: float = Query(default=0, description="Unix timestamp — return shots updated after this time"),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Return shots with status changes since a given timestamp.

    Scans ExecutionStore for recently-updated shots. This catches changes
    from CLI/StepRunner runs that bypass the task_registry.
    """
    if since <= 0:
        since = time.time() - 30  # Default: last 30 seconds

    activity = []
    for shot in store.get_all_shots():
        shot_id = shot.get("shot_id", "")
        updated = shot.get("updated_at", 0)
        if updated > since:
            status = shot.get("status", "unknown")
            # Determine action from status prefix
            if "video" in status:
                action = "video"
            elif "keyframe" in status:
                action = "keyframe"
            elif "previs" in status:
                action = "previz"
            else:
                action = "update"

            activity.append({
                "shot_id": shot_id,
                "status": status,
                "action": action,
                "updated_at": updated,
                "cost": shot.get("total_cost", 0),
            })

    activity.sort(key=lambda a: a["updated_at"], reverse=True)
    return {"activity": activity, "server_time": time.time()}


@router.get("/api/tasks/{task_id}")
def get_task(task_id: str):
    """Get a specific task by ID."""
    with task_lock:
        task = task_registry.get(task_id)
    if task:
        return task
    return JSONResponse({"error": f"Task not found: {task_id}"}, status_code=404)


# ══════════════════════════════════════════════════════════════════
# Phase 2 endpoints — board, budget, bible, content, launch-batch
# ══════════════════════════════════════════════════════════════════


# ── Helper: resolve output-relative path to absolute ──────────────

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)


# ── Board ─────────────────────────────────────────────────────────

@router.get("/api/board")
def api_board(project: str = Query(None)):
    """All episodes with 5-state counts for the Board tab."""
    p = get_project(project)
    pp = _paths_for_project(p)
    fdir = pp["frames_dir"]
    pdir = pp["plans_dir"]

    try:
        store = get_store(p)
    except Exception:
        store = None

    if store is None:
        # Fallback: scan episode directories
        ep_dirs = get_episode_dirs(frames_dir=fdir)
        episodes = []
        for ep_dir in ep_dirs:
            ep_num = get_episode_number(ep_dir)
            shot_data = load_shot_data(ep_dir, frames_dir=fdir, plans_dir=pdir)
            episodes.append({
                "episode_id": f"EP{ep_num:03d}",
                "dir": ep_dir,
                "total_shots": shot_data.get("total_shots", 0) if shot_data else 0,
                "total_cost": shot_data.get("total_cost", 0) if shot_data else 0,
                "has_plan": shot_data is not None,
            })
        return JSONResponse({"episodes": episodes, "season_total_cost": 0})

    budget = store.budget_summary()
    return JSONResponse(budget)


@router.get("/api/board/{episode}")
def api_board_episode(episode: str, project: str = Query(None), coverage: str = Query("true")):
    """Scene-level drill-down for an episode."""
    p = get_project(project)
    pp = _paths_for_project(p)
    pdir = pp["plans_dir"]
    store = get_store(p)

    # Normalize episode_id (accept ep_001, EP001, or just "1")
    if episode.startswith("ep_"):
        ep_num = get_episode_number(episode)
    else:
        ep_num = int(episode.replace("EP", ""))
    episode_id = f"EP{ep_num:03d}"

    include_cov = coverage == "true"
    shots = store.get_shots_by_episode(episode_id, include_coverage=include_cov)
    summary = store.summary(episode_id)

    # Enrich shots with scene data from plan
    plan_path = pdir / f"ep_{ep_num:03d}_plan.json"
    plan_lookup = {}
    if plan_path.exists():
        try:
            plan = json.loads(plan_path.read_text(encoding="utf-8"))
            for ps in plan.get("shots", []):
                asset = ps.get("asset_data", {})
                prompt = ps.get("prompt_data", {})
                plan_lookup[ps["shot_id"]] = {
                    "scene_id": asset.get("location_id", ""),
                    "shot_type": prompt.get("shot_type", ""),
                    "origin": ps.get("origin", "script_derived"),
                }
        except (json.JSONDecodeError, IOError):
            pass

    for shot in shots:
        extra = plan_lookup.get(shot["shot_id"], {})
        shot["scene_id"] = extra.get("scene_id", "")
        shot["shot_type"] = extra.get("shot_type", "")
        shot["origin"] = extra.get("origin", "script_derived")

        # Populate hero_frame in gate_results when missing
        gate = shot.get("gate_results") or {}
        if not gate.get("hero_frame"):
            _is_img = lambda p: p and not p.endswith((".mp4", ".webm"))
            fallback = ""
            # 1. keyframe_path from gate (image only)
            kp = gate.get("keyframe_path", "")
            if _is_img(kp):
                fallback = kp
            # 2. output_path (image only — skip video files)
            if not fallback:
                op = shot.get("output_path", "")
                if _is_img(op):
                    fallback = op
            # 3. Last non-rejected take (image only)
            if not fallback:
                for t in reversed(shot.get("takes") or []):
                    fp = t.get("file_path", "")
                    if _is_img(fp) and not t.get("rejected"):
                        fallback = fp
                        break
            if fallback:
                gate["hero_frame"] = fallback
                shot["gate_results"] = gate

    return JSONResponse({
        "episode_id": episode_id,
        "summary": summary,
        "shots": shots,
    })


# ── Budget ────────────────────────────────────────────────────────

@router.get("/api/budget")
def api_budget(project: str = Query(None)):
    """Season-level cost aggregation from SQLite."""
    p = get_project(project)
    store = get_store(p)
    return JSONResponse(store.budget_summary())


@router.get("/api/studio-budget")
def api_studio_budget():
    """Cross-project budget aggregation."""
    try:
        from recoil.execution.execution_store import global_budget_summary
        data = global_budget_summary()
        return JSONResponse(data)
    except Exception as e:
        return JSONResponse({"error": str(e)}, status_code=500)


# ── Bible ─────────────────────────────────────────────────────────

@router.get("/api/bible")
def api_bible(project: str = Query(None)):
    """Return the GlobalBible JSON."""
    p = get_project(project)
    pp = _paths_for_project(p)
    bp = pp["bible_path"]
    if not bp.exists():
        return JSONResponse({"error": "GlobalBible not found. Run Stage 1 first."}, status_code=404)
    try:
        bible = json.loads(bp.read_text(encoding="utf-8"))
        return JSONResponse(bible)
    except (json.JSONDecodeError, IOError) as e:
        return JSONResponse({"error": f"Could not read GlobalBible: {e}"}, status_code=500)


@router.patch("/api/bible/character/{char_id}")
def api_bible_character_patch(
    char_id: str,
    body: dict = Body(default={}),
    project: str = Query(None),
):
    """Manually override character traits in the GlobalBible."""
    p = get_project(project)
    pp = _paths_for_project(p)
    bible_path = pp["bible_path"]
    if not bible_path.exists():
        return JSONResponse({"error": "GlobalBible not found"}, status_code=404)
    try:
        bible = json.loads(bible_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, IOError):
        return JSONResponse({"error": "Could not read GlobalBible"}, status_code=500)

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

    # Merge overrides
    for key, value in body.items():
        characters[char_id][key] = value
    characters[char_id]["_manual_override"] = True
    characters[char_id]["_override_at"] = time.time()

    bible["characters"] = characters
    bible_path.write_text(json.dumps(bible, indent=2), encoding="utf-8")

    return JSONResponse({"char_id": char_id, "updated": list(body.keys())})


@router.patch("/api/bible/aesthetic-directives")
def api_bible_aesthetic_directives_patch(
    body: dict = Body(default={}),
    project: str = Query(None),
):
    """Update show-level aesthetic directives in the GlobalBible."""
    p = get_project(project)
    pp = _paths_for_project(p)
    bible_path = pp["bible_path"]
    if not bible_path or not bible_path.exists():
        return JSONResponse({"error": "GlobalBible not found"}, status_code=404)
    try:
        bible = json.loads(bible_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, IOError):
        return JSONResponse({"error": "Could not read GlobalBible"}, status_code=500)

    directives = bible.get("aesthetic_directives", {})
    for key, value in body.items():
        directives[key] = value
    bible["aesthetic_directives"] = directives
    bible_path.write_text(json.dumps(bible, indent=2, ensure_ascii=False), encoding="utf-8")

    return JSONResponse({"updated": list(body.keys()), "aesthetic_directives": directives})


# ── Stale Check ───────────────────────────────────────────────────

@router.get("/api/stale-check/{ep_id}")
def api_stale_check(ep_id: str, project: str = Query(None)):
    """Compare source_hash in plan vs current screenplay text."""
    p = get_project(project)
    pp = _paths_for_project(p)

    # Normalize
    if ep_id.startswith("ep_"):
        ep_num = get_episode_number(ep_id)
        episode_id = f"EP{ep_num:03d}"
    else:
        episode_id = ep_id

    ep_dir = f"ep_{int(episode_id.replace('EP', '')):03d}"
    shot_data = load_shot_data(ep_dir, frames_dir=pp["frames_dir"], plans_dir=pp["plans_dir"])
    if shot_data is None:
        return JSONResponse({"error": f"No plan or log for {episode_id}"}, status_code=404)

    stored_hash = shot_data.get("source_hash")
    if not stored_hash:
        return JSONResponse({
            "episode_id": episode_id,
            "stale": None,
            "message": "No source_hash in plan",
        })

    # Try to read current screenplay from Recoil
    try:
        from recoil.core.paths import RECOIL_ROOT
        screenplay_dir = RECOIL_ROOT / "screenplays"
        candidates = list(screenplay_dir.glob(f"*{episode_id}*")) if screenplay_dir.is_dir() else []
        if not candidates:
            candidates = list(screenplay_dir.glob("*.fountain")) if screenplay_dir.is_dir() else []

        current_hash = None
        for fp in candidates:
            text = fp.read_text(encoding="utf-8")
            current_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
            break

        if current_hash is None:
            return JSONResponse({
                "episode_id": episode_id,
                "stale": None,
                "message": "Could not find screenplay to compare",
            })

        is_stale = current_hash != stored_hash
        return JSONResponse({
            "episode_id": episode_id,
            "stale": is_stale,
            "stored_hash": stored_hash,
            "current_hash": current_hash,
        })
    except Exception as e:
        return JSONResponse({
            "episode_id": episode_id,
            "stale": None,
            "message": f"Error checking staleness: {e}",
        })


# ── Manifest / Plan / Cost ────────────────────────────────────────

@router.get("/api/manifest/{ep_dir}")
def api_manifest(ep_dir: str, project: str = Query(None)):
    """Return plan for an episode (alias for /api/plan)."""
    return _api_plan_impl(ep_dir, project)


@router.get("/api/plan/{ep_dir}")
def api_plan(ep_dir: str, project: str = Query(None)):
    """Return plan for an episode, merged with any generation log data."""
    return _api_plan_impl(ep_dir, project)


def _api_plan_impl(ep_dir: str, project: str = None):
    """Shared implementation for /api/manifest and /api/plan."""
    p = get_project(project)
    pp = _paths_for_project(p)
    fdir = pp["frames_dir"]
    pdir = pp["plans_dir"]

    # Always load plan first (has shots), then merge log data on top
    plan_name = ep_dir if ep_dir.startswith("ep_") else f"ep_{ep_dir}"
    plan_data = None
    if pdir:
        plan_path = pdir / f"{plan_name}_plan.json"
        if plan_path.exists():
            try:
                with open(plan_path) as f:
                    plan_data = json.load(f)
            except (json.JSONDecodeError, IOError):
                pass

    # Merge generation log (prompt overrides, etc.) on top of plan
    if fdir:
        log_path = fdir / ep_dir / "log.json"
        if log_path.exists():
            try:
                with open(log_path) as f:
                    log_data = json.load(f)
                if plan_data:
                    # Merge log keys into plan without clobbering shots
                    for k, v in log_data.items():
                        if k != "shots":
                            plan_data[k] = v
                else:
                    plan_data = log_data
            except (json.JSONDecodeError, IOError):
                pass

    if plan_data is None:
        return JSONResponse({"error": f"No plan or log found for {ep_dir}"}, status_code=404)
    return JSONResponse(plan_data)


@router.get("/api/cost/{ep_dir}")
def api_cost(ep_dir: str, project: str = Query(None)):
    """Return cost_log.json for an episode."""
    p = get_project(project)
    pp = _paths_for_project(p)
    fdir = pp["frames_dir"]
    cost_log = load_cost_log(ep_dir, frames_dir=fdir)
    if cost_log is None:
        return JSONResponse({"error": f"No cost_log.json for {ep_dir}"}, status_code=404)
    return JSONResponse(cost_log)


# ── Project-scoped content routes ─────────────────────────────────

@router.get("/api/project/{name}/episodes/{ep}/content")
def api_episode_content(name: str, ep: str):
    """Serve episode markdown content as JSON."""
    project_dir = projects_root() / name
    if not project_dir.is_dir():
        return JSONResponse({"error": f"Project not found: {name}"}, status_code=404)
    ep_file = project_dir / "episodes" / f"{ep}.md"
    if ep_file.is_file():
        content = ep_file.read_text(encoding="utf-8")
        return JSONResponse({"content": content})
    return JSONResponse({"error": f"Episode not found: {ep}"}, status_code=404)


@router.get("/api/project/{name}/episodes/{ep}/annotations")
def api_episode_annotations_get(name: str, ep: str):
    """Serve annotations JSON sidecar."""
    project_dir = projects_root() / name
    if not project_dir.is_dir():
        return JSONResponse({"error": f"Project not found: {name}"}, status_code=404)
    ann_file = project_dir / "episodes" / f"{ep}.annotations.json"
    if ann_file.is_file():
        try:
            data = json.loads(ann_file.read_text(encoding="utf-8"))
            return JSONResponse(data)
        except (json.JSONDecodeError, IOError):
            return JSONResponse({"annotations": [], "edits": []})
    return JSONResponse({"annotations": [], "edits": []})


@router.post("/api/project/{name}/episodes/{ep}/annotations")
def api_episode_annotations_post(name: str, ep: str, body: dict = Body(default={})):
    """Save annotations JSON sidecar."""
    project_dir = projects_root() / name
    ann_file = project_dir / "episodes" / f"{ep}.annotations.json"
    ann_file.parent.mkdir(parents=True, exist_ok=True)
    with open(ann_file, "w", encoding="utf-8") as f:
        json.dump(body, f, indent=2, ensure_ascii=False)
    return JSONResponse({"status": "saved", "path": str(ann_file.relative_to(projects_root()))})


@router.post("/api/project/{name}/episodes/{ep}/content")
def api_episode_content_post(name: str, ep: str, body: dict = Body(default={})):
    """Save episode markdown content."""
    project_dir = projects_root() / name
    ep_file = project_dir / "episodes" / f"{ep}.md"
    ep_file.parent.mkdir(parents=True, exist_ok=True)
    content = body.get("content", "")
    ep_file.write_text(content, encoding="utf-8")
    return JSONResponse({"status": "saved", "path": str(ep_file.relative_to(projects_root()))})


@router.get("/api/project/{name}/fountain/{filename}")
def api_fountain(name: str, filename: str):
    """Serve a .fountain file as JSON."""
    project_dir = projects_root() / name
    if not project_dir.is_dir():
        return JSONResponse({"error": f"Project not found: {name}"}, status_code=404)
    ft_path = project_dir / filename
    if ft_path.is_file() and ft_path.suffix == ".fountain":
        content = ft_path.read_text(encoding="utf-8")
        return JSONResponse({"content": content})
    return JSONResponse({"error": f"Fountain file not found: {filename}"}, status_code=404)


@router.get("/api/project/{name}/visual-bible")
def api_visual_bible(name: str):
    """Serve the visual_bible.md for a project."""
    project_dir = projects_root() / name
    if not project_dir.is_dir():
        return JSONResponse({"error": f"Project not found: {name}"}, status_code=404)
    vb_path = project_dir / "visual_bible.md"
    if vb_path.is_file():
        content = vb_path.read_text(encoding="utf-8")
        return JSONResponse({"content": content})
    return JSONResponse({"error": "No visual bible found"}, status_code=404)


# ── Bible Visual Sync ─────────────────────────────────────────────

@router.post("/api/project/{name}/bible/propose-visual-sync")
def api_propose_visual_sync(name: str, body: dict = Body(default={})):
    """Vision AI analyzes an approved image and proposes bible text updates.

    Body: { char_id, phase_id (optional), image_path, current_text, sync_type }
    Returns: { proposed_changes: {...}, sync_type }
    """
    from recoil.pipeline._lib.visual_sync import propose_visual_sync

    pp = _paths_for_project(name)
    char_id = body.get("char_id")
    image_rel = body.get("image_path", "")
    current_text = body.get("current_text", {})
    sync_type = body.get("sync_type", "phase")

    if not char_id or not image_rel:
        return JSONResponse({"error": "char_id and image_path required"}, status_code=400)

    # Resolve image path
    image_abs = str(_resolve_output_rel(image_rel, pp))

    if not Path(image_abs).exists():
        return JSONResponse({"error": f"Image not found: {image_rel}"}, status_code=404)

    try:
        proposed = propose_visual_sync(image_abs, current_text, sync_type)
        return JSONResponse({"proposed_changes": proposed, "sync_type": sync_type})
    except Exception as e:
        return JSONResponse({"error": str(e)}, status_code=500)


# ── Launch Batch ──────────────────────────────────────────────────

@router.post("/api/launch-batch")
def api_launch_batch(body: dict = Body(default={}), project: str = Query(None)):
    """Run PreFlightChecker + return cost estimate (no generation yet)."""
    p = get_project(project)
    pp = _paths_for_project(p)
    episode_id = body.get("episode_id")
    if not episode_id:
        return JSONResponse({"error": "Missing episode_id"}, status_code=400)

    # Load plan or generation log
    ep_dir = f"ep_{int(episode_id.replace('EP', '')):03d}"
    plan = load_shot_data(ep_dir, frames_dir=pp["frames_dir"], plans_dir=pp["plans_dir"])
    if plan is None:
        # Try plan by episode ID
        plan_path = pp["plans_dir"] / f"{episode_id}.json"
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                pass

    if plan is None:
        return JSONResponse({"error": f"No plan or log found for {episode_id}"}, status_code=404)

    # Run PreFlightChecker
    try:
        from recoil.pipeline._lib.preflight import PreFlightChecker
        checker = PreFlightChecker()
        warnings = checker.validate_batch(plan, project=p)
        cost_estimate = checker.estimate_cost(plan)

        return JSONResponse({
            "episode_id": episode_id,
            "warnings": [
                {"shot_id": w.shot_id, "severity": w.severity,
                 "check": w.check, "message": w.message}
                for w in warnings
            ],
            "cost_estimate": {
                "total": cost_estimate.total,
                "previs": cost_estimate.previs,
                "keyframes": cost_estimate.keyframes,
                "video": cost_estimate.video,
                "qc": cost_estimate.qc,
                "per_shot": cost_estimate.per_shot,
            },
            "errors": len([w for w in warnings if w.severity == "error"]),
            "can_launch": all(w.severity != "error" for w in warnings),
        })
    except ImportError:
        return JSONResponse({"error": "PreFlightChecker not available"}, status_code=503)


@router.post("/api/launch-batch/confirm")
def api_launch_batch_confirm(body: dict = Body(default={}), project: str = Query(None)):
    """Actually start generation after preflight approval."""
    p = get_project(project)
    episode_id = body.get("episode_id")
    if not episode_id:
        return JSONResponse({"error": "Missing episode_id"}, status_code=400)

    # Queue the batch for processing
    store = get_store(p)

    shots = store.get_shots_by_episode(episode_id)

    # Guard against double-queue: reject if shots already generating
    already_generating = [s for s in shots if s["status"] == "previs_generating"]
    if already_generating:
        return JSONResponse({
            "error": f"Batch already in progress — {len(already_generating)} shots generating",
        }, status_code=409)

    queued = 0
    for shot in shots:
        if shot["status"] == "previs_pending":
            store.update_shot(shot["shot_id"], status="previs_generating")
            queued += 1

    return JSONResponse({
        "episode_id": episode_id,
        "queued": queued,
        "message": f"Queued {queued} shots for generation",
    })
