"""
Frame Expander Agent – expands narrative beats into multiple cinematic frames.

Takes 8-10 narrative beats from ScriptAnalyzer and expands each into 2-4
individual frames with varied camera angles, compositions, and visual
progression. Targets ~25 frames per minute of video.

Uses parallel batching (2-3 concurrent LLM calls) for faster processing.
"""

from __future__ import annotations

import logging
from concurrent.futures import ThreadPoolExecutor, as_completed

from models import (
    ExpandedFrameBreakdown,
    PipelineConfig,
    SceneBreakdown,
    TargetPlatform,
)
from agents.base_agent import BaseAgent

logger = logging.getLogger(__name__)


SYSTEM_PROMPT = """\
You are an expert cinematographer and visual storyteller. Your job is to take
narrative beats (scenes) from a script and EXPAND each one into multiple
individual FRAMES with varied camera angles, creating rich cinematic progression.

You MUST generate exactly {target_frames} frames total. NO MORE, NO LESS.
This is a HARD LIMIT — even if there are many beats, you must stay within {target_frames} frames.

{frames_per_beat_instruction}

Follow cinematic grammar:

═══════════════════════════════════════════════════════════════════════════════
CAMERA SHOT VOCABULARY (MANDATORY VARIATION)
═══════════════════════════════════════════════════════════════════════════════
Use these shot types and VARY them across frames:
• Eye-level / Chest-level – General action and dialogue scenes
• Low-angle / Contre-plongée – Power, strength, struggle, climbing
• High-angle / Plongée – Vulnerability, introspection, defeat
• Overhead / Bird's eye – Scale, isolation, plot twist, death scenes
• 3/4 frontal – Facial expression + body language simultaneously
• Side profile – Sprint, breathing, arm movement, action profile
• Over-the-shoulder – POV of pursuit, navigation in space
• Extreme macro / Close-up – Finger on trigger, muscles contracting, anatomical tissue
• Wide shot / Establishing – Complete environment, scale of chaos, solitude

═══════════════════════════════════════════════════════════════════════════════
CAMERA MOVEMENT VOCABULARY (CINEMATIC)
═══════════════════════════════════════════════════════════════════════════════
• LOCKED_STATIC – Camera fixed, character moves
• SUBTLE_DRIFT – Almost imperceptible micro-movement
• SLOW_PAN_LEFT / SLOW_PAN_RIGHT – Slow horizontal rotation
• 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
• HANDHELD – Organic movement with tremor
• CRANE_UP / CRANE_DOWN – Physical vertical movement
• OVERHEAD_STATIC – Bird's eye looking down
• OVERHEAD_SLOW_PULLBACK – Bird's eye slowly retreating

═══════════════════════════════════════════════════════════════════════════════
COMPOSITION VOCABULARY
═══════════════════════════════════════════════════════════════════════════════
• centered – subject in center
• rule of thirds left / rule of thirds right
• foreground emphasis – object in front, subject behind
• background depth – deep focus showing layers
• symmetrical – balanced frame
• diagonal – dynamic diagonal lines
• negative space – subject small in frame, lots of empty space

═══════════════════════════════════════════════════════════════════════════════
SHOT INTERCALATION (MANDATORY)
═══════════════════════════════════════════════════════════════════════════════
OPENING (scenes 1-3): Wide establishing → Eye-level medium → Close-up reaction
INTENSE ACTION (scenes 4-8): Low-angle action → Side profile sprint → Over-shoulder → Macro detail → Wide chaos
NARRATIVE TRANSITION (scenes 9-12): Overhead aerial → Eye-level contemplative → High-angle vulnerability
SURVIVAL (scenes 13-17): Medium environment → Macro hands/anatomical detail → 3/4 frontal → Wide isolation
EMOTIONAL CLIMAX (scenes 18-24): Eye-level intimate → Extreme close skull → Medium two-shot → Macro trigger/detail
PLOT TWIST/END (scenes 25-30): Extreme macro → Medium agony → Overhead aerial final → Fade to black

RULES:
- RULE OF THIRDS: Every 3 scenes, at least 1 must be extreme close or anatomical macro
- AERIAL RULE: Massive scale scenes (city, crowd, isolation) = always overhead or wide
- CLIMAX RULE: Last 3 prompts before plot twist must have progressively tighter shots

═══════════════════════════════════════════════════════════════════════════════
CINEMATIC PROGRESSION
═══════════════════════════════════════════════════════════════════════════════
{phase_hint}

═══════════════════════════════════════════════════════════════════════════════
NARRATION DISTRIBUTION
═══════════════════════════════════════════════════════════════════════════════
Split each beat's narration text across its frames. Each frame gets a portion
of the narration that matches its visual content. Short frames may share
narration with adjacent frames (use "..." for continuation).
- All narration_text MUST remain in Brazilian Portuguese (pt-BR)
- When splitting narration across frames, keep the natural pt-BR rhythm and phrasing

═══════════════════════════════════════════════════════════════════════════════
SYNC WORD RULE
═══════════════════════════════════════════════════════════════════════════════
For EACH frame, extract the EXACT WORD or SHORT PHRASE (1-5 words) from the
narration text that this frame best represents. This is the editing anchor —
the moment in the audio where this image should cut in.

═══════════════════════════════════════════════════════════════════════════════
ANATOMICAL HIGHLIGHT RULE (REAL ANATOMY)
═══════════════════════════════════════════════════════════════════════════════
If the narration mentions a BODY PART, ORGAN, HEALTH CONDITION, PAIN, or
PHYSICAL SYMPTOM, set anatomical_highlight to describe the REAL ANATOMICAL
structure with medical accuracy:
- Muscles with fibers, fascicles, tendons with correct anatomical names
- Organs with structure, lobes, organic texture
- NEVER use generic "glow" — always real anatomical visualization

═══════════════════════════════════════════════════════════════════════════════
GOLDEN RULES
═══════════════════════════════════════════════════════════════════════════════
• Generate EXACTLY {target_frames} frames
• NEVER repeat the same camera shot type in consecutive frames
• NEVER use more than 2 "static" camera movements in a row
• NEVER repeat identical poses in back-to-back frames
• Consecutive scenes MUST CONTRAST in camera type
• Every 3 scenes: at least 1 extreme close or anatomical macro
• Each frame must advance the visual story
• Visual focus should shift between character, environment, and details
• Every frame MUST have narration_text (never empty)
• Every frame MUST have sync_word (1-5 words from narration)
• Vary environments across frames for visual dynamism\
"""

FULL_ARC_HINT = """\
Full story arc — maintain this progression:
1. HOOK (first 2 frames): STRIKING wide/establishing shot, then dramatic close-up
2. BUILDING (frames 3-60%): Alternate medium shots and close-ups, vary angles
3. CLIMAX (60-85%): Extreme close-ups, dutch angles, rapid angle changes
4. RESOLUTION (final 15%): Wider shots, slower movements, stable compositions"""

PHASE_HINTS = {
    2: [
        "OPENING half — HOOK + BUILD:\n"
        "Start with a STRIKING establishing shot, then dramatic close-up.\n"
        "Build with alternating medium shots and close-ups, varied angles.",
        "CLOSING half — CLIMAX + RESOLVE:\n"
        "Use extreme close-ups, dutch angles, rapid changes for intensity.\n"
        "End with wider shots, slower movements for emotional landing.",
    ],
    3: [
        "OPENING section — HOOK + EARLY BUILD:\n"
        "Start with a STRIKING establishing shot, then dramatic close-up.\n"
        "Begin building with varied medium shots.",
        "MIDDLE section — LATE BUILD + CLIMAX:\n"
        "Intensify with more close-ups, low/high angles.\n"
        "Push toward visual climax with extreme angles and rapid cuts.",
        "CLOSING section — LATE CLIMAX + RESOLVE:\n"
        "Peak intensity then gradually return to wider, calmer shots.\n"
        "End with stable compositions for emotional landing.",
    ],
}

PLATFORM_FRAME_TARGETS: dict[str, int] = {
    "tiktok": 25,       # ~25 frames for 60s max
    "instagram_reels": 20,  # ~20 frames for 60s
    "youtube_shorts": 25,   # ~25 frames for 60s
    "youtube": 50,       # ~50 frames for 120s
}

# Coverage Shots System Prompt
COVERAGE_SYSTEM_PROMPT = """\
You are an expert cinematographer creating COVERAGE SHOTS for cinematic storytelling.

COVERAGE SHOTS means generating MULTIPLE FRAMES of the EXACT SAME SCENE from DIFFERENT CAMERA ANGLES.
This technique allows editors to cut between angles for dynamic visual storytelling.

You MUST generate exactly {total_coverage_frames} frames total.
There are {num_beats} narrative beats, and each beat gets {angles_per_beat} camera angles.

═══════════════════════════════════════════════════════════════════════════════
COVERAGE RULES (CRITICAL)
═══════════════════════════════════════════════════════════════════════════════

For EACH narrative beat, generate {angles_per_beat} frames showing the EXACT SAME ACTION 
from DIFFERENT CAMERA ANGLES.

1. ALL frames in a coverage group MUST share:
   - IDENTICAL environment description (verbatim)
   - IDENTICAL character pose/action (verbatim)
   - IDENTICAL lighting conditions
   - SAME sync_word
   - SAME coverage_group_id

2. ONLY these elements change between coverage frames:
   - camera_shot (wide → medium → close-up)
   - camera_movement
   - composition
   - visual_focus

3. COVERAGE ANGLE SEQUENCE (for {angles_per_beat} angles):
{angle_sequence}

═══════════════════════════════════════════════════════════════════════════════
CAMERA SHOT VOCABULARY
═══════════════════════════════════════════════════════════════════════════════
• Wide shot / Establishing – Full scene, character + environment
• Medium-wide shot – Knees up, shows body language
• Medium shot – Waist up, balanced view
• Medium close-up – Chest up, emotional connection
• Close-up – Face or specific detail
• Extreme close-up – Eyes, hands, texture detail
• Low angle – Looking up (power, dominance)
• High angle – Looking down (vulnerability)
• Side profile – Lateral view, action profile
• 3/4 frontal – Face + body language
• Over-the-shoulder – POV perspective

═══════════════════════════════════════════════════════════════════════════════
OUTPUT FORMAT
═══════════════════════════════════════════════════════════════════════════════

For each frame, include:
- frame_number: Sequential number starting at 1
- parent_scene: The beat number this frame belongs to
- coverage_group_id: "coverage_X" where X is the beat number
- coverage_angle_index: 0 for master, 1 for medium, 2 for close-up, etc.
- coverage_angle_type: "master", "medium", "close-up", "reverse", or "insert"
- camera_shot, camera_movement, composition
- environment_detail, pose_action, mood, visual_focus
- narration_text: The narration for this beat (same for all angles in group)
- sync_word: Key word from narration (same for all angles in group)

CONSISTENCY CHECK:
If Frame 1 shows "skeleton sitting on couch in living room"
Then Frame 2 MUST show "skeleton sitting on couch in living room"
And Frame 3 MUST show "skeleton sitting on couch in living room"
ONLY the camera angle changes.\
"""

COVERAGE_ANGLE_SEQUENCES = {
    2: """   - Angle 0 (MASTER): Wide/establishing shot, shows full scene
   - Angle 1 (CLOSE-UP): Detail shot (face, hands, anatomical)""",
    3: """   - Angle 0 (MASTER): Wide/establishing shot, shows full scene
   - Angle 1 (MEDIUM): Different angle (lateral, 3/4, over-shoulder)
   - Angle 2 (CLOSE-UP): Detail shot (face, hands, anatomical)""",
    4: """   - Angle 0 (MASTER): Wide/establishing shot, shows full scene
   - Angle 1 (MEDIUM): Medium shot from different angle
   - Angle 2 (CLOSE-UP): Detail shot (face, hands)
   - Angle 3 (REVERSE): Over-the-shoulder or reverse angle""",
    5: """   - Angle 0 (MASTER): Wide/establishing shot, shows full scene
   - Angle 1 (MEDIUM): Medium shot from lateral angle
   - Angle 2 (CLOSE-UP): Close-up of face/expression
   - Angle 3 (INSERT): Detail insert (hands, object, anatomical)
   - Angle 4 (REVERSE): Over-the-shoulder or reverse angle""",
}


class FrameExpanderAgent(BaseAgent):
    """Expands narrative beats into cinematic frames with camera variety.

    Uses parallel batching: splits beats into 2-3 groups and generates frames
    for each group concurrently, then merges results. This halves or thirds
    the generation time compared to a single monolithic call.
    """

    def expand(
        self,
        breakdown: SceneBreakdown,
        config: PipelineConfig,
    ) -> ExpandedFrameBreakdown:
        # Check if coverage mode is enabled
        if getattr(config, 'enable_coverage_shots', False):
            coverage_mode = getattr(config, 'coverage_mode', 'all')
            if coverage_mode == 'smart':
                return self._expand_with_smart_coverage(breakdown, config)
            else:
                return self._expand_with_coverage(breakdown, config)
        
        # Standard expansion mode
        return self._expand_standard(breakdown, config)
    
    def _expand_standard(
        self,
        breakdown: SceneBreakdown,
        config: PipelineConfig,
    ) -> ExpandedFrameBreakdown:
        """Standard frame expansion without coverage shots."""
        # Calculate target frame count — user value takes priority
        target_frames = config.frames_per_minute

        beats = breakdown.scenes
        num_beats = len(beats)

        # Small stories or low frame targets: single call
        if num_beats <= 4 or target_frames <= 15:
            result = self._expand_batch(
                beats, breakdown, target_frames,
                phase_hint=FULL_ARC_HINT,
                frame_offset=0,
            )
            return self._enforce_frame_limit(result, target_frames)

        # Split into parallel batches
        num_batches = 3 if num_beats >= 8 else 2
        chunk_size = num_beats // num_batches
        remainder = num_beats % num_batches

        chunks: list[list] = []
        start = 0
        for i in range(num_batches):
            size = chunk_size + (1 if i < remainder else 0)
            chunks.append(beats[start:start + size])
            start += size

        # Distribute frame targets proportionally
        frames_per = target_frames // num_batches
        frame_rem = target_frames % num_batches
        frame_targets = [
            frames_per + (1 if i < frame_rem else 0)
            for i in range(num_batches)
        ]

        hints = PHASE_HINTS[num_batches]

        # Calculate frame number offsets
        offsets = []
        offset = 0
        for ft in frame_targets:
            offsets.append(offset)
            offset += ft

        logger.info(
            "Expanding %d beats into %d frames using %d parallel batches",
            num_beats, target_frames, num_batches,
        )

        # Run batches in parallel
        results: list[ExpandedFrameBreakdown | None] = [None] * num_batches
        with ThreadPoolExecutor(max_workers=num_batches) as pool:
            futures = {}
            for i, (chunk, ft, hint, off) in enumerate(
                zip(chunks, frame_targets, hints, offsets)
            ):
                futures[pool.submit(
                    self._expand_batch, chunk, breakdown, ft, hint, off,
                )] = i

            for future in as_completed(futures):
                i = futures[future]
                results[i] = future.result()
                logger.info("Batch %d/%d complete", i + 1, num_batches)

        # Merge all frames in order
        all_frames = []
        for r in results:
            all_frames.extend(r.frames)

        merged = ExpandedFrameBreakdown(
            title=results[0].title,
            summary=results[0].summary,
            total_frames=len(all_frames),
            frames=all_frames,
        )
        return self._enforce_frame_limit(merged, target_frames)

    def _expand_with_coverage(
        self,
        breakdown: SceneBreakdown,
        config: PipelineConfig,
    ) -> ExpandedFrameBreakdown:
        """Generate coverage shots - multiple camera angles per scene.
        
        Respects frames_per_minute: if user requests 6 frames with 3 angles,
        we generate 2 scenes × 3 angles = 6 frames total.
        """
        beats = breakdown.scenes
        num_beats = len(beats)
        angles_per_beat = getattr(config, 'coverage_angles_per_scene', 3)
        target_frames = config.frames_per_minute  # Respect user's frame count
        
        # Calculate how many scenes we can cover with the target frame count
        # target_frames ÷ angles_per_beat = number of scenes to use
        num_scenes_to_use = max(1, target_frames // angles_per_beat)
        
        # Don't use more scenes than we have beats
        num_scenes_to_use = min(num_scenes_to_use, num_beats)
        
        # Recalculate total frames based on actual scenes used
        total_frames = num_scenes_to_use * angles_per_beat
        
        # Select the most important beats (evenly distributed across the story)
        if num_scenes_to_use < num_beats:
            step = num_beats / num_scenes_to_use
            selected_indices = [int(i * step) for i in range(num_scenes_to_use)]
            selected_beats = [beats[i] for i in selected_indices]
        else:
            selected_beats = beats
        
        logger.info(
            "Coverage mode: Using %d of %d beats, generating %d frames (%d angles per beat)",
            num_scenes_to_use, num_beats, total_frames, angles_per_beat,
        )
        
        # Get angle sequence for this number of angles
        angle_sequence = COVERAGE_ANGLE_SEQUENCES.get(
            angles_per_beat,
            COVERAGE_ANGLE_SEQUENCES[3]  # Default to 3 angles
        )
        
        system = COVERAGE_SYSTEM_PROMPT.format(
            total_coverage_frames=total_frames,
            num_beats=num_scenes_to_use,
            angles_per_beat=angles_per_beat,
            angle_sequence=angle_sequence,
        )
        
        beats_text = []
        for idx, s in enumerate(selected_beats):
            beats_text.append(
                f"Beat {idx + 1} (coverage_group_id: coverage_{idx + 1}):\n"
                f"  Environment: {s.environment}\n"
                f"  Pose/Action: {s.pose_action}\n"
                f"  Mood: {s.mood}\n"
                f"  Narration: {s.narration_text}\n"
                f"  Notes: {s.narration_notes}\n"
                f"  → Generate {angles_per_beat} frames with DIFFERENT camera angles for this SAME scene"
            )
        
        user = (
            f"VIDEO TITLE: {breakdown.title}\n"
            f"SUMMARY: {breakdown.summary}\n"
            f"TOTAL FRAMES TO GENERATE: {total_frames}\n"
            f"ANGLES PER BEAT: {angles_per_beat}\n"
            f"NARRATIVE BEATS ({num_scenes_to_use} selected beats):\n\n"
            + "\n\n".join(beats_text)
        )
        
        # More tokens needed for coverage (more frames)
        max_tokens = min(32768, max(8192, total_frames * 500))
        
        result = self.call(
            system_prompt=system,
            user_prompt=user,
            response_model=ExpandedFrameBreakdown,
            temperature=0.7,
            max_tokens=max_tokens,
        )
        
        # Ensure coverage metadata is set correctly
        result = self._ensure_coverage_metadata(result, num_scenes_to_use, angles_per_beat)
        
        return result
    
    def _expand_with_smart_coverage(
        self,
        breakdown: SceneBreakdown,
        config: PipelineConfig,
    ) -> ExpandedFrameBreakdown:
        """Smart Coverage: AI decides which scenes need multi-angle coverage.
        
        Analyzes each beat's mood, action, and narrative importance to determine
        if it benefits from multiple camera angles.
        """
        beats = breakdown.scenes
        num_beats = len(beats)
        angles_per_beat = getattr(config, 'coverage_angles_per_scene', 3)
        target_frames = config.frames_per_minute
        
        logger.info(
            "Smart Coverage mode: Analyzing %d beats to determine coverage needs",
            num_beats,
        )
        
        # Analyze each beat to determine if it needs coverage
        # Use the needs_coverage and coverage_priority fields from SceneAnalysis
        coverage_decisions = []
        for beat in beats:
            needs_cov = getattr(beat, 'needs_coverage', False)
            priority = getattr(beat, 'coverage_priority', 5)
            
            # If not already classified, use heuristics based on mood and action
            if not needs_cov:
                needs_cov = self._should_have_coverage(beat)
                priority = self._calculate_coverage_priority(beat)
            
            coverage_decisions.append({
                'beat': beat,
                'needs_coverage': needs_cov,
                'priority': priority,
            })
        
        # Sort by priority (highest first) for potential trimming
        coverage_decisions.sort(key=lambda x: x['priority'], reverse=True)
        
        # Calculate how many frames we'd generate
        frames_with_coverage = sum(
            angles_per_beat if d['needs_coverage'] else 1
            for d in coverage_decisions
        )
        
        # Adjust to fit target_frames
        if frames_with_coverage > target_frames:
            # Too many frames - remove coverage from lowest priority beats
            coverage_decisions = self._trim_coverage_to_fit(
                coverage_decisions, target_frames, angles_per_beat
            )
        elif frames_with_coverage < target_frames:
            # Too few frames - add coverage to more beats
            coverage_decisions = self._expand_coverage_to_fit(
                coverage_decisions, target_frames, angles_per_beat
            )
        
        # Re-sort by scene number for proper ordering
        coverage_decisions.sort(key=lambda x: x['beat'].scene_number)
        
        # Generate frames based on decisions
        all_frames = []
        frame_number = 1
        coverage_group = 1
        
        for decision in coverage_decisions:
            beat = decision['beat']
            if decision['needs_coverage']:
                # Generate multiple angles for this beat
                for angle_idx in range(angles_per_beat):
                    angle_types = ["master", "medium", "close-up", "reverse", "insert"]
                    angle_type = angle_types[min(angle_idx, len(angle_types) - 1)]
                    
                    frame = self._create_frame_from_beat(
                        beat, frame_number, 
                        coverage_group_id=f"coverage_{coverage_group}",
                        coverage_angle_index=angle_idx,
                        coverage_angle_type=angle_type,
                    )
                    all_frames.append(frame)
                    frame_number += 1
                coverage_group += 1
            else:
                # Single frame for this beat (no coverage)
                frame = self._create_frame_from_beat(beat, frame_number)
                all_frames.append(frame)
                frame_number += 1
        
        logger.info(
            "Smart Coverage: Generated %d frames (%d beats with coverage, %d without)",
            len(all_frames),
            sum(1 for d in coverage_decisions if d['needs_coverage']),
            sum(1 for d in coverage_decisions if not d['needs_coverage']),
        )
        
        return ExpandedFrameBreakdown(
            title=breakdown.title,
            summary=breakdown.summary,
            total_frames=len(all_frames),
            frames=all_frames,
        )
    
    def _should_have_coverage(self, beat) -> bool:
        """Heuristic to determine if a beat needs coverage based on mood/action."""
        mood = (getattr(beat, 'mood', '') or '').lower()
        action = (getattr(beat, 'pose_action', '') or '').lower()
        
        # High-intensity moods that benefit from coverage
        coverage_moods = [
            'intense', 'dramatic', 'tense', 'climactic', 'explosive',
            'fearful', 'triumphant', 'desperate', 'angry', 'terrified',
            'shocked', 'ecstatic', 'furious', 'agonizing', 'thrilling'
        ]
        
        # Action verbs that benefit from coverage
        coverage_actions = [
            'running', 'fighting', 'falling', 'jumping', 'struggling',
            'confronting', 'attacking', 'defending', 'escaping', 'chasing',
            'collapsing', 'exploding', 'transforming', 'screaming', 'crying'
        ]
        
        # Check mood
        for m in coverage_moods:
            if m in mood:
                return True
        
        # Check action
        for a in coverage_actions:
            if a in action:
                return True
        
        return False
    
    def _calculate_coverage_priority(self, beat) -> int:
        """Calculate priority score (1-10) for coverage."""
        mood = (getattr(beat, 'mood', '') or '').lower()
        action = (getattr(beat, 'pose_action', '') or '').lower()
        
        score = 5  # Default middle score
        
        # Boost for intense moods
        if any(m in mood for m in ['climactic', 'explosive', 'peak']):
            score = 10
        elif any(m in mood for m in ['intense', 'dramatic', 'tense']):
            score = 8
        elif any(m in mood for m in ['emotional', 'powerful', 'desperate']):
            score = 7
        
        # Boost for action
        if any(a in action for a in ['fighting', 'attacking', 'exploding']):
            score = max(score, 9)
        elif any(a in action for a in ['running', 'falling', 'jumping']):
            score = max(score, 7)
        
        # Lower for static/calm
        if any(m in mood for m in ['calm', 'peaceful', 'neutral', 'contemplative']):
            score = min(score, 3)
        if any(a in action for a in ['standing', 'sitting', 'lying']):
            score = min(score, 4)
        
        return score
    
    def _trim_coverage_to_fit(
        self, decisions: list, target: int, angles: int
    ) -> list:
        """Remove coverage from lowest priority beats to fit target frame count."""
        # Already sorted by priority (highest first)
        current_frames = sum(
            angles if d['needs_coverage'] else 1 for d in decisions
        )
        
        # Remove coverage from lowest priority beats
        for i in range(len(decisions) - 1, -1, -1):
            if current_frames <= target:
                break
            if decisions[i]['needs_coverage']:
                # Removing coverage saves (angles - 1) frames
                decisions[i]['needs_coverage'] = False
                current_frames -= (angles - 1)
        
        return decisions
    
    def _expand_coverage_to_fit(
        self, decisions: list, target: int, angles: int
    ) -> list:
        """Add coverage to more beats to reach target frame count."""
        current_frames = sum(
            angles if d['needs_coverage'] else 1 for d in decisions
        )
        
        # Add coverage to highest priority beats that don't have it
        for d in decisions:  # Already sorted by priority (highest first)
            if current_frames >= target:
                break
            if not d['needs_coverage']:
                # Adding coverage adds (angles - 1) frames
                if current_frames + (angles - 1) <= target:
                    d['needs_coverage'] = True
                    current_frames += (angles - 1)
        
        return decisions
    
    def _create_frame_from_beat(
        self, beat, frame_number: int,
        coverage_group_id: str = None,
        coverage_angle_index: int = 0,
        coverage_angle_type: str = "",
    ):
        """Create a Frame object from a SceneAnalysis beat."""
        from models import Frame
        
        # Determine camera shot based on angle type
        camera_shots = {
            "master": "wide shot",
            "medium": "medium shot",
            "close-up": "close-up",
            "reverse": "over-the-shoulder",
            "insert": "extreme close-up",
        }
        camera_shot = camera_shots.get(coverage_angle_type, "medium shot")
        
        return Frame(
            frame_number=frame_number,
            parent_scene=beat.scene_number,
            camera_shot=camera_shot,
            camera_movement="static" if coverage_angle_type == "master" else "subtle drift",
            composition="centered" if coverage_angle_type == "master" else "rule of thirds",
            environment_detail=beat.environment,
            pose_action=beat.pose_action,
            mood=beat.mood,
            visual_focus=beat.pose_action.split()[0] if beat.pose_action else "character",
            narration_text=beat.narration_text,
            narration_notes=beat.narration_notes,
            sync_word="",
            anatomical_highlight="",
            coverage_group_id=coverage_group_id,
            coverage_angle_index=coverage_angle_index,
            coverage_angle_type=coverage_angle_type,
        )
    
    def _ensure_coverage_metadata(
        self,
        result: ExpandedFrameBreakdown,
        num_beats: int,
        angles_per_beat: int,
    ) -> ExpandedFrameBreakdown:
        """Ensure all frames have correct coverage metadata."""
        angle_types = ["master", "medium", "close-up", "reverse", "insert"]
        
        for i, frame in enumerate(result.frames):
            beat_idx = i // angles_per_beat
            angle_idx = i % angles_per_beat
            
            # Set coverage metadata if not already set
            if not frame.coverage_group_id:
                frame.coverage_group_id = f"coverage_{beat_idx + 1}"
            
            frame.coverage_angle_index = angle_idx
            
            if not frame.coverage_angle_type or frame.coverage_angle_type == "":
                frame.coverage_angle_type = angle_types[min(angle_idx, len(angle_types) - 1)]
            
            # Renumber frames sequentially
            frame.frame_number = i + 1
        
        result.total_frames = len(result.frames)
        return result

    def _expand_batch(
        self,
        beats: list,
        breakdown: SceneBreakdown,
        target_frames: int,
        phase_hint: str,
        frame_offset: int,
    ) -> ExpandedFrameBreakdown:
        """Generate frames for a subset of beats."""
        num_beats = len(beats)
        avg_per_beat = max(1, target_frames // max(num_beats, 1))

        if avg_per_beat <= 1:
            fpb_instruction = (
                f"There are {num_beats} beats but only {target_frames} frames allowed. "
                f"You MUST pick the {target_frames} most visually impactful moments — "
                f"some beats will get 1 frame, others may get 0. Prioritize variety."
            )
        elif avg_per_beat <= 2:
            fpb_instruction = (
                f"Create 1-2 frames per beat (average ~{avg_per_beat}). "
                f"Some beats get 1 frame, key beats get 2. "
                f"Total MUST be exactly {target_frames}."
            )
        else:
            lo = max(1, avg_per_beat - 1)
            hi = avg_per_beat + 1
            fpb_instruction = (
                f"For EACH narrative beat, create {lo}-{hi} frames using DIFFERENT "
                f"camera angles and compositions. Total MUST be exactly {target_frames}."
            )

        system = SYSTEM_PROMPT.format(
            target_frames=target_frames,
            phase_hint=phase_hint,
            frames_per_beat_instruction=fpb_instruction,
        )

        beats_text = []
        for s in beats:
            beats_text.append(
                f"Beat {s.scene_number}:\n"
                f"  Environment: {s.environment}\n"
                f"  Pose/Action: {s.pose_action}\n"
                f"  Mood: {s.mood}\n"
                f"  Narration: {s.narration_text}\n"
                f"  Notes: {s.narration_notes}"
            )

        user = (
            f"VIDEO TITLE: {breakdown.title}\n"
            f"SUMMARY: {breakdown.summary}\n"
            f"TARGET FRAMES: {target_frames}\n"
            f"FRAME NUMBERING: Start from frame {frame_offset + 1}\n"
            f"NARRATIVE BEATS ({len(beats)} beats):\n\n"
            + "\n\n".join(beats_text)
        )

        # Fewer frames per batch = less output tokens needed
        batch_max_tokens = min(16384, max(4096, target_frames * 600))

        return self.call(
            system_prompt=system,
            user_prompt=user,
            response_model=ExpandedFrameBreakdown,
            temperature=0.7,
            max_tokens=batch_max_tokens,
        )

    @staticmethod
    def _enforce_frame_limit(
        result: ExpandedFrameBreakdown,
        target: int,
    ) -> ExpandedFrameBreakdown:
        """Hard-truncate frames to target count and renumber sequentially."""
        frames = result.frames
        if len(frames) <= target:
            # Already at or below target — just renumber
            for i, f in enumerate(frames):
                f.frame_number = i + 1
            result.total_frames = len(frames)
            return result

        logger.warning(
            "LLM generated %d frames but target is %d — truncating",
            len(frames), target,
        )
        # Keep evenly spaced frames to preserve narrative arc
        step = len(frames) / target
        selected = []
        for i in range(target):
            idx = int(i * step)
            selected.append(frames[idx])

        # Renumber 1..target
        for i, f in enumerate(selected):
            f.frame_number = i + 1

        return ExpandedFrameBreakdown(
            title=result.title,
            summary=result.summary,
            total_frames=target,
            frames=selected,
        )
