# api/routes/dailies.py
"""Dailies endpoints — Phase 3: priority queue, video clips, take actions,
legacy review endpoints (frames, previs, accept, reject, promote, approve-previs).

Mechanical port from editors/review_server.py.
"""

import json
import os
import re
import shutil
import time
from pathlib import Path

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

from ..deps import (
    get_project,
    get_paths,
    get_store,
    get_runner,
    _paths_for_project,
)
from ..state import PROJECT_ROOT
from recoil.core.paths import ProjectPaths
from recoil.execution.execution_store import InvalidTransitionError
from recoil.core.exceptions import RecommendationsCorruptError
from recoil.pipeline._lib.take_keys import TakeNumberMissingError, read_take_number
from recoil.pipeline.core.cost import read_cost_from_record_safe

import logging as _logging

_log = _logging.getLogger(__name__)

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

# ── 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


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


def get_episode_dirs(frames_dir=None):
    """Scan sequences/ for ep_* directories, return sorted list."""
    if not frames_dir or not frames_dir.is_dir():
        return []
    dirs = []
    for d in sorted(frames_dir.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."""
    ep_path = frames_dir / 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(output_dir)
                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, project=None):
    """Load shot data for an episode (generation log or plan)."""
    if frames_dir is None or plans_dir is None:
        pp = _paths_for_project(project)
        if frames_dir is None:
            frames_dir = pp["frames_dir"]
        if plans_dir is None:
            plans_dir = pp["plans_dir"]

    if frames_dir:
        gen_path = frames_dir / 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 plans_dir:
        plan_name = ep_dir if ep_dir.startswith("ep_") else f"ep_{ep_dir}"
        plan_path = plans_dir / 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 save_log(ep_dir, log_data, frames_dir=None, project=None):
    """Write generation log.json for an episode."""
    if frames_dir is None:
        frames_dir = _paths_for_project(project)["frames_dir"]
    log_path = frames_dir / ep_dir / "log.json"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with open(log_path, "w") as f:
        json.dump(log_data, f, indent=2)


def load_cost_log(ep_dir, frames_dir=None):
    """Load cost_log.json for an episode, return dict or None."""
    cost_path = frames_dir / 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


# ── Video bin helpers (from review_server.py lines 2547-2566) ──────


def _video_bin_path(project):
    pp = _paths_for_project(project)
    return pp["state_dir"] / "video_bin.json"


def _load_video_bin(project):
    bp = _video_bin_path(project)
    if bp.exists():
        try:
            return set(json.loads(bp.read_text(encoding="utf-8")))
        except (json.JSONDecodeError, OSError):
            pass
    return set()


def _save_video_bin(project, binned: set):
    bp = _video_bin_path(project)
    bp.parent.mkdir(parents=True, exist_ok=True)
    bp.write_text(json.dumps(sorted(binned)), encoding="utf-8")


# ══════════════════════════════════════════════════════════════════════
# DAILIES ENDPOINTS
# ══════════════════════════════════════════════════════════════════════


@router.get("/api/dailies")
def dailies_list(
    project: str = Depends(get_project),
    paths: dict = Depends(get_paths),
    store=Depends(get_store),
):
    """Priority queue of all action-required items across episodes."""
    items = []

    # P1: DEAD (failed) + Gate 2 semantic fails
    dead_shots = store.get_shots_by_status("failed")
    for shot in dead_shots:
        items.append(
            {
                "priority": 1,
                "shot_id": shot["shot_id"],
                "episode_id": shot["episode_id"],
                "status": shot["status"],
                "error": shot.get("error_message", ""),
                "pipeline": shot.get("pipeline", ""),
                "model": shot.get("model", ""),
                "actions": ["override", "reroute", "abandon"],
            }
        )

    # P2: Gate 3 video drift (video_ready but needs review)
    drift_shots = store.get_shots_by_status("video_ready")
    for shot in drift_shots:
        items.append(
            {
                "priority": 2,
                "shot_id": shot["shot_id"],
                "episode_id": shot["episode_id"],
                "status": shot["status"],
                "output_path": shot.get("output_path", ""),
                "takes": shot.get("takes", []),
                "actions": ["approve", "reject"],
            }
        )

    # P3: Previs / keyframe approvals (skip shots with no output)
    approval_shots = store.get_shots_by_status("previs_generated", "keyframe_generated")
    for shot in approval_shots:
        takes = shot.get("takes", [])
        output = shot.get("output_path", "")
        if not output and not takes:
            continue
        prompt = ""
        if takes:
            prompt = takes[-1].get("prompt", "")
        items.append(
            {
                "priority": 3,
                "shot_id": shot["shot_id"],
                "episode_id": shot["episode_id"],
                "status": shot["status"],
                "output_path": output,
                "prompt": prompt,
                "takes": takes,
                "actions": ["approve", "reject"],
            }
        )

    # P4: Take selection (multiple takes available)
    take_shots = store.get_shots_by_status("video_complete")
    for shot in take_shots:
        takes = shot.get("takes", [])
        if len(takes) > 1:
            items.append(
                {
                    "priority": 4,
                    "shot_id": shot["shot_id"],
                    "episode_id": shot["episode_id"],
                    "status": shot["status"],
                    "takes": takes,
                    "actions": ["select-take"],
                }
            )

    # P5: Approved/locked shots (for bin review + unlock)
    approved_shots = store.get_shots_by_status("previs_approved", "keyframe_approved")
    for shot in approved_shots:
        takes = shot.get("takes", [])
        prompt = ""
        if takes:
            prompt = takes[-1].get("prompt", "")
        items.append(
            {
                "priority": 5,
                "shot_id": shot["shot_id"],
                "episode_id": shot["episode_id"],
                "status": "approved",
                "output_path": shot.get("output_path", ""),
                "prompt": prompt,
                "takes": takes,
                "actions": ["unlock"],
            }
        )

    # Sort by priority
    items.sort(key=lambda x: (x["priority"], x["episode_id"], x["shot_id"]))

    needs_action = sum(1 for i in items if i["priority"] <= 4)
    return JSONResponse(
        {"items": items, "total": len(items), "needs_action": needs_action}
    )


@router.get("/api/dailies/videos")
def dailies_videos(
    project: str = Depends(get_project),
    paths: dict = Depends(get_paths),
    store=Depends(get_store),
):
    """Return only video clips for the video-only dailies tab."""
    binned_set = _load_video_bin(project)

    clips = []
    video_shots = store.get_shots_by_status("video_complete", "video_pending")
    _VIDEO_PIPELINES = {
        "video",
        "i2v",
        "t2v",
        "multi_shot",
        "coverage",
        "action",
        "choreography",
        "constraint",
        "sequence",
    }

    for shot in video_shots:
        gate = shot.get("gate_results", {}) or {}
        poster = gate.get("hero_frame") or gate.get("first_frame") or ""
        prompt = shot.get("prompt", "")
        takes = shot.get("takes", [])
        if takes and not prompt:
            prompt = takes[-1].get("prompt", "")
        model = shot.get("model", shot.get("pipeline", ""))

        # Find source keyframe
        source_frame = ""
        if gate.get("hero_frame"):
            source_frame = gate["hero_frame"]
        if not source_frame:
            kf_takes = [
                t
                for t in takes
                if t.get("layer") == "keyframe" and not t.get("rejected")
            ]
            if kf_takes:
                source_frame = kf_takes[-1].get("file_path", "")
        if not source_frame:
            approved_takes = [
                t for t in takes if t.get("approved") and not t.get("layer")
            ]
            if approved_takes:
                source_frame = approved_takes[-1].get("file_path", "")
        if not source_frame and paths:
            parts = shot["shot_id"].split("_SH")
            if len(parts) == 2:
                ep_num = parts[0].replace("EP", "").lstrip("0") or "0"
                ep_part = f"ep_{int(ep_num):03d}"
                frames_dir = paths["frames_dir"] / ep_part
                if frames_dir.exists():
                    sta_pattern = f"STA_*{shot['shot_id']}.*"
                    sta_files = sorted(frames_dir.glob(sta_pattern))
                    if sta_files:
                        source_frame = str(
                            sta_files[-1].relative_to(paths["project_dir"])
                        )
        if not source_frame:
            source_frame = poster

        # Primary: read from takes[] in store
        video_takes = [
            t
            for t in takes
            if t.get("pipeline") in _VIDEO_PIPELINES
            or t.get("layer") == "video"
            or (t.get("file_path", "").endswith(".mp4"))
        ]

        if video_takes:
            for t in video_takes:
                file_path = t.get("file_path", "")
                try:
                    take_n = read_take_number(t)
                except TakeNumberMissingError:
                    # `take_num` is a separate legacy alias not modelled by read_take_number.
                    take_n = t.get("take_num", 0)
                t_pipeline = t.get("pipeline", "")
                if not t_pipeline or t_pipeline == "video":
                    t_pipeline = "t2v" if "_t2v" in file_path else "i2v"
                clips.append(
                    {
                        "shot_id": shot["shot_id"],
                        "episode_id": shot["episode_id"],
                        "video_path": file_path,
                        "poster": poster,
                        "source_frame": source_frame if t_pipeline != "t2v" else "",
                        "model": t.get("model", model),
                        "prompt": t.get("prompt_used", t.get("prompt", prompt)),
                        "pipeline": t_pipeline,
                        "status": shot["status"],
                        "approved": shot.get("video_approved", False),
                        "rejected": shot.get("video_rejected", False),
                        "cost": read_cost_from_record_safe(t) or t.get("cost", 0),
                        "take_num": take_n,
                        "binned": file_path in binned_set,
                    }
                )
        else:
            # Fallback: scan disk for legacy shots without takes[]
            parts = shot["shot_id"].split("_SH")
            if len(parts) == 2:
                ep_num = parts[0].replace("EP", "").lstrip("0") or "0"
                ep_part = f"ep_{int(ep_num):03d}"
                sh_match = re.match(r"(\d+)(.*)", parts[1])
                if sh_match:
                    sh_num = int(sh_match.group(1))
                    sh_suffix = sh_match.group(2).lower()
                    base_name = f"shot_{sh_num:03d}{sh_suffix}"
                else:
                    base_name = f"shot_{parts[1].lower()}"
                video_dir = paths["video_dir"] / ep_part
            else:
                video_dir = paths["video_dir"]
                base_name = shot["shot_id"]

            video_files = []
            if video_dir.exists():
                candidates = []
                for ext in VIDEO_EXTS:
                    candidates.extend(video_dir.glob(f"{base_name}*{ext}"))
                    candidates.extend(video_dir.glob(f"{shot['shot_id']}*{ext}"))
                seen = set()
                deduped = []
                for c in candidates:
                    if c not in seen:
                        seen.add(c)
                        deduped.append(c)
                deduped.sort(key=lambda p: p.name)
                for vf in deduped:
                    stem = vf.stem
                    rest_base = (
                        stem[len(base_name) :] if stem.startswith(base_name) else None
                    )
                    rest_shot = (
                        stem[len(shot["shot_id"]) :]
                        if stem.startswith(shot["shot_id"])
                        else None
                    )
                    if (
                        rest_base is not None
                        and (rest_base == "" or rest_base.startswith("_"))
                    ) or (
                        rest_shot is not None
                        and (rest_shot == "" or rest_shot.startswith("_"))
                    ):
                        video_files.append(vf)

            if video_files:
                for vf in video_files:
                    rel = str(vf.relative_to(paths["project_dir"]))
                    m = re.search(r"_take(\d+)\.mp4$", vf.name)
                    take_n = int(m.group(1)) if m else 0
                    vf_pipeline = "t2v" if "_t2v" in vf.name else "i2v"
                    clips.append(
                        {
                            "shot_id": shot["shot_id"],
                            "episode_id": shot["episode_id"],
                            "video_path": rel,
                            "poster": poster,
                            "source_frame": source_frame
                            if vf_pipeline != "t2v"
                            else "",
                            "model": model or "",
                            "prompt": prompt,
                            "pipeline": vf_pipeline,
                            "status": shot["status"],
                            "approved": shot.get("video_approved", False),
                            "rejected": shot.get("video_rejected", False),
                            "cost": shot.get("cost_incurred", 0),
                            "take_num": take_n,
                            "binned": rel in binned_set,
                        }
                    )
            else:
                video_path = gate.get("video_path", "")
                if video_path:
                    gp = "t2v" if "_t2v" in video_path else "i2v"
                    clips.append(
                        {
                            "shot_id": shot["shot_id"],
                            "episode_id": shot["episode_id"],
                            "video_path": video_path,
                            "poster": poster,
                            "source_frame": source_frame if gp != "t2v" else "",
                            "model": model or "",
                            "prompt": prompt,
                            "pipeline": gp,
                            "status": shot["status"],
                            "approved": shot.get("video_approved", False),
                            "rejected": shot.get("video_rejected", False),
                            "cost": shot.get("cost_incurred", 0),
                            "take_num": 1,
                            "binned": video_path in binned_set,
                        }
                    )

    # Catch-all: pick up ANY .mp4 in video dirs not already tracked
    tracked_paths = {c["video_path"] for c in clips}
    if paths:
        vdir = paths["video_dir"]
        for ep_dir in sorted(vdir.iterdir()) if vdir.exists() else []:
            if not ep_dir.is_dir():
                continue
            for vf in sorted(ep_dir.glob("*.mp4")):
                rel = str(vf.relative_to(paths["project_dir"]))
                if rel in tracked_paths:
                    continue
                m = re.search(r"_take(\d+)\.mp4$", vf.name)
                take_n = int(m.group(1)) if m else 0
                clips.append(
                    {
                        "shot_id": vf.stem.split("_take")[0].split("_t2v")[0],
                        "episode_id": ep_dir.name.upper()
                        .replace("EP_", "EP")
                        .replace("EP0", "EP00")[:5]
                        or "EP001",
                        "video_path": rel,
                        "poster": "",
                        "source_frame": "",
                        "model": "unknown",
                        "prompt": "",
                        "pipeline": "untracked",
                        "status": "video_complete",
                        "approved": False,
                        "rejected": False,
                        "cost": 0,
                        "take_num": take_n,
                        "binned": rel in binned_set,
                        "is_coverage": "coverage" in vf.name,
                        "framing": "",
                    }
                )

    clips.sort(
        key=lambda x: (
            x.get("episode_id", ""),
            x.get("shot_id", ""),
            x.get("take_num", 0),
        ),
        reverse=True,
    )
    binned_count = sum(1 for c in clips if c.get("binned"))
    return JSONResponse(
        {"clips": clips, "total": len(clips), "binned_count": binned_count}
    )


@router.get("/api/dailies/filmstrip/{episode_id}/{shot_id}")
def dailies_filmstrip(
    episode_id: str,
    shot_id: str,
    coverage: str = Query("true"),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Return prev/current/next shot frames for filmstrip continuity view."""
    include_cov = coverage == "true"
    shots = store.get_shots_by_episode(episode_id, include_coverage=include_cov)
    if not shots:
        return JSONResponse(
            {"error": f"No shots found for {episode_id}"}, status_code=404
        )

    current_idx = None
    for i, s in enumerate(shots):
        if s["shot_id"] == shot_id:
            current_idx = i
            break

    if current_idx is None:
        return JSONResponse(
            {"error": f"Shot {shot_id} not found in {episode_id}"}, status_code=404
        )

    def shot_frame(shot_dict):
        """Extract frame summary from a shot record."""
        takes = shot_dict.get("takes", [])
        frame_path = shot_dict.get("output_path", "")
        if takes:
            for t in reversed(takes):
                if t.get("file_path") and not t.get("rejected"):
                    frame_path = t["file_path"]
                    break
        return {
            "shot_id": shot_dict["shot_id"],
            "status": shot_dict.get("status", ""),
            "frame_path": frame_path,
            "take_count": len(takes),
        }

    result = {
        "episode_id": episode_id,
        "current": shot_frame(shots[current_idx]),
        "prev": shot_frame(shots[current_idx - 1]) if current_idx > 0 else None,
        "next": shot_frame(shots[current_idx + 1])
        if current_idx < len(shots) - 1
        else None,
    }
    return JSONResponse(result)


# ══════════════════════════════════════════════════════════════════════
# DAILIES ACTIONS (POST)
# ══════════════════════════════════════════════════════════════════════


@router.post("/api/dailies/approve")
def dailies_approve(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    paths: dict = Depends(get_paths),
    store=Depends(get_store),
):
    """Approve a previs/keyframe/video shot."""
    shot_id = body.get("shot_id")
    if not shot_id:
        return JSONResponse({"error": "Missing shot_id"}, status_code=400)

    shot = store.get_shot(shot_id)
    if shot is None:
        return JSONResponse({"error": f"Shot not found: {shot_id}"}, status_code=404)

    status_map = {
        "previs_generated": "previs_approved",
        "previs_approved": "previs_approved",
        "keyframe_generated": "keyframe_approved",
        "keyframe_approved": "keyframe_approved",
        "video_pending": "previs_approved",
        "video_complete": "approved",
        "video_ready": "video_complete",
    }
    cur = shot["status"]
    new_status = status_map.get(cur)
    if new_status is None:
        return JSONResponse(
            {"error": f"Cannot approve shot in status '{cur}'"},
            status_code=400,
        )
    if new_status == cur:
        return JSONResponse({"shot_id": shot_id, "new_status": cur, "already": True})

    try:
        ep_num = int(shot["episode_id"].replace("EP", ""))
        runner = get_runner(project, ep_num)
        runner.transition(shot_id, new_status, reason=f"Console approve from {cur}")
    except InvalidTransitionError as e:
        return JSONResponse({"error": str(e), "status": 409}, status_code=409)

    # Write approval to Layer 1 generation log
    ep_dir = f"ep_{ep_num:03d}"
    log = load_shot_data(ep_dir, project=project) or {"episode": ep_num}
    approvals = log.setdefault("human_approvals", {})
    approvals.setdefault(shot_id, {})
    stage = shot["status"].split("_")[0]  # previs, keyframe, video
    approvals[shot_id][f"{stage}_approved"] = True
    approvals[shot_id][f"{stage}_approved_at"] = time.time()
    save_log(ep_dir, log, project=project)

    # Write to editorial feedback log (Layer 1)
    approval_tags = body.get("tags", [])
    approval_notes = body.get("notes", "")
    if approval_tags or approval_notes:
        from recoil.pipeline._lib.feedback_logger import log_editorial_decision

        log_editorial_decision(
            project=project,
            episode_id=shot.get("episode_id", ""),
            shot_id=shot_id,
            decision="approve",
            stage=stage,
            tags=approval_tags,
            notes=approval_notes,
        )

    # Copy approved video to NLE-ready export folder
    approved_path = None
    if new_status == "approved":
        gate = shot.get("gate_results", {})
        video_rel = gate.get("video_path", "")
        if video_rel:
            video_abs = (
                paths["project_dir"] / video_rel
                if not Path(video_rel).is_absolute()
                else Path(video_rel)
            )
            if video_abs.is_file():
                approved_dir = paths["output_dir"] / "approved" / ep_dir
                approved_dir.mkdir(parents=True, exist_ok=True)
                dest = approved_dir / f"{shot_id}{video_abs.suffix}"
                shutil.copy2(str(video_abs), str(dest))
                approved_path = str(dest)
                print(f"  [APPROVE] {shot_id}: copied to {dest}")
            else:
                print(f"  [APPROVE] {shot_id}: video file not found at {video_abs}")

    return JSONResponse(
        {
            "shot_id": shot_id,
            "new_status": new_status,
            "approved_path": approved_path,
        }
    )


@router.post("/api/dailies/reject")
def dailies_reject(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Reject and re-queue a shot, with structured rejection tags."""
    shot_id = body.get("shot_id")
    if not shot_id:
        return JSONResponse({"error": "Missing shot_id"}, status_code=400)

    shot = store.get_shot(shot_id)
    if shot is None:
        return JSONResponse({"error": f"Shot not found: {shot_id}"}, status_code=404)

    status_map = {
        "previs_generated": "previs_rejected",
        "keyframe_generated": "keyframe_rejected",
        "video_complete": "video_rejected",
    }
    new_status = status_map.get(shot["status"])
    if new_status is None:
        return JSONResponse(
            {
                "error": f"Cannot reject shot in status '{shot['status']}'",
                "status": 400,
            },
            status_code=400,
        )

    # Store rejection metadata with structured override actions
    tags = body.get("tags", [])
    notes = body.get("notes", "")
    rejection_data = {}
    if tags or notes:
        from recoil.pipeline._lib.rejection import compute_rejection_overrides

        spoiler_word = body.get("spoiler_word", "")
        rejection_overrides = compute_rejection_overrides(
            tags, spoiler_word=spoiler_word
        )

        rejection_data = {
            "last_rejection": {
                "tags": tags,
                "notes": notes,
                "at": time.time(),
                "from_status": shot["status"],
                "overrides": rejection_overrides,
            }
        }

    try:
        ep_num = int(shot["episode_id"].replace("EP", ""))
        runner = get_runner(project, ep_num)
        runner.transition(
            shot_id, new_status, reason=f"Console reject: {','.join(tags)}"
        )
        if rejection_data:
            store.update_shot(shot_id, gate_results=rejection_data)
    except InvalidTransitionError as e:
        return JSONResponse({"error": str(e), "status": 409}, status_code=409)

    # Append to JSONL analytics log
    if tags or notes:
        log_path = (
            ProjectPaths.for_project(project).visual_state_dir
            / "rejection_log.jsonl"
        )
        log_path.parent.mkdir(parents=True, exist_ok=True)
        attempt_count = 0
        if log_path.exists():
            try:
                with open(log_path, "r", encoding="utf-8") as f:
                    for line in f:
                        try:
                            entry = json.loads(line)
                            if entry.get("shot_id") == shot_id:
                                attempt_count += 1
                        except json.JSONDecodeError:
                            pass
            except OSError:
                pass
        log_entry = {
            "timestamp": time.time(),
            "episode_id": shot.get("episode_id", ""),
            "shot_id": shot_id,
            "tags": tags,
            "attempt_count": attempt_count + 1,
            "from_status": shot["status"],
        }
        try:
            with open(log_path, "a", encoding="utf-8") as f:
                f.write(json.dumps(log_entry) + "\n")
        except OSError:
            pass  # Non-fatal: analytics logging should not block rejection

    # Write to editorial feedback log (Layer 1)
    if tags or notes:
        from recoil.pipeline._lib.feedback_logger import log_editorial_decision

        log_editorial_decision(
            project=project,
            episode_id=shot.get("episode_id", ""),
            shot_id=shot_id,
            decision="reject",
            stage=shot["status"].split("_")[0],
            tags=tags,
            notes=notes,
        )

    return JSONResponse({"shot_id": shot_id, "new_status": new_status, "tags": tags})


@router.post("/api/dailies/override")
def dailies_override(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Save manual_prompt_override to Layer 1 generation log."""
    shot_id = body.get("shot_id")
    prompt = body.get("prompt")
    if not shot_id or not prompt:
        return JSONResponse({"error": "Missing shot_id or prompt"}, status_code=400)

    shot = store.get_shot(shot_id)
    if shot:
        ep_num = int(shot["episode_id"].replace("EP", ""))
        ep_dir = f"ep_{ep_num:03d}"
        log = load_shot_data(ep_dir, project=project) or {"episode": ep_num}
        overrides = log.setdefault("manual_prompt_overrides", {})
        overrides[shot_id] = {
            "prompt": prompt,
            "override_at": time.time(),
        }
        save_log(ep_dir, log, project=project)

        # Re-queue the shot
        store.force_reset_status(
            shot_id,
            "previs_pending",
            reason="dailies override — prompt changed by user",
        )
        return JSONResponse({"shot_id": shot_id, "overridden": True})

    return JSONResponse({"error": "Could not save override"}, status_code=500)


@router.post("/api/dailies/reroute")
def dailies_reroute(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Change shot model/pipeline."""
    shot_id = body.get("shot_id")
    model = body.get("model")
    pipeline = body.get("pipeline")
    if not shot_id:
        return JSONResponse({"error": "Missing shot_id"}, status_code=400)

    # Translate legacy model names
    LEGACY_MODEL_MAP = {
        "kling-3.0": "kling-v3",
        "kling-3.0-fal": "kling-v3",
        "kling-v3-fal": "kling-v3",
        "kling-o3-fal": "kling-o3",
    }
    updates = {}
    if model:
        model = LEGACY_MODEL_MAP.get(model, model)
        updates["model"] = model
    if pipeline:
        updates["pipeline"] = pipeline

    if updates:
        store.update_shot(shot_id, **updates)
    store.force_reset_status(
        shot_id,
        "previs_pending",
        reason="dailies reroute — model/pipeline changed by user",
    )
    return JSONResponse(
        {"shot_id": shot_id, "rerouted": True, "status": "previs_pending", **updates}
    )


@router.post("/api/dailies/abandon")
def dailies_abandon(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Remove shot from active production (mark as abandoned)."""
    shot_id = body.get("shot_id")
    if not shot_id:
        return JSONResponse({"error": "Missing shot_id"}, status_code=400)

    store.force_reset_status(shot_id, "abandoned", reason="user abandoned from dailies")
    return JSONResponse({"shot_id": shot_id, "abandoned": True})


@router.post("/api/dailies/select-take")
def dailies_select_take(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Write selected_take_id to Layer 1 generation log."""
    shot_id = body.get("shot_id")
    take_id = body.get("take_id")
    if not shot_id or not take_id:
        return JSONResponse({"error": "Missing shot_id or take_id"}, status_code=400)

    shot = store.get_shot(shot_id)
    if shot:
        # Mark selected take as approved, deselect others
        takes = shot.get("takes", [])
        for t in takes:
            if t.get("take_id") == take_id:
                t["approved"] = True
                t.pop("rejected", None)
            else:
                t.pop("approved", None)
        store.update_shot(shot_id, takes=takes)

        # Persist to Layer 1 generation log
        ep_num = int(shot["episode_id"].replace("EP", ""))
        ep_dir = f"ep_{ep_num:03d}"
        log = load_shot_data(ep_dir, project=project) or {"episode": ep_num}
        selections = log.setdefault("selected_takes", {})
        selections[shot_id] = {
            "take_id": take_id,
            "selected_at": time.time(),
        }
        save_log(ep_dir, log, project=project)
        return JSONResponse({"shot_id": shot_id, "take_id": take_id})

    return JSONResponse({"error": "Could not save take selection"}, status_code=500)


@router.post("/api/dailies/unlock")
def dailies_unlock(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Undo approval — regress status one step back."""
    shot_id = body.get("shot_id")
    if not shot_id:
        return JSONResponse({"error": "Missing shot_id"}, status_code=400)

    shot = store.get_shot(shot_id)
    if shot is None:
        return JSONResponse({"error": f"Shot not found: {shot_id}"}, status_code=404)

    UNLOCK_MAP = {
        "previs_approved": "previs_generated",
        "keyframe_generated": "previs_approved",
        "keyframe_approved": "keyframe_generated",
        "video_pending": "keyframe_approved",
    }

    current = shot["status"]
    target = UNLOCK_MAP.get(current)
    if not target:
        return JSONResponse(
            {"error": f"Cannot unlock shot in status '{current}'"},
            status_code=400,
        )

    try:
        ep_num = int(shot["episode_id"].replace("EP", ""))
        runner = get_runner(project, ep_num)
        runner.transition(shot_id, target, reason=f"Console unlock from {current}")
    except InvalidTransitionError as e:
        return JSONResponse({"error": str(e), "status": 409}, status_code=409)

    # Remove approval from Layer 1 generation log (only for previz unlock)
    if current == "previs_approved":
        ep_match = re.match(r"EP(\d+)", shot["episode_id"])
        if ep_match:
            ep_num_log = int(ep_match.group(1))
            ep_dir = f"ep_{ep_num_log:03d}"
            log = load_shot_data(ep_dir, project=project)
            if log and "human_approvals" in log:
                approvals_log = log["human_approvals"]
                if shot_id in approvals_log:
                    approvals_log[shot_id].pop("previs_approved", None)
                    approvals_log[shot_id].pop("previs_approved_at", None)
                    save_log(ep_dir, log, project=project)

    return JSONResponse({"shot_id": shot_id, "new_status": target})


@router.post("/api/dailies/mark-seen")
def dailies_mark_seen(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Record which takes have been seen by the mobile reviewer."""
    seen_takes = body.get("seen_takes", [])
    ts = body.get("timestamp", time.time())
    if not seen_takes:
        return JSONResponse({"status": "ok", "stored": 0})

    seen_file = Path(store.shots_dir) / "mobile_seen.json"
    try:
        existing = _read_recommendations_or_raise(seen_file)
    except RecommendationsCorruptError as e:
        _log.exception(
            "dailies recommendations: corrupt at %s — refusing to overwrite",
            seen_file,
        )
        return JSONResponse(
            {
                "error": "corrupt recommendations — cannot append "
                "without preserving prior context",
                "path": str(seen_file),
                "detail": str(e),
            },
            status_code=500,
        )

    for take_key in seen_takes:
        existing[take_key] = ts

    seen_file.write_text(json.dumps(existing, indent=2))
    return JSONResponse({"status": "ok", "stored": len(seen_takes)})


def _read_recommendations_or_raise(existing_path: Path) -> dict:
    """Read a dailies recommendations / mark-seen JSON, distinguishing
    "no file" (return empty dict) from "corrupt file" (raise) per Tenet 6.

    Replaces the silent ``except Exception: existing = {}`` pattern that
    would obliterate prior accept/reject context on the next write.
    """
    if not existing_path.exists():
        return {}
    try:
        return json.loads(existing_path.read_text())
    except FileNotFoundError:
        # File deleted between exists() and read_text() (TOCTOU race).
        # Treat as if it never existed — not a corruption signal.
        return {}
    except (json.JSONDecodeError, OSError) as e:
        raise RecommendationsCorruptError(str(existing_path), message=str(e)) from e


@router.post("/api/dailies/undo-reject")
def dailies_undo_reject(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Undo a rejection — set status back to *_generated."""
    shot_id = body.get("shot_id")
    if not shot_id:
        return JSONResponse({"error": "Missing shot_id"}, status_code=400)

    shot = store.get_shot(shot_id)
    if shot is None:
        return JSONResponse({"error": f"Shot not found: {shot_id}"}, status_code=404)

    undo_map = {
        "previs_rejected": "previs_generated",
        "keyframe_rejected": "keyframe_generated",
        "video_rejected": "video_complete",
    }
    new_status = undo_map.get(shot["status"])
    if not new_status:
        return JSONResponse(
            {"error": f"Cannot undo-reject shot in status '{shot['status']}'"},
            status_code=400,
        )

    try:
        store.update_shot(shot_id, status=new_status)
    except InvalidTransitionError as e:
        return JSONResponse({"error": str(e), "status": 409}, status_code=409)
    return JSONResponse({"shot_id": shot_id, "new_status": new_status})


# ── Take actions (keep, reject, restore, unkept) ──────────────────


def _dailies_take_action(body, action, project, store):
    """Mark an individual take as kept, rejected, restored, or reset."""
    shot_id = body.get("shot_id")
    take_index = body.get("take_index")
    if not shot_id or take_index is None:
        return JSONResponse({"error": "Missing shot_id or take_index"}, status_code=400)

    shot = store.get_shot(shot_id)
    if shot is None:
        return JSONResponse({"error": f"Shot not found: {shot_id}"}, status_code=404)

    takes = shot.get("takes", [])
    if take_index < 0 or take_index >= len(takes):
        return JSONResponse(
            {"error": f"Take index {take_index} out of range (0-{len(takes) - 1})"},
            status_code=400,
        )

    if action == "keep":
        takes[take_index]["approved"] = True
        takes[take_index].pop("rejected", None)
    elif action == "reject":
        takes[take_index]["rejected"] = True
        takes[take_index].pop("approved", None)
    elif action == "restore":
        takes[take_index].pop("rejected", None)
        takes[take_index].pop("approved", None)
    elif action == "unkept":
        takes[take_index].pop("approved", None)
        takes[take_index].pop("rejected", None)

    store.update_shot(shot_id, takes=takes)

    return JSONResponse(
        {
            "shot_id": shot_id,
            "take_index": take_index,
            "action": action,
        }
    )


@router.post("/api/dailies/keep-take")
def dailies_keep_take(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    return _dailies_take_action(body, "keep", project, store)


@router.post("/api/dailies/reject-take")
def dailies_reject_take(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    return _dailies_take_action(body, "reject", project, store)


@router.post("/api/dailies/restore-take")
def dailies_restore_take(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    return _dailies_take_action(body, "restore", project, store)


@router.post("/api/dailies/unkept-take")
def dailies_unkept_take(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    return _dailies_take_action(body, "unkept", project, store)


# ── Video bin endpoints ───────────────────────────────────────────


@router.post("/api/dailies/bin-clip")
def dailies_bin_clip(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
):
    """Bin a video clip — hide from main view."""
    video_path = body.get("video_path")
    if not video_path:
        return JSONResponse({"error": "Missing video_path"}, status_code=400)
    binned = _load_video_bin(project)
    binned.add(video_path)
    _save_video_bin(project, binned)
    return JSONResponse({"ok": True, "binned": len(binned)})


@router.post("/api/dailies/unbin-clip")
def dailies_unbin_clip(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
):
    """Restore a clip from the bin."""
    video_path = body.get("video_path")
    if not video_path:
        return JSONResponse({"error": "Missing video_path"}, status_code=400)
    binned = _load_video_bin(project)
    binned.discard(video_path)
    _save_video_bin(project, binned)
    return JSONResponse({"ok": True, "binned": len(binned)})


@router.post("/api/dailies/batch-override")
def dailies_batch_override(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Apply prompt override to multiple shots at once."""
    shot_ids = body.get("shot_ids", [])
    prompt = body.get("prompt")
    if not shot_ids or not prompt:
        return JSONResponse({"error": "Missing shot_ids or prompt"}, status_code=400)

    results = []
    for shot_id in shot_ids:
        try:
            shot = store.get_shot(shot_id)
            if shot:
                ep_num = int(shot["episode_id"].replace("EP", ""))
                ep_dir = f"ep_{ep_num:03d}"
                log = load_shot_data(ep_dir, project=project) or {"episode": ep_num}
                overrides = log.setdefault("manual_prompt_overrides", {})
                overrides[shot_id] = {
                    "prompt": prompt,
                    "override_at": time.time(),
                }
                save_log(ep_dir, log, project=project)
                store.force_reset_status(
                    shot_id,
                    "previs_pending",
                    reason="batch override — prompt changed by user",
                )
                results.append({"shot_id": shot_id, "overridden": True})
            else:
                results.append(
                    {"shot_id": shot_id, "overridden": False, "error": "not found"}
                )
        except Exception as e:
            results.append({"shot_id": shot_id, "overridden": False, "error": str(e)})

    return JSONResponse({"results": results, "count": len(results)})


# ── Video approve/unapprove ───────────────────────────────────────


@router.post("/api/dailies/approve-video")
def dailies_approve_video(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Mark a video clip as approved."""
    shot_id = body.get("shot_id")
    if not shot_id:
        return JSONResponse({"error": "Missing shot_id"}, status_code=400)
    store.update_shot(shot_id, video_approved=True, video_rejected=False)

    # Write to editorial feedback log
    approval_tags = body.get("tags", [])
    approval_notes = body.get("notes", "")
    if approval_tags or approval_notes:
        from recoil.pipeline._lib.feedback_logger import log_editorial_decision

        shot = store.get_shot(shot_id)
        log_editorial_decision(
            project=project,
            episode_id=shot.get("episode_id", "") if shot else "",
            shot_id=shot_id,
            decision="approve",
            stage="video",
            tags=approval_tags,
            notes=approval_notes,
        )

    return JSONResponse({"ok": True, "shot_id": shot_id})


@router.post("/api/dailies/unapprove-video")
def dailies_unapprove_video(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Remove video approval."""
    shot_id = body.get("shot_id")
    if not shot_id:
        return JSONResponse({"error": "Missing shot_id"}, status_code=400)
    store.update_shot(shot_id, video_approved=False)
    return JSONResponse({"ok": True, "shot_id": shot_id})


# ── Set previz hero ───────────────────────────────────────────────


@router.post("/api/set-previz-hero")
def set_previz_hero(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
    store=Depends(get_store),
):
    """Set a specific take as the hero for a shot."""
    shot_id = body.get("shot_id")
    take_index = body.get("take_index")
    if not shot_id or take_index is None:
        return JSONResponse({"error": "Missing shot_id or take_index"}, status_code=400)

    shot = store.get_shot(shot_id)
    if not shot:
        return JSONResponse({"error": f"Shot not found: {shot_id}"}, status_code=404)

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

    take = takes[take_index]
    file_path = take.get("file_path")
    if not file_path:
        return JSONResponse({"error": "Take has no file_path"}, status_code=400)

    store.update_shot(
        shot_id,
        gate_results={"hero_frame": file_path},
        output_path=file_path,
    )

    return JSONResponse(
        {
            "shot_id": shot_id,
            "hero_frame": file_path,
            "take_index": take_index,
        }
    )


# ══════════════════════════════════════════════════════════════════════
# LEGACY REVIEW ENDPOINTS
# ══════════════════════════════════════════════════════════════════════


@router.get("/api/frames/{ep_dir}")
def api_frames(
    ep_dir: str,
    paths: dict = Depends(get_paths),
):
    """List all generated frames for an episode."""
    fdir = paths["frames_dir"]
    odir = paths["output_dir"]
    pdir = paths["plans_dir"]
    if not (fdir / ep_dir).is_dir():
        return JSONResponse(
            {"error": f"Episode directory not found: {ep_dir}"}, status_code=404
        )

    frames = scan_frames(ep_dir, frames_dir=fdir, output_dir=odir)
    shot_data = load_shot_data(ep_dir, frames_dir=fdir, plans_dir=pdir)

    # Enrich frames with plan/log data
    records_by_shot = {}
    if shot_data and "records" in shot_data:
        for rec in shot_data["records"]:
            shot_name = rec.get("shot_name", "")
            if shot_name not in records_by_shot:
                records_by_shot[shot_name] = []
            records_by_shot[shot_name].append(rec)

    review_status = {}
    if shot_data and "review_status" in shot_data:
        review_status = shot_data["review_status"]

    for frame in frames:
        shot_name = frame["shot_name"]
        base_name = shot_name
        for suffix in ("_grid", "_panel", "_final", "_pro", "_flash"):
            if base_name.endswith(suffix):
                base_name = base_name[: len(base_name) - len(suffix)]
                break

        if base_name in records_by_shot:
            recs = records_by_shot[base_name]
            frame["tier"] = recs[0].get("tier", "unknown")
            frame["pass_types"] = list({r.get("pass_type", "") for r in recs})
            frame["grid_type"] = next(
                (r.get("grid_type") for r in recs if r.get("grid_type")), None
            )
            frame["model"] = recs[0].get("model", "")
        elif shot_name in records_by_shot:
            recs = records_by_shot[shot_name]
            frame["tier"] = recs[0].get("tier", "unknown")
            frame["pass_types"] = list({r.get("pass_type", "") for r in recs})
            frame["grid_type"] = next(
                (r.get("grid_type") for r in recs if r.get("grid_type")), None
            )
            frame["model"] = recs[0].get("model", "")
        else:
            frame["tier"] = "unknown"
            frame["pass_types"] = []
            frame["grid_type"] = None
            frame["model"] = ""

        frame["status"] = review_status.get(shot_name, "pending")

    return JSONResponse({"episode": ep_dir, "frames": frames})


@router.get("/api/previs/{ep_dir}")
def api_previs(
    ep_dir: str,
    paths: dict = Depends(get_paths),
):
    """List previs frames with statuses for an episode."""
    pvis_dir = paths["previs_dir"]
    fdir = paths["frames_dir"]
    previs_path = pvis_dir / ep_dir

    if not previs_path.is_dir():
        return JSONResponse(
            {"error": f"No previs directory for {ep_dir}"}, status_code=404
        )

    # Scan for previs frames
    frames = []
    for f in sorted(previs_path.iterdir()):
        if f.suffix.lower() in IMAGE_EXTS:
            frames.append(
                {
                    "filename": f.name,
                    "path": f"sequences/{ep_dir}/{f.name}",
                    "shot_name": f.stem,
                    "size_bytes": f.stat().st_size,
                }
            )

    # Load previs summary
    summary_path = previs_path / "previs_summary.json"
    summary = None
    if summary_path.exists():
        try:
            with open(summary_path) as sf:
                summary = json.load(sf)
        except (json.JSONDecodeError, IOError):
            pass

    # Load approval status from Layer 1 generation log
    approvals = {}
    log_path = fdir / ep_dir / "log.json"
    if log_path.exists():
        try:
            with open(log_path) as lf:
                log_data = json.load(lf)
                approvals = log_data.get("human_approvals", {})
        except (json.JSONDecodeError, IOError):
            pass

    # Enrich frames with approval status
    for frame in frames:
        shot_name = frame["shot_name"]
        for key, approval in approvals.items():
            if key in shot_name or shot_name.endswith(key):
                frame["approved"] = approval.get("previs_approved", False)
                break
        else:
            frame["approved"] = False

    return JSONResponse(
        {
            "episode": ep_dir,
            "frames": frames,
            "summary": summary,
            "total_frames": len(frames),
        }
    )


@router.post("/api/accept/{episode}/{shot_id}")
def api_accept(
    episode: str,
    shot_id: str,
    project: str = Depends(get_project),
):
    """Set review status for a shot to accepted."""
    return _api_set_status(episode, shot_id, "accepted", project)


@router.post("/api/reject/{episode}/{shot_id}")
def api_reject(
    episode: str,
    shot_id: str,
    project: str = Depends(get_project),
):
    """Set review status for a shot to rejected."""
    return _api_set_status(episode, shot_id, "rejected", project)


def _api_set_status(ep_dir, shot_id, status, project):
    """Set review status for a shot (accepted/rejected)."""
    log = load_shot_data(ep_dir, project=project)
    if log is None:
        log = {"episode": get_episode_number(ep_dir), "review_status": {}}

    if "review_status" not in log:
        log["review_status"] = {}

    log["review_status"][shot_id] = status
    save_log(ep_dir, log, project=project)

    return JSONResponse(
        {
            "shot_id": shot_id,
            "status": status,
            "episode": ep_dir,
        }
    )


@router.post("/api/promote/{episode}/{shot_id}")
def api_promote(
    episode: str,
    shot_id: str,
    project: str = Depends(get_project),
    paths: dict = Depends(get_paths),
):
    """Promote a generated frame to assets/."""
    ep_path = paths["frames_dir"] / episode
    if not ep_path.is_dir():
        return JSONResponse({"error": f"Episode not found: {episode}"}, status_code=404)

    # Find the image file matching shot_id
    source_file = None
    for root, _dirs, files in os.walk(ep_path):
        for f in files:
            fpath = Path(root) / f
            if fpath.suffix.lower() in MEDIA_EXTS and fpath.stem == shot_id:
                source_file = fpath
                break
        if source_file:
            break

    if source_file is None:
        return JSONResponse(
            {"error": f"No media found for shot: {shot_id}"}, status_code=404
        )

    # Copy to assets/
    paths["refs_dir"].mkdir(parents=True, exist_ok=True)
    dest = paths["refs_dir"] / source_file.name
    shutil.copy2(source_file, dest)

    # Update generation log
    log = load_shot_data(episode, project=project)
    if log is None:
        log = {"episode": get_episode_number(episode)}
    if "promoted" not in log:
        log["promoted"] = []
    promoted_entry = {
        "shot_id": shot_id,
        "source": str(source_file.relative_to(PROJECT_ROOT)),
        "dest": str(dest.relative_to(PROJECT_ROOT)),
    }
    if promoted_entry not in log["promoted"]:
        log["promoted"].append(promoted_entry)
    save_log(episode, log, project=project)

    return JSONResponse(
        {
            "shot_id": shot_id,
            "promoted_to": str(dest.relative_to(PROJECT_ROOT)),
            "episode": episode,
        }
    )


@router.post("/api/approve-previs")
def api_approve_previs(
    body: dict = Body(default={}),
    project: str = Depends(get_project),
):
    """Approve previs frame — promotes approval to Layer 1 generation log."""
    ep_dir = body.get("episode", "")
    shot_id = body.get("shot_id", "")

    if not ep_dir or not shot_id:
        return JSONResponse(
            {"error": "Missing 'episode' or 'shot_id' in request body"},
            status_code=400,
        )

    log = load_shot_data(ep_dir, project=project)
    if log is None:
        log = {"episode": get_episode_number(ep_dir)}

    approvals = log.setdefault("human_approvals", {})
    approvals.setdefault(shot_id, {})
    approvals[shot_id]["previs_approved"] = True
    approvals[shot_id]["previs_approved_at"] = time.time()
    approvals[shot_id]["previs_approved_by"] = "director"

    save_log(ep_dir, log, project=project)

    return JSONResponse(
        {
            "episode": ep_dir,
            "shot_id": shot_id,
            "previs_approved": True,
        }
    )
