"""
Dry-run payload assertions — validate API payload schemas without making calls.

Mocks breakdown/bible data through the assembler and checks that Kling,
SeedDance, and Gemini payloads are structurally valid. Catches field
mismatches, wrong types, and missing keys before any money is spent.
"""

import sys
from dataclasses import dataclass, field
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

# Ensure pipeline root is on path
PIPELINE_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PIPELINE_ROOT))

from recoil.execution.assembler import (
    ShotAssembler,
    PromptPackage,
    ReferenceImage,
    GenaiPayload,
    KlingPayload,
    SeedDancePayload,
)


# ── Test Fixtures ─────────────────────────────────────────────


def _make_ref(label="identity_jinx", weight=9, ref_type="identity"):
    """Create a mock ReferenceImage with fake path."""
    ref = ReferenceImage(
        path=Path("/tmp/test_ref.png"),
        label=label,
        weight=weight,
        ref_type=ref_type,
    )
    return ref


def _make_package(
    model="gemini-3-pro-image-preview",
    modality="image",
    duration_s=0,
    is_env=False,
    camera_movement=None,
    start_frame_path=None,
    end_frame_path=None,
    refs=None,
):
    """Create a minimal PromptPackage for testing."""
    return PromptPackage(
        prompt_text="A salvager crouches in dim amber corridor light.",
        references=refs or [],
        model=model,
        aspect_ratio="9:16",
        image_size="1K",
        is_env=is_env,
        modality=modality,
        duration_s=duration_s,
        camera_movement=camera_movement,
        start_frame_path=start_frame_path,
        end_frame_path=end_frame_path,
    )


# ── Gemini Image Payload Tests ────────────────────────────────


class TestGenaiImagePayload:
    """Test Gemini image payload structure."""

    def test_basic_structure(self):
        pkg = _make_package()
        assembler = ShotAssembler()
        payload = assembler.to_genai_image_payload(pkg)

        assert isinstance(payload, GenaiPayload)
        assert payload.model == "gemini-3-pro-image-preview"
        assert payload.modality == "image"
        assert isinstance(payload.parts, list)
        assert isinstance(payload.config, dict)

    def test_config_has_required_fields(self):
        pkg = _make_package()
        assembler = ShotAssembler()
        payload = assembler.to_genai_image_payload(pkg)

        config = payload.config
        assert "response_modalities" in config
        assert "IMAGE" in config["response_modalities"]

    def test_config_aspect_ratio(self):
        pkg = _make_package()
        assembler = ShotAssembler()
        payload = assembler.to_genai_image_payload(pkg)

        config = payload.config
        img_config = config.get("image_generation", {})
        if img_config:
            assert img_config.get("aspect_ratio") == "9:16"

    def test_prompt_text_is_last_part(self):
        """Recency bias: prompt text must be the last element."""
        pkg = _make_package()
        assembler = ShotAssembler()
        payload = assembler.to_genai_image_payload(pkg)

        # Last part should be text containing the prompt
        if payload.parts:
            last = payload.parts[-1]
            # genai Part has .text attribute for text parts
            if hasattr(last, "text"):
                assert pkg.prompt_text in last.text

    def test_env_shot_has_no_humans_directive(self):
        """ENV shots must include the no-humans directive."""
        pkg = _make_package(is_env=True)
        assembler = ShotAssembler()
        payload = assembler.to_genai_image_payload(pkg)

        # Check that some part contains the ENV directive
        found_env_directive = False
        for part in payload.parts:
            if hasattr(part, "text") and "no human" in part.text.lower():
                found_env_directive = True
                break
        assert found_env_directive, "ENV shot payload missing no-humans directive"


# ── Kling Payload Tests ───────────────────────────────────────


class TestKlingPayload:
    """Test Kling I2V payload structure."""

    def test_basic_structure(self):
        pkg = _make_package(
            model="kling-v3",
            modality="video",
            duration_s=5,
        )
        assembler = ShotAssembler()
        payload = assembler.to_kling_payload(pkg)

        assert isinstance(payload, KlingPayload)
        assert isinstance(payload.prompt, str)
        assert len(payload.prompt) > 0

    def test_duration_is_5_or_10(self):
        """Kling only accepts 5 or 10 second durations."""
        for input_dur in [1, 3, 5, 7, 10, 15]:
            pkg = _make_package(
                model="kling-v3",
                modality="video",
                duration_s=input_dur,
            )
            assembler = ShotAssembler()
            payload = assembler.to_kling_payload(pkg)
            assert payload.duration_s in (5, 10), (
                f"Input duration {input_dur} produced {payload.duration_s}, "
                f"expected 5 or 10"
            )

    def test_camera_control_is_dict_or_none(self):
        pkg = _make_package(
            model="kling-v3",
            modality="video",
            duration_s=5,
            camera_movement="pan left",
        )
        assembler = ShotAssembler()
        payload = assembler.to_kling_payload(pkg)

        if payload.camera_control is not None:
            assert isinstance(payload.camera_control, dict)
            for key in ("pan", "tilt", "roll", "zoom"):
                assert key in payload.camera_control
                assert isinstance(payload.camera_control[key], (int, float))

    def test_negative_prompt_present(self):
        pkg = _make_package(
            model="kling-v3",
            modality="video",
            duration_s=5,
        )
        assembler = ShotAssembler()
        payload = assembler.to_kling_payload(pkg)

        assert isinstance(payload.negative_prompt, str)
        assert len(payload.negative_prompt) > 0

    def test_aspect_ratio_set(self):
        pkg = _make_package(
            model="kling-v3",
            modality="video",
            duration_s=5,
        )
        assembler = ShotAssembler()
        payload = assembler.to_kling_payload(pkg)

        assert payload.aspect_ratio == "9:16"


# ── SeedDance Payload Tests ───────────────────────────────────


class TestSeedDancePayload:
    """Test SeedDance multi-shot/dialogue payload structure."""

    def test_basic_structure(self):
        pkg = _make_package(
            model="seeddance-2.0",
            modality="video",
            duration_s=8,
        )
        assembler = ShotAssembler()
        payload = assembler.to_seeddance_payload(pkg)

        assert isinstance(payload, SeedDancePayload)
        assert isinstance(payload.prompt, str)
        assert len(payload.prompt) > 0

    def test_refs_and_weights_match_length(self):
        """reference_images and reference_weights must have same length."""
        refs = [
            _make_ref("identity_jinx", 9, "identity"),
            _make_ref("scene_corridor", 2, "scene"),
        ]
        pkg = _make_package(
            model="seeddance-2.0",
            modality="video",
            duration_s=8,
            refs=refs,
        )
        assembler = ShotAssembler()

        # Mock file reads since paths don't exist
        with patch.object(ReferenceImage, "load_bytes", return_value=b"fake_image_data"):
            payload = assembler.to_seeddance_payload(pkg)

        assert len(payload.reference_images) == len(payload.reference_weights), (
            f"refs ({len(payload.reference_images)}) != weights ({len(payload.reference_weights)})"
        )

    def test_weights_are_valid_floats(self):
        """All weights must be 0.0-1.0 floats."""
        refs = [_make_ref("identity_jinx", 9, "identity")]
        pkg = _make_package(
            model="seeddance-2.0",
            modality="video",
            duration_s=8,
            refs=refs,
        )
        assembler = ShotAssembler()

        with patch.object(ReferenceImage, "load_bytes", return_value=b"fake_image_data"):
            payload = assembler.to_seeddance_payload(pkg)

        for w in payload.reference_weights:
            assert isinstance(w, float)
            assert 0.0 <= w <= 1.0, f"Weight {w} out of range [0.0, 1.0]"

    def test_aspect_ratio_set(self):
        pkg = _make_package(
            model="seeddance-2.0",
            modality="video",
            duration_s=8,
        )
        assembler = ShotAssembler()
        payload = assembler.to_seeddance_payload(pkg)

        assert payload.aspect_ratio == "9:16"


# ── Cross-Model Consistency ───────────────────────────────────


class TestCrossModelConsistency:
    """Verify consistent behavior across assembler outputs."""

    def test_all_payloads_preserve_prompt_content(self):
        """Core prompt content should appear in all payload types."""
        prompt = "A salvager crouches in dim amber corridor light."

        for model, modality, dur in [
            ("gemini-3-pro-image-preview", "image", 0),
            ("kling-v3", "video", 5),
            ("seeddance-2.0", "video", 8),
        ]:
            pkg = _make_package(model=model, modality=modality, duration_s=dur)
            assembler = ShotAssembler()

            if modality == "image":
                payload = assembler.to_genai_image_payload(pkg)
                # Check prompt is in parts
                parts_text = " ".join(
                    p.text for p in payload.parts if hasattr(p, "text")
                )
                assert prompt in parts_text, f"{model}: prompt not found in genai parts"
            elif model == "kling-v3":
                payload = assembler.to_kling_payload(pkg)
                assert prompt in payload.prompt or len(payload.prompt) > 0
            elif model == "seeddance-2.0":
                payload = assembler.to_seeddance_payload(pkg)
                assert prompt in payload.prompt or len(payload.prompt) > 0
