#!/usr/bin/env python3
"""Dry-run test for SeedDance prompt builders (T2V, I2V, R2V).

Validates all three SeedDance prompt builders using embedded mock shot data.
Zero API cost — tests prompt construction only, no generation calls.

Checks:
  - No section headers (would render as text overlays)
  - Quality suffix present in T2V/R2V output (removed from I2V per consultation)
  - Identity lock phrase in I2V prompts
  - Word count within model_profiles range per endpoint
  - All builders run without exceptions

Integration tests (5 varied mock shots):
  - seeddance_t2v key exists and contains five-block prose (120-250 words)
  - seeddance_i2v key exists and contains motion-only prompt (50-75 words)
  - seeddance_r2v key exists and contains enhanced R2V prompt
  - No section headers in any SeedDance prompt
  - Quality suffix present in T2V/R2V (intentionally removed from I2V)
  - Identity lock phrase present in I2V only
  - @Image1 is primary character in R2V/I2V ref ordering

Usage:
    python3 tools/test_seeddance_builders.py
    python3 tools/test_seeddance_builders.py --plan path/to/ep_001_plan.json
    python3 tools/test_seeddance_builders.py -v   # verbose output

Exit codes:
    0 — all tests pass
    1 — one or more tests fail
"""

import argparse
import json
import sys
from pathlib import Path

# Ensure project root is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Ensure pipeline (starsend) is importable for prompt_engine
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "pipeline"))

# ──────────────────────────────────────────────────────────────────────
# Embedded mock data — realistic plan shot for testing without real files
# ──────────────────────────────────────────────────────────────────────

MOCK_SHOT = {
    "shot_id": "EP01_SH003",
    "prompt_data": {
        "prompt_skeleton": {
            "subject_line": (
                "Kira Tanaka, an East Asian woman in her mid-twenties with black "
                "shoulder-length straight hair and sharp angular features, wearing a dark "
                "tactical jacket over a fitted black shirt and olive cargo pants, stands "
                "at the edge of the rain-slicked rooftop with her boots planted wide apart"
            ),
            "environment_line": (
                "Rain-slicked concrete rooftop overlooking a dense cyberpunk cityscape "
                "at night, neon signage reflecting in shallow puddles"
            ),
            "emotion_line": (
                "Her expression is resolute and focused, jaw set with quiet determination, "
                "eyes narrowing as she makes a decision, nostrils flaring slightly with "
                "controlled adrenaline"
            ),
            "action_line": (
                "She turns sharply on her heel and strides with purpose toward the "
                "rusted fire escape at the far edge of the rooftop, her jacket flaring "
                "in the gusting crosswind"
            ),
        },
        "shot_type": "MS",
        "camera_movement": "tracking",
        "kinetic_action": (
            "pivots on her heel and strides with purpose toward the rusted fire escape, "
            "jacket flaring behind her in the gusting crosswind"
        ),
        "focal_length": "35mm",
        "lighting": {
            "sources": [
                {"motivator": "neon signage", "direction": "back", "quality": "hard", "color_temp": "cool"},
                {"motivator": "city glow", "direction": "ambient", "quality": "soft", "color_temp": "warm"},
            ],
        },
    },
    "asset_data": {
        "characters": [
            {
                "char_id": "kira-tanaka",
                "display_name": "Kira Tanaka",
                "hair": "black, shoulder-length, straight",
                "wardrobe": "dark tactical jacket, cargo pants",
            },
        ],
        "location_id": "neon-rooftop",
        "start_frame_path": "/tmp/mock_start_frame.jpg",
        "start_frame_url": "https://example.com/mock_start_frame.jpg",
    },
    "routing_data": {
        "target_editorial_duration_s": 5,
        "start_frame_path": "/tmp/mock_start_frame.jpg",
        "start_frame_url": "https://example.com/mock_start_frame.jpg",
    },
}

MOCK_BIBLE = {
    "characters": {
        "kira-tanaka": {
            "display_name": "Kira Tanaka",
            "visual_description": "East Asian woman, mid-20s, athletic build, black shoulder-length straight hair, sharp features",
        },
    },
    "locations": {
        "neon-rooftop": {
            "display_name": "Neon Rooftop",
            "spatial_description": "Rain-slicked concrete rooftop with ventilation units, neon signage reflected in puddles",
            "description": "Elevated vantage point above the city, industrial feel",
        },
    },
}

MOCK_PROJECT_CONFIG = {
    "film_stock": "ARRI Alexa, Kodak 2383 print film",
    "allow_music": False,
}

# ──────────────────────────────────────────────────────────────────────
# Integration test mock shots — 5 varied scenarios
# ──────────────────────────────────────────────────────────────────────

INTEGRATION_BIBLE = {
    "characters": {
        "kira-tanaka": {
            "display_name": "Kira Tanaka",
            "visual_description": "East Asian woman, mid-20s, athletic build, black shoulder-length straight hair, sharp features",
        },
        "marco-silva": {
            "display_name": "Marco Silva",
            "visual_description": "Latino man, early 30s, stocky build, buzz cut, square jaw, sleeve tattoos",
        },
    },
    "locations": {
        "neon-rooftop": {
            "display_name": "Neon Rooftop",
            "spatial_description": "Rain-slicked concrete rooftop with ventilation units, neon signage reflected in puddles",
            "description": "Elevated vantage point above the city, industrial feel",
        },
        "warehouse-floor": {
            "display_name": "Warehouse Floor",
            "spatial_description": "Cavernous industrial space with concrete columns, overhead fluorescent tubes, stacked pallets",
            "description": "Repurposed industrial space used as a black-market trading floor",
        },
        "subway-platform": {
            "display_name": "Subway Platform",
            "spatial_description": "Underground transit platform with tiled walls, dim overhead strip lights, rats on tracks",
            "description": "Late-night empty subway station, eerie silence",
        },
        "penthouse-office": {
            "display_name": "Penthouse Office",
            "spatial_description": "Glass-walled corner office at the top of a skyscraper, city lights panorama, minimalist desk",
            "description": "Power seat of the antagonist, cold and sterile luxury",
        },
        "alley-market": {
            "display_name": "Back Alley Market",
            "spatial_description": "Narrow alley with hanging lanterns, street food stalls, steam vents, wet cobblestones",
            "description": "Crowded night market hidden between buildings",
        },
    },
}

# Shot 1: Action scene — character running, fast camera
INTEGRATION_SHOT_ACTION = {
    "shot_id": "EP01_SH001",
    "prompt_data": {
        "prompt_skeleton": {
            "subject_line": (
                "Kira Tanaka sprints across the rain-slicked rooftop, arms pumping, "
                "tactical jacket billowing behind her as she leaps over a ventilation unit"
            ),
            "environment_line": (
                "Rain-slicked concrete rooftop at night, neon signs flickering below, "
                "puddles splashing underfoot"
            ),
            "emotion_line": (
                "Her face is locked in fierce concentration, teeth bared, eyes wide "
                "with adrenaline and fear"
            ),
            "action_line": (
                "She vaults over a low concrete barrier, tucks into a roll, and springs "
                "back to her feet without breaking stride"
            ),
        },
        "shot_type": "MLS",
        "camera_movement": "tracking",
        "kinetic_action": (
            "vaults over barrier, rolls, springs back upright in continuous motion"
        ),
        "focal_length": "24mm",
        "lighting": {
            "sources": [
                {"motivator": "neon signs", "direction": "above", "quality": "hard", "color_temp": "cool"},
                {"motivator": "city glow", "direction": "ambient", "quality": "soft", "color_temp": "warm"},
            ],
        },
    },
    "asset_data": {
        "characters": [
            {"char_id": "kira-tanaka", "display_name": "Kira Tanaka"},
        ],
        "location_id": "neon-rooftop",
        "start_frame_path": "/tmp/mock_action_start.jpg",
        "start_frame_url": "https://example.com/mock_action_start.jpg",
    },
    "routing_data": {
        "target_editorial_duration_s": 5,
        "start_frame_path": "/tmp/mock_action_start.jpg",
        "start_frame_url": "https://example.com/mock_action_start.jpg",
    },
}

# Shot 2: Dialogue scene — two characters talking, static camera
INTEGRATION_SHOT_DIALOGUE = {
    "shot_id": "EP01_SH005",
    "prompt_data": {
        "prompt_skeleton": {
            "subject_line": (
                "Kira Tanaka and Marco Silva face each other across a scarred wooden table "
                "in the dim warehouse, both leaning forward with hands flat on the surface"
            ),
            "environment_line": (
                "Cavernous warehouse interior, overhead fluorescent tubes casting harsh pools of light, "
                "stacked pallets visible in the shadows behind them"
            ),
            "emotion_line": (
                "Kira's expression is guarded but intent, lips pressed thin, while Marco's jaw "
                "tightens with barely contained frustration"
            ),
            "action_line": (
                "Marco slams his palm on the table and leans in closer, jabbing a finger "
                "toward Kira as he speaks through gritted teeth"
            ),
        },
        "shot_type": "MS",
        "camera_movement": "static",
        "kinetic_action": (
            "slams palm on table, jabs finger forward, leans aggressively closer"
        ),
        "focal_length": "50mm",
        "lighting": {
            "sources": [
                {"motivator": "fluorescent tubes", "direction": "above", "quality": "hard", "color_temp": "cool"},
            ],
        },
    },
    "asset_data": {
        "characters": [
            {"char_id": "kira-tanaka", "display_name": "Kira Tanaka"},
            {"char_id": "marco-silva", "display_name": "Marco Silva"},
        ],
        "location_id": "warehouse-floor",
        "start_frame_path": "/tmp/mock_dialogue_start.jpg",
        "start_frame_url": "https://example.com/mock_dialogue_start.jpg",
    },
    "routing_data": {
        "target_editorial_duration_s": 5,
        "start_frame_path": "/tmp/mock_dialogue_start.jpg",
        "start_frame_url": "https://example.com/mock_dialogue_start.jpg",
    },
}

# Shot 3: Close-up — face detail, subtle emotion
INTEGRATION_SHOT_CLOSEUP = {
    "shot_id": "EP01_SH008",
    "prompt_data": {
        "prompt_skeleton": {
            "subject_line": (
                "Kira Tanaka's face fills the frame, beads of sweat on her temples, "
                "a shallow cut visible above her left eyebrow"
            ),
            "environment_line": (
                "Blurred subway platform lights behind her, dim and out of focus"
            ),
            "emotion_line": (
                "Her eyes dart left then right, pupils dilating, breath visible as a "
                "thin mist in the cold air, expression shifting from alert to resigned"
            ),
            "action_line": (
                "She exhales slowly, closes her eyes for a single beat, then opens them "
                "with renewed resolve"
            ),
        },
        "shot_type": "CU",
        "camera_movement": "push_in",
        "kinetic_action": (
            "exhales slowly, closes eyes for a beat, reopens with resolve"
        ),
        "focal_length": "85mm",
        "lighting": {
            "sources": [
                {"motivator": "platform strip light", "direction": "side", "quality": "hard", "color_temp": "neutral"},
            ],
        },
    },
    "asset_data": {
        "characters": [
            {"char_id": "kira-tanaka", "display_name": "Kira Tanaka"},
        ],
        "location_id": "subway-platform",
        "start_frame_path": "/tmp/mock_closeup_start.jpg",
        "start_frame_url": "https://example.com/mock_closeup_start.jpg",
    },
    "routing_data": {
        "target_editorial_duration_s": 3,
        "start_frame_path": "/tmp/mock_closeup_start.jpg",
        "start_frame_url": "https://example.com/mock_closeup_start.jpg",
    },
}

# Shot 4: Wide establishing — no character focus, environment only
INTEGRATION_SHOT_WIDE = {
    "shot_id": "EP02_SH001",
    "prompt_data": {
        "prompt_skeleton": {
            "subject_line": (
                "The penthouse office stretches out in cold minimalist splendor, floor-to-ceiling "
                "glass walls revealing the glittering cityscape below, a single halogen desk lamp "
                "casting a cone of light on the empty leather chair"
            ),
            "environment_line": (
                "Glass-walled corner office at the apex of a skyscraper, city lights panorama "
                "spread to the horizon, clean lines, monochrome palette"
            ),
            "emotion_line": (
                "The stillness suggests absence, as though the occupant has just left or is "
                "about to arrive, tension in the emptiness"
            ),
            "action_line": (
                "The desk lamp flickers once, casting brief shadows across the polished floor, "
                "then steadies"
            ),
        },
        "shot_type": "EWS",
        "camera_movement": "crane",
        "kinetic_action": "desk lamp flickers once, shadows shift briefly, then stabilize",
        "focal_length": "16mm",
        "lighting": {
            "sources": [
                {"motivator": "city lights", "direction": "back", "quality": "soft", "color_temp": "warm"},
                {"motivator": "desk lamp", "direction": "below", "quality": "hard", "color_temp": "cool"},
            ],
        },
    },
    "asset_data": {
        "characters": [],
        "location_id": "penthouse-office",
        "start_frame_path": "/tmp/mock_wide_start.jpg",
        "start_frame_url": "https://example.com/mock_wide_start.jpg",
    },
    "routing_data": {
        "target_editorial_duration_s": 7,
        "start_frame_path": "/tmp/mock_wide_start.jpg",
        "start_frame_url": "https://example.com/mock_wide_start.jpg",
    },
}

# Shot 5: Transition — character entering a new space, dolly movement
INTEGRATION_SHOT_TRANSITION = {
    "shot_id": "EP02_SH004",
    "prompt_data": {
        "prompt_skeleton": {
            "subject_line": (
                "Marco Silva pushes through a beaded curtain into the narrow back-alley "
                "market, steam from food stalls wrapping around his broad shoulders"
            ),
            "environment_line": (
                "Narrow alley with hanging lanterns in red and gold, street food stalls "
                "lining both sides, steam vents rising, wet cobblestones reflecting "
                "lantern light"
            ),
            "emotion_line": (
                "His expression is cautious and watchful, eyes scanning the crowd, "
                "one hand resting inside his jacket near the hip"
            ),
            "action_line": (
                "He ducks under a low-hanging tarp, sidesteps a vendor cart, and pauses "
                "at a gap between two stalls to orient himself"
            ),
        },
        "shot_type": "MFS",
        "camera_movement": "dolly",
        "kinetic_action": (
            "ducks under tarp, sidesteps vendor cart, pauses between stalls"
        ),
        "focal_length": "35mm",
        "lighting": {
            "sources": [
                {"motivator": "hanging lanterns", "direction": "above", "quality": "soft", "color_temp": "warm"},
                {"motivator": "steam vents", "direction": "ambient", "quality": "soft", "color_temp": "neutral"},
            ],
        },
    },
    "asset_data": {
        "characters": [
            {"char_id": "marco-silva", "display_name": "Marco Silva"},
        ],
        "location_id": "alley-market",
        "start_frame_path": "/tmp/mock_transition_start.jpg",
        "start_frame_url": "https://example.com/mock_transition_start.jpg",
    },
    "routing_data": {
        "target_editorial_duration_s": 5,
        "start_frame_path": "/tmp/mock_transition_start.jpg",
        "start_frame_url": "https://example.com/mock_transition_start.jpg",
    },
}

INTEGRATION_SHOTS = [
    ("action", INTEGRATION_SHOT_ACTION),
    ("dialogue", INTEGRATION_SHOT_DIALOGUE),
    ("close-up", INTEGRATION_SHOT_CLOSEUP),
    ("wide establishing", INTEGRATION_SHOT_WIDE),
    ("transition", INTEGRATION_SHOT_TRANSITION),
]

# ──────────────────────────────────────────────────────────────────────
# Section header patterns (anti-pattern for SeedDance)
# ──────────────────────────────────────────────────────────────────────

# These headers would be rendered as text overlays by SeedDance
_SECTION_HEADER_PATTERNS = [
    "CAMERA:", "SUBJECT:", "ACTION:", "STYLE:", "LIGHTING:",
    "ENVIRONMENT:", "AUDIO:", "QUALITY:", "MOTION:",
    "## ", "### ", "**CAMERA", "**SUBJECT", "**ACTION",
]

# Quality suffix from PROMPT_BIBLE
_QUALITY_SUFFIX_FRAGMENT = "4K, Ultra HD"

# Identity lock phrase from PROMPT_BIBLE
_IDENTITY_LOCK_FRAGMENT = "Same person as @Image1"


# ──────────────────────────────────────────────────────────────────────
# Test runner
# ──────────────────────────────────────────────────────────────────────

class TestResult:
    def __init__(self, name: str):
        self.name = name
        self.passed = True
        self.errors: list[str] = []

    def fail(self, msg: str):
        self.passed = False
        self.errors.append(msg)

    def __str__(self):
        status = "PASS" if self.passed else "FAIL"
        result = f"  [{status}] {self.name}"
        for err in self.errors:
            result += f"\n         {err}"
        return result


def _load_plan_shot(plan_path: str) -> dict:
    """Load the first shot from a real plan file."""
    with open(plan_path) as f:
        plan = json.load(f)
    shots = plan.get("shots", [])
    if not shots:
        raise ValueError(f"No shots found in {plan_path}")
    return shots[0]


def _get_word_range(endpoint: str) -> tuple[int, int]:
    """Get optimal word range for a SeedDance endpoint from model_profiles."""
    try:
        from recoil.core import model_profiles
        profile = model_profiles.get_profile("seeddance-2.0")
        key = f"optimal_prompt_length_{endpoint}"
        range_val = profile.get(key, profile.get("optimal_prompt_length", [50, 250]))
        return (range_val[0], range_val[1])
    except Exception:
        # Fallback ranges from PROMPT_BIBLE
        defaults = {
            "t2v": (120, 250),
            "i2v": (50, 75),
            "r2v": (120, 200),
        }
        return defaults.get(endpoint, (50, 250))


def _check_no_section_headers(prompt: str, result: TestResult):
    """Verify prompt contains no section headers."""
    for pattern in _SECTION_HEADER_PATTERNS:
        if pattern in prompt:
            result.fail(f"Section header found: '{pattern}' — would render as text overlay")


def _check_quality_suffix(prompt: str, result: TestResult):
    """Verify quality suffix is present (T2V/R2V only)."""
    if _QUALITY_SUFFIX_FRAGMENT not in prompt:
        result.fail(f"Quality suffix missing — expected '{_QUALITY_SUFFIX_FRAGMENT}' in prompt")


def _check_no_quality_suffix(prompt: str, result: TestResult):
    """Verify quality suffix is NOT present (I2V — removed per consultation, 22% budget reclaim)."""
    if _QUALITY_SUFFIX_FRAGMENT in prompt:
        result.fail(f"Quality suffix should NOT be in I2V prompt — was intentionally removed")


def _check_identity_lock(prompt: str, result: TestResult):
    """Verify identity lock phrase is present (I2V only)."""
    if _IDENTITY_LOCK_FRAGMENT not in prompt:
        result.fail(f"Identity lock missing — expected '{_IDENTITY_LOCK_FRAGMENT}' in prompt")


def _check_no_identity_lock(prompt: str, result: TestResult):
    """Verify identity lock phrase is NOT present (T2V/R2V should not have it)."""
    if _IDENTITY_LOCK_FRAGMENT in prompt:
        result.fail(f"Identity lock found in non-I2V prompt — '{_IDENTITY_LOCK_FRAGMENT}' should only be in I2V")


def _check_word_count(prompt: str, endpoint: str, result: TestResult):
    """Verify word count is within range for this endpoint."""
    word_count = len(prompt.split())
    min_words, max_words = _get_word_range(endpoint)
    # Allow 20% tolerance on both ends for edge cases
    soft_min = int(min_words * 0.8)
    soft_max = int(max_words * 1.2)
    if word_count < soft_min:
        result.fail(f"Word count {word_count} below range [{min_words}-{max_words}] (soft min: {soft_min})")
    if word_count > soft_max:
        result.fail(f"Word count {word_count} above range [{min_words}-{max_words}] (soft max: {soft_max})")


# ──────────────────────────────────────────────────────────────────────
# Unit tests (Phase 3 — single mock shot per builder)
# ──────────────────────────────────────────────────────────────────────

def test_t2v(shot: dict, bible: dict, config: dict, verbose: bool = False) -> TestResult:
    """Test build_seeddance_t2v_prompt."""
    result = TestResult("SeedDance T2V Builder")
    try:
        from recoil.pipeline._lib.prompt_engine import build_seeddance_t2v_prompt
        prompt = build_seeddance_t2v_prompt(
            shot=shot,
            bible=bible,
            project_config=config,
            episode=1,
        )

        if verbose:
            word_count = len(prompt.split())
            print(f"\n  --- T2V Prompt ({word_count} words) ---")
            print(f"  {prompt}")
            print("  --- end ---\n")

        _check_no_section_headers(prompt, result)
        _check_quality_suffix(prompt, result)
        _check_word_count(prompt, "t2v", result)

        if not prompt.strip():
            result.fail("T2V prompt is empty")

    except Exception as e:
        result.fail(f"Builder raised exception: {e}")

    return result


def test_i2v(shot: dict, config: dict, verbose: bool = False) -> TestResult:
    """Test build_seeddance_i2v_prompt."""
    result = TestResult("SeedDance I2V Builder")
    try:
        from recoil.pipeline._lib.prompt_engine import build_seeddance_i2v_prompt
        prompt = build_seeddance_i2v_prompt(
            shot=shot,
            project_config=config,
        )

        if verbose:
            word_count = len(prompt.split())
            print(f"\n  --- I2V Prompt ({word_count} words) ---")
            print(f"  {prompt}")
            print("  --- end ---\n")

        _check_no_section_headers(prompt, result)
        _check_no_quality_suffix(prompt, result)
        _check_identity_lock(prompt, result)
        _check_word_count(prompt, "i2v", result)

        if not prompt.strip():
            result.fail("I2V prompt is empty")

        # I2V must reference @Image1
        if "@Image1" not in prompt:
            result.fail("I2V prompt missing @Image1 reference")

    except Exception as e:
        result.fail(f"Builder raised exception: {e}")

    return result


def test_r2v(shot: dict, bible: dict, config: dict, verbose: bool = False) -> TestResult:
    """Test build_seeddance_r2v_prompt."""
    result = TestResult("SeedDance R2V Builder")
    try:
        from recoil.pipeline._lib.prompt_engine import build_seeddance_r2v_prompt
        prompt = build_seeddance_r2v_prompt(
            shots=[shot],
            bible=bible,
            project_config=config,
            episode=1,
        )

        if verbose:
            word_count = len(prompt.split())
            print(f"\n  --- R2V Prompt ({word_count} words) ---")
            print(f"  {prompt}")
            print("  --- end ---\n")

        _check_no_section_headers(prompt, result)
        _check_quality_suffix(prompt, result)
        _check_word_count(prompt, "r2v", result)

        if not prompt.strip():
            result.fail("R2V prompt is empty")

    except Exception as e:
        result.fail(f"Builder raised exception: {e}")

    return result


# ──────────────────────────────────────────────────────────────────────
# Integration tests (Phase 4 — 5 varied mock shots, all 3 builders)
# ──────────────────────────────────────────────────────────────────────

def test_integration(verbose: bool = False) -> list[TestResult]:
    """Run integration tests across 5 varied mock shots.

    For each shot, calls all three SeedDance builders and verifies:
    - seeddance_t2v: five-block prose (120-250 words)
    - seeddance_i2v: motion-only prompt (50-75 words)
    - seeddance_r2v: enhanced R2V prompt with ref declarations
    - No section headers in any prompt
    - Quality suffix present in T2V/R2V (intentionally removed from I2V)
    - Identity lock phrase present in I2V only
    - @Image1 is primary character in R2V/I2V ref ordering
    """
    from recoil.pipeline._lib.prompt_engine import (
        build_seeddance_t2v_prompt,
        build_seeddance_i2v_prompt,
        build_seeddance_r2v_prompt,
    )

    results = []
    bible = INTEGRATION_BIBLE
    config = MOCK_PROJECT_CONFIG

    for scenario_name, shot in INTEGRATION_SHOTS:
        # ── T2V ──
        t2v_result = TestResult(f"Integration T2V [{scenario_name}]")
        try:
            t2v_prompt = build_seeddance_t2v_prompt(
                shot=shot, bible=bible, project_config=config, episode=1,
            )

            if verbose:
                wc = len(t2v_prompt.split())
                print(f"\n  --- T2V [{scenario_name}] ({wc} words) ---")
                print(f"  {t2v_prompt}")
                print("  --- end ---\n")

            # Must not be empty
            if not t2v_prompt.strip():
                t2v_result.fail("T2V prompt is empty")

            # No section headers
            _check_no_section_headers(t2v_prompt, t2v_result)

            # Quality suffix present
            _check_quality_suffix(t2v_prompt, t2v_result)

            # Word count: five-block prose 120-250 words
            _check_word_count(t2v_prompt, "t2v", t2v_result)

            # Identity lock must NOT be in T2V (no image refs in T2V)
            _check_no_identity_lock(t2v_prompt, t2v_result)

            # T2V must NOT contain @Image references (no refs in T2V)
            if "@Image" in t2v_prompt:
                t2v_result.fail("T2V prompt contains @Image reference — T2V takes no refs")

        except Exception as e:
            t2v_result.fail(f"Builder raised exception: {e}")
        results.append(t2v_result)

        # ── I2V ──
        i2v_result = TestResult(f"Integration I2V [{scenario_name}]")
        try:
            i2v_prompt = build_seeddance_i2v_prompt(
                shot=shot, project_config=config,
            )

            if verbose:
                wc = len(i2v_prompt.split())
                print(f"\n  --- I2V [{scenario_name}] ({wc} words) ---")
                print(f"  {i2v_prompt}")
                print("  --- end ---\n")

            # Must not be empty
            if not i2v_prompt.strip():
                i2v_result.fail("I2V prompt is empty")

            # No section headers
            _check_no_section_headers(i2v_prompt, i2v_result)

            # Quality suffix intentionally removed from I2V (consultation — 22% budget reclaim)
            _check_no_quality_suffix(i2v_prompt, i2v_result)

            # Identity lock present (I2V only)
            _check_identity_lock(i2v_prompt, i2v_result)

            # Word count: motion-only 50-75 words
            _check_word_count(i2v_prompt, "i2v", i2v_result)

            # Must reference @Image1 (start frame anchor)
            if "@Image1" not in i2v_prompt:
                i2v_result.fail("I2V prompt missing @Image1 reference")

            # @Image1 must appear as primary character anchor
            # (should appear before any other text as "The subject in @Image1")
            first_image_pos = i2v_prompt.find("@Image1")
            if first_image_pos < 0:
                i2v_result.fail("@Image1 not found in I2V prompt")

        except Exception as e:
            i2v_result.fail(f"Builder raised exception: {e}")
        results.append(i2v_result)

        # ── R2V ──
        r2v_result = TestResult(f"Integration R2V [{scenario_name}]")
        try:
            r2v_prompt = build_seeddance_r2v_prompt(
                shots=[shot], bible=bible, project_config=config, episode=1,
            )

            if verbose:
                wc = len(r2v_prompt.split())
                print(f"\n  --- R2V [{scenario_name}] ({wc} words) ---")
                print(f"  {r2v_prompt}")
                print("  --- end ---\n")

            # Must not be empty
            if not r2v_prompt.strip():
                r2v_result.fail("R2V prompt is empty")

            # No section headers
            _check_no_section_headers(r2v_prompt, r2v_result)

            # Quality suffix present
            _check_quality_suffix(r2v_prompt, r2v_result)

            # Word count: R2V 120-200 words
            _check_word_count(r2v_prompt, "r2v", r2v_result)

            # Identity lock must NOT be in R2V
            _check_no_identity_lock(r2v_prompt, r2v_result)

            # @Image ordering: if characters present, primary character
            # should be declared first (identity_1 → @Image1 position).
            # R2V uses placeholder tokens @Image{identity_1} which the
            # assembler resolves to @Image1 later. Verify the first
            # identity token appears before any scene token.
            characters = shot.get("asset_data", {}).get("characters", [])
            if characters:
                identity_pos = r2v_prompt.find("@Image{identity_1}")
                scene_pos = r2v_prompt.find("@Image{scene_")
                if identity_pos < 0:
                    # Also accept resolved @Image1 if ref_manifest was used
                    identity_pos = r2v_prompt.find("@Image1")
                if identity_pos >= 0 and scene_pos >= 0:
                    if identity_pos > scene_pos:
                        r2v_result.fail(
                            "@Image1 ordering violated: scene ref appears before "
                            "identity ref in R2V prompt — primary character must be first"
                        )

        except Exception as e:
            r2v_result.fail(f"Builder raised exception: {e}")
        results.append(r2v_result)

    return results


# ──────────────────────────────────────────────────────────────────────
# Phase 2 tests (lighting, film stock, action enforcement)
# ──────────────────────────────────────────────────────────────────────

def test_lighting_anchors(verbose: bool = False) -> list[TestResult]:
    """Test that Seedance lighting anchors appear in T2V prompts.

    Uses INTEGRATION_SHOT_ACTION (has hard+practical sources) and a
    warm candle variant to verify _seedance_lighting_anchors() integration.
    """
    from recoil.pipeline._lib.prompt_engine import build_seeddance_t2v_prompt

    results = []

    # ── Test 1: INTEGRATION_SHOT_ACTION has hard directional + practical sources ──
    r1 = TestResult("Lighting anchors — hard+practical (action shot)")
    try:
        prompt = build_seeddance_t2v_prompt(
            shot=INTEGRATION_SHOT_ACTION,
            bible=INTEGRATION_BIBLE,
            project_config=MOCK_PROJECT_CONFIG,
            episode=1,
        )

        if verbose:
            wc = len(prompt.split())
            print(f"\n  --- Lighting Anchors [action] ({wc} words) ---")
            print(f"  {prompt}")
            print("  --- end ---\n")

        # INTEGRATION_SHOT_ACTION has:
        #   source 1: neon signs, above, hard, cool → hard_directional + practical_source
        #   source 2: city glow, ambient, soft, warm → atmospheric + warm_color_temp
        # Expected anchors: "motivated lighting" (hard_directional),
        #   "practical light sources visible in frame" (practical_source)
        #   Plus potentially: "warm tungsten bounce" (warm_color_temp),
        #   "volumetric dust particles" (atmospheric)
        prompt_lower = prompt.lower()
        has_any_anchor = any(phrase in prompt_lower for phrase in [
            "motivated lighting",
            "practical light sources visible in frame",
            "warm tungsten bounce",
            "volumetric dust particles",
            "negative fill",
        ])
        if not has_any_anchor:
            r1.fail(
                "No Seedance lighting anchor phrases found in T2V prompt — "
                "expected at least one of: motivated lighting, practical light "
                "sources visible in frame, warm tungsten bounce, volumetric "
                "dust particles, negative fill"
            )

        # Specifically expect hard_directional and practical_source signals
        if "motivated lighting" not in prompt_lower:
            r1.fail(
                "'motivated lighting' not found — action shot has hard "
                "directional neon source, should trigger hard_directional signal"
            )

    except Exception as e:
        r1.fail(f"Builder raised exception: {e}")
    results.append(r1)

    # ── Test 2: Warm candle source → warm_color_temp signal ──
    r2 = TestResult("Lighting anchors — warm candle source")
    try:
        warm_shot = {
            "shot_id": "PHASE2_WARM_TEST",
            "prompt_data": {
                "prompt_skeleton": {
                    "subject_line": "A man sits alone at a wooden table in a dark room",
                    "environment_line": "Sparse room with stone walls, single candle on the table",
                    "emotion_line": "Contemplative expression, eyes downcast",
                    "action_line": "He lifts a glass slowly to his lips",
                },
                "shot_type": "MS",
                "camera_movement": "static",
                "kinetic_action": "lifts a glass slowly to his lips",
                "focal_length": "50mm",
                "lighting": {
                    "sources": [
                        {
                            "motivator": "candle",
                            "direction": "below",
                            "quality": "soft",
                            "color_temp": "warm",
                        },
                    ],
                },
            },
            "asset_data": {
                "characters": [],
                "location_id": "dark-room",
            },
            "routing_data": {"target_editorial_duration_s": 5},
        }

        warm_bible = {
            "characters": {},
            "locations": {
                "dark-room": {
                    "display_name": "Dark Room",
                    "spatial_description": "Sparse room with stone walls",
                },
            },
        }

        prompt = build_seeddance_t2v_prompt(
            shot=warm_shot,
            bible=warm_bible,
            project_config=MOCK_PROJECT_CONFIG,
            episode=1,
        )

        if verbose:
            wc = len(prompt.split())
            print(f"\n  --- Lighting Anchors [warm candle] ({wc} words) ---")
            print(f"  {prompt}")
            print("  --- end ---\n")

        prompt_lower = prompt.lower()
        # Warm candle: soft quality, warm color_temp, named motivator
        # Signals: warm_color_temp → "warm tungsten bounce"
        #          practical_source → "practical light sources visible in frame"
        if "warm tungsten bounce" not in prompt_lower:
            r2.fail(
                "'warm tungsten bounce' not found — warm candle source "
                "should trigger warm_color_temp signal"
            )

    except Exception as e:
        r2.fail(f"Builder raised exception: {e}")
    results.append(r2)

    return results


def test_film_stock_default(verbose: bool = False) -> list[TestResult]:
    """Test film stock default from PROMPT_BIBLE and project config override.

    Verifies:
    1. When project_config has no film_stock, bible default is used
    2. When project_config has film_stock, it overrides the bible default
    """
    from recoil.pipeline._lib.prompt_engine import build_seeddance_t2v_prompt

    results = []

    # ── Test 1: Empty config → bible default ──
    r1 = TestResult("Film stock — bible default when config empty")
    try:
        empty_config = {"allow_music": False}
        prompt = build_seeddance_t2v_prompt(
            shot=MOCK_SHOT,
            bible=MOCK_BIBLE,
            project_config=empty_config,
            episode=1,
        )

        if verbose:
            wc = len(prompt.split())
            print(f"\n  --- Film Stock [default] ({wc} words) ---")
            print(f"  {prompt}")
            print("  --- end ---\n")

        # PROMPT_BIBLE film_stock_default is "Kodak Vision3 500T"
        if "Kodak Vision3 500T" not in prompt:
            r1.fail(
                "Bible default film stock 'Kodak Vision3 500T' not found "
                "in prompt — _get_seeddance_film_stock() should fall back "
                "to PROMPT_BIBLE.film_stock_default when project_config is empty"
            )

    except Exception as e:
        r1.fail(f"Builder raised exception: {e}")
    results.append(r1)

    # ── Test 2: Project config override ──
    r2 = TestResult("Film stock — project config override")
    try:
        override_config = {
            "film_stock": "ARRI Alexa, Kodak 2383 print film",
            "allow_music": False,
        }
        prompt = build_seeddance_t2v_prompt(
            shot=MOCK_SHOT,
            bible=MOCK_BIBLE,
            project_config=override_config,
            episode=1,
        )

        if verbose:
            wc = len(prompt.split())
            print(f"\n  --- Film Stock [override] ({wc} words) ---")
            print(f"  {prompt}")
            print("  --- end ---\n")

        if "ARRI Alexa" not in prompt:
            r2.fail(
                "Project config film stock 'ARRI Alexa, Kodak 2383 print film' "
                "not found in prompt — project_config.film_stock should override "
                "the bible default"
            )

        # Make sure the bible default is NOT also present (no double stock)
        if "Kodak Vision3 500T" in prompt:
            r2.fail(
                "Bible default 'Kodak Vision3 500T' found alongside project "
                "override — _get_seeddance_film_stock() should return ONLY "
                "the project config value when set"
            )

    except Exception as e:
        r2.fail(f"Builder raised exception: {e}")
    results.append(r2)

    return results


def test_single_verb_enforcement(verbose: bool = False) -> list[TestResult]:
    """Test that compound actions are simplified to single-verb form.

    Verifies _enforce_single_verb_action() behavior:
    1. Compound with ', and ' — splits at marker, keeps first clause
    2. Compound with ', then ' — splits at marker, keeps first clause
    3. Simple action — passes through unchanged
    4. Non-compound 'and' (within a clause) — passes through unchanged
    """
    from recoil.pipeline._lib.prompt_engine import _enforce_single_verb_action

    results = []

    # ── Test 1: Compound with ', and ' ──
    r1 = TestResult("Single verb — compound ', and ' split")
    try:
        input_action = "She turns on her heel, and strides toward the fire escape"
        result = _enforce_single_verb_action(input_action)

        if verbose:
            print("\n  --- Single Verb [', and '] ---")
            print(f"  Input:  {input_action}")
            print(f"  Output: {result}")
            print("  --- end ---\n")

        # Should keep only the first clause
        if ", and " in result:
            r1.fail(
                f"Compound marker ', and ' still present in output: '{result}' — "
                f"_enforce_single_verb_action should split at the marker"
            )
        if "strides" in result:
            r1.fail(
                f"Second clause 'strides toward...' found in output: '{result}' — "
                f"should be trimmed to first clause only"
            )
        if "turns" not in result:
            r1.fail(
                f"First clause verb 'turns' missing from output: '{result}' — "
                f"first clause should be preserved"
            )

    except Exception as e:
        r1.fail(f"Function raised exception: {e}")
    results.append(r1)

    # ── Test 2: Compound with ', then ' ──
    r2 = TestResult("Single verb — compound ', then ' split")
    try:
        input_action = "He ducks under the tarp, then pauses at the gap"
        result = _enforce_single_verb_action(input_action)

        if verbose:
            print("\n  --- Single Verb [', then '] ---")
            print(f"  Input:  {input_action}")
            print(f"  Output: {result}")
            print("  --- end ---\n")

        if ", then " in result:
            r2.fail(
                f"Compound marker ', then ' still present in output: '{result}'"
            )
        if "pauses" in result:
            r2.fail(
                f"Second clause 'pauses at...' found in output: '{result}'"
            )
        if "ducks" not in result:
            r2.fail(
                f"First clause verb 'ducks' missing from output: '{result}'"
            )

    except Exception as e:
        r2.fail(f"Function raised exception: {e}")
    results.append(r2)

    # ── Test 3: Simple action — unchanged ──
    r3 = TestResult("Single verb — simple action unchanged")
    try:
        input_action = "She sprints across the rooftop"
        result = _enforce_single_verb_action(input_action)

        if verbose:
            print("\n  --- Single Verb [simple] ---")
            print(f"  Input:  {input_action}")
            print(f"  Output: {result}")
            print("  --- end ---\n")

        if result != input_action:
            r3.fail(
                f"Simple action was modified: '{input_action}' → '{result}' — "
                f"should pass through unchanged"
            )

    except Exception as e:
        r3.fail(f"Function raised exception: {e}")
    results.append(r3)

    # ── Test 4: Non-compound 'and' in a clause — unchanged ──
    r4 = TestResult("Single verb — non-compound 'and' passes through")
    try:
        input_action = "She grabs the rope and pulls herself up"
        result = _enforce_single_verb_action(input_action)

        if verbose:
            print("\n  --- Single Verb [non-compound 'and'] ---")
            print(f"  Input:  {input_action}")
            print(f"  Output: {result}")
            print("  --- end ---\n")

        # The marker is ", and " (with comma), not " and " (without comma)
        # So "rope and pulls" should NOT trigger the split
        if result != input_action:
            r4.fail(
                f"Non-compound 'and' was incorrectly split: "
                f"'{input_action}' → '{result}' — "
                f"only ', and ' (with comma) should trigger split"
            )

    except Exception as e:
        r4.fail(f"Function raised exception: {e}")
    results.append(r4)

    return results


def main():
    parser = argparse.ArgumentParser(
        description="Dry-run test for SeedDance prompt builders",
    )
    parser.add_argument(
        "--plan",
        help="Path to a real plan file (JSON). Uses first shot. Falls back to embedded mock.",
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Print full prompts to console",
    )
    args = parser.parse_args()

    print("=" * 60)
    print("SeedDance Prompt Builder Tests")
    print("=" * 60)

    # Load shot data
    if args.plan:
        print(f"\nLoading plan: {args.plan}")
        shot = _load_plan_shot(args.plan)
        bible = MOCK_BIBLE  # Real plan files don't bundle the bible
        config = MOCK_PROJECT_CONFIG
    else:
        print("\nUsing embedded mock shot data")
        shot = MOCK_SHOT
        bible = MOCK_BIBLE
        config = MOCK_PROJECT_CONFIG

    # ── Unit tests (Phase 3) ──
    print("\n--- Unit Tests (single shot) ---")
    results = []
    results.append(test_t2v(shot, bible, config, verbose=args.verbose))
    results.append(test_i2v(shot, config, verbose=args.verbose))
    results.append(test_r2v(shot, bible, config, verbose=args.verbose))

    # ── Integration tests (Phase 4) ──
    print("\n--- Integration Tests (5 scenarios) ---")
    integration_results = test_integration(verbose=args.verbose)
    results.extend(integration_results)

    # ── Phase 2 tests ──
    print("\n--- Phase 2 Tests (lighting, film stock, action enforcement) ---")
    results.extend(test_lighting_anchors(verbose=args.verbose))
    results.extend(test_film_stock_default(verbose=args.verbose))
    results.extend(test_single_verb_enforcement(verbose=args.verbose))

    # Print results
    print("\nResults:")
    all_passed = True
    for r in results:
        print(r)
        if not r.passed:
            all_passed = False

    # Summary
    passed = sum(1 for r in results if r.passed)
    total = len(results)
    print(f"\n{passed}/{total} tests passed")

    if all_passed:
        print("\nAll SeedDance builders validated.")
        sys.exit(0)
    else:
        print("\nSome tests FAILED.")
        sys.exit(1)


if __name__ == "__main__":
    main()
