"""
ref_selector.py — Unified Reference Selection System (URSS) library.

Provides generation functions for all asset types via type descriptors.
Strategies: composite_grid (single image → split), parallel_singles (N calls).
"""

import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Optional

from recoil.core.paths import PIPELINE_ROOT, get_config
from recoil.core.prompt_config import get_constant

logger = logging.getLogger("starsend.ref_selector")


def _save_as_png(path: Path, raw_bytes: bytes) -> None:
    """Save image bytes as actual PNG, converting from JPEG if needed.

    Gemini often returns JPEG data regardless of requested format.
    This ensures .png files contain real PNG data to avoid MIME mismatches
    downstream (elements pipeline, ref resolution, etc.).
    """
    if path.suffix.lower() == ".png" and raw_bytes[:3] == b"\xff\xd8\xff":
        # JPEG data being saved as .png — convert
        from PIL import Image
        import io
        img = Image.open(io.BytesIO(raw_bytes))
        img.save(path, format="PNG")
        logger.debug("Converted JPEG→PNG on save: %s", path.name)
    else:
        path.write_bytes(raw_bytes)

CONFIG_PATH = PIPELINE_ROOT / "config" / "ref_descriptors.json"

_descriptors_cache = None


def _resolve_model(role: str, modality: str = "image") -> str:
    """Resolve a model role to a concrete model ID via starsend_config.

    Roles: "exploration" → Flash 3.1, "production" → NBP, "flash" → Flash for text.
    """
    cfg = get_config()
    if role == "exploration":
        return cfg.get("exploration_model", "gemini-3.1-flash-image-preview")
    elif role == "production":
        return cfg.get("default_model", "gemini-3-pro-image-preview")
    elif role == "flash":
        # Flash for text-only calls (vision extraction)
        if modality == "text":
            return "gemini-2.5-flash"
        return cfg.get("exploration_model", "gemini-3.1-flash-image-preview")
    else:
        return cfg.get("default_model", "gemini-3-pro-image-preview")


def load_descriptor(asset_type: str) -> dict:
    """Load type descriptor for an asset type."""
    global _descriptors_cache
    if _descriptors_cache is None:
        from recoil.core.config_schema import validate_and_load
        raw = validate_and_load(CONFIG_PATH, "ref_descriptors")
        # Strip schema_version — descriptor entries only.
        _descriptors_cache = {k: v for k, v in raw.items() if k != "schema_version"}
    desc = _descriptors_cache.get(asset_type)
    if desc is None:
        raise ValueError(f"Unknown asset type: {asset_type}. Valid: {list(_descriptors_cache.keys())}")
    return desc


def parse_grid_format(grid_format: str) -> tuple:
    """Parse '2x3' → (rows=2, cols=3)."""
    parts = grid_format.lower().split("x")
    return int(parts[0]), int(parts[1])


def candidate_count(descriptor: dict) -> int:
    """How many candidates does one generation produce?"""
    strategy = descriptor["generation_strategy"]
    if strategy == "composite_grid":
        rows, cols = parse_grid_format(descriptor["grid_format"])
        return rows * cols
    elif strategy == "parallel_singles":
        return descriptor["candidates_per_batch"]
    raise ValueError(f"Unknown strategy: {strategy}")


def split_composite_grid(grid_path: Path, grid_format: str, output_dir: Path, prefix: str) -> list:
    """Split a composite grid image into individual panels.

    Reuses the smart label/border detection from prep_character_angles.py.
    Returns list of Path objects for each panel.
    """
    from PIL import Image

    if not grid_path.exists():
        return []

    img = Image.open(grid_path)
    rows, cols = parse_grid_format(grid_format)

    # Detect content bounds (skip any title bands / borders NBP adds)
    top, bottom, left, right = _detect_content_bounds(img)
    content = img.crop((left, top, right, bottom))
    cw, ch = content.size

    col_edges = [round(cw * i / cols) for i in range(cols + 1)]
    row_edges = [round(ch * i / rows) for i in range(rows + 1)]

    output_dir.mkdir(parents=True, exist_ok=True)
    panels = []
    for row in range(rows):
        for col in range(cols):
            panel_num = row * cols + col + 1
            box = (col_edges[col], row_edges[row], col_edges[col + 1], row_edges[row + 1])
            panel = content.crop(box)
            panel_path = output_dir / f"{prefix}_candidate_{panel_num:02d}.png"
            panel.save(panel_path)
            panels.append(panel_path)

    logger.info("Split %s grid into %d panels at %s", grid_format, len(panels), output_dir)
    return panels


def _detect_content_bounds(img):
    """Detect actual content area, skipping title bands and borders.

    Ported from prep_character_angles.py — scans for rows/cols that are
    mostly uniform (label/border) vs varied (content).
    """
    import numpy as np

    arr = np.array(img.convert("RGB"))
    h, w, _ = arr.shape

    def row_variance(y):
        return float(np.std(arr[y, :, :]))

    def col_variance(x):
        return float(np.std(arr[:, x, :]))

    # Scan from top for content start (variance spike)
    threshold = 15.0
    top = 0
    for y in range(min(h // 4, 200)):
        if row_variance(y) > threshold:
            top = max(0, y - 2)
            break

    # Scan from bottom
    bottom = h
    for y in range(h - 1, max(h * 3 // 4, h - 200), -1):
        if row_variance(y) > threshold:
            bottom = min(h, y + 3)
            break

    # Scan from left
    left = 0
    for x in range(min(w // 4, 200)):
        if col_variance(x) > threshold:
            left = max(0, x - 2)
            break

    # Scan from right
    right = w
    for x in range(w - 1, max(w * 3 // 4, w - 200), -1):
        if col_variance(x) > threshold:
            right = min(w, x + 3)
            break

    return top, bottom, left, right


# ── Vision Extraction ──

_VISION_EXTRACTION_PROMPT = """You are an expert production designer. Analyze this image and generate a comma-separated mood profile for a character's baseline look.

FOCUS STRICTLY ON:
1. Base color palette (e.g., muted earth tones, desaturated cool blues).
2. Atmospheric texture (e.g., dusty, humid, sterile, gritty).
3. General aesthetic vibe (e.g., industrial cyberpunk, high-fantasy regal).

CRITICAL DIRECTIVES:
- DO NOT describe lighting direction, harsh shadows, or camera angles.
- DO NOT describe specific clothing, props, or background objects.
- DO NOT describe the subject's identity, age, or gender.
Keep it to atmospheric textures and color palettes only."""


def extract_mood_text(image_path: str) -> str:
    """Extract mood/style text from an image, stripping identity.

    Uses Gemini Flash (text model) with vision to analyze the image
    and produce a text-only mood description safe for casting grids.
    """
    from google import genai
    from google.genai import types

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    image_bytes = Path(image_path).read_bytes()
    suffix = Path(image_path).suffix.lower()
    mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(
        suffix.lstrip("."), "image/png"
    )

    client = genai.Client(api_key=api_key)
    model_id = _resolve_model("flash", "text")
    response = client.models.generate_content(
        model=model_id,
        contents=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime),
            types.Part(text=_VISION_EXTRACTION_PROMPT),
        ],
    )

    mood_text = response.text.strip() if response and response.text else ""
    logger.info("Vision extraction (%d chars): %s...", len(mood_text), mood_text[:100])
    return mood_text


# ── Prompt Templates ──

# Maps template names to prompt builders.
# Each builder receives (description, diegetic_frame, mood_text, user_override, **kwargs)
# and returns a complete generation prompt string.

def _build_casting_prompt(description, diegetic_frame, mood_text, user_override,
                          grid_format="2x3", gender=None, **kwargs):
    """Character casting grid prompt (ADR-LV01 through LV08).

    Uses Hybrid On-Location Screen Test approach:
    - Defocused environmental background (not flat studio paper)
    - 5600K neutral lighting (preserves relighting downstream)
    - Lived-in surface texture (grime, sweat, freckles)
    - Simple base-layer wardrobe (low neckline, no props)
    """
    rows, cols = parse_grid_format(grid_format)
    count = rows * cols

    # Gender anchor
    if gender and gender.lower() == "female":
        physicality = f"Female. {description}"
    elif gender and gender.lower() == "male":
        physicality = f"Male. {description}"
    else:
        physicality = description

    # Synthetic detection for frame switching
    synthetic_kw = {"android", "cyborg", "robot", "mechanical", "synthetic", "cybernetic",
                    "combat chassis", "alloy", "servos", "hydraulic", "mech"}
    is_synthetic = any(kw in description.lower() for kw in synthetic_kw)

    if is_synthetic:
        texture = get_constant("casting", "casting_texture_synthetic")
    else:
        texture = get_constant("casting", "casting_texture_human")

    # Incorporate mood as ambient environmental tone (ADR-LV03)
    mood_section = (f"ENVIRONMENTAL TONE: {mood_text}. "
                    f"(Apply as ambient texture and color palette, do not obscure faces).\n"
                    if mood_text else "")

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single image containing a {cols}x{rows} grid of photos.\n"
        f"DIEGETIC FRAMING: {diegetic_frame}\n"
        f"{count} DIFFERENT interpretations of the same role.\n\n"
        f"PHYSICALITY: {physicality}\n"
        f"WARDROBE: A simple, unbranded, short-sleeve base-layer or tank top appropriate to the "
        f"ENVIRONMENTAL TONE. The neckline MUST be low and simple. "
        f"No heavy outerwear, no obscuring collars, no hats, no accessories.\n"
        f"CASTING STANDARD: A-list lead casting. Striking, photogenic features with magnetic screen presence.\n"
        f"{mood_section}\n"
        f"GRID STRUCTURE: {cols} columns by {rows} rows. Strictly isolated panels separated by "
        f"thick BLACK film-matte borders. No overlapping.\n"
        f"MUTATION ALLOWANCE (CRITICAL): You are generating {count} GENUINELY DIFFERENT actors "
        f"auditioning for this same role. You MUST aggressively vary:\n"
        f"- Facial bone structures (wide, narrow, sharp, soft)\n"
        f"- Skin tones (within the logical bounds of the character description)\n"
        f"- Nose and jaw shapes\n"
        f"- Hair textures and micro-styling\n"
        f"DO NOT generate clones. Each panel must look like a completely different human being. "
        f"Keep age range and gender consistent. Frame as 3/4 medium-full shot, eye-level.\n\n"
        f"PHOTOGRAPHIC ANCHORS:\n"
        f"- Medium: 35mm film still, shot on {get_constant('casting', 'casting_camera')}.\n"
        f"- Lighting: Broad, soft cinematic ambient light. Key light must be "
        f"{get_constant('casting', 'casting_lighting')}. No colored gels, no neon rim lights, "
        f"no warm tungsten. Skin moisture/sweat must reflect pure white light.\n"
        f"- Background: Defocused, neutral-toned ambient environment matching the Environmental Tone. "
        f"Do not use flat studio paper.\n"
        f"- Texture (CRITICAL): {texture}\n"
    )

    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"

    return prompt


def _build_location_prompt(description, diegetic_frame, mood_text, user_override, **kwargs):
    """Location scout prompt for parallel singles."""
    atmosphere = kwargs.get("atmosphere", "")
    lighting = kwargs.get("lighting", "")
    palette = kwargs.get("palette", [])

    parts = [
        f"Cinematic environment concept art.",
        f"Location: {description}.",
        diegetic_frame,
    ]
    if mood_text:
        parts.append(f"Mood reference: {mood_text}.")
    if atmosphere:
        parts.append(f"Atmosphere: {atmosphere}.")
    if lighting:
        parts.append(f"Lighting: {lighting}.")
    if palette:
        parts.append(f"Color palette: {', '.join(palette[:5])}.")
    parts.append("No people, no characters, no figures. Environment only. Photorealistic.")

    prompt = " ".join(parts)
    if user_override:
        prompt += f" [USER OVERRIDE: {user_override}]"
    return prompt


def _build_costume_prompt(description, diegetic_frame, mood_text, user_override,
                          grid_format="2x3", **kwargs):
    """Wardrobe/costume casting grid prompt."""
    rows, cols = parse_grid_format(grid_format)
    count = rows * cols

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single image containing a {rows}x{cols} grid.\n"
        f"DIEGETIC FRAMING: {diegetic_frame}\n"
        f"{count} DIFFERENT wardrobe variations for the same character.\n\n"
        f"WARDROBE BRIEF: {description}\n"
    )
    if mood_text:
        prompt += f"STYLE REFERENCE: {mood_text}\n"
    prompt += (
        f"\nGRID STRUCTURE: {cols} columns by {rows} rows. Strictly isolated panels.\n"
        f"Each panel shows a different interpretation of the wardrobe brief.\n"
        f"Full body shot. Clean white/gray backdrop."
    )
    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"
    return prompt


def _build_makeup_prompt(description, diegetic_frame, mood_text, user_override,
                         grid_format="2x2", **kwargs):
    """Hair/makeup continuity grid prompt."""
    rows, cols = parse_grid_format(grid_format)
    count = rows * cols

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single image containing a {rows}x{cols} grid.\n"
        f"DIEGETIC FRAMING: {diegetic_frame}\n"
        f"{count} DIFFERENT hair and makeup variations.\n\n"
        f"LOOK BRIEF: {description}\n"
    )
    if mood_text:
        prompt += f"STYLE REFERENCE: {mood_text}\n"
    prompt += (
        f"\nGRID STRUCTURE: {cols} columns by {rows} rows. Strictly isolated panels.\n"
        f"Extreme close-up face shots. Harsh flash. Maximum detail."
    )
    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"
    return prompt


def _build_prop_prompt(description, diegetic_frame, mood_text, user_override,
                       grid_format="3x3", **kwargs):
    """Prop master inventory grid prompt."""
    rows, cols = parse_grid_format(grid_format)
    count = rows * cols

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single image containing a {rows}x{cols} grid.\n"
        f"DIEGETIC FRAMING: {diegetic_frame}\n"
        f"{count} DIFFERENT prop design variations.\n\n"
        f"PROP DESCRIPTION: {description}\n"
    )
    if mood_text:
        prompt += f"STYLE REFERENCE: {mood_text}\n"
    prompt += (
        f"\nGRID STRUCTURE: {cols} columns by {rows} rows. Strictly isolated panels.\n"
        f"Top-down on cutting mat. Clean isolation. No hands."
    )
    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"
    return prompt


_PROMPT_BUILDERS = {
    "casting_director": _build_casting_prompt,
    "location_scout": _build_location_prompt,
    "costume_designer": _build_costume_prompt,
    "makeup_continuity": _build_makeup_prompt,
    "prop_master": _build_prop_prompt,
}


# ── Generation ──

def generate_candidates(
    descriptor: dict,
    description: str,
    output_dir: Path,
    prefix: str,
    mood_text: str = "",
    user_override: str = "",
    anchor_image_path: str = None,
    **prompt_kwargs,
) -> dict:
    """Generate candidates using the strategy defined in the descriptor.

    Returns: {
        "grid_path": str or None (composite_grid only),
        "panels": [str, ...],
        "cost": float,
        "model": str,
    }
    """
    strategy = descriptor["generation_strategy"]
    if strategy == "composite_grid":
        return _generate_composite_grid(descriptor, description, output_dir, prefix,
                                        mood_text, user_override, anchor_image_path, **prompt_kwargs)
    elif strategy == "parallel_singles":
        return _generate_parallel_singles(descriptor, description, output_dir, prefix,
                                          mood_text, user_override, anchor_image_path, **prompt_kwargs)
    else:
        raise ValueError(f"Unknown generation strategy: {strategy}")


def _generate_composite_grid(descriptor, description, output_dir, prefix,
                             mood_text, user_override, anchor_image_path, **prompt_kwargs):
    """Generate a composite grid image and split into panels."""
    from google import genai
    from google.genai import types
    from recoil.core.model_profiles import get_cost

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    model_id = _resolve_model(descriptor["model_role"], "image")
    cost = get_cost(model_id)

    # Build prompt
    builder = _PROMPT_BUILDERS[descriptor["prompt_template"]]
    prompt = builder(
        description=description,
        diegetic_frame=descriptor["diegetic_frame"],
        mood_text=mood_text,
        user_override=user_override,
        grid_format=descriptor["grid_format"],
        **prompt_kwargs,
    )

    # Build content parts
    ref_handling = descriptor.get("ref_handling", {})
    parts = []

    # Hybrid strategy: include hero image as inline ref for identity lock
    if ref_handling.get("strategy") == "hybrid" and ref_handling.get("inline_ref") == "hero_image":
        if anchor_image_path and Path(anchor_image_path).exists():
            hero_bytes = Path(anchor_image_path).read_bytes()
            suffix = Path(anchor_image_path).suffix.lower().lstrip(".")
            mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(suffix, "image/png")
            parts.append(types.Part.from_bytes(data=hero_bytes, mime_type=mime))
            parts.append(types.Part(text="[IDENTITY REFERENCE — maintain this person's face and body]"))

    # Direct pass: include anchor as inline ref
    if ref_handling.get("strategy") == "direct_pass" and ref_handling.get("inline_ref") is True:
        if anchor_image_path and Path(anchor_image_path).exists():
            ref_bytes = Path(anchor_image_path).read_bytes()
            suffix = Path(anchor_image_path).suffix.lower().lstrip(".")
            mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(suffix, "image/png")
            parts.append(types.Part.from_bytes(data=ref_bytes, mime_type=mime))
            parts.append(types.Part(text="[STYLE REFERENCE]"))

    parts.append(types.Part(text=prompt))

    # API call
    client = genai.Client(api_key=api_key)
    config = types.GenerateContentConfig(
        temperature=descriptor["temperature"],
        response_modalities=["IMAGE", "TEXT"],
        image_config=types.ImageConfig(
            aspect_ratio=descriptor["aspect_ratio"],
        ),
    )

    try:
        response = client.models.generate_content(model=model_id, contents=parts, config=config)
    except Exception as e:
        logger.error("Composite grid generation failed: %s", e)
        return {"grid_path": None, "panels": [], "cost": cost, "model": model_id}

    # Save grid image
    output_dir.mkdir(parents=True, exist_ok=True)
    grid_path = output_dir / f"{prefix}_grid.png"
    if response and response.candidates:
        for cand in response.candidates:
            if cand.content and cand.content.parts:
                for part in cand.content.parts:
                    if hasattr(part, "inline_data") and part.inline_data:
                        _save_as_png(grid_path, part.inline_data.data)
                        break

    # Split into panels
    panels_dir = output_dir / "candidates"
    panels = split_composite_grid(grid_path, descriptor["grid_format"], panels_dir, prefix)

    return {
        "grid_path": str(grid_path) if grid_path.exists() else None,
        "panels": [str(p) for p in panels],
        "cost": cost,
        "model": model_id,
    }


def _generate_parallel_singles(descriptor, description, output_dir, prefix,
                               mood_text, user_override, anchor_image_path, **prompt_kwargs):
    """Generate N individual images with staggered dispatch."""
    from google import genai
    from google.genai import types
    from recoil.core.model_profiles import get_cost

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    model_id = _resolve_model(descriptor["model_role"], "image")
    per_cost = get_cost(model_id)
    count = descriptor["candidates_per_batch"]
    stagger = descriptor.get("stagger_delay_ms", 500) / 1000.0

    builder = _PROMPT_BUILDERS[descriptor["prompt_template"]]
    prompt = builder(
        description=description,
        diegetic_frame=descriptor["diegetic_frame"],
        mood_text=mood_text,
        user_override=user_override,
        **prompt_kwargs,
    )

    # Build content parts
    ref_handling = descriptor.get("ref_handling", {})
    base_parts = []
    if ref_handling.get("inline_ref") is True and anchor_image_path and Path(anchor_image_path).exists():
        ref_bytes = Path(anchor_image_path).read_bytes()
        suffix = Path(anchor_image_path).suffix.lower().lstrip(".")
        mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(suffix, "image/png")
        base_parts.append(types.Part.from_bytes(data=ref_bytes, mime_type=mime))
        base_parts.append(types.Part(text="[SCENE ENVIRONMENT REFERENCE]"))

    base_parts.append(types.Part(text=prompt))

    client = genai.Client(api_key=api_key)
    config = types.GenerateContentConfig(
        temperature=descriptor["temperature"],
        response_modalities=["IMAGE", "TEXT"],
        image_config=types.ImageConfig(
            aspect_ratio=descriptor["aspect_ratio"],
        ),
    )

    output_dir.mkdir(parents=True, exist_ok=True)
    panels = []
    total_cost = 0.0

    for i in range(count):
        if i > 0:
            time.sleep(stagger)  # Staggered dispatch for Flash QPS

        try:
            response = client.models.generate_content(model=model_id, contents=base_parts, config=config)
        except Exception as e:
            logger.error("Parallel single %d/%d failed: %s", i + 1, count, e)
            continue

        panel_path = output_dir / "candidates" / f"{prefix}_candidate_{i + 1:02d}.png"
        panel_path.parent.mkdir(parents=True, exist_ok=True)

        if response and response.candidates:
            for cand in response.candidates:
                if cand.content and cand.content.parts:
                    for part in cand.content.parts:
                        if hasattr(part, "inline_data") and part.inline_data:
                            _save_as_png(panel_path, part.inline_data.data)
                            panels.append(str(panel_path))
                            total_cost += per_cost
                            break

        logger.info("Generated candidate %d/%d: %s", i + 1, count, panel_path.name)

    return {
        "grid_path": None,
        "panels": panels,
        "cost": total_cost,
        "model": model_id,
    }


# ── Phase-Aware Generation (Continuity View) ──

def _build_phase_wardrobe_prompt(description, phase_wardrobe, hero_present=True,
                                  mood_text="", user_override="", gender=None):
    """Build a single-image wardrobe prompt for one phase.

    Uses hero image as identity ref (recency bias ordering).
    Generates one 2:3 wardrobe candidate per call.
    """
    gender_anchor = ""
    if gender and gender.lower() == "female":
        gender_anchor = "Female. "
    elif gender and gender.lower() == "male":
        gender_anchor = "Male. "

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single full-body wardrobe continuity photograph.\n"
        f"DIEGETIC FRAMING: A costume designer's wardrobe continuity Polaroid, "
        f"shot in a neutral fitting room with flat, even lighting.\n\n"
        f"CHARACTER: {gender_anchor}{description}\n"
        f"WARDROBE: {phase_wardrobe}\n"
    )

    if mood_text:
        prompt += f"STYLE REFERENCE: {mood_text}\n"

    prompt += (
        f"\nPHOTOGRAPHIC ANCHORS:\n"
        f"- Full body, head to toe, centered in frame.\n"
        f"- {get_constant('casting', 'casting_background')}.\n"
        f"- Flat, even studio lighting, {get_constant('casting', 'casting_lighting')}.\n"
        f"- 35mm lens, eye-level, no dramatic angles.\n"
        f"- {get_constant('casting', 'casting_texture_human_short')}. Natural skin texture.\n"
    )

    if hero_present:
        prompt += (
            f"\nIDENTITY LOCK: Maintain EXACT facial features, body type, and skin tone "
            f"from the provided identity reference image. Only change the wardrobe.\n"
        )

    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"

    return prompt


def _build_phase_hair_prompt(description, phase_hair, hero_present=True,
                              mood_text="", user_override="", gender=None):
    """Build a single-image hair/makeup prompt for one phase."""
    gender_anchor = ""
    if gender and gender.lower() == "female":
        gender_anchor = "Female. "
    elif gender and gender.lower() == "male":
        gender_anchor = "Male. "

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single extreme close-up hair and makeup continuity photograph.\n"
        f"DIEGETIC FRAMING: A makeup artist's continuity Polaroid, "
        f"harsh flash photography, extreme close-up macro shot of the face.\n\n"
        f"CHARACTER: {gender_anchor}{description}\n"
        f"HAIR & MAKEUP: {phase_hair}\n"
    )

    if mood_text:
        prompt += f"STYLE REFERENCE: {mood_text}\n"

    prompt += (
        f"\nPHOTOGRAPHIC ANCHORS:\n"
        f"- Extreme close-up, face fills frame.\n"
        f"- Direct flash, harsh but revealing lighting.\n"
        f"- Maximum detail on hair texture, makeup application.\n"
        f"- {get_constant('casting', 'casting_texture_human_short')}.\n"
    )

    if hero_present:
        prompt += (
            f"\nIDENTITY LOCK: Maintain EXACT facial features and skin tone "
            f"from the provided identity reference image. Only change hair and makeup.\n"
        )

    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"

    return prompt


def generate_phase_candidates(
    asset_type: str,
    phases_data: list[dict],
    output_dir: Path,
    char_id: str,
    hero_image_path: str = None,
    char_description: str = "",
    mood_text: str = "",
    user_override: str = "",
    gender: str = None,
    candidates_per_phase: int = 3,
    stagger_ms: int = 200,
    on_candidate_ready: callable = None,
) -> dict:
    """Generate candidates for multiple phases (wardrobe or hair_makeup).

    Fires candidates_per_phase individual API calls per phase.
    Calls on_candidate_ready(phase_id, slot, path) as each image completes.

    Args:
        asset_type: "wardrobe" or "hair_makeup"
        phases_data: list of phase dicts from bible, each with phase_id + wardrobe_description/hair_makeup
        output_dir: base output dir (e.g., REFS_DIR / "characters" / char_lower)
        char_id: character ID (lowercase)
        hero_image_path: absolute path to locked hero image for identity ref
        char_description: character visual description from bible
        mood_text: mood reference text
        user_override: user override text
        gender: character gender
        candidates_per_phase: how many candidates per phase (default 3)
        stagger_ms: delay between API calls in ms
        on_candidate_ready: optional callback(phase_id, slot_index, image_path)

    Returns: {
        "phases": { "phase_id": [path1, path2, path3], ... },
        "cost": float,
        "model": str,
    }
    """
    from google import genai
    from google.genai import types
    from recoil.core.model_profiles import get_cost

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    # Use exploration model for phase candidates
    model_id = _resolve_model("exploration", "image")
    per_cost = get_cost(model_id)
    stagger = stagger_ms / 1000.0

    # Load hero image bytes once
    hero_parts = []
    if hero_image_path and Path(hero_image_path).exists():
        hero_bytes = Path(hero_image_path).read_bytes()
        suffix = Path(hero_image_path).suffix.lower().lstrip(".")
        mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
                "webp": "image/webp"}.get(suffix, "image/png")
        hero_parts = [
            types.Part.from_bytes(data=hero_bytes, mime_type=mime),
            types.Part(text="[IDENTITY REFERENCE — maintain this exact person's face, body type, and skin tone]"),
        ]

    client = genai.Client(api_key=api_key)

    # Lower temperature for continuity (wardrobe should be consistent)
    temp = 0.35 if asset_type == "hair_makeup" else 0.45
    aspect = "1:1" if asset_type == "hair_makeup" else "2:3"

    config = types.GenerateContentConfig(
        temperature=temp,
        response_modalities=["IMAGE", "TEXT"],
        image_config=types.ImageConfig(aspect_ratio=aspect),
    )

    from concurrent.futures import ThreadPoolExecutor, as_completed
    import threading

    result_phases = {}
    cost_lock = threading.Lock()
    total_cost = 0.0

    def _generate_one(phase_id, phase_dir, parts, slot_idx):
        """Generate a single candidate. Returns (phase_id, slot_idx, path, cost) or None."""
        nonlocal total_cost
        panel_path = phase_dir / f"{char_id}_{asset_type}_{phase_id}_c{slot_idx + 1:02d}.png"
        try:
            response = client.models.generate_content(
                model=model_id, contents=parts, config=config,
            )
        except Exception as e:
            logger.error("Phase %s candidate %d/%d failed: %s",
                         phase_id, slot_idx + 1, candidates_per_phase, e)
            return None

        if response and response.candidates:
            for cand in response.candidates:
                if cand.content and cand.content.parts:
                    for part in cand.content.parts:
                        if hasattr(part, "inline_data") and part.inline_data:
                            _save_as_png(panel_path, part.inline_data.data)
                            with cost_lock:
                                total_cost += per_cost
                            logger.info("Phase %s candidate %d/%d: %s",
                                        phase_id, slot_idx + 1, candidates_per_phase, panel_path.name)
                            if on_candidate_ready:
                                on_candidate_ready(phase_id, slot_idx, str(panel_path))
                            return (phase_id, slot_idx, str(panel_path))
        return None

    # Build all jobs upfront: [(phase_id, phase_dir, parts, slot_idx), ...]
    jobs = []
    for phase in phases_data:
        phase_id = phase.get("phase_id", "unknown")
        phase_dir = output_dir / "candidates" / asset_type / phase_id
        phase_dir.mkdir(parents=True, exist_ok=True)

        if asset_type == "wardrobe":
            phase_desc = phase.get("wardrobe_description", "")
            prompt = _build_phase_wardrobe_prompt(
                description=char_description,
                phase_wardrobe=phase_desc,
                hero_present=bool(hero_parts),
                mood_text=mood_text,
                user_override=user_override,
                gender=gender,
            )
        else:  # hair_makeup
            phase_desc = phase.get("hair_makeup", "")
            prompt = _build_phase_hair_prompt(
                description=char_description,
                phase_hair=phase_desc,
                hero_present=bool(hero_parts),
                mood_text=mood_text,
                user_override=user_override,
                gender=gender,
            )

        parts = list(hero_parts) + [types.Part(text=prompt)]
        result_phases[phase_id] = []

        for i in range(candidates_per_phase):
            jobs.append((phase_id, phase_dir, parts, i))

    # Fire all jobs in parallel (capped at 6 concurrent to avoid API rate limits)
    max_workers = min(6, len(jobs))
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = {}
        for j_idx, (pid, pdir, parts, slot) in enumerate(jobs):
            # Small stagger between submissions to avoid burst rate limits
            if j_idx > 0:
                time.sleep(stagger)
            f = pool.submit(_generate_one, pid, pdir, parts, slot)
            futures[f] = pid

        for f in as_completed(futures):
            r = f.result()
            if r:
                pid, slot, path = r
                result_phases[pid].append(path)

    return {
        "phases": result_phases,
        "cost": total_cost,
        "model": model_id,
    }


# ── Wardrobe Intent Gate ──────────────────────────────────────────


def propose_wardrobe_philosophy(treatment: str, series_bible: str) -> list[str]:
    """Generate 3 series-level wardrobe philosophy options via Flash.

    Cost: ~$0.01. Returns a list of 3 one-sentence philosophy strings.
    """
    from google import genai
    from google.genai import types

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    prompt = f"""You are a costume designer reading a series treatment to propose a wardrobe philosophy.

SERIES BIBLE:
{series_bible}

TREATMENT:
{treatment}

Generate exactly 3 one-sentence wardrobe philosophy options for this series.
Each philosophy must:
1. Describe what CLOTHING MEANS in this world (not just what characters wear)
2. Propose a metaphorical framework (e.g., "clothing as armor", "clothing as contamination", "clothing as debt")
3. Imply a direction of change across the series
4. Be distinct from the other options

Output as a JSON array of 3 strings. No markdown fences.
["option 1", "option 2", "option 3"]"""

    client = genai.Client(api_key=api_key)
    model_id = _resolve_model("flash", "text")
    resp = client.models.generate_content(
        model=model_id,
        contents=[types.Part(text=prompt)],
        config=types.GenerateContentConfig(temperature=0.7),
    )
    raw = resp.text.strip() if resp and resp.text else "[]"
    if raw.startswith("```"):
        lines = raw.split("\n")
        lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        raw = "\n".join(lines)

    candidates = json.loads(raw)
    if not isinstance(candidates, list):
        raise ValueError(f"Expected JSON array, got: {type(candidates)}")
    logger.info("Proposed %d wardrobe philosophy options", len(candidates))
    return candidates


def propose_character_theses(
    character_id: str,
    char_description: str,
    phase_boundaries: list[dict],
    series_philosophy: str,
    episode_arc: str,
) -> list[str]:
    """Generate 3 per-character wardrobe thesis options via Flash.

    phase_boundaries should contain ONLY structural data:
    phase_id, start_ep, end_ep, phase_trigger_event (no wardrobe descriptions).

    Cost: ~$0.01. Returns a list of 3 one-sentence thesis strings.
    """
    from google import genai
    from google.genai import types

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    boundaries_block = ""
    for i, pb in enumerate(phase_boundaries):
        boundaries_block += (
            f"Phase {i+1} [{pb.get('phase_id', f'phase_{i+1}')}] "
            f"(EP {pb.get('start_ep', '?')}-{pb.get('end_ep', '?')}): "
            f"{pb.get('phase_trigger_event', '')}\n"
        )

    prompt = f"""You are proposing wardrobe arc theses for a director to choose from.

SERIES WARDROBE PHILOSOPHY (all character arcs must serve this):
{series_philosophy}

CHARACTER: {character_id}
{char_description}

STORY TIMELINE (emotional beats -- use for WHEN things change, not WHAT the character wears):
The following is an EMOTIONAL TIMELINE. Use it to understand WHEN the character's emotional state changes, so you can align wardrobe transitions to those moments. Do NOT use it to determine WHAT the character wears -- the series philosophy above controls that.

{episode_arc}

PHASE BOUNDARIES:
{boundaries_block}

Generate exactly 3 one-sentence wardrobe arc thesis options for {character_id}.
Each thesis must:
1. Serve the series philosophy above
2. Describe what the wardrobe arc SAYS about the character (metaphorical, not literal)
3. Imply a DIRECTION of change (not just "clothes get dirty")
4. Be distinct from the other options (offer genuinely different readings)
5. Reference specific emotional beats from the phase triggers

Output as a JSON array of 3 strings. No markdown fences.
["option 1", "option 2", "option 3"]"""

    client = genai.Client(api_key=api_key)
    model_id = _resolve_model("flash", "text")
    resp = client.models.generate_content(
        model=model_id,
        contents=[types.Part(text=prompt)],
        config=types.GenerateContentConfig(temperature=0.7),
    )
    raw = resp.text.strip() if resp and resp.text else "[]"
    if raw.startswith("```"):
        lines = raw.split("\n")
        lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        raw = "\n".join(lines)

    candidates = json.loads(raw)
    if not isinstance(candidates, list):
        raise ValueError(f"Expected JSON array, got: {type(candidates)}")
    logger.info("Proposed %d thesis options for %s", len(candidates), character_id)
    return candidates


def rewrite_wardrobe_phases(
    character_id: str,
    char_description: str,
    phase_boundaries: list[dict],
    thesis: str,
    series_philosophy: str = "",
    episode_arc: str = "",
    director_hint: str = "",
) -> dict:
    """Generate concrete wardrobe descriptions from an approved thesis.

    Used by both the Intent Gate (greenfield/retrofit) and the Arc Editor
    REDRAFT FROM THESIS button.

    Deliberately EXCLUDES:
    - Existing wardrobe descriptions (they are wrong, that's why we're here)
    - Treatment (literal-event bias)
    - Series bible (narrative prose bias)
    - Characters bible (behavioral, not visual)

    Cost: ~$0.02. Returns: {
        "thesis": str,
        "phases": [{ phase_id, wardrobe_description, wardrobe_arc_delta, wardrobe_arc_carries }]
    }
    """
    from google import genai
    from google.genai import types

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    boundaries_block = ""
    for i, pb in enumerate(phase_boundaries):
        boundaries_block += (
            f"Phase {i+1} [phase_id: {pb.get('phase_id', f'phase_{i+1}')}] "
            f"(EP {pb.get('start_ep', '?')}-{pb.get('end_ep', '?')}): "
            f"{pb.get('phase_trigger_event', '')}\n"
        )

    hint_line = f"\nADDITIONAL DIRECTION: {director_hint}\n" if director_hint else ""

    prompt = f"""You are a costume designer working under a director who has a SPECIFIC VISION for this character's wardrobe arc. Your job is to REALIZE the director's vision in concrete wardrobe terms. You do not get to disagree with the director. The director's word is final.

{"=" * 60}
SERIES WARDROBE PHILOSOPHY
{"=" * 60}

{series_philosophy or "(not set)"}

{"=" * 60}
DIRECTOR-APPROVED THESIS FOR {character_id} (THIS IS YOUR MANDATE)
{"=" * 60}

{thesis}
{hint_line}
{"=" * 60}
CHARACTER
{"=" * 60}

{character_id}
{char_description}

{"=" * 60}
TIMELINE (emotional beats -- for WHEN things change, not WHAT changes)
{"=" * 60}

The following is an EMOTIONAL TIMELINE. Use it to understand WHEN the character's emotional state changes, so you can align wardrobe transitions to those moments. Do NOT use it to determine WHAT the character wears -- the thesis above controls that.

{episode_arc or "(not available)"}

{"=" * 60}
PHASE STRUCTURE (fill these slots)
{"=" * 60}

{boundaries_block}

{"=" * 60}
YOUR TASK
{"=" * 60}

Create a complete wardrobe arc that SERVES THE THESIS above.

CRITICAL RULES:
1. The thesis describes the MEANING of the wardrobe changes. Translate that meaning into specific, concrete garments, accessories, and states of wear.
2. If the thesis says items are REMOVED, they are removed. Do not keep items that the thesis says go away.
3. If the thesis says the character is STRIPPED DOWN or LAID BARE, that means fewer garments, not more.
4. Wardrobe changes are SYMBOLIC. A jacket being removed can mean emotional armor dropping. A device being taken off can mean freedom.
5. Phase 1 = exhaustive base inventory (80+ words). Every garment, accessory, device, with materials, colors, textures, wear state, fit.
6. Phases 2+ = structured deltas. ONLY what changes:
   + added (with narrative/emotional reason)
   - removed (with narrative/emotional reason)
   ~ modified (what changed and why)
7. carries_forward = every unchanged item from previous phase.
8. wardrobe_description = complete picture for each phase (what the character looks like RIGHT NOW). Must be self-contained -- an image generator reading ONLY this field must get the full picture. 60-120 words.
9. Lead wardrobe_description with the most visually dominant element. Use concrete visual language: materials, colors, textures, wear state, fit. No narrative language. Mention ABSENCE explicitly when items are removed ("bare arms, jacket removed", "face exposed, rebreather off").

OUTPUT: valid JSON only, no markdown fences.
CRITICAL: The "phase_id" value MUST be copied EXACTLY from the [phase_id: ...] brackets above.
{{
  "thesis": "{thesis}",
  "phases": [
    {{
      "phase_id": "EXACT phase_id from above",
      "wardrobe_description": "Complete description (80+ words Phase 1, 60-120 words subsequent)",
      "wardrobe_arc_delta": "BASE (Phase 1) or structured +/-/~ deltas with reasons",
      "wardrobe_arc_carries": "N/A (Phase 1) or comma-separated unchanged items"
    }}
  ]
}}"""

    client = genai.Client(api_key=api_key)
    model_id = _resolve_model("flash", "text")
    resp = client.models.generate_content(
        model=model_id,
        contents=[types.Part(text=prompt)],
        config=types.GenerateContentConfig(temperature=0.5),
    )
    raw = resp.text.strip() if resp and resp.text else ""
    if raw.startswith("```"):
        lines = raw.split("\n")
        lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        raw = "\n".join(lines)

    result = json.loads(raw)
    if "thesis" not in result or "phases" not in result:
        raise ValueError("Flash response missing 'thesis' or 'phases' keys")

    # Validate phase_ids match input
    input_ids = [pb.get("phase_id", "") for pb in phase_boundaries]
    for phase in result["phases"]:
        if phase.get("phase_id") not in input_ids:
            logger.warning("Rewritten phase_id '%s' not in input", phase.get("phase_id"))

    # Run validation heuristic
    warnings = validate_arc_against_thesis(thesis, result["phases"])
    result["warnings"] = warnings

    logger.info(
        "Wardrobe phases rewritten for %s: thesis='%s', %d phases, %d warnings",
        character_id, thesis[:60], len(result["phases"]), len(warnings),
    )
    return result


def validate_arc_against_thesis(thesis: str, phases: list[dict]) -> list[str]:
    """Pre-apply sanity check: does the arc serve the thesis?

    Pure Python heuristic — no LLM call. Returns list of warning strings.
    """
    warnings = []
    if not thesis:
        warnings.append("No thesis defined.")
        return warnings

    thesis_lower = thesis.lower()

    # Check for monotonic accumulation (the original bug)
    desc_lengths = []
    for phase in phases:
        desc = phase.get("wardrobe_description", "")
        desc_lengths.append(len([s for s in desc.split(",") if s.strip()]))

    if len(desc_lengths) > 3:
        if all(desc_lengths[i] <= desc_lengths[i + 1] for i in range(len(desc_lengths) - 1)):
            warnings.append(
                "Items only accumulate across phases -- nothing is removed. "
                "If the thesis describes stripping, vulnerability, or loss, "
                "this arc may contradict it."
            )

    # Check for removal keywords in thesis vs removal markers in deltas
    removal_keywords = ["remov", "strip", "bare", "vulnerab", "shed", "discard", "lose", "lost", "off"]
    thesis_implies_removal = any(kw in thesis_lower for kw in removal_keywords)

    if thesis_implies_removal:
        has_removal = False
        for phase in phases[1:]:
            delta = phase.get("wardrobe_arc_delta", "")
            if isinstance(delta, dict):
                has_removal = has_removal or bool(delta.get("removed"))
            elif isinstance(delta, str):
                has_removal = has_removal or ("- " in delta or "removed" in delta.lower())
        if not has_removal:
            warnings.append(
                "Thesis implies removal/stripping but no phase has a "
                "'removed' delta. The arc may not serve the thesis."
            )

    # Check last phase is simpler than middle phases (for stripping arcs)
    if thesis_implies_removal and len(desc_lengths) >= 4:
        peak = max(desc_lengths[1:-1])
        final = desc_lengths[-1]
        if final >= peak:
            warnings.append(
                "Final phase has as many or more items than middle phases. "
                "For an arc about stripping/loss, the final phase should be simpler."
            )

    return warnings


# ── Continuity Grid Generation ──

def _enrich_continuity_grid_prompt(
    asset_type: str,
    phases_data: list[dict],
    char_description: str = "",
    gender: str = None,
    hero_present: bool = True,
    user_override: str = "",
    phase_overrides: dict = None,
) -> str:
    """Use Flash to enrich bible shorthand into an NBP-optimized continuity grid prompt.

    Sends character + phase data to Flash, which knows how to talk to NBP:
    - Expands terse bible descriptions into rich visual language
    - Applies ADR-C01 through C07 (diegetic framing, anti-airbrush, gray bg, etc.)
    - Structures Visual Anchors block, panel descriptions, consistency rules
    - Enforces description density (>40 words per panel)
    - Adds mid-prompt grid reinforcement for recency bias

    Cost: ~$0.01 per call (Flash text-only).
    Falls back to _build_continuity_grid_prompt_fallback() on failure.
    """
    from google import genai
    from google.genai import types

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        logger.warning("No API key for enrichment, using fallback template")
        return _build_continuity_grid_prompt_fallback(
            asset_type, phases_data, char_description, gender,
            hero_present, user_override, phase_overrides,
        )

    n = len(phases_data)
    if n <= 4:
        grid_label = "2x2 grid (4 panels)"
    elif n <= 6:
        grid_label = "2x3 grid (6 panels)"
    else:
        grid_label = "2x4 grid (8 panels)"

    if asset_type == "hair_makeup":
        domain = "hair and makeup"
        frame = "makeup artist's continuity chart"
        shot_type = "Extreme close-up of the face, harsh flash photography"
    else:
        domain = "wardrobe"
        frame = "costume designer's continuity chart"
        shot_type = "Full-body shot, even studio lighting, neutral pose"

    gender_str = f"{gender}. " if gender else ""

    # Build the raw input for Flash
    phase_block = ""
    overrides = phase_overrides or {}
    for i, phase in enumerate(phases_data):
        phase_label = phase.get("phase_trigger_event", f"Phase {i + 1}")
        ep_range = ""
        if phase.get("start_ep") and phase.get("end_ep"):
            ep_range = f" (EP {phase['start_ep']}-{phase['end_ep']})"
        if asset_type == "hair_makeup":
            desc = phase.get("hair_makeup", "(no description)")
        else:
            desc = phase.get("wardrobe_description", "(no description)")
        phase_block += f"Phase {i + 1} — {phase_label}{ep_range}: {desc}\n"
        # Include arc enrichment data when present (structured deltas + carries)
        arc_delta = phase.get("wardrobe_arc_delta", "")
        arc_carries = phase.get("wardrobe_arc_carries", "")
        if arc_delta:
            phase_block += f"  ARC DELTA: {arc_delta}\n"
        if arc_carries:
            phase_block += f"  CARRIES FORWARD: {arc_carries}\n"
        phase_id = phase.get("phase_id", "")
        if phase_id and overrides.get(phase_id):
            phase_block += f"  Director's note for this phase: {overrides[phase_id]}\n"

    enrichment_prompt = f"""You are a prompt engineer specializing in Gemini 3 Pro Image (NBP) generation.

Your job: take the raw character and wardrobe data below and produce a single, complete image generation prompt optimized for NBP to generate a {frame}.

RAW INPUT:
- Character: {gender_str}{char_description}
- Asset type: {domain}
- Grid layout: {grid_label} ({n} phases)
- Shot type: {shot_type}
- Hero reference image provided: {"yes" if hero_present else "no"}
{f'- Director general direction: {user_override}' if user_override else ''}

PHASE DATA:
{phase_block}

RULES YOU MUST FOLLOW IN THE OUTPUT PROMPT:

1. DIEGETIC FRAMING: Frame it as a "{frame}" — a practical, physical document. Use camera language: "35mm motion picture film, {get_constant('casting', 'casting_camera')}." Add anti-airbrush: "{get_constant('casting', 'casting_texture_human_short')}. {get_constant('casting', 'casting_anti_airbrush')}"

2. 18% NEUTRAL GRAY BACKGROUND: Specify "{get_constant('casting', 'casting_background')}" — NOT white (white causes light wrap and edge blowout).

3. VISUAL ANCHORS BLOCK: Before the panel descriptions, include a "VISUAL ANCHORS (MUST REMAIN CONSTANT IN ALL PANELS):" block listing 4-5 traits that must be identical across every panel (face, body type, skin tone, lighting, background, base garments that don't change).

4. PANEL 1 = EXHAUSTIVE BASE: Panel 1 must describe the complete base look in rich, specific visual language (>60 words). Expand terse descriptions — "blood-splattered salvage gear" becomes specific garment-by-garment detail with material textures, wear patterns, color specifics, fit descriptions.

5. PANELS 2+ = DELTA ONLY: Each subsequent panel describes ONLY what changes from the previous panel. Explicitly state that everything else carries forward unchanged. Still expand the descriptions into rich visual language (>40 words each).

6. PANEL ISOLATION: Include "Strictly isolated panels, no overlapping elements between panels."

7. GRID REINFORCEMENT: Mention the grid structure at least twice — once at the start and once mid-prompt after the panel descriptions. This combats recency bias.

8. DESCRIPTION DENSITY: Every panel description must be >40 words. If the raw input is terse, expand it with plausible visual details (fabric textures, wear patterns, color temperatures, material finishes). Stay faithful to the character but make it specific.

9. IDENTITY LOCK: {"Include identity lock language — the provided reference image is the definitive identity." if hero_present else "No hero image provided — describe the character's identifying features precisely."}

10. BLANK PANELS: If the grid has more slots than phases, fill remaining with "BLANK — solid {get_constant('casting', 'casting_background').split(' seamless')[0]}."

OUTPUT FORMAT:
Return ONLY the final prompt text. No explanations, no markdown formatting, no preamble. Just the raw prompt string ready to send directly to NBP's generate_content API."""

    try:
        client = genai.Client(api_key=api_key)
        model_id = _resolve_model("flash", "text")
        response = client.models.generate_content(
            model=model_id,
            contents=[types.Part(text=enrichment_prompt)],
            config=types.GenerateContentConfig(temperature=0.1),
        )
        enriched = response.text.strip() if response and response.text else None
        if enriched and len(enriched) > 200:
            logger.info("Continuity grid prompt enriched via Flash (%d chars)", len(enriched))
            return enriched
        else:
            logger.warning("Flash enrichment returned insufficient output, using fallback")
    except Exception as e:
        logger.warning("Flash enrichment failed: %s — using fallback", e)

    return _build_continuity_grid_prompt_fallback(
        asset_type, phases_data, char_description, gender,
        hero_present, user_override, phase_overrides,
    )


def _build_continuity_grid_prompt_fallback(
    asset_type: str,
    phases_data: list[dict],
    char_description: str = "",
    gender: str = None,
    hero_present: bool = True,
    user_override: str = "",
    phase_overrides: dict = None,
) -> str:
    """Fallback template prompt when Flash enrichment is unavailable."""
    gender_anchor = ""
    if gender and gender.lower() == "female":
        gender_anchor = "Female. "
    elif gender and gender.lower() == "male":
        gender_anchor = "Male. "

    n = len(phases_data)
    if n <= 4:
        grid_label = "2x2 grid (4 panels)"
    elif n <= 6:
        grid_label = "2x3 grid (6 panels)"
    else:
        grid_label = "2x4 grid (8 panels)"

    if asset_type == "hair_makeup":
        domain = "hair and makeup"
        frame = "makeup artist's continuity chart"
        shot_type = "Extreme close-up of the face, harsh flash photography"
    else:
        domain = "wardrobe"
        frame = "costume designer's continuity chart"
        shot_type = "Full-body shot, even studio lighting, neutral pose"

    casting_camera = get_constant("casting", "casting_camera")
    casting_texture = get_constant("casting", "casting_texture_human").split(".")[0]
    casting_anti = get_constant("casting", "casting_anti_airbrush")
    casting_bg = get_constant("casting", "casting_background")

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a {frame} for one character showing "
        f"{n} sequential {domain} phases in a {grid_label} layout. "
        f"35mm motion picture film, {casting_camera}. "
        f"{casting_texture}. "
        f"{casting_anti}\n\n"
        f"CHARACTER: {gender_anchor}{char_description}\n"
        f"{shot_type}. {casting_bg}.\n\n"
        f"LAYOUT: {grid_label}, reading left-to-right, top-to-bottom. "
        f"Strictly isolated panels, no overlapping elements between panels.\n\n"
    )

    overrides = phase_overrides or {}
    first_phase = phases_data[0]
    first_label = first_phase.get("phase_trigger_event", "Phase 1")
    if asset_type == "hair_makeup":
        first_desc = first_phase.get("hair_makeup", "(no description)")
    else:
        first_desc = first_phase.get("wardrobe_description", "(no description)")

    first_arc_delta = first_phase.get("wardrobe_arc_delta", "")
    prompt += (
        f"Panel 1 — BASE {domain.upper()} — {first_label}:\n"
        f"  {first_desc}\n"
    )
    if first_arc_delta:
        prompt += f"  ARC DELTA: {first_arc_delta}\n"
    prompt += (
        f"  This is the DEFINITIVE base look. Every garment, accessory, shoe, "
        f"and detail described here carries forward to ALL subsequent panels "
        f"unless explicitly changed.\n"
    )
    first_id = first_phase.get("phase_id", "")
    if first_id and overrides.get(first_id):
        prompt += f"  [DIRECTION: {overrides[first_id]}]\n"
    prompt += "\n"

    for i, phase in enumerate(phases_data[1:], start=2):
        phase_label = phase.get("phase_trigger_event", f"Phase {i}")
        if asset_type == "hair_makeup":
            desc = phase.get("hair_makeup", "(no description)")
        else:
            desc = phase.get("wardrobe_description", "(no description)")
        arc_delta = phase.get("wardrobe_arc_delta", "")
        arc_carries = phase.get("wardrobe_arc_carries", "")
        prompt += (
            f"Panel {i} — CHANGES FROM PREVIOUS — {phase_label}:\n"
            f"  {desc}\n"
        )
        if arc_delta:
            prompt += f"  ARC DELTA: {arc_delta}\n"
        if arc_carries:
            prompt += f"  CARRIES FORWARD: {arc_carries}\n"
        prompt += (
            f"  Everything NOT mentioned stays EXACTLY as previous panel.\n"
        )
        phase_id = phase.get("phase_id", "")
        if phase_id and overrides.get(phase_id):
            prompt += f"  [DIRECTION: {overrides[phase_id]}]\n"

    total_slots = 4 if n <= 4 else (6 if n <= 6 else 8)
    for i in range(n, total_slots):
        prompt += f"Panel {i + 1}: BLANK — solid {casting_bg.split(' seamless')[0]}.\n"

    prompt += (
        f"\nGRID STRUCTURE REINFORCEMENT: This is a {grid_label}. "
        f"Every panel must be clearly separated and self-contained.\n"
    )

    if asset_type == "hair_makeup":
        consistency_detail = (
            f"- The character's clothing, accessories, and body language MUST stay identical across ALL panels.\n"
            f"- ONLY the hair styling and makeup change between panels — nothing else.\n"
        )
    else:
        consistency_detail = (
            f"- Panel 1 defines the COMPLETE base outfit. Panels 2+ are MODIFICATIONS only.\n"
            f"- If a panel doesn't mention pants — pants are IDENTICAL to previous panel.\n"
            f"- If a panel doesn't mention shoes — shoes are IDENTICAL to previous panel.\n"
            f"- Only what is EXPLICITLY called out changes.\n"
        )
    prompt += (
        f"\nVISUAL ANCHORS (MUST REMAIN CONSTANT IN ALL PANELS):\n"
        f"- Character face, body type, skin tone\n"
        f"- {casting_bg}\n"
        f"- Lighting setup\n"
        f"- Pose and framing\n"
        f"{consistency_detail}"
    )

    if hero_present:
        prompt += (
            f"\nIDENTITY LOCK: The provided reference image is the DEFINITIVE identity. "
            f"Match this person's exact face, body, and proportions in every panel.\n"
        )

    if user_override:
        prompt += f"\n[DIRECTOR OVERRIDE: {user_override}]\n"

    return prompt


def generate_continuity_grid(
    asset_type: str,
    phases_data: list[dict],
    output_dir: Path,
    char_id: str,
    hero_image_path: str = None,
    char_description: str = "",
    gender: str = None,
    user_override: str = "",
    phase_overrides: dict = None,
    num_candidates: int = 3,
    stagger_ms: int = 500,
    on_grid_ready: callable = None,
) -> dict:
    """Generate continuity grid candidates — all phases in one image.

    Each candidate is a single grid image with all phases visible.
    Uses NBP (production model) for maximum cross-panel coherence.

    Args:
        asset_type: "wardrobe" or "hair_makeup"
        phases_data: list of phase dicts from bible
        output_dir: base output dir for this character's refs
        char_id: character ID (lowercase)
        hero_image_path: absolute path to locked hero image
        char_description: visual description from bible
        gender: character gender
        user_override: user override text
        num_candidates: how many grid candidates to generate (default 3)
        stagger_ms: delay between API calls in ms
        on_grid_ready: callback(slot_index, grid_path) called as each grid completes

    Returns: {
        "grids": [path1, path2, path3],
        "cost": float,
        "model": str,
    }
    """
    from concurrent.futures import ThreadPoolExecutor, as_completed
    from google import genai
    from google.genai import types
    from recoil.core.model_profiles import get_cost

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    # Use production model (NBP) for grid coherence
    model_id = _resolve_model("production", "image")
    per_cost = get_cost(model_id)

    # Enrich prompt via Flash (falls back to template on failure)
    prompt = _enrich_continuity_grid_prompt(
        asset_type=asset_type,
        phases_data=phases_data,
        char_description=char_description,
        gender=gender,
        hero_present=bool(hero_image_path),
        user_override=user_override,
        phase_overrides=phase_overrides,
    )

    # Load hero image
    hero_parts = []
    if hero_image_path and Path(hero_image_path).exists():
        hero_bytes = Path(hero_image_path).read_bytes()
        suffix = Path(hero_image_path).suffix.lower().lstrip(".")
        mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
                "webp": "image/webp"}.get(suffix, "image/png")
        hero_parts = [
            types.Part.from_bytes(data=hero_bytes, mime_type=mime),
            types.Part(text="[IDENTITY REFERENCE — maintain this exact person in every panel]"),
        ]

    parts = list(hero_parts) + [types.Part(text=prompt)]
    client = genai.Client(api_key=api_key)

    # Grid aspect ratio: 16:9 for 2x4, 3:4 for 2x3, 1:1 for 2x2
    n = len(phases_data)
    if n <= 4:
        aspect = "1:1"
    elif n <= 6:
        aspect = "3:4"
    else:
        aspect = "16:9"

    config = types.GenerateContentConfig(
        temperature=0.4,
        response_modalities=["IMAGE", "TEXT"],
        image_config=types.ImageConfig(aspect_ratio=aspect),
    )

    grid_dir = output_dir / "candidates" / asset_type / "grids"
    grid_dir.mkdir(parents=True, exist_ok=True)

    import threading
    cost_lock = threading.Lock()
    total_cost = 0.0
    grids = [None] * num_candidates

    def _generate_one_grid(slot_idx):
        nonlocal total_cost
        grid_path = grid_dir / f"{char_id}_{asset_type}_grid_c{slot_idx + 1:02d}.png"
        try:
            response = client.models.generate_content(
                model=model_id, contents=parts, config=config,
            )
        except Exception as e:
            logger.error("Continuity grid candidate %d failed: %s", slot_idx + 1, e)
            return None

        if response and response.candidates:
            for cand in response.candidates:
                if cand.content and cand.content.parts:
                    for part in cand.content.parts:
                        if hasattr(part, "inline_data") and part.inline_data:
                            _save_as_png(grid_path, part.inline_data.data)
                            with cost_lock:
                                total_cost += per_cost
                            logger.info("Continuity grid candidate %d: %s", slot_idx + 1, grid_path.name)
                            if on_grid_ready:
                                on_grid_ready(slot_idx, str(grid_path))
                            return str(grid_path)
        return None

    # Fire all candidates in parallel
    stagger = stagger_ms / 1000.0
    with ThreadPoolExecutor(max_workers=num_candidates) as pool:
        futures = {}
        for i in range(num_candidates):
            if i > 0:
                time.sleep(stagger)
            f = pool.submit(_generate_one_grid, i)
            futures[f] = i

        for f in as_completed(futures):
            slot = futures[f]
            result = f.result()
            if result:
                grids[slot] = result

    return {
        "grids": [g for g in grids if g],
        "cost": total_cost,
        "model": model_id,
    }


# ── Beauty Pass (ADR-BP01 through BP07) ──

_BEAUTY_PASS_ORGANIC_TEXTURE = (
    "Organic Texture: Unretouched photorealism. Visible skin pores, vellus hair (peach fuzz), "
    "micro-imperfections, natural subsurface scattering, matte skin. "
    "Fine chromatic aberration at edges."
)

_BEAUTY_PASS_SYNTHETIC_TEXTURE = (
    "Synthetic Texture: Unretouched photorealism. Anodized metal surfaces, "
    "micro-scratches, matte carbon fiber weave, machine-milled edges, "
    "dust in crevices. No organic skin."
)


def run_beauty_pass(
    hero_image_path: str,
    description: str,
    output_path: Path,
    temperature: float = 0.2,
    is_synthetic: bool = False,
) -> dict:
    """Run NBP beauty pass on a Flash-generated hero image.

    Uses the Sandwich Pattern (ADR-BP01):
      Part 1 (text): Decoupling command + photographic anchors
      Part 2 (image): Flash hero reference
      Part 3 (text): Execution trigger

    Returns: {"path": str, "cost": float, "model": str}
    """
    from google import genai
    from google.genai import types
    from recoil.core.model_profiles import get_cost

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    model_id = _resolve_model("production", "image")
    cost = get_cost(model_id)

    # Detect synthetic from description if not explicitly set
    if not is_synthetic:
        synthetic_kw = {"android", "cyborg", "robot", "mechanical", "synthetic", "cybernetic",
                        "combat chassis", "alloy", "servos", "hydraulic", "mech"}
        is_synthetic = any(kw in description.lower() for kw in synthetic_kw)

    texture_line = _BEAUTY_PASS_SYNTHETIC_TEXTURE if is_synthetic else _BEAUTY_PASS_ORGANIC_TEXTURE

    # Part 1: Text setup (before image)
    text_setup = (
        "CRITICAL DIRECTIVE: Perform a high-fidelity beauty pass and resolution upgrade "
        "on the following reference image.\n"
        "DECOUPLING COMMAND: Extract the core identity, facial structure, and pose. "
        "DISCARD the reference's soft texture, lighting, and low-resolution limitations. "
        "CRITICAL: Maintain the exact lived-in skin textures (sweat, grime, scars, freckles) "
        "of the reference subject.\n\n"
        f"PHYSICALITY:\n{description}\n\n"
        "PHOTOGRAPHIC ANCHORS:\n"
        f"- Medium: 35mm film still, shot on {get_constant('casting', 'casting_camera')}. "
        "Unretouched editorial portrait.\n"
        "- ENVIRONMENT OVERRIDE: Strip the background entirely. Place the subject against "
        f"a {get_constant('casting', 'casting_background')}. The backdrop MUST be uniformly lit "
        "with flat, shadowless studio lighting to ensure a mathematically clean silhouette "
        "extraction. No cast shadows on the background. Catch lights in irises.\n"
        f"- {texture_line}\n"
        "- Negative: DO NOT RENDER. NO ILLUSTRATION. NO AIRBRUSHING. NO PLASTIC SKIN. NO 3D MODELS."
    )

    # Part 2: Flash hero image
    hero_bytes = Path(hero_image_path).read_bytes()
    suffix = Path(hero_image_path).suffix.lower().lstrip(".")
    mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
            "webp": "image/webp"}.get(suffix, "image/png")

    # Part 3: Execution trigger (after image — recency anchor)
    text_trigger = (
        "FINAL INSTRUCTION: Regenerate the subject from the image above. "
        "Maintain exact pose, framing, and gaze direction. "
        "Maximize photorealistic micro-detail."
    )

    # Sandwich Pattern: [text_setup, image, text_trigger]
    contents = [
        types.Part(text=text_setup),
        types.Part.from_bytes(data=hero_bytes, mime_type=mime),
        types.Part(text=text_trigger),
    ]

    client = genai.Client(api_key=api_key)
    config = types.GenerateContentConfig(
        temperature=temperature,
        response_modalities=["IMAGE"],
        image_config=types.ImageConfig(
            aspect_ratio="2:3",
        ),
    )

    try:
        response = client.models.generate_content(model=model_id, contents=contents, config=config)
    except Exception as e:
        logger.error("Beauty pass failed: %s", e)
        return {"path": None, "cost": cost, "model": model_id}

    # Save output
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    if response and response.candidates:
        for cand in response.candidates:
            if cand.content and cand.content.parts:
                for part in cand.content.parts:
                    if hasattr(part, "inline_data") and part.inline_data:
                        _save_as_png(output_path, part.inline_data.data)
                        logger.info("Beauty pass saved: %s (%s)", output_path, model_id)
                        return {"path": str(output_path), "cost": cost, "model": model_id}

    logger.warning("Beauty pass returned no image data")
    return {"path": None, "cost": cost, "model": model_id}
