"""
FastAPI application for the Video Production Pipeline web interface.

Provides REST endpoints for configuration and a WebSocket endpoint
for real-time pipeline execution with event streaming.
Persistent SQLite storage via web/database.py.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import subprocess
import re
import sys
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles

# Ensure project root is on sys.path so we can import pipeline modules
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(_PROJECT_ROOT))

from config import build_config, list_available_templates
from models import PipelineEvent, PipelineMode
from output_formatter import save_json, save_markdown
from pipeline import Pipeline
from web.database_sqlite import (
    character_count,
    close_db,
    delete_character,
    delete_clothing_style,
    delete_grok_account,
    delete_image,
    delete_production_rules,
    delete_project,
    delete_world_style,
    get_character,
    get_character_by_name,
    get_clothing_style,
    get_next_grok_account,
    get_production_rules,
    get_project,
    get_project_images,
    get_project_videos,
    get_project_audios,
    get_setting,
    get_world_style,
    insert_clothing_style,
    insert_production_rules,
    insert_world_style,
    list_clothing_styles,
    list_production_rules,
    list_world_styles,
    set_setting,
    get_all_settings as db_get_all_settings,
    init_db,
    insert_character,
    insert_grok_account,
    insert_project,
    list_characters,
    list_grok_accounts,
    list_projects,
    update_character,
    update_image_path,
    update_image_status,
    update_project_result,
    update_project_status,
    upsert_image,
    upsert_video,
    upsert_audio,
)

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------

_WEB_DIR = Path(__file__).resolve().parent
_STATIC_DIR = _WEB_DIR / "static"


# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------


async def _seed_characters_from_json() -> None:
    """Seed character DB from JSON templates if table is empty."""
    import uuid

    count = await character_count()
    if count > 0:
        return
    templates_dir = _PROJECT_ROOT / "character_templates"
    if not templates_dir.exists():
        return
    for path in sorted(templates_dir.glob("*.json")):
        try:
            import json as _json

            data = _json.loads(path.read_text(encoding="utf-8"))
            await insert_character(
                id=str(uuid.uuid4()),
                name=data.get("name", path.stem),
                physical_description=data.get("physical_description", ""),
                style_keywords=data.get("style_keywords", []),
                negative_keywords=data.get("negative_keywords", []),
                hard_prohibitions=data.get("hard_prohibitions", []),
                camera_settings=data.get("camera_settings", ""),
                lighting_settings=data.get("lighting_settings", ""),
                pose_rule=data.get("pose_rule", ""),
                environment_rule=data.get("environment_rule", ""),
                clothing_rule=data.get("clothing_rule", ""),
                anatomical_highlight_rules=data.get("anatomical_highlight_rules", ""),
                is_default=True,
            )
            logger.info("Seeded character: %s", data.get("name", path.stem))
        except Exception as exc:
            logger.warning("Failed to seed character from %s: %s", path, exc)


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Modern lifespan context manager replacing on_event startup/shutdown."""
    # --- Startup ---
    await init_db()
    await _seed_characters_from_json()
    
    # FlowCamoufoxEngine is initialized lazily on first generate_image call
    # (no eager browser launch on server startup)
    
    yield
    
    # --- Shutdown ---
    # Stop FlowCamoufoxEngine if it was started
    try:
        from providers.flow_camoufox_engine import stop_camoufox_engine
        logger.info("Stopping FlowCamoufoxEngine...")
        await stop_camoufox_engine()
        logger.info("FlowCamoufoxEngine stopped")
    except Exception as e:
        logger.warning("Error stopping FlowCamoufoxEngine: %s", e)
    
    await close_db()


app = FastAPI(title="Video Production Pipeline", version="2.0.0", lifespan=lifespan)

# CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# ---------------------------------------------------------------------------
# Static files & SPA
# ---------------------------------------------------------------------------

app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")

# Serve generated output assets (images, videos, audio) as static files
_OUTPUT_DIR = _PROJECT_ROOT / "output"
_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/output", StaticFiles(directory=str(_OUTPUT_DIR)), name="output")


@app.get("/")
async def index():
    return FileResponse(str(_STATIC_DIR / "index.html"))


# SPA catch-all routes - serve index.html for client-side routing
@app.get("/new")
@app.get("/settings")
@app.get("/settings/{section:path}")
@app.get("/projects/{project_id:path}")
@app.get("/characters/{path:path}")
async def spa_catch_all():
    """Serve index.html for all SPA routes (client-side routing)."""
    return FileResponse(str(_STATIC_DIR / "index.html"))


@app.get("/health")
@app.get("/api/health")
async def health_check():
    """Health check endpoint for Docker / Electron."""
    return {"status": "healthy", "database": "sqlite"}


# ---------------------------------------------------------------------------
# REST API
# ---------------------------------------------------------------------------


@app.get("/api/templates")
async def get_templates():
    """List available character templates (legacy, redirects to characters)."""
    chars = await list_characters()
    return {"templates": [c["name"] for c in chars]}


# ---------------------------------------------------------------------------
# Characters API
# ---------------------------------------------------------------------------


@app.get("/api/characters")
async def api_list_characters():
    """List all characters with metadata."""
    chars = await list_characters()
    return {"characters": chars}


@app.get("/api/characters/{char_id}")
async def api_get_character(char_id: str):
    """Get a single character by ID."""
    char = await get_character(char_id)
    if char is None:
        return JSONResponse({"error": "Character not found"}, status_code=404)
    return char


@app.post("/api/characters")
async def api_create_character(request: Request):
    """Create a new character."""
    import uuid

    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    name = body.get("name", "").strip()
    if not name:
        return JSONResponse({"error": "Name is required"}, status_code=400)

    existing = await get_character_by_name(name)
    if existing:
        return JSONResponse(
            {"error": f"Character '{name}' already exists"}, status_code=409
        )

    char_id = str(uuid.uuid4())
    await insert_character(
        id=char_id,
        name=name,
        physical_description=body.get("physical_description", ""),
        style_keywords=body.get("style_keywords", []),
        negative_keywords=body.get("negative_keywords", []),
        hard_prohibitions=body.get("hard_prohibitions", []),
        camera_settings=body.get("camera_settings", ""),
        lighting_settings=body.get("lighting_settings", ""),
        pose_rule=body.get("pose_rule", ""),
        environment_rule=body.get("environment_rule", ""),
        clothing_rule=body.get("clothing_rule", ""),
        anatomical_highlight_rules=body.get("anatomical_highlight_rules", ""),
    )

    char = await get_character(char_id)
    return JSONResponse(char, status_code=201)


@app.put("/api/characters/{char_id}")
async def api_update_character(char_id: str, request: Request):
    """Update individual fields of a character."""
    char = await get_character(char_id)
    if char is None:
        return JSONResponse({"error": "Character not found"}, status_code=404)

    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    allowed = {
        "name",
        "physical_description",
        "style_keywords",
        "negative_keywords",
        "hard_prohibitions",
        "camera_settings",
        "lighting_settings",
        "pose_rule",
        "environment_rule",
        "clothing_rule",
        "anatomical_highlight_rules",
    }
    updates = {k: v for k, v in body.items() if k in allowed}
    if not updates:
        return JSONResponse({"error": "No valid fields to update"}, status_code=400)

    # Check name uniqueness if renaming
    if "name" in updates:
        existing = await get_character_by_name(updates["name"])
        if existing and existing["id"] != char_id:
            return JSONResponse({"error": "Name already taken"}, status_code=409)

    await update_character(char_id, updates)
    return await get_character(char_id)


@app.delete("/api/characters/{char_id}")
async def api_delete_character(char_id: str):
    """Delete a character."""
    char = await get_character(char_id)
    if char is None:
        return JSONResponse({"error": "Character not found"}, status_code=404)
    # Remove thumbnail file if exists
    if char.get("thumbnail_path"):
        thumb = Path(char["thumbnail_path"])
        if thumb.exists():
            thumb.unlink(missing_ok=True)
    await delete_character(char_id)
    return {"status": "deleted", "id": char_id}


@app.post("/api/characters/{char_id}/generate-thumbnail")
async def api_generate_thumbnail(char_id: str):
    """Generate a thumbnail preview image for a character."""
    char = await get_character(char_id)
    if char is None:
        return JSONResponse({"error": "Character not found"}, status_code=404)

    phys = char.get("physical_description", "")
    if not phys:
        return JSONResponse(
            {"error": "No physical description to generate from"}, status_code=400
        )

    style_kw = ", ".join(char.get("style_keywords", []))
    prompt = (
        f"Portrait shot, neutral standing pose, studio lighting, dark gradient background, "
        f"centered composition, {phys} "
        f"{style_kw}. "
        f"Photorealistic, 8K, sharp focus, no text, no watermark."
    )

    thumb_dir = _PROJECT_ROOT / "data" / "thumbnails"
    thumb_dir.mkdir(parents=True, exist_ok=True)
    thumb_path = thumb_dir / f"{char_id}.png"

    try:

        def _gen():
            from providers.google_flow_provider import GoogleFlowImageProvider

            with GoogleFlowImageProvider() as prov:
                prov.generate(prompt, thumb_path)

        await asyncio.to_thread(_gen)

        await update_character(char_id, {"thumbnail_path": str(thumb_path)})

        return {
            "status": "generated",
            "thumbnail_url": f"/api/characters/{char_id}/thumbnail",
            "char_id": char_id,
        }
    except Exception as exc:
        logger.exception("Thumbnail generation failed for character %s", char_id)
        return JSONResponse({"error": f"Generation failed: {exc}"}, status_code=500)


@app.get("/api/characters/{char_id}/thumbnail")
async def api_serve_thumbnail(char_id: str):
    """Serve a character's thumbnail image."""
    char = await get_character(char_id)
    if char is None:
        return JSONResponse({"error": "Character not found"}, status_code=404)
    thumb_path = char.get("thumbnail_path", "")
    if not thumb_path or not Path(thumb_path).exists():
        return JSONResponse({"error": "No thumbnail available"}, status_code=404)
    return FileResponse(str(thumb_path))


# ---------------------------------------------------------------------------
# Style Presets API
# ---------------------------------------------------------------------------


@app.get("/api/world-styles")
async def api_list_world_styles():
    """List all world style presets (built-in + custom)."""
    from presets import list_world_styles as list_preset_world_styles, WORLD_STYLE_PRESETS
    
    # Get built-in presets
    presets = [
        {
            "id": ws.id,
            "name": ws.name,
            "preset_type": ws.preset_type,
            "is_preset": True,
            "background_rules": ws.background_rules,
            "floor_rules": ws.floor_rules,
            "secondary_characters_rules": ws.secondary_characters_rules,
            "lighting_rules": ws.lighting_rules,
            "props_rules": ws.props_rules,
            "architecture_allowed": ws.architecture_allowed,
        }
        for ws in WORLD_STYLE_PRESETS.values()
    ]
    
    # Get custom styles from DB
    custom = await list_world_styles()
    for c in custom:
        c["is_preset"] = False
    
    return {"world_styles": presets + custom}


@app.get("/api/clothing-styles")
async def api_list_clothing_styles():
    """List all clothing style presets (built-in + custom)."""
    from presets import CLOTHING_STYLE_PRESETS
    
    # Get built-in presets
    presets = [
        {
            "id": cs.id,
            "name": cs.name,
            "preset_type": cs.preset_type,
            "is_preset": True,
            "clothing_rules": cs.clothing_rules,
            "opacity_rules": cs.opacity_rules,
            "max_pieces": cs.max_pieces,
        }
        for cs in CLOTHING_STYLE_PRESETS.values()
    ]
    
    # Get custom styles from DB
    custom = await list_clothing_styles()
    for c in custom:
        c["is_preset"] = False
    
    return {"clothing_styles": presets + custom}


@app.get("/api/production-rules")
async def api_list_production_rules():
    """List all production rules presets (built-in + custom)."""
    from presets import PRODUCTION_RULES_PRESETS
    
    # Get built-in presets
    presets = [
        {
            "id": pr.id,
            "name": pr.name,
            "is_preset": True,
            "camera_settings": pr.camera_settings,
            "pose_rule": pr.pose_rule,
            "shot_intercalation_rules": pr.shot_intercalation_rules,
            "death_scene_rules": pr.death_scene_rules,
            "child_rules": pr.child_rules,
            "zombie_rules": pr.zombie_rules,
            "consequence_philosophy": pr.consequence_philosophy,
            "scale_rule": pr.scale_rule,
        }
        for pr in PRODUCTION_RULES_PRESETS.values()
    ]
    
    # Get custom rules from DB
    custom = await list_production_rules()
    for c in custom:
        c["is_preset"] = False
    
    return {"production_rules": presets + custom}


@app.post("/api/world-styles")
async def api_create_world_style(request: Request):
    """Create a new custom world style."""
    import uuid
    data = await request.json()
    style_id = f"custom_{uuid.uuid4().hex[:8]}"
    await insert_world_style(
        id=style_id,
        name=data.get("name", "Untitled"),
        preset_type="custom",
        background_rules=data.get("background_rules", ""),
        floor_rules=data.get("floor_rules", ""),
        secondary_characters_rules=data.get("secondary_characters_rules", ""),
        lighting_rules=data.get("lighting_rules", ""),
        props_rules=data.get("props_rules", ""),
        architecture_allowed=data.get("architecture_allowed", True),
    )
    return {"id": style_id, "success": True}


@app.delete("/api/world-styles/{style_id}")
async def api_delete_world_style(style_id: str):
    """Delete a custom world style."""
    success = await delete_world_style(style_id)
    if not success:
        raise HTTPException(status_code=404, detail="Style not found or is a preset")
    return {"success": True}


@app.post("/api/clothing-styles")
async def api_create_clothing_style(request: Request):
    """Create a new custom clothing style."""
    import uuid
    data = await request.json()
    style_id = f"custom_{uuid.uuid4().hex[:8]}"
    await insert_clothing_style(
        id=style_id,
        name=data.get("name", "Untitled"),
        preset_type="custom",
        clothing_rules=data.get("clothing_rules", ""),
        opacity_rules=data.get("opacity_rules", ""),
        max_pieces=data.get("max_pieces", 2),
    )
    return {"id": style_id, "success": True}


@app.delete("/api/clothing-styles/{style_id}")
async def api_delete_clothing_style(style_id: str):
    """Delete a custom clothing style."""
    success = await delete_clothing_style(style_id)
    if not success:
        raise HTTPException(status_code=404, detail="Style not found or is a preset")
    return {"success": True}


# ---------------------------------------------------------------------------
# Product Placement / Merchandising API
# ---------------------------------------------------------------------------

@app.post("/api/analyze-product")
async def api_analyze_product(request: Request):
    """Analyze a product image and generate a description using vision AI."""
    import base64
    import os
    from openai import OpenAI
    
    data = await request.json()
    image_base64 = data.get("image_base64", "")
    product_name = data.get("product_name", "")
    
    if not image_base64:
        raise HTTPException(status_code=400, detail="No image provided")
    
    # Remove data URL prefix if present
    if "," in image_base64:
        image_base64 = image_base64.split(",")[1]
    
    # Use OpenAI Vision to analyze the product
    try:
        client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
        
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {
                    "role": "system",
                    "content": """You are a product analyst for video production. Analyze the product image and provide:
1. A detailed visual description suitable for image generation prompts
2. The product type/category
3. Key visual features (colors, shape, materials, branding)
4. How the product might be naturally held or used by a person

Format your response as JSON:
{
    "description": "Detailed visual description for image prompts",
    "product_type": "Category of product",
    "brand_detected": "Brand name if visible, null otherwise",
    "key_features": ["feature1", "feature2"],
    "usage_context": "How a person would naturally use/hold this product"
}"""
                },
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "text",
                            "text": f"Analyze this product image{' (product name: ' + product_name + ')' if product_name else ''}. Provide a detailed description for use in video production image prompts."
                        },
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:image/jpeg;base64,{image_base64}"
                            }
                        }
                    ]
                }
            ],
            max_tokens=500
        )
        
        import json
        result_text = response.choices[0].message.content
        # Try to parse as JSON
        try:
            # Find JSON in the response
            if "```json" in result_text:
                result_text = result_text.split("```json")[1].split("```")[0]
            elif "```" in result_text:
                result_text = result_text.split("```")[1].split("```")[0]
            result = json.loads(result_text.strip())
        except:
            # Fallback: use raw text as description
            result = {
                "description": result_text,
                "product_type": "unknown",
                "brand_detected": None,
                "key_features": [],
                "usage_context": "Character holds or interacts with the product"
            }
        
        return {
            "success": True,
            "analysis": result
        }
        
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to analyze product: {str(e)}")


# ---------------------------------------------------------------------------
# Settings helpers
# ---------------------------------------------------------------------------


def _update_env_keys(updates: dict[str, str]) -> None:
    """Update keys in the .env file and in the running process."""
    env_path = _PROJECT_ROOT / ".env"
    content = env_path.read_text() if env_path.exists() else ""
    found = set()
    new_lines = []
    for line in content.splitlines():
        key = line.split("=", 1)[0] if "=" in line else ""
        if key in updates:
            new_lines.append(f"{key}={updates[key]}")
            found.add(key)
        else:
            new_lines.append(line)
    for k, v in updates.items():
        if k not in found:
            new_lines.append(f"{k}={v}")
    env_path.write_text("\n".join(new_lines) + "\n")
    for k, v in updates.items():
        os.environ[k] = v


# ---------------------------------------------------------------------------
# Settings API – LLM
# ---------------------------------------------------------------------------


@app.get("/api/settings/llm")
async def get_llm_settings():
    """Return current LLM provider, model, and key status."""
    db_settings = await db_get_all_settings()
    llm_model = db_settings.get("llm_model") or os.environ.get("LLM_MODEL", "openai/gpt-4o")
    parts = llm_model.split("/", 1)
    provider = parts[0] if len(parts) == 2 else "openai"
    model = parts[1] if len(parts) == 2 else parts[0]

    openai_key = db_settings.get("openai_api_key") or os.environ.get("OPENAI_API_KEY", "")
    anthropic_key = db_settings.get("anthropic_api_key") or os.environ.get("ANTHROPIC_API_KEY", "")
    kimi_key = db_settings.get("kimi_api_key") or os.environ.get("KIMI_API_KEY", "")

    return {
        "provider": provider,
        "model": model,
        "llm_model": llm_model,
        "has_openai_key": bool(openai_key),
        "has_anthropic_key": bool(anthropic_key),
        "has_kimi_key": bool(kimi_key),
    }


@app.post("/api/settings/llm")
async def update_llm_settings(request: Request):
    """Update LLM provider, API key, and model."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    provider = body.get("provider", "").strip()
    api_key = body.get("api_key", "").strip()
    model = body.get("model", "").strip()

    if provider not in ("openai", "anthropic", "kimi"):
        return JSONResponse(
            {"error": "Provider must be 'openai', 'anthropic', or 'kimi'"}, status_code=400
        )

    if api_key:
        if provider == "openai":
            await set_setting("openai_api_key", api_key, "OpenAI API key")
            os.environ["OPENAI_API_KEY"] = api_key
        elif provider == "anthropic":
            await set_setting("anthropic_api_key", api_key, "Anthropic API key")
            os.environ["ANTHROPIC_API_KEY"] = api_key
        elif provider == "kimi":
            await set_setting("kimi_api_key", api_key, "Kimi API key")
            os.environ["KIMI_API_KEY"] = api_key

    if model:
        llm_model = f"{provider}/{model}"
    elif provider:
        db_settings = await db_get_all_settings()
        current = db_settings.get("llm_model") or os.environ.get("LLM_MODEL", "openai/gpt-4o")
        current_model = current.split("/", 1)[-1]
        llm_model = f"{provider}/{current_model}"
    else:
        llm_model = None

    if llm_model:
        await set_setting("llm_model", llm_model, "LLM model identifier")
        os.environ["LLM_MODEL"] = llm_model

    final_model = llm_model or (await db_get_all_settings()).get("llm_model") or os.environ.get("LLM_MODEL", "")
    return {"status": "updated", "llm_model": final_model}


@app.get("/api/settings/llm/models")
async def list_llm_models(provider: str = "openai"):
    """Fetch available models from the selected provider."""
    if provider == "openai":
        db_settings = await db_get_all_settings()
        api_key = db_settings.get("openai_api_key") or os.environ.get("OPENAI_API_KEY", "")
        if not api_key:
            return JSONResponse(
                {"error": "OPENAI_API_KEY not configured"}, status_code=400
            )
        try:
            import httpx

            res = await asyncio.to_thread(
                lambda: httpx.get(
                    "https://api.openai.com/v1/models",
                    headers={"Authorization": f"Bearer {api_key}"},
                    timeout=15,
                )
            )
            if res.status_code != 200:
                return JSONResponse(
                    {"error": f"OpenAI API error: {res.status_code}"}, status_code=502
                )
            data = res.json()
            models = sorted(
                [m["id"] for m in data.get("data", []) if "gpt" in m["id"]],
                reverse=True,
            )
            return {"provider": "openai", "models": models}
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, status_code=500)

    elif provider == "anthropic":
        db_settings = await db_get_all_settings()
        api_key = db_settings.get("anthropic_api_key") or os.environ.get("ANTHROPIC_API_KEY", "")
        if not api_key:
            return JSONResponse(
                {"error": "ANTHROPIC_API_KEY not configured"}, status_code=400
            )
        try:
            import httpx

            res = await asyncio.to_thread(
                lambda: httpx.get(
                    "https://api.anthropic.com/v1/models",
                    headers={
                        "x-api-key": api_key,
                        "anthropic-version": "2023-06-01",
                    },
                    timeout=15,
                )
            )
            if res.status_code != 200:
                return JSONResponse(
                    {"error": f"Anthropic API error: {res.status_code}"},
                    status_code=502,
                )
            data = res.json()
            models = sorted(
                [m["id"] for m in data.get("data", [])],
                reverse=True,
            )
            return {"provider": "anthropic", "models": models}
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, status_code=500)

    elif provider == "kimi":
        # Kimi/Moonshot models — try dynamic list first, fallback to known models
        kimi_key = db_settings.get("kimi_api_key") or os.environ.get("KIMI_API_KEY", "")
        if kimi_key:
            try:
                async with httpx.AsyncClient(timeout=10.0) as client:
                    resp = await client.get(
                        "https://api.moonshot.cn/v1/models",
                        headers={"Authorization": f"Bearer {kimi_key}"},
                    )
                    if resp.status_code == 200:
                        data = resp.json()
                        models = sorted([m["id"] for m in data.get("data", [])], reverse=True)
                        if models:
                            return {"provider": "kimi", "models": models}
            except Exception:
                pass
        return {"provider": "kimi", "models": [
            "moonshot-v1-auto", "moonshot-v1-128k", "moonshot-v1-32k", "moonshot-v1-8k",
            "kimi-latest",
        ]}

    return JSONResponse({"error": "Unknown provider"}, status_code=400)


# ---------------------------------------------------------------------------
# Settings API – Cookies
# ---------------------------------------------------------------------------


@app.get("/api/settings/cookies")
async def get_cookies_status():
    """Check if Google Flow cookies are configured."""
    env_path = _PROJECT_ROOT / ".env"
    session = os.environ.get("GOOGLE_FLOW_SESSION_TOKEN", "")
    csrf = os.environ.get("GOOGLE_FLOW_CSRF_TOKEN", "")
    return {
        "has_session_token": bool(session),
        "has_csrf_token": bool(csrf),
        "session_token_preview": session[:20] + "..." if len(session) > 20 else session,
        "csrf_token_preview": csrf[:20] + "..." if len(csrf) > 20 else csrf,
    }


@app.post("/api/settings/cookies")
async def update_cookies(request: Request):
    """Update Google Flow cookies from browser-exported JSON array."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    if not isinstance(body, list):
        return JSONResponse(
            {"error": "Expected a JSON array of cookies"}, status_code=400
        )

    # Extract the two required cookies
    session_token = ""
    csrf_token = ""
    for cookie in body:
        name = cookie.get("name", "")
        value = cookie.get("value", "")
        if name == "__Secure-next-auth.session-token":
            session_token = value
        elif name == "__Host-next-auth.csrf-token":
            csrf_token = value

    if not session_token:
        return JSONResponse(
            {"error": "Cookie '__Secure-next-auth.session-token' not found in JSON"},
            status_code=400,
        )
    if not csrf_token:
        return JSONResponse(
            {"error": "Cookie '__Host-next-auth.csrf-token' not found in JSON"},
            status_code=400,
        )

    try:
        await set_setting("google_flow_session_token", session_token, "Google Flow session cookie")
        await set_setting("google_flow_csrf_token", csrf_token, "Google Flow CSRF cookie")
        await set_setting("image_provider", "google_flow", "Active image provider")
        # Keep env vars in sync for the current process
        os.environ["GOOGLE_FLOW_SESSION_TOKEN"] = session_token
        os.environ["GOOGLE_FLOW_CSRF_TOKEN"] = csrf_token
        # Save full cookie list so the engine injects ALL cookies into the browser
        os.environ["GOOGLE_FLOW_COOKIES"] = json.dumps(body)
    except Exception as exc:
        logger.exception("Failed to save Flow cookies to database")
        return JSONResponse({"error": f"Failed to save: {exc}"}, status_code=500)

    return {
        "status": "updated",
        "session_token_preview": session_token[:20] + "...",
        "csrf_token_preview": csrf_token[:20] + "...",
    }


@app.post("/api/settings/flow-auto-setup")
async def flow_auto_setup():
    """Use headless Playwright to auto-extract project_id and reCAPTCHA token."""
    session_token = os.environ.get("GOOGLE_FLOW_SESSION_TOKEN", "")
    csrf_token = os.environ.get("GOOGLE_FLOW_CSRF_TOKEN", "")

    if not session_token or not csrf_token:
        return JSONResponse(
            {"error": "Cookies must be configured first (session + CSRF tokens)."},
            status_code=400,
        )

    try:
        from providers.flow_token_service import auto_setup_flow_credentials

        result = await auto_setup_flow_credentials(session_token, csrf_token)

        if result.get("error"):
            return JSONResponse(
                {"error": f"Auto-setup failed: {result['error']}"},
                status_code=500,
            )

        updates = {}
        if result.get("project_id"):
            updates["GOOGLE_FLOW_PROJECT_ID"] = result["project_id"]
        if result.get("recaptcha_token"):
            updates["GOOGLE_FLOW_RECAPTCHA_TOKEN"] = result["recaptcha_token"]

        if updates:
            _update_env_keys(updates)

        return {
            "status": "ok",
            "project_id": result.get("project_id"),
            "has_recaptcha": bool(result.get("recaptcha_token")),
            "message": (
                "Flow API credentials extracted successfully!"
                if result.get("project_id")
                else "Could not extract project_id. Try generating an image manually on labs.google/fx first, then retry."
            ),
        }

    except ImportError:
        return JSONResponse(
            {
                "error": "Playwright is not installed. Run: pip install playwright && playwright install chromium"
            },
            status_code=500,
        )
    except Exception as exc:
        logger.exception("Flow auto-setup error")
        return JSONResponse({"error": str(exc)}, status_code=500)


# ─────────────────────────────────────────────────────────────────────────────
# Grok Cookie Management
# ─────────────────────────────────────────────────────────────────────────────


@app.get("/api/settings/grok-cookies")
async def get_grok_cookies_status():
    """Check if Grok cookies are configured."""
    accounts = await list_grok_accounts()
    return {
        "accounts": [
            {
                "id": a["id"],
                "label": a["label"],
                "user_id": a["user_id"],
                "is_active": a["is_active"],
                "usage_count": a["usage_count"],
                "daily_usage_count": a["daily_usage_count"],
                "last_used_at": str(a["last_used_at"]) if a["last_used_at"] else None,
                "created_at": str(a["created_at"]) if a["created_at"] else None,
            }
            for a in accounts
        ],
        "total": len(accounts),
        "active": sum(1 for a in accounts if a["is_active"]),
    }


@app.post("/api/settings/grok-cookies")
async def update_grok_cookies(request: Request):
    """Update Grok cookies from browser-exported JSON array."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    if not isinstance(body, list):
        return JSONResponse(
            {"error": "Expected a JSON array of cookies"}, status_code=400
        )

    # Extract the required cookies
    sso_token = ""
    sso_rw_token = ""
    user_id = ""

    for cookie in body:
        name = cookie.get("name", "")
        value = cookie.get("value", "")
        if name == "sso":
            sso_token = value
        elif name == "sso-rw":
            sso_rw_token = value
        elif name == "x-userid":
            user_id = value

    if not sso_token:
        return JSONResponse(
            {"error": "Cookie 'sso' not found in JSON"},
            status_code=400,
        )
    if not sso_rw_token:
        return JSONResponse(
            {"error": "Cookie 'sso-rw' not found in JSON"},
            status_code=400,
        )
    if not user_id:
        return JSONResponse(
            {"error": "Cookie 'x-userid' not found in JSON"},
            status_code=400,
        )

    # Extract optional label
    label = ""
    for cookie in body:
        if cookie.get("name", "") == "x-userid":
            label = cookie.get("value", "")[:20]  # Use first 20 chars of user_id as label

    try:
        account = await insert_grok_account(
            label=label,
            sso_token=sso_token,
            sso_rw_token=sso_rw_token,
            user_id=user_id,
        )
        # Also set video_provider to grok
        await set_setting("video_provider", "grok", "Active video provider")
    except Exception as exc:
        logger.exception("Failed to save Grok account to database")
        return JSONResponse({"error": f"Failed to save: {exc}"}, status_code=500)

    return {
        "status": "added",
        "account_id": account["id"],
        "label": account["label"],
        "user_id": user_id,
    }


@app.delete("/api/settings/grok-cookies/{account_id}")
async def remove_grok_account(account_id: int):
    """Remove a Grok account."""
    deleted = await delete_grok_account(account_id)
    if not deleted:
        return JSONResponse({"error": "Account not found"}, status_code=404)
    return {"status": "deleted", "account_id": account_id}


@app.post("/api/settings/grok-cookies/validate")
async def validate_grok_cookies(request: Request):
    """Validate Grok cookies by making a lightweight API call."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    sso_token = body.get("sso_token", "").strip()
    sso_rw_token = body.get("sso_rw_token", "").strip()
    user_id = body.get("user_id", "").strip()

    if not sso_token or not sso_rw_token:
        return JSONResponse(
            {"error": "sso_token and sso_rw_token are required"},
            status_code=400,
        )

    try:
        import httpx

        cookies = {
            "sso": sso_token,
            "sso-rw": sso_rw_token,
            "x-userid": user_id,
        }
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.get(
                "https://grok.com/rest/app-chat/conversations",
                cookies=cookies,
            )

        if resp.status_code == 200:
            return {
                "valid": True,
                "username": user_id,
                "message": "Cookies are valid",
            }
        elif resp.status_code in (401, 403):
            return {
                "valid": False,
                "message": f"Authentication failed (HTTP {resp.status_code})",
            }
        else:
            return {
                "valid": False,
                "message": f"Unexpected response (HTTP {resp.status_code})",
            }

    except Exception as exc:
        logger.exception("Grok cookie validation error")
        return JSONResponse({"error": str(exc)}, status_code=500)


@app.get("/api/settings/grok-session")
async def validate_grok_session():
    """Validate if Grok session is active using Playwright."""
    sso = os.environ.get("GROK_SSO_TOKEN", "")
    sso_rw = os.environ.get("GROK_SSO_RW_TOKEN", "")
    user_id = os.environ.get("GROK_USER_ID", "")

    if not all([sso, sso_rw, user_id]):
        return JSONResponse(
            {"error": "Grok cookies not configured. Please save cookies first."},
            status_code=400,
        )

    try:
        from playwright.async_api import async_playwright

        async with async_playwright() as p:
            browser = await p.chromium.launch(headless=True)
            context = await browser.new_context()

            # Add auth cookies
            await context.add_cookies(
                [
                    {"name": "sso", "value": sso, "domain": ".grok.com", "path": "/"},
                    {
                        "name": "sso-rw",
                        "value": sso_rw,
                        "domain": ".grok.com",
                        "path": "/",
                    },
                    {
                        "name": "x-userid",
                        "value": user_id,
                        "domain": ".grok.com",
                        "path": "/",
                    },
                ]
            )

            page = await context.new_page()
            await page.goto("https://grok.com/", timeout=30000)
            await page.wait_for_load_state("domcontentloaded")

            # Check if logged in by looking for user-specific elements
            is_logged_in = await page.evaluate("""
                () => {
                    // Check for profile button or user menu
                    const pfp = document.querySelector('img[alt="pfp"]');
                    const userMenu = document.querySelector('[aria-label="pfp"]');
                    return !!(pfp || userMenu);
                }
            """)

            await browser.close()

            return {
                "valid": is_logged_in,
                "message": "Session is active"
                if is_logged_in
                else "Session expired - please update cookies",
            }

    except ImportError:
        return JSONResponse(
            {
                "error": "Playwright not installed. Run: pip install playwright && playwright install chromium"
            },
            status_code=500,
        )
    except Exception as exc:
        logger.exception("Grok session validation error")
        return JSONResponse({"error": str(exc)}, status_code=500)


# ── Leonardo AI Settings ─────────────────────────────────────────────────────


@app.post("/api/settings/leonardo")
async def update_leonardo_settings(request: Request):
    """Update Leonardo AI API key and settings."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    api_key = body.get("api_key", "").strip()
    resolution = body.get("resolution", "720").strip()

    if not api_key:
        return JSONResponse({"error": "API key is required"}, status_code=400)

    if resolution not in ("480", "720"):
        resolution = "720"

    # Update env vars for current session
    os.environ["LEONARDO_API_KEY"] = api_key
    os.environ["LEONARDO_RESOLUTION"] = resolution

    # Persist to database
    try:
        await set_setting("leonardo_api_key", api_key, "Leonardo AI API key")
        await set_setting("leonardo_resolution", resolution, "Leonardo video resolution")
        await set_setting("video_provider", "leonardo", "Active video provider")
    except Exception as exc:
        logger.exception("Failed to save Leonardo settings to database")
        return JSONResponse({"error": f"Failed to save settings: {exc}"}, status_code=500)

    return {
        "status": "updated",
        "resolution": resolution,
    }


@app.get("/api/settings/leonardo/test")
async def test_leonardo_api():
    """Test Leonardo AI API key by fetching user info."""
    # Try database first, then env var
    api_key = await get_setting("leonardo_api_key") or os.environ.get("LEONARDO_API_KEY", "")

    if not api_key:
        return JSONResponse(
            {"error": "Leonardo API key not configured"},
            status_code=400,
        )

    try:
        import httpx

        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.get(
                "https://cloud.leonardo.ai/api/rest/v1/me",
                headers={
                    "accept": "application/json",
                    "authorization": f"Bearer {api_key}",
                },
            )

            if resp.status_code == 200:
                data = resp.json()
                user_details = data.get("user_details", [{}])[0]
                return {
                    "valid": True,
                    "credits": user_details.get("apiConcurrencySlots", "unknown"),
                    "username": user_details.get("username", ""),
                }
            elif resp.status_code == 401:
                return {"valid": False, "error": "Invalid API key"}
            else:
                return JSONResponse(
                    {"error": f"API error: {resp.status_code}"},
                    status_code=resp.status_code,
                )

    except Exception as exc:
        logger.exception("Leonardo API test error")
        return JSONResponse({"error": str(exc)}, status_code=500)


@app.post("/api/settings/pollinations")
async def update_pollinations_settings(request: Request):
    """Update Pollinations API key and settings."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    api_key = body.get("api_key", "").strip()
    image_model = body.get("image_model", "flux").strip()
    video_model = body.get("video_model", "seedance").strip()
    tts_voice = body.get("tts_voice", "nova").strip()

    # API key is optional for Pollinations (limited free usage)
    # Update env vars for current session
    if api_key:
        os.environ["POLLINATIONS_API_KEY"] = api_key
    os.environ["POLLINATIONS_IMAGE_MODEL"] = image_model
    os.environ["POLLINATIONS_VIDEO_MODEL"] = video_model
    os.environ["POLLINATIONS_TTS_VOICE"] = tts_voice

    # Persist to database
    try:
        if api_key:
            await set_setting("pollinations_api_key", api_key, "Pollinations API key")
        await set_setting("pollinations_image_model", image_model, "Pollinations image model")
        await set_setting("pollinations_video_model", video_model, "Pollinations video model")
        await set_setting("pollinations_tts_voice", tts_voice, "Pollinations TTS voice")
    except Exception as exc:
        logger.exception("Failed to save Pollinations settings to database")
        return JSONResponse({"error": f"Failed to save settings: {exc}"}, status_code=500)

    return {
        "status": "updated",
        "image_model": image_model,
        "video_model": video_model,
        "tts_voice": tts_voice,
    }


@app.post("/api/settings/pollinations/image")
async def update_pollinations_image_settings(request: Request):
    """Update Pollinations Image settings."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    model = body.get("model", "flux").strip()
    width = body.get("width", "1024").strip()
    height = body.get("height", "1024").strip()
    api_key = body.get("api_key", "").strip()

    os.environ["POLLINATIONS_IMAGE_MODEL"] = model
    os.environ["POLLINATIONS_IMAGE_WIDTH"] = width
    os.environ["POLLINATIONS_IMAGE_HEIGHT"] = height
    if api_key:
        os.environ["POLLINATIONS_API_KEY"] = api_key

    try:
        await set_setting("pollinations_image_model", model, "Pollinations image model")
        await set_setting("pollinations_image_width", width, "Pollinations image width")
        await set_setting("pollinations_image_height", height, "Pollinations image height")
        if api_key:
            await set_setting("pollinations_api_key", api_key, "Pollinations API key")
        await set_setting("image_provider", "pollinations", "Active image provider")
    except Exception as exc:
        logger.exception("Failed to save Pollinations Image settings")
        return JSONResponse({"error": f"Failed to save: {exc}"}, status_code=500)

    return {"status": "updated", "model": model, "width": width, "height": height}


@app.post("/api/settings/pollinations/tts")
async def update_pollinations_tts_settings(request: Request):
    """Update Pollinations TTS settings."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    model = body.get("model", "openai").strip()
    voice = body.get("voice", "nova").strip()
    speed = body.get("speed", "1.0").strip()
    format = body.get("format", "mp3").strip()
    api_key = body.get("api_key", "").strip()

    os.environ["POLLINATIONS_TTS_MODEL"] = model
    os.environ["POLLINATIONS_TTS_VOICE"] = voice
    os.environ["POLLINATIONS_TTS_SPEED"] = speed
    os.environ["POLLINATIONS_TTS_FORMAT"] = format
    if api_key:
        os.environ["POLLINATIONS_API_KEY"] = api_key

    try:
        await set_setting("pollinations_tts_model", model, "Pollinations TTS model")
        await set_setting("pollinations_tts_voice", voice, "Pollinations TTS voice")
        await set_setting("pollinations_tts_speed", speed, "Pollinations TTS speed")
        await set_setting("pollinations_tts_format", format, "Pollinations TTS format")
        if api_key:
            await set_setting("pollinations_api_key", api_key, "Pollinations API key")
        await set_setting("tts_provider", "pollinations", "Active TTS provider")
    except Exception as exc:
        logger.exception("Failed to save Pollinations TTS settings")
        return JSONResponse({"error": f"Failed to save: {exc}"}, status_code=500)

    return {"status": "updated", "model": model, "voice": voice, "speed": speed, "format": format}


@app.post("/api/settings/pollinations/video")
async def update_pollinations_video_settings(request: Request):
    """Update Pollinations Video settings."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    model = body.get("model", "seedance").strip()
    duration = body.get("duration", "5").strip()
    aspect_ratio = body.get("aspect_ratio", "16:9").strip()
    api_key = body.get("api_key", "").strip()

    os.environ["POLLINATIONS_VIDEO_MODEL"] = model
    os.environ["POLLINATIONS_VIDEO_DURATION"] = duration
    os.environ["POLLINATIONS_VIDEO_ASPECT"] = aspect_ratio
    if api_key:
        os.environ["POLLINATIONS_API_KEY"] = api_key

    try:
        await set_setting("pollinations_video_model", model, "Pollinations video model")
        await set_setting("pollinations_video_duration", duration, "Pollinations video duration")
        await set_setting("pollinations_video_aspect", aspect_ratio, "Pollinations video aspect ratio")
        if api_key:
            await set_setting("pollinations_api_key", api_key, "Pollinations API key")
        await set_setting("video_provider", "pollinations", "Active video provider")
    except Exception as exc:
        logger.exception("Failed to save Pollinations Video settings")
        return JSONResponse({"error": f"Failed to save: {exc}"}, status_code=500)

    return {"status": "updated", "model": model, "duration": duration, "aspect_ratio": aspect_ratio}


@app.get("/api/settings/pollinations/test")
async def test_pollinations_api():
    """Test Pollinations API key by checking account balance."""
    # Try database first, then env var
    api_key = await get_setting("pollinations_api_key") or os.environ.get("POLLINATIONS_API_KEY", "")

    if not api_key:
        # Pollinations works without API key for limited usage
        return {
            "valid": True,
            "limited": True,
            "message": "No API key configured - using limited free tier",
        }

    try:
        import httpx

        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.get(
                "https://gen.pollinations.ai/account/balance",
                headers={
                    "Authorization": f"Bearer {api_key}",
                },
            )

            if resp.status_code == 200:
                data = resp.json()
                return {
                    "valid": True,
                    "limited": False,
                    "balance": data.get("balance", "unknown"),
                }
            elif resp.status_code == 401:
                return {"valid": False, "error": "Invalid API key"}
            else:
                return JSONResponse(
                    {"error": f"API error: {resp.status_code}"},
                    status_code=resp.status_code,
                )

    except Exception as exc:
        logger.exception("Pollinations API test error")
        return JSONResponse({"error": str(exc)}, status_code=500)


async def _grok_settings_summary() -> dict:
    """Build Grok accounts summary for /api/settings."""
    try:
        accounts = await list_grok_accounts()
        active = sum(1 for a in accounts if a.get("is_active"))
        return {
            "accounts_configured": active > 0,
            "active_accounts": active,
            "total_accounts": len(accounts),
        }
    except Exception:
        return {"accounts_configured": False, "active_accounts": 0, "total_accounts": 0}


@app.get("/api/settings")
async def get_all_settings_endpoint():
    """Return all configurable settings (keys masked)."""

    def _mask(val: str) -> str:
        if not val or val.startswith("your-"):
            return ""
        if len(val) > 12:
            return val[:8] + "..." + val[-4:]
        return "***"

    # Get settings from database
    try:
        db_settings = await db_get_all_settings()
    except Exception:
        db_settings = {}

    # Leonardo settings from DB with env fallback
    leonardo_key = db_settings.get("leonardo_api_key") or os.environ.get("LEONARDO_API_KEY", "")
    leonardo_resolution = db_settings.get("leonardo_resolution") or os.environ.get("LEONARDO_RESOLUTION", "720")
    video_provider = db_settings.get("video_provider", "grok")
    image_provider = db_settings.get("image_provider", "google_flow")
    tts_provider = db_settings.get("tts_provider", "elevenlabs")

    # Pollinations settings from DB with env fallback
    pollinations_key = db_settings.get("pollinations_api_key") or os.environ.get("POLLINATIONS_API_KEY", "")
    pollinations_image_model = db_settings.get("pollinations_image_model") or os.environ.get("POLLINATIONS_IMAGE_MODEL", "flux")
    pollinations_video_model = db_settings.get("pollinations_video_model") or os.environ.get("POLLINATIONS_VIDEO_MODEL", "seedance")
    pollinations_tts_voice = db_settings.get("pollinations_tts_voice") or os.environ.get("POLLINATIONS_TTS_VOICE", "nova")

    llm_model = db_settings.get("llm_model") or os.environ.get("LLM_MODEL", "openai/gpt-4o")
    parts = llm_model.split("/", 1)
    provider = parts[0] if len(parts) == 2 else "openai"
    model = parts[1] if len(parts) == 2 else parts[0]

    openai_key = db_settings.get("openai_api_key") or os.environ.get("OPENAI_API_KEY", "")
    anthropic_key = db_settings.get("anthropic_api_key") or os.environ.get("ANTHROPIC_API_KEY", "")
    kimi_key = db_settings.get("kimi_api_key") or os.environ.get("KIMI_API_KEY", "")

    return {
        "llm": {
            "provider": provider,
            "model": model,
            "openai_key": _mask(openai_key),
            "anthropic_key": _mask(anthropic_key),
            "kimi_key": _mask(kimi_key),
        },
        "image": {
            "model": os.environ.get("IMAGE_MODEL", "dall-e-3"),
            "size": os.environ.get("IMAGE_SIZE", "1792x1024"),
            "quality": os.environ.get("IMAGE_QUALITY", "hd"),
            "style": os.environ.get("IMAGE_STYLE", "natural"),
        },
        "google_flow": {
            "has_session": bool(db_settings.get("google_flow_session_token", "")),
            "has_csrf": bool(db_settings.get("google_flow_csrf_token", "")),
            "aspect_ratio": db_settings.get("google_flow_aspect_ratio", "portrait"),
            "model": "nano_banana_pro",
        },
        "grok": await _grok_settings_summary(),
        "leonardo": {
            "api_key": _mask(leonardo_key),
            "resolution": leonardo_resolution,
            "configured": bool(leonardo_key),
        },
        "pollinations": {
            "api_key": _mask(pollinations_key),
            "image_model": pollinations_image_model,
            "video_model": pollinations_video_model,
            "tts_voice": pollinations_tts_voice,
            "configured": True,  # Works without API key (limited)
            "has_api_key": bool(pollinations_key),
        },
        "video_provider": video_provider,
        "image_provider": image_provider,
        "tts_provider": tts_provider,
        "tts": {
            "elevenlabs_key": _mask(os.environ.get("ELEVENLABS_API_KEY", "")),
            "voice_id": os.environ.get("ELEVENLABS_VOICE_ID", ""),
            "model_id": os.environ.get("ELEVENLABS_MODEL_ID", "eleven_multilingual_v2"),
        },
        "video": {
            "runway_key": _mask(os.environ.get("RUNWAY_API_KEY", "")),
            "runway_model": os.environ.get("RUNWAY_MODEL", "gen3a_turbo"),
        },
        "output": {
            "dir": os.environ.get("OUTPUT_DIR", "output"),
            "platform": os.environ.get("TARGET_PLATFORM", "tiktok"),
        },
    }


@app.post("/api/settings")
async def update_settings(request: Request):
    """Update one or more settings (DB-backed for LLM, env-backed for others)."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    # DB-backed keys (LLM) — save to DB + inject into os.environ
    DB_KEYS = {
        "openai_key": ("openai_api_key", "OPENAI_API_KEY", "OpenAI API key"),
        "anthropic_key": ("anthropic_api_key", "ANTHROPIC_API_KEY", "Anthropic API key"),
        "kimi_key": ("kimi_api_key", "KIMI_API_KEY", "Kimi API key"),
        "llm_model": ("llm_model", "LLM_MODEL", "LLM model identifier"),
    }
    db_updated = []
    for frontend_key, (db_key, env_key, desc) in DB_KEYS.items():
        if frontend_key in body:
            val = str(body[frontend_key]).strip()
            if val:
                await set_setting(db_key, val, desc)
                os.environ[env_key] = val
                db_updated.append(env_key)

    # Env-backed keys (non-LLM) — keep existing behavior
    ENV_KEY_MAP = {
        "image_model": "IMAGE_MODEL",
        "image_size": "IMAGE_SIZE",
        "image_quality": "IMAGE_QUALITY",
        "image_style": "IMAGE_STYLE",
        "google_flow_aspect_ratio": "GOOGLE_FLOW_ASPECT_RATIO",
        "elevenlabs_key": "ELEVENLABS_API_KEY",
        "elevenlabs_voice_id": "ELEVENLABS_VOICE_ID",
        "elevenlabs_model_id": "ELEVENLABS_MODEL_ID",
        "runway_key": "RUNWAY_API_KEY",
        "runway_model": "RUNWAY_MODEL",
        "output_dir": "OUTPUT_DIR",
        "target_platform": "TARGET_PLATFORM",
    }

    env_updates = {}
    for frontend_key, env_key in ENV_KEY_MAP.items():
        if frontend_key in body:
            val = str(body[frontend_key]).strip()
            if val:
                env_updates[env_key] = val

    if env_updates:
        _update_env_keys(env_updates)

    all_keys = db_updated + list(env_updates.keys())
    if not all_keys:
        return JSONResponse({"error": "Nothing to update"}, status_code=400)

    return {"status": "updated", "keys": all_keys}


@app.patch("/api/settings/provider")
async def update_provider_selection(request: Request):
    """Update the active provider for a given type (image/video/tts)."""
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    provider_type = body.get("type", "")
    provider_name = body.get("provider", "")
    key_map = {"image": "image_provider", "video": "video_provider", "tts": "tts_provider"}

    if provider_type not in key_map:
        return JSONResponse(
            {"error": f"Invalid type '{provider_type}'. Must be image, video, or tts."},
            status_code=400,
        )
    if not provider_name:
        return JSONResponse({"error": "provider is required"}, status_code=400)

    await set_setting(key_map[provider_type], provider_name, f"Active {provider_type} provider")
    return {"status": "ok", "type": provider_type, "provider": provider_name}


@app.get("/api/providers/health")
async def check_providers_health():
    """Health check for all configured providers."""
    try:
        db_settings = await db_get_all_settings()
    except Exception:
        db_settings = {}

    # Image provider
    image_provider = db_settings.get("image_provider", "google_flow")
    image_status = "not_configured"
    image_details = ""
    if image_provider == "google_flow":
        has_session = bool(db_settings.get("google_flow_session_token", ""))
        image_status = "configured" if has_session else "not_configured"
        image_details = "Cookies present" if has_session else "No session token"
    elif image_provider == "pollinations":
        image_status = "configured"
        image_details = "Free cloud provider"
    elif image_provider == "dalle3":
        has_key = bool(os.environ.get("OPENAI_API_KEY", ""))
        image_status = "configured" if has_key else "not_configured"
        image_details = "API key set" if has_key else "No API key"

    # Video provider
    video_provider = db_settings.get("video_provider", "grok")
    video_status = "not_configured"
    video_details = ""
    if video_provider == "grok":
        try:
            accounts = await list_grok_accounts()
            active = sum(1 for a in accounts if a.get("is_active"))
            video_status = "configured" if active > 0 else "not_configured"
            video_details = f"{active} active account{'s' if active != 1 else ''}"
        except Exception:
            video_status = "error"
            video_details = "Failed to query accounts"
    elif video_provider == "leonardo":
        has_key = bool(db_settings.get("leonardo_api_key", "") or os.environ.get("LEONARDO_API_KEY", ""))
        video_status = "configured" if has_key else "not_configured"
        video_details = "API key set" if has_key else "No API key"
    elif video_provider == "pollinations":
        video_status = "configured"
        video_details = "Free cloud provider"

    # TTS provider
    tts_provider = db_settings.get("tts_provider", "elevenlabs")
    tts_status = "configured"
    tts_details = ""
    if tts_provider == "elevenlabs":
        has_key = bool(os.environ.get("ELEVENLABS_API_KEY", ""))
        tts_status = "configured" if has_key else "not_configured"
        tts_details = "API key set" if has_key else "No API key"
    elif tts_provider == "pollinations":
        tts_status = "configured"
        tts_details = "Free cloud provider"

    return {
        "providers": {
            "image": {"name": image_provider, "status": image_status, "details": image_details},
            "video": {"name": video_provider, "status": video_status, "details": video_details},
            "tts": {"name": tts_provider, "status": tts_status, "details": tts_details},
        },
    }


@app.get("/api/projects")
async def api_list_projects():
    """List all projects (summary)."""
    projects = await list_projects()
    return {"projects": projects}


@app.delete("/api/projects/{project_id}")
async def api_delete_project(project_id: str):
    """Delete a project and its images (cascade)."""
    import shutil

    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)
    # Remove output directory
    output_dir = _PROJECT_ROOT / "output" / project_id
    if output_dir.exists():
        shutil.rmtree(output_dir, ignore_errors=True)
    # Delete from DB (images cascade)
    await delete_project(project_id)
    return {"status": "deleted", "id": project_id}


@app.get("/api/projects/{project_id}")
async def api_get_project(project_id: str):
    """Get full project details including result and images."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)
    images = await get_project_images(project_id)
    proj["images"] = images
    videos = await get_project_videos(project_id)
    proj["videos"] = videos
    audios = await get_project_audios(project_id)
    proj["audios"] = audios
    return proj


@app.get("/api/projects/{project_id}/assets")
async def get_project_assets(project_id: str):
    """Return detailed asset metadata for CLI inspection."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    scenes = result.get("scenes", []) if result else []
    total_scenes = len(scenes)

    images_db = await get_project_images(project_id)
    videos_db = await get_project_videos(project_id)
    audios_db = await get_project_audios(project_id)

    # Build per-scene image info with disk checks
    images_list = []
    for img in (images_db or []):
        fp = img.get("file_path", "")
        full_path = Path(fp) if os.path.isabs(fp) else _PROJECT_ROOT / fp
        exists = full_path.exists()
        images_list.append({
            "scene_number": img["scene_number"],
            "file_path": fp,
            "exists": exists,
            "file_size_bytes": full_path.stat().st_size if exists else 0,
            "status": img.get("status", "pending"),
        })

    videos_list = []
    for vid in (videos_db or []):
        fp = vid.get("file_path", "")
        full_path = Path(fp) if os.path.isabs(fp) else _PROJECT_ROOT / fp
        exists = full_path.exists()
        videos_list.append({
            "scene_number": vid["scene_number"],
            "file_path": fp,
            "exists": exists,
            "file_size_bytes": full_path.stat().st_size if exists else 0,
            "duration_seconds": vid.get("duration_seconds"),
            "status": vid.get("status", "pending"),
        })

    audios_list = []
    for aud in (audios_db or []):
        fp = aud.get("file_path", "")
        full_path = Path(fp) if os.path.isabs(fp) else _PROJECT_ROOT / fp
        exists = full_path.exists()
        audios_list.append({
            "scene_number": aud["scene_number"],
            "file_path": fp,
            "exists": exists,
            "file_size_bytes": full_path.stat().st_size if exists else 0,
            "duration_seconds": aud.get("duration_seconds"),
            "status": aud.get("status", "pending"),
        })

    gen_images = [i for i in images_list if i["status"] in ("generated", "approved")]
    gen_videos = [v for v in videos_list if v["status"] == "generated"]
    gen_audios = [a for a in audios_list if a["status"] == "generated"]
    total_disk = (
        sum(i["file_size_bytes"] for i in images_list)
        + sum(v["file_size_bytes"] for v in videos_list)
        + sum(a["file_size_bytes"] for a in audios_list)
    )
    total_duration = sum(v.get("duration_seconds") or 0 for v in videos_list)

    return {
        "project_id": project_id,
        "total_scenes": total_scenes,
        "assets": {
            "images": images_list,
            "videos": videos_list,
            "audios": audios_list,
        },
        "summary": {
            "total_images": total_scenes,
            "generated_images": len(gen_images),
            "missing_images": total_scenes - len(gen_images),
            "total_videos": total_scenes,
            "generated_videos": len(gen_videos),
            "missing_videos": total_scenes - len(gen_videos),
            "total_audios": total_scenes,
            "generated_audios": len(gen_audios),
            "missing_audios": total_scenes - len(gen_audios),
            "total_disk_size_bytes": total_disk,
            "total_duration_seconds": round(total_duration, 2),
        },
    }


@app.get("/api/projects/{project_id}/generation-status")
async def get_generation_status(project_id: str):
    """Non-SSE status polling — return current generation state as plain JSON."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    scenes = result.get("scenes", []) if result else []

    images_db = await get_project_images(project_id)
    videos_db = await get_project_videos(project_id)
    audios_db = await get_project_audios(project_id)

    img_status = {
        img["scene_number"]: img.get("status", "pending")
        for img in (images_db or [])
    }
    vid_status = {
        vid["scene_number"]: vid.get("status", "pending")
        for vid in (videos_db or [])
    }
    aud_status = {
        aud["scene_number"]: aud.get("status", "pending")
        for aud in (audios_db or [])
    }

    scene_statuses = []
    for scene in scenes:
        sn = scene.get("scene_number", 0)
        scene_statuses.append({
            "scene_number": sn,
            "image": img_status.get(sn, "pending"),
            "video": vid_status.get(sn, "pending"),
            "audio": aud_status.get(sn, "pending"),
        })

    total = len(scenes)
    img_done = sum(1 for s in scene_statuses if s["image"] in ("generated", "approved"))
    vid_done = sum(1 for s in scene_statuses if s["video"] == "generated")
    aud_done = sum(1 for s in scene_statuses if s["audio"] == "generated")

    return {
        "project_id": project_id,
        "project_status": proj.get("status", "unknown"),
        "scenes": scene_statuses,
        "progress": {
            "images": {
                "done": img_done,
                "total": total,
                "percent": round(img_done * 100 / total) if total else 0,
            },
            "videos": {
                "done": vid_done,
                "total": total,
                "percent": round(vid_done * 100 / total) if total else 0,
            },
            "audios": {
                "done": aud_done,
                "total": total,
                "percent": round(aud_done * 100 / total) if total else 0,
            },
        },
    }


@app.get("/output/{project_id}/assets/{filename}")
async def serve_asset(project_id: str, filename: str):
    """Serve a generated image from per-project output directory."""
    # Path traversal guard
    if ".." in filename or "/" in filename or "\\" in filename:
        return JSONResponse({"error": "Invalid filename"}, status_code=400)
    asset_path = _PROJECT_ROOT / "output" / project_id / "assets" / filename
    if not asset_path.exists():
        return JSONResponse({"error": "File not found"}, status_code=404)
    # Double-check resolved path is inside the expected directory
    resolved = asset_path.resolve()
    expected_parent = (_PROJECT_ROOT / "output" / project_id / "assets").resolve()
    if not str(resolved).startswith(str(expected_parent)):
        return JSONResponse({"error": "Invalid path"}, status_code=400)
    return FileResponse(str(resolved))


@app.post("/api/projects/{project_id}/scenes/{scene_num}/approve")
async def approve_scene(project_id: str, scene_num: int):
    """Mark a scene image as approved."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)
    await update_image_status(project_id, scene_num, "approved")
    return {"status": "approved", "scene_number": scene_num}


@app.delete("/api/projects/{project_id}/scenes/{scene_num}/image")
async def delete_scene_image(project_id: str, scene_num: int):
    """Delete a generated image so it can be regenerated."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    # Delete the file from disk
    output_dir = _PROJECT_ROOT / "output" / project_id / "assets"
    img_path = output_dir / f"frame_{scene_num:02d}.png"
    if img_path.exists():
        img_path.unlink()

    # Delete the database record
    await delete_image(project_id, scene_num)

    return {"status": "deleted", "scene_number": scene_num}


@app.post("/api/projects/{project_id}/scenes/{scene_num}/regenerate")
async def regenerate_scene(project_id: str, scene_num: int, request: Request):
    """Regenerate a single scene image, optionally with feedback."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    if not result:
        return JSONResponse({"error": "No plan result found"}, status_code=400)

    # Find the scene's image prompt
    scenes = result.get("scenes", [])
    scene = next((s for s in scenes if s["scene_number"] == scene_num), None)
    if scene is None:
        return JSONResponse({"error": "Scene not found"}, status_code=404)

    image_prompt = scene.get("image_prompt", "")
    if not image_prompt:
        return JSONResponse({"error": "No image prompt for scene"}, status_code=400)

    # Optional feedback to refine the prompt
    feedback = ""
    try:
        body = await request.json()
        feedback = body.get("feedback", "").strip()
    except Exception:
        pass  # No body is fine

    if feedback:
        image_prompt = f"{image_prompt}\n\nAdditional direction: {feedback}"

    output_dir = _PROJECT_ROOT / "output" / project_id / "assets"
    output_dir.mkdir(parents=True, exist_ok=True)
    img_path = output_dir / f"frame_{scene_num:02d}.png"

    # Get image provider preference from database
    try:
        db_settings = await db_get_all_settings()
        image_provider_pref = db_settings.get("image_provider", "google_flow")
    except Exception:
        image_provider_pref = "google_flow"

    try:
        if image_provider_pref == "pollinations":
            from providers import get_image_provider
            
            pollinations_model = db_settings.get("pollinations_image_model", "flux")
            pollinations_width = int(db_settings.get("pollinations_image_width", "1024"))
            pollinations_height = int(db_settings.get("pollinations_image_height", "1024"))
            pollinations_key = db_settings.get("pollinations_api_key", "")
            
            img_prov = get_image_provider("pollinations", api_key=pollinations_key or None)
            await asyncio.to_thread(
                img_prov.generate,
                prompt=image_prompt,
                output_path=img_path,
                model=pollinations_model,
                width=pollinations_width,
                height=pollinations_height,
            )
        else:
            # Default to Google Flow (Camoufox anti-detect browser)
            from providers.flow_camoufox_engine import generate_image_sequential, get_engine, ASPECT_RATIOS

            # Ensure engine is initialized with DB credentials
            await get_engine(
                session_token=db_settings.get("google_flow_session_token", ""),
                csrf_token=db_settings.get("google_flow_csrf_token", ""),
            )
            aspect = ASPECT_RATIOS.get(
                db_settings.get("google_flow_aspect_ratio",
                    os.environ.get("GOOGLE_FLOW_ASPECT_RATIO", "portrait")).lower(),
                "IMAGE_ASPECT_RATIO_PORTRAIT",
            )
            await generate_image_sequential(image_prompt, img_path, aspect_ratio=aspect)

        # Update DB
        await update_image_path(project_id, scene_num, str(img_path))

        # Read image and convert to base64 for instant UI update
        import base64
        image_base64 = ""
        if img_path.exists():
            image_base64 = base64.b64encode(img_path.read_bytes()).decode("utf-8")

        return {
            "status": "regenerated",
            "scene_number": scene_num,
            "image_url": f"/output/{project_id}/assets/frame_{scene_num:02d}.png",
            "image_base64": image_base64,
            "provider": image_provider_pref,
        }
    except Exception as exc:
        logger.exception(
            "Regeneration failed for project %s scene %d", project_id, scene_num
        )
        return JSONResponse({"error": f"Regeneration failed: {exc}"}, status_code=500)


# ---------------------------------------------------------------------------
# SSE – generate images from completed plan
# ---------------------------------------------------------------------------


@app.post("/api/projects/{project_id}/generate-images")
async def generate_images_sse(project_id: str):
    """Generate images for missing scenes in a completed plan, streaming progress via SSE."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    if not result:
        return JSONResponse({"error": "No plan result found"}, status_code=400)

    scenes = result.get("scenes", [])
    if not scenes:
        return JSONResponse({"error": "No scenes in plan"}, status_code=400)

    # Find which scenes already have images
    existing_images = await get_project_images(project_id)
    existing_scene_nums = {
        img["scene_number"]
        for img in (existing_images or [])
        if img.get("status") in ("generated", "approved")
    }

    # Filter to only missing scenes
    missing_scenes = [
        s
        for s in scenes
        if s.get("scene_number", 0) not in existing_scene_nums and s.get("image_prompt")
    ]

    if not missing_scenes:
        return JSONResponse(
            {"error": "All scenes already have images"}, status_code=400
        )

    output_dir = _PROJECT_ROOT / "output" / project_id / "assets"
    output_dir.mkdir(parents=True, exist_ok=True)

    total_scenes = len(scenes)

    async def event_stream():
        total_missing = len(missing_scenes)
        # Track master mediaGenerationId per coverage group
        coverage_master_media_ids: dict[str, str] = {}

        db_settings = await db_get_all_settings()
        image_provider_name = db_settings.get("image_provider", "google_flow")

        # ── SEQUENTIAL generation — one frame at a time with 5s delay ──
        # This fixes: (A) out-of-order images, (B) images only appearing on browser close,
        # (C) FIFO Future mismatch from parallel submissions.
        INTER_FRAME_DELAY = 5  # seconds between each frame submission

        for idx, scene in enumerate(missing_scenes, 1):
            scene_num = scene.get("scene_number", idx)
            image_prompt = scene.get("image_prompt", "")
            coverage_group = scene.get("coverage_group_id")
            angle_index = scene.get("coverage_angle_index", 0)
            angle_type = scene.get("coverage_angle_type", "")
            img_path = output_dir / f"frame_{scene_num:02d}.png"

            # Emit "generating" event
            yield f"data: {json.dumps({'scene_number': scene_num, 'total': total_missing, 'total_scenes': total_scenes, 'current': idx, 'status': 'generating', 'coverage_group': coverage_group, 'angle_type': angle_type})}\n\n"

            try:
                if image_provider_name == "google_flow":
                    from providers.flow_camoufox_engine import (
                        generate_image_sequential,
                        get_engine,
                        ASPECT_RATIOS,
                    )

                    aspect = ASPECT_RATIOS.get(
                        db_settings.get("google_flow_aspect_ratio",
                            os.environ.get("GOOGLE_FLOW_ASPECT_RATIO", "portrait")).lower(),
                        "IMAGE_ASPECT_RATIO_PORTRAIT",
                    )

                    # Coverage Shot: in sequential mode, master always completes before secondary
                    reference_media_id: str | None = None
                    if coverage_group and angle_index > 0:
                        reference_media_id = coverage_master_media_ids.get(coverage_group)
                        if reference_media_id:
                            logger.info("Coverage ref: group=%s angle=%d type=%s media_id=%s…",
                                coverage_group, angle_index, angle_type, reference_media_id[:16])

                    saved_path, media_id = await generate_image_sequential(
                        image_prompt, img_path,
                        aspect_ratio=aspect,
                        reference_media_id=reference_media_id,
                    )

                    # Capture master mediaGenerationId for coverage group
                    if coverage_group and angle_index == 0 and media_id:
                        coverage_master_media_ids[coverage_group] = media_id
                        logger.info("Coverage master captured: group=%s media_id=%s…",
                            coverage_group, media_id[:16])

                elif image_provider_name == "pollinations":
                    from providers.pollinations_image_provider import PollinationsImageProvider

                    pollinations_key = db_settings.get("pollinations_api_key", "") or os.environ.get("POLLINATIONS_API_KEY", "")
                    model = db_settings.get("pollinations_image_model", "flux")
                    width = int(db_settings.get("pollinations_image_width", "1024"))
                    height = int(db_settings.get("pollinations_image_height", "1024"))

                    img_prov = PollinationsImageProvider(api_key=pollinations_key or None)
                    await img_prov.generate(
                        image_prompt, img_path,
                        model=model, width=width, height=height,
                    )

                elif image_provider_name == "dalle3":
                    from providers.image_provider import DallE3ImageProvider

                    img_prov = DallE3ImageProvider()
                    await img_prov.generate(image_prompt, img_path)

                else:
                    logger.warning("Unknown image provider '%s', falling back to google_flow", image_provider_name)
                    from providers.flow_camoufox_engine import generate_image_sequential
                    await generate_image_sequential(image_prompt, img_path)

                await upsert_image(
                    project_id=project_id,
                    scene_number=scene_num,
                    image_prompt=image_prompt,
                    file_path=str(img_path),
                    status="generated",
                )

                yield f"data: {json.dumps({'scene_number': scene_num, 'total': total_missing, 'total_scenes': total_scenes, 'current': idx, 'image_url': f'/output/{project_id}/assets/frame_{scene_num:02d}.png', 'status': 'done', 'coverage_group': coverage_group, 'angle_type': angle_type})}\n\n"

            except Exception as exc:
                logger.exception("Image generation failed for project %s scene %d", project_id, scene_num)
                yield f"data: {json.dumps({'scene_number': scene_num, 'total': total_missing, 'total_scenes': total_scenes, 'current': idx, 'error': str(exc), 'coverage_group': coverage_group})}\n\n"

            # Inter-frame delay (skip after last frame)
            if idx < total_missing:
                logger.info("Image generation: waiting %ds before next frame (%d/%d)...",
                           INTER_FRAME_DELAY, idx, total_missing)
                await asyncio.sleep(INTER_FRAME_DELAY)

        yield "event: done\ndata: {}\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )


# ---------------------------------------------------------------------------
# SSE – generate videos from images (Grok I2V)
# ---------------------------------------------------------------------------


@app.post("/api/projects/{project_id}/generate-videos")
async def generate_videos_sse(project_id: str, request: Request):
    """Generate videos for scenes that have images but no videos, streaming progress via SSE.

    Query params:
        parallel (int): Number of concurrent Grok browser workers (default 1 = sequential).
    """
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    if not result:
        return JSONResponse({"error": "No plan result found"}, status_code=400)

    scenes = result.get("scenes", [])
    if not scenes:
        return JSONResponse({"error": "No scenes in plan"}, status_code=400)

    # Find which scenes have images
    existing_images = await get_project_images(project_id)
    image_scene_nums = {
        img["scene_number"]
        for img in (existing_images or [])
        if img.get("status") in ("generated", "approved")
    }

    # Find which scenes already have videos
    existing_videos = await get_project_videos(project_id)
    video_scene_nums = {
        vid["scene_number"]
        for vid in (existing_videos or [])
        if vid.get("status") == "generated"
    }

    # Filter to scenes with images but no video
    missing_scenes = [
        s
        for s in scenes
        if s.get("scene_number", 0) in image_scene_nums
        and s.get("scene_number", 0) not in video_scene_nums
    ]

    if not missing_scenes:
        return JSONResponse(
            {"error": "All scenes with images already have videos"}, status_code=400
        )

    output_dir = _PROJECT_ROOT / "output" / project_id / "assets"

    # Get video provider preference from database
    try:
        db_settings = await db_get_all_settings()
        video_provider_pref = db_settings.get("video_provider", "grok")
        leonardo_key_db = db_settings.get("leonardo_api_key", "")
        pollinations_key_db = db_settings.get("pollinations_api_key", "")
        pollinations_video_model = db_settings.get("pollinations_video_model", "seedance")
        pollinations_video_duration = db_settings.get("pollinations_video_duration", "5")
        pollinations_video_aspect = db_settings.get("pollinations_video_aspect", "16:9")
    except Exception:
        video_provider_pref = "grok"
        leonardo_key_db = ""
        pollinations_key_db = ""
        pollinations_video_model = "seedance"
        pollinations_video_duration = "5"
        pollinations_video_aspect = "16:9"


    # Extract parallel query parameter
    try:
        parallel = int(request.query_params.get("parallel", "1"))
    except (ValueError, TypeError):
        parallel = 1
    parallel = max(1, min(parallel, 10))  # Clamp to 1-10

    async def event_stream():
        total_missing = len(missing_scenes)
        video_prov = None
        provider_name = "unknown"
        
        # Use provider based on database preference
        leonardo_key = leonardo_key_db or os.environ.get("LEONARDO_API_KEY", "")
        
        if video_provider_pref == "pollinations":
            try:
                from providers.pollinations_video_provider import PollinationsVideoProvider
                video_prov = PollinationsVideoProvider(
                    api_key=pollinations_key_db or None,
                    model=pollinations_video_model,
                    duration=int(pollinations_video_duration),
                    aspect_ratio=pollinations_video_aspect,
                )
                provider_name = "pollinations"
            except Exception as exc:
                logger.warning("Pollinations video provider failed: %s, trying Grok", exc)
        elif video_provider_pref == "leonardo" and leonardo_key:
            try:
                from providers.leonardo_video_provider import LeonardoVideoProvider
                # Set env var for provider to use
                os.environ["LEONARDO_API_KEY"] = leonardo_key
                video_prov = LeonardoVideoProvider()
                provider_name = "leonardo"
            except Exception as exc:
                logger.warning("Leonardo provider failed: %s, trying Grok", exc)
        
        # For Grok, we'll rotate accounts per-scene (handled in loop below)
        use_grok_rotation = video_prov is None
        if use_grok_rotation:
            # Verify at least one account exists
            test_account = await get_next_grok_account()
            if not test_account:
                err_payload = json.dumps({"error": "No Grok accounts configured. Add accounts in Settings."})
                yield f"data: {err_payload}\n\n"
                yield "event: done\ndata: {}\n\n"
                return
            provider_name = "grok"
        
        logger.info("Using video provider: %s", provider_name)


        # ----- PARALLEL PATH: use GrokWorkerPool when parallel > 1 -----
        if use_grok_rotation and parallel > 1:
            from providers.grok_worker_pool import GrokWorkerPool

            progress_queue: asyncio.Queue[dict | None] = asyncio.Queue()

            async def _on_pool_progress(
                scene_num: int, status: str, current: int, total: int, error: str | None = None,
            ) -> None:
                await progress_queue.put({
                    "scene_number": scene_num, "status": status,
                    "current": current, "total": total, "error": error,
                })

            async def _run_pool() -> None:
                try:
                    async with GrokWorkerPool(max_workers=parallel) as pool:
                        await pool.start()
                        scenes_data = [
                            {
                                "scene_number": s.get("scene_number", i),
                                "image_path": str(output_dir / f"frame_{s.get('scene_number', i):02d}.png"),
                                "video_prompt": s.get("video_prompt", ""),
                                "output_path": str(output_dir / f"frame_{s.get('scene_number', i):02d}.mp4"),
                            }
                            for i, s in enumerate(missing_scenes, 1)
                        ]
                        results = await pool.generate_batch(scenes=scenes_data, on_progress=_on_pool_progress)
                        for r in results:
                            if r.success:
                                vid_prompt = next(
                                    (sc.get("video_prompt", "") for sc in missing_scenes
                                     if sc.get("scene_number") == r.scene_number), ""
                                )
                                await upsert_video(
                                    project_id=project_id,
                                    scene_number=r.scene_number,
                                    video_prompt=vid_prompt,
                                    file_path=str(r.output_path),
                                    status="generated",
                                )
                except Exception as exc:
                    logger.exception("Parallel pool error for project %s", project_id)
                    await progress_queue.put({"scene_number": 0, "status": "error", "error": str(exc), "current": 0, "total": 0})
                finally:
                    await progress_queue.put(None)

            pool_task = asyncio.create_task(_run_pool())
            done_count = 0
            try:
                while True:
                    event = await progress_queue.get()
                    if event is None:
                        break
                    if event["status"] == "completed":
                        done_count += 1
                        payload = json.dumps({"scene_number": event["scene_number"], "total": total_missing, "current": done_count, "status": "done"})
                        yield f"data: {payload}\n\n"
                    elif event["status"] == "failed":
                        done_count += 1
                        payload = json.dumps({"scene_number": event["scene_number"], "total": total_missing, "current": done_count, "error": event.get("error", "Unknown")})
                        yield f"data: {payload}\n\n"
                    elif event["status"] == "generating":
                        payload = json.dumps({"scene_number": event["scene_number"], "total": total_missing, "current": done_count, "status": "generating"})
                        yield f"data: {payload}\n\n"
                    elif event["status"] == "error":
                        payload = json.dumps({"error": event.get("error", "Pool error")})
                        yield f"data: {payload}\n\n"
            finally:
                await pool_task
            yield f"data: {json.dumps({'parallel_mode': True, 'workers': parallel})}\n\n"
            yield "event: done\ndata: {}\n\n"
            return

        try:
            current_account_id = None
            for idx, scene in enumerate(missing_scenes, 1):
                scene_num = scene.get("scene_number", idx)
                video_prompt = scene.get("video_prompt", "")

                img_path = output_dir / f"frame_{scene_num:02d}.png"
                vid_path = output_dir / f"frame_{scene_num:02d}.mp4"

                # Send "generating" event
                start_payload = json.dumps(
                    {
                        "scene_number": scene_num,
                        "total": total_missing,
                        "current": idx,
                        "status": "generating",
                    }
                )
                yield f"data: {start_payload}\n\n"

                try:
                    if use_grok_rotation:
                        # Per-scene account rotation
                        account = await get_next_grok_account()
                        if not account:
                            raise RuntimeError("No active Grok accounts available")
                        
                        # Only create new provider if account changed
                        if account["id"] != current_account_id:
                            if video_prov is not None:
                                try:
                                    await video_prov.close()
                                except Exception:
                                    pass
                            from providers.grok_playwright_provider import GrokPlaywrightProvider as GrokVideoProvider
                            video_prov = GrokVideoProvider(
                                sso_token=account["sso_token"],
                                sso_rw_token=account["sso_rw_token"],
                                user_id=account["user_id"],
                                headless=False,
                            )
                            current_account_id = account["id"]
                            logger.info("Using Grok account %d (%s) for scene %d",
                                       account["id"], account["label"], scene_num)

                    await video_prov.generate(
                        image_path=img_path,
                        video_prompt=video_prompt,
                        output_path=vid_path,
                    )

                    await upsert_video(
                        project_id=project_id,
                        scene_number=scene_num,
                        video_prompt=video_prompt,
                        file_path=str(vid_path),
                        status="generated",
                    )

                    payload = json.dumps(
                        {
                            "scene_number": scene_num,
                            "total": total_missing,
                            "current": idx,
                            "status": "done",
                        }
                    )
                    yield f"data: {payload}\n\n"

                except Exception as exc:
                    logger.exception(
                        "Video generation failed for project %s scene %d",
                        project_id,
                        scene_num,
                    )
                    err_payload = json.dumps(
                        {
                            "scene_number": scene_num,
                            "total": total_missing,
                            "current": idx,
                            "error": str(exc),
                        }
                    )
                    yield f"data: {err_payload}\n\n"
        finally:
            if video_prov:
                try:
                    await video_prov.close()
                except Exception:
                    pass

        yield "event: done\ndata: {}\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )


# ---------------------------------------------------------------------------
# SSE – generate TTS audio from completed plan
# ---------------------------------------------------------------------------


@app.post("/api/projects/{project_id}/generate-audios")
async def generate_audios_sse(project_id: str):
    """Generate TTS audio for all scenes in a completed plan, streaming progress via SSE."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    if not result:
        return JSONResponse({"error": "No plan result found"}, status_code=400)

    scenes = result.get("scenes", [])
    if not scenes:
        return JSONResponse({"error": "No scenes in plan"}, status_code=400)

    # Check which scenes already have audio
    existing_audios = await get_project_audios(project_id)
    audio_scene_nums = {
        aud["scene_number"]
        for aud in (existing_audios or [])
        if aud.get("status") == "generated"
    }

    # Filter to scenes without audio
    missing_scenes = [
        s
        for s in scenes
        if s.get("scene_number", 0) not in audio_scene_nums
        and s.get("tts_text", "").strip()
    ]

    if not missing_scenes:
        return JSONResponse(
            {"message": "All scenes already have audio"}, status_code=200
        )

    output_dir = _PROJECT_ROOT / "output" / project_id / "assets"
    output_dir.mkdir(parents=True, exist_ok=True)

    async def event_stream():
        total_missing = len(missing_scenes)
        tts_prov = None
        provider_name = "elevenlabs"

        try:
            from providers import get_tts_provider
            from models import VoiceSettings

            # Get TTS provider preference from database
            try:
                db_settings = await db_get_all_settings()
                db_tts_provider = db_settings.get("tts_provider", None)
                pollinations_model = db_settings.get("pollinations_tts_model", "openai")
                pollinations_voice = db_settings.get("pollinations_tts_voice", "nova")
                pollinations_speed = db_settings.get("pollinations_tts_speed", "1.0")
                pollinations_format = db_settings.get("pollinations_tts_format", "mp3")
                pollinations_api_key = db_settings.get("pollinations_api_key", "")
            except Exception:
                db_tts_provider = None
                pollinations_model = "openai"
                pollinations_voice = "nova"
                pollinations_speed = "1.0"
                pollinations_format = "mp3"
                pollinations_api_key = ""

            # Get voice settings from project or use defaults
            voice_settings = result.get("voice_settings", {})
            # Use DB setting if available, otherwise use project setting
            provider_name = db_tts_provider or voice_settings.get("tts_provider", "elevenlabs")

            settings = VoiceSettings(
                tts_provider=provider_name,
                voice_id=voice_settings.get("voice_id", "JBFqnCBsd6RMkjVDRZzb"),
                model_id=voice_settings.get("model_id", "eleven_multilingual_v2"),
                pollinations_model=pollinations_model,
                pollinations_voice=pollinations_voice,
                pollinations_speed=float(pollinations_speed),
                pollinations_format=pollinations_format,
                pollinations_api_key=pollinations_api_key or None,
            )
            tts_prov = get_tts_provider(settings)

        except Exception as exc:
            err_payload = json.dumps({"error": f"TTS provider not available: {exc}"})
            yield f"data: {err_payload}\n\n"
            yield "event: done\ndata: {}\n\n"
            return

        # --- Determine ext and mime_type once (depends on provider, not scene) ---
        import shutil
        from pathlib import Path as _Path

        if provider_name == "pollinations":
            ext = f".{pollinations_format}"
            format_mime_map = {
                "mp3": "audio/mpeg",
                "opus": "audio/opus",
                "aac": "audio/aac",
                "flac": "audio/flac",
                "wav": "audio/wav",
                "pcm": "audio/pcm",
            }
            mime_type = format_mime_map.get(pollinations_format, "audio/mpeg")
        else:
            ext = ".mp3"
            mime_type = "audio/mpeg"

        # --- Separate masters from secondaries for coverage TTS dedup ---
        masters = []
        secondaries = []
        for s in missing_scenes:
            if s.get("coverage_angle_index", 0) == 0:
                masters.append(s)
            else:
                secondaries.append(s)

        master_audio_paths: dict[str, _Path] = {}
        total_to_process = len(masters) + len(secondaries)
        processed = 0

        # --- Pass 1: generate TTS for master scenes ---
        for scene in masters:
            processed += 1
            scene_num = scene.get("scene_number", 0)
            tts_text = scene.get("tts_text", "")
            audio_path = output_dir / f"frame_{scene_num:02d}{ext}"

            start_payload = json.dumps(
                {
                    "scene_number": scene_num,
                    "status": "generating",
                    "progress": processed,
                    "total": total_to_process,
                    "provider": provider_name,
                }
            )
            yield f"data: {start_payload}\n\n"

            try:
                # Generate audio in thread to not block
                await tts_prov.generate(tts_text, audio_path)

                # Track master path for secondaries to copy
                cov_group = scene.get("coverage_group_id")
                if cov_group:
                    master_audio_paths[cov_group] = audio_path

                # Get duration if available
                duration = None
                try:
                    duration = tts_prov.get_audio_duration(audio_path)
                except Exception:
                    pass

                # Save to database
                await upsert_audio(
                    project_id=project_id,
                    scene_number=scene_num,
                    tts_text=tts_text,
                    tts_provider=provider_name,
                    file_path=str(audio_path),
                    audio_data=audio_path.read_bytes(),
                    mime_type=mime_type,
                    duration_seconds=duration,
                    status="generated",
                )

                done_payload = json.dumps(
                    {
                        "scene_number": scene_num,
                        "status": "generated",
                        "progress": processed,
                        "total": total_to_process,
                        "audio_url": f"/output/{project_id}/assets/frame_{scene_num:02d}{ext}",
                        "mime_type": mime_type,
                        "duration": duration,
                        "provider": provider_name,
                    }
                )
                yield f"data: {done_payload}\n\n"

            except Exception as exc:
                logger.exception(
                    "TTS generation failed for project %s scene %d",
                    project_id,
                    scene_num,
                )
                err_payload = json.dumps(
                    {
                        "scene_number": scene_num,
                        "status": "error",
                        "error": str(exc),
                        "progress": processed,
                        "total": total_to_process,
                    }
                )
                yield f"data: {err_payload}\n\n"

        # --- Pass 2: copy audio for secondary coverage angles ---
        for scene in secondaries:
            processed += 1
            scene_num = scene.get("scene_number", 0)
            tts_text = scene.get("tts_text", "")
            audio_path = output_dir / f"frame_{scene_num:02d}{ext}"
            cov_group = scene.get("coverage_group_id", "")

            start_payload = json.dumps(
                {
                    "scene_number": scene_num,
                    "status": "generating",
                    "progress": processed,
                    "total": total_to_process,
                    "provider": provider_name,
                }
            )
            yield f"data: {start_payload}\n\n"

            try:
                master_path = master_audio_paths.get(cov_group)
                if master_path and master_path.exists():
                    # Copy master audio instead of generating TTS
                    shutil.copy2(master_path, audio_path)
                else:
                    # Fallback: generate TTS normally if master missing
                    logger.warning(
                        "Master audio not found for group %s, generating TTS for scene %d",
                        cov_group,
                        scene_num,
                    )
                    await tts_prov.generate(tts_text, audio_path)

                # Get duration if available
                duration = None
                try:
                    duration = tts_prov.get_audio_duration(audio_path)
                except Exception:
                    pass

                # Save to database
                await upsert_audio(
                    project_id=project_id,
                    scene_number=scene_num,
                    tts_text=tts_text,
                    tts_provider=provider_name,
                    file_path=str(audio_path),
                    audio_data=audio_path.read_bytes(),
                    mime_type=mime_type,
                    duration_seconds=duration,
                    status="generated",
                )

                done_payload = json.dumps(
                    {
                        "scene_number": scene_num,
                        "status": "generated",
                        "progress": processed,
                        "total": total_to_process,
                        "audio_url": f"/output/{project_id}/assets/frame_{scene_num:02d}{ext}",
                        "mime_type": mime_type,
                        "duration": duration,
                        "provider": provider_name,
                    }
                )
                yield f"data: {done_payload}\n\n"

            except Exception as exc:
                logger.exception(
                    "TTS generation failed for project %s scene %d",
                    project_id,
                    scene_num,
                )
                err_payload = json.dumps(
                    {
                        "scene_number": scene_num,
                        "status": "error",
                        "error": str(exc),
                        "progress": processed,
                        "total": total_to_process,
                    }
                )
                yield f"data: {err_payload}\n\n"

        yield "event: done\ndata: {}\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )


@app.post("/api/projects/{project_id}/scenes/{scene_num}/regenerate-audio")
async def regenerate_audio(project_id: str, scene_num: int):
    """Generate or regenerate TTS audio for a single scene."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    if not result:
        return JSONResponse({"error": "No plan result found"}, status_code=400)

    scenes = result.get("scenes", [])
    scene = next((s for s in scenes if s["scene_number"] == scene_num), None)
    if scene is None:
        return JSONResponse({"error": "Scene not found"}, status_code=404)

    tts_text = scene.get("tts_text", "")
    if not tts_text.strip():
        return JSONResponse({"error": "No TTS text for this scene"}, status_code=400)

    output_dir = _PROJECT_ROOT / "output" / project_id / "assets"
    output_dir.mkdir(parents=True, exist_ok=True)

    # Get TTS provider preference from database
    try:
        db_settings = await db_get_all_settings()
        db_tts_provider = db_settings.get("tts_provider", None)
        pollinations_model = db_settings.get("pollinations_tts_model", "openai")
        pollinations_voice = db_settings.get("pollinations_tts_voice", "nova")
        pollinations_speed = db_settings.get("pollinations_tts_speed", "1.0")
        pollinations_format = db_settings.get("pollinations_tts_format", "mp3")
        pollinations_api_key = db_settings.get("pollinations_api_key", "")
    except Exception:
        db_tts_provider = None
        pollinations_model = "openai"
        pollinations_voice = "nova"
        pollinations_speed = "1.0"
        pollinations_format = "mp3"
        pollinations_api_key = ""

    try:
        from providers import get_tts_provider
        from models import VoiceSettings

        voice_settings = result.get("voice_settings", {})
        # Use DB setting if available, otherwise use project setting
        provider_name = db_tts_provider or voice_settings.get("tts_provider", "elevenlabs")

        settings = VoiceSettings(
            tts_provider=provider_name,
            voice_id=voice_settings.get("voice_id", "JBFqnCBsd6RMkjVDRZzb"),
            model_id=voice_settings.get("model_id", "eleven_multilingual_v2"),
            pollinations_model=pollinations_model,
            pollinations_voice=pollinations_voice,
            pollinations_speed=float(pollinations_speed),
            pollinations_format=pollinations_format,
            pollinations_api_key=pollinations_api_key or None,
        )
        tts_prov = get_tts_provider(settings)

    except Exception as exc:
        return JSONResponse(
            {"error": f"TTS provider not available: {exc}"}, status_code=400
        )

    # Use appropriate extension based on provider
    if provider_name == "pollinations":
        ext = f".{pollinations_format}"
    else:
        ext = ".mp3"
    audio_path = output_dir / f"frame_{scene_num:02d}{ext}"

    try:
        await tts_prov.generate(tts_text, audio_path)

        duration = None
        try:
            duration = tts_prov.get_audio_duration(audio_path)
        except Exception:
            pass

        # Determine mime type based on provider and format
        if provider_name == "pollinations":
            format_mime_map = {
                "mp3": "audio/mpeg",
                "opus": "audio/opus",
                "aac": "audio/aac",
                "flac": "audio/flac",
                "wav": "audio/wav",
                "pcm": "audio/pcm",
            }
            mime_type = format_mime_map.get(pollinations_format, "audio/mpeg")
        else:
            mime_type = "audio/mpeg"

        # Read audio and convert to base64 for instant UI update
        import base64
        audio_bytes = audio_path.read_bytes()
        audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")

        await upsert_audio(
            project_id=project_id,
            scene_number=scene_num,
            tts_text=tts_text,
            tts_provider=provider_name,
            file_path=str(audio_path),
            audio_data=audio_bytes,
            mime_type=mime_type,
            duration_seconds=duration,
            status="generated",
        )

        return {
            "status": "regenerated",
            "scene_number": scene_num,
            "audio_url": f"/output/{project_id}/assets/frame_{scene_num:02d}{ext}",
            "audio_base64": audio_base64,
            "mime_type": mime_type,
            "duration": duration,
            "provider": provider_name,
        }
    except Exception as exc:
        logger.exception(
            "TTS generation failed for project %s scene %d", project_id, scene_num
        )
        return JSONResponse({"error": f"TTS generation failed: {exc}"}, status_code=500)


@app.post("/api/projects/{project_id}/scenes/{scene_num}/regenerate-video")
async def regenerate_video(project_id: str, scene_num: int):
    """Generate or regenerate a video for a single scene."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    if not result:
        return JSONResponse({"error": "No plan result found"}, status_code=400)

    scenes = result.get("scenes", [])
    scene = next((s for s in scenes if s["scene_number"] == scene_num), None)
    if scene is None:
        return JSONResponse({"error": "Scene not found"}, status_code=404)

    output_dir = _PROJECT_ROOT / "output" / project_id / "assets"
    img_path = output_dir / f"frame_{scene_num:02d}.png"
    vid_path = output_dir / f"frame_{scene_num:02d}.mp4"

    if not img_path.exists():
        return JSONResponse(
            {"error": "Image not found for this scene"}, status_code=400
        )

    video_prompt = scene.get("video_prompt", "")

    # Get video provider preference from database
    try:
        db_settings = await db_get_all_settings()
        video_provider_pref = db_settings.get("video_provider", "grok")
        leonardo_key_db = db_settings.get("leonardo_api_key", "")
        pollinations_key_db = db_settings.get("pollinations_api_key", "")
        pollinations_video_model = db_settings.get("pollinations_video_model", "seedance")
        pollinations_video_duration = db_settings.get("pollinations_video_duration", "5")
        pollinations_video_aspect = db_settings.get("pollinations_video_aspect", "16:9")
    except Exception:
        video_provider_pref = "grok"
        leonardo_key_db = ""
        pollinations_key_db = ""
        pollinations_video_model = "seedance"
        pollinations_video_duration = "5"
        pollinations_video_aspect = "16:9"

    video_prov = None
    provider_name = "unknown"
    
    leonardo_key = leonardo_key_db or os.environ.get("LEONARDO_API_KEY", "")
    
    if video_provider_pref == "pollinations":
        try:
            from providers.pollinations_video_provider import PollinationsVideoProvider
            video_prov = PollinationsVideoProvider(
                api_key=pollinations_key_db or None,
                model=pollinations_video_model,
                duration=int(pollinations_video_duration),
                aspect_ratio=pollinations_video_aspect,
            )
            provider_name = "pollinations"
        except Exception as exc:
            logger.warning("Pollinations video provider failed: %s, trying Grok", exc)
    elif video_provider_pref == "leonardo" and leonardo_key:
        try:
            from providers.leonardo_video_provider import LeonardoVideoProvider
            os.environ["LEONARDO_API_KEY"] = leonardo_key
            video_prov = LeonardoVideoProvider()
            provider_name = "leonardo"
        except Exception as exc:
            logger.warning("Leonardo provider failed: %s, trying Grok", exc)
    
    if video_prov is None:
        try:
            from providers.grok_playwright_provider import GrokPlaywrightProvider as GrokVideoProvider
            account = await get_next_grok_account()
            if not account:
                return JSONResponse(
                    {"error": "No Grok accounts configured. Add accounts in Settings."},
                    status_code=400,
                )
            video_prov = GrokVideoProvider(
                sso_token=account["sso_token"],
                sso_rw_token=account["sso_rw_token"],
                user_id=account["user_id"],
                headless=False,
            )
            provider_name = "grok"
        except Exception as exc:
            return JSONResponse(
                {"error": f"No video provider configured: {exc}"},
                status_code=400,
            )
    
    logger.info("Using video provider: %s for scene %d", provider_name, scene_num)

    try:
        await video_prov.generate(
            image_path=img_path,
            video_prompt=video_prompt,
            output_path=vid_path,
        )

        await upsert_video(
            project_id=project_id,
            scene_number=scene_num,
            video_prompt=video_prompt,
            file_path=str(vid_path),
            status="generated",
        )

        return {
            "status": "regenerated",
            "scene_number": scene_num,
        }
    except Exception as exc:
        logger.exception(
            "Video generation failed for project %s scene %d", project_id, scene_num
        )
        return JSONResponse(
            {"error": f"Video generation failed: {exc}"}, status_code=500
        )
    finally:
        try:
            await video_prov.close()
        except Exception:
            pass


# ---------------------------------------------------------------------------
# Subtitle generation – word-level timestamps from TTS audio
# ---------------------------------------------------------------------------

_stable_whisper_model = None


def _get_whisper_model():
    """Load and cache the stable-whisper faster-whisper model (large-v3 for Portuguese accuracy)."""
    global _stable_whisper_model
    if _stable_whisper_model is None:
        import torch
        import stable_whisper
        _device = "cuda" if torch.cuda.is_available() else "cpu"
        _compute = "float16" if _device == "cuda" else "int8"
        _stable_whisper_model = stable_whisper.load_faster_whisper(
            "large-v3",
            device=_device,
            compute_type=_compute,
        )
    return _stable_whisper_model


def _estimate_word_duration(word: str) -> float:
    """Estimate relative duration based on syllable count (Portuguese)."""
    vowel_groups = re.findall(r'[aeiouáéíóúâêîôûãõàü]+', word.lower())
    return max(1, len(vowel_groups))


def _syllable_weighted_subtitles(
    words: list, total_duration: float, time_start: float, scene_num: int,
) -> list:
    """Distribute duration among words proportionally to syllable count."""
    weights = [_estimate_word_duration(w) for w in words]
    total_weight = sum(weights)
    cursor = time_start
    result = []
    for i, w in enumerate(words):
        word_dur = total_duration * (weights[i] / total_weight)
        result.append({
            "scene": scene_num,
            "word": w,
            "start": round(cursor, 3),
            "end": round(cursor + word_dur, 3),
        })
        cursor += word_dur
    return result


@app.post("/api/projects/{project_id}/generate-subtitles")
async def generate_subtitles(project_id: str):
    """Generate word-level subtitle timestamps from TTS audio files.
    
    Uses stable-ts for forced alignment of known TTS text with audio.
    Falls back to simple word-duration estimation if stable-ts unavailable.
    """
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)
    
    result = proj.get("result")
    if not result:
        return JSONResponse({"error": "No plan result found"}, status_code=400)
    
    scenes = result.get("scenes", [])
    output_dir = _PROJECT_ROOT / "output" / project_id / "assets"
    
    subtitles = []
    
    for scene in scenes:
        sn = scene["scene_number"]
        tts_text = scene.get("tts_text", "")
        if not tts_text:
            continue
        
        # Find audio file
        audio_path = None
        for ext in (".wav", ".mp3", ".opus", ".aac", ".flac"):
            candidate = output_dir / f"frame_{sn:02d}{ext}"
            if candidate.exists():
                audio_path = candidate
                break
        
        if not audio_path:
            # No audio file — use syllable-weighted estimation
            words = tts_text.split()
            if not words:
                continue
            time_start = scene.get("time_start", 0)
            if isinstance(time_start, str):
                parts = time_start.split(":")
                time_start = int(parts[0]) * 60 + float(parts[1]) if len(parts) == 2 else float(time_start)
            duration = scene.get("duration_seconds", 3.0)
            subtitles.extend(_syllable_weighted_subtitles(words, duration, time_start, sn))
            continue
        
        # Try stable-ts for accurate word-level alignment
        try:
            model = _get_whisper_model()
            result_ts = model.align(str(audio_path), tts_text, language="pt", fast_mode=True, stream=False, token_step=0, nonspeech_skip=None, vad=True, vad_threshold=0.20, suppress_silence=True, suppress_word_ts=True, min_word_dur=0.05, max_word_dur=1.5, regroup=False)
            
            time_start = scene.get("time_start", 0)
            if isinstance(time_start, str):
                parts = time_start.split(":")
                time_start = int(parts[0]) * 60 + float(parts[1]) if len(parts) == 2 else float(time_start)
            
            for segment in result_ts.segments:
                for word_data in segment.words:
                    subtitles.append({
                        "scene": sn,
                        "word": word_data.word.strip(),
                        "start": round(time_start + word_data.start, 3),
                        "end": round(time_start + word_data.end, 3),
                    })
        except ImportError:
            # stable-ts not installed — fall back to estimation from audio duration
            logger.warning("stable-ts not installed, using duration-based estimation")
            try:
                from pydub import AudioSegment
                audio = AudioSegment.from_file(str(audio_path))
                audio_duration = len(audio) / 1000.0
            except Exception:
                audio_duration = scene.get("duration_seconds", 3.0)
            
            words = tts_text.split()
            if not words:
                continue
            time_start = scene.get("time_start", 0)
            if isinstance(time_start, str):
                parts = time_start.split(":")
                time_start = int(parts[0]) * 60 + float(parts[1]) if len(parts) == 2 else float(time_start)
            subtitles.extend(_syllable_weighted_subtitles(words, audio_duration, time_start, sn))
        except Exception as exc:
            logger.exception("Subtitle alignment failed for scene %d: %s", sn, exc)
            # Fall back to syllable-weighted estimation
            words = tts_text.split()
            if not words:
                continue
            time_start = scene.get("time_start", 0)
            if isinstance(time_start, str):
                parts = time_start.split(":")
                time_start = int(parts[0]) * 60 + float(parts[1]) if len(parts) == 2 else float(time_start)
            duration = scene.get("duration_seconds", 3.0)
            subtitles.extend(_syllable_weighted_subtitles(words, duration, time_start, sn))
    
    return {"subtitles": subtitles, "count": len(subtitles)}


@app.get("/api/projects/{project_id}/subtitles.srt")
async def export_subtitles_srt(project_id: str):
    """Export subtitles in SRT format from production plan."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    result = proj.get("result")
    if not result:
        return JSONResponse({"error": "No plan result found"}, status_code=400)

    scenes = result.get("scenes", [])
    if not scenes:
        return JSONResponse({"error": "No scenes in plan"}, status_code=400)

    def _time_to_srt(time_str: str) -> str:
        """Convert 'MM:SS.ss' or 'MM:SS' to SRT format 'HH:MM:SS,mmm'."""
        try:
            parts = time_str.split(":")
            if len(parts) == 2:
                minutes = int(parts[0])
                sec_parts = parts[1].split(".")
                seconds = int(sec_parts[0])
                millis = int(sec_parts[1].ljust(3, "0")[:3]) if len(sec_parts) > 1 else 0
            else:
                minutes = 0
                sec_parts = parts[0].split(".")
                seconds = int(sec_parts[0])
                millis = int(sec_parts[1].ljust(3, "0")[:3]) if len(sec_parts) > 1 else 0
            hours = minutes // 60
            minutes = minutes % 60
            return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
        except (ValueError, IndexError):
            return "00:00:00,000"

    srt_lines = []
    for idx, scene in enumerate(scenes, 1):
        tts_text = scene.get("tts_text", "").strip()
        if not tts_text:
            continue
        time_start = scene.get("time_start", "00:00")
        time_end = scene.get("time_end", "00:03")
        srt_lines.append(str(idx))
        srt_lines.append(f"{_time_to_srt(time_start)} --> {_time_to_srt(time_end)}")
        srt_lines.append(tts_text)
        srt_lines.append("")

    srt_content = "\n".join(srt_lines)
    return Response(
        content=srt_content,
        media_type="text/plain",
        headers={"Content-Disposition": 'attachment; filename="subtitles.srt"'},
    )


# ---------------------------------------------------------------------------
# Video export – FFmpeg server-side composition
# ---------------------------------------------------------------------------


@app.post("/api/projects/{project_id}/export-video")
async def export_video(project_id: str, request: Request):
    """Export final video using FFmpeg server-side composition.
    
    Receives editor project JSON with clip timings, effects, and settings.
    Composes all video/image/audio clips into a single MP4.
    Returns download URL when complete.
    """
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)
    
    try:
        editor_data = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
    
    output_dir = _PROJECT_ROOT / "output" / project_id
    assets_dir = output_dir / "assets"
    export_path = output_dir / "final_export.mp4"
    output_dir.mkdir(parents=True, exist_ok=True)
    
    # Parse tracks — payload sends array [{id:'video', clips:[...]}, ...]
    raw_tracks = editor_data.get("tracks", [])
    track_map = {}
    if isinstance(raw_tracks, list):
        for t in raw_tracks:
            tid = t.get("id") or t.get("type", "")
            track_map[tid] = t.get("clips", [])
    elif isinstance(raw_tracks, dict):
        track_map = raw_tracks
    
    video_clips = track_map.get("video", [])
    audio_clips = track_map.get("audio", [])
    subtitle_clips = track_map.get("subtitle", [])
    duration = editor_data.get("duration", 0)
    transitions = editor_data.get("transitions", [])
    
    # Fetch actual audio durations from database
    audio_durations = {}
    try:
        # Get project audios
        project_audios = await get_project_audios(project_id)
        for audio in project_audios:
            scene_num = audio.get("scene_number", 0)
            duration_sec = audio.get("duration_seconds", 0)
            audio_durations[scene_num] = duration_sec
    except Exception as e:
        logger.warning(f"Could not fetch audio durations: {e}")
    
    # Calculate total audio duration from DB (ground truth for sync)
    total_audio_duration = sum(audio_durations.values()) if audio_durations else 0.0
    logger.info(f"Total audio duration from DB: {total_audio_duration:.2f}s")
    
    # Pre-sort clips for processing
    video_clips_sorted = sorted(video_clips, key=lambda c: c.get("startTime", 0))
    
    if not video_clips:
        return JSONResponse({"error": "No video clips in project"}, status_code=400)
    
    try:
        import subprocess
        from providers.audio_sync import DurationReconciler
        from providers.export_utils import (
            fetch_scene_audio_durations,
            calculate_total_video_duration,
            build_audio_filter_chain,
            apply_audio_sync_to_ffmpeg_command,
            log_sync_details,
        )
        
        # --- Constants ---
        DEFAULT_FONT = "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"
        W, H, FPS = 1080, 1920, 30
        
        # Editor transition type -> FFmpeg xfade transition name
        XFADE_MAP = {
            # Backward-compatible aliases (legacy editor names)
            "crossfade": "fade",
            "slide-left": "slideleft", "slide-right": "slideright",
            "slide-up": "slideup", "slide-down": "slidedown",
            "zoom": "smoothup", "cross-zoom": "smoothdown",
            "spin": "circleopen", "circle-wipe": "circlecrop",
            "wipe-left": "wipeleft", "wipe-right": "wiperight",
            "cut": None,  # hard cut = no xfade
            # All 58 FFmpeg xfade transitions (direct names)
            "fade": "fade",
            "wipeleft": "wipeleft", "wiperight": "wiperight",
            "wipeup": "wipeup", "wipedown": "wipedown",
            "slideleft": "slideleft", "slideright": "slideright",
            "slideup": "slideup", "slidedown": "slidedown",
            "circlecrop": "circlecrop", "rectcrop": "rectcrop",
            "distance": "distance", "fadeblack": "fadeblack",
            "fadewhite": "fadewhite", "radial": "radial",
            "smoothleft": "smoothleft", "smoothright": "smoothright",
            "smoothup": "smoothup", "smoothdown": "smoothdown",
            "circleopen": "circleopen", "circleclose": "circleclose",
            "vertopen": "vertopen", "vertclose": "vertclose",
            "horzopen": "horzopen", "horzclose": "horzclose",
            "dissolve": "dissolve", "pixelize": "pixelize",
            "diagtl": "diagtl", "diagtr": "diagtr",
            "diagbl": "diagbl", "diagbr": "diagbr",
            "hlslice": "hlslice", "hrslice": "hrslice",
            "vuslice": "vuslice", "vdslice": "vdslice",
            "hblur": "hblur", "fadegrays": "fadegrays",
            "wipetl": "wipetl", "wipetr": "wipetr",
            "wipebl": "wipebl", "wipebr": "wipebr",
            "squeezeh": "squeezeh", "squeezev": "squeezev",
            "zoomin": "zoomin", "fadefast": "fadefast",
            "fadeslow": "fadeslow",
            "hlwind": "hlwind", "hrwind": "hrwind",
            "vuwind": "vuwind", "vdwind": "vdwind",
            "coverleft": "coverleft", "coverright": "coverright",
            "coverup": "coverup", "coverdown": "coverdown",
            "revealleft": "revealleft", "revealright": "revealright",
            "revealup": "revealup", "revealdown": "revealdown",
        }
        
        COLOR_PRESETS = {
            "warm":      "eq=brightness=0.04:saturation=1.3:gamma_r=1.1:gamma_b=0.9",
            "cool":      "eq=brightness=0.02:saturation=1.1:gamma_r=0.9:gamma_b=1.1",
            "cinematic": "eq=brightness=-0.05:contrast=1.2:saturation=0.9",
            "vibrant":   "eq=saturation=1.5:contrast=1.1",
            "bw":        "eq=saturation=0",
            "vintage":   "eq=brightness=0.06:saturation=0.7:gamma=0.9",
            "film-grain":    "noise=alls=20:allf=t+u,eq=brightness=-0.02:contrast=1.05",
            "vignette":      "vignette=PI/4",
            "bloom":         "gblur=sigma=5,eq=brightness=0.1",
            "high-contrast": "eq=contrast=1.5:brightness=-0.05:saturation=1.1",
            "muted":         "eq=saturation=0.5:brightness=0.03:contrast=0.95",
            "sepia":         "colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131",
            "teal-orange":   "colorbalance=rs=0.1:gs=-0.1:bs=-0.1:rm=0.1:gm=0.0:bm=-0.1:rh=0.0:gh=-0.05:bh=0.1",
            "noir":          "eq=saturation=0:contrast=1.4:brightness=-0.1,curves=preset=cross_process",
            "dreamy":        "gblur=sigma=2,eq=brightness=0.08:saturation=1.2:contrast=0.9",
            "horror":        "eq=brightness=-0.15:contrast=1.3:saturation=0.4:gamma_r=1.2:gamma_b=0.8",
        }
        
        def _sanitize_drawtext(text: str) -> str:
            """Escape special chars for FFmpeg drawtext."""
            text = text.replace("\\", "\\\\")
            text = text.replace("'", "'\\''")
            text = text.replace(":", "\\:")
            text = text.replace("%", "%%")
            return text
        
        # --- Parse effects from editor data ---
        effects = editor_data.get("effects", {})
        

        # Subtitle mode
        subtitle_mode = effects.get('subtitleMode', 'single_word')  # single_word | grouped | karaoke

        # Intro montage config
        intro_cfg = effects.get('intro', {})
        intro_enabled = intro_cfg.get('enabled', True)
        intro_clip_count = intro_cfg.get('clipCount', 5)
        intro_transition = intro_cfg.get('transition', 'fadewhite')
        intro_transition_duration = intro_cfg.get('transitionDuration', 0.1)
        # Dynamic intro clip duration: fill entire Scene 1 audio
        # Formula: total_dur = clip_count * clip_dur - (clip_count-1) * transition_dur
        # So: clip_dur = (audio_dur + (clip_count-1) * transition_dur) / clip_count
        _intro_audio_dur = audio_durations.get(1, 0)
        if _intro_audio_dur > 0 and intro_clip_count > 0:
            intro_clip_duration = (_intro_audio_dur + (intro_clip_count - 1) * intro_transition_duration) / intro_clip_count
        else:
            intro_clip_duration = intro_cfg.get('clipDuration', 0.4)

        # Time marker config
        marker_cfg = effects.get('timeMarkers', {})
        markers_enabled = marker_cfg.get('enabled', True)
        marker_style = marker_cfg.get('style', 'slam')  # slam | fade | none
        marker_duration = marker_cfg.get('duration', 1.2)  # seconds on screen
        marker_font_size = marker_cfg.get('fontSize', 400)
        # Timing config (parameterizable via effects.timing)
        timing_cfg = effects.get('timing', {})
        marker_gap = timing_cfg.get('markerGap', 0.75)          # silence AFTER marker voice, before narrative speech
        pre_marker_gap = timing_cfg.get('preMarkerGap', 1.0)    # silence at END of scene before TIME_MARKER (video keeps playing)
        scene_end_silence = timing_cfg.get('sceneEndSilence', 0.5)   # silence at END of standard scene (video keeps playing)
        scene_start_silence = timing_cfg.get('sceneStartSilence', 0.5) # silence at START of next standard scene (before speech)
        standard_transition = timing_cfg.get('standardTransition', 'cut')  # transition type between non-marker scenes
        standard_transition_dur = timing_cfg.get('standardTransitionDuration', 0.001)  # duration of standard transition

        # SFX config
        sfx_cfg = effects.get('sfx', {})
        sfx_enabled = sfx_cfg.get('enabled', True)
        sfx_transitions = sfx_cfg.get('transitions', True)
        sfx_markers = sfx_cfg.get('markers', True)
        sfx_volume = sfx_cfg.get('volume', 0.5)


        # Background music config
        bgm_cfg = effects.get('bgMusic', {})
        bgm_enabled = bgm_cfg.get('enabled', True)
        bgm_volume = bgm_cfg.get('volume', 0.35)  # 35% default — sits behind narration
        bgm_file = bgm_cfg.get('file', None)  # custom file path, or None for default

        # --- Helper: convert Portuguese written-out numbers to digits ---
        def _pt_words_to_number(text: str) -> int | None:
            """Convert Portuguese written number to integer. Returns None if not parseable."""
            UNITS = {'um': 1, 'uma': 1, 'dois': 2, 'duas': 2, 'três': 3, 'tres': 3,
                     'quatro': 4, 'cinco': 5, 'seis': 6, 'sete': 7, 'oito': 8, 'nove': 9}
            TEENS = {'dez': 10, 'onze': 11, 'doze': 12, 'treze': 13, 'catorze': 14, 'quatorze': 14,
                     'quinze': 15, 'dezesseis': 16, 'dezessete': 17, 'dezoito': 18, 'dezenove': 19}
            TENS = {'vinte': 20, 'trinta': 30, 'quarenta': 40, 'cinquenta': 50,
                    'sessenta': 60, 'setenta': 70, 'oitenta': 80, 'noventa': 90}
            HUNDREDS = {'cem': 100, 'cento': 100, 'duzentos': 200, 'duzentas': 200,
                        'trezentos': 300, 'trezentas': 300, 'quatrocentos': 400, 'quatrocentas': 400,
                        'quinhentos': 500, 'quinhentas': 500, 'seiscentos': 600, 'seiscentas': 600,
                        'setecentos': 700, 'setecentas': 700, 'oitocentos': 800, 'oitocentas': 800,
                        'novecentos': 900, 'novecentas': 900}
            SPECIAL = {'mil': 1000}

            words = [w.strip().lower() for w in text.replace(' e ', ' ').split() if w.strip()]
            if not words:
                return None
            total = 0
            current = 0
            for w in words:
                if w in UNITS:
                    current += UNITS[w]
                elif w in TEENS:
                    current += TEENS[w]
                elif w in TENS:
                    current += TENS[w]
                elif w in HUNDREDS:
                    current += HUNDREDS[w]
                elif w in SPECIAL:
                    current = (current if current else 1) * SPECIAL[w]
                else:
                    return None
            total += current
            return total if total > 0 else None

        # --- Scene type detection from TTS text ---
        TIME_MARKER_RE = re.compile(r'^(Dia\s+[\w\s]+?)\.\s*', re.IGNORECASE)
        scene_types = {}  # scene_number -> {type, marker_text, narrative_text}
        proj_result_st = proj.get('result', {})
        proj_scenes_st = proj_result_st.get('scenes', []) if isinstance(proj_result_st, dict) else []
        for scene_st in proj_scenes_st:
            sn_st = scene_st.get('scene_number', 0)
            tts_text_st = scene_st.get('tts_text', '')
            if sn_st == 1:
                scene_types[sn_st] = {'type': 'intro', 'marker_text': None, 'narrative_text': tts_text_st}
            elif markers_enabled:
                match_st = TIME_MARKER_RE.match(tts_text_st.strip())
                if match_st:
                    marker_text_st = match_st.group(1).strip()
                    # Convert "Dia trinta" -> "Dia 30" etc.
                    day_words = marker_text_st.split(None, 1)  # ["Dia", "trinta"]
                    if len(day_words) == 2:
                        num = _pt_words_to_number(day_words[1])
                        if num is not None:
                            marker_text_st = f"Dia {num}"
                    rest_st = tts_text_st[match_st.end():].strip()
                    scene_types[sn_st] = {'type': 'time_marker', 'marker_text': marker_text_st, 'original_marker_text': match_st.group(1).strip(), 'narrative_text': rest_st}
                else:
                    scene_types[sn_st] = {'type': 'narrative', 'marker_text': None, 'narrative_text': tts_text_st}
            else:
                scene_types[sn_st] = {'type': 'narrative', 'marker_text': None, 'narrative_text': tts_text_st}
        logger.info(f"Scene types detected: { {sn: st['type'] for sn, st in scene_types.items()} }")

        # Pre-compute marker voice boundaries for audio gap insertion
        output_dir_assets = _PROJECT_ROOT / 'output' / project_id / 'assets'
        marker_voice_ends = {}  # scene_number -> float (seconds where marker voice ends)
        if markers_enabled and marker_gap > 0:
            for sn_mv, st_mv in scene_types.items():
                if st_mv['type'] != 'time_marker':
                    continue
                tts_mv = None
                for scene_mv in proj_scenes_st:
                    if scene_mv.get('scene_number') == sn_mv:
                        tts_mv = scene_mv.get('tts_text', '')
                        break
                if not tts_mv:
                    continue
                for ext_mv in ('.mp3', '.wav', '.opus', '.aac', '.flac'):
                    af_mv = output_dir_assets / f'frame_{sn_mv:02d}{ext_mv}'
                    if af_mv.exists():
                        try:
                            model_mv = _get_whisper_model()
                            res_mv = model_mv.align(str(af_mv), tts_mv, language='pt', fast_mode=True, stream=False, token_step=0, nonspeech_skip=None, vad=True, vad_threshold=0.20, suppress_silence=True, suppress_word_ts=True, min_word_dur=0.05, max_word_dur=1.5, regroup=False)
                            for seg_mv in res_mv.segments:
                                for wd_mv in seg_mv.words:
                                    if '.' in wd_mv.word.strip():
                                        marker_voice_ends[sn_mv] = wd_mv.end
                                        break
                                if sn_mv in marker_voice_ends:
                                    break
                        except Exception as e_mv:
                            logger.warning(f'Marker boundary detection failed for scene {sn_mv}: {e_mv}')
                        break
                if sn_mv not in marker_voice_ends:
                    marker_voice_ends[sn_mv] = 0.8  # fallback estimate
            logger.info(f'Marker voice boundaries: {marker_voice_ends}')

        # Compute pre-marker scenes (scenes that immediately precede a TIME_MARKER)
        pre_marker_scenes = set()
        for sn_pm, st_pm in scene_types.items():
            if st_pm['type'] == 'time_marker' and sn_pm > 1:
                prev_sn = sn_pm - 1
                if prev_sn in audio_durations:
                    pre_marker_scenes.add(prev_sn)

        # Extend audio durations: +pre_marker_gap for pre-marker scenes, +marker_gap for TIME_MARKER,
        # +scene_end_silence/scene_start_silence for standard scenes
        scene_start_silence_scenes = set()  # scenes that get silence at START of their audio
        if markers_enabled:
            for sn_ext in pre_marker_scenes:
                audio_durations[sn_ext] += pre_marker_gap
            for sn_ext, st_ext in scene_types.items():
                if st_ext['type'] == 'time_marker' and sn_ext in audio_durations:
                    audio_durations[sn_ext] += marker_gap
            # Standard (non-pre-marker) scenes: add end silence
            for sn_ext in audio_durations:
                if sn_ext not in pre_marker_scenes:
                    audio_durations[sn_ext] += scene_end_silence
            # Determine which scenes need start silence (scenes after a standard scene, not TIME_MARKERs)
            sorted_scenes = sorted(audio_durations.keys())
            for idx_ss, sn_ss in enumerate(sorted_scenes):
                if idx_ss == 0:
                    continue  # first scene (intro) has no preceding standard scene
                prev_sn_ss = sorted_scenes[idx_ss - 1]
                # If the previous scene is a standard scene (not pre-marker), this scene gets start silence
                if prev_sn_ss not in pre_marker_scenes and scene_types.get(sn_ss, {}).get('type') != 'time_marker':
                    audio_durations[sn_ss] += scene_start_silence
                    scene_start_silence_scenes.add(sn_ss)
            total_audio_duration = sum(audio_durations.values())
            logger.info(f"Extended audio: {len(pre_marker_scenes)} pre-marker scenes (+{pre_marker_gap}s), "
                        f"{sum(1 for s in scene_types.values() if s['type']=='time_marker')} markers (+{marker_gap}s), "
                        f"standard scenes (+{scene_end_silence}s end, +{scene_start_silence}s start on {len(scene_start_silence_scenes)} scenes), "
                        f"total: {total_audio_duration:.2f}s")

        # --- Build FFmpeg filter_complex ---
        inputs = []
        filter_parts = []
        
        # Sort clips by startTime
        video_clips_sorted = sorted(video_clips, key=lambda c: c.get("startTime", 0))
        audio_clips_sorted = sorted(audio_clips, key=lambda c: c.get("startTime", 0))
        
        input_idx = 0
        video_labels = []  # plain label strings like "v0", "v1" (no brackets)
        clip_durations = []
        clip_ids = []
        
        video_audio_info = []  # (is_video, input_idx, clip_duration) for video audio mixing
        def _get_video_duration(path):
            try:
                r = subprocess.run(
                    ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', str(path)],
                    capture_output=True, text=True, timeout=10
                )
                return float(json.loads(r.stdout)['format']['duration'])
            except Exception:
                return 0.0

        # ---- 1. Build inputs + per-clip video filters ----
        for clip_index, clip in enumerate(video_clips_sorted):
            src = clip.get("videoUrl") or clip.get("imageUrl") or clip.get("src", "")
            if not src:
                continue
            file_path = _PROJECT_ROOT / src.lstrip("/")
            if not file_path.exists():
                logger.warning("Export: missing file %s", file_path)
                continue
            
            clip_duration = clip.get("duration", 3.0)
            editor_clip_duration = clip_duration  # preserve original for tpad calc
            
            # Adjust clip duration to match actual audio for this scene
            if audio_durations:
                scene_num = clip.get("scene_number")
                if scene_num is None:
                    scene_num = clip_index + 1  # fallback: scenes are 1-indexed
                audio_dur = audio_durations.get(scene_num, 0)
                if audio_dur > 0:
                    clip_duration = max(clip_duration, audio_dur)
            # Last scene: use natural video duration (don't trim short)
            if clip_index == len(video_clips_sorted) - 1:
                actual_last_vid_dur = _get_video_duration(file_path) if str(file_path).endswith('.mp4') else 0
                if actual_last_vid_dur > 0:
                    clip_duration = max(clip_duration, actual_last_vid_dur)
            label = f"v{input_idx}"
            is_image = not str(file_path).endswith(".mp4")
            kb = clip.get("kenBurns")
            use_ken_burns = bool(is_image and kb and kb.get("enabled"))
            
            if not is_image:
                # Video file
                inputs.extend(["-t", str(clip_duration), "-i", str(file_path)])
            elif use_ken_burns:
                # Image with Ken Burns: single frame input (zoompan controls duration)
                inputs.extend(["-i", str(file_path)])
            else:
                # Image without Ken Burns: loop for clip duration
                inputs.extend(["-loop", "1", "-t", str(clip_duration), "-i", str(file_path)])
            
            if use_ken_burns:
                # Ken Burns zoompan on image clips
                frames = int(clip_duration * FPS)
                s_scale = kb.get("startScale", 1.0)
                e_scale = kb.get("endScale", 1.15)
                s_x = kb.get("startX", 0.5)
                s_y = kb.get("startY", 0.5)
                e_x = kb.get("endX", 0.5)
                e_y = kb.get("endY", 0.5)
                filter_parts.append(
                    f"[{input_idx}:v]scale={W*2}:{H*2},"
                    f"zoompan=z='min({s_scale}+({e_scale}-{s_scale})*on/{frames},{e_scale})'"
                    f":x='iw*({s_x}+({e_x}-{s_x})*on/{frames})-iw/zoom/2'"
                    f":y='ih*({s_y}+({e_y}-{s_y})*on/{frames})-ih/zoom/2'"
                    f":d={frames}:s={W}x{H}:fps={FPS},"
                    f"setsar=1[{label}]"
                )
            elif is_image:
                # Image without Ken Burns: standard scale + pad
                filter_parts.append(
                    f"[{input_idx}:v]scale={W}:{H}:force_original_aspect_ratio=decrease,"
                    f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:black,"
                    f"setsar=1,fps={FPS}[{label}]"
                )
            else:
                # Video file: scale + pad + speed adjust (slow down to match audio) + trim
                actual_vid_dur = _get_video_duration(file_path)
                if actual_vid_dur > 0 and clip_duration > actual_vid_dur:
                    # Video is shorter than needed — slow it down to fill clip_duration
                    speed_factor = actual_vid_dur / clip_duration  # < 1.0 = slower
                    filter_parts.append(
                        f"[{input_idx}:v]scale={W}:{H}:force_original_aspect_ratio=decrease,"
                        f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:black,"
                        f"setsar=1,fps={FPS},"
                        f"setpts=PTS/{speed_factor:.4f},"
                        f"trim=end={clip_duration:.3f},setpts=PTS-STARTPTS,"
                        f"tpad=stop_mode=clone:stop_duration=0.5[{label}]"
                    )
                else:
                    # Video is long enough — just trim (with tpad safety net)
                    filter_parts.append(
                        f"[{input_idx}:v]scale={W}:{H}:force_original_aspect_ratio=decrease,"
                        f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:black,"
                        f"setsar=1,fps={FPS},"
                        f"trim=end={clip_duration:.3f},setpts=PTS-STARTPTS,"
                        f"tpad=stop_mode=clone:stop_duration=0.5[{label}]"
                    )
            
            video_labels.append(label)
            clip_durations.append(clip_duration)
            clip_ids.append(clip.get("id", ""))
            video_audio_info.append((not is_image, input_idx, clip_duration))
            input_idx += 1
        
        # ---- 1b. Intro montage (rapid clips with fadewhite) ----
        if intro_enabled and scene_types.get(1, {}).get('type') == 'intro' and len(video_labels) > 1:
            # Gather source files from scenes 2..N for montage clips
            montage_sources = []
            for mc_idx, mc_clip in enumerate(video_clips_sorted[1:], start=2):
                if len(montage_sources) >= intro_clip_count:
                    break
                mc_src = mc_clip.get('videoUrl') or mc_clip.get('imageUrl') or mc_clip.get('src', '')
                if mc_src:
                    mc_path = _PROJECT_ROOT / mc_src.lstrip('/')
                    if mc_path.exists():
                        montage_sources.append(mc_path)

            if montage_sources:
                # Check if intro needs extra time for pre-marker gap
                intro_audio_dur = audio_durations.get(1, 0)
                intro_extra = max(0, intro_audio_dur - (len(montage_sources) * intro_clip_duration - (len(montage_sources) - 1) * intro_transition_duration)) if intro_audio_dur > 0 else 0
                montage_labels = []
                for mi, mc_path in enumerate(montage_sources):
                    mc_is_video = str(mc_path).endswith('.mp4')
                    mc_label = f'montage{mi}'
                    # Last montage clip gets extra duration so video keeps playing during gap
                    mc_dur = intro_clip_duration
                    if mi == len(montage_sources) - 1 and intro_extra > 0:
                        mc_dur += intro_extra
                    if mc_is_video:
                        inputs.extend(['-ss', '0.3', '-t', str(mc_dur), '-i', str(mc_path)])
                    else:
                        inputs.extend(['-loop', '1', '-t', str(mc_dur), '-i', str(mc_path)])
                    filter_parts.append(
                        f'[{input_idx}:v]scale={W}:{H}:force_original_aspect_ratio=decrease,'
                        f'pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:black,'
                        f'setsar=1,fps={FPS}[{mc_label}]'
                    )
                    montage_labels.append(mc_label)
                    input_idx += 1
                if len(montage_labels) == 1:
                    montage_out = montage_labels[0]
                    montage_total_dur = intro_clip_duration + intro_extra
                else:
                    mc_prev = montage_labels[0]
                    mc_cum = intro_clip_duration
                    for mci in range(1, len(montage_labels)):
                        mc_offset = max(0, mc_cum - intro_transition_duration)
                        mc_out = f'mxf{mci}' if mci < len(montage_labels) - 1 else 'montage_final'
                        filter_parts.append(
                            f'[{mc_prev}][{montage_labels[mci]}]xfade=transition={intro_transition}'
                            f':duration={intro_transition_duration:.3f}:offset={mc_offset:.3f}[{mc_out}]'
                        )
                        mc_prev = mc_out
                        # Last clip uses extended duration
                        if mci == len(montage_labels) - 1:
                            mc_cum += (intro_clip_duration + intro_extra) - intro_transition_duration
                        else:
                            mc_cum += intro_clip_duration - intro_transition_duration
                    montage_out = mc_prev
                    montage_total_dur = mc_cum
                # Remove the original v0 filter that is now unused (would cause 'unconnected' error)
                old_v0_label = 'v0'
                filter_parts[:] = [f for f in filter_parts if not f.endswith(f'[{old_v0_label}]')]
                video_labels[0] = montage_out
                clip_durations[0] = montage_total_dur
                logger.info(f'Intro montage: {len(montage_sources)} clips, {montage_total_dur:.2f}s total')

        # Build scene_number -> clip_duration mapping (for audio padding sync)
        scene_to_clip_dur = {}
        for ci, cd in enumerate(clip_durations):
            scene_to_clip_dur[ci + 1] = cd

        # Recalculate video duration using audio-adjusted clip durations
        calculated_video_duration = sum(clip_durations) if clip_durations else 0.0
        if transitions:
            for tr in transitions:
                calculated_video_duration -= tr.get("duration", 0.5) * 0.5
        logger.info(f"Adjusted video duration (with audio sync): {calculated_video_duration:.2f}s")

        # Add fade-to-black at the end of the video
        vid_fade_dur = 2.0
        vid_fade_start = max(0, calculated_video_duration - vid_fade_dur)

        n = len(video_labels)
        if n == 0:
            return JSONResponse({"error": "No valid video sources found"}, status_code=400)
        
        # ---- 2. xfade chaining between video clips ----
        trans_lookup = {}
        for tr in transitions:
            trans_lookup[(tr.get("fromClipId"), tr.get("toClipId"))] = {
                "type": tr.get("type", "fade"),
                "duration": tr.get("duration", 0.5),
            }

        # Force fadewhite transition before TIME_MARKER scenes
        for ci_tr in range(1, len(clip_ids)):
            tr_scene_num = ci_tr + 1  # clip_index is 0-indexed, scenes are 1-indexed
            if scene_types.get(tr_scene_num, {}).get('type') == 'time_marker':
                tr_key = (clip_ids[ci_tr - 1], clip_ids[ci_tr])
                trans_lookup[tr_key] = {"type": "fadewhite", "duration": 0.3}
                logger.info(f'Forced fadewhite transition before TIME_MARKER scene {tr_scene_num}')

        # Force standard transition (default: cut) between non-marker standard scenes
        for ci_tr in range(1, len(clip_ids)):
            tr_scene_num = ci_tr + 1
            if scene_types.get(tr_scene_num, {}).get('type') != 'time_marker':
                tr_key = (clip_ids[ci_tr - 1], clip_ids[ci_tr])
                trans_lookup[tr_key] = {'type': standard_transition, 'duration': standard_transition_dur}
                logger.info(f'Forced {standard_transition} transition before standard scene {tr_scene_num}')

        if n == 1:
            vid_label = video_labels[0]
        else:
            cum_dur = clip_durations[0]
            prev = video_labels[0]
            for i in range(1, n):
                key = (clip_ids[i - 1], clip_ids[i])
                tr = trans_lookup.get(key, {"type": "fade", "duration": 0.5})
                tr_type = tr["type"]
                tr_dur = tr["duration"]
                ffmpeg_name = XFADE_MAP.get(tr_type)
                
                if ffmpeg_name is None:
                    # "cut" transition -> near-instant fade
                    ffmpeg_name = "fade"
                    tr_dur = 0.001
                
                offset = max(0, cum_dur - tr_dur)
                out = f"xf{i}" if i < n - 1 else "vxfade"
                filter_parts.append(
                    f"[{prev}][{video_labels[i]}]xfade=transition={ffmpeg_name}"
                    f":duration={tr_dur:.3f}:offset={offset:.3f}[{out}]"
                )
                prev = out
                cum_dur += clip_durations[i] - tr_dur
            vid_label = prev
        
        # ---- 3. Color grading (optional) ----
        color_preset = effects.get("colorGrading", "")
        if color_preset in COLOR_PRESETS:
            eq_filter = COLOR_PRESETS[color_preset]
            filter_parts.append(f"[{vid_label}]{eq_filter}[vgraded]")
            vid_label = "vgraded"

        # Fade to black on the last 2 seconds of video
        filter_parts.append(f'[{vid_label}]fade=t=out:st={vid_fade_start:.3f}:d={vid_fade_dur:.3f}[vfade]')
        vid_label = 'vfade'

        # ---- 3b. Time marker overlays ("Dia X" slam text) ----
        # Calculate scene start times (used by markers AND SFX)
        scene_start_times = {}
        tm_cum = 0.0
        for si, cd in enumerate(clip_durations):
            scene_num_for_time = si + 1  # 1-indexed
            scene_start_times[scene_num_for_time] = tm_cum
            # Subtract transition overlap for next scene
            tr_overlap = 0.0
            if si < len(clip_durations) - 1:
                tr_overlap = 0.25  # default xfade overlap estimate
                if si < len(clip_ids) and si + 1 < len(clip_ids):
                    tr_key = (clip_ids[si], clip_ids[si + 1])
                    tr_info = trans_lookup.get(tr_key)
                    if tr_info:
                        tr_overlap = tr_info.get('duration', 0.25)
                tm_cum += cd - tr_overlap
        # Time marker overlays are now rendered as ASS events (injected after subtitle generation)
        # This ensures markers use the same bold Montserrat font as subtitles
        # ---- 4. Subtitle burn-in via ASS (replaces drawtext) ----
        from web.subtitle_engine import generate_ass
        
        # Determine subtitle preset from subtitle_mode
        _subtitle_mode_presets = {
            'single_word': 'single_word_pop',
            'karaoke': 'tiktok_karaoke',
            'grouped': 'bold_center',
        }
        subtitle_style = effects.get('subtitleStyle', {})
        if 'preset' not in subtitle_style:
            subtitle_style['preset'] = _subtitle_mode_presets.get(subtitle_mode, 'single_word_pop')
        all_words = []
        if not subtitle_clips:
            try:
                proj_result = proj.get('result', {})
                scenes = proj_result.get('scenes', []) if isinstance(proj_result, dict) else []
                output_dir_assets = _PROJECT_ROOT / 'output' / project_id / 'assets'
                # Use VIDEO timeline scene_start_times for subtitle positioning
                # (audio also uses acrossfade with same transition durations as video xfade,
                # so both audio and video timelines are compressed equally)
                for scene in scenes:
                    sn = scene.get('scene_number', 0)
                    if not sn:
                        continue
                    # Use scene_start_times (accounts for transition compression)
                    cum_time = scene_start_times.get(sn, 0)
                    tts_text = scene.get('tts_text', '')
                    if not tts_text:
                        continue
                    # Use scene_types to determine subtitle text and timing offset
                    st_info = scene_types.get(sn, {})
                    st_type = st_info.get('type', 'narrative')
                    if st_type == 'time_marker' and st_info.get('narrative_text'):
                        # For time_marker scenes: subtitle only the narrative part (after "Dia X.")
                        sub_text = st_info['narrative_text']
                        timing_offset = (1.0 + marker_duration + 0.75) if markers_enabled else 0
                    else:
                        # For intro and narrative scenes: use full tts_text
                        sub_text = tts_text
                        timing_offset = scene_start_silence if sn in scene_start_silence_scenes else 0
                    scene_dur = audio_durations.get(sn, scene.get('duration_seconds', 3.0))
                    if not sub_text:
                        continue
                    words = sub_text.split()
                    if not words:
                        continue
                    aligned = False
                    for ext in ('.wav', '.mp3', '.opus', '.aac', '.flac'):
                        audio_file = output_dir_assets / f'frame_{sn:02d}{ext}'
                        if audio_file.exists():
                            try:
                                model = _get_whisper_model()
                                # Always align FULL tts_text so whisper finds correct
                                # absolute word positions (forced alignment of partial text
                                # maps narrative words to position 0 instead of after marker voice)
                                align_text = tts_text
                                result_ts = model.align(str(audio_file), align_text, language='pt', fast_mode=True, stream=False, token_step=0, nonspeech_skip=None, vad=True, vad_threshold=0.20, suppress_silence=True, suppress_word_ts=True, min_word_dur=0.05, max_word_dur=1.5, regroup=False)
                                # For time_marker scenes, determine boundary between marker voice and narrative
                                _mv_end = marker_voice_ends.get(sn, 0.8) if (st_type == 'time_marker' and markers_enabled) else 0
                                for segment in result_ts.segments:
                                    for wd in segment.words:
                                        # Skip marker voice words (before mv_end) for time_marker scenes
                                        if st_type == 'time_marker' and markers_enabled and _mv_end > 0:
                                            if wd.end <= _mv_end + 0.05:
                                                continue
                                        # Whisper timestamps are correct absolute positions in the audio file.
                                        # For TIME_MARKER scenes: add marker_gap (silence inserted after marker voice)
                                        # For start_silence scenes: add scene_start_silence (silence prepended)
                                        _audio_offset = marker_gap if (st_type == 'time_marker' and markers_enabled) else 0
                                        if sn in scene_start_silence_scenes and scene_start_silence > 0:
                                            _audio_offset += scene_start_silence
                                        all_words.append({
                                            'text': wd.word.strip(),
                                            'start': cum_time + _audio_offset + wd.start,
                                            'end': cum_time + _audio_offset + wd.end,
                                        })
                                aligned = True
                            except Exception as whisper_exc:
                                logger.warning('Whisper alignment failed for scene %d: %s', sn, whisper_exc)
                            break
                    if not aligned:
                        sub_start = cum_time + timing_offset
                        sub_dur = scene_dur - timing_offset
                        subs = _syllable_weighted_subtitles(words, max(sub_dur, 0.5), sub_start, sn)
                        for s in subs:
                            all_words.append({
                                'text': s['word'],
                                'start': s['start'],
                                'end': s['end'],
                            })
                if all_words:
                    logger.info(f'Auto-generated {len(all_words)} subtitle words')
            except Exception as sub_exc:
                logger.warning(f'Auto-subtitle generation failed: {sub_exc}')
        # --- DIAGNOSTIC: Log timing per scene for desync debugging ---
        if all_words:
            _diag_scenes = {}
            for _dw in all_words:
                _dw_text = _dw.get('text', '')
                _dw_start = _dw.get('start', 0)
                _dw_end = _dw.get('end', 0)
                # Find which scene this word belongs to by checking scene_start_times
                _dw_scene = 0
                for _ds_sn in sorted(scene_start_times.keys(), reverse=True):
                    if _dw_start >= scene_start_times[_ds_sn] - 0.5:
                        _dw_scene = _ds_sn
                        break
                if _dw_scene not in _diag_scenes:
                    _diag_scenes[_dw_scene] = {'first_word': _dw_text, 'first_start': _dw_start, 'last_word': _dw_text, 'last_end': _dw_end, 'count': 0}
                _diag_scenes[_dw_scene]['last_word'] = _dw_text
                _diag_scenes[_dw_scene]['last_end'] = _dw_end
                _diag_scenes[_dw_scene]['count'] += 1
            print('=== SUBTITLE TIMING DIAGNOSTIC ===', flush=True)
            for _ds_sn in sorted(_diag_scenes.keys()):
                _ds = _diag_scenes[_ds_sn]
                _ds_type = scene_types.get(_ds_sn, {}).get('type', '?')
                _ds_scene_start = scene_start_times.get(_ds_sn, -1)
                _ds_has_start_sil = _ds_sn in scene_start_silence_scenes
                _ds_clip_dur = clip_durations[_ds_sn - 1] if _ds_sn > 0 and _ds_sn <= len(clip_durations) else -1
                _ds_audio_dur = audio_durations.get(_ds_sn, -1)
                print(
                    f'Scene {_ds_sn:2d} [{_ds_type:12s}] | scene_start={_ds_scene_start:7.3f}s | '
                    f'clip_dur={_ds_clip_dur:6.2f}s | audio_dur={_ds_audio_dur:6.2f}s | '
                    f'start_silence={_ds_has_start_sil} | '
                    f'first_word="{_ds["first_word"]}" @ {_ds["first_start"]:.3f}s | '
                    f'last_word="{_ds["last_word"]}" @ {_ds["last_end"]:.3f}s | '
                    f'words={_ds["count"]}',
                    flush=True
                )
            print('=== END DIAGNOSTIC ===', flush=True)
        for sub_clip in subtitle_clips:
            clip_start = sub_clip.get('startTime', 0)
            for w in sub_clip.get('words', []):
                word_text = w.get('word', '').strip()
                if not word_text:
                    continue
                all_words.append({
                    'text': word_text,
                    'start': clip_start + w.get('startTime', 0),
                    'end': clip_start + w.get('endTime', 0),
                })
        
        # Ensure no word overlap: strict sequential ordering (hard cut)
        if all_words:
            all_words.sort(key=lambda w: (w['start'], w['end']))
            # Pass 1: push overlapping starts forward so each word starts after prev ends
            for i in range(1, len(all_words)):
                if all_words[i]['start'] < all_words[i - 1]['end']:
                    all_words[i]['start'] = all_words[i - 1]['end']
                # Also fix end < start (can happen with same-start words)
                if all_words[i]['end'] <= all_words[i]['start']:
                    all_words[i]['end'] = all_words[i]['start'] + 0.05
            # Pass 2: clip end to next start and enforce minimum display
            for i in range(len(all_words) - 1):
                next_start = all_words[i + 1]['start']
                if all_words[i]['end'] > next_start:
                    all_words[i]['end'] = next_start
                # Minimum 20ms display (hard floor — never exceed next_start)
                if all_words[i]['end'] - all_words[i]['start'] < 0.02:
                    all_words[i]['end'] = min(all_words[i]['start'] + 0.02, next_start)

        final_duration = max(calculated_video_duration, total_audio_duration) if total_audio_duration > 0 else calculated_video_duration
        if all_words:
            ass_path = output_dir / 'subtitles.ass'
            generate_ass(
                word_timings=all_words,
                output_path=ass_path,
                video_duration=final_duration if final_duration > 0 else 9999,
                style=subtitle_style,
                video_width=W,
                video_height=H,
            )

            # ---- 4b. Inject time marker events into ASS file ----
            # 3-layer glow effect with Anton font for bold TikTok-style markers
            if markers_enabled and marker_style != 'none':
                from web.subtitle_engine import _fmt_ass_time as _fmt_marker_time
                marker_ass_events = []
                # Center position for 1080x1920
                pos_x, pos_y = W // 2, H // 3  # upper third of screen
                for tm_sn, tm_info in scene_types.items():
                    if tm_info['type'] != 'time_marker' or not tm_info.get('marker_text'):
                        continue
                    tm_start = scene_start_times.get(tm_sn, 0)  # No delay — marker voice plays immediately at scene start
                    tm_end = tm_start + marker_duration
                    tm_text = tm_info['marker_text'].upper()  # "DIA 1" looks bolder
                    tm_text = tm_text.replace('\\', '\\\\').replace('{', '\\{').replace('}', '\\}')
                    ts_start = _fmt_marker_time(tm_start)
                    ts_end = _fmt_marker_time(tm_end)
                    # Slam animation (core layer only): scale 180% -> 100% over 100ms
                    slam_anim = r'\fscx180\fscy180\t(0,100,\fscx100\fscy100)'

                    # Single clean layer: large white text with thick black outline
                    marker_ass_events.append(
                        f'Dialogue: 1,{ts_start},{ts_end},Marker,,0,0,0,,'
                        f'{{\\pos({pos_x},{pos_y})\\an5\\blur0.8\\bord8'
                        f'\\3c&H000000&\\1c&HFFFFFF&\\shad3\\4c&H000000&{slam_anim}}}{tm_text}'
                    )
                if marker_ass_events:
                    ass_content = ass_path.read_text(encoding='utf-8-sig')
                    # Marker style: Anton Bold, large, with base defaults
                    # (actual glow effects are applied via override tags in each Dialogue line)
                    marker_style_def = (
                        f'Style: Marker,Anton,{marker_font_size},'
                        f'&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,'
                        f'-1,0,0,0,100,100,0,0,1,8,3,5,0,0,0,1'
                    )
                    ass_content = ass_content.replace(
                        '\n\n[Events]',
                        f'\n{marker_style_def}\n\n[Events]'
                    )
                    if not ass_content.endswith('\n'):
                        ass_content += '\n'
                    ass_content += '\n'.join(marker_ass_events) + '\n'
                    ass_path.write_text(ass_content, encoding='utf-8-sig')
                    logger.info(f'Injected {len(marker_ass_events)} marker ASS events (3-layer glow)')

            ass_path_escaped = str(ass_path).replace(':', '\\:').replace("'", "\\\'")
            fonts_dir = str(_PROJECT_ROOT / 'web' / 'fonts').replace(':', '\\:').replace("'", "\\\'")
            filter_parts.append(f"[{vid_label}]ass={ass_path_escaped}:fontsdir={fonts_dir}:shaping=complex[vsub]")
            vid_label = 'vsub'
            logger.info('ASS subtitles generated at %s (style=%s)', ass_path, subtitle_style.get('preset', 'tiktok_karaoke'))
        
        # ---- 5. Audio handling with acrossfade ----
        audio_inputs = []
        audio_clip_ids = []
        audio_scene_nums = []  # Track scene numbers for audio delay
        for ai_clip, clip in enumerate(audio_clips_sorted):
            src = clip.get("src", "")
            if not src:
                continue
            file_path = _PROJECT_ROOT / src.lstrip("/")
            if not file_path.exists():
                continue
            # Determine scene number from clip data or filename
            a_scene_num = clip.get("scene_number")
            if a_scene_num is None:
                import re as _re_audio
                _m_sn = _re_audio.search(r'frame_(\d+)', src)
                a_scene_num = int(_m_sn.group(1)) if _m_sn else ai_clip + 1
            inputs.extend(["-i", str(file_path)])
            audio_inputs.append(input_idx)
            audio_clip_ids.append(clip.get("id", ""))
            audio_scene_nums.append(a_scene_num)
            input_idx += 1
        
        aud_label = None
        if audio_inputs:
            # Pre-process audio clips: insert gap for TIME_MARKER, pad for pre-marker
            audio_labels = []
            for ai_idx in range(len(audio_inputs)):
                a_sn = audio_scene_nums[ai_idx] if ai_idx < len(audio_scene_nums) else ai_idx + 1
                is_tm = markers_enabled and scene_types.get(a_sn, {}).get('type') == 'time_marker'
                is_pre = a_sn in pre_marker_scenes
                current_label = f'{audio_inputs[ai_idx]}:a'

                # Step A: For TIME_MARKER scenes, split audio and insert silence after marker voice
                if is_tm and a_sn in marker_voice_ends:
                    mv_end = marker_voice_ends[a_sn]
                    s1 = f'as1_{ai_idx}'; s2 = f'as2_{ai_idx}'
                    mp = f'amp_{ai_idx}'; sil = f'asil_{ai_idx}'
                    np_ = f'anp_{ai_idx}'; gap = f'agap_{ai_idx}'
                    fmt = 'aformat=sample_rates=44100:channel_layouts=mono'
                    filter_parts.append(f'[{current_label}]asplit[{s1}][{s2}]')
                    filter_parts.append(f'[{s1}]atrim=0:{mv_end:.3f},asetpts=PTS-STARTPTS,{fmt}[{mp}]')
                    filter_parts.append(f'aevalsrc=0:d={marker_gap:.3f}:s=44100:c=mono[{sil}]')
                    filter_parts.append(f'[{s2}]atrim={mv_end:.3f},asetpts=PTS-STARTPTS,{fmt}[{np_}]')
                    filter_parts.append(f'[{mp}][{sil}][{np_}]concat=n=3:v=0:a=1[{gap}]')
                    current_label = gap
                    logger.info(f'Inserted {marker_gap}s silence at {mv_end:.2f}s in scene {a_sn} audio')

                # Step A2: For scenes with start silence, prepend silence before speech
                if a_sn in scene_start_silence_scenes and scene_start_silence > 0:
                    pre_sil = f'apresil_{ai_idx}'
                    pre_fmt = f'afmt_{ai_idx}'
                    pre_cat = f'aprecat_{ai_idx}'
                    fmt_str = 'aformat=sample_rates=44100:channel_layouts=mono'
                    filter_parts.append(f'aevalsrc=0:d={scene_start_silence:.3f}:s=44100:c=mono[{pre_sil}]')
                    filter_parts.append(f'[{current_label}]{fmt_str}[{pre_fmt}]')
                    filter_parts.append(f'[{pre_sil}][{pre_fmt}]concat=n=2:v=0:a=1[{pre_cat}]')
                    current_label = pre_cat
                    logger.info(f'Prepended {scene_start_silence}s silence to scene {a_sn} audio')

                # Step B: Pad ALL audio clips to match their video clip duration
                target_dur = scene_to_clip_dur.get(a_sn, audio_durations.get(a_sn, 5.0))
                padded = f'afinal_{ai_idx}'
                filter_parts.append(f'[{current_label}]apad=whole_dur={target_dur:.3f}[{padded}]')
                current_label = padded

                audio_labels.append(current_label)

            if len(audio_inputs) == 1:
                aud_label = audio_labels[0]
            elif not transitions:
                arefs = "".join(f"[{lbl}]" for lbl in audio_labels)
                filter_parts.append(f"{arefs}concat=n={len(audio_labels)}:v=0:a=1[aout]")
                aud_label = "aout"
            else:
                prev_a = audio_labels[0]
                for i in range(1, len(audio_inputs)):
                    tr_dur = 0.5
                    if i - 1 < len(clip_ids) and i < len(clip_ids):
                        key = (clip_ids[i - 1], clip_ids[i])
                        tr = trans_lookup.get(key)
                        if tr:
                            tr_type = tr["type"]
                            if XFADE_MAP.get(tr_type) is not None:
                                tr_dur = tr["duration"]
                            else:
                                tr_dur = 0.001
                    out_a = f"axf{i}" if i < len(audio_inputs) - 1 else "aout"
                    filter_parts.append(
                        f"[{prev_a}][{audio_labels[i]}]acrossfade=d={tr_dur:.3f}[{out_a}]"
                    )
                    prev_a = out_a
                aud_label = "aout"
            # --- DIAGNOSTIC: Log audio acrossfade cumulative timeline ---
            print('=== AUDIO ACROSSFADE TIMELINE ===', flush=True)
            _acf_cum = clip_durations[0] if clip_durations else 0
            print(f'  Audio scene 1: start=0.000s, padded_dur={_acf_cum:.3f}s', flush=True)
            for _acf_i in range(1, len(audio_inputs)):
                _acf_sn = audio_scene_nums[_acf_i] if _acf_i < len(audio_scene_nums) else _acf_i + 1
                _acf_tr = 0.001
                if _acf_i - 1 < len(clip_ids) and _acf_i < len(clip_ids):
                    _acf_key = (clip_ids[_acf_i - 1], clip_ids[_acf_i])
                    _acf_tr_info = trans_lookup.get(_acf_key)
                    if _acf_tr_info:
                        if XFADE_MAP.get(_acf_tr_info['type']) is not None:
                            _acf_tr = _acf_tr_info['duration']
                        else:
                            _acf_tr = 0.001
                _acf_start = _acf_cum - _acf_tr
                _acf_pad = clip_durations[_acf_i] if _acf_i < len(clip_durations) else 0
                _acf_has_sil = _acf_sn in scene_start_silence_scenes
                _acf_is_tm = scene_types.get(_acf_sn, {}).get('type') == 'time_marker'
                print(
                    f'  Audio scene {_acf_sn:2d}: acf_start={_acf_start:.3f}s (xfade={_acf_tr:.3f}s) | '
                    f'padded_dur={_acf_pad:.3f}s | start_silence={_acf_has_sil} | tm={_acf_is_tm} | '
                    f'vid_scene_start={scene_start_times.get(_acf_sn, -1):.3f}s | '
                    f'DELTA={_acf_start - scene_start_times.get(_acf_sn, 0):.4f}s',
                    flush=True
                )
                _acf_cum += _acf_pad - _acf_tr
            print('=== END AUDIO TIMELINE ===', flush=True)
        
        # ---- 5b. Mix video clip audio with TTS ----
        has_video_audio = any(is_vid for is_vid, _, _ in video_audio_info)
        if has_video_audio:
            video_vol = max(0.0, min(1.0, float(effects.get('videoVolume', 0.5))))
            va_labels = []
            for va_i, (is_vid, vid_idx, va_dur) in enumerate(video_audio_info):
                va_label = f'va{va_i}'
                va_scene = va_i + 1  # scene number (1-indexed)
                va_start_ms = int(scene_start_times.get(va_scene, 0) * 1000)
                if is_vid:
                    filter_parts.append(
                        f'[{vid_idx}:a]volume={video_vol:.2f},atrim=0:{va_dur:.3f},asetpts=PTS-STARTPTS,'
                        f'adelay={va_start_ms}|{va_start_ms}[{va_label}]'
                    )
                else:
                    filter_parts.append(
                        f'aevalsrc=0:d={va_dur:.3f}:s=44100:c=mono[{va_label}]'
                    )
                va_labels.append(va_label)
            
            if len(va_labels) == 1:
                vid_audio_label = va_labels[0]
            else:
                va_refs = ''.join(f'[{l}]' for l in va_labels)
                filter_parts.append(
                    f'{va_refs}amix=inputs={len(va_labels)}:duration=longest'
                    f':dropout_transition=0:normalize=0[vid_audio]'
                )
                vid_audio_label = 'vid_audio'
            
            if aud_label:
                filter_parts.append(
                    f'[{aud_label}][{vid_audio_label}]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[amixed]'
                )
                aud_label = 'amixed'
                logger.info('Mixed video audio (%s%% volume) with TTS audio', int(video_vol * 100))
            else:
                aud_label = vid_audio_label
                logger.info('Using video audio only (%s%% volume, no TTS)', int(video_vol * 100))

        # ---- 5c. Sound effects mixing (whoosh/boom at transitions and markers) ----
        if sfx_enabled and aud_label:
            sfx_dir = _PROJECT_ROOT / 'web' / 'sfx'
            sfx_labels = []
            sfx_input_count = 0

            # SFX at time markers (ding.mp3)
            if sfx_markers:
                ding_path = sfx_dir / 'ding.mp3'
                if ding_path.exists():
                    for tm_sn, tm_info in scene_types.items():
                        if tm_info['type'] != 'time_marker':
                            continue
                        tm_start_ms = int(scene_start_times.get(tm_sn, 0) * 1000)
                        sfx_lbl = f'sfx{sfx_input_count}'
                        inputs.extend(['-i', str(ding_path)])
                        filter_parts.append(
                            f'[{input_idx}:a]volume={sfx_volume:.2f},'
                            f'adelay={tm_start_ms}|{tm_start_ms}[{sfx_lbl}]'
                        )
                        sfx_labels.append(sfx_lbl)
                        sfx_input_count += 1
                        input_idx += 1

            # SFX at intro montage transitions (camera_flash.mp3)
            if sfx_transitions and intro_enabled and scene_types.get(1, {}).get('type') == 'intro':
                flash_path = sfx_dir / 'camera_flash.mp3'
                if flash_path.exists():
                    montage_src_count = min(intro_clip_count, max(0, len(video_clips_sorted) - 1))
                    for fli in range(1, montage_src_count):
                        # Flash at each montage cut point
                        flash_time_ms = int(fli * intro_clip_duration * 1000)
                        sfx_lbl = f'sfx{sfx_input_count}'
                        inputs.extend(['-i', str(flash_path)])
                        filter_parts.append(
                            f'[{input_idx}:a]volume={sfx_volume:.2f},'
                            f'adelay={flash_time_ms}|{flash_time_ms}[{sfx_lbl}]'
                        )
                        sfx_labels.append(sfx_lbl)
                        sfx_input_count += 1
                        input_idx += 1

            # Mix all SFX with existing audio
            if sfx_labels:
                all_audio_refs = f'[{aud_label}]' + ''.join(f'[{sl}]' for sl in sfx_labels)
                total_mix = 1 + len(sfx_labels)
                filter_parts.append(
                    f'{all_audio_refs}amix=inputs={total_mix}:duration=longest'
                    f':dropout_transition=0:normalize=0[asfx]'
                )
                aud_label = 'asfx'
                logger.info(f'Mixed {len(sfx_labels)} SFX into audio (volume={sfx_volume})')


        # ---- 5d. Background music mixing ----
        if bgm_enabled and aud_label:
            bgm_path = None
            if bgm_file:
                bgm_candidate = _PROJECT_ROOT / bgm_file
                if bgm_candidate.exists():
                    bgm_path = bgm_candidate
            if bgm_path is None:
                bgm_path = _PROJECT_ROOT / 'web' / 'sfx' / 'music.mp3'
            if bgm_path.exists():
                inputs.extend(['-i', str(bgm_path)])
                bgm_input_lbl = f'{input_idx}:a'
                # Loop the bg music to cover entire video, apply volume, fade in/out
                bgm_stream = f'bgm'
                filter_parts.append(
                    f'[{bgm_input_lbl}]aloop=loop=-1:size=2e+09,'
                    f'atrim=0:{calculated_video_duration:.3f},'
                    f'volume={bgm_volume:.3f},'
                    f'afade=t=in:st=0:d=2,'
                    f'afade=t=out:st={max(0, calculated_video_duration - 3):.3f}:d=3'
                    f'[{bgm_stream}]'
                )
                # Mix bg music with existing audio
                filter_parts.append(
                    f'[{aud_label}][{bgm_stream}]amix=inputs=2:duration=first'
                    f':dropout_transition=0:normalize=0[abgm]'
                )
                aud_label = 'abgm'
                input_idx += 1
                logger.info(f'Mixed background music at {bgm_volume*100:.0f}%% volume, looped to {calculated_video_duration:.1f}s')
            else:
                logger.warning(f'Background music file not found: {bgm_path}')

        # ---- 6. Audio Sync Adjustment ----
        total_audio_duration = 0.0
        if audio_clips_sorted and audio_durations:
            for i, audio_clip in enumerate(audio_clips_sorted):
                scene_num = audio_clip.get("scene_number", i)
                if scene_num in audio_durations:
                    total_audio_duration += audio_durations[scene_num]
        # Fallback: compute from all audio durations if track-based sum is 0
        if total_audio_duration == 0 and audio_durations:
            total_audio_duration = sum(audio_durations.values())
        if total_audio_duration > 0:
            logger.info(f"Total audio duration: {total_audio_duration:.2f}s")
            logger.info(f"Calculated video duration: {calculated_video_duration:.2f}s")
        # Primary sync: clip durations already extended to match audio (above)
        # Safety net: if video is still longer than audio, pad audio with silence
        if total_audio_duration > 0 and aud_label and calculated_video_duration > total_audio_duration:
            pad_dur = calculated_video_duration
            filter_parts.append(f"[{aud_label}]apad=whole_dur={pad_dur:.3f}[apadded]")
            aud_label = "apadded"
            logger.info(f"Padded audio with silence to {pad_dur:.2f}s")
        
        # ---- 7. Assemble final FFmpeg command ----
        filter_complex = ";".join(filter_parts)



        
        cmd = ["ffmpeg", "-y"]
        cmd.extend(inputs)
        cmd.extend(["-filter_complex", filter_complex])
        cmd.extend(["-map", f"[{vid_label}]"])
        if aud_label:
            cmd.extend(["-map", f"[{aud_label}]"])
        cmd.extend([
            "-c:v", "libx264",
            "-profile:v", "high",
            "-level:v", "4.2",
            "-preset", "slow",
            "-crf", "18",
            "-pix_fmt", "yuv420p",
            "-color_range", "tv",
            "-colorspace", "bt709",
            "-color_primaries", "bt709",
            "-color_trc", "bt709",
            "-r", "30",
            "-g", "60",
            "-keyint_min", "30",
            "-sc_threshold", "0",
            "-c:a", "aac",
            "-b:a", "192k",
            "-ar", "48000",
            "-ac", "2",
            "-movflags", "+faststart",
            # NOTE: -shortest removed — durations are precisely calculated; the flag caused premature truncation
            str(export_path),
        ])
        
        # Log detailed sync information
        logger.info(f"Export config:")
        logger.info(f"  Video duration: {calculated_video_duration:.2f}s")
        logger.info(f"  Audio duration: {total_audio_duration:.2f}s")
        logger.info(f"  Video clips: {len(video_clips_sorted)}")
        logger.info(f"  Audio clips: {len(audio_clips_sorted)}")
        logger.info(f"  Transitions: {len(transitions)}")
        if audio_durations:
            logger.info(f"  Scene audio durations: {audio_durations}")
        
        logger.info("FFmpeg export command: %s", " ".join(cmd))
        
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()

        
        if proc.returncode != 0:
            error_msg = stderr.decode("utf-8", errors="replace")[-500:]
            logger.error("FFmpeg export failed: %s", error_msg)
            return JSONResponse(
                {"error": f"FFmpeg export failed: {error_msg}"}, status_code=500
            )
        
        download_url = f"/output/{project_id}/final_export.mp4"
        return {
            "status": "completed",
            "download_url": download_url,
            "file_size": export_path.stat().st_size,
        }
    
    except FileNotFoundError:
        return JSONResponse(
            {"error": "FFmpeg not found. Install it: sudo apt install ffmpeg"},
            status_code=500,
        )
    except Exception as exc:
        logger.exception("Video export failed for project %s", project_id)
        return JSONResponse(
            {"error": f"Export failed: {exc}"}, status_code=500
        )


@app.get("/api/variants")
async def api_list_variants():
    """[DEPRECATED] List available editing variant presets. Use /api/projects/{id}/export-video with effects config instead."""
    from web.variant_presets import list_variants
    return {"variants": list_variants()}


@app.post("/api/projects/{project_id}/export-variants")
async def api_export_variants(project_id: str, request: Request):
    """[DEPRECATED] Generate multiple video exports. Use POST /api/projects/{id}/export-video with different effects payloads instead."""
    from web.variant_presets import generate_variant_payload, VARIANT_PRESETS
    import shutil

    # Load project
    project = await get_project(project_id)
    if not project:
        return JSONResponse({"error": "Project not found"}, status_code=404)
    project["images"] = await get_project_images(project_id)
    project["videos"] = await get_project_videos(project_id)
    project["audios"] = await get_project_audios(project_id)

    try:
        body = await request.json()
    except Exception:
        body = {}

    variant_names = body.get("variants", list(VARIANT_PRESETS.keys()))
    seed = body.get("seed", 42)
    output_dir = _PROJECT_ROOT / "output" / project_id

    results = {}
    for vname in variant_names:
        if vname not in VARIANT_PRESETS:
            results[vname] = {"error": f"Unknown variant: {vname}"}
            continue

        try:
            payload = generate_variant_payload(project, vname, seed=seed)

            import httpx
            async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=600.0) as client:
                resp = await client.post(
                    f"/api/projects/{project_id}/export-video",
                    json=payload,
                )

            if resp.status_code == 200:
                data = resp.json()
                # Rename exported file to variant-specific name
                default_path = output_dir / "final_export.mp4"
                variant_path = output_dir / f"final_export_{vname}.mp4"
                if default_path.exists():
                    shutil.move(str(default_path), str(variant_path))
                results[vname] = {
                    "status": "completed",
                    "download_url": f"/output/{project_id}/final_export_{vname}.mp4",
                    "file_size": variant_path.stat().st_size if variant_path.exists() else 0,
                }
            else:
                results[vname] = {"status": "failed", "error": resp.text[:500]}

        except Exception as exc:
            results[vname] = {"status": "failed", "error": str(exc)[:500]}

    return {"variants": results}


@app.get("/api/subtitle-presets")
async def api_subtitle_presets():
    from web.subtitle_engine import list_presets
    return {"presets": list_presets()}


@app.get("/api/projects/{project_id}/export-status")
async def get_export_status(project_id: str):
    """Check if an exported video exists and its metadata."""
    proj = await get_project(project_id)
    if proj is None:
        return JSONResponse({"error": "Project not found"}, status_code=404)

    output_dir = _PROJECT_ROOT / "output" / project_id

    # Check common export file names
    candidates = [
        output_dir / "final_export.mp4",
        output_dir / "final_video.mp4",
        output_dir / "assets" / "final_video.mp4",
    ]

    for path in candidates:
        if path.exists():
            return {
                "project_id": project_id,
                "exported": True,
                "file_path": str(path.relative_to(_PROJECT_ROOT)),
                "file_size_bytes": path.stat().st_size,
                "export_available": True,
            }

    return {
        "project_id": project_id,
        "exported": False,
        "file_path": None,
        "file_size_bytes": 0,
        "export_available": False,
    }

# ---------------------------------------------------------------------------
# WebSocket – pipeline execution
# ---------------------------------------------------------------------------


@app.websocket("/ws/pipeline/{project_id}")
async def ws_pipeline(ws: WebSocket, project_id: str):
    await ws.accept()

    # Generate a valid UUID for PostgreSQL (frontend sends short IDs)
    import uuid

    project_id = str(uuid.uuid4())

    try:
        # Wait for the client to send the pipeline configuration
        config_data = await ws.receive_json()

        story: str = config_data.get("story", "")
        mode: str = config_data.get("mode", "plan")
        character_id: str = config_data.get("character_id", "")
        character_name: str = config_data.get("character", "skeleton")
        frames_per_minute: int = int(config_data.get("frames_per_minute", 25))
        enable_coverage_shots: bool = config_data.get("enable_coverage_shots", False)
        coverage_angles_per_scene: int = int(
            config_data.get("coverage_angles_per_scene", 3)
        )
        coverage_mode: str = config_data.get("coverage_mode", "all")
        # Style presets (new decoupled system)
        world_style_id: str = config_data.get("world_style_id", "")
        clothing_style_id: str = config_data.get("clothing_style_id", "")
        production_rules_id: str = config_data.get("production_rules_id", "")
        # TTS settings
        tts_provider: str = config_data.get("tts_provider", "elevenlabs")
        # Product placement
        product_placement_data: dict | None = config_data.get("product_placement", None)

        if not story.strip():
            await ws.send_json({"event_type": "error", "message": "No story provided"})
            await ws.close()
            return

        # Resolve character: try DB first (by id or name), fall back to JSON
        char_data = None
        if character_id:
            char_data = await get_character(character_id)
        if not char_data and character_name:
            char_data = await get_character_by_name(character_name)

        # Per-project output directory
        project_output_dir = str(_PROJECT_ROOT / "output" / project_id)

        # Inject LLM settings from DB into env for this pipeline run
        db_settings = await db_get_all_settings()
        if db_settings.get("llm_model"):
            os.environ["LLM_MODEL"] = db_settings["llm_model"]
        if db_settings.get("openai_api_key"):
            os.environ["OPENAI_API_KEY"] = db_settings["openai_api_key"]
        if db_settings.get("anthropic_api_key"):
            os.environ["ANTHROPIC_API_KEY"] = db_settings["anthropic_api_key"]

        # Build pipeline config
        try:
            from models import CharacterTemplate, WorldStyle, ClothingStyle, ProductionRules, ProductPlacement
            from presets import (
                get_world_style as get_preset_world_style,
                get_clothing_style as get_preset_clothing_style,
                get_production_rules as get_preset_production_rules,
            )

            char_template = None
            if char_data:
                char_template = CharacterTemplate(
                    **{
                        k: v
                        for k, v in char_data.items()
                        if k in CharacterTemplate.model_fields
                    }
                )
            
            # Resolve style presets
            world_style = None
            if world_style_id:
                # Try preset first, then DB
                world_style = get_preset_world_style(world_style_id)
                if not world_style:
                    ws_data = await get_world_style(world_style_id)
                    if ws_data:
                        world_style = WorldStyle(**{k: v for k, v in ws_data.items() if k in WorldStyle.model_fields})
            
            clothing_style = None
            if clothing_style_id:
                clothing_style = get_preset_clothing_style(clothing_style_id)
                if not clothing_style:
                    cs_data = await get_clothing_style(clothing_style_id)
                    if cs_data:
                        clothing_style = ClothingStyle(**{k: v for k, v in cs_data.items() if k in ClothingStyle.model_fields})
            
            production_rules = None
            if production_rules_id:
                production_rules = get_preset_production_rules(production_rules_id)
                if not production_rules:
                    pr_data = await get_production_rules(production_rules_id)
                    if pr_data:
                        production_rules = ProductionRules(**{k: v for k, v in pr_data.items() if k in ProductionRules.model_fields})
            
            # Build ProductPlacement if provided
            product_placement = None
            if product_placement_data and product_placement_data.get("enabled"):
                product_placement = ProductPlacement(
                    enabled=True,
                    product_name=product_placement_data.get("product_name", ""),
                    product_description=product_placement_data.get("product_description", ""),
                    brand_name=product_placement_data.get("brand_name", ""),
                    placement_style="natural",
                    placement_frequency="contextual",
                )
            
            cfg = build_config(
                mode=mode,
                character=character_name if not char_template else None,
                character_template=char_template,
                world_style=world_style,
                clothing_style=clothing_style,
                production_rules=production_rules,
                product_placement=product_placement,
                output_dir=project_output_dir,
                frames_per_minute=frames_per_minute,
                enable_coverage_shots=enable_coverage_shots,
                coverage_angles_per_scene=coverage_angles_per_scene,
                coverage_mode=coverage_mode,
                # TTS settings
                tts_provider=tts_provider,
            )
        except Exception as exc:
            await ws.send_json({"event_type": "error", "message": str(exc)})
            await ws.close()
            return

        # Insert project into DB
        resolved_name = char_data["name"] if char_data else character_name
        await insert_project(
            id=project_id,
            status="running",
            mode=mode,
            platform="tiktok",
            character=resolved_name,
            story_preview=story[:200],
        )

        # Create async queue for event bridging
        event_queue: asyncio.Queue[PipelineEvent | None] = asyncio.Queue()

        def on_event(event: PipelineEvent) -> None:
            """Callback invoked from the sync pipeline thread."""
            event_queue.put_nowait(event)

        pipeline = Pipeline(cfg, on_event=on_event)

        # Run pipeline in a separate thread
        async def run_in_thread():
            try:
                plan = await asyncio.to_thread(pipeline.run, story)
                # Save outputs
                save_markdown(plan, cfg.output_dir)
                save_json(plan, cfg.output_dir)
                return plan
            finally:
                # Signal completion to the queue consumer
                event_queue.put_nowait(None)

        pipeline_task = asyncio.create_task(run_in_thread())

        # Forward events from queue to WebSocket
        while True:
            event = await event_queue.get()
            if event is None:
                break

            evt_dict = event.model_dump(exclude_none=True)

            # Persist image_generated events to DB
            if event.event_type == "image_generated" and event.image_scene_number:
                img_file = f"frame_{event.image_scene_number:02d}.png"
                img_full_path = str(Path(project_output_dir) / "assets" / img_file)
                await upsert_image(
                    project_id=project_id,
                    scene_number=event.image_scene_number,
                    file_path=img_full_path,
                    status="generated",
                )

            try:
                await ws.send_json(evt_dict)
            except WebSocketDisconnect:
                pipeline_task.cancel()
                return

        # Wait for pipeline to finish and get result
        plan = await pipeline_task

        # Update project in DB
        plan_dict = plan.model_dump()
        await update_project_status(project_id, "completed")
        await update_project_result(project_id, plan_dict, title=plan.title)

        # Backfill image prompts into images table
        for scene in plan.scenes:
            images = await get_project_images(project_id)
            img_row = next(
                (i for i in images if i["scene_number"] == scene.scene_number),
                None,
            )
            if img_row:
                await upsert_image(
                    project_id=project_id,
                    scene_number=scene.scene_number,
                    image_prompt=scene.image_prompt,
                    file_path=img_row["file_path"],
                    status=img_row["status"],
                )
            else:
                # No image generated (plan-only mode), still record prompt
                await upsert_image(
                    project_id=project_id,
                    scene_number=scene.scene_number,
                    image_prompt=scene.image_prompt,
                    status="pending",
                )

        # Send final result
        await ws.send_json(
            {
                "event_type": "result",
                "data": plan_dict,
            }
        )

    except WebSocketDisconnect:
        logger.info("WebSocket disconnected: %s", project_id)
        await update_project_status(project_id, "disconnected")
    except Exception as exc:
        logger.exception("Pipeline error for project %s", project_id)
        try:
            await update_project_status(project_id, "error")
        except Exception:
            pass
        try:
            await ws.send_json({"event_type": "error", "message": str(exc)})
        except Exception:
            pass
    finally:
        try:
            await ws.close()
        except Exception:
            pass
