"""Visual pipeline compiler — format-aware shot compilation.

Reads format visual rules and episode data, produces render manifests
that starsend/step_runner.py can execute without format knowledge.

Architecture:
    recoil/visual/compiler.py  (this file — The Compiler)
        reads: formats/{name}/visual_rules.py
        reads: formats/{name}/parser.py
        reads: project data (breakdown.json, characters.md)
        produces: render manifests (JSON)

    starsend/step_runner.py  (The CPU)
        reads: render manifests
        executes: API calls to Kling/Veo/SeedDance
        validates: via critics + format_check_questions
"""

from __future__ import annotations

import importlib
import logging
import sys
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

RECOIL_ROOT = Path(__file__).resolve().parent.parent
FORMATS_ROOT = RECOIL_ROOT / "formats"

# CP-3 Phase 8 (2026-04-26): post-stub-deletion bootstrap.
# Imports inside compile_with_prompt_engine() now go directly to
# `lib.prompt_engine` (the SSOT at recoil/pipeline/lib/prompt_engine.py),
# which requires recoil/pipeline/ on sys.path.
_PIPELINE_DIR = str(RECOIL_ROOT / "pipeline")
if _PIPELINE_DIR not in sys.path:
    sys.path.insert(0, _PIPELINE_DIR)


def load_visual_rules(format_name: str):
    """Load visual rules for a format.

    Returns the FormatVisualRules instance from
    formats/{format_name}/visual_rules.py, or None if the format
    doesn't have visual rules (legacy path).
    """
    rules_path = FORMATS_ROOT / format_name / "visual_rules.py"
    if not rules_path.exists():
        return None

    # Ensure recoil root is importable
    if str(RECOIL_ROOT) not in sys.path:
        sys.path.insert(0, str(RECOIL_ROOT))

    module = importlib.import_module(f"formats.{format_name}.visual_rules")
    return module.RULES


def load_format_parser(format_name: str):
    """Load the episode parser for a format.

    Returns the parser module with parse_episode() function,
    or None if no parser exists.
    """
    parser_path = FORMATS_ROOT / format_name / "parser.py"
    if not parser_path.exists():
        return None

    if str(RECOIL_ROOT) not in sys.path:
        sys.path.insert(0, str(RECOIL_ROOT))

    return importlib.import_module(f"formats.{format_name}.parser")


def compile_episode_manifest(
    episode_text: str,
    format_name: str,
    project_name: str,
    episode_number: int,
    grammars: Optional[dict] = None,
    exposure_level: int = 1,
) -> list[dict]:
    """Compile an episode into render manifests (stub compiler).

    Parses the episode using the format's parser, applies visual rules,
    and produces one render manifest per shot. Uses basic prompt
    construction (not the full 10-layer prompt engine).

    Args:
        episode_text: Full episode markdown text.
        format_name: Format name (e.g., "puzzle_box").
        project_name: Project name (e.g., "afterimage").
        episode_number: Episode number.
        grammars: Optional dict of per-character lens grammars.
        exposure_level: Current exposure level (1-4) for grammar bleed.

    Returns:
        List of render manifest dicts, one per shot.
    """
    rules = load_visual_rules(format_name)
    if rules is None:
        raise ValueError(
            f"Format '{format_name}' has no visual_rules.py — "
            f"cannot compile through new pipeline"
        )

    parser = load_format_parser(format_name)
    if parser is None:
        raise ValueError(
            f"Format '{format_name}' has no parser.py — "
            f"cannot parse episode structure"
        )

    # Parse episode into structured shots
    episode_data = parser.parse_episode(episode_text)
    shots = episode_data.get("shots", [])

    if not shots:
        logger.warning(
            "No shots parsed from episode %d (%s format)",
            episode_number, format_name,
        )
        return []

    manifests = []
    grammars = grammars or {}

    for shot in shots:
        beat = shot.get("beat", "")
        beat_policy = rules.get_beat_policy(beat)

        # ── Resolve grammar injection ─────────────────────────────
        grammar_text = ""
        if rules.supports_grammar and shot.get("grammar_tag"):
            tag = shot["grammar_tag"]
            base_grammar = grammars.get(tag, "")
            if base_grammar:
                bleed = rules.get_bleed_factor(exposure_level)
                if bleed == 0.0:
                    grammar_text = base_grammar
                elif bleed >= 1.0:
                    all_grammars = " ".join(grammars.values())
                    grammar_text = all_grammars
                else:
                    grammar_text = (
                        f"{base_grammar}. "
                        f"Grammar bleed {bleed:.0%} — subtle contamination "
                        f"from alternate lens language."
                    )

        # ── Build manifest ────────────────────────────────────────
        characters = shot.get("characters", [])
        element_chars = characters[:beat_policy.max_loras]
        text_only_chars = characters[beat_policy.max_loras:]

        # Build prompt components
        prompt_parts = []
        shot_type = shot.get("shot_type", "MS")
        prompt_parts.append(f"{shot_type}.")
        if grammar_text:
            prompt_parts.append(grammar_text)
        description = shot.get("description", "")
        if text_only_chars:
            text_refs = ", ".join(
                f"reflection/presence of {c}" for c in text_only_chars
            )
            description += f" ({text_refs})"
        prompt_parts.append(description)

        compiled_prompt = " ".join(p for p in prompt_parts if p)

        manifest = {
            "job_id": f"ep{episode_number:02d}_{beat.lower()}_sh{shot.get('shot_number', 0):02d}",
            "format": format_name,
            "episode": episode_number,
            "beat": beat,
            "execution": {
                "engine": rules.default_engine,
                "profile": rules.default_profile,
                "aspect_ratio": rules.aspect_ratio,
                "duration": int(shot.get("duration_seconds", rules.default_duration)),
            },
            "payload": {
                "compiled_prompt": compiled_prompt,
                "shot_type": shot_type,
                "camera_movement": shot.get("camera_movement", "static"),
                "image_refs": [
                    {"char_id": c, "type": "character_identity"}
                    for c in element_chars
                ],
            },
            "composition": {
                "style": beat_policy.composition_style,
                "max_loras": beat_policy.max_loras,
                "kinetic_prompting": beat_policy.allow_kinetic_prompting,
            },
            "validation": {
                "universal_critics": ["extra_limbs", "identity_consistency"],
                "format_questions": rules.format_check_questions,
            },
        }

        manifests.append(manifest)

    logger.info(
        "Compiled %d manifests for ep%02d (%s format)",
        len(manifests), episode_number, format_name,
    )
    return manifests


def compile_with_prompt_engine(
    episode_text: str,
    format_name: str,
    project_name: str,
    episode_number: int,
    bible: dict = None,
    project_config: dict = None,
    grammars: dict = None,
    exposure_level: int = 1,
) -> list[dict]:
    """Full compilation using the format-aware prompt engine.

    Unlike compile_episode_manifest() which produces stub prompts,
    this function runs the actual 10-layer prompt engine with format
    rules applied.

    Args:
        episode_text: Full episode markdown.
        format_name: Format name.
        project_name: Project name.
        episode_number: Episode number.
        bible: Global bible dict (characters, locations, props).
        project_config: project_config.json dict.
        grammars: Per-character lens grammar dict.
        exposure_level: Current exposure level (1-4).

    Returns:
        List of render manifests with fully-compiled prompts.
    """
    rules = load_visual_rules(format_name)
    if rules is None:
        raise ValueError(f"Format '{format_name}' has no visual_rules.py")

    parser = load_format_parser(format_name)
    if parser is None:
        raise ValueError(f"Format '{format_name}' has no parser.py")

    episode_data = parser.parse_episode(episode_text)
    shots = episode_data.get("shots", [])

    bible = bible or {}
    project_config = project_config or {}
    grammars = grammars or {}

    from recoil.pipeline._lib.prompt_engine import build_prompt_from_plan
    from visual.elements import ElementManager

    manifests = []

    for shot in shots:
        beat = shot.get("beat", "")
        beat_policy = rules.get_beat_policy(beat)

        # Build plan-compatible shot dict for prompt engine
        plan_shot = {
            "prompt_data": {
                "shot_type": shot.get("shot_type", "MS"),
                "focal_length": "50mm",
                "camera_movement": shot.get("camera_movement", "static"),
                "prompt_skeleton": {
                    "subject_line": shot.get("description", ""),
                    "environment_line": "",
                },
                "grammar_tag": shot.get("grammar_tag"),
            },
            "routing_data": {
                "is_env_only": beat_policy.composition_style == "ENVIRONMENT",
            },
            "asset_data": {
                "characters": [
                    {"char_id": c} for c in shot.get("characters", [])
                ],
            },
            "spatial_data": {},
        }

        # Compile prompt using format-aware engine
        compiled_prompt = build_prompt_from_plan(
            shot=plan_shot,
            bible=bible,
            project_config=project_config,
            episode=episode_number,
            format_rules=rules,
            grammar_context=grammars,
            exposure_level=exposure_level,
        )

        # Split characters by beat policy
        characters = shot.get("characters", [])
        element_chars, text_chars = ElementManager.split_characters_by_policy(
            characters, beat_policy=beat_policy,
        )

        manifest = {
            "job_id": f"ep{episode_number:02d}_{beat.lower()}_sh{shot.get('shot_number', 0):02d}",
            "format": format_name,
            "episode": episode_number,
            "beat": beat,
            "execution": {
                "engine": rules.default_engine,
                "profile": rules.default_profile,
                "aspect_ratio": rules.aspect_ratio,
                "duration": int(shot.get("duration_seconds", rules.default_duration)),
            },
            "payload": {
                "compiled_prompt": compiled_prompt,
                "shot_type": shot.get("shot_type", "MS"),
                "camera_movement": shot.get("camera_movement", "static"),
                "image_refs": [
                    {"char_id": c, "type": "character_identity"}
                    for c in element_chars
                ],
                "text_only_characters": text_chars,
            },
            "composition": {
                "style": beat_policy.composition_style,
                "max_loras": beat_policy.max_loras,
                "kinetic_prompting": beat_policy.allow_kinetic_prompting,
            },
            "validation": {
                "universal_critics": ["extra_limbs", "identity_consistency"],
                "format_questions": rules.format_check_questions,
            },
        }

        manifests.append(manifest)

    logger.info(
        "Compiled %d manifests with prompt engine for ep%02d (%s format)",
        len(manifests), episode_number, format_name,
    )
    return manifests


def compile_from_breakdown(breakdown_path: Path, project_path: Path,
                            format_name: str = 'puzzle_box') -> dict:
    """Compile a breakdown.json into a render manifest.

    New pipeline path:
        episode.md -> breakdown.json -> manifest.json -> StepRunner

    Args:
        breakdown_path: Path to the breakdown JSON
        project_path: Path to the project root
        format_name: Format name for visual rules

    Returns:
        manifest dict with task list for StepRunner
    """
    import json

    breakdown = json.loads(breakdown_path.read_text())
    episode_id = breakdown['episode_id']

    # Load visual rules if available
    rules = load_visual_rules(format_name)

    tasks = []

    for beat in breakdown.get('beats', []):
        for shot in beat.get('shots', []):
            shot_id = shot['shot_id']

            # Image generation task
            img_prompt = _build_image_prompt_from_shot(shot, breakdown.get('grammar', {}), rules)
            tasks.append({
                'task_id': f"{shot_id}_img",
                'type': 'image_generation',
                'model': 'gemini-3.1-flash-image-preview',
                'prompt': img_prompt,
                'dimensions': '576x1024',
                'element_refs': [c for c in shot.get('characters', []) if c],
            })

            # Video generation task
            motion = _build_motion_prompt(shot)
            if motion:
                tasks.append({
                    'task_id': f"{shot_id}_vid",
                    'type': 'video_generation',
                    'model': 'kling-v3',
                    'source_task': f"{shot_id}_img",
                    'motion_prompt': motion,
                    'duration_s': shot.get('duration_s', 5),
                })

    # Audio tasks
    audio = breakdown.get('global_audio', {})
    if audio.get('vo_text'):
        tasks.append({
            'task_id': f"{episode_id}_vo",
            'type': 'audio_generation',
            'model': 'elevenlabs',
            'voice_id': f"{audio.get('vo_character', 'default').lower()}_v1",
            'text': audio['vo_text'],
            'delivery': audio.get('vo_delivery', ''),
        })

    if audio.get('ambient_bed'):
        tasks.append({
            'task_id': f"{episode_id}_amb",
            'type': 'audio_generation',
            'model': 'elevenlabs_sfx',
            'description': audio['ambient_bed'],
            'duration_s': 30,
        })

    manifest = {
        'manifest_id': f"{episode_id}_v1",
        'format': format_name,
        'episode_id': episode_id,
        'tasks': tasks,
    }

    # Save manifest
    output_dir = project_path / 'state' / 'visual' / 'manifests'
    output_dir.mkdir(parents=True, exist_ok=True)
    output_path = output_dir / f"{episode_id}_manifest.json"
    output_path.write_text(json.dumps(manifest, indent=2))

    print(f"Manifest written: {output_path} ({len(tasks)} tasks)")
    return manifest


def _build_image_prompt_from_shot(shot: dict, grammar: dict, rules=None) -> str:
    """Build an image generation prompt from a breakdown shot."""
    parts = []

    # Shot type and lens
    lens = shot.get('lens', grammar.get('lens', '50mm'))
    dof = shot.get('dof', grammar.get('dof', 'normal'))
    parts.append(f"Cinematic film still, {lens}, {dof} depth of field")

    # Action
    action = shot.get('action', '')
    if action:
        parts.append(action)

    # Lighting
    lighting = shot.get('lighting', '')
    if lighting:
        parts.append(lighting)

    # Color
    color_temp = shot.get('color_temp', grammar.get('color_temp', ''))
    if color_temp:
        parts.append(f"{color_temp} color grade")

    # Camera
    movement = shot.get('camera_movement', '')
    if movement and movement != 'static':
        parts.append(f"{movement} camera")

    # Aspect ratio
    parts.append('--ar 9:16')

    return '. '.join(parts)


def _build_motion_prompt(shot: dict) -> str:
    """Build a video motion prompt from a breakdown shot."""
    movement = shot.get('camera_movement', 'static')
    action = shot.get('action', '')

    if movement == 'static' and not action:
        return ''

    parts = []
    if movement != 'static':
        parts.append(movement.replace('_', ' '))
    if action:
        parts.append(action)

    return '. '.join(parts)


def has_visual_rules(format_name: str) -> bool:
    """Check if a format has visual rules (strangler fig routing check)."""
    return (FORMATS_ROOT / format_name / "visual_rules.py").exists()
