"""
Pydantic models for the Starsend Render Extraction Pipeline.

Three schema groups:
  1. Camera-Test output (Stage 0) — shot boundary detection
  2. Global Bible (Stage 1) — characters, locations, props, lighting motifs
  3. Episode Plan (Stage 2) — per-shot render records with 5 consumer groups

These models serve as:
  - Input validation for LLM extraction output
  - Gemini response_schema enforcement (via .model_json_schema())
  - Runtime type safety for all downstream consumers

Phase A MF-6 schema_version audit (2026-04-30):
  PERSISTED to disk (have schema_version stamped, default=1):
    * CameraTestedEpisode  — saved at state/visual/camera_tested/ep_NNN.json
                             (orchestrator/ingest_pipeline.py::_save_json)
    * GlobalBible          — saved at state/visual/global_bible.json
                             (orchestrator/ingest_pipeline.py::_save_json)
    * EpisodePlan          — saved at state/visual/plans/ep_NNN_plan.json
                             (orchestrator/ingest_pipeline.py::_save_json,
                              via result.model_dump(mode="json") so the field
                              propagates through the dict-write path)
    * SceneBlockingDocument — saved at state/visual/blocking/ep_NNN/
                              scene_NNN_sbd.json
                              (orchestrator/blocking_pass.py::_save_sbd)

  SKIPPED — in-memory-only intermediate types, never reach disk as the
  outermost JSON document (they appear only as nested fields inside the
  four persisted classes above; their parent's schema_version covers them):
    * _LenientEnum bases (ShotType, CameraMovement, Visibility,
      VisualMode, TimeOfDay, LightDirection, LightQuality,
      LightColorTemp, LightIntensity, ScreenDirection,
      InteractionType, ScreenPosition, PropInteraction)
    * CameraTestedShot       (nested in CameraTestedEpisode.shots)
    * CharacterPhase         (nested in BibleCharacter.phases)
    * BibleCharacter         (nested in GlobalBible.characters)
    * LocationLightingProfile (nested in BibleLocation.lighting_profile)
    * BibleLocation          (nested in GlobalBible.locations)
    * BibleProp              (nested in GlobalBible.props)
    * LightingMotif          (nested in GlobalBible.lighting_motifs)
    * RoutingData, LightSource, Lighting, PromptSkeleton, PromptData,
      CharacterRelationships, SpatialData, ShotCharacter, ShotProp,
      AssetData, DialogueLine, AudioData
                             (all nested inside ShotRecord)
    * HandState, CharacterBlocking
                             (nested in BlockingMetadata)
    * BlockingMetadata       (nested in ShotRecord.blocking_metadata)
    * PropTransition, PropLedgerEntry, GazeSequenceEntry,
      CoverageAnchor        (nested in SceneBlockingDocument)
    * ShotRecord             (nested in EpisodePlan.shots)

  SKIPPED — handoff / request / response types that flow over a function
  call boundary and are validated then discarded, never persisted:
    * HandoffCharacter       (Recoil → Starsend boundary validator;
                              built by validate_handoff(), consumed in
                              memory, never written to disk)
    * ComposeShotRequest     (Shot Composer HTTP request body;
                              POSTed in, never persisted as a top-level
                              JSON document)
    * ComposeShotResponse    (Shot Composer HTTP response body;
                              returned to client, never persisted as a
                              top-level JSON document. The new shot it
                              wraps is then merged into an EpisodePlan,
                              which IS persisted and IS versioned.)

  This audit is the single source of truth for which 4 classes carry
  schema_version vs the 30 that are nested-only. ScreenTestState (a
  fifth persisted class) lives in pipeline/lib/screen_test.py and is
  versioned in that file.
"""

from __future__ import annotations

import re
from datetime import datetime, timezone
from enum import Enum
from typing import Literal, Optional

from pydantic import BaseModel, Field, field_validator, model_validator

from recoil.pipeline._lib.schema_versions import RENDER_SCHEMA_VERSION


# ---------------------------------------------------------------------------
# Enums — all use _LenientEnum to accept LLM free-text and coerce to defaults
# ---------------------------------------------------------------------------


class _LenientEnum(str, Enum):
    """Base for enums that may receive free-text from LLMs.

    When Gemini generates a value not in the enum (e.g., 'character_to_environment'
    instead of 'solo'), _missing_() returns the first enum member as a safe default
    rather than raising a validation error that blocks the pipeline.
    """

    @classmethod
    def _missing_(cls, value):
        # Try case-insensitive match first
        if isinstance(value, str):
            for member in cls:
                if member.value.lower() == value.lower():
                    return member
            # Try substring match (e.g., 'dialogue' in 'character_dialogue')
            for member in cls:
                if member.value.lower() in value.lower():
                    return member
        # Fall back to first member
        return list(cls)[0]


class ShotType(_LenientEnum):
    WS = "WS"
    LS = "LS"
    FS = "FS"
    MS = "MS"
    MCU = "MCU"
    CU = "CU"
    ECU = "ECU"
    INSERT = "INSERT"


class CameraMovement(_LenientEnum):
    STATIC = "static"
    PAN = "pan"
    TILT = "tilt"
    PUSH_IN = "push_in"
    PULL_BACK = "pull_back"
    TRACKING = "tracking"
    CRANE = "crane"
    HANDHELD = "handheld"
    STEADICAM = "steadicam"
    DOLLY = "dolly"


class Visibility(_LenientEnum):
    IN_FRAME = "in_frame"
    OFF_SCREEN = "off_screen"


class VisualMode(_LenientEnum):
    REALITY = "reality"
    FLASHBACK = "flashback"
    FANTASY = "fantasy"
    MEMORY = "memory"
    SIMULATION = "simulation"


class TimeOfDay(_LenientEnum):
    DAY = "day"
    NIGHT = "night"
    DAWN = "dawn"
    DUSK = "dusk"
    INTERIOR = "interior"
    SPACE = "space"
    VOID = "void"
    UNDERWATER = "underwater"


class LightDirection(_LenientEnum):
    ABOVE = "ABOVE"
    BELOW = "BELOW"
    LEFT = "LEFT"
    RIGHT = "RIGHT"
    ABOVE_LEFT = "ABOVE_LEFT"
    ABOVE_RIGHT = "ABOVE_RIGHT"
    BELOW_LEFT = "BELOW_LEFT"
    BELOW_RIGHT = "BELOW_RIGHT"
    BEHIND = "BEHIND"
    FRONT = "FRONT"
    FROM_SUBJECT = "FROM_SUBJECT"
    SELF_ILLUMINATED = "SELF_ILLUMINATED"


class LightQuality(_LenientEnum):
    HARD = "hard"
    SOFT = "soft"
    MIXED = "mixed"


class LightColorTemp(_LenientEnum):
    WARM = "warm"
    COOL = "cool"
    AMBER = "amber"
    BLUE = "blue"
    NEUTRAL = "neutral"
    MIXED = "mixed"


class LightIntensity(_LenientEnum):
    DIM = "dim"
    MODERATE = "moderate"
    BRIGHT = "bright"
    BLINDING = "blinding"


class ScreenDirection(_LenientEnum):
    LEFT_TO_RIGHT = "left-to-right"
    RIGHT_TO_LEFT = "right-to-left"
    CENTER = "center"
    TOWARD_CAMERA = "toward-camera"
    AWAY_FROM_CAMERA = "away-from-camera"


class InteractionType(_LenientEnum):
    SOLO = "solo"
    DIALOGUE = "dialogue"
    CONFRONTATION = "confrontation"
    GROUP = "group"


class ScreenPosition(_LenientEnum):
    LEFT = "left"
    LEFT_FOREGROUND = "left_foreground"
    FOREGROUND_LEFT = "foreground_left"
    RIGHT_FOREGROUND = "right_foreground"
    FOREGROUND_RIGHT = "foreground_right"
    CENTER = "center"
    RIGHT = "right"
    FOREGROUND = "foreground"
    BACKGROUND = "background"


class PropInteraction(_LenientEnum):
    """ADR-P06: Prop interaction level — 'manipulated' forces still pipeline."""
    NONE = "none"
    STATIC = "static"
    MANIPULATED = "manipulated"

    @classmethod
    def _missing_(cls, value):
        """Coerce unknown values: if it describes interaction, use 'manipulated', else 'static'."""
        if isinstance(value, str):
            v = value.lower()
            if any(w in v for w in ("reveal", "open", "pull", "push", "grab", "touch", "manipulat", "use", "hold")):
                return cls.MANIPULATED
            return cls.STATIC
        return cls.NONE


# ---------------------------------------------------------------------------
# Stage 0: Camera-Test Output
# ---------------------------------------------------------------------------

class CameraTestedShot(BaseModel):
    """A single shot boundary identified by the Camera-Test LLM."""
    shot_index: int = Field(..., ge=1, description="1-based shot index")
    scene_index: int = Field(..., ge=1, description="Groups shots into narrative scenes/beats. LLM assigns based on narrative boundaries, not just location changes.")
    source_text: str = Field(..., min_length=1, description="Original script text for this shot")
    has_dialogue: bool = Field(default=False, description="Whether this shot contains character dialogue")
    characters_mentioned: list[str] = Field(default_factory=list, description="Character names appearing in this shot")
    location_hint: Optional[str] = Field(default=None, description="Scene heading if one precedes this shot")


class CameraTestedEpisode(BaseModel):
    """Output of Stage 0: Camera-Test Pass for a single episode."""
    schema_version: int = Field(
        default=RENDER_SCHEMA_VERSION,
        description="Persisted-shape schema version. Bump on breaking change.",
    )
    episode_id: str = Field(..., pattern=r"^EP\d{3}$", description="Episode ID, e.g. EP001")
    project: str = Field(..., description="Project identifier, e.g. leviathan")
    total_shots: int = Field(..., ge=1, description="Total shots in this episode")
    shots: list[CameraTestedShot] = Field(..., min_length=1)

    @field_validator("shots")
    @classmethod
    def validate_shot_count(cls, v, info):
        if "total_shots" in info.data and len(v) != info.data["total_shots"]:
            raise ValueError(f"total_shots={info.data['total_shots']} but got {len(v)} shots")
        return v

    @field_validator("shots")
    @classmethod
    def validate_shot_indices(cls, v):
        for i, shot in enumerate(v, 1):
            if shot.shot_index != i:
                raise ValueError(f"Shot {i} has shot_index={shot.shot_index}, expected {i}")
        return v


# ---------------------------------------------------------------------------
# Stage 1: Global Bible
# ---------------------------------------------------------------------------

class CharacterPhase(BaseModel):
    """A wardrobe/appearance phase for a character across a range of episodes."""
    phase_id: str = Field(..., description="Unique phase ID, e.g. jinx_lower_deck_salvager")
    start_ep: int = Field(..., ge=1, description="First episode of this phase")
    end_ep: int = Field(..., ge=1, description="Last episode of this phase")
    phase_trigger_event: str = Field(default="", description="Why the phase changed, e.g. 'Survived explosion in Ep 12'")
    wardrobe_description: str = Field(..., description="Full wardrobe description for this phase")
    wardrobe_arc_delta: str = Field(default="", description="Structured delta: + added, - removed, ~ modified items with narrative reasons")
    wardrobe_arc_carries: str = Field(default="", description="Items that carry forward unchanged from previous phase")
    hair_makeup: str = Field(default="", description="Hair and makeup notes")
    distinguishing_marks: str = Field(default="", description="Scars, injuries, accumulated damage")
    trigger: Optional["PhaseTrigger"] = Field(
        default=None,
        description="Additive L9 trigger block for script-event or episode-range wardrobe/appearance changes.",
    )
    appearance: Optional["PhaseAppearance"] = Field(
        default=None,
        description="Additive L9 structured appearance block; legacy prose fields remain operative for live readers.",
    )


class IdentityInvariants(BaseModel):
    """Never-changing visual identity traits for a character."""
    hair_color: str
    eye_color: str
    skin_tone: str
    build: str
    distinguishing: list[str] = Field(default_factory=list)


class PhaseTrigger(BaseModel):
    """Evidence-backed trigger for a character appearance phase."""
    type: Literal["script_event", "ep_range"]
    scene_ref: str
    description: str
    evidence_hash: str


class WardrobePiece(BaseModel):
    """Structured wardrobe piece state inside a phase appearance."""
    piece: str
    descriptor: str
    state: Literal["worn", "removed", "damaged", "torn"]
    salient: bool


class PhaseAppearance(BaseModel):
    """Structured appearance state for a character phase."""
    wardrobe: list[WardrobePiece] = Field(default_factory=list)
    hair_state: Literal["loose", "tucked", "tied", "covered"]
    visible_gear: list[str] = Field(default_factory=list)
    notable_marks: list[str] = Field(default_factory=list)


class BibleCharacter(BaseModel):
    """A character entry in the Global Bible."""
    char_id: str = Field(..., description="Canonical character ID, e.g. JINX")
    display_name: str = Field(..., description="Human-readable name, e.g. Jinx")
    visual_description: str = Field(..., description="Core visual identity description")
    height_cm: Optional[int] = Field(default=None, description="Character height in cm")
    scale_prompt_fragment: Optional[str] = Field(default=None, description="Relative scale for prompts")
    wardrobe_arc_thesis: str = Field(default="", description="One-sentence thesis for the wardrobe arc progression")
    wardrobe_arc_thesis_approved: bool = Field(default=False, description="Whether the director has approved this thesis")
    wardrobe_arc_thesis_source: str = Field(default="auto", description="How the thesis was set: auto|director|edited")
    wardrobe_arc_vision: str = Field(default="", description="Director's free-text vision notes for this character's wardrobe")
    phases: list[CharacterPhase] = Field(default_factory=list, description="Wardrobe/appearance phases")
    episodes: list[int] = Field(default_factory=list, description="All episodes this character appears in")
    identity_invariants: Optional[IdentityInvariants] = Field(
        default=None,
        description="Additive L9 never-changing visual identity block.",
    )
    transients: Optional[list[str]] = Field(
        default=None,
        description="Optional declared transient visual states resolved alongside phase appearance.",
    )

    def phase_for_episode(self, ep: int) -> Optional[CharacterPhase]:
        """Get the active phase for a given episode number."""
        for phase in self.phases:
            if phase.start_ep <= ep <= phase.end_ep:
                return phase
        return None


class LocationLightingProfile(BaseModel):
    """Default lighting profile for a location."""
    primary_source: str = Field(..., description="Primary practical light source")
    direction: LightDirection = Field(default=LightDirection.ABOVE)
    quality: LightQuality = Field(default=LightQuality.HARD)
    color_temp: LightColorTemp = Field(default=LightColorTemp.AMBER)


class BibleLocation(BaseModel):
    """A location entry in the Global Bible."""
    location_id: str = Field(..., description="Canonical location ID, e.g. int_leviathan_lower_deck")
    habitat_zone: str = Field(default="", description="Super-category zone, e.g. Lower Decks, The Root")
    aliases: list[str] = Field(default_factory=list, description="Original screenplay scene headings mapped to this location")
    description: str = Field(..., description="Full location description")
    lighting_profile: Optional[LocationLightingProfile] = Field(default=None)
    color_palette: list[str] = Field(default_factory=list, description="HEX color codes")
    atmosphere: str = Field(default="", description="Atmospheric/environmental notes")
    sublocations: Optional[dict[str, "BibleSublocation"]] = Field(
        default=None,
        description="Sublocation names and semantic descriptions; geometry/adjacency stays in location.json.",
    )


class BibleSublocation(BaseModel):
    """A named semantic sublocation owned by the bible."""
    description: str


class PropState(BaseModel):
    """Operative visual state for a prop."""
    description: str
    visual_delta: str


class BiblePropTransition(BaseModel):
    """Directed transition between prop states."""
    from_state: str = Field(..., alias="from")
    to: str
    reversible: bool
    trigger_scene: Optional[str] = None


class BibleProp(BaseModel):
    """A recurring prop in the Global Bible."""
    prop_id: str = Field(..., description="Canonical prop ID, e.g. salvage_hook")
    description: str = Field(..., description="Visual description of the prop")
    state_notes: str = Field(default="", description="Annotation only; operative state changes live in states/transitions.")
    associated_characters: list[str] = Field(default_factory=list, description="Character IDs that use this prop")
    episodes: list[int] = Field(default_factory=list, description="Episodes where this prop appears")
    # ADR-P03: Permanent props baked into identity
    is_permanent_attachment: bool = Field(default=False, description="If True, baked into character identity refs (Stack B)")
    attached_to: Optional[str] = Field(default=None, description="Character ID this prop is permanently attached to")
    # ADR-P05: Multiple prop states
    states: dict[str, PropState | str] = Field(default_factory=dict, description="state_id → L9 state block; legacy string descriptions still parse")
    initial_state: str = Field(default="", description="Initial state_id for the operative prop state machine")
    transitions: list["BiblePropTransition"] = Field(default_factory=list, description="Allowed prop state transitions")
    carriable: bool = Field(default=False, description="Whether the prop can be carried by a character")


class LightingMotif(BaseModel):
    """A recurring lighting motif across the series."""
    motif_id: str = Field(..., description="Motif identifier, e.g. debt_counter_amber")
    description: str = Field(..., description="What this motif represents visually and thematically")
    color_temp: LightColorTemp = Field(default=LightColorTemp.AMBER)
    associated_locations: list[str] = Field(default_factory=list, description="Location IDs")
    associated_characters: list[str] = Field(default_factory=list, description="Character IDs")


class GlobalBible(BaseModel):
    """Output of Stage 1: Breakdown Pass — the canonical visual reference for the entire series."""
    schema_version: int = Field(
        default=RENDER_SCHEMA_VERSION,
        description="Persisted-shape schema version. Bump on breaking change.",
    )
    structural_analysis: str = Field(default="", description="Gemini CoT scratchpad — timeline calculations, dedup logic, phase checks. Stripped before Pass 2.")
    project: str = Field(..., description="Project identifier")
    total_episodes: int = Field(..., ge=1)
    generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    wardrobe_philosophy: str = Field(default="", description="Series-level wardrobe philosophy — what clothing means in this world")
    wardrobe_philosophy_approved: bool = Field(default=False, description="Whether the director has approved the wardrobe philosophy")
    characters: dict[str, BibleCharacter] = Field(default_factory=dict, description="Keyed by char_id")
    locations: dict[str, BibleLocation] = Field(default_factory=dict, description="Keyed by location_id")
    props: dict[str, BibleProp] = Field(default_factory=dict, description="Keyed by prop_id")
    lighting_motifs: list[LightingMotif] = Field(default_factory=list)


# ---------------------------------------------------------------------------
# Stage 2: Episode Plan — Shot Record Consumer Groups
# ---------------------------------------------------------------------------

class RoutingData(BaseModel):
    """Consumer: scene_planner.py, pipeline.py — determines render strategy."""
    target_editorial_duration_s: int = Field(..., ge=1, le=30, description="Target editorial duration in seconds")
    has_dialogue: bool = Field(default=False)
    camera_complexity: CameraMovement = Field(default=CameraMovement.STATIC, description="Dominant camera movement")
    num_characters: int = Field(..., ge=0, description="Number of characters in frame")
    is_env_only: bool = Field(default=False, description="True if no characters in shot")
    narrative_requires_match_cut: bool = Field(default=False, description="True if shot needs first+last frame")


class LightSource(BaseModel):
    """A single practical light source in the scene."""
    motivator: str = Field(..., description="What's emitting the light, e.g. emergency strip light")
    direction: LightDirection = Field(default=LightDirection.ABOVE)
    quality: LightQuality = Field(default=LightQuality.HARD)
    color_temp: LightColorTemp = Field(default=LightColorTemp.AMBER)
    intensity: LightIntensity = Field(default=LightIntensity.MODERATE)


class Lighting(BaseModel):
    """Complete lighting specification for a shot."""
    dominant_source_index: int = Field(default=0, ge=0, description="Index into sources[] for the primary light")
    sources: list[LightSource] = Field(..., min_length=1)


class PromptSkeleton(BaseModel):
    """Creative prompt components — LLM generates these, code adds technical."""
    subject_line: str = Field(
        ...,
        description=(
            "Physical blocking: character name + static body pose (posture, hand placement, "
            "gaze target, prop contact). Freeze-frame geometry — no appearance or wardrobe. "
            "For ENV shots: focal element and spatial position."
        ),
    )
    environment_line: str = Field(..., description="Location details, production design")
    action_line: str = Field(..., description="Kinetic micro-details, camera-aware descriptions")
    motion_line: Optional[str] = Field(default=None, description="Temporal action from shot start to shot end")
    emotion_line: str = Field(..., description="Emotional tone and character state")


# ---------------------------------------------------------------------------
# Stage 2 (creative-only): minimal LLM output, assembled into EpisodePlan
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Scene axis plan (180-degree line) — LLM-authored scene-level editorial geometry.
# Per-shot SpatialData.screen_direction/camera_side are DERIVED from this by
# orchestrator/axis_propagation.propagate_axis (REC-180). Plain str-Enums (NOT
# _LenientEnum) so a bad kind fails loud instead of silently coercing to a member.
# Strict by design (data integrity): a genuinely invalid enum string makes CreativeEpisodeOutput
# parsing raise. The storyboard retry loop recovers from this on the final attempt by dropping
# axis_plans and re-parsing (neutral spatial derivation), so a bad axis value never caps the
# episode (ingest_pipeline.run_storyboard_pass); semantically-invalid-but-parseable plans degrade
# earlier via sanitize_axis_plans.
# ---------------------------------------------------------------------------

class AxisKind(str, Enum):
    DIALOGUE = "dialogue"
    MOTION = "motion"
    GEOGRAPHY = "geography"
    GROUP = "group"
    NEUTRAL = "neutral"


class AxisTransitionKind(str, Enum):
    INTENTIONAL_JUMP = "intentional_jump"
    RE_ESTABLISH = "re_establish"
    NEUTRAL_PIVOT = "neutral_pivot"


class CutRelation(str, Enum):
    SCENE_OPEN = "scene_open"
    CONSISTENT = "consistent"
    INTENTIONAL_JUMP = "intentional_jump"
    RE_ESTABLISH = "re_establish"
    NEUTRAL_PIVOT = "neutral_pivot"


class AxisAnchor(BaseModel):
    kind: AxisKind = Field(..., description="What defines the 180-degree line")
    subjects: list[str] = Field(
        default_factory=list,
        description="Character names/ids defining the line, drawn from that SCENE's chars "
                    "across its input blocks (not a single shot). Scene-level editorial "
                    "geometry — distinct from per-shot asset references.",
    )
    reference_subject: Optional[str] = Field(
        default=None, description="Whose screen direction anchors camera side A"
    )
    reference_direction: ScreenDirection = Field(
        default=ScreenDirection.CENTER,
        description="Screen direction of the reference subject on side A (travel/relationship vector)",
    )
    description: str = Field(default="", description="One-line line-of-action")


class AxisTransition(BaseModel):
    before_shot_index: int = Field(
        ...,
        description="1-based shot_index (matches CreativeShot.shot_index) at/before which "
                    "the axis changes, within this scene. Out-of-range values are caught "
                    "semantically by validate_axis_plans (sanitizable), not by a ge= constraint "
                    "that would Pydantic-abort the whole creative output before the degrade path.",
    )
    kind: AxisTransitionKind
    # reason is a plain str (no min_length) so an empty value is caught by
    # validate_axis_plans (sanitizable / retry-able) rather than raising in Pydantic
    # model validation BEFORE the axis validator can run.
    reason: str = Field(default="", description="Why this break is intentional — must be non-empty (enforced by validate_axis_plans)")
    new_anchor: Optional[AxisAnchor] = Field(
        default=None, description="REQUIRED for re_establish; omit for intentional_jump/neutral_pivot"
    )


class SceneAxisPlan(BaseModel):
    initial_anchor: AxisAnchor
    transitions: list[AxisTransition] = Field(default_factory=list)


class CreativeShot(BaseModel):
    """The genuinely-creative, LLM-judged fields for ONE shot.

    Everything else in ShotRecord is copied from the matching
    CameraTestedShot, derived, or defaulted by the Python assembly step.
    Keyed back to the camera-tested shot by `shot_index`.
    """
    shot_index: int = Field(..., ge=1, description="1-based index; MUST match the CameraTestedShot it corresponds to")
    prompt_skeleton: PromptSkeleton = Field(..., description="The 5 creative prompt lines")
    shot_type: ShotType = Field(..., description="WS/LS/FS/MS/MCU/CU/ECU/INSERT")
    camera_movement: CameraMovement = Field(default=CameraMovement.STATIC)
    kinetic_action: str = Field(default="", description="Frozen-frame camera-artifact language")
    target_editorial_duration_s: int = Field(..., ge=1, le=30)
    narrative_requires_match_cut: bool = Field(default=False)
    light_motivator: str = Field(
        default="",
        description="What physically emits the dominant light, e.g. 'emergency strip light'. "
                    "If empty, assembly falls back to the bible location lighting_profile.",
    )


class CreativeEpisodeOutput(BaseModel):
    """Output of the creative-only Stage 2 LLM pass (one episode)."""
    episode_id: str = Field(..., pattern=r"^EP\d{3}$")
    total_shots: int = Field(..., ge=1)
    shots: list[CreativeShot] = Field(..., min_length=1)
    axis_plans: dict[int, SceneAxisPlan] = Field(
        default_factory=dict,
        description="Scene-level 180-line plans keyed by scene_index. LLM-authored.",
    )

    @field_validator("shots")
    @classmethod
    def validate_shot_count(cls, v, info):
        if "total_shots" in info.data and len(v) != info.data["total_shots"]:
            raise ValueError(f"total_shots={info.data['total_shots']} but got {len(v)} shots")
        return v


class PromptData(BaseModel):
    """Consumer: prompt_engine.py — builds the final generation prompt."""
    shot_type: ShotType = Field(...)
    camera_movement: CameraMovement = Field(default=CameraMovement.STATIC)
    focal_length: str = Field(default="50mm", description="Lens focal length, e.g. 50mm, 85mm")
    kinetic_action: str = Field(default="", description="Motion-blur-level kinetic descriptors")
    lighting: Lighting = Field(...)
    prompt_skeleton: PromptSkeleton = Field(...)


class CharacterRelationships(BaseModel):
    """Spatial relationships between characters in the frame."""
    interaction_type: InteractionType = Field(default=InteractionType.SOLO)
    dominant_character: Optional[str] = Field(default=None, description="Character ID with screen dominance")
    relative_scale: str = Field(default="standard", description="e.g. standard, looming, diminished")
    shared_lighting: bool = Field(default=True, description="Whether characters share the same light source")


class SpatialData(BaseModel):
    """Consumer: prompt_engine.py (two-character shots) — spatial blocking.

    camera_side/screen_direction are DERIVED by axis_propagation.propagate_axis from
    the scene's SceneAxisPlan (REC-180); the axis_* fields carry the derivation provenance.
    """
    camera_side: str = Field(default="A", description="DERIVED: side of the active scene axis ('A'/'B')")
    screen_direction: ScreenDirection = Field(default=ScreenDirection.CENTER, description="DERIVED from the active anchor + side")
    character_relationships: CharacterRelationships = Field(default_factory=CharacterRelationships)
    axis_segment_id: int = Field(default=0, description="DERIVED: scene-local segment index after transitions")
    cut_relation: CutRelation = Field(default=CutRelation.SCENE_OPEN, description="DERIVED: axis relation to the previous shot")
    axis_transition_reason: Optional[str] = Field(default=None, description="DERIVED: reason carried from the transition at this shot")


class ShotCharacter(BaseModel):
    """A character appearing in a specific shot."""
    char_id: str = Field(..., description="Must match a key in GlobalBible.characters")
    wardrobe_phase_id: str = Field(..., description="Must match a CharacterPhase.phase_id")
    emotion_keyword: str = Field(default="neutral", description="Primary emotion for this shot")
    screen_position: ScreenPosition = Field(default=ScreenPosition.CENTER)
    visibility: Visibility = Field(default=Visibility.IN_FRAME)


class ShotProp(BaseModel):
    """A prop appearing in a specific shot.

    Accepts bare strings (e.g., 'loose panel') from LLM output and
    coerces them into proper ShotProp objects with snake_case prop_id.
    """
    prop_id: str = Field(..., description="Must match a key in GlobalBible.props")
    visibility: Visibility = Field(default=Visibility.IN_FRAME)

    @model_validator(mode="before")
    @classmethod
    def _coerce_from_string(cls, v):
        if isinstance(v, str):
            return {"prop_id": v.lower().replace(" ", "_")}
        return v


class AssetData(BaseModel):
    """Consumer: asset_manager.py — reference image loading and caching."""
    location_id: str = Field(..., description="Must match a key in GlobalBible.locations")
    time_of_day: TimeOfDay = Field(default=TimeOfDay.INTERIOR)
    visual_mode: VisualMode = Field(default=VisualMode.REALITY)
    characters: list[ShotCharacter] = Field(default_factory=list)
    props: list[ShotProp] = Field(default_factory=list)
    prop_interaction: PropInteraction = Field(default=PropInteraction.NONE, description="ADR-P06: 'manipulated' forces still pipeline")


class DialogueLine(BaseModel):
    """A line of dialogue within a shot."""
    character: str = Field(..., description="Character ID speaking")
    text: str = Field(..., description="The dialogue text")
    delivery_note: str = Field(default="", description="Parenthetical direction")
    is_voiceover: bool = Field(default=False, description="V.O. — no lip sync needed")


class AudioData(BaseModel):
    """Consumer: TTS/SFX pipeline — audio generation."""
    dialogue: list[DialogueLine] = Field(default_factory=list)
    ambient_sfx: str = Field(default="", description="Environmental sound design")
    foley_action: str = Field(default="", description="Specific physical sound effects")


# ---------------------------------------------------------------------------
# Stage 2.5: Blocking Pass — Physical Blocking Data
# ---------------------------------------------------------------------------


class HandState(BaseModel):
    """State of one hand in a freeze-frame blocking position."""
    hand: Literal["left", "right"] = Field(..., description="Which hand")
    action: str = Field(
        default="hanging_at_side",
        description="What the hand is doing: gripping, resting_on, hanging_at_side, raised_to, pressing, bracing_against",
    )
    target: Optional[str] = Field(
        default=None,
        description="What the hand is touching/holding: salvage_hook_handle, bulkhead_surface, console_panel. None if at side.",
    )
    prop_id: Optional[str] = Field(
        default=None,
        description="References BibleProp.prop_id if holding a tracked prop",
    )


class CharacterBlocking(BaseModel):
    """Freeze-frame blocking for one character in one shot."""
    character_id: str = Field(..., description="Must match a key in GlobalBible.characters")
    stance: str = Field(
        default="standing",
        description="Body geometry: standing, crouched, seated, kneeling_left_knee, leaning_forward, prone",
    )
    torso_facing: str = Field(
        default="camera",
        description="Torso orientation: camera, left, right, away, three_quarter_left, three_quarter_right",
    )
    head_facing: str = Field(
        default="camera",
        description="Head orientation: left, right, down, up, camera, three_quarter_left, three_quarter_right",
    )
    gaze_target: str = Field(
        default="camera",
        description="What the character is looking at: character_id, prop_id, location_landmark, camera, floor, distance",
    )
    dominant_hand: HandState = Field(default_factory=lambda: HandState(hand="right"))
    secondary_hand: HandState = Field(default_factory=lambda: HandState(hand="left"))
    weight_bearing: Optional[str] = Field(
        default=None,
        description="Weight distribution: left_foot, right_foot, both, seated, null for CU/ECU",
    )


class BlockingMetadata(BaseModel):
    """Physical blocking state for this shot, generated by Stage 2.5 Blocking Pass."""
    characters: list[CharacterBlocking] = Field(default_factory=list)
    prop_states: dict[str, str] = Field(
        default_factory=dict,
        description="prop_id → state string, e.g. {'salvage_hook': 'held_by_torch_right_hand'}",
    )
    scene_blocking_hash: str = Field(default="", description="SHA256 of the SBD that produced this")
    axis_violation: bool = Field(default=False, description="True if spatial_data contradicts gaze axis")
    blocking_pass_model: str = Field(default="", description="Model used, e.g. gemini-3.1-pro")
    blocking_pass_timestamp: str = Field(default="", description="ISO 8601 timestamp")


# --- Scene Blocking Document (SBD) — intermediate artifact, not in plan JSON ---


class PropTransition(BaseModel):
    """A prop state change at a narrative moment in the scene."""
    at_shot_approx: str = Field(..., description="Semantic moment: entry, midpoint, SH_011, climax")
    state: str = Field(..., description="Prop state: right_hand_trailing, two_hand_grip_chest_height, on_console")


class PropLedgerEntry(BaseModel):
    """Tracks one prop through the entire scene."""
    prop_id: str = Field(..., description="References BibleProp.prop_id")
    initial_holder: Optional[str] = Field(default=None, description="character_id or None if environmental")
    transitions: list[PropTransition] = Field(default_factory=list)


class GazeSequenceEntry(BaseModel):
    """Ordered sequence of gaze targets for one character through the scene."""
    character: str = Field(..., description="character_id")
    sequence: list[str] = Field(default_factory=list, description="Ordered gaze targets")


class CoverageAnchor(BaseModel):
    """A moment significant for coverage pass planning."""
    moment: str = Field(..., description="Semantic label: torch_reaches_midpoint")
    description: str = Field(..., description="What happens at this moment")
    approximate_shot: str = Field(default="", description="Nearest shot_id, e.g. SH_011")
    coverage_note: str = Field(default="", description="Why this matters for coverage")


class SceneBlockingDocument(BaseModel):
    """Scene-level blocking description — intermediate artifact of Stage 2.5.

    Stored as sidecar JSON files, not embedded in the plan JSON.
    The blocking_narrative provides continuous prose choreography;
    prop_ledger and gaze_sequence provide structured continuity tracking.
    """
    schema_version: int = Field(
        default=RENDER_SCHEMA_VERSION,
        description="Persisted-shape schema version. Bump on breaking change.",
    )
    scene_index: int = Field(..., ge=1)
    location_id: str = Field(default="", description="Primary location for this scene")
    characters_present: list[str] = Field(default_factory=list, description="character_ids in scene")
    scene_duration_shots: int = Field(default=0, ge=0)
    blocking_narrative: str = Field(
        default="",
        description="Continuous prose choreography — script supervisor style blocking notes",
    )
    starting_positions: str = Field(
        default="",
        description="Where each character is when the scene begins",
    )
    prop_ledger: list[PropLedgerEntry] = Field(default_factory=list)
    gaze_sequence: list[GazeSequenceEntry] = Field(default_factory=list)
    coverage_anchors: list[CoverageAnchor] = Field(default_factory=list)


# ---------------------------------------------------------------------------
# Stage 2: Shot Record and Episode Plan
# ---------------------------------------------------------------------------

class ShotRecord(BaseModel):
    """A complete render-ready shot record with all 5 consumer groups."""
    shot_id: str = Field(..., pattern=r"^EP\d{3}_SH\d{2,3}[A-Z]*$", description="e.g. EP001_SH01 or EP001_SH01A for inserted shots, multi-letter suffix for nested inserts")
    shot_index: int = Field(default=0, ge=0, description="1-based camera-test shot_index (authoritative key for axis transitions, REC-180). 0 only on legacy plans assembled before this field existed.")
    scene_index: int = Field(..., ge=1, description="Narrative scene index, propagated from Camera-Test Stage 0")
    source_text: str = Field(..., description="Original script text that generated this shot")
    origin: Literal["script_derived", "composed"] = Field(
        default="script_derived",
        description="How this shot was created: script_derived (from screenplay breakdown) or composed (ad-hoc insertion via Shot Composer)"
    )

    routing_data: RoutingData
    prompt_data: PromptData
    spatial_data: SpatialData
    asset_data: AssetData
    audio_data: AudioData
    blocking_metadata: Optional[BlockingMetadata] = Field(
        default=None,
        description="Physical blocking from Stage 2.5. None for ENV shots or before blocking pass runs.",
    )


class EpisodePlan(BaseModel):
    """Output of Stage 2: Storyboard Pass — complete render plan for one episode."""
    schema_version: int = Field(
        default=RENDER_SCHEMA_VERSION,
        description="Persisted-shape schema version. Bump on breaking change.",
    )
    episode_id: str = Field(..., pattern=r"^EP\d{3}$")
    project: str = Field(...)
    total_shots: int = Field(..., ge=1)
    generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    shots: list[ShotRecord] = Field(..., min_length=1)
    axis_plans: dict[int, SceneAxisPlan] = Field(
        default_factory=dict,
        description="Copied from the creative output during assembly; SSOT for the derived per-shot spatial_data.",
    )

    @field_validator("shots")
    @classmethod
    def validate_shot_count(cls, v, info):
        if "total_shots" in info.data and len(v) != info.data["total_shots"]:
            raise ValueError(f"total_shots={info.data['total_shots']} but got {len(v)} shots")
        return v


# ---------------------------------------------------------------------------
# Validation Helpers
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Handoff Validation — Recoil→Starsend boundary
# ---------------------------------------------------------------------------

_LAZY_WARDROBE_PATTERNS = re.compile(
    r"^(same|same as before|unchanged|as before|see above|previous|no change)$",
    re.IGNORECASE,
)


class HandoffCharacter(BaseModel):
    """Validated character data at the Recoil→Starsend boundary.

    Ensures character data is fully resolved before entering the visual
    pipeline — prevents wasted API spend on shots with bad data.
    """
    char_id: str = Field(..., description="Canonical character ID, e.g. JINX")
    display_name: str = Field(..., min_length=1)
    visual_description: str = Field(..., min_length=10)
    wardrobe_description: str = Field(..., min_length=5)
    hair_makeup_description: str = Field(default="")
    identity_type: Literal["human", "non_human"] = Field(...)
    episode: int = Field(..., ge=1)
    wardrobe_phase_id: str = Field(default="")
    height_cm: Optional[int] = Field(default=None)
    distinguishing_marks: str = Field(default="")

    @field_validator("wardrobe_description")
    @classmethod
    def reject_lazy_wardrobe(cls, v):
        stripped = v.strip().rstrip(".")
        if _LAZY_WARDROBE_PATTERNS.match(stripped):
            raise ValueError(
                f"Wardrobe description '{v}' is not explicit — "
                f"must describe actual clothing, not reference a previous state"
            )
        return v


def validate_handoff(char_key: str, episode: int, bible: dict) -> HandoffCharacter:
    """Build and validate a HandoffCharacter from bible data.

    Args:
        char_key: Character key (e.g. "JINX")
        episode: Episode number
        bible: Parsed global_bible.json or breakdown.json dict

    Returns:
        Validated HandoffCharacter instance.

    Raises:
        KeyError: Character not found in bible.
        ValidationError: Data fails handoff validation.
    """
    characters = bible.get("characters", {})
    char_data = characters.get(char_key.upper())
    if char_data is None:
        raise KeyError(
            f"Character '{char_key}' not in bible. "
            f"Available: {', '.join(characters.keys())}"
        )

    # Resolve wardrobe phase for this episode
    wardrobe_desc = ""
    wardrobe_phase_id = ""
    hair_makeup = ""
    distinguishing = ""

    for phase in char_data.get("phases", []):
        if phase.get("start_ep", 0) <= episode <= phase.get("end_ep", 0):
            wardrobe_desc = phase.get("wardrobe_description", "")
            wardrobe_phase_id = phase.get("phase_id", "")
            hair_makeup = phase.get("hair_makeup", "")
            distinguishing = phase.get("distinguishing_marks", "")
            break

    # Determine identity_type from rendering_directives if available,
    # otherwise from visual_description
    identity_type = "human"
    rd = char_data.get("rendering_directives", {})
    if rd.get("identity_type"):
        identity_type = rd["identity_type"]
    else:
        vis = char_data.get("visual_description", "").lower()
        if any(kw in vis for kw in ("android", "robot", "synthetic", "mechanical", "cyborg")):
            identity_type = "non_human"

    return HandoffCharacter(
        char_id=char_key.upper(),
        display_name=char_data.get("display_name", char_key),
        visual_description=char_data.get("visual_description", ""),
        wardrobe_description=wardrobe_desc,
        hair_makeup_description=hair_makeup,
        identity_type=identity_type,
        episode=episode,
        wardrobe_phase_id=wardrobe_phase_id,
        height_cm=char_data.get("height_cm"),
        distinguishing_marks=distinguishing,
    )


_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
_TRIGGER_TYPES = {"script_event", "ep_range"}
_WARDROBE_STATES = {"worn", "removed", "damaged", "torn"}
_HAIR_STATES = {"loose", "tucked", "tied", "covered"}
_IDENTITY_KEYS = ("hair_color", "eye_color", "skin_tone", "build", "distinguishing")
_TRIGGER_KEYS = ("type", "scene_ref", "description", "evidence_hash")
_APPEARANCE_KEYS = ("wardrobe", "hair_state", "visible_gear", "notable_marks")
_WARDROBE_KEYS = ("piece", "descriptor", "state", "salient")
_PROP_STATE_KEYS = ("description", "visual_delta")
_PROP_TRANSITION_KEYS = ("from", "to", "reversible")


def _missing_keys(data: dict, keys: tuple[str, ...]) -> list[str]:
    return [key for key in keys if key not in data]


def _is_str_list(value) -> bool:
    return isinstance(value, list) and all(isinstance(item, str) for item in value)


def validate_identity_invariants(char: dict) -> list[str]:
    """Validate additive character identity blocks.

    Absent blocks are valid so legacy bibles pass untouched. Unknown character
    keys are ignored; JADE/WREN manual sheet metadata must never be rejected.
    """
    errors: list[str] = []

    invariants = char.get("identity_invariants")
    if invariants is not None:
        if not isinstance(invariants, dict):
            errors.append("identity_invariants must be an object")
        else:
            for key in _missing_keys(invariants, _IDENTITY_KEYS):
                errors.append(f"identity_invariants.{key} is required")
            for key in ("hair_color", "eye_color", "skin_tone", "build"):
                if key in invariants and not isinstance(invariants[key], str):
                    errors.append(f"identity_invariants.{key} must be a string")
            if "distinguishing" in invariants and not _is_str_list(invariants["distinguishing"]):
                errors.append("identity_invariants.distinguishing must be a list of strings")

    if "transients" in char and char.get("transients") is not None and not _is_str_list(char["transients"]):
        errors.append("transients must be a list of strings")

    return errors


def validate_phase_trigger(phase: dict) -> list[str]:
    """Validate the additive L9 phase trigger block."""
    trigger = phase.get("trigger")
    if trigger is None:
        return []

    if not isinstance(trigger, dict):
        return ["trigger must be an object"]

    errors: list[str] = []
    for key in _missing_keys(trigger, _TRIGGER_KEYS):
        errors.append(f"trigger.{key} is required")

    if "type" in trigger and trigger["type"] not in _TRIGGER_TYPES:
        errors.append("trigger.type must be one of: ep_range, script_event")
    for key in ("scene_ref", "description", "evidence_hash"):
        if key in trigger and not isinstance(trigger[key], str):
            errors.append(f"trigger.{key} must be a string")
    if isinstance(trigger.get("evidence_hash"), str) and not _SHA256_RE.match(trigger["evidence_hash"]):
        errors.append("trigger.evidence_hash must be a lowercase sha256 hex digest")

    return errors


def validate_appearance(phase: dict) -> list[str]:
    """Validate the additive L9 structured phase appearance block."""
    appearance = phase.get("appearance")
    if appearance is None:
        return []

    if not isinstance(appearance, dict):
        return ["appearance must be an object"]

    errors: list[str] = []
    for key in _missing_keys(appearance, _APPEARANCE_KEYS):
        errors.append(f"appearance.{key} is required")

    wardrobe = appearance.get("wardrobe")
    if "wardrobe" in appearance and not isinstance(wardrobe, list):
        errors.append("appearance.wardrobe must be a list")
    elif isinstance(wardrobe, list):
        for index, item in enumerate(wardrobe):
            prefix = f"appearance.wardrobe[{index}]"
            if not isinstance(item, dict):
                errors.append(f"{prefix} must be an object")
                continue
            for key in _missing_keys(item, _WARDROBE_KEYS):
                errors.append(f"{prefix}.{key} is required")
            for key in ("piece", "descriptor", "state"):
                if key in item and not isinstance(item[key], str):
                    errors.append(f"{prefix}.{key} must be a string")
            if "state" in item and isinstance(item["state"], str) and item["state"] not in _WARDROBE_STATES:
                errors.append(f"{prefix}.state must be one of: damaged, removed, torn, worn")
            if "salient" in item and not isinstance(item["salient"], bool):
                errors.append(f"{prefix}.salient must be a boolean")

    if "hair_state" in appearance and appearance["hair_state"] not in _HAIR_STATES:
        errors.append("appearance.hair_state must be one of: covered, loose, tucked, tied")
    for key in ("visible_gear", "notable_marks"):
        if key in appearance and not _is_str_list(appearance[key]):
            errors.append(f"appearance.{key} must be a list of strings")

    return errors


def validate_prop_state_machine(prop: dict) -> list[str]:
    """Validate the additive L9 prop state-machine block.

    Absent blocks are valid. When present, state endpoints must exist and every
    declared state must be reachable from initial_state via transitions.
    """
    state_machine_keys = {"states", "initial_state", "transitions", "carriable"}
    # Additive grace: a legacy prop with an EMPTY/None states map (old default
    # writes "states": {}) has NO machine declared — it must pass untouched,
    # exactly like a prop with no machine keys at all.
    if not prop.get("states") and not prop.get("transitions") and "initial_state" not in prop:
        return []
    # Legacy STRING-valued states ({"pristine": "worn smooth"}) are prose
    # annotations BibleProp still accepts — not an L9 machine. Only dict-valued
    # states declare the machine contract.
    states_map = prop.get("states")
    if (
        isinstance(states_map, dict)
        and states_map
        and all(isinstance(v, str) for v in states_map.values())
        and not prop.get("transitions")
        and "initial_state" not in prop
    ):
        return []
    if not any(key in prop for key in state_machine_keys):
        return []

    errors: list[str] = []
    states = prop.get("states")
    initial_state = prop.get("initial_state")
    transitions = prop.get("transitions")

    if not isinstance(states, dict) or not states:
        errors.append("states must be a non-empty object")
        state_ids: set[str] = set()
    else:
        state_ids = set(states)
        for state_id, state in states.items():
            prefix = f"states.{state_id}"
            if not isinstance(state, dict):
                errors.append(f"{prefix} must be an object with description and visual_delta")
                continue
            for key in _missing_keys(state, _PROP_STATE_KEYS):
                errors.append(f"{prefix}.{key} is required")
            for key in _PROP_STATE_KEYS:
                if key in state and not isinstance(state[key], str):
                    errors.append(f"{prefix}.{key} must be a string")

    if not isinstance(initial_state, str):
        errors.append("initial_state must be a string")
    elif state_ids and initial_state not in state_ids:
        errors.append(f"initial_state '{initial_state}' is not declared in states")

    if not isinstance(transitions, list):
        errors.append("transitions must be a list")
        transitions = []
    else:
        for index, transition in enumerate(transitions):
            prefix = f"transitions[{index}]"
            if not isinstance(transition, dict):
                errors.append(f"{prefix} must be an object")
                continue
            for key in _missing_keys(transition, _PROP_TRANSITION_KEYS):
                errors.append(f"{prefix}.{key} is required")
            for key in ("from", "to"):
                if key in transition:
                    if not isinstance(transition[key], str):
                        errors.append(f"{prefix}.{key} must be a string")
                    elif state_ids and transition[key] not in state_ids:
                        errors.append(f"{prefix}.{key} '{transition[key]}' is not declared in states")
            if "reversible" in transition and not isinstance(transition["reversible"], bool):
                errors.append(f"{prefix}.reversible must be a boolean")
            if "trigger_scene" in transition and not isinstance(transition["trigger_scene"], str):
                errors.append(f"{prefix}.trigger_scene must be a string")

    if "carriable" not in prop:
        errors.append("carriable is required")
    elif not isinstance(prop["carriable"], bool):
        errors.append("carriable must be a boolean")

    if (
        state_ids
        and isinstance(initial_state, str)
        and initial_state in state_ids
        and isinstance(transitions, list)
    ):
        reachable = {initial_state}
        changed = True
        while changed:
            changed = False
            for transition in transitions:
                if not isinstance(transition, dict):
                    continue
                source = transition.get("from")
                target = transition.get("to")
                if source in reachable and isinstance(target, str) and target in state_ids and target not in reachable:
                    reachable.add(target)
                    changed = True
                if transition.get("reversible") is True and target in reachable and isinstance(source, str) and source in state_ids and source not in reachable:
                    reachable.add(source)
                    changed = True

        unreachable = sorted(state_ids - reachable)
        if unreachable:
            errors.append(f"states unreachable from initial_state '{initial_state}': {', '.join(unreachable)}")

    return errors


# ---------------------------------------------------------------------------
# Validation Helpers
# ---------------------------------------------------------------------------

def validate_plan_against_bible(plan: EpisodePlan, bible: GlobalBible) -> list[str]:
    """Validate that a plan's IDs exist in the bible. Returns list of error strings."""
    errors = []
    ep_num = int(plan.episode_id[2:])

    for shot in plan.shots:
        # Location exists
        if shot.asset_data.location_id not in bible.locations:
            errors.append(f"{shot.shot_id}: location_id '{shot.asset_data.location_id}' not in bible")

        # Characters exist and have valid phases
        for char in shot.asset_data.characters:
            if char.char_id not in bible.characters:
                errors.append(f"{shot.shot_id}: char_id '{char.char_id}' not in bible")
            else:
                bible_char = bible.characters[char.char_id]
                phase = bible_char.phase_for_episode(ep_num)
                if phase is None:
                    errors.append(
                        f"{shot.shot_id}: no phase for '{char.char_id}' in EP{ep_num:03d}"
                    )
                elif phase.phase_id != char.wardrobe_phase_id:
                    errors.append(
                        f"{shot.shot_id}: wardrobe_phase_id '{char.wardrobe_phase_id}' "
                        f"doesn't match bible phase '{phase.phase_id}' for EP{ep_num:03d}"
                    )

        # Props exist
        for prop in shot.asset_data.props:
            if prop.prop_id not in bible.props:
                errors.append(f"{shot.shot_id}: prop_id '{prop.prop_id}' not in bible")

        # ENV-only consistency
        if shot.routing_data.is_env_only and len(shot.asset_data.characters) > 0:
            errors.append(f"{shot.shot_id}: is_env_only=True but has characters")
        if not shot.routing_data.is_env_only and len(shot.asset_data.characters) == 0:
            errors.append(f"{shot.shot_id}: is_env_only=False but no characters")

        # num_characters consistency — warning only (LLM CoT forcing function, not a hard error)
        in_frame = [c for c in shot.asset_data.characters if c.visibility == Visibility.IN_FRAME]
        if shot.routing_data.num_characters != len(in_frame):
            import logging as _val_logging
            _val_logging.getLogger("starsend.validation").warning(
                "%s: num_characters=%d but %d in_frame characters (auto-correcting)",
                shot.shot_id, shot.routing_data.num_characters, len(in_frame),
            )

        # Dialogue character exists
        for line in shot.audio_data.dialogue:
            if line.character not in bible.characters:
                errors.append(f"{shot.shot_id}: dialogue character '{line.character}' not in bible")

        # has_dialogue consistency
        has_spoken = len(shot.audio_data.dialogue) > 0
        if shot.routing_data.has_dialogue != has_spoken:
            errors.append(
                f"{shot.shot_id}: has_dialogue={shot.routing_data.has_dialogue} "
                f"but {'has' if has_spoken else 'no'} dialogue lines"
            )

    return errors


def validate_camera_test_budget(episode: CameraTestedEpisode, min_shots: int = 28, max_shots: int = 41) -> list[str]:
    """Validate camera-test shot budget. Returns list of error strings."""
    errors = []
    if episode.total_shots < min_shots:
        errors.append(f"Shot budget violation: {episode.total_shots} shots (minimum {min_shots})")
    if episode.total_shots > max_shots:
        errors.append(f"Shot budget violation: {episode.total_shots} shots (maximum {max_shots})")
    return errors


# ---------------------------------------------------------------------------
# Shot Composer — Ad-Hoc Insertion Models
# ---------------------------------------------------------------------------

class ComposeShotRequest(BaseModel):
    """Request to compose a new shot between two anchor shots."""
    episode_id: str = Field(..., pattern=r"^EP\d{3}$", description="Episode ID, e.g. EP001")
    after_shot_id: str = Field(..., description="Shot ID to insert after. Use empty string to insert at beginning.")
    description: str = Field(..., min_length=5, description="Director's description of the desired shot")


class ComposeShotResponse(BaseModel):
    """Response from shot composition."""
    shot: dict = Field(..., description="The generated ShotRecord as dict")
    shot_id: str = Field(..., description="The computed shot_id for the new shot")
    anchors_used: list[str] = Field(default_factory=list, description="Shot IDs of the anchor shots used as context")


def get_sort_float(shot_id: str) -> float:
    """Derive a sort-order float from a shot_id using base-27 fractional math.

    Examples:
        EP001_SH03  → 3.0
        EP001_SH03A → 3.037037...  (3 + 1/27)
        EP001_SH03B → 3.074074...  (3 + 2/27)
        EP001_SH03AA → 3.038408...  (3 + 1/27 + 1/729)

    Each suffix letter maps to 1-26 (A=1, Z=26) at successive base-27 depths.
    This guarantees correct insertion ordering without a stored sort field.
    """
    m = re.match(r"EP\d{3}_SH(\d{2,3})([A-Z]*)", shot_id)
    if not m:
        return 0.0
    base = int(m.group(1))
    suffix = m.group(2)
    frac = 0.0
    for i, ch in enumerate(suffix):
        frac += (ord(ch) - ord('A') + 1) / (27 ** (i + 1))
    return base + frac
