#!/usr/bin/env python3
"""
batch_generate_refs.py — Batch Reference Image Generation

Reads breakdown.json prompts.reference fields and generates reference images
using Gemini API (default) or fal.ai (fallback).

Usage:
    python3 batch_generate_refs.py leviathan/
    python3 batch_generate_refs.py leviathan/ --characters
    python3 batch_generate_refs.py leviathan/ --locations
    python3 batch_generate_refs.py leviathan/ --props
    python3 batch_generate_refs.py leviathan/ --character JINX
    python3 batch_generate_refs.py leviathan/ --character JINX --variant lower_deck_salvager
    python3 batch_generate_refs.py leviathan/ --engine gemini
    python3 batch_generate_refs.py leviathan/ --engine fal
    python3 batch_generate_refs.py leviathan/ --dry-run
    python3 batch_generate_refs.py leviathan/ --skip-existing
    python3 batch_generate_refs.py leviathan/ --regenerate             # delete + regen all
    python3 batch_generate_refs.py leviathan/ --max-retries 3          # retry bad images up to 3x
    python3 batch_generate_refs.py leviathan/ --model gemini-2.5-flash-image
    python3 batch_generate_refs.py leviathan/ --reconcile               # force hero-variant reconciliation
    python3 batch_generate_refs.py leviathan/ --no-reconcile            # skip reconciliation
    python3 batch_generate_refs.py leviathan/ --characters --qc        # generate + run visual QC
    python3 batch_generate_refs.py leviathan/ --characters --qc-only   # QC only, no generation
    python3 batch_generate_refs.py leviathan/ --qc-only --qc-model gemini  # QC with Gemini
    python3 batch_generate_refs.py leviathan/ --no-anchor-gates        # skip hero/adherence gates

    # LoRA training data prep:
    #   1. Generate keystone images in MidJourney (7-8 images: front, profiles,
    #      3/4 angles, full body, back — all neutral expression)
    #   2. Place keystones in: [project]/visual/lora_candidates/[CHAR]/keystones/
    #   3. Run --lora-prep to generate identity-consistent candidates:
    python3 batch_generate_refs.py leviathan/ --character JINX --lora-prep 50
    python3 batch_generate_refs.py leviathan/ --character JINX --lora-prep 50 --dry-run
    python3 batch_generate_refs.py leviathan/ --character JINX --lora-prep 50 --lora-target flux2
    python3 batch_generate_refs.py leviathan/ --character JINX --lora-pick

    # Hybrid Qwen + Gemini pipeline (recommended for LoRA prep):
    python3 batch_generate_refs.py leviathan/ --character JINX --hybrid               # parallel (default)
    python3 batch_generate_refs.py leviathan/ --character JINX --hybrid parallel       # explicit parallel
    python3 batch_generate_refs.py leviathan/ --character JINX --hybrid twopass        # Qwen angles → Gemini variations
    python3 batch_generate_refs.py leviathan/ --character JINX --hybrid --dry-run      # preview jobs
    python3 batch_generate_refs.py leviathan/ --character JINX --hybrid --lora-prep 20 # override Gemini count

Dependencies:
    pip install google-genai Pillow
    # Optional for fal fallback:
    pip install fal-client
    # Optional for visual QC:
    pip install anthropic

Env vars:
    GOOGLE_API_KEY   — Gemini API key (required for gemini engine)
    FAL_KEY          — fal.ai API key (required for fal engine)
    ANTHROPIC_API_KEY — Anthropic API key (required for --qc with claude model)
"""

import argparse
import json
import os
import re
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple

from cost_tracker import CostTracker
from recoil.core.model_profiles import get_model

# Shared config loader
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from config_loader import load_project_config, load_rendering_directives


# ── Data Structures ──────────────────────────────────────────────────────

@dataclass
class GenerationJob:
    """A single image generation job."""
    asset_type: str          # "character", "location", "prop"
    asset_key: str           # e.g., "JINX", "INT. LEVIATHAN - CROWN LEVEL"
    variant: Optional[str]   # e.g., "lower_deck_salvager" or None
    angle: Optional[str]     # e.g., "front", "profile" or None
    prompt: str              # The reference prompt text
    output_path: Path        # Where to save the generated image
    aspect_ratio: str        # "9:16", "16:9", "1:1"
    hero_image_path: Optional[Path] = None  # Identity ref for character variants
    anchor_image_path: Optional[Path] = None  # Front angle for intra-variant consistency
    keystone_image_paths: List[Path] = field(default_factory=list)  # Multiple identity refs for LoRA prep
    is_anchor: bool = False  # True for front angles (generated first)
    display_name: str = ""   # Human-readable name
    variant_description: str = ""  # Raw variant text for prompt adherence gate
    is_lora_candidate: bool = False  # True for --lora-prep generated candidates


@dataclass
class GenerationResult:
    """Result of a single generation attempt."""
    job: GenerationJob
    success: bool
    error: Optional[str] = None
    output_path: Optional[Path] = None
    validation_warnings: List[str] = field(default_factory=list)


# ── Path Resolution ──────────────────────────────────────────────────────

def resolve_project_path(project_arg: str) -> Path:
    """Resolve project path from argument."""
    script_dir = Path(__file__).resolve().parent
    if script_dir.name == "tools" and script_dir.parent.name == "recoil":
        root = script_dir.parent.parent
    else:
        root = Path.cwd()

    project_name = project_arg.strip("/").strip("\\")
    candidate = root / project_name
    if candidate.is_dir():
        return candidate

    abs_path = Path(project_arg)
    if abs_path.is_dir():
        return abs_path

    cwd_path = Path.cwd() / project_name
    if cwd_path.is_dir():
        return cwd_path

    print(f"ERROR: Project directory not found: {project_arg}", file=sys.stderr)
    sys.exit(1)


def sanitize_path_component(name: str) -> str:
    """Sanitize a path component for filesystem use."""
    import re
    clean = re.sub(r'^(INT|EXT)\.\s*LEVIATHAN\s*-\s*', '', name, flags=re.IGNORECASE)
    clean = re.sub(r'[\s.\-]+', '_', clean)
    clean = re.sub(r'[^a-zA-Z0-9_]', '', clean)
    return clean.upper()


# ── Job Building ─────────────────────────────────────────────────────────

ANGLE_PREFIXES = {
    "front": (
        "Single photorealistic full-body photograph, front-facing view. "
        "Subject standing upright, arms relaxed at sides, neutral pose. "
        "Camera at eye level. Full body visible from head to feet."
    ),
    "profile": (
        "Single photorealistic full-body photograph, left profile 90-degree side view. "
        "Subject's body turned so the left shoulder faces the camera. "
        "Arms relaxed at sides, neutral standing pose. "
        "Full body visible from head to feet."
    ),
    "three_quarter": (
        "Single photorealistic full-body photograph, three-quarter view. "
        "Subject's body rotated 45 degrees from camera — the far shoulder partially hidden "
        "behind the torso. This is NOT a front-facing view; the body is clearly turned. "
        "Arms relaxed at sides, neutral standing pose. "
        "Full body visible from head to feet."
    ),
    "close_up": (
        "Tight headshot photograph framed from mid-chest to top of head. "
        "ONLY the face, neck, and upper shoulders are visible in frame. "
        "NO full body. NO legs. NO waist. NO hands. NO belt. NO tools. "
        "The face fills most of the frame. Shot on {close_up_lens}, shallow depth of field."
    ),
    "back": (
        "Single photorealistic full-body photograph, rear view. "
        "Subject facing DIRECTLY away from camera — back of head visible, face NOT visible. "
        "Subject must NOT turn or look over shoulder. Head facing forward, away from camera. "
        "Arms relaxed at sides, neutral standing pose. "
        "Full body visible from head to feet."
    ),
}

# close_up uses a stripped-down description (face/hair/skin only, no wardrobe)
CLOSE_UP_STRIPS_WARDROBE = True

ANGLE_NAMES = list(ANGLE_PREFIXES.keys())


def _strip_wardrobe_for_closeup(description: str) -> str:
    """Strip wardrobe/outfit details from a variant description for close-up framing.

    Keeps: face, hair, skin, makeup, injuries, grime, scars, emotional state.
    Removes: clothing, pants, boots, belts, tools, gear, weapons.
    This prevents Gemini from widening the frame to include full outfit.
    """
    # Keywords that indicate wardrobe lines (should be removed)
    wardrobe_keywords = [
        r'\b(?:pants|trousers|cargo\s+pants|boots|shoes|footwear)\b',
        r'\b(?:belt|tool\s+belt|utility\s+belt|holster)\b',
        r'\b(?:salvage\s+hook|hook\s+blade|rebreather|tool\s+kit|wire\s+strippers)\b',
        r'\b(?:work\s+boots|heavy\s+boots|steel-?toe)\b',
        r'\b(?:fingerless\s+gloves|gloves|gauntlets)\b',
        r'\b(?:knee\s+pads|shin\s+guards|leg\s+armor|greaves)\b',
        r'\b(?:jumpsuit|coverall|uniform|armor\s+plating|chest\s+plate|power\s+armor)\b',
        r'\b(?:backpack|pack|satchel|pouch|bag)\b',
    ]

    # Keywords that indicate face/skin lines (should be kept)
    face_keywords = [
        r'\b(?:face|hair|eyes|skin|freckles|scar|scars|bruise|bruises|lips)\b',
        r'\b(?:dirt|grime|sweat|blood|dust|ash|tears|crying)\b',
        r'\b(?:expression|stare|gaze|jaw|cheekbones|forehead|brow)\b',
        r'\b(?:makeup|smear|streak|pallor|flush|sunburn|windburn)\b',
        r'\b(?:stubble|beard|shaved|cropped|matted|tangled|messy)\b',
    ]

    # Split description into sentences
    sentences = re.split(r'(?<=[.!])\s+', description)
    kept = []

    for sentence in sentences:
        sentence_lower = sentence.lower()

        # Check if sentence is primarily about wardrobe
        has_wardrobe = any(re.search(kw, sentence_lower) for kw in wardrobe_keywords)
        has_face = any(re.search(kw, sentence_lower) for kw in face_keywords)

        # Keep sentence if it mentions face/skin OR doesn't mention wardrobe
        if has_face or not has_wardrobe:
            kept.append(sentence)

    result = " ".join(kept).strip()

    # If we stripped everything, return at least a basic description
    if not result or len(result) < 20:
        return description  # Fallback: use full description

    return result


def _extract_character_identity(hero_prompt: str) -> str:
    """Extract the character identity portion from a hero prompt.

    The hero prompt typically starts with 'Extremely candid, photorealistic ... (shot|portrait|view) of [IDENTITY].'
    We extract everything after the 'of' following the angle description.
    """
    # Try to find "(shot|portrait|view) of [identity]. [rest]"
    match = re.search(r'(?:shot|portrait|view)\s+of\s+(.+?)(?:\.\s+(?:Ultra|ultra|Patched|Wearing|In )|\. Ultra)', hero_prompt)
    if match:
        return match.group(1).strip().rstrip(".,")
    # Fallback: everything after "(shot|portrait|view) of"
    match = re.search(r'(?:shot|portrait|view)\s+of\s+(.+)', hero_prompt)
    if match:
        # Take up to first sentence boundary (period followed by space and capital)
        identity = match.group(1).strip()
        sent_end = re.search(r'\.\s+[A-Z]', identity)
        if sent_end:
            return identity[:sent_end.start()].rstrip(".,")
        return identity.rstrip(".,")
    return hero_prompt


def build_character_jobs(
    breakdown: dict,
    project_path: Path,
    character_filter: Optional[str] = None,
    variant_filter: Optional[str] = None,
    skip_existing: bool = False,
    regenerate_hero: bool = False,
    project_config: Optional[dict] = None,
) -> List[GenerationJob]:
    """Build generation jobs for characters.

    New format: variants are single wardrobe description strings.
    This function generates 5 angle jobs per variant by combining:
      - Angle instruction prefix
      - Character identity (extracted from hero prompt)
      - Variant wardrobe description
      - Standard quality suffix
    """
    jobs = []
    refs_dir = project_path / "visual" / "refs" / "characters"

    for char_key, char_data in breakdown.get("characters", {}).items():
        if character_filter and char_key != character_filter.upper():
            continue

        prompts = char_data.get("prompts", {})
        ref_data = prompts.get("reference")

        if not ref_data:
            continue

        # Skip non-visual characters
        if isinstance(ref_data, str) and "N/A" in ref_data:
            continue

        display_name = char_data.get("display_name", char_key)
        char_dir = refs_dir / char_key

        # Determine if structured prompt (hero + variants) or simple string
        if isinstance(ref_data, dict):
            hero_prompt = ref_data.get("hero", "")

            # Hero path: prefer heroes/ folder, fall back to legacy CHARKEY/hero.png
            heroes_dir = refs_dir / "heroes"
            hero_path_new = heroes_dir / f"{char_key}.png"
            hero_path_legacy = char_dir / "hero.png"
            hero_path = hero_path_new if hero_path_new.exists() else hero_path_legacy

            # Hero job — protected by default. Only generated if missing or --regenerate-hero.
            # Writes to heroes/ folder (new canonical location)
            hero_exists = hero_path_new.exists() or hero_path_legacy.exists()
            output_hero_path = heroes_dir / f"{char_key}.png"
            if hero_prompt and not variant_filter and (not hero_exists or regenerate_hero):
                jobs.append(GenerationJob(
                    asset_type="character",
                    asset_key=char_key,
                    variant=None,
                    angle="hero",
                    prompt=hero_prompt,
                    output_path=output_hero_path,
                    aspect_ratio="9:16",
                    display_name=f"{display_name} — Hero",
                ))

            # Extract character identity from hero prompt for variant jobs
            character_identity = _extract_character_identity(hero_prompt) if hero_prompt else display_name

            # Variant angle jobs — each variant is a single wardrobe description string
            variants = ref_data.get("variants", {})
            for var_name, var_description in variants.items():
                if variant_filter and var_name != variant_filter:
                    continue

                if not var_description or not isinstance(var_description, str):
                    continue

                # Generate 5 angle jobs from the single variant description
                front_path = char_dir / var_name / "front.png"
                for angle_name in ANGLE_NAMES:
                    out_path = char_dir / var_name / f"{angle_name}.png"
                    if skip_existing and out_path.exists():
                        continue

                    # Format lens into angle prefix from config
                    _cfg = project_config or {}
                    _lenses = _cfg.get("candidate_lenses", {})
                    angle_prefix = ANGLE_PREFIXES[angle_name].format(
                        close_up_lens=_lenses.get("close_up", "85mm f/1.4 prime"))

                    # Background directive — aggressive to prevent environment leaking
                    bg_directive = (
                        "CRITICAL: Pure white #FFFFFF studio background. "
                        "No floor, no ground, no terrain, no dirt, no scenery, no environment. "
                        "Studio lighting only."
                    )

                    # Load character-specific rendering directives
                    _rendering = load_rendering_directives(project_path, char_key)

                    # Close-up: skin-detail block + stripped wardrobe description
                    if angle_name == "close_up":
                        quality_suffix = (
                            f"{_rendering['texture_prompt']} "
                            "Chiaroscuro lighting casting shadows that sculpt facial features and celebrate skin texture. "
                            "NOT airbrushed. No over-smoothing. No plastic sheen. "
                            "Ultra-detailed, true-to-life."
                        )
                        # For close_up, strip wardrobe to prevent full-body framing.
                        # Only mention face/hair/skin state — rely on hero+anchor refs for identity.
                        if CLOSE_UP_STRIPS_WARDROBE:
                            desc_for_prompt = _strip_wardrobe_for_closeup(var_description)
                        else:
                            desc_for_prompt = var_description
                    else:
                        quality_suffix = (
                            f"{_rendering['texture_prompt']} "
                            "NOT airbrushed. No plastic sheen. "
                            "8K, hyperdetailed, true-to-life."
                        )
                        desc_for_prompt = var_description

                    prompt = (
                        f"{angle_prefix} "
                        f"Subject: {character_identity}. "
                        f"{desc_for_prompt}. "
                        f"{bg_directive} "
                        f"One person only. "
                        f"No multiple views. No turnaround sheet. No split panels. No text. No other people. "
                        f"{quality_suffix}"
                    )

                    is_front = (angle_name == "front")
                    jobs.append(GenerationJob(
                        asset_type="character",
                        asset_key=char_key,
                        variant=var_name,
                        angle=angle_name,
                        prompt=prompt,
                        output_path=out_path,
                        aspect_ratio="9:16",
                        hero_image_path=hero_path if hero_path.exists() else None,
                        anchor_image_path=front_path if not is_front else None,
                        is_anchor=is_front,
                        display_name=f"{display_name} — {var_name}/{angle_name}",
                        variant_description=var_description,
                    ))
        elif isinstance(ref_data, str):
            # Simple string prompt (no wardrobe variants)
            heroes_dir_simple = refs_dir / "heroes"
            out_path_new = heroes_dir_simple / f"{char_key}.png"
            out_path_legacy = char_dir / "hero.png"
            out_path = out_path_new
            already_exists = out_path_new.exists() or out_path_legacy.exists()
            if not (skip_existing and already_exists):
                jobs.append(GenerationJob(
                    asset_type="character",
                    asset_key=char_key,
                    variant=None,
                    angle="hero",
                    prompt=ref_data,
                    output_path=out_path,
                    aspect_ratio="9:16",
                    display_name=f"{display_name} — Hero",
                ))

    return jobs


# ── LoRA Prep Diversity System ──────────────────────────────────────────

LORA_PREP_ANGLES = [
    ("front", "Front-facing view, eye level, subject looking directly at camera"),
    ("three_quarter_left", "Three-quarter view from the left, subject's right shoulder toward camera"),
    ("three_quarter_right", "Three-quarter view from the right, subject's left shoulder toward camera"),
    ("profile_left", "Left profile, 90-degree side view, left shoulder facing camera"),
    ("profile_right", "Right profile, 90-degree side view, right shoulder facing camera"),
    ("over_shoulder", "Camera positioned behind and to the right of the subject, looking past the near shoulder at a three-quarter view of the face, shoulder and upper back visible in foreground as framing device"),
    ("back", "Rear view, subject facing directly away from camera, back of head and full body visible, face NOT visible"),
    ("low_angle", "Low angle looking up at the subject, camera below eye level, full body visible from feet to head"),
    ("high_angle", "High angle looking down at the subject, camera above eye level"),
    ("closeup_face", "Tight close-up of the face from mid-chest up, face fills the frame"),
    ("upper_body", "Upper body framing from waist up, showing torso and face"),
    ("full_body", "Full body photograph from head to feet, subject standing, entire figure visible with space above and below"),
]

# 5 emotion families × 3 intensities + neutral baseline = 16 expressions
# Moderate expressions work best for identity learning. Strong is OK if the
# mouth isn't gaping open. Extreme theatrical distortion (howling, screaming)
# warps facial geometry and confuses identity learning. Cap at "strong."
LORA_PREP_EXPRESSIONS = [
    # Baseline
    ("neutral", "neutral relaxed expression, jaw unclenched, mouth closed naturally, eyes open and steady"),

    # Anger family (mild → strong)
    ("annoyed", "mildly annoyed expression, slight tension in jaw, lips pressed together, eyes flat"),
    ("angry", "angry expression, brow lowered and compressed, jaw clenched, nostrils flared, hard glare"),
    ("furious", "furious expression, deep brow furrow, teeth visible through snarl, veins in temple, intense glare"),

    # Sadness family (mild → strong)
    ("melancholy", "melancholy expression, soft downturned eyes, slight frown, subdued and withdrawn"),
    ("sad", "sad expression, downturned mouth corners, unfocused wet eyes, visible weight in face"),
    ("grief", "grief-stricken expression, face crumpling, eyes red and brimming, mouth trembling"),

    # Fear family (mild → strong)
    ("uneasy", "uneasy expression, slight tension around eyes, swallowing, gaze shifting"),
    ("afraid", "fearful expression, wide eyes, raised eyebrows, slightly open mouth, neck tense"),
    ("terrified", "terrified expression, whites of eyes showing, pupils dilated, face drained of color"),

    # Exhaustion family (mild → strong)
    ("tired", "tired expression, heavy eyelids, slight slouch in face, unfocused gaze"),
    ("exhausted", "exhausted expression, hollow stare, dark under eyes, slack jaw, drained"),
    ("destroyed", "utterly spent expression, barely conscious, face slack, thousand-yard stare"),

    # Resolve family (mild → strong)
    ("thoughtful", "thoughtful assessing expression, eyes narrowed slightly, head tilted, weighing options"),
    ("determined", "determined expression, set jaw, steady focused gaze, lips pressed firm"),
    ("steeled", "steeled expression, cold fire in eyes, every muscle locked, absolute zero calm"),
]

# Keystone selection: for each target angle, which keystone angles are most useful?
# First match = best fit. "front" is always included as the identity anchor.
# Gemini 2.5 Flash: max 3 input images (hard API limit).
# Gemini 3 Pro: up to 5 human reference images.
# We send 2-3 keystones per generation, selecting the most relevant for the target angle.
_KEYSTONE_ANGLE_RELEVANCE = {
    "front":               ["front"],
    "three_quarter_left":  ["three_quarter_left", "three_quarter", "profile_left"],
    "three_quarter_right": ["three_quarter_right", "three_quarter", "profile_right"],
    "profile_left":        ["profile_left", "profile", "three_quarter_left"],
    "profile_right":       ["profile_right", "profile", "three_quarter_right"],
    "over_shoulder":       ["back", "three_quarter_right", "three_quarter"],
    "back":                ["back", "over_shoulder"],
    "low_angle":           ["full_body", "front"],
    "high_angle":          ["front", "close_up", "closeup"],
    "closeup_face":        ["close_up", "closeup", "front"],
    "upper_body":          ["front", "three_quarter"],
    "full_body":           ["full_body", "front"],
}

# Max keystones to send per generation call
_MAX_KEYSTONES_PER_CALL = 3


def _select_keystones_for_angle(
    keystone_paths: List[Path],
    target_angle: str,
) -> List[Path]:
    """Select the 2-3 most relevant keystones for a target generation angle.

    Strategy:
    1. Always include the front keystone (primary identity anchor)
    2. Add the closest-angle match for the target
    3. Add one more for depth if available (a different angle for 3D coverage)
    Cap at _MAX_KEYSTONES_PER_CALL.
    """
    if not keystone_paths:
        return []

    # Build a lookup: lowercase stem → path
    by_stem = {p.stem.lower(): p for p in keystone_paths}

    selected = []
    used_stems = set()

    # Step 1: Find front keystone (always first)
    front_path = None
    for stem, path in by_stem.items():
        if "front" in stem and "quarter" not in stem:
            front_path = path
            break
    if front_path:
        selected.append(front_path)
        used_stems.add(front_path.stem.lower())

    # Step 2: Find closest-angle match for target
    relevance = _KEYSTONE_ANGLE_RELEVANCE.get(target_angle, [])
    for angle_keyword in relevance:
        if len(selected) >= _MAX_KEYSTONES_PER_CALL:
            break
        for stem, path in by_stem.items():
            if angle_keyword in stem and stem not in used_stems:
                selected.append(path)
                used_stems.add(stem)
                break

    # Step 3: If we have room, add one more for 3D coverage
    # Prefer a profile or side view if we don't have one yet
    if len(selected) < _MAX_KEYSTONES_PER_CALL:
        side_keywords = ["profile", "three_quarter", "3quarter", "side"]
        for kw in side_keywords:
            if len(selected) >= _MAX_KEYSTONES_PER_CALL:
                break
            for stem, path in by_stem.items():
                if kw in stem and stem not in used_stems:
                    selected.append(path)
                    used_stems.add(stem)
                    break

    # If still under cap, add any remaining (better to have some ref than none)
    if len(selected) < min(2, len(keystone_paths)):
        for path in keystone_paths:
            if path.stem.lower() not in used_stems and len(selected) < _MAX_KEYSTONES_PER_CALL:
                selected.append(path)
                used_stems.add(path.stem.lower())

    return selected[:_MAX_KEYSTONES_PER_CALL]


LORA_PREP_LIGHTING = [
    ("soft_studio", "Soft diffused studio lighting, even illumination"),
    ("dramatic_side", "Dramatic side lighting, strong shadows on one side of the face"),
    ("harsh_topdown", "Harsh top-down lighting, deep eye socket shadows"),
    ("rim_backlit", "Rim lighting from behind, backlit silhouette edges glowing"),
    ("warm_ambient", "Warm amber ambient lighting, golden hour tone"),
    ("cool_clinical", "Cool clinical lighting, blue-white fluorescent tone"),
]


def _get_character_locations(breakdown: dict, character_key: str) -> List[dict]:
    """Cross-reference character episodes with location episodes to find actual locations."""
    characters = breakdown.get("characters", {})
    char_data = characters.get(character_key.upper(), {})
    char_episodes = set(char_data.get("episodes", []))

    locations = []
    for loc_key, loc_data in breakdown.get("locations", {}).items():
        loc_episodes = set(loc_data.get("episodes", []))
        if char_episodes & loc_episodes:
            locations.append({
                "key": loc_key,
                "zone": loc_data.get("habitat_zone", "UNKNOWN"),
                "descriptions": loc_data.get("description_samples", []),
                "lighting": loc_data.get("lighting_notes", []),
            })

    # If too few locations found, supplement with other locations from character's zones
    if len(locations) < 5:
        char_zones = {loc["zone"] for loc in locations}
        if not char_zones:
            # Guess zones from character data
            char_zones = {"MID_SHIP", "LOWER_DECKS"}
        for loc_key, loc_data in breakdown.get("locations", {}).items():
            zone = loc_data.get("habitat_zone", "")
            if zone in char_zones and not any(l["key"] == loc_key for l in locations):
                locations.append({
                    "key": loc_key,
                    "zone": zone,
                    "descriptions": loc_data.get("description_samples", []),
                    "lighting": loc_data.get("lighting_notes", []),
                })
            if len(locations) >= 10:
                break

    return locations


def _build_location_env_desc(loc: dict) -> str:
    """Build an environment description from a location's data."""
    descs = loc.get("descriptions", [])
    if descs:
        # Use the first description sample as the environment
        return descs[0] if isinstance(descs[0], str) else str(descs[0])
    # Fallback: derive from the key name
    key = loc.get("key", "industrial corridor")
    # Convert "INT. LEVIATHAN - LOWER DECK SALVAGE CORRIDOR" to readable form
    clean = re.sub(r'^(INT|EXT)\.\s*\w+\s*-\s*', '', key, flags=re.IGNORECASE)
    clean = clean.replace("_", " ").title()
    return f"Inside a {clean.lower()}, worn metal walls, industrial atmosphere"


def build_lora_prep_jobs(
    breakdown: dict,
    project_path: Path,
    character_key: str,
    num_candidates: int,
    target_model: str = "z_image",
    project_config: Optional[dict] = None,
) -> List[GenerationJob]:
    """Build N diverse candidate image jobs for LoRA training data.

    Generates a Cartesian product across angles, environments, expressions,
    and lighting, then samples N from the grid with diversity guarantees.
    """
    import random

    characters = breakdown.get("characters", {})
    char_data = characters.get(character_key.upper())
    if not char_data:
        print(f"ERROR: Character '{character_key}' not found in breakdown.json", file=sys.stderr)
        return []

    # Extract character identity
    prompts = char_data.get("prompts", {})
    ref_data = prompts.get("reference")
    if isinstance(ref_data, dict):
        hero_prompt = ref_data.get("hero", "")
        character_identity = _extract_character_identity(hero_prompt) if hero_prompt else char_data.get("display_name", character_key)
    else:
        character_identity = char_data.get("display_name", character_key)

    # Get physical description (fallback if no wardrobe states)
    visual_desc = char_data.get("visual_description", "")

    # Get wardrobe states from breakdown.json
    wardrobe_data = char_data.get("wardrobe", {})
    wardrobe_states = []
    primary_wardrobe = None
    if wardrobe_data:
        for w_key, w_data in wardrobe_data.items():
            desc = w_data.get("visual_description", w_data.get("description", ""))
            episodes = w_data.get("episodes", [])
            wardrobe_states.append({
                "key": w_key,
                "description": desc,
                "episodes": episodes,
                "episode_count": len(episodes),
            })
        # Sort by episode count descending — most episodes = primary wardrobe
        wardrobe_states.sort(key=lambda w: w["episode_count"], reverse=True)
        primary_wardrobe = wardrobe_states[0]["key"] if wardrobe_states else None

    if not wardrobe_states:
        # Fallback: single "default" state using visual_description
        wardrobe_states = [{"key": "default", "description": visual_desc, "episodes": [], "episode_count": 1}]
        primary_wardrobe = "default"

    print(f"  Wardrobe states: {len(wardrobe_states)} ({', '.join(w['key'] for w in wardrobe_states)})")
    print(f"  Primary wardrobe: {primary_wardrobe}")

    # Get character-specific locations from breakdown
    locations = _get_character_locations(breakdown, character_key)
    if not locations:
        # Absolute fallback
        locations = [{"key": "GENERIC_INTERIOR", "zone": "MID_SHIP",
                      "descriptions": ["Dark industrial corridor with worn metal walls"],
                      "lighting": []}]

    # Build weighted wardrobe list — primary wardrobe gets ~50% of slots,
    # remaining states split the other ~50% evenly.
    # We achieve this by repeating the primary wardrobe state.
    weighted_wardrobe = []
    if len(wardrobe_states) == 1:
        weighted_wardrobe = wardrobe_states
    else:
        non_primary = [w for w in wardrobe_states if w["key"] != primary_wardrobe]
        primary_state = next(w for w in wardrobe_states if w["key"] == primary_wardrobe)
        # Repeat primary N times where N = number of non-primary states
        # This gives ~50% primary, ~50% split among others
        for _ in range(len(non_primary)):
            weighted_wardrobe.append(primary_state)
        weighted_wardrobe.extend(non_primary)

    # Build the full grid (5 dimensions: angle x location x expression x lighting x wardrobe)
    grid = []
    for angle_name, angle_desc in LORA_PREP_ANGLES:
        for loc in locations:
            for expr_name, expr_desc in LORA_PREP_EXPRESSIONS:
                for light_name, light_desc in LORA_PREP_LIGHTING:
                    for ward in weighted_wardrobe:
                        env_desc = _build_location_env_desc(loc)
                        loc_key_safe = sanitize_path_component(loc["key"])
                        grid.append({
                            "angle": angle_name,
                            "angle_desc": angle_desc,
                            "location": loc["key"],
                            "location_safe": loc_key_safe,
                            "habitat_zone": loc.get("zone", "UNKNOWN"),
                            "env_desc": env_desc,
                            "expression": expr_name,
                            "expr_desc": expr_desc,
                            "lighting": light_name,
                            "light_desc": light_desc,
                            "wardrobe_key": ward["key"],
                            "wardrobe_desc": ward["description"],
                        })

    print(f"  Grid size: {len(grid)} combinations "
          f"({len(LORA_PREP_ANGLES)} angles x {len(locations)} locations x "
          f"{len(LORA_PREP_EXPRESSIONS)} expressions x {len(LORA_PREP_LIGHTING)} lighting x "
          f"{len(weighted_wardrobe)} wardrobe weighted)")

    # ── One-variable-at-a-time selection ──
    # Research shows grouping by angle produces better identity consistency
    # when using reference images. We select candidates angle-by-angle,
    # spreading expressions/lighting/locations within each angle group.
    # This also enables the batch runner to process angle groups together,
    # which aligns with Gemini's recommendation to restart sessions every 5-8 images.
    rng = random.Random(42)

    # Group grid by angle
    by_angle = {}
    for item in grid:
        by_angle.setdefault(item["angle"], []).append(item)
    for angle_items in by_angle.values():
        rng.shuffle(angle_items)

    # Distribute N candidates across angles as evenly as possible
    angle_names = [a[0] for a in LORA_PREP_ANGLES]
    per_angle = max(1, num_candidates // len(angle_names))
    remainder = num_candidates - (per_angle * len(angle_names))

    selected = []
    used_wardrobes = set()

    for i, angle_name in enumerate(angle_names):
        angle_pool = by_angle.get(angle_name, [])
        count = per_angle + (1 if i < remainder else 0)

        # Pick diverse items from this angle's pool
        picked = 0
        used_expr = set()
        # First pass: one of each expression
        for item in angle_pool:
            if picked >= count:
                break
            if item["expression"] not in used_expr:
                selected.append(item)
                used_expr.add(item["expression"])
                used_wardrobes.add(item["wardrobe_key"])
                picked += 1

        # Fill remaining from this angle
        for item in angle_pool:
            if picked >= count:
                break
            if item not in selected:
                selected.append(item)
                used_wardrobes.add(item["wardrobe_key"])
                picked += 1

    # Ensure wardrobe coverage (swap in if any state is missing)
    all_wardrobe_keys = {w["key"] for w in wardrobe_states}
    missing_wardrobes = all_wardrobe_keys - used_wardrobes
    if missing_wardrobes and len(selected) >= num_candidates:
        for missing_w in missing_wardrobes:
            # Find a grid item with this wardrobe, swap it in for the last selected
            for item in grid:
                if item["wardrobe_key"] == missing_w and item not in selected:
                    selected[-1] = item
                    break

    # If grid is smaller than requested
    if len(selected) < num_candidates:
        print(f"  WARNING: Grid only has {len(grid)} combinations, requested {num_candidates}. "
              f"Using {len(selected)}.")

    # Build output directory
    char_upper = character_key.upper()
    candidates_dir = project_path / "visual" / "lora_candidates" / char_upper

    # ── Keystone images (primary identity references for LoRA prep) ──
    # Keystones are manually-generated hero images that define the character's
    # visual identity. All AI candidates are generated using these as references
    # to ensure consistent identity across the training set.
    keystones_dir = candidates_dir / "keystones"
    keystone_paths = []
    if keystones_dir.is_dir():
        for f in sorted(keystones_dir.iterdir()):
            if f.suffix.lower() in (".png", ".jpeg", ".jpg", ".webp"):
                keystone_paths.append(f)

    if keystone_paths:
        print(f"  Keystones: {len(keystone_paths)} found in {keystones_dir}")
        for ks in keystone_paths:
            print(f"    - {ks.name}")
    else:
        print(f"  Keystones: NONE (looked in {keystones_dir})")
        print(f"    WARNING: Without keystones, each candidate will be a different")
        print(f"    interpretation of the text description. Identity will be inconsistent.")
        print(f"    To fix: generate 7-8 keystone images in MidJourney (front, profiles,")
        print(f"    3/4 angles, full body, back — all neutral expression) and drop them")
        print(f"    in {keystones_dir}/")

    # Hero image for identity reference — fallback if no keystones
    refs_dir = project_path / "visual" / "refs" / "characters"
    heroes_dir = refs_dir / "heroes"
    hero_path = None
    if heroes_dir.is_dir():
        # Try exact matches first, then glob for variants
        char_title = char_data.get("display_name", character_key).replace(" ", "_")
        for name_pattern in [f"{char_title}_Hero", f"{char_upper}_Hero", char_upper, char_title]:
            for ext in [".png", ".jpeg", ".jpg", ".webp"]:
                candidate = heroes_dir / f"{name_pattern}{ext}"
                if candidate.exists():
                    hero_path = candidate
                    break
            if hero_path:
                break
        # Last resort: case-insensitive glob — prefer _Hero files
        if not hero_path:
            hero_candidates = []
            for f in heroes_dir.iterdir():
                if f.stem.lower().startswith(char_upper.lower()) and f.suffix.lower() in (".png", ".jpeg", ".jpg", ".webp"):
                    hero_candidates.append(f)
            hero_candidates.sort(key=lambda p: (0 if "hero" in p.stem.lower() else 1, p.name))
            if hero_candidates:
                hero_path = hero_candidates[0]
    # Legacy fallback
    if not hero_path:
        legacy_dir = refs_dir / char_upper
        if legacy_dir.is_dir():
            for ext in [".png", ".jpeg", ".jpg"]:
                candidate = legacy_dir / f"hero{ext}"
                if candidate.exists():
                    hero_path = candidate
                    break
    has_hero = hero_path is not None

    # If keystones exist, they supersede the hero image for identity reference.
    # If no keystones but hero exists, it's used as a single-image fallback.
    if keystone_paths:
        print(f"  Identity source: {len(keystone_paths)} keystone images")
    elif has_hero:
        print(f"  Identity source: single hero image (fallback) — {hero_path.name}")
    else:
        print(f"  Identity source: TEXT ONLY (no visual references)")

    # Load rendering directives for quality suffix
    _rendering = load_rendering_directives(project_path, char_upper)
    _quality_suffix = f"{_rendering['texture_prompt']} 8K, hyperdetailed."

    # Build jobs
    jobs = []
    for idx, item in enumerate(selected):
        ward_key_safe = sanitize_path_component(item["wardrobe_key"])
        filename = f"cand_{idx:03d}_{item['angle']}_{ward_key_safe}_{item['expression']}.png"
        out_path = candidates_dir / filename

        # Build the prompt — use wardrobe-specific description instead of generic visual_desc
        wardrobe_desc = item.get("wardrobe_desc", visual_desc)
        prompt = (
            f"Single photorealistic photograph. {item['angle_desc']}. "
            f"Subject: {character_identity}. {wardrobe_desc}. "
            f"Environment: {item['env_desc']}. "
            f"{item['expr_desc'].capitalize()}. {item['light_desc']}. "
            f"One person only. No text. No split panels. "
            f"{_quality_suffix}"
        )

        jobs.append(GenerationJob(
            asset_type="character",
            asset_key=char_upper,
            variant=item["wardrobe_key"],
            angle=item["angle"],
            prompt=prompt,
            output_path=out_path,
            aspect_ratio="1:1",
            hero_image_path=hero_path if has_hero and not keystone_paths else None,
            keystone_image_paths=keystone_paths,
            is_anchor=False,
            display_name=f"LoRA Candidate {idx:03d} — {item['angle']}/{ward_key_safe}/{item['expression']}",
            is_lora_candidate=True,
        ))

    return jobs, selected  # Return both jobs and metadata for manifest


def _write_lora_manifest(
    project_path: Path,
    character_key: str,
    target_model: str,
    candidates_metadata: List[dict],
    engine_name: str,
) -> Path:
    """Write manifest.json for LoRA prep candidates."""
    from datetime import datetime, timezone

    char_upper = character_key.upper()
    candidates_dir = project_path / "visual" / "lora_candidates" / char_upper
    candidates_dir.mkdir(parents=True, exist_ok=True)

    manifest = {
        "version": 1,
        "character": char_upper,
        "target_model": target_model,
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "total_candidates": len(candidates_metadata),
        "engine": engine_name,
        "candidates": [],
    }

    for idx, item in enumerate(candidates_metadata):
        ward_key_safe = sanitize_path_component(item.get("wardrobe_key", "default"))
        filename = f"cand_{idx:03d}_{item['angle']}_{ward_key_safe}_{item['expression']}.png"
        manifest["candidates"].append({
            "filename": filename,
            "index": idx,
            "angle": item["angle"],
            "wardrobe": item.get("wardrobe_key", "default"),
            "location": item.get("location", ""),
            "habitat_zone": item.get("habitat_zone", "UNKNOWN"),
            "expression": item["expression"],
            "lighting": item["lighting"],
            "status": "pending",
        })

    manifest_path = candidates_dir / "manifest.json"
    manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
    print(f"  Manifest written: {manifest_path}")
    return manifest_path


def build_location_jobs(
    breakdown: dict,
    project_path: Path,
    skip_existing: bool = False,
) -> List[GenerationJob]:
    """Build generation jobs for locations."""
    jobs = []
    refs_dir = project_path / "visual" / "refs" / "locations"

    for loc_key, loc_data in breakdown.get("locations", {}).items():
        prompts = loc_data.get("prompts", {})
        ref_prompt = prompts.get("reference")

        if not ref_prompt or not isinstance(ref_prompt, str):
            continue

        sanitized = sanitize_path_component(loc_key)
        out_path = refs_dir / sanitized / "wide_establishing.png"

        if skip_existing and out_path.exists():
            continue

        jobs.append(GenerationJob(
            asset_type="location",
            asset_key=loc_key,
            variant=None,
            angle="wide_establishing",
            prompt=ref_prompt,
            output_path=out_path,
            aspect_ratio="16:9",
            display_name=loc_key,
        ))

    return jobs


def build_prop_jobs(
    breakdown: dict,
    project_path: Path,
    skip_existing: bool = False,
) -> List[GenerationJob]:
    """Build generation jobs for props."""
    jobs = []
    refs_dir = project_path / "visual" / "refs" / "props"

    for prop_key, prop_data in breakdown.get("props", {}).items():
        prompts = prop_data.get("prompts", {})
        ref_prompt = prompts.get("reference")

        if not ref_prompt or not isinstance(ref_prompt, str):
            continue

        # Only generate for high-confidence props with reasonable episode count
        confidence = prop_data.get("confidence", "low")
        ep_count = prop_data.get("episode_count", 0)
        if confidence == "low" or ep_count < 3:
            continue

        out_path = refs_dir / prop_key.upper() / "default.png"

        if skip_existing and out_path.exists():
            continue

        jobs.append(GenerationJob(
            asset_type="prop",
            asset_key=prop_key,
            variant=None,
            angle="default",
            prompt=ref_prompt,
            output_path=out_path,
            aspect_ratio="1:1",
            display_name=prop_data.get("display_name", prop_key),
        ))

    return jobs


# ── Engine: Gemini ───────────────────────────────────────────────────────

class GeminiEngine:
    """Generate images using Google Gemini API."""

    def __init__(self, model: str = get_model("exploration_fallback", "image"), tracker: Optional[CostTracker] = None):
        api_key = os.environ.get("GOOGLE_API_KEY")
        if not api_key:
            print("ERROR: GOOGLE_API_KEY environment variable not set.", file=sys.stderr)
            print("Get a key at: https://aistudio.google.com/apikey", file=sys.stderr)
            sys.exit(1)

        try:
            from google import genai
            from google.genai import types
            self.genai = genai
            self.types = types
        except ImportError:
            print("ERROR: google-genai not installed. Run: pip install google-genai", file=sys.stderr)
            sys.exit(1)

        self.client = genai.Client(api_key=api_key)
        self.model = model
        self.tracker = tracker
        self.rate_limit_delay = 4.5  # seconds between requests (15 RPM for Flash)

    def generate(self, job: GenerationJob) -> GenerationResult:
        """Generate a single image."""
        t0 = time.time()
        try:
            contents = self._build_contents(job)

            # Map job aspect ratio to Gemini-supported values
            gemini_aspect = {
                "9:16": "9:16",
                "16:9": "16:9",
                "1:1": "1:1",
            }.get(job.aspect_ratio, "1:1")

            response = self.client.models.generate_content(
                model=self.model,
                contents=contents,
                config=self.types.GenerateContentConfig(
                    response_modalities=["IMAGE", "TEXT"],
                    image_config=self.types.ImageConfig(
                        aspect_ratio=gemini_aspect,
                    ),
                ),
            )

            # Extract token usage from response
            tokens_in = 0
            tokens_out = 0
            if hasattr(response, 'usage_metadata') and response.usage_metadata:
                tokens_in = getattr(response.usage_metadata, 'prompt_token_count', 0) or 0
                tokens_out = getattr(response.usage_metadata, 'candidates_token_count', 0) or 0

            elapsed_ms = int((time.time() - t0) * 1000)

            # Extract image from response
            if response.candidates:
                for part in response.candidates[0].content.parts:
                    if part.inline_data and part.inline_data.mime_type.startswith("image/"):
                        image_data = part.inline_data.data
                        job.output_path.parent.mkdir(parents=True, exist_ok=True)
                        job.output_path.write_bytes(image_data)

                        # Log successful generation
                        if self.tracker:
                            detail = f"{job.asset_type} ref: {job.asset_key}"
                            if job.variant:
                                detail += f"/{job.variant}"
                            if job.angle:
                                detail += f"/{job.angle}"
                            self.tracker.log(
                                category="reference",
                                provider="gemini",
                                model=self.model,
                                images_out=1,
                                tokens_in=tokens_in,
                                tokens_out=tokens_out,
                                detail=detail,
                                success=True,
                                duration_ms=elapsed_ms,
                            )

                        return GenerationResult(
                            job=job,
                            success=True,
                            output_path=job.output_path,
                        )

            # No image in response
            elapsed_ms = int((time.time() - t0) * 1000)
            if self.tracker:
                detail = f"{job.asset_type} ref: {job.asset_key}"
                if job.variant:
                    detail += f"/{job.variant}"
                if job.angle:
                    detail += f"/{job.angle}"
                self.tracker.log(
                    category="reference",
                    provider="gemini",
                    model=self.model,
                    images_out=0,
                    tokens_in=tokens_in,
                    tokens_out=tokens_out,
                    detail=f"{detail} — no image in response",
                    success=False,
                    duration_ms=elapsed_ms,
                )

            return GenerationResult(
                job=job,
                success=False,
                error="No image in response",
            )

        except Exception as e:
            elapsed_ms = int((time.time() - t0) * 1000)
            if self.tracker:
                detail = f"{job.asset_type} ref: {job.asset_key}"
                if job.variant:
                    detail += f"/{job.variant}"
                if job.angle:
                    detail += f"/{job.angle}"
                self.tracker.log(
                    category="reference",
                    provider="gemini",
                    model=self.model,
                    images_out=0,
                    tokens_in=0,
                    tokens_out=0,
                    detail=f"{detail} — {str(e)[:100]}",
                    success=False,
                    duration_ms=elapsed_ms,
                )

            return GenerationResult(
                job=job,
                success=False,
                error=str(e),
            )

    def _build_contents(self, job: GenerationJob) -> list:
        """Build the content list for Gemini API call.

        Four modes (checked in order):
        0. Keystones (multi-image identity): all keystone images as identity anchors (LoRA prep)
        1. Hero + anchor (dual reference): identity from hero, wardrobe from front angle
        2. Hero only: identity reference for front/anchor shots
        3. Text only: fallback when no reference images available
        """
        parts = []

        # ── Mode 0: Keystone-conditioned generation (LoRA prep) ──
        has_keystones = bool(job.keystone_image_paths)
        if has_keystones:
            try:
                # Smart selection: pick 2-3 most relevant keystones for this angle
                selected_ks = _select_keystones_for_angle(
                    [p for p in job.keystone_image_paths if p.exists()],
                    job.angle or "front",
                )
                if selected_ks:
                    for ks_path in selected_ks:
                        ks_bytes = ks_path.read_bytes()
                        mime = "image/jpeg" if ks_path.suffix.lower() in (".jpg", ".jpeg") else "image/png"
                        parts.append(self.types.Part(
                            inline_data=self.types.Blob(mime_type=mime, data=ks_bytes)
                        ))

                    n = len(selected_ks)
                    is_closeup_angle = job.angle in ("closeup_face", "close_up")
                    if is_closeup_angle:
                        identity_instruction = (
                            f"The above {n} image{'s' if n > 1 else ''} show{'s' if n == 1 else ''} "
                            "this exact person. This is ONE person.\n\n"
                            "Generate a TIGHT HEADSHOT of this exact person. "
                            "Frame from mid-chest to top of head — face fills most of the image. "
                            "Maintain identical skull structure, brow ridge, nose bridge, nose width, "
                            "cheekbone position, chin shape, ear shape, eye spacing, eye size, iris color, "
                            "skin tone, skin texture, hair color, and hair texture. "
                            "Expression muscles may move freely — brows, eyelids, nostrils, lips, jaw — "
                            "these are temporary muscular movements, not identity changes. "
                            "Do NOT show full body, legs, waist, or hands.\n\n"
                            f"{job.prompt}"
                        )
                    else:
                        identity_instruction = (
                            f"The above {n} image{'s' if n > 1 else ''} show{'s' if n == 1 else ''} "
                            "this exact person. This is ONE person.\n\n"
                            "Generate a new photograph of this exact same person. "
                            "Maintain identical skull structure, brow ridge, nose bridge, nose width, "
                            "cheekbone position, chin shape, ear shape, eye spacing, eye size, iris color, "
                            "skin tone, skin texture, hair color, and hair texture. "
                            "Expression muscles may move freely — brows, eyelids, nostrils, lips, jaw — "
                            "these are temporary muscular movements, not identity changes. "
                            "Change ONLY the camera angle, expression, lighting, and environment "
                            "as specified below.\n\n"
                            f"{job.prompt}"
                        )

                    parts.append(self.types.Part(text=identity_instruction))
                    return [self.types.Content(parts=parts)]
            except Exception:
                pass  # Fall through to other modes

        has_hero = (
            job.hero_image_path
            and job.hero_image_path.exists()
            and job.angle != "hero"
        )
        has_anchor = (
            job.anchor_image_path
            and job.anchor_image_path.exists()
        )

        is_closeup = (job.angle == "close_up")

        try:
            if has_hero and has_anchor:
                # Dual reference: hero (identity) + front (variant ground truth)
                hero_bytes = job.hero_image_path.read_bytes()
                anchor_bytes = job.anchor_image_path.read_bytes()
                parts.append(self.types.Part(
                    inline_data=self.types.Blob(mime_type="image/png", data=hero_bytes)
                ))
                parts.append(self.types.Part(
                    inline_data=self.types.Blob(mime_type="image/png", data=anchor_bytes)
                ))
                if is_closeup:
                    parts.append(self.types.Part(
                        text=(
                            "Image 1 is the identity reference. Image 2 shows the current styling/condition. "
                            "Generate a TIGHT HEADSHOT of this person's FACE ONLY. "
                            "Frame from mid-chest to top of head — the face fills most of the image. "
                            "Same face, same hair, same skin condition as the references. "
                            "Do NOT show full body, legs, waist, or hands.\n\n"
                            f"{job.prompt}"
                        ),
                    ))
                else:
                    parts.append(self.types.Part(
                        text=(
                            "Image 1 is the identity reference (face, build, skin). "
                            "Image 2 is the wardrobe/styling reference (exact clothing, colors, materials, hair, makeup). "
                            "Generate a new angle of this exact same person wearing this exact same outfit. "
                            "Do not change any detail — same face, same wardrobe, same colors, same materials, same hair. "
                            "Only the camera angle changes.\n\n"
                            f"{job.prompt}"
                        ),
                    ))
            elif has_hero:
                # Hero-only reference (for front/anchor shots)
                hero_bytes = job.hero_image_path.read_bytes()
                parts.append(self.types.Part(
                    inline_data=self.types.Blob(mime_type="image/png", data=hero_bytes)
                ))

                # LoRA candidates and hybrid pipeline: NO white background.
                # The job.prompt already includes environment descriptions.
                # Standard refs: white background for clean reference sheets.
                is_lora = getattr(job, 'is_lora_candidate', False)

                if is_closeup:
                    bg_instruction = "" if is_lora else "Pure white background. "
                    parts.append(self.types.Part(
                        text=(
                            "Using the person in the above image as the identity reference. "
                            "Generate a TIGHT HEADSHOT of this person's FACE ONLY. "
                            "Frame from mid-chest to top of head — the face fills most of the image. "
                            f"Same face, same hair, same skin. {bg_instruction}"
                            "Do NOT show full body, legs, waist, or hands.\n\n"
                            f"{job.prompt}"
                        ),
                    ))
                else:
                    if is_lora:
                        instruction = (
                            "Using the person in the above image as the identity reference — "
                            "maintain their exact face, skin, hair, and build. "
                            "Generate a single photorealistic photograph as described below:\n\n"
                            f"{job.prompt}"
                        )
                    else:
                        instruction = (
                            "Using the person in the above image as the identity reference — "
                            "maintain their exact face, skin, hair, and build. "
                            "Generate a single photorealistic photograph on a solid white background:\n\n"
                            f"{job.prompt}"
                        )
                    parts.append(self.types.Part(text=instruction))
            else:
                parts.append(self.types.Part(
                    text=f"Generate a single photorealistic photograph:\n\n{job.prompt}"
                ))
        except Exception:
            parts.append(self.types.Part(
                text=f"Generate the following image:\n\n{job.prompt}"
            ))

        return [self.types.Content(parts=parts)]


# ── Engine: fal.ai ───────────────────────────────────────────────────────

class FalEngine:
    """Generate images using fal.ai API (Flux models)."""

    def __init__(self, model: str = "fal-ai/flux-pro/v1.1-ultra"):
        fal_key = os.environ.get("FAL_KEY")
        if not fal_key:
            print("ERROR: FAL_KEY environment variable not set.", file=sys.stderr)
            sys.exit(1)

        try:
            import fal_client
            self.fal_client = fal_client
        except ImportError:
            print("ERROR: fal-client not installed. Run: pip install fal-client", file=sys.stderr)
            sys.exit(1)

        self.model = model
        self.rate_limit_delay = 2.0

    def generate(self, job: GenerationJob) -> GenerationResult:
        """Generate a single image."""
        try:
            # Map aspect ratios to pixel dimensions
            dimensions = {
                "9:16": {"width": 768, "height": 1376},
                "16:9": {"width": 1376, "height": 768},
                "1:1": {"width": 1024, "height": 1024},
            }
            dims = dimensions.get(job.aspect_ratio, {"width": 1024, "height": 1024})

            arguments = {
                "prompt": job.prompt,
                "image_size": dims,
                "num_images": 1,
                "enable_safety_checker": False,
            }

            # Include hero image as reference if available
            if job.hero_image_path and job.hero_image_path.exists() and job.angle != "hero":
                import base64
                hero_bytes = job.hero_image_path.read_bytes()
                hero_b64 = base64.b64encode(hero_bytes).decode()
                arguments["image_url"] = f"data:image/png;base64,{hero_b64}"

            result = self.fal_client.subscribe(
                self.model,
                arguments=arguments,
            )

            if result and "images" in result and len(result["images"]) > 0:
                image_url = result["images"][0]["url"]

                # Download the image
                import urllib.request
                job.output_path.parent.mkdir(parents=True, exist_ok=True)
                urllib.request.urlretrieve(image_url, str(job.output_path))

                return GenerationResult(
                    job=job,
                    success=True,
                    output_path=job.output_path,
                )

            return GenerationResult(
                job=job,
                success=False,
                error="No image in fal response",
            )

        except Exception as e:
            return GenerationResult(
                job=job,
                success=False,
                error=str(e),
            )


# ── Engine: Qwen Multi-Angle (fal.ai) ────────────────────────────────────

# Mapping from angle names to numeric API parameters
# horizontal_angle: 0=front, 45=3/4 right, 90=profile right, 180=back, 270=profile left, 315=3/4 left
# vertical_angle: -30=low angle, 0=eye level, 30=elevated
# zoom: 0=wide (full body), 5=medium, 7=upper body, 10=close-up
QWEN_ANGLE_MAP = {
    "front":               {"h": 0,   "v": 0,   "z": 5},
    "three_quarter_right": {"h": 45,  "v": 0,   "z": 5},
    "profile_right":       {"h": 90,  "v": 0,   "z": 5},
    "back_right":          {"h": 135, "v": 0,   "z": 5},
    "back":                {"h": 180, "v": 0,   "z": 5},
    "back_left":           {"h": 225, "v": 0,   "z": 5},
    "profile_left":        {"h": 270, "v": 0,   "z": 5},
    "three_quarter_left":  {"h": 315, "v": 0,   "z": 5},
    "low_angle":           {"h": 0,   "v": -30, "z": 5},
    "high_angle":          {"h": 0,   "v": 30,  "z": 0},
    "closeup_front":       {"h": 0,   "v": 0,   "z": 10},
    "closeup_three_quarter": {"h": 45, "v": 0,  "z": 10},
    # Aliases from LORA_PREP_ANGLES
    "over_shoulder":       {"h": 200, "v": 0,   "z": 5},
    "closeup_face":        {"h": 0,   "v": 0,   "z": 10},
    "upper_body":          {"h": 0,   "v": 0,   "z": 7},
    "full_body":           {"h": 0,   "v": 0,   "z": 0},
}

# Default 12-angle set for hybrid Qwen pass
QWEN_HYBRID_ANGLES = [
    {"name": "front",               "h": 0,   "v": 0,   "z": 5,  "desc": "Front, eye level, medium"},
    {"name": "three_quarter_right",  "h": 45,  "v": 0,   "z": 5,  "desc": "3/4 right, eye level, medium"},
    {"name": "profile_right",        "h": 90,  "v": 0,   "z": 5,  "desc": "Profile right, eye level"},
    {"name": "back_right",           "h": 135, "v": 0,   "z": 5,  "desc": "Back-right quarter"},
    {"name": "back",                 "h": 180, "v": 0,   "z": 5,  "desc": "Back view, eye level"},
    {"name": "back_left",            "h": 225, "v": 0,   "z": 5,  "desc": "Back-left quarter"},
    {"name": "profile_left",         "h": 270, "v": 0,   "z": 5,  "desc": "Profile left, eye level"},
    {"name": "three_quarter_left",   "h": 315, "v": 0,   "z": 5,  "desc": "3/4 left, eye level, medium"},
    {"name": "low_angle",            "h": 0,   "v": -30, "z": 5,  "desc": "Low angle looking up"},
    {"name": "high_angle",           "h": 0,   "v": 30,  "z": 0,  "desc": "High angle, full body"},
    {"name": "closeup_front",        "h": 0,   "v": 0,   "z": 10, "desc": "Close-up, front"},
    {"name": "closeup_three_quarter","h": 45,  "v": 0,   "z": 10, "desc": "Close-up, 3/4 right"},
]


class QwenMultiAngleEngine:
    """Generate multi-angle images using Qwen Image Edit + Multi-Angle LoRA via fal.ai."""

    ENDPOINT = "fal-ai/qwen-image-edit-2511-multiple-angles"

    def __init__(self, tracker: Optional[CostTracker] = None):
        fal_key = os.environ.get("FAL_KEY")
        if not fal_key:
            print("ERROR: FAL_KEY environment variable not set.", file=sys.stderr)
            sys.exit(1)

        try:
            import fal_client
            self.fal_client = fal_client
        except ImportError:
            print("ERROR: fal-client not installed. Run: pip install fal-client", file=sys.stderr)
            sys.exit(1)

        self.tracker = tracker
        self.rate_limit_delay = 2.0
        self._uploaded_urls: Dict[str, str] = {}  # path → fal URL cache

    def _upload_image(self, image_path: Path) -> str:
        """Upload image to fal.ai storage, caching by path."""
        key = str(image_path)
        if key in self._uploaded_urls:
            return self._uploaded_urls[key]
        url = self.fal_client.upload_file(key)
        self._uploaded_urls[key] = url
        return url

    def generate(self, job: GenerationJob) -> GenerationResult:
        """Generate a single multi-angle image."""
        t0 = time.time()
        try:
            # Determine the source image — use first keystone or hero
            source_path = None
            if job.keystone_image_paths:
                # Use the front keystone if available, else first keystone
                for ks in job.keystone_image_paths:
                    if ks.exists() and "front" in ks.stem.lower():
                        source_path = ks
                        break
                if not source_path:
                    for ks in job.keystone_image_paths:
                        if ks.exists():
                            source_path = ks
                            break
            if not source_path and job.hero_image_path and job.hero_image_path.exists():
                source_path = job.hero_image_path

            if not source_path:
                return GenerationResult(
                    job=job, success=False,
                    error="No source image (keystone or hero) available for Qwen",
                )

            image_url = self._upload_image(source_path)

            # Map angle name to numeric params
            angle_params = QWEN_ANGLE_MAP.get(job.angle, {"h": 0, "v": 0, "z": 5})

            result = self.fal_client.subscribe(
                self.ENDPOINT,
                arguments={
                    "image_urls": [image_url],
                    "horizontal_angle": angle_params["h"],
                    "vertical_angle": angle_params["v"],
                    "zoom": angle_params["z"],
                    "lora_scale": 0.9,
                    "image_size": "square_hd",
                    "num_inference_steps": 28,
                    "guidance_scale": 4.5,
                    "num_images": 1,
                    "enable_safety_checker": False,
                },
                with_logs=False,
            )

            elapsed_ms = int((time.time() - t0) * 1000)

            if result and "images" in result and len(result["images"]) > 0:
                image_url_out = result["images"][0]["url"]

                # Download the image
                import urllib.request
                job.output_path.parent.mkdir(parents=True, exist_ok=True)
                urllib.request.urlretrieve(image_url_out, str(job.output_path))

                if self.tracker:
                    detail = f"qwen angle: {job.asset_key}/{job.angle} h={angle_params['h']} v={angle_params['v']} z={angle_params['z']}"
                    self.tracker.log(
                        category="reference",
                        provider="fal",
                        model=self.ENDPOINT,
                        images_out=1,
                        tokens_in=0,
                        tokens_out=0,
                        detail=detail,
                        success=True,
                        duration_ms=elapsed_ms,
                    )

                return GenerationResult(
                    job=job, success=True, output_path=job.output_path,
                )

            elapsed_ms = int((time.time() - t0) * 1000)
            if self.tracker:
                self.tracker.log(
                    category="reference", provider="fal", model=self.ENDPOINT,
                    images_out=0, tokens_in=0, tokens_out=0,
                    detail=f"qwen angle: {job.asset_key}/{job.angle} — no image",
                    success=False, duration_ms=elapsed_ms,
                )
            return GenerationResult(job=job, success=False, error="No image in Qwen response")

        except Exception as e:
            elapsed_ms = int((time.time() - t0) * 1000)
            if self.tracker:
                self.tracker.log(
                    category="reference", provider="fal", model=self.ENDPOINT,
                    images_out=0, tokens_in=0, tokens_out=0,
                    detail=f"qwen angle: {job.asset_key}/{job.angle} — {str(e)[:100]}",
                    success=False, duration_ms=elapsed_ms,
                )
            return GenerationResult(job=job, success=False, error=str(e))


# ── Hybrid Pipeline Job Builders ─────────────────────────────────────────

# Gemini-safe angles for hybrid mode (angles Gemini can actually produce)
GEMINI_SAFE_ANGLES = [
    "front", "three_quarter_left", "three_quarter_right",
    "profile_left", "profile_right", "closeup_face",
]


def build_hybrid_qwen_jobs(
    project_path: Path,
    character_key: str,
    keystone_paths: List[Path],
    hero_path: Optional[Path],
) -> Tuple[List[GenerationJob], List[dict]]:
    """Build Qwen angle-coverage jobs for hybrid pipeline.

    Returns (jobs, metadata) for 12 angles from the hero/keystone image.
    """
    char_upper = character_key.upper()
    candidates_dir = project_path / "visual" / "lora_candidates" / char_upper
    jobs = []
    metadata = []

    for idx, angle in enumerate(QWEN_HYBRID_ANGLES):
        filename = f"qwen_{idx:03d}_{angle['name']}.png"
        out_path = candidates_dir / filename

        jobs.append(GenerationJob(
            asset_type="character",
            asset_key=char_upper,
            variant=None,
            angle=angle["name"],
            prompt=angle["desc"],  # Not used by Qwen engine (uses numeric params)
            output_path=out_path,
            aspect_ratio="1:1",
            hero_image_path=hero_path,
            keystone_image_paths=keystone_paths,
            is_anchor=False,
            display_name=f"Qwen {idx:03d} — {angle['desc']}",
            is_lora_candidate=True,
        ))

        metadata.append({
            "filename": filename,
            "engine": "qwen",
            "angle": angle["name"],
            "h": angle["h"],
            "v": angle["v"],
            "z": angle["z"],
        })

    return jobs, metadata


def build_hybrid_gemini_jobs(
    breakdown: dict,
    project_path: Path,
    character_key: str,
    num_candidates: int,
    keystone_paths: List[Path],
    hero_path: Optional[Path],
) -> Tuple[List[GenerationJob], List[dict]]:
    """Build Gemini diversity-coverage jobs for hybrid pipeline.

    Uses the same grid logic as build_lora_prep_jobs but restricts angles
    to Gemini-safe angles (no low/high/back). Returns (jobs, metadata).
    """
    import random

    characters = breakdown.get("characters", {})
    char_data = characters.get(character_key.upper())
    if not char_data:
        return [], []

    # Extract character identity
    prompts = char_data.get("prompts", {})
    ref_data = prompts.get("reference")
    if isinstance(ref_data, dict):
        hero_prompt = ref_data.get("hero", "")
        character_identity = _extract_character_identity(hero_prompt) if hero_prompt else char_data.get("display_name", character_key)
    else:
        character_identity = char_data.get("display_name", character_key)

    visual_desc = char_data.get("visual_description", "")

    # Get wardrobe states
    wardrobe_data = char_data.get("wardrobe", {})
    wardrobe_states = []
    primary_wardrobe = None
    if wardrobe_data:
        for w_key, w_data in wardrobe_data.items():
            desc = w_data.get("visual_description", w_data.get("description", ""))
            episodes = w_data.get("episodes", [])
            wardrobe_states.append({
                "key": w_key, "description": desc,
                "episodes": episodes, "episode_count": len(episodes),
            })
        wardrobe_states.sort(key=lambda w: w["episode_count"], reverse=True)
        primary_wardrobe = wardrobe_states[0]["key"] if wardrobe_states else None

    if not wardrobe_states:
        wardrobe_states = [{"key": "default", "description": visual_desc, "episodes": [], "episode_count": 1}]
        primary_wardrobe = "default"

    # Weighted wardrobe distribution
    weighted_wardrobe = []
    if len(wardrobe_states) == 1:
        weighted_wardrobe = wardrobe_states
    else:
        non_primary = [w for w in wardrobe_states if w["key"] != primary_wardrobe]
        primary_state = next(w for w in wardrobe_states if w["key"] == primary_wardrobe)
        for _ in range(len(non_primary)):
            weighted_wardrobe.append(primary_state)
        weighted_wardrobe.extend(non_primary)

    # Get locations
    locations = _get_character_locations(breakdown, character_key)
    if not locations:
        locations = [{"key": "GENERIC_INTERIOR", "zone": "MID_SHIP",
                      "descriptions": ["Dark industrial corridor with worn metal walls"],
                      "lighting": []}]

    # Filter LORA_PREP_ANGLES to Gemini-safe angles only
    gemini_angles = [(name, desc) for name, desc in LORA_PREP_ANGLES
                     if name in GEMINI_SAFE_ANGLES]

    # Build grid restricted to safe angles
    grid = []
    for angle_name, angle_desc in gemini_angles:
        for loc in locations:
            for expr_name, expr_desc in LORA_PREP_EXPRESSIONS:
                for light_name, light_desc in LORA_PREP_LIGHTING:
                    for ward in weighted_wardrobe:
                        env_desc = _build_location_env_desc(loc)
                        loc_key_safe = sanitize_path_component(loc["key"])
                        grid.append({
                            "angle": angle_name, "angle_desc": angle_desc,
                            "location": loc["key"], "location_safe": loc_key_safe,
                            "habitat_zone": loc.get("zone", "UNKNOWN"),
                            "env_desc": env_desc,
                            "expression": expr_name, "expr_desc": expr_desc,
                            "lighting": light_name, "light_desc": light_desc,
                            "wardrobe_key": ward["key"], "wardrobe_desc": ward["description"],
                        })

    # Sample from grid with diversity
    rng = random.Random(43)  # Different seed from main build_lora_prep_jobs

    by_angle = {}
    for item in grid:
        by_angle.setdefault(item["angle"], []).append(item)
    for angle_items in by_angle.values():
        rng.shuffle(angle_items)

    angle_names = [a[0] for a in gemini_angles]
    per_angle = max(1, num_candidates // len(angle_names))
    remainder = num_candidates - (per_angle * len(angle_names))

    selected = []
    for i, angle_name in enumerate(angle_names):
        angle_pool = by_angle.get(angle_name, [])
        count = per_angle + (1 if i < remainder else 0)
        picked = 0
        used_expr = set()
        for item in angle_pool:
            if picked >= count:
                break
            if item["expression"] not in used_expr:
                selected.append(item)
                used_expr.add(item["expression"])
                picked += 1
        for item in angle_pool:
            if picked >= count:
                break
            if item not in selected:
                selected.append(item)
                picked += 1

    # Build jobs
    char_upper = character_key.upper()
    candidates_dir = project_path / "visual" / "lora_candidates" / char_upper
    jobs = []
    meta = []

    # Load rendering directives for quality suffix
    _rendering = load_rendering_directives(project_path, char_upper)
    _quality_suffix = f"{_rendering['texture_prompt']} 8K, hyperdetailed."

    for idx, item in enumerate(selected):
        ward_key_safe = sanitize_path_component(item.get("wardrobe_key", "default"))
        filename = f"gemini_{idx:03d}_{item['angle']}_{ward_key_safe}_{item['expression']}.png"
        out_path = candidates_dir / filename

        wardrobe_desc = item.get("wardrobe_desc", visual_desc)
        prompt = (
            f"Single photorealistic photograph. {item['angle_desc']}. "
            f"Subject: {character_identity}. {wardrobe_desc}. "
            f"Environment: {item['env_desc']}. "
            f"{item['expr_desc'].capitalize()}. {item['light_desc']}. "
            f"One person only. No text. No split panels. "
            f"{_quality_suffix}"
        )

        jobs.append(GenerationJob(
            asset_type="character",
            asset_key=char_upper,
            variant=item["wardrobe_key"],
            angle=item["angle"],
            prompt=prompt,
            output_path=out_path,
            aspect_ratio="1:1",
            hero_image_path=hero_path if hero_path and not keystone_paths else None,
            keystone_image_paths=keystone_paths,
            is_anchor=False,
            display_name=f"Gemini {idx:03d} — {item['angle']}/{ward_key_safe}/{item['expression']}",
            is_lora_candidate=True,
        ))

        meta.append({
            "filename": filename,
            "engine": "gemini",
            "angle": item["angle"],
            "wardrobe": item.get("wardrobe_key", "default"),
            "location": item.get("location", ""),
            "habitat_zone": item.get("habitat_zone", "UNKNOWN"),
            "expression": item["expression"],
            "lighting": item["lighting"],
        })

    return jobs, meta


def build_hybrid_twopass_gemini_jobs(
    breakdown: dict,
    project_path: Path,
    character_key: str,
    qwen_results: List[GenerationResult],
    keystone_paths: List[Path],
) -> Tuple[List[GenerationJob], List[dict]]:
    """Build Gemini variation jobs from Qwen outputs for two-pass hybrid.

    For each successful Qwen output, generates 2-3 Gemini variations
    with different wardrobe + background + expression.
    """
    import random

    characters = breakdown.get("characters", {})
    char_data = characters.get(character_key.upper())
    if not char_data:
        return [], []

    prompts = char_data.get("prompts", {})
    ref_data = prompts.get("reference")
    if isinstance(ref_data, dict):
        hero_prompt = ref_data.get("hero", "")
        character_identity = _extract_character_identity(hero_prompt) if hero_prompt else char_data.get("display_name", character_key)
    else:
        character_identity = char_data.get("display_name", character_key)

    visual_desc = char_data.get("visual_description", "")

    # Get wardrobe states
    wardrobe_data = char_data.get("wardrobe", {})
    wardrobe_states = []
    if wardrobe_data:
        for w_key, w_data in wardrobe_data.items():
            desc = w_data.get("visual_description", w_data.get("description", ""))
            wardrobe_states.append({"key": w_key, "description": desc})

    if not wardrobe_states:
        wardrobe_states = [{"key": "default", "description": visual_desc}]

    # Get locations
    locations = _get_character_locations(breakdown, character_key)
    if not locations:
        locations = [{"key": "GENERIC_INTERIOR", "zone": "MID_SHIP",
                      "descriptions": ["Dark industrial corridor with worn metal walls"],
                      "lighting": []}]

    rng = random.Random(44)

    char_upper = character_key.upper()
    candidates_dir = project_path / "visual" / "lora_candidates" / char_upper
    jobs = []
    meta = []
    idx = 0

    # Get successful Qwen results
    successful_qwen = [r for r in qwen_results if r.success and r.output_path]

    # Load rendering directives for quality suffix
    _rendering = load_rendering_directives(project_path, char_upper)
    _quality_suffix = f"{_rendering['texture_prompt']} 8K, hyperdetailed."

    for qwen_result in successful_qwen:
        angle = qwen_result.job.angle
        variations_per_angle = 2 if len(successful_qwen) > 8 else 3

        for var_idx in range(variations_per_angle):
            wardrobe = rng.choice(wardrobe_states)
            loc = rng.choice(locations)
            expr_name, expr_desc = rng.choice(LORA_PREP_EXPRESSIONS)
            light_name, light_desc = rng.choice(LORA_PREP_LIGHTING)
            env_desc = _build_location_env_desc(loc)
            ward_key_safe = sanitize_path_component(wardrobe["key"])

            filename = f"twopass_{idx:03d}_{angle}_{ward_key_safe}_{expr_name}.png"
            out_path = candidates_dir / filename

            prompt = (
                f"Single photorealistic photograph. Same camera angle and pose as the reference image. "
                f"Subject: {character_identity}. {wardrobe['description']}. "
                f"Environment: {env_desc}. "
                f"{expr_desc.capitalize()}. {light_desc}. "
                f"One person only. No text. No split panels. "
                f"{_quality_suffix}"
            )

            # Use the Qwen output as the primary reference image
            jobs.append(GenerationJob(
                asset_type="character",
                asset_key=char_upper,
                variant=wardrobe["key"],
                angle=angle,
                prompt=prompt,
                output_path=out_path,
                aspect_ratio="1:1",
                hero_image_path=qwen_result.output_path,
                keystone_image_paths=keystone_paths,
                is_anchor=False,
                display_name=f"TwoPass {idx:03d} — {angle}/{ward_key_safe}/{expr_name}",
                is_lora_candidate=True,
            ))

            meta.append({
                "filename": filename,
                "engine": "gemini",
                "source": "twopass",
                "qwen_source": qwen_result.output_path.name,
                "angle": angle,
                "wardrobe": wardrobe["key"],
                "location": loc.get("key", ""),
                "habitat_zone": loc.get("zone", "UNKNOWN"),
                "expression": expr_name,
                "lighting": light_name,
            })
            idx += 1

    return jobs, meta


def _write_hybrid_manifest(
    project_path: Path,
    character_key: str,
    target_model: str,
    hybrid_mode: str,
    qwen_meta: List[dict],
    gemini_meta: List[dict],
) -> Path:
    """Write manifest.json for hybrid pipeline candidates."""
    from datetime import datetime, timezone

    char_upper = character_key.upper()
    candidates_dir = project_path / "visual" / "lora_candidates" / char_upper
    candidates_dir.mkdir(parents=True, exist_ok=True)

    manifest = {
        "version": 1,
        "character": char_upper,
        "target_model": target_model,
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "total_candidates": len(qwen_meta) + len(gemini_meta),
        "hybrid_mode": hybrid_mode,
        "engines": {
            "qwen": {
                "count": len(qwen_meta),
                "model": QwenMultiAngleEngine.ENDPOINT,
            },
            "gemini": {
                "count": len(gemini_meta),
                "model": "gemini-2.5-flash-image",
            },
        },
        "candidates": [],
    }

    for item in qwen_meta:
        entry = {"status": "pending"}
        entry.update(item)
        manifest["candidates"].append(entry)

    for item in gemini_meta:
        entry = {"status": "pending"}
        entry.update(item)
        manifest["candidates"].append(entry)

    manifest_path = candidates_dir / "manifest.json"
    manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
    print(f"  Manifest written: {manifest_path}")
    return manifest_path


# ── Image Validation ─────────────────────────────────────────────────────

# Expected aspect ratios mapped to tolerance ranges (width/height ratio)
ASPECT_TOLERANCES = {
    "9:16": (0.45, 0.70),   # portrait: ~0.5625
    "16:9": (1.50, 2.10),   # landscape: ~1.778
    "1:1":  (0.85, 1.18),   # square: ~1.0
}

MIN_FILE_SIZE = 10_000       # 10KB minimum — anything smaller is likely corrupt
MIN_DIMENSION = 256          # Minimum pixel dimension on any side


def validate_image(path: Path, expected_aspect: str) -> Tuple[bool, List[str]]:
    """
    Validate a generated image. Returns (is_usable, warnings).
    is_usable=False means the image should be retried.
    """
    warnings = []

    if not path.exists():
        return False, ["File does not exist"]

    file_size = path.stat().st_size
    if file_size < MIN_FILE_SIZE:
        return False, [f"File too small ({file_size} bytes) — likely corrupt or blank"]

    try:
        from PIL import Image
        img = Image.open(path)
        img.verify()  # Check for corruption
        # Re-open after verify (verify closes the file)
        img = Image.open(path)
        width, height = img.size
    except ImportError:
        # PIL not available — skip image-level checks, trust file size
        warnings.append("Pillow not installed — skipping pixel-level validation")
        return True, warnings
    except Exception as e:
        return False, [f"Image corrupt or unreadable: {e}"]

    # Dimension check
    if width < MIN_DIMENSION or height < MIN_DIMENSION:
        return False, [f"Image too small ({width}x{height}px) — minimum {MIN_DIMENSION}px"]

    # Aspect ratio check
    actual_ratio = width / height
    tolerance = ASPECT_TOLERANCES.get(expected_aspect)
    if tolerance:
        low, high = tolerance
        if actual_ratio < low or actual_ratio > high:
            warnings.append(
                f"Aspect ratio mismatch: got {width}x{height} ({actual_ratio:.2f}), "
                f"expected ~{expected_aspect} ({low:.2f}-{high:.2f})"
            )
            # Aspect mismatch is a warning, not a hard failure — image may still be usable

    # Check for mostly-blank images (low entropy)
    try:
        from PIL import ImageStat
        stat = ImageStat.Stat(img.convert("L"))
        # stddev < 5 means nearly uniform color (blank/solid)
        if stat.stddev[0] < 5.0:
            return False, [f"Image appears blank (stddev={stat.stddev[0]:.1f})"]
        # Very low stddev suggests poor quality
        if stat.stddev[0] < 15.0:
            warnings.append(f"Low visual complexity (stddev={stat.stddev[0]:.1f}) — may be low quality")
    except Exception:
        pass  # Stat check is best-effort

    return True, warnings


# ── Post-Generation Gates ────────────────────────────────────────────────

def detect_multiview(engine, image_path: Path) -> bool:
    """Returns True if image contains multiple views/panels (should be rejected).

    Sends the generated image back to Gemini for a cheap text-only YES/NO check.
    Only works with GeminiEngine; returns False (pass) for other engines.
    """
    if not isinstance(engine, GeminiEngine):
        return False

    try:
        image_bytes = image_path.read_bytes()
        parts = [
            engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=image_bytes)
            ),
            engine.types.Part(
                text=(
                    "Does this image contain multiple separate views, poses, angles, or panels of a character? "
                    "A turnaround sheet, reference sheet, or collage counts as multiple views. "
                    "A single photograph of one person from one angle is NOT multiple views. "
                    "Answer only YES or NO."
                ),
            ),
        ]

        response = engine.client.models.generate_content(
            model=engine.model,
            contents=[engine.types.Content(parts=parts)],
            config=engine.types.GenerateContentConfig(
                response_modalities=["TEXT"],
            ),
        )

        if response.candidates:
            text = response.candidates[0].content.parts[0].text.strip().upper()
            return text.startswith("YES")

    except Exception:
        pass  # On error, don't block — let the image through

    return False


def check_closeup_framing(engine, image_path: Path) -> Tuple[bool, str]:
    """Verify that a close_up image is actually a headshot, not a full-body shot.

    Returns (passed, reason). Only called for close_up angle images.
    Sends the image to Gemini for a text-only framing check.
    Only works with GeminiEngine; returns (True, "") for other engines.
    """
    if not isinstance(engine, GeminiEngine):
        return True, ""

    try:
        image_bytes = image_path.read_bytes()
        parts = [
            engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=image_bytes)
            ),
            engine.types.Part(
                text=(
                    "Is this image a CLOSE-UP HEADSHOT (face and upper chest filling most of the frame), "
                    "or a FULL-BODY shot (showing the entire person from head to feet)?\n\n"
                    "A close-up headshot shows the face as the dominant element — you can see fine skin details, "
                    "pores, and expression clearly. The frame cuts off at mid-chest or shoulders.\n\n"
                    "A full-body shot shows the whole person standing, including legs and feet.\n\n"
                    "Answer ONLY one of: HEADSHOT or FULL-BODY"
                ),
            ),
        ]

        response = engine.client.models.generate_content(
            model=engine.model,
            contents=[engine.types.Content(parts=parts)],
            config=engine.types.GenerateContentConfig(
                response_modalities=["TEXT"],
            ),
        )

        if response.candidates:
            text = response.candidates[0].content.parts[0].text.strip().upper()
            if "FULL" in text or "BODY" in text:
                return False, "Full-body shot instead of headshot close-up"
            return True, ""

    except Exception:
        pass  # On error, don't block

    return True, ""


def check_background(engine, image_path: Path) -> Tuple[bool, str]:
    """Verify the image has a clean white/neutral studio background.

    Returns (passed, reason). Rejects images with environment backgrounds.
    Only works with GeminiEngine; returns (True, "") for other engines.
    """
    if not isinstance(engine, GeminiEngine):
        return True, ""

    try:
        image_bytes = image_path.read_bytes()
        parts = [
            engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=image_bytes)
            ),
            engine.types.Part(
                text=(
                    "Does this image have a CLEAN STUDIO BACKGROUND (white, light grey, or neutral gradient), "
                    "or does it have an ENVIRONMENT BACKGROUND (visible floor, terrain, walls, scenery, "
                    "corridors, rooms, outdoor landscapes, dirt ground, or any environmental elements)?\n\n"
                    "A small shadow on the ground plane is acceptable for a studio background.\n"
                    "Dirt, soil, terrain, or textured ground is NOT acceptable.\n\n"
                    "Answer ONLY: STUDIO or ENVIRONMENT"
                ),
            ),
        ]

        response = engine.client.models.generate_content(
            model=engine.model,
            contents=[engine.types.Content(parts=parts)],
            config=engine.types.GenerateContentConfig(
                response_modalities=["TEXT"],
            ),
        )

        if response.candidates:
            text = response.candidates[0].content.parts[0].text.strip().upper()
            if "ENVIRONMENT" in text:
                return False, "Environment background instead of studio white"
            return True, ""

    except Exception:
        pass  # On error, don't block

    return True, ""


def check_consistency(engine, generated_path: Path, anchor_path: Path) -> Tuple[bool, str]:
    """Compare generated image to anchor for identity/wardrobe consistency.

    Returns (passed, reason). Only called for dependent angles (not front/hero).
    Sends both images to Gemini for a cheap text-only check.
    Only works with GeminiEngine; returns (True, "") for other engines.
    """
    if not isinstance(engine, GeminiEngine):
        return True, ""

    try:
        anchor_bytes = anchor_path.read_bytes()
        generated_bytes = generated_path.read_bytes()
        parts = [
            engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=anchor_bytes)
            ),
            engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=generated_bytes)
            ),
            engine.types.Part(
                text=(
                    "Image 1 is the ANCHOR (ground truth). Image 2 is the NEW image from a DIFFERENT camera angle.\n"
                    "The camera angle, pose, and framing WILL be different — ignore those completely.\n"
                    "Check ONLY whether this is the same person with identical wardrobe and makeup continuity:\n"
                    "1. Face — same person, same skin tone, same bone structure?\n"
                    "2. Clothing — same garments, same cut, same tears/damage/wear/patches?\n"
                    "3. Colors — same fabric colors, same material sheen?\n"
                    "4. Hair — same color, same LENGTH (critical), same style, same amount of mess/grease/tangles?\n"
                    "5. Makeup/FX — same dirt, grime, bruises, blood, stains, scars, sweat in the same places?\n"
                    "6. Accessories/props — same items present (belts, tools, masks, devices)?\n\n"
                    "Hair length is the most common failure — compare carefully.\n"
                    "If ALL six match: answer PASS\n"
                    "If ANY differ: answer FAIL and state which failed and why.\n"
                    "Answer format: PASS or FAIL: [reason]"
                ),
            ),
        ]

        response = engine.client.models.generate_content(
            model=engine.model,
            contents=[engine.types.Content(parts=parts)],
            config=engine.types.GenerateContentConfig(
                response_modalities=["TEXT"],
            ),
        )

        if response.candidates:
            text = response.candidates[0].content.parts[0].text.strip()
            if text.upper().startswith("PASS"):
                return True, ""
            # Extract reason after "FAIL:" or "FAIL -" etc.
            reason = text
            for prefix in ("FAIL:", "FAIL -", "FAIL."):
                if text.upper().startswith(prefix.upper()):
                    reason = text[len(prefix):].strip()
                    break
            return False, reason

    except Exception as e:
        # On error, don't block — let the image through
        return True, f"(check skipped: {e})"

    return True, ""


def check_hero_alignment(engine, generated_path: Path, hero_path: Path, variant_description: str) -> Tuple[bool, str]:
    """Compare generated front anchor to hero image for identity and color alignment.

    Returns (passed, reason). Only called for front anchors (Pass 1).
    Checks:
    - Identity (face, skin, hair color/length, build, age) — HARD FAIL if different person
    - Garment color family vs variant description — FAIL if description specifies a color
      but the image shows a clearly different color family

    Wardrobe style/cut differences are EXPECTED and intentionally ignored.
    Only works with GeminiEngine; returns (True, "") for other engines.
    """
    if not isinstance(engine, GeminiEngine):
        return True, ""

    try:
        hero_bytes = hero_path.read_bytes()
        generated_bytes = generated_path.read_bytes()
        parts = [
            engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=hero_bytes)
            ),
            engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=generated_bytes)
            ),
            engine.types.Part(
                text=(
                    "Image 1 is the HERO reference. Image 2 is a NEW image of the SAME character in a DIFFERENT outfit.\n\n"
                    "The WARDROBE WILL BE DIFFERENT — that is intentional. Ignore clothing style/cut differences.\n\n"
                    "Check ONLY these two things:\n"
                    "1. IDENTITY — Is this the same person? Compare face shape, skin tone, hair color, "
                    "hair length, build, and approximate age. If the person looks fundamentally different "
                    "(wrong ethnicity, wrong hair color, wrong build), that is a FAIL.\n"
                    "2. COLOR FAMILY — Read the following variant description and check if it specifies "
                    "a dominant garment color. If it does, does the generated image roughly match that "
                    "color family? (e.g., 'white uniform' should not appear as charcoal/dark grey)\n\n"
                    f"VARIANT DESCRIPTION: {variant_description}\n\n"
                    "If identity matches AND either no color is specified or color roughly matches: PASS\n"
                    "If identity differs OR a specified color is clearly wrong: FAIL\n\n"
                    "Answer format: PASS or FAIL: [reason]"
                ),
            ),
        ]

        response = engine.client.models.generate_content(
            model=engine.model,
            contents=[engine.types.Content(parts=parts)],
            config=engine.types.GenerateContentConfig(
                response_modalities=["TEXT"],
            ),
        )

        if response.candidates:
            text = response.candidates[0].content.parts[0].text.strip()
            if text.upper().startswith("PASS"):
                return True, ""
            reason = text
            for prefix in ("FAIL:", "FAIL -", "FAIL."):
                if text.upper().startswith(prefix.upper()):
                    reason = text[len(prefix):].strip()
                    break
            return False, reason

    except Exception as e:
        return True, f"(check skipped: {e})"

    return True, ""


def check_prompt_adherence(engine, generated_path: Path, hero_path: Optional[Path], variant_description: str) -> Tuple[bool, str]:
    """Verify that the generated image contains the distinctive visual elements from the variant description.

    Returns (passed, reason). Only called for front anchors (Pass 1).
    Sends hero (optional) + generated image + variant prompt text to Gemini vision.
    Extracts 3-5 most visually distinctive elements from the description and checks
    each is PRESENT or MISSING in the generated image.

    Only works with GeminiEngine; returns (True, "") for other engines.
    """
    if not isinstance(engine, GeminiEngine):
        return True, ""

    if not variant_description:
        return True, ""

    try:
        parts = []

        # Include hero as identity context if available
        if hero_path and hero_path.exists():
            hero_bytes = hero_path.read_bytes()
            parts.append(engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=hero_bytes)
            ))

        generated_bytes = generated_path.read_bytes()
        parts.append(engine.types.Part(
            inline_data=engine.types.Blob(mime_type="image/png", data=generated_bytes)
        ))

        hero_note = "Image 1 is the hero identity reference. The LAST image is the generated image to evaluate.\n" if (hero_path and hero_path.exists()) else ""

        parts.append(engine.types.Part(
            text=(
                f"{hero_note}"
                "TASK: Check whether the generated image contains the distinctive visual elements "
                "described in the variant description below.\n\n"
                f"VARIANT DESCRIPTION:\n{variant_description}\n\n"
                "INSTRUCTIONS:\n"
                "1. Extract the 3-5 most visually distinctive elements from the description. "
                "Focus on: signature props, dominant garment colors, physical state changes "
                "(injuries, missing limbs, cybernetics), and key wardrobe pieces.\n"
                "2. For each element, check if it is PRESENT or MISSING in the generated image.\n"
                "3. Be strict. Examples:\n"
                "   - 'Left arm severed at shoulder' + both arms visible = MISSING\n"
                "   - 'Rebreather mask around neck' + no mask visible = MISSING\n"
                "   - 'White chrome-plated uniform' + dark charcoal uniform = MISSING\n"
                "   - 'Crown of salvaged circuit boards' + no headpiece = MISSING\n\n"
                "List each element and its status, then give a final verdict.\n\n"
                "VERDICT: PASS (if all key elements present) or FAIL: [list of missing elements]"
            ),
        ))

        response = engine.client.models.generate_content(
            model=engine.model,
            contents=[engine.types.Content(parts=parts)],
            config=engine.types.GenerateContentConfig(
                response_modalities=["TEXT"],
            ),
        )

        if response.candidates:
            text = response.candidates[0].content.parts[0].text.strip()
            # Search from the bottom of the response for VERDICT line
            lines = text.split("\n")
            for line in reversed(lines):
                line_stripped = line.strip().upper()
                if line_stripped.startswith("VERDICT:"):
                    verdict_text = line.strip()[len("VERDICT:"):].strip()
                    if verdict_text.upper().startswith("PASS"):
                        return True, ""
                    # Extract reason after FAIL
                    reason = verdict_text
                    for prefix in ("FAIL:", "FAIL -", "FAIL.", "FAIL"):
                        if verdict_text.upper().startswith(prefix.upper()):
                            reason = verdict_text[len(prefix):].strip()
                            break
                    return False, reason if reason else "Missing elements detected"

            # No VERDICT line found — check for PASS/FAIL anywhere in last few lines
            tail = " ".join(lines[-3:]).upper()
            if "PASS" in tail and "FAIL" not in tail:
                return True, ""
            if "FAIL" in tail:
                return False, "Missing elements (see gate output)"

    except Exception as e:
        return True, f"(check skipped: {e})"

    return True, ""


# ── Hero-Variant Reconciliation ──────────────────────────────────────────

def extract_hero_baseline(engine, hero_path: Path) -> Optional[dict]:
    """Send hero image to Gemini vision and extract physical baseline.

    Returns dict with keys: hair_length, hair_color, hair_style, skin_tone,
    build, distinguishing_marks, baseline_wardrobe. Returns None on failure.
    Only works with GeminiEngine (needs vision).
    """
    if not isinstance(engine, GeminiEngine):
        return None

    if not hero_path.exists():
        return None

    try:
        hero_bytes = hero_path.read_bytes()
        parts = [
            engine.types.Part(
                inline_data=engine.types.Blob(mime_type="image/png", data=hero_bytes)
            ),
            engine.types.Part(
                text=(
                    "Analyze this character reference image and extract ONLY observable physical attributes. "
                    "Return a JSON object with exactly these keys (use empty string if not visible):\n"
                    '{\n'
                    '  "hair_length": "[specific: cropped above ears / chin-length / shoulder-length / waist-length / bald]",\n'
                    '  "hair_color": "[specific color]",\n'
                    '  "hair_style": "[texture and style: straight, curly, locs, braided, pulled back, etc.]",\n'
                    '  "skin_tone": "[specific: pale, fair, olive, medium brown, dark brown, etc.]",\n'
                    '  "build": "[body type and posture: lean/athletic/stocky/heavy, any notable posture]",\n'
                    '  "distinguishing_marks": "[visible scars, tattoos, freckles, birthmarks, piercings]",\n'
                    '  "baseline_wardrobe": "[what they are wearing: garments, colors, materials, condition]"\n'
                    '}\n\n'
                    "Return ONLY the JSON object. No markdown fencing. No explanation."
                ),
            ),
        ]

        response = engine.client.models.generate_content(
            model=engine.model,
            contents=[engine.types.Content(parts=parts)],
            config=engine.types.GenerateContentConfig(
                response_modalities=["TEXT"],
            ),
        )

        if response.candidates:
            text = response.candidates[0].content.parts[0].text.strip()
            # Strip markdown fencing if present
            if text.startswith("```"):
                text = re.sub(r'^```(?:json)?\s*', '', text)
                text = re.sub(r'\s*```$', '', text)
            return json.loads(text)

    except (json.JSONDecodeError, Exception) as e:
        print(f"    WARNING: Could not extract hero baseline: {e}", file=sys.stderr)

    return None


def reconcile_variant_description(
    engine,
    baseline: dict,
    variant_description: str,
    variant_name: str,
) -> Optional[str]:
    """Rewrite a variant description so physical attributes match the hero baseline.

    Respects [PROGRESSION] tag — skips physical normalization for tagged variants
    but still validates identity consistency.
    Returns reconciled description, or None on failure (keeps original).
    Only works with GeminiEngine.
    """
    if not isinstance(engine, GeminiEngine):
        return None

    is_progression = variant_description.strip().startswith("[PROGRESSION]")

    try:
        if is_progression:
            # For progressions, only check that identity markers (skin tone, build proportions) are preserved
            prompt_text = (
                "This variant is tagged [PROGRESSION] — the physical changes from hero are INTENTIONAL.\n"
                "Only verify that the description maintains consistent identity markers "
                "(skin tone, basic build proportions, face structure).\n"
                "If the description already maintains identity, return it unchanged.\n"
                "If it contradicts identity (wrong skin tone, completely different build), "
                "fix ONLY those identity markers while keeping all intentional changes.\n\n"
                f"Hero baseline: {json.dumps(baseline)}\n\n"
                f"Variant description: {variant_description}\n\n"
                "Return ONLY the (possibly unchanged) variant description text. No explanation. No quotes."
            )
        else:
            prompt_text = (
                "TASK: Adjust a variant costume description so physical attributes match a hero baseline.\n\n"
                f"HERO BASELINE (physical ground truth):\n{json.dumps(baseline)}\n\n"
                f"VARIANT DESCRIPTION (current):\n{variant_description}\n\n"
                "RULES — follow these exactly:\n"
                "1. Your output MUST contain ALL wardrobe, clothing, gear, damage, injury, and action "
                "details from the variant description. Do NOT remove or summarize any of them.\n"
                "2. You may ONLY modify references to hair, skin tone, and build so they match the "
                "hero baseline. Everything else stays verbatim or near-verbatim.\n"
                "3. If the variant description does not mention hair, skin, or build at all, return it UNCHANGED.\n"
                "4. HAIR LENGTH IS CRITICAL. If the baseline says 'cropped above ears' or 'short', "
                "the variant CANNOT say 'loose', 'flowing', 'tangled', 'shoulder-length', or anything "
                "implying longer hair. Rewrite to match the baseline length: e.g., 'cropped red hair "
                "matted with organic matter' not 'hair loose and tangled with organic matter'. "
                "Short hair can be messy, matted, sweat-slicked, or plastered down — but NOT loose or tangled.\n"
                "5. Do NOT add physical attributes that weren't in the original. "
                "If the original says nothing about skin tone, don't add skin tone.\n"
                "6. Do NOT add angle instructions, character identity, or quality suffixes.\n"
                "7. The output must be roughly the same length as the input (±20 words).\n\n"
                "Return ONLY the adjusted variant description. No explanation. No quotes. No labels."
            )

        parts = [engine.types.Part(text=prompt_text)]

        response = engine.client.models.generate_content(
            model=engine.model,
            contents=[engine.types.Content(parts=parts)],
            config=engine.types.GenerateContentConfig(
                response_modalities=["TEXT"],
            ),
        )

        if response.candidates:
            return response.candidates[0].content.parts[0].text.strip()

    except Exception as e:
        print(f"    WARNING: Could not reconcile variant '{variant_name}': {e}", file=sys.stderr)

    return None


def reconcile_variants(
    engine,
    character_key: str,
    hero_path: Path,
    breakdown: dict,
    dry_run: bool = False,
) -> dict:
    """Vision-analyze hero image, propose variant prompt rewrites for physical consistency.

    Returns dict of {variant_key: reconciled_description} for variants that changed.
    Writes hero_baseline to breakdown dict (mutates in place).
    Does NOT apply variant changes — caller must apply after human review.
    Only works with GeminiEngine (needs vision).
    """
    changes = {}

    char_data = breakdown.get("characters", {}).get(character_key)
    if not char_data:
        return changes

    prompts = char_data.get("prompts", {})
    ref_data = prompts.get("reference")
    if not isinstance(ref_data, dict):
        return changes

    variants = ref_data.get("variants", {})
    if not variants:
        return changes

    # Step 1: Extract hero baseline via vision
    print(f"  RECONCILE: {character_key} — extracting hero baseline...", end="", flush=True)

    if dry_run:
        print(" [DRY RUN — would extract baseline from hero image]")
        for var_name, var_desc in variants.items():
            print(f"    {var_name}: would reconcile against hero baseline")
        return changes

    baseline = extract_hero_baseline(engine, hero_path)
    if not baseline:
        print(" SKIPPED (could not extract baseline)")
        return changes

    print(" OK")

    # Write baseline to breakdown (factual data — always saved)
    char_data["hero_baseline"] = baseline

    # Print extracted baseline
    print(f"    Baseline: hair={baseline.get('hair_length', '?')} {baseline.get('hair_color', '?')} "
          f"{baseline.get('hair_style', '?')}, skin={baseline.get('skin_tone', '?')}, "
          f"build={baseline.get('build', '?')}")

    # Step 2: Propose reconciled descriptions (do NOT apply yet)
    for var_name, var_desc in variants.items():
        if not var_desc or not isinstance(var_desc, str):
            continue

        # Rate limit between Gemini calls
        time.sleep(engine.rate_limit_delay)

        reconciled = reconcile_variant_description(engine, baseline, var_desc, var_name)
        if reconciled and reconciled != var_desc:
            changes[var_name] = reconciled
            tag = " [PROGRESSION]" if var_desc.strip().startswith("[PROGRESSION]") else ""
            print(f"    {var_name}{tag}: CHANGED")
            print(f"      was: {var_desc}")
            print(f"      now: {reconciled}")
            print()
        else:
            print(f"    {var_name}: OK (no changes needed)")

    return changes


def apply_reconciled_changes(breakdown: dict, character_key: str, changes: dict):
    """Apply approved reconciliation changes to breakdown dict."""
    char_data = breakdown.get("characters", {}).get(character_key)
    if not char_data:
        return
    variants = char_data.get("prompts", {}).get("reference", {}).get("variants", {})
    for var_name, new_desc in changes.items():
        if var_name in variants:
            variants[var_name] = new_desc


# ── Batch Runner ─────────────────────────────────────────────────────────

def run_batch(
    engine,
    jobs: List[GenerationJob],
    dry_run: bool = False,
    max_retries: int = 2,
    no_anchor_gates: bool = False,
) -> List[GenerationResult]:
    """Run all jobs through the engine with rate limiting and validation."""
    results = []
    total = len(jobs)

    if total == 0:
        print("No jobs to process.")
        return results

    if dry_run:
        print(f"\n{'='*60}")
        print(f"DRY RUN — {total} images would be generated:")
        print(f"{'='*60}\n")

        for i, job in enumerate(jobs, 1):
            anchor_tag = " [ANCHOR]" if job.is_anchor else ""
            print(f"  [{i:3d}/{total}] {job.display_name}{anchor_tag}")
            print(f"          → {job.output_path}")
            print(f"          Aspect: {job.aspect_ratio}")
            if job.keystone_image_paths:
                selected_ks = _select_keystones_for_angle(
                    [p for p in job.keystone_image_paths if p.exists()],
                    job.angle or "front",
                )
                ks_names = [p.stem for p in selected_ks]
                print(f"          Keystones: {len(selected_ks)} selected ({', '.join(ks_names)})")
            elif job.hero_image_path:
                exists = "EXISTS" if job.hero_image_path.exists() else "MISSING"
                print(f"          Hero ref: {job.hero_image_path} [{exists}]")
            if job.anchor_image_path:
                exists = "EXISTS" if job.anchor_image_path.exists() else "PENDING"
                print(f"          Anchor ref: {job.anchor_image_path} [{exists}]")
            if job.variant_description:
                desc_preview = job.variant_description[:100] + ("..." if len(job.variant_description) > 100 else "")
                print(f"          Variant desc: {desc_preview}")
            # Show first 120 chars of prompt
            prompt_preview = job.prompt[:120] + ("..." if len(job.prompt) > 120 else "")
            print(f"          Prompt: {prompt_preview}")
            print()
        return results

    print(f"\n{'='*60}")
    print(f"GENERATING {total} IMAGES (max {max_retries} retries per job)")
    print(f"Engine: {engine.__class__.__name__}")
    print(f"Rate limit delay: {engine.rate_limit_delay}s")
    print(f"{'='*60}\n")

    successes = 0
    failures = 0
    retried = 0

    for i, job in enumerate(jobs, 1):
        print(f"  [{i:3d}/{total}] {job.display_name}...", end="", flush=True)

        result = None
        gate_status = ""  # Track gate results for console output
        for attempt in range(1 + max_retries):
            if attempt > 0:
                retried += 1
                print(f"\n          Retry {attempt}/{max_retries}...", end="", flush=True)
                time.sleep(engine.rate_limit_delay)

            result = engine.generate(job)

            if not result.success:
                # Generation itself failed — retry
                continue

            # Generation succeeded — validate the image
            is_usable, warnings = validate_image(result.output_path, job.aspect_ratio)
            result.validation_warnings = warnings

            if not is_usable:
                # Image failed validation — treat as failure for retry
                result.success = False
                result.error = f"Validation failed: {'; '.join(warnings)}"
                # Delete the bad file
                if result.output_path and result.output_path.exists():
                    result.output_path.unlink()
                continue

            # LoRA candidates: only run multiview gate, skip all others
            if getattr(job, 'is_lora_candidate', False):
                is_multiview = detect_multiview(engine, result.output_path)
                if is_multiview:
                    gate_status = "multiview:FAIL"
                    result.success = False
                    result.error = "Multi-view sheet detected"
                    if result.output_path and result.output_path.exists():
                        result.output_path.unlink()
                    continue
                gate_status = "multiview:OK"
                break  # Skip all other gates for LoRA candidates

            # Gate 1: Multi-view detection (character non-hero angles only)
            if job.asset_type == "character" and job.angle != "hero":
                is_multiview = detect_multiview(engine, result.output_path)
                if is_multiview:
                    gate_status = "multiview:FAIL"
                    result.success = False
                    result.error = "Multi-view sheet detected"
                    if result.output_path and result.output_path.exists():
                        result.output_path.unlink()
                    continue
                gate_status = "multiview:OK"

            # Gate 2: Close-up framing check (close_up angles only)
            if job.angle == "close_up":
                passed, reason = check_closeup_framing(engine, result.output_path)
                if not passed:
                    gate_status += " framing:FAIL"
                    result.success = False
                    result.error = f"Close-up framing failed: {reason}"
                    if result.output_path and result.output_path.exists():
                        result.output_path.unlink()
                    continue
                gate_status += " framing:OK"

            # Gate 3: Background check (character non-hero angles)
            if job.asset_type == "character" and job.angle not in ("hero", None):
                passed, reason = check_background(engine, result.output_path)
                if not passed:
                    gate_status += " bg:FAIL"
                    result.success = False
                    result.error = f"Background check failed: {reason}"
                    if result.output_path and result.output_path.exists():
                        result.output_path.unlink()
                    continue
                gate_status += " bg:OK"

            # Gate 4: Hero alignment (front anchors only)
            if (not no_anchor_gates
                    and job.is_anchor
                    and job.hero_image_path
                    and job.hero_image_path.exists()):
                passed, reason = check_hero_alignment(
                    engine, result.output_path, job.hero_image_path, job.variant_description)
                if not passed:
                    gate_status += f" hero:FAIL ({reason[:60]})"
                    result.success = False
                    result.error = f"Hero alignment failed: {reason}"
                    if result.output_path and result.output_path.exists():
                        result.output_path.unlink()
                    continue
                gate_status += " hero:OK"

            # Gate 5: Prompt adherence (front anchors only)
            if (not no_anchor_gates
                    and job.is_anchor
                    and job.variant_description):
                passed, reason = check_prompt_adherence(
                    engine, result.output_path, job.hero_image_path, job.variant_description)
                if not passed:
                    gate_status += f" adherence:FAIL ({reason[:60]})"
                    result.success = False
                    result.error = f"Prompt adherence failed: {reason}"
                    if result.output_path and result.output_path.exists():
                        result.output_path.unlink()
                    continue
                gate_status += " adherence:OK"

            # Gate 6: Identity/consistency check against anchor
            if (job.asset_type == "character"
                    and job.anchor_image_path
                    and job.anchor_image_path.exists()):
                passed, reason = check_consistency(engine, result.output_path, job.anchor_image_path)
                if not passed:
                    gate_status += f" consistency:FAIL ({reason[:60]})"
                    result.success = False
                    result.error = f"Consistency check failed: {reason}"
                    if result.output_path and result.output_path.exists():
                        result.output_path.unlink()
                    continue
                gate_status += " consistency:OK"

            break  # All gates passed

        # Record final result
        results.append(result)

        if result.success:
            successes += 1
            parts = []
            if gate_status:
                parts.append(gate_status)
            if result.validation_warnings:
                parts.append(f"warnings: {', '.join(result.validation_warnings)}")
            suffix = f" ({' | '.join(parts)})" if parts else ""
            print(f" OK{suffix}")
        else:
            failures += 1
            print(f" FAILED: {result.error}")

        # Rate limiting (skip delay on last job)
        if i < total:
            time.sleep(engine.rate_limit_delay)

    print(f"\n{'='*60}")
    print(f"COMPLETE: {successes} succeeded, {failures} failed, {retried} retries")
    print(f"{'='*60}\n")

    return results


# ── Breakdown Update ─────────────────────────────────────────────────────

def update_breakdown_paths(
    breakdown: dict,
    results: List[GenerationResult],
    project_path: Path,
) -> int:
    """Update breakdown.json with generated image paths. Returns count of updates."""
    updated = 0

    for result in results:
        if not result.success or not result.output_path:
            continue

        job = result.job
        # Make path relative to visual/ directory
        try:
            rel_path = result.output_path.relative_to(project_path / "visual")
            path_str = str(rel_path)
        except ValueError:
            path_str = str(result.output_path)

        if job.asset_type == "character":
            char_data = breakdown.get("characters", {}).get(job.asset_key)
            if not char_data:
                continue

            if job.variant and job.angle:
                # Per-variant per-angle
                wardrobe = char_data.get("wardrobe", {})
                variant_data = wardrobe.get(job.variant, {})
                if "reference_images" not in variant_data:
                    variant_data["reference_images"] = {}
                variant_data["reference_images"][job.angle] = path_str
                updated += 1
            elif job.angle == "hero":
                # Hero shot goes to top-level reference_images
                if "reference_images" not in char_data:
                    char_data["reference_images"] = {}
                char_data["reference_images"]["hero"] = path_str
                updated += 1

        elif job.asset_type == "location":
            loc_data = breakdown.get("locations", {}).get(job.asset_key)
            if loc_data:
                if "reference_images" not in loc_data:
                    loc_data["reference_images"] = {}
                loc_data["reference_images"][job.angle or "wide_establishing"] = path_str
                updated += 1

        elif job.asset_type == "prop":
            prop_data = breakdown.get("props", {}).get(job.asset_key)
            if prop_data:
                if "reference_images" not in prop_data:
                    prop_data["reference_images"] = {}
                prop_data["reference_images"][job.angle or "default"] = path_str
                updated += 1

    return updated


# ── Visual QC Integration ────────────────────────────────────────────────

def run_visual_qc(
    breakdown: dict,
    project_path: Path,
    qc_model: str = "claude",
) -> int:
    """Run visual QC on generated character images via visual_qc.py batch-check.

    Returns 0 if all pass, 1 if any failures.
    """
    import subprocess

    script_dir = Path(__file__).resolve().parent
    qc_script = script_dir / "visual_qc.py"

    if not qc_script.exists():
        print(f"ERROR: visual_qc.py not found at {qc_script}", file=sys.stderr)
        return 2

    breakdown_path = project_path / "visual" / "breakdown.json"
    refs_dir = project_path / "visual" / "refs"

    characters = breakdown.get("characters", {})
    if not characters:
        print("No characters in breakdown — skipping QC.")
        return 0

    print(f"\n{'='*60}")
    print(f"VISUAL QC — Reviewing character references")
    print(f"Model: {qc_model}")
    print(f"{'='*60}\n")

    any_fail = False
    qc_results = []

    for char_key in characters:
        # Check if this character has any variant images
        char_refs_dir = refs_dir / "characters" / char_key
        if not char_refs_dir.exists():
            continue

        has_images = False
        for variant_dir in char_refs_dir.iterdir():
            if variant_dir.is_dir() and any(variant_dir.glob("*.png")):
                has_images = True
                break
        if not has_images:
            continue

        print(f"  QC: {char_key}...", end="", flush=True)

        try:
            result = subprocess.run(
                [
                    sys.executable, str(qc_script),
                    "--model", qc_model,
                    "batch-check",
                    "--breakdown", str(breakdown_path),
                    "--refs-dir", str(refs_dir),
                    "--character", char_key,
                ],
                capture_output=True,
                text=True,
                timeout=300,
            )

            # Parse JSON output (stdout)
            try:
                qc_data = json.loads(result.stdout)
                variants = qc_data.get("variants", {})
                passed_count = 0
                failed_count = 0
                failed_variants = []

                CONSISTENCY_DIMS = [
                    ("identity_consistency", "identity"),
                    ("wardrobe_consistency", "wardrobe"),
                    ("color_consistency", "color"),
                    ("hair_makeup_consistency", "hair/makeup"),
                    ("props_accessories_consistency", "props"),
                ]

                for vname, vresult in variants.items():
                    if isinstance(vresult, dict) and "error" not in vresult:
                        if vresult.get("overall_pass", False):
                            passed_count += 1
                        else:
                            failed_count += 1
                            # Collect all failing dimensions
                            fail_dims = []
                            for dim_key, dim_label in CONSISTENCY_DIMS:
                                score = vresult.get(dim_key, {}).get("score", "?")
                                if isinstance(score, (int, float)) and score < 9:
                                    fail_dims.append(f"{dim_label}:{score}")
                            failed_variants.append((vname, fail_dims))
                    else:
                        failed_count += 1
                        failed_variants.append((vname, ["error"]))

                if failed_count > 0:
                    any_fail = True
                    print(f" {passed_count} PASS, {failed_count} FAIL")
                    for vname, fail_dims in failed_variants:
                        suggestions = []
                        vdata = variants.get(vname, {})
                        if isinstance(vdata, dict):
                            suggestions = vdata.get("fix_suggestions", [])
                        dims_str = ", ".join(fail_dims) if fail_dims else "see details"
                        print(f"    {char_key}/{vname}: FAIL ({dims_str})")
                        for s in suggestions[:2]:
                            print(f"      → {s}")
                    qc_results.append((char_key, passed_count, failed_count, failed_variants))
                else:
                    print(f" {passed_count} PASS")
                    qc_results.append((char_key, passed_count, 0, []))

            except json.JSONDecodeError:
                print(f" ERROR: Could not parse QC output")
                if result.stderr:
                    # Print stderr lines that aren't progress messages
                    for line in result.stderr.strip().split("\n")[-3:]:
                        print(f"    {line}")
                any_fail = True

        except subprocess.TimeoutExpired:
            print(f" TIMEOUT")
            any_fail = True
        except Exception as e:
            print(f" ERROR: {e}")
            any_fail = True

    # Summary
    print(f"\n{'='*60}")
    print("QC SUMMARY:")
    total_pass = sum(p for _, p, _, _ in qc_results)
    total_fail = sum(f for _, _, f, _ in qc_results)
    print(f"  {total_pass} variants passed, {total_fail} variants failed")

    if any_fail:
        print(f"\nTo regenerate failed variants:")
        for char_key, _, fail_count, failed_variants in qc_results:
            for vname, _ in failed_variants:
                print(f"  python3 {__file__} {project_path.name}/ --character {char_key} --variant {vname} --regenerate")
    print(f"{'='*60}\n")

    return 1 if any_fail else 0


# ── Main ─────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="Batch generate reference images from breakdown.json prompts"
    )
    parser.add_argument("project", help="Project path (e.g., 'leviathan/')")

    # Asset type filters
    filter_group = parser.add_argument_group("asset filters")
    filter_group.add_argument("--characters", action="store_true",
                              help="Generate character references only")
    filter_group.add_argument("--locations", action="store_true",
                              help="Generate location references only")
    filter_group.add_argument("--props", action="store_true",
                              help="Generate prop references only")
    filter_group.add_argument("--character", type=str, metavar="KEY",
                              help="Generate for a specific character (e.g., JINX)")
    filter_group.add_argument("--variant", type=str,
                              help="Generate for a specific wardrobe variant (use with --character)")

    # Engine options
    engine_group = parser.add_argument_group("engine options")
    engine_group.add_argument("--engine", choices=["gemini", "fal", "qwen"], default="gemini",
                              help="Image generation engine (default: gemini). "
                                   "'qwen' uses Qwen Multi-Angle LoRA for angle rotation.")
    engine_group.add_argument("--model", type=str,
                              help="Override the model ID for the selected engine")

    # Run options
    run_group = parser.add_argument_group("run options")
    run_group.add_argument("--dry-run", action="store_true",
                           help="Preview jobs without generating")
    run_group.add_argument("--skip-existing", action="store_true",
                           help="Skip images that already exist on disk")
    run_group.add_argument("--regenerate", action="store_true",
                           help="Delete and regenerate all matching variant images (heroes are protected)")
    run_group.add_argument("--regenerate-hero", action="store_true",
                           help="Also regenerate hero images (must combine with --regenerate)")
    run_group.add_argument("--max-retries", type=int, default=2,
                           help="Max retries per job on validation failure (default: 2)")
    run_group.add_argument("--no-update", action="store_true",
                           help="Don't update breakdown.json with generated paths")
    run_group.add_argument("--force", action="store_true",
                           help="Proceed even if [ENRICHMENT NEEDED] placeholders are detected")
    run_group.add_argument("--no-anchor-gates", action="store_true",
                           help="Skip hero alignment and prompt adherence gates on front anchors")

    # Reconciliation options
    recon_group = parser.add_argument_group("hero-variant reconciliation")
    recon_mutex = recon_group.add_mutually_exclusive_group()
    recon_mutex.add_argument("--reconcile", action="store_true",
                              help="Force hero-variant reconciliation (re-extract baseline, rewrite variants)")
    recon_mutex.add_argument("--no-reconcile", action="store_true",
                              help="Skip reconciliation entirely")

    # QC options
    qc_group = parser.add_argument_group("quality control")
    qc_group.add_argument("--qc", action="store_true",
                           help="Run visual QC after generation (requires ANTHROPIC_API_KEY or --qc-model gemini)")
    qc_group.add_argument("--qc-only", action="store_true",
                           help="Run QC on existing images without generating (skips generation)")
    qc_group.add_argument("--qc-model", choices=["claude", "gemini"], default="claude",
                           help="Vision model for QC (default: claude)")

    # LoRA prep options
    lora_group = parser.add_argument_group("LoRA training prep")
    lora_group.add_argument("--lora-prep", type=int, metavar="N",
                            help="Generate N diverse candidate images for LoRA training (requires --character)")
    lora_group.add_argument("--lora-target", choices=["z_image", "flux2"], default="z_image",
                            help="Target model for LoRA training (default: z_image)")
    lora_group.add_argument("--lora-pick", action="store_true",
                            help="Open picker UI in browser for existing candidates")
    lora_group.add_argument("--hybrid", nargs="?", const="parallel",
                            choices=["parallel", "twopass"],
                            help="Hybrid Qwen+Gemini pipeline for LoRA prep. "
                                 "'parallel' (default): Qwen angles + Gemini diversity in parallel. "
                                 "'twopass': Qwen angles first, then Gemini variations per angle. "
                                 "Requires --character and FAL_KEY + GOOGLE_API_KEY.")

    args = parser.parse_args()

    # Resolve project
    project_path = resolve_project_path(args.project)
    breakdown_path = project_path / "visual" / "breakdown.json"

    if not breakdown_path.exists():
        print(f"ERROR: breakdown.json not found at {breakdown_path}", file=sys.stderr)
        sys.exit(1)

    # Load breakdown
    try:
        breakdown = json.loads(breakdown_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        print(f"ERROR: Invalid JSON in {breakdown_path}: {e}", file=sys.stderr)
        sys.exit(1)

    # Load shared project config
    project_config = load_project_config(project_path)

    # Check for unenriched placeholders
    raw_text = breakdown_path.read_text(encoding="utf-8")
    if "[ENRICHMENT NEEDED]" in raw_text and not args.force:
        count = raw_text.count("[ENRICHMENT NEEDED]")
        print(f"ERROR: {count} [ENRICHMENT NEEDED] placeholder(s) found in breakdown.json.", file=sys.stderr)
        print("Run Claude enrichment step (Step 2 in /breakdown) before generating images.", file=sys.stderr)
        print("Use --force to override this check.", file=sys.stderr)
        sys.exit(1)

    # --qc-only: run QC on existing images and exit
    if args.qc_only:
        qc_exit = run_visual_qc(breakdown, project_path, args.qc_model)
        sys.exit(qc_exit)

    # --lora-pick: open picker UI in browser
    if args.lora_pick:
        if not args.character:
            print("ERROR: --lora-pick requires --character KEY", file=sys.stderr)
            sys.exit(1)
        char_upper = args.character.upper()
        url = f"http://127.0.0.1:8420/_standalone/lora_picker.html?project={project_path.name}&character={char_upper}"
        print(f"Opening picker: {url}")
        import webbrowser
        webbrowser.open(url)
        sys.exit(0)

    # --hybrid: Qwen + Gemini hybrid pipeline for LoRA prep
    if args.hybrid:
        if not args.character:
            print("ERROR: --hybrid requires --character KEY", file=sys.stderr)
            sys.exit(1)

        char_upper = args.character.upper()
        hybrid_mode = args.hybrid
        target_model = args.lora_target

        # Check for keystones
        keystones_dir = project_path / "visual" / "lora_candidates" / char_upper / "keystones"
        keystone_paths = []
        if keystones_dir.is_dir():
            for f in sorted(keystones_dir.iterdir()):
                if f.suffix.lower() in (".png", ".jpeg", ".jpg", ".webp"):
                    keystone_paths.append(f)

        # Find hero image
        refs_dir = project_path / "visual" / "refs" / "characters"
        heroes_dir = refs_dir / "heroes"
        hero_path = None
        char_data = breakdown.get("characters", {}).get(char_upper, {})
        if heroes_dir.is_dir():
            char_title = char_data.get("display_name", char_upper).replace(" ", "_")
            for name_pattern in [f"{char_title}_Hero", f"{char_upper}_Hero", char_upper, char_title]:
                for ext in [".png", ".jpeg", ".jpg", ".webp"]:
                    candidate = heroes_dir / f"{name_pattern}{ext}"
                    if candidate.exists():
                        hero_path = candidate
                        break
                if hero_path:
                    break
            if not hero_path:
                hero_candidates = []
                for f in heroes_dir.iterdir():
                    if f.stem.lower().startswith(char_upper.lower()) and f.suffix.lower() in (".png", ".jpeg", ".jpg", ".webp"):
                        hero_candidates.append(f)
                # Sort: _Hero files first, then alphabetical
                hero_candidates.sort(key=lambda p: (0 if "hero" in p.stem.lower() else 1, p.name))
                if hero_candidates:
                    hero_path = hero_candidates[0]

        if not keystone_paths and not hero_path:
            print(f"ERROR: No keystone images or hero image found for {char_upper}.", file=sys.stderr)
            print(f"  Place keystones in: {keystones_dir}/", file=sys.stderr)
            sys.exit(1)

        print(f"\n{'='*60}")
        print(f"HYBRID PIPELINE — {char_upper} ({hybrid_mode})")
        print(f"Target model: {target_model}")
        print(f"Identity source: {len(keystone_paths)} keystones" if keystone_paths else f"Identity source: hero image ({hero_path.name})")
        print(f"{'='*60}\n")

        # Determine Gemini candidate count
        gemini_count = args.lora_prep if args.lora_prep else 18

        # Build Qwen jobs
        print("── Building Qwen angle jobs ──")
        qwen_jobs, qwen_meta = build_hybrid_qwen_jobs(
            project_path, char_upper, keystone_paths, hero_path,
        )
        print(f"  Qwen: {len(qwen_jobs)} angle variations")

        if hybrid_mode == "parallel":
            # Build Gemini diversity jobs
            print("── Building Gemini diversity jobs ──")
            gemini_jobs, gemini_meta = build_hybrid_gemini_jobs(
                breakdown, project_path, char_upper, gemini_count,
                keystone_paths, hero_path,
            )
            print(f"  Gemini: {len(gemini_jobs)} diversity variations")
        else:
            # twopass: Gemini jobs built after Qwen completes
            gemini_jobs, gemini_meta = [], []
            print(f"  Gemini: will generate after Qwen pass completes")

        # Write manifest
        manifest_path = _write_hybrid_manifest(
            project_path, char_upper, target_model, hybrid_mode,
            qwen_meta, gemini_meta if hybrid_mode == "parallel" else [],
        )

        if args.dry_run:
            print(f"\n── DRY RUN: Qwen pass ({len(qwen_jobs)} jobs) ──")
            run_batch(engine=None, jobs=qwen_jobs, dry_run=True)
            if hybrid_mode == "parallel":
                print(f"\n── DRY RUN: Gemini pass ({len(gemini_jobs)} jobs) ──")
                run_batch(engine=None, jobs=gemini_jobs, dry_run=True)
            else:
                print(f"\n── DRY RUN: Two-pass Gemini jobs will be built from Qwen outputs ──")
                print(f"  ~{len(qwen_jobs) * 2}-{len(qwen_jobs) * 3} Gemini variations expected")
            print(f"\nManifest preview: {manifest_path}")
            sys.exit(0)

        # Initialize engines
        tracker = CostTracker(project_path)
        qwen_engine = QwenMultiAngleEngine(tracker=tracker)
        gemini_model = args.model or get_model("exploration_fallback", "image")
        gemini_engine = GeminiEngine(model=gemini_model, tracker=tracker)

        # ── Qwen pass ──
        print(f"\n── QWEN PASS: {len(qwen_jobs)} angle variations ──")
        qwen_results = run_batch(
            qwen_engine, qwen_jobs,
            dry_run=False, max_retries=args.max_retries, no_anchor_gates=True,
        )

        if hybrid_mode == "twopass":
            # Build Gemini jobs from Qwen outputs
            print(f"\n── Building two-pass Gemini jobs from Qwen outputs ──")
            gemini_jobs, gemini_meta = build_hybrid_twopass_gemini_jobs(
                breakdown, project_path, char_upper, qwen_results, keystone_paths,
            )
            print(f"  Gemini: {len(gemini_jobs)} two-pass variations")

            # Update manifest with Gemini jobs
            manifest_path = _write_hybrid_manifest(
                project_path, char_upper, target_model, hybrid_mode,
                qwen_meta, gemini_meta,
            )

        # ── Gemini pass ──
        if gemini_jobs:
            print(f"\n── GEMINI PASS: {len(gemini_jobs)} diversity variations ──")
            gemini_results = run_batch(
                gemini_engine, gemini_jobs,
                dry_run=False, max_retries=args.max_retries, no_anchor_gates=True,
            )
        else:
            gemini_results = []

        # Report
        all_results = qwen_results + gemini_results
        qwen_success = sum(1 for r in qwen_results if r.success)
        gemini_success = sum(1 for r in gemini_results if r.success)
        total_success = qwen_success + gemini_success
        total_fail = len(all_results) - total_success

        print(f"\n{'='*60}")
        print(f"HYBRID COMPLETE ({hybrid_mode})")
        print(f"  Qwen:   {qwen_success}/{len(qwen_results)} succeeded")
        print(f"  Gemini: {gemini_success}/{len(gemini_results)} succeeded")
        print(f"  Total:  {total_success} generated, {total_fail} failed")
        print(f"{'='*60}")

        if total_success > 0:
            picker_url = f"http://127.0.0.1:8420/_standalone/lora_picker.html?project={project_path.name}&character={char_upper}"
            print(f"\nCurate candidates: {picker_url}")
            print(f"Or run: python3 {__file__} {project_path.name}/ --character {char_upper} --lora-pick")

        sys.exit(1 if total_fail > 0 and total_success == 0 else 0)

    # --lora-prep: generate diverse LoRA training candidates
    if args.lora_prep:
        if not args.character:
            print("ERROR: --lora-prep requires --character KEY", file=sys.stderr)
            sys.exit(1)
        if args.variant or args.locations or args.props:
            print("ERROR: --lora-prep is mutually exclusive with --variant, --locations, --props", file=sys.stderr)
            sys.exit(1)

        char_upper = args.character.upper()
        num_candidates = args.lora_prep
        target_model = args.lora_target

        # Check for keystones before starting
        keystones_dir = project_path / "visual" / "lora_candidates" / char_upper / "keystones"
        ks_count = 0
        if keystones_dir.is_dir():
            ks_count = sum(1 for f in keystones_dir.iterdir()
                          if f.suffix.lower() in (".png", ".jpeg", ".jpg", ".webp"))

        print(f"\n{'='*60}")
        print(f"LORA PREP — {char_upper}")
        print(f"Generating {num_candidates} diverse candidates for {target_model} training")
        if ks_count:
            print(f"Identity source: {ks_count} keystone images")
        else:
            print(f"Identity source: NONE — candidates will have inconsistent identity")
            print(f"  Place keystone images in: {keystones_dir}/")
        print(f"{'='*60}\n")

        lora_jobs, candidates_meta = build_lora_prep_jobs(
            breakdown, project_path, char_upper, num_candidates, target_model,
            project_config=project_config,
        )

        if not lora_jobs:
            print("ERROR: Could not build LoRA prep jobs.", file=sys.stderr)
            sys.exit(1)

        # Write manifest before generation
        engine_name = args.engine
        manifest_path = _write_lora_manifest(
            project_path, char_upper, target_model, candidates_meta, engine_name,
        )

        if args.dry_run:
            run_batch(engine=None, jobs=lora_jobs, dry_run=True)
            print(f"\nManifest preview: {manifest_path}")
            sys.exit(0)

        # Initialize engine
        tracker = CostTracker(project_path)
        if args.engine == "gemini":
            model = args.model or "gemini-2.5-flash-image"
            engine = GeminiEngine(model=model, tracker=tracker)
        elif args.engine == "qwen":
            engine = QwenMultiAngleEngine(tracker=tracker)
        else:
            model = args.model or "fal-ai/flux-pro/v1.1-ultra"
            engine = FalEngine(model=model)

        # Run with reduced gates (only validate_image + detect_multiview)
        results = run_batch(
            engine, lora_jobs,
            dry_run=False,
            max_retries=args.max_retries,
            no_anchor_gates=True,  # Skip hero alignment + prompt adherence
        )

        # Report
        successes = sum(1 for r in results if r.success)
        failures = sum(1 for r in results if not r.success)
        print(f"\nLoRA prep complete: {successes} generated, {failures} failed")

        if successes > 0:
            picker_url = f"http://127.0.0.1:8420/_standalone/lora_picker.html?project={project_path.name}&character={char_upper}"
            print(f"\nCurate candidates: {picker_url}")
            print(f"Or run: python3 {__file__} {project_path.name}/ --character {char_upper} --lora-pick")

        sys.exit(1 if failures > 0 and successes == 0 else 0)

    # --regenerate and --skip-existing are mutually exclusive
    if args.regenerate and args.skip_existing:
        print("ERROR: --regenerate and --skip-existing are mutually exclusive.", file=sys.stderr)
        sys.exit(1)

    # Determine which asset types to generate
    has_filter = args.characters or args.locations or args.props or args.character
    do_characters = args.characters or args.character or not has_filter
    do_locations = args.locations or not has_filter
    do_props = args.props or not has_filter

    # Build job list (--regenerate means never skip existing)
    jobs = []
    if do_characters:
        jobs.extend(build_character_jobs(
            breakdown, project_path,
            character_filter=args.character,
            variant_filter=args.variant,
            skip_existing=args.skip_existing,
            regenerate_hero=args.regenerate_hero,
            project_config=project_config,
        ))
    if do_locations:
        jobs.extend(build_location_jobs(breakdown, project_path, args.skip_existing))
    if do_props:
        jobs.extend(build_prop_jobs(breakdown, project_path, args.skip_existing))

    if not jobs and not args.reconcile:
        print("No jobs to process. All images may already exist (try without --skip-existing).")
        return

    if not jobs and args.reconcile:
        print("No image jobs to process (all exist). Running reconciliation only.")

    # Summary
    char_jobs = sum(1 for j in jobs if j.asset_type == "character")
    loc_jobs = sum(1 for j in jobs if j.asset_type == "location")
    prop_jobs = sum(1 for j in jobs if j.asset_type == "prop")
    print(f"\nProject: {breakdown.get('project', 'unknown')}")
    print(f"Jobs: {len(jobs)} total ({char_jobs} characters, {loc_jobs} locations, {prop_jobs} props)")

    # Check hero images for character variants
    hero_missing = set()
    for job in jobs:
        if job.asset_type == "character" and job.hero_image_path and not job.hero_image_path.exists():
            hero_missing.add(job.asset_key)

    if hero_missing and not args.dry_run:
        print(f"\nWARNING: Hero images missing for: {', '.join(sorted(hero_missing))}")
        print("Variant generation will proceed without identity reference.")
        print("For better consistency, generate hero shots first, then run again.\n")

    # Initialize engine
    if not args.dry_run:
        tracker = CostTracker(project_path)
        if args.engine == "gemini":
            model = args.model or "gemini-2.5-flash-image"
            engine = GeminiEngine(model=model, tracker=tracker)
        elif args.engine == "qwen":
            engine = QwenMultiAngleEngine(tracker=tracker)
        else:
            model = args.model or "fal-ai/flux-pro/v1.1-ultra"
            engine = FalEngine(model=model)
    else:
        engine = None

    # If --regenerate, delete existing files before generating
    if args.regenerate and not args.dry_run:
        deleted = 0
        for job in jobs:
            if job.output_path.exists():
                job.output_path.unlink()
                deleted += 1
        if deleted:
            print(f"Deleted {deleted} existing images for regeneration.")

    # Two-pass execution: anchors (front angles) first, then dependents
    anchor_jobs = [j for j in jobs if j.is_anchor]
    dependent_jobs = [j for j in jobs if j.asset_type == "character" and not j.is_anchor and j.angle != "hero"]
    hero_jobs = [j for j in jobs if j.asset_type == "character" and j.angle == "hero"]
    non_char_jobs = [j for j in jobs if j.asset_type != "character"]

    results = []

    if hero_jobs:
        print(f"\n── PASS 0: Hero shots ({len(hero_jobs)} jobs) ──")
        results.extend(run_batch(engine, hero_jobs, dry_run=args.dry_run, max_retries=args.max_retries, no_anchor_gates=args.no_anchor_gates))

    # Hero-variant reconciliation (between hero and anchor passes)
    if do_characters and not args.no_reconcile and (engine or args.dry_run):
        # Determine which characters need reconciliation
        chars_to_reconcile = set()
        if args.reconcile:
            # Explicit --reconcile: scan breakdown for all matching characters
            for char_key, char_data in breakdown.get("characters", {}).items():
                if args.character and char_key != args.character.upper():
                    continue
                ref = char_data.get("prompts", {}).get("reference")
                if isinstance(ref, dict) and ref.get("variants"):
                    chars_to_reconcile.add(char_key)
        else:
            # Auto-reconcile: only characters with pending generation jobs
            for job in anchor_jobs + dependent_jobs:
                if job.asset_type == "character":
                    chars_to_reconcile.add(job.asset_key)

        if chars_to_reconcile:
            refs_dir = project_path / "visual" / "refs" / "characters"
            needs_reconcile = False
            reconcile_chars = []

            for char_key in sorted(chars_to_reconcile):
                hero_path = refs_dir / char_key / "hero.png"
                if not hero_path.exists():
                    continue

                char_data = breakdown.get("characters", {}).get(char_key, {})
                has_baseline = bool(char_data.get("hero_baseline"))

                if args.reconcile or not has_baseline:
                    reconcile_chars.append((char_key, hero_path))
                    needs_reconcile = True

            if needs_reconcile:
                print(f"\n── RECONCILE: Hero-variant consistency ({len(reconcile_chars)} characters) ──")
                all_changes = {}  # {char_key: {variant_name: new_description}}
                for char_key, hero_path in reconcile_chars:
                    changes = reconcile_variants(
                        engine, char_key, hero_path, breakdown,
                        dry_run=args.dry_run,
                    )
                    if changes:
                        all_changes[char_key] = changes

                # Human review gate — show proposed changes and ask for confirmation
                if all_changes and not args.dry_run and not args.no_update:
                    total_changes = sum(len(v) for v in all_changes.values())
                    print(f"\n{'─'*60}")
                    print(f"REVIEW: {total_changes} variant description(s) would be rewritten.")
                    print(f"Hero baselines have been saved regardless of your answer.")
                    print(f"{'─'*60}")

                    try:
                        answer = input("\nApply reconciled descriptions? [y/N] ").strip().lower()
                    except (EOFError, KeyboardInterrupt):
                        answer = "n"

                    if answer in ("y", "yes"):
                        for char_key, changes in all_changes.items():
                            apply_reconciled_changes(breakdown, char_key, changes)
                        print(f"Applied {total_changes} reconciled descriptions.")
                    else:
                        print("Skipped — variant descriptions unchanged.")
                        all_changes = {}

                    # Save breakdown (baseline is always saved, descriptions only if approved)
                    breakdown_path.write_text(
                        json.dumps(breakdown, indent=2, ensure_ascii=False) + "\n",
                        encoding="utf-8",
                    )
                    print(f"Saved breakdown.json (hero baselines updated).")

                    # Rebuild variant jobs if descriptions were changed
                    if all_changes:
                        old_anchor_keys = {(j.asset_key, j.variant, j.angle) for j in anchor_jobs}
                        old_dep_keys = {(j.asset_key, j.variant, j.angle) for j in dependent_jobs}

                        rebuilt_char_jobs = build_character_jobs(
                            breakdown, project_path,
                            character_filter=args.character,
                            variant_filter=args.variant,
                            skip_existing=args.skip_existing,
                            regenerate_hero=args.regenerate_hero,
                            project_config=project_config,
                        )
                        anchor_jobs = [j for j in rebuilt_char_jobs
                                       if j.is_anchor and (j.asset_key, j.variant, j.angle) in old_anchor_keys]
                        dependent_jobs = [j for j in rebuilt_char_jobs
                                          if j.asset_type == "character" and not j.is_anchor
                                          and j.angle != "hero"
                                          and (j.asset_key, j.variant, j.angle) in old_dep_keys]
                        print(f"Rebuilt {len(anchor_jobs)} anchor + {len(dependent_jobs)} dependent jobs with reconciled prompts.")
                elif not all_changes and not args.dry_run:
                    # No text changes but baseline may have been extracted — save it
                    breakdown_path.write_text(
                        json.dumps(breakdown, indent=2, ensure_ascii=False) + "\n",
                        encoding="utf-8",
                    )
                    print(f"Saved breakdown.json (hero baselines updated, no variant changes needed).")

    if anchor_jobs:
        print(f"\n── PASS 1: Anchor/front angles ({len(anchor_jobs)} jobs) ──")
        results.extend(run_batch(engine, anchor_jobs, dry_run=args.dry_run, max_retries=args.max_retries, no_anchor_gates=args.no_anchor_gates))

    if dependent_jobs:
        print(f"\n── PASS 2: Dependent angles ({len(dependent_jobs)} jobs) ──")
        results.extend(run_batch(engine, dependent_jobs, dry_run=args.dry_run, max_retries=args.max_retries, no_anchor_gates=args.no_anchor_gates))

    if non_char_jobs:
        print(f"\n── PASS 3: Non-character assets ({len(non_char_jobs)} jobs) ──")
        results.extend(run_batch(engine, non_char_jobs, dry_run=args.dry_run, max_retries=args.max_retries, no_anchor_gates=args.no_anchor_gates))

    # Update breakdown.json with paths
    if results and not args.dry_run and not args.no_update:
        updated = update_breakdown_paths(breakdown, results, project_path)
        if updated > 0:
            # Write updated breakdown.json
            breakdown_path.write_text(
                json.dumps(breakdown, indent=2, ensure_ascii=False) + "\n",
                encoding="utf-8",
            )
            print(f"Updated breakdown.json with {updated} reference image paths.")

    # Report failed jobs
    failed = [r for r in results if not r.success]
    if failed:
        print(f"\nFailed jobs ({len(failed)}):")
        for r in failed:
            print(f"  - {r.job.display_name}: {r.error}")

    # Visual QC integration
    qc_exit = 0
    if (args.qc or args.qc_only) and not args.dry_run:
        qc_exit = run_visual_qc(breakdown, project_path, args.qc_model)

    if args.qc_only:
        sys.exit(qc_exit)

    if failed:
        sys.exit(1)
    if qc_exit != 0:
        sys.exit(1)


if __name__ == "__main__":
    main()
