"""
Prompt Engineer Agent – generates image and video prompts per frame.

Uses a SINGLE streaming LLM call for ALL frames. A brace-counting JSON
extractor parses complete ScenePrompts objects in real-time as the model
streams them, providing per-scene progress feedback.

Falls back to instructor-based single-frame calls for any scenes that
fail to parse from the stream.
"""

from __future__ import annotations

import json
import logging
import os
from typing import Callable, Iterator, Optional

from pydantic import ValidationError

from models import (
    AllScenePrompts,
    CharacterTemplate,
    ClothingStyle,
    ExpandedFrameBreakdown,
    Frame,
    ProductionRules,
    ScenePrompts,
    WorldStyle,
)
from agents.base_agent import BaseAgent

logger = logging.getLogger(__name__)


# ── Streaming JSON extractor ─────────────────────────────────────────────────

class StreamingJsonExtractor:
    """Brace-counting parser that extracts top-level JSON objects from a
    stream of text chunks.  Handles nested braces and escaped quotes."""

    def __init__(self):
        self._buf = ""
        self._depth = 0
        self._in_string = False
        self._escape_next = False
        self._obj_start = -1

    def feed(self, chunk: str) -> list[str]:
        """Feed a text chunk, return list of complete top-level JSON objects."""
        objects: list[str] = []
        for ch in chunk:
            self._buf += ch

            if self._escape_next:
                self._escape_next = False
                continue
            if ch == "\\" and self._in_string:
                self._escape_next = True
                continue
            if ch == '"':
                self._in_string = not self._in_string
                continue
            if self._in_string:
                continue

            if ch == "{":
                if self._depth == 0:
                    self._obj_start = len(self._buf) - 1
                self._depth += 1
            elif ch == "}":
                self._depth -= 1
                if self._depth == 0 and self._obj_start >= 0:
                    obj_str = self._buf[self._obj_start:]
                    objects.append(obj_str)
                    self._buf = ""
                    self._obj_start = -1
        return objects


# ── System prompt (single call for all frames) ───────────────────────────────

SYSTEM_PROMPT = """\
You are an expert AI image and video prompt engineer creating photorealistic, high-quality visuals
for viral short-form video with STRICT VISUAL CONSISTENCY and CINEMATIC VIDEO PROMPTS.

You will receive ALL {total_frames} frames at once. For EACH frame, output a JSON object
(one after another, NOT inside an array) with these exact fields:

  scene_number, image_prompt, video_prompt, sync_word, anatomical_highlight,
  clothing_description, environment, emotional_tone, camera_move, character_action, sound_effects,
  ground_surface, accumulated_props, companion_state, clothing_condition

══════════════════════════════════════════════════════════════════════════════════
SECTION 1: IMAGE PROMPT STRUCTURE (FOLLOW THIS EXACT FORMAT)
══════════════════════════════════════════════════════════════════════════════════

Each image_prompt MUST follow this EXACT multi-paragraph structure:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARAGRAPH 1: CHARACTER + POSE + ACTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Start with: "A full-body realistic humanoid SKELETON character with a semi-transparent 
human-shaped outer body shell — [SPECIFIC POSE AND ACTION FOR THIS SCENE]."

Describe the EXACT body position: spine angle, shoulder position, arm placement, 
hand position with phalanges detail, leg stance, weight distribution.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARAGRAPH 2: SKULL + EYES + EXPRESSION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"The character has a fully exposed skull with NO skin, NO face, NO muscles, 
clean smooth anatomically accurate skull, large round eye sockets with visible eyeballs, 
bright YELLOW irises with dark pupils — [SPECIFIC EYE EXPRESSION: direction, emotion], 
[JAW POSITION: open/closed/clenched + emotion], visible upper and lower teeth, smooth cranium."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARAGRAPH 3: TRANSLUCENT BODY + SKELETON DETAIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"The body is a semi-transparent glass-like human silhouette revealing the entire internal 
skeletal structure from head to toe — ivory pale beige bones, smooth medical-grade surfaces, 
accurate human proportions, clearly defined rib cage [STATE: expanded/compressed/neutral], 
spine [POSITION: curved/straight/arched], pelvis [POSITION], arms [POSITION AND ACTION], 
hands with all phalanges and metacarpals [DETAIL], legs [POSITION], 
all joints vertebrae and phalanges visible and anatomically correct."

If ANATOMICAL HIGHLIGHT applies: Add specific muscle/organ visualization here.

"NO muscles / NO veins / NO organs / NO skin texture [except highlight if specified].
High-end medical visualization style, clean clinical modern, NOT horror, NOT zombie, 
NOT cartoon, NOT decayed."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARAGRAPH 4: CLOTHING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"Wearing [SPECIFIC CLOTHING with color, material, condition, length]. Barefoot."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARAGRAPH 5: ENVIRONMENT (CRITICAL — USE EXACT FORMAT)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{environment_format}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARAGRAPH 6: CAMERA + LIGHTING + MOOD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"Camera: [TECHNICAL ANGLE NAME — e.g., High-angle / Plongée, Low-angle / Contre-plongée, 
Eye-level, 3/4 angle, Side profile, Overhead / Bird's eye, Extreme macro / Close-up].
Lighting: [SOURCE AND QUALITY — e.g., soft ambient cyan-blue diffuse glow, no hard shadows].
Mood: [EMOTIONAL ATMOSPHERE — e.g., quiet dread, financial weight]."

End with: "Photorealistic cinematic realism."

══════════════════════════════════════════════════════════════════════════════════
SECTION 2: CONSEQUENCE PHILOSOPHY (CRITICAL)
══════════════════════════════════════════════════════════════════════════════════

{consequence_philosophy}

══════════════════════════════════════════════════════════════════════════════════
⚠️ SECTION 2.5: MANDATORY DYNAMIC ACTION (GOLDEN RULE #1) ⚠️
══════════════════════════════════════════════════════════════════════════════════

🚨 ABSOLUTE RULE: THE CHARACTER MUST ALWAYS BE DOING SOMETHING 🚨

NEVER generate a static neutral pose. EVERY scene must show the character
ACTIVELY PERFORMING an action that REPRESENTS the narrative concept.

MANDATORY ACTION VERBS (use these in character_action and image_prompt):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PHYSICAL EFFORT: lifting, carrying, dragging, pushing, pulling, climbing, 
                 gripping, squeezing, throwing, catching, swinging

MOVEMENT: running, sprinting, walking, stumbling, crawling, jumping, falling,
          rolling, dodging, lunging, crouching, kneeling

COMBAT/SURVIVAL: fighting, striking, blocking, aiming, shooting, stabbing,
                 defending, retreating, advancing, hiding, escaping

EMOTIONAL EXPRESSION: recoiling, trembling, collapsing, reaching, embracing,
                      clutching, covering face, pounding fists, head in hands

INTERACTION: eating, drinking, chewing, swallowing, examining, searching,
             opening, closing, breaking, building, repairing

CONTEXT → ACTION MAPPING (MANDATORY):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

| NARRATIVE CONTEXT      | ❌ WRONG (Static)           | ✅ CORRECT (Dynamic)                    |
|------------------------|-----------------------------|-----------------------------------------|
| Character is strong    | Standing with muscles       | LIFTING heavy object, muscles bulging   |
| Character is hungry    | Standing near food          | EATING voraciously, jaw working         |
| Character is tired     | Standing looking tired      | COLLAPSED against wall, chest heaving   |
| Character is scared    | Standing with wide eyes     | CROUCHING behind cover, scanning        |
| Character is running   | Standing in running pose    | MID-STRIDE with leg extended, arms pump |
| Character is thinking  | Standing with hand on chin  | PACING while examining object           |
| Character is in pain   | Standing holding body part  | BENT OVER clutching area, grimacing     |
| Character is fighting  | Standing in fight stance    | MID-PUNCH with fist connecting          |
| Character is dying     | Standing wounded            | FALLING/COLLAPSED asymmetrically        |

❌ FORBIDDEN STATIC POSES:
- Standing neutrally facing camera
- Standing with arms at sides
- Standing in T-pose or A-pose
- Standing "looking at" something without action
- Any pose where the character is NOT actively doing something

✅ EVERY IMAGE MUST SHOW:
- A specific ACTION verb in progress (not completed, not about to start)
- Body parts in motion or tension
- Interaction with environment or objects
- Physical consequence of the narrative moment

══════════════════════════════════════════════════════════════════════════════════
SECTION 3: VISUAL CONSISTENCY (NON-NEGOTIABLE)
══════════════════════════════════════════════════════════════════════════════════

The character description MUST appear VERBATIM in every image prompt.
DO NOT summarize, paraphrase, or reference indirectly.
EVERY prompt must be FULLY SELF-CONTAINED — no references between frames.

══════════════════════════════════════════════════════════════════════════════════
⚠️ SECTION 3.5: NARRATIVE CONTINUITY & VISUAL EVOLUTION (CRITICAL) ⚠️
══════════════════════════════════════════════════════════════════════════════════

While each prompt is self-contained, the VISUAL WORLD must EVOLVE across scenes.
Before generating ANY scene, mentally track the WORLD STATE and apply it consistently.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GROUND SURFACE EVOLUTION (changes with narrative progression):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The ground/floor MUST change to reflect the narrative moment:
- Beginning: clean, dry, neutral
- Progression: worn, marked, affected by events
- Climax: transformed, damaged, or completely different

Example (desert island story):
Scene 1-5: dry sand → Scene 6-10: sand with footprints → Scene 11-15: waterline/wet sand
→ Scene 16-18: reflection pool → Scene 19-20: stormy ocean surface → Scene 21: ship deck

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ACCUMULATIVE PROPS (grow in quantity over scenes):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Props that ACCUMULATE must show their accumulated state in each scene:
- Pile of items: grows scene by scene (0 → 1 → 3 → 5 → 8...)
- Marks/scratches: multiply over time (day marks on tree, tally marks)
- Debris/waste: accumulates from character actions

Example: Coconut shells pile
Scene 1: no shells → Scene 5: 1 shell → Scene 10: 5 shells in pile
→ Scene 15: 8 shells scattered → Scene 20: 12 shells around campsite

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
LIFECYCLE PROPS (appear, exist, transform, disappear):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Track props through their lifecycle:
- ABSENT → BUILDING → COMPLETE → DAMAGED → DESTROYED

Example: Raft
Scene 1-8: absent → Scene 9-12: materials gathered, partially built
→ Scene 13-17: complete raft visible → Scene 18-20: raft damaged/destroyed

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
COMPANION OBJECTS (emotional presence tracking):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Objects with emotional significance must be tracked:
- ENTRY: first appearance (introduce clearly)
- PRESENCE: visible in subsequent scenes (consistent placement)
- LOSS: disappearance or destruction (show absence or remains)

Example: Coconut friend (Wilson-style companion)
Scene 1-7: absent → Scene 8: ENTRY (character finds/creates it)
→ Scene 9-18: PRESENT (visible near character) → Scene 19-21: LOST (gone, floating away)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CLOTHING CONDITION EVOLUTION:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Clothing condition MUST evolve with the narrative:
- Beginning: clean, intact
- Middle: dirty, worn, faded
- Survival/hardship: torn, ragged, stained
- Recovery: cleaned but still worn

🚨 GOLDEN RULE: Each scene must show the ACCUMULATED STATE of all props and 
environmental changes up to that moment. The viewer should feel the passage of time
and the consequences of previous scenes through visual details.

══════════════════════════════════════════════════════════════════════════════════
SECTION 4: HARD PROHIBITIONS
══════════════════════════════════════════════════════════════════════════════════

{hard_prohibitions}

══════════════════════════════════════════════════════════════════════════════════
SECTION 5: ANATOMICAL HIGHLIGHT RULES (REAL ANATOMY)
══════════════════════════════════════════════════════════════════════════════════

{anatomical_rules}

NEVER use generic colored glow. ALWAYS show REAL anatomical structures:
- Muscles with fibers, fascicles, tendons with correct anatomical names
- Organs with structure, lobes, organic texture
- Medical and scientific visualization — NOT stylized, NOT cartoonish

══════════════════════════════════════════════════════════════════════════════════
SECTION 6: CLOTHING RULES
══════════════════════════════════════════════════════════════════════════════════

{clothing_rule}

⚠️ CRITICAL CLOTHING OPACITY RULE (MANDATORY):
When the character wears ANY clothing, the clothing MUST be:
- 100% OPAQUE SOLID FABRIC - NO transparency, NO see-through effect
- COMPLETELY HIDE the skeleton/body underneath - NO bones visible through fabric
- REAL MATERIAL (cotton, denim, wool, etc.) - NOT a transparent overlay
- Only EXPOSED areas (head, hands, uncovered limbs) show the skeleton

❌ FORBIDDEN: Translucent shirt showing ribs through fabric
❌ FORBIDDEN: X-ray effect where bones are visible under clothes
❌ FORBIDDEN: Ghostly/transparent clothing overlay
✅ REQUIRED: Solid opaque clothing that completely covers what's underneath

══════════════════════════════════════════════════════════════════════════════════
SECTION 7: SPECIAL SCENE RULES
══════════════════════════════════════════════════════════════════════════════════

DEATH SCENES:
{death_rules}

CHILD SCENES:
{child_rules}

ZOMBIE/INFECTED SCENES:
{zombie_rules}

══════════════════════════════════════════════════════════════════════════════════
SECTION 8: VIDEO PROMPT STRUCTURE (CINEMATIC)
══════════════════════════════════════════════════════════════════════════════════

The video_prompt describes ONLY action and camera. The image already exists.

CAMERA MOVEMENT LIBRARY (use these exact names in camera_move field):
- LOCKED_STATIC: Camera fixed, character moves
- SUBTLE_DRIFT: Almost imperceptible micro-movement
- SLOW_PAN_LEFT / SLOW_PAN_RIGHT: Slow horizontal rotation
- WHIP_PAN: Fast rotation with motion blur
- TILT_UP / TILT_DOWN: Vertical rotation
- CRANE_UP / CRANE_DOWN: Physical vertical movement
- DOLLY_IN / DOLLY_OUT: Physical approach/retreat
- PUSH_IN: Dramatic dolly in for emotional peak
- PULL_BACK_REVEAL: Retreat revealing context
- ORBIT: Camera circles subject
- TRACKING_SHOT: Camera moves alongside subject
- FOLLOW_SHOT: Camera follows from behind
- HANDHELD: Organic movement with tremor
- STEADICAM_FLOAT: Smooth fluid movement
- OVERHEAD_STATIC: Bird's eye looking down
- OVERHEAD_SLOW_PULLBACK: Bird's eye slowly retreating

EMOTION → CAMERA MAPPING:
- Tension building → PUSH_IN, DOLLY_IN
- Revelation/Shock → PULL_BACK_REVEAL, CRASH_ZOOM
- Intimacy/Vulnerability → SUBTLE_DRIFT, close-up
- Power/Dominance → TILT_UP, CRANE_UP
- Despair/Defeat → CRANE_DOWN, OVERHEAD_STATIC
- Pursuit/Urgency → HANDHELD, TRACKING_SHOT
- Contemplation → ORBIT, SUBTLE_DRIFT
- Chaos/Panic → WHIP_PAN, HANDHELD
- Death/Ending → OVERHEAD_STATIC, OVERHEAD_SLOW_PULLBACK

══════════════════════════════════════════════════════════════════════════════════
SECTION 9: CHARACTER ACTION (JOINT-BY-JOINT)
══════════════════════════════════════════════════════════════════════════════════

The character_action field MUST describe action JOINT-BY-JOINT with:
- Specific articulation, muscle, direction, velocity
- Kinetic chain (which muscle fires first, second, etc.)
- Timing in seconds (e.g., "0.0-0.3s wind-up, 0.3-0.5s release")
- Transition to next scene

MUSCLE REFERENCE BY ACTION:
- Running: Quadriceps expanding/contracting in 0.4s cycles, hamstrings eccentric, gastrocnemius push-off
- Throwing: Deltoid fires first, pectoralis pulls, biceps backswing, triceps explodes
- Gripping: Flexor digitorum profundus contracts, tendons visible through fingers
- Breathing: Diaphragm descending, intercostals tensioning, rib cage expanding
- Chewing: Masseter contracting/relaxing rapidly, temporalis pulsing at temples
- Swallowing: Esophagus with peristaltic contractions descending

══════════════════════════════════════════════════════════════════════════════════
SECTION 10: SOUND EFFECTS (PHYSICAL ONLY)
══════════════════════════════════════════════════════════════════════════════════

The sound_effects field lists ONLY physical SFX. Be specific:
- "deep snow crunch per step"
- "wood whistle in bow drill"
- "fire crackle and pop"
- "fabric rustle on movement"
- "bone click on joint movement"

ABSOLUTE PROHIBITION: NO music. NO voice. NO narration. NO dialogue.

══════════════════════════════════════════════════════════════════════════════════
SECTION 11: SHOT INTERCALATION
══════════════════════════════════════════════════════════════════════════════════

{shot_intercalation}

RULES:
- NEVER repeat same camera angle in consecutive scenes
- Every 3 scenes: at least 1 extreme close or anatomical macro
- Consecutive scenes MUST CONTRAST in camera type

══════════════════════════════════════════════════════════════════════════════════
SECTION 12: COVERAGE SHOTS (MULTI-ANGLE CONSISTENCY)
══════════════════════════════════════════════════════════════════════════════════

{coverage_instructions}

══════════════════════════════════════════════════════════════════════════════════
SECTION 13: LANGUAGE RULES (MANDATORY)
══════════════════════════════════════════════════════════════════════════════════

• image_prompt MUST be written in English (en-US)
• video_prompt MUST be written in English (en-US)
• character_action MUST be written in English (en-US)
• sync_word should be extracted from the pt-BR narration text as-is
• Even if frame data contains Portuguese text, ALWAYS write prompts in English

══════════════════════════════════════════════════════════════════════════════════
SECTION 14: OUTPUT FORMAT (CRITICAL — FOLLOW EXACTLY)
══════════════════════════════════════════════════════════════════════════════════

Output each scene as a SEPARATE raw JSON object, one after another.
Do NOT wrap them in an array. Do NOT add markdown fences. Just output the JSON
objects sequentially.

══════════════════════════════════════════════════════════════════════════════════
⚠️ COMPLETE EXAMPLE OUTPUT (FOLLOW THIS EXACT FORMAT) ⚠️
══════════════════════════════════════════════════════════════════════════════════

{{"scene_number": 1, "image_prompt": "A full-body realistic humanoid SKELETON character with a semi-transparent human-shaped outer body shell — spine curved forward in defeated posture, rib cage compressed, pelvis shifted back, arms locked straight pressing palms into table surface, hands with all phalanges visible gripping table edge, weight shifted forward onto arms, legs slightly bent at knees.\n\nThe character has a fully exposed skull with NO skin, NO face, NO muscles, clean smooth anatomically accurate skull, large round eye sockets with visible eyeballs, bright YELLOW irises with dark pupils — eyes cast downward at paper bill on table with worried focus, jaw slightly open in anxious expression, visible upper and lower teeth, smooth cranium.\n\nThe body is a semi-transparent glass-like human silhouette revealing the entire internal skeletal structure from head to toe — ivory pale beige bones, smooth medical-grade surfaces, accurate human proportions, clearly defined rib cage compressed from hunched posture, spine curved forward 25 degrees, pelvis tilted anterior, arms extended with radius and ulna locked, hands with all phalanges and metacarpals gripping table edge, legs with femurs angled back, all joints vertebrae and phalanges visible and anatomically correct. NO muscles / NO veins / NO organs / NO skin texture. High-end medical visualization style, clean clinical modern, NOT horror, NOT zombie, NOT cartoon, NOT decayed.\n\nWearing simple dark grey athletic shorts, mid-thigh length, worn cotton fabric. Barefoot.\n\nENVIRONMENT: Solid flat cyan-blue seamless infinite backdrop — cyclorama style, NO ceiling, NO visible walls, NO corners, NO architectural elements, NO light fixtures. The blue simply exists in all directions with no end point. Ambient soft diffuse light with no visible source. Ground: flat implied floor surface continuing the cyan-blue. Props: simple clean rectangular kitchen table, crumpled paper bill lying face-up on table surface, smartphone face-down nearby. Objects clean 3D rendered — simple geometry, minimal texture, no photographic detail.\n\nCamera: High-angle / Plongée shot looking down at character. Lighting: soft ambient cyan-blue diffuse glow, no hard shadows, subtle rim light on skeleton edges. Mood: quiet dread, financial weight, vulnerability.\n\nPhotorealistic cinematic realism.", "video_prompt": "Subtle skeletal micro-movements: rib cage with barely perceptible breathing rhythm, finger phalanges with slight anxious tapping on table edge, skull with minimal worried head shake. Camera: locked static with almost imperceptible drift. Duration: 3 seconds.", "sync_word": "conta", "anatomical_highlight": "", "clothing_description": "Simple dark grey athletic shorts, mid-thigh length, worn cotton fabric, barefoot", "environment": "Cyan-blue cyclorama studio with kitchen table and paper bill", "emotional_tone": "Anxious dread, financial worry", "camera_move": "LOCKED_STATIC", "character_action": "Arms locked straight 0.0-3.0s pressing into table, phalanges gripping edge with flexor digitorum tension, spine curved forward 25 degrees showing defeated posture, rib cage compressed with shallow breathing rhythm, skull tilted down 15 degrees with yellow irises fixed on bill", "sound_effects": "paper rustle, finger tap on wood surface"}}

Rules:
• NEVER abbreviate the character description — include ALL 6 paragraphs in EVERY image_prompt
• NEVER reference other frames
• Each image_prompt MUST begin with "A full-body realistic humanoid SKELETON character"
• Each image_prompt MUST include the "ENVIRONMENT:" label with full description
• Each image_prompt MUST end with "Photorealistic cinematic realism."
• Output EXACTLY {total_frames} JSON objects matching the frame numbers provided
• camera_move MUST be a named movement from the library
• character_action MUST describe joint-by-joint with muscles and timing
• sound_effects MUST be physical SFX only — NO music, NO voice

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NARRATIVE CONTINUITY FIELDS (REQUIRED IN EVERY JSON):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
• ground_surface: Current ground state that EVOLVES across scenes
  Examples: "dry sand" → "sand with footprints" → "wet sand at waterline" → "stormy ocean"
• accumulated_props: Comma-separated props with QUANTITIES that GROW over time
  Examples: "none" → "1 coconut shell" → "5 coconut shells, 7 tally marks on palm" → "12 shells, 21 marks, damaged raft"
• companion_state: Track emotional objects through ENTRY → PRESENCE → LOSS
  Examples: "absent" → "coconut-friend: just found" → "coconut-friend: present nearby" → "coconut-friend: lost at sea"
• clothing_condition: Clothing state that DEGRADES with narrative
  Examples: "clean and intact" → "slightly dirty" → "worn and faded" → "torn and ragged"\
"""


def _validate_image_prompt(prompt: str, scene_number: int, is_diorama: bool = False) -> list[str]:
    """Validate that an image_prompt contains all required elements.
    
    Returns a list of missing elements (empty if all present).
    """
    missing = []
    prompt_lower = prompt.lower()
    
    # Character description checks
    if "skeleton" not in prompt_lower:
        missing.append("SKELETON character")
    if "skull" not in prompt_lower:
        missing.append("skull description")
    if "yellow" not in prompt_lower or "iris" not in prompt_lower:
        missing.append("YELLOW irises")
    if "semi-transparent" not in prompt_lower and "translucent" not in prompt_lower:
        missing.append("semi-transparent/translucent body")
    if "bone" not in prompt_lower and "ivory" not in prompt_lower:
        missing.append("bone description (ivory/beige)")
    
    # Clothing check
    if "wearing" not in prompt_lower and "shorts" not in prompt_lower:
        missing.append("clothing description")
    if "barefoot" not in prompt_lower:
        missing.append("barefoot mention")
    
    # Environment check (critical for diorama)
    if is_diorama:
        if "environment:" not in prompt_lower:
            missing.append("ENVIRONMENT: label")
        if "cyclorama" not in prompt_lower and "cyan-blue" not in prompt_lower:
            missing.append("cyclorama/cyan-blue backdrop")
    
    # Camera/Lighting check
    if "camera:" not in prompt_lower:
        missing.append("Camera: label")
    if "lighting:" not in prompt_lower:
        missing.append("Lighting: label")
    
    # Final phrase check
    if "photorealistic" not in prompt_lower:
        missing.append("Photorealistic ending")
    
    if missing:
        logger.warning(
            "Scene %d image_prompt missing elements: %s",
            scene_number, ", ".join(missing)
        )
    
    return missing


def _validate_narrative_continuity(scenes: list, total_scenes: int) -> list[str]:
    """Validate narrative continuity across all scenes.
    
    Checks that:
    - ground_surface evolves (not all identical)
    - accumulated_props grow over time
    - companion_state follows ENTRY → PRESENCE → LOSS pattern
    - clothing_condition degrades appropriately
    
    Returns list of warnings.
    """
    warnings = []
    
    if len(scenes) < 2:
        return warnings
    
    # Check ground_surface evolution
    ground_surfaces = [s.ground_surface for s in scenes if s.ground_surface]
    if ground_surfaces and len(set(ground_surfaces)) == 1:
        warnings.append("ground_surface is identical across all scenes - should evolve with narrative")
    
    # Check accumulated_props growth
    props_list = [s.accumulated_props for s in scenes if s.accumulated_props]
    if props_list:
        # Simple check: later scenes should have more content (longer strings or more items)
        first_props = props_list[0] if props_list else ""
        last_props = props_list[-1] if props_list else ""
        if len(last_props) < len(first_props):
            warnings.append("accumulated_props should grow over time, but last scene has fewer props than first")
    
    # Check companion_state lifecycle
    companion_states = [s.companion_state for s in scenes if s.companion_state]
    if companion_states:
        # Check for proper lifecycle: should see "absent" early, "present" in middle, potentially "lost" at end
        has_entry = any("found" in s.lower() or "entry" in s.lower() or "just" in s.lower() for s in companion_states)
        has_presence = any("present" in s.lower() or "nearby" in s.lower() for s in companion_states)
        has_loss = any("lost" in s.lower() or "gone" in s.lower() for s in companion_states)
        
        if has_presence and not has_entry:
            warnings.append("companion_state shows presence but no entry moment - consider adding introduction scene")
    
    # Check clothing_condition degradation
    clothing_conditions = [s.clothing_condition for s in scenes if s.clothing_condition]
    if clothing_conditions:
        degradation_keywords = ["dirty", "worn", "faded", "torn", "ragged", "stained"]
        first_condition = clothing_conditions[0].lower()
        last_condition = clothing_conditions[-1].lower()
        
        first_degraded = any(kw in first_condition for kw in degradation_keywords)
        last_degraded = any(kw in last_condition for kw in degradation_keywords)
        
        if first_degraded and not last_degraded:
            warnings.append("clothing_condition starts degraded but ends clean - should degrade over time")
    
    if warnings:
        for w in warnings:
            logger.warning("Narrative continuity issue: %s", w)
    
    return warnings


class PromptEngineerAgent(BaseAgent):
    """Generates full image + video prompts via a single streaming LLM call."""

    def generate(
        self,
        frames: ExpandedFrameBreakdown,
        character_template: CharacterTemplate,
        world_style: Optional[WorldStyle] = None,
        clothing_style: Optional[ClothingStyle] = None,
        production_rules: Optional[ProductionRules] = None,
        product_placement: Optional["ProductPlacement"] = None,
        on_progress: Optional[Callable[[int, int], None]] = None,
        on_scene_ready: Optional[Callable[[ScenePrompts], None]] = None,
        max_retries: int = 3,
    ) -> AllScenePrompts:
        """Generate prompts for all frames in one streaming LLM call.

        Args:
            frames: Expanded frame breakdown from the Frame Expander agent.
            character_template: Character template with physical description.
            world_style: Optional world/environment style preset.
            clothing_style: Optional clothing style preset.
            production_rules: Optional production rules preset.
            on_progress: Callback(current, total) fired as each scene completes.
            on_scene_ready: Callback(scene) fired with each parsed ScenePrompts.
            max_retries: Max retries for failed/missing scenes via instructor fallback.
        """
        total = len(frames.frames)
        results: list[ScenePrompts | None] = [None] * total
        completed = 0

        # ── Build prompts ────────────────────────────────────────────
        system, user = self._build_prompts(
            frames.frames, character_template, world_style, clothing_style, production_rules, product_placement
        )

        # ── Stream from LLM ──────────────────────────────────────────
        logger.info(
            "Streaming prompt generation for %d frames in a single call (model=%s)",
            total, self.model,
        )

        try:
            text_stream = self._stream_text(system, user)

            extractor = StreamingJsonExtractor()
            for text_chunk in text_stream:
                json_objects = extractor.feed(text_chunk)
                for obj_str in json_objects:
                    try:
                        scene = ScenePrompts.model_validate_json(obj_str)
                        idx = scene.scene_number - 1  # 1-indexed → 0-indexed
                        if 0 <= idx < total:
                            # Validate image_prompt has required elements
                            is_diorama = world_style and world_style.preset_type == "diorama"
                            _validate_image_prompt(
                                scene.image_prompt, 
                                scene.scene_number, 
                                is_diorama=is_diorama
                            )
                            
                            results[idx] = scene
                            completed += 1
                            logger.debug(
                                "Streamed scene %d/%d: %s",
                                scene.scene_number, total,
                                scene.environment[:50] if scene.environment else "?",
                            )
                            if on_progress:
                                on_progress(completed, total)
                            if on_scene_ready:
                                on_scene_ready(scene)
                        else:
                            logger.warning(
                                "Scene number %d out of range (total=%d)",
                                scene.scene_number, total,
                            )
                    except (ValidationError, json.JSONDecodeError) as e:
                        logger.warning(
                            "Failed to parse streamed JSON object: %s — %s",
                            obj_str[:100], e,
                        )

        except Exception:
            logger.warning(
                "Streaming call failed, will retry missing scenes individually",
                exc_info=True,
            )

        # ── Retry missing scenes via instructor fallback ─────────────
        failed = [i for i, r in enumerate(results) if r is None]
        if failed:
            logger.info(
                "%d/%d scenes missing after stream — retrying individually: %s",
                len(failed), total, [i + 1 for i in failed],
            )

        for attempt in range(1, max_retries + 1):
            if not failed:
                break
            logger.info(
                "Retrying %d scene(s), attempt %d/%d",
                len(failed), attempt, max_retries,
            )
            still_failed = []
            for idx in failed:
                try:
                    scene = self._generate_single(
                        frames.frames[idx], character_template
                    )
                    results[idx] = scene
                    completed += 1
                    if on_progress:
                        on_progress(completed, total)
                    if on_scene_ready:
                        on_scene_ready(scene)
                except Exception:
                    logger.warning(
                        "Frame %d retry %d failed", idx + 1, attempt,
                        exc_info=True,
                    )
                    still_failed.append(idx)
            failed = still_failed

        if failed:
            raise RuntimeError(
                f"Failed to generate prompts for frames: "
                f"{[i + 1 for i in failed]} after {max_retries} retries"
            )

        # Validate narrative continuity across all scenes
        valid_scenes = [s for s in results if s is not None]
        _validate_narrative_continuity(valid_scenes, total)

        return AllScenePrompts(scenes=results)

    # ── Prompt building ───────────────────────────────────────────────────────

    def _build_prompts(
        self,
        all_frames: list[Frame],
        template: CharacterTemplate,
        world_style: Optional[WorldStyle] = None,
        clothing_style: Optional[ClothingStyle] = None,
        prod_rules: Optional[ProductionRules] = None,
        product_placement: Optional["ProductPlacement"] = None,
    ) -> tuple[str, str]:
        """Build system + user prompts for ALL frames in one call.
        
        Uses decoupled style presets when provided, falling back to character template rules.
        """
        hard_prohibitions = ("The model MUST NOT generate:\n" + "\n".join(
            f"✗ {p}" for p in template.hard_prohibitions
        )) if template.hard_prohibitions else "None specified."

        # Get production rules - prefer preset, fall back to character template
        if prod_rules:
            consequence_philosophy = prod_rules.consequence_philosophy
            death_rules = prod_rules.death_scene_rules
            child_rules = prod_rules.child_rules
            zombie_rules = prod_rules.zombie_rules
            shot_intercalation = prod_rules.shot_intercalation_rules
        else:
            consequence_philosophy = getattr(template, 'consequence_philosophy', '') or (
                "Show the CONSEQUENCE, not the symptom. Each image must REPRESENT an action, "
                "not just literally illustrate what was said. The character is ALWAYS doing "
                "something that demonstrates the concept through visible physical consequences."
            )
            death_rules = getattr(template, 'death_scene_rules', '') or (
                "Overhead camera 100%. Asymmetric fall posture (head NOT facing camera). "
                "Pale blue-white bioluminescent fluid pooling around body."
            )
            child_rules = getattr(template, 'child_rules', '') or (
                "Small blurred indistinct human-shaped silhouette — NO facial features, "
                "NO detailed body, purely abstract shape."
            )
            zombie_rules = getattr(template, 'zombie_rules', '') or (
                "Grey-green desaturated skin, unnatural posture, empty eyes. "
                "ZERO wounds, ZERO decomposition, ZERO gore."
            )
            shot_intercalation = getattr(template, 'shot_intercalation_rules', '') or (
                "Opening: Wide → Medium → Close. Action: Low-angle → Profile → Macro. "
                "Climax: Eye-level → Extreme close → Medium. End: Macro → Overhead."
            )
        
        # Get clothing rules - prefer preset, fall back to character template
        if clothing_style:
            clothing_rule = (
                f"{clothing_style.clothing_rules}\n"
                f"OPACITY: {clothing_style.opacity_rules}\n"
                f"MAX PIECES: {clothing_style.max_pieces}"
            )
        else:
            clothing_rule = template.clothing_rule
        
        # Build environment format based on world style (diorama vs realistic)
        if world_style and world_style.preset_type == "diorama":
            environment_format = '''FOR DIORAMA STYLE — USE THIS EXACT FORMAT:

"ENVIRONMENT: Solid flat cyan-blue seamless infinite backdrop — cyclorama style, 
NO ceiling, NO visible walls, NO corners, NO architectural elements, NO light fixtures. 
The blue simply exists in all directions with no end point. Ambient soft diffuse light 
with no visible source. Ground: [THEMATIC FLOOR — e.g., flat implied floor surface, 
grass patch, sand area, concrete slab]. Props: [2-4 SIMPLE CLEAN PROPS ONLY — e.g., 
simple clean rectangular table, crumpled paper bill, smartphone]. 
Objects clean 3D rendered — simple geometry, minimal texture, no photographic detail."

CRITICAL DIORAMA RULES:
- Background is ALWAYS solid flat cyan-blue infinite cyclorama
- NO architecture: no walls, no ceilings, no rooms, no buildings
- NO light fixtures visible — light exists without source
- Props are MINIMAL: 2-4 simple clean objects maximum
- Props described as "clean 3D rendered, simple geometry, minimal texture"
- Floor is a THEMATIC PATCH on the blue, not infinite
- Secondary characters have "smooth plastic-like skin, matte opaque finish, mannequin quality"'''
        else:
            environment_format = '''FOR REALISTIC STYLE:

Describe the environment naturally based on the script context. Include:
- Location (city, forest, home, street, etc.)
- Architectural elements if appropriate (walls, ceiling, furniture)
- Lighting sources (sunlight, streetlights, lamps)
- Props and objects that support the narrative
- Photorealistic detail and texture'''

        # Check if frames have coverage metadata
        has_coverage = any(
            getattr(frame, 'coverage_group_id', None) 
            for frame in all_frames
        )
        
        if has_coverage:
            coverage_instructions = (
                "COVERAGE MODE ACTIVE: Some frames share the same coverage_group_id.\n"
                "Frames with the SAME coverage_group_id show the SAME SCENE from DIFFERENT ANGLES.\n\n"
                "CRITICAL RULES FOR COVERAGE FRAMES:\n"
                "- Frames with same coverage_group_id MUST have IDENTICAL:\n"
                "  • Environment description (verbatim)\n"
                "  • Character pose/action description (verbatim)\n"
                "  • Lighting conditions\n"
                "  • sync_word\n\n"
                "- ONLY vary between coverage frames:\n"
                "  • Camera angle (wide/medium/close-up)\n"
                "  • Composition\n"
                "  • Visual emphasis\n"
                "  • camera_move (use subtle movements for coverage)\n\n"
                "EXAMPLE: If coverage_group_id='coverage_1' has 3 frames:\n"
                "  Frame 1 (master): Wide shot of skeleton sitting on couch\n"
                "  Frame 2 (medium): Medium shot of skeleton sitting on couch (lateral angle)\n"
                "  Frame 3 (close-up): Close-up of skeleton sitting on couch (face detail)\n"
                "ALL THREE show the SAME ACTION, just different camera angles."
            )
        else:
            coverage_instructions = "Coverage mode is not active for these frames."

        system = SYSTEM_PROMPT.format(
            total_frames=len(all_frames),
            hard_prohibitions=hard_prohibitions,
            anatomical_rules=template.anatomical_highlight_rules,
            clothing_rule=clothing_rule,
            consequence_philosophy=consequence_philosophy,
            death_rules=death_rules,
            child_rules=child_rules,
            zombie_rules=zombie_rules,
            shot_intercalation=shot_intercalation,
            coverage_instructions=coverage_instructions,
            environment_format=environment_format,
        )

        frame_sections = []
        for frame in all_frames:
            anat_hint = ""
            if frame.anatomical_highlight:
                anat_hint = f"\n  Anatomical highlight: {frame.anatomical_highlight}"
            
            # Add coverage metadata if present
            coverage_hint = ""
            coverage_group = getattr(frame, 'coverage_group_id', None)
            if coverage_group:
                angle_type = getattr(frame, 'coverage_angle_type', 'unknown')
                angle_idx = getattr(frame, 'coverage_angle_index', 0)
                coverage_hint = (
                    f"\n  [COVERAGE] Group: {coverage_group}, "
                    f"Angle: {angle_idx} ({angle_type})"
                )

            section = (
                f"═══════════════════════════════════════════════════════════════════\n"
                f"FRAME {frame.frame_number}\n"
                f"═══════════════════════════════════════════════════════════════════\n"
                f"  Environment: {frame.environment_detail}\n"
                f"  Pose/Action: {frame.pose_action}\n"
                f"  Mood: {frame.mood}\n"
                f"  Visual focus: {frame.visual_focus}\n"
                f"  Camera shot: {frame.camera_shot}\n"
                f"  Camera movement: {frame.camera_movement}\n"
                f"  Composition: {frame.composition}\n"
                f"  Narration text: {frame.narration_text}"
                f"{anat_hint}"
                f"{coverage_hint}"
            )
            frame_sections.append(section)

        # Build environment/world rules - prefer preset, fall back to character template
        if world_style:
            environment_rules = (
                f"WORLD STYLE: {world_style.name} ({world_style.preset_type})\n"
                f"BACKGROUND: {world_style.background_rules}\n"
                f"FLOOR: {world_style.floor_rules}\n"
                f"SECONDARY CHARACTERS: {world_style.secondary_characters_rules}\n"
                f"LIGHTING: {world_style.lighting_rules}\n"
                f"PROPS: {world_style.props_rules}\n"
                f"ARCHITECTURE ALLOWED: {'Yes' if world_style.architecture_allowed else 'No - use infinite cyclorama background'}"
            )
        else:
            environment_rules = f"ENVIRONMENT RULE: {template.environment_rule}"
        
        # Get pose rule - prefer production rules preset, fall back to character template
        pose_rule = prod_rules.pose_rule if prod_rules else template.pose_rule
        
        # Build product placement instructions if enabled
        product_placement_rules = ""
        if product_placement and product_placement.enabled and product_placement.product_description:
            product_name = product_placement.product_name or "the product"
            brand = f" ({product_placement.brand_name})" if product_placement.brand_name else ""
            product_placement_rules = (
                f"\n\n═══════════════════════════════════════════════════════════════════\n"
                f"PRODUCT PLACEMENT / MERCHANDISING\n"
                f"═══════════════════════════════════════════════════════════════════\n"
                f"PRODUCT: {product_name}{brand}\n"
                f"DESCRIPTION: {product_placement.product_description}\n\n"
                f"INTEGRATION RULES:\n"
                f"- Naturally integrate {product_name} into scenes where it makes contextual sense\n"
                f"- The character should USE or INTERACT with the product when appropriate\n"
                f"- Do NOT force the product into every frame - only where it fits the story\n"
                f"- Product should be clearly visible but not dominate the composition\n"
                f"- Maintain photorealistic rendering of the product\n"
                f"- When the product appears, describe it accurately based on the description above"
            )
        
        user = (
            "\n\n".join(frame_sections)
            + f"\n\n═══════════════════════════════════════════════════════════════════\n"
            f"CHARACTER TEMPLATE (COPY VERBATIM INTO EVERY IMAGE PROMPT)\n"
            f"═══════════════════════════════════════════════════════════════════\n"
            f"{template.physical_description}\n\n"
            f"═══════════════════════════════════════════════════════════════════\n"
            f"RULES (APPLY TO ALL FRAMES)\n"
            f"═══════════════════════════════════════════════════════════════════\n"
            f"POSE RULE: {pose_rule}\n"
            f"{environment_rules}\n"
            f"CLOTHING RULE: {clothing_rule}"
            f"{product_placement_rules}"
        )

        return system, user

    # ── Provider-agnostic streaming ─────────────────────────────────────────

    def _stream_text(self, system: str, user: str) -> Iterator[str]:
        """Stream raw text chunks from the LLM, auto-detecting the provider."""
        provider = self.model.split("/", 1)[0].lower() if "/" in self.model else "openai"
        model_name = self.model.split("/", 1)[-1]

        if provider == "anthropic":
            yield from self._stream_anthropic(model_name, system, user)
        else:
            yield from self._stream_openai(model_name, system, user)

    @staticmethod
    def _stream_anthropic(model: str, system: str, user: str) -> Iterator[str]:
        """Stream text from Anthropic Messages API."""
        import anthropic
        client = anthropic.Anthropic()  # uses ANTHROPIC_API_KEY from env

        with client.messages.stream(
            model=model,
            max_tokens=16384,
            system=system,
            messages=[{"role": "user", "content": user}],
            temperature=0.6,
        ) as stream:
            for text in stream.text_stream:
                yield text

    @staticmethod
    def _stream_openai(model: str, system: str, user: str) -> Iterator[str]:
        """Stream text from OpenAI Chat Completions API."""
        import openai
        client = openai.OpenAI()  # uses OPENAI_API_KEY from env

        stream = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": system},
                {"role": "user", "content": user},
            ],
            temperature=0.6,
            stream=True,
        )
        for chunk in stream:
            delta = chunk.choices[0].delta if chunk.choices else None
            if delta and delta.content:
                yield delta.content

    # ── Instructor fallback for single frames ─────────────────────────────────

    def _generate_single(
        self,
        frame: Frame,
        template: CharacterTemplate,
    ) -> ScenePrompts:
        """Generate prompt for a single frame using instructor (structured output)."""
        hard_prohibitions = ("The model MUST NOT generate:\n" + "\n".join(
            f"✗ {p}" for p in template.hard_prohibitions
        )) if template.hard_prohibitions else "None specified."

        # Get new template fields with defaults (same as streaming version)
        consequence_philosophy = getattr(template, 'consequence_philosophy', '') or (
            "Show the CONSEQUENCE, not the symptom. Each image must REPRESENT an action, "
            "not just literally illustrate what was said. The character is ALWAYS doing "
            "something that demonstrates the concept through visible physical consequences."
        )
        death_rules = getattr(template, 'death_rules', '') or ""
        child_rules = getattr(template, 'child_rules', '') or ""
        zombie_rules = getattr(template, 'zombie_rules', '') or ""

        # Shot intercalation for single frame fallback
        shot_intercalation = "Single frame mode - maintain visual variety."
        coverage_instructions = "Coverage mode is not active for single frame fallback."
        
        # Default to realistic environment format for single frame fallback
        environment_format = '''FOR REALISTIC STYLE:

Describe the environment naturally based on the script context. Include:
- Location (city, forest, home, street, etc.)
- Architectural elements if appropriate (walls, ceiling, furniture)
- Lighting sources (sunlight, streetlights, lamps)
- Props and objects that support the narrative
- Photorealistic detail and texture'''

        system = SYSTEM_PROMPT.format(
            total_frames=1,
            hard_prohibitions=hard_prohibitions,
            anatomical_rules=template.anatomical_highlight_rules,
            clothing_rule=template.clothing_rule,
            consequence_philosophy=consequence_philosophy,
            death_rules=death_rules,
            child_rules=child_rules,
            zombie_rules=zombie_rules,
            shot_intercalation=shot_intercalation,
            coverage_instructions=coverage_instructions,
            environment_format=environment_format,
        )

        anat_hint = ""
        if frame.anatomical_highlight:
            anat_hint = f"\n  Anatomical highlight: {frame.anatomical_highlight}"

        user = (
            f"FRAME {frame.frame_number}\n"
            f"  Environment: {frame.environment_detail}\n"
            f"  Pose/Action: {frame.pose_action}\n"
            f"  Mood: {frame.mood}\n"
            f"  Visual focus: {frame.visual_focus}\n"
            f"  Camera shot: {frame.camera_shot}\n"
            f"  Camera movement: {frame.camera_movement}\n"
            f"  Composition: {frame.composition}\n"
            f"  Narration text: {frame.narration_text}"
            f"{anat_hint}\n\n"
            f"CHARACTER TEMPLATE (COPY VERBATIM):\n"
            f"{template.physical_description}\n\n"
            f"POSE RULE: {template.pose_rule}\n"
            f"ENVIRONMENT RULE: {template.environment_rule}\n"
            f"CLOTHING RULE: {template.clothing_rule}"
        )

        return self.call(
            system_prompt=system,
            user_prompt=user,
            response_model=ScenePrompts,
            temperature=0.6,
        )
