"""변형 파이프라인 테스트 — variation recommender, i2i editor, API endpoints."""

import io
import json
import shutil
import struct
import uuid
import zlib
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from fastapi.testclient import TestClient
from fpdf import FPDF

from app.core.config import settings
from app.core.database import Base, engine, SessionLocal
from app.main import app
from app.models.project import ImageAsset, SceneStill
from tests._safety_guards import safe_drop_all, safe_rmtree


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


def make_test_pdf(text="Test screenplay content") -> bytes:
    pdf = FPDF()
    pdf.add_page()
    pdf.set_font("Helvetica", size=12)
    pdf.cell(200, 10, text=text)
    return pdf.output()


def _make_minimal_png() -> bytes:
    """Create a minimal valid 1x1 white PNG."""
    signature = b'\x89PNG\r\n\x1a\n'

    def chunk(chunk_type, data):
        c = chunk_type + data
        crc = struct.pack('>I', zlib.crc32(c) & 0xffffffff)
        return struct.pack('>I', len(data)) + c + crc

    ihdr_data = struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0)
    raw_data = b'\x00\xff\xff\xff'
    idat_data = zlib.compress(raw_data)

    return signature + chunk(b'IHDR', ihdr_data) + chunk(b'IDAT', idat_data) + chunk(b'IEND', b'')


@pytest.fixture(autouse=True)
def _setup_db():
    Base.metadata.create_all(engine)
    with TestClient(app):
        pass
    yield
    safe_drop_all(engine, Base.metadata)
    proj_dir = Path(settings.projects_dir)
    if proj_dir.exists():
        safe_rmtree(proj_dir)


@pytest.fixture()
def client():
    with TestClient(app, raise_server_exceptions=False) as c:
        yield c


def _admin_login(client: TestClient):
    resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
    assert resp.status_code == 200


def _create_project(client: TestClient) -> str:
    _admin_login(client)
    resp = client.post("/api/v1/projects/", json={"name": "Variation Test Proj"})
    assert resp.status_code == 200
    return resp.json()["id"]


def _create_episode(client: TestClient, project_id: str) -> str:
    pdf_bytes = make_test_pdf("INT. OFFICE - DAY")
    resp = client.post(
        f"/api/v1/projects/{project_id}/episodes/",
        data={"episode_number": "1", "title": "Pilot"},
        files={"file": ("pilot.pdf", io.BytesIO(pdf_bytes), "application/pdf")},
    )
    assert resp.status_code == 200
    return resp.json()["id"]


def _insert_scene_still(project_id: str, episode_id: str, **overrides) -> str:
    """Directly insert a SceneStill into the DB for testing."""
    db = SessionLocal()
    try:
        still_id = overrides.pop("id", str(uuid.uuid4()))
        now = datetime.now(timezone.utc).isoformat()
        defaults = {
            "id": still_id,
            "project_id": project_id,
            "episode_id": episode_id,
            "still_index": 1,
            "screenplay_scene_heading": "INT. OFFICE - DAY",
            "beat_title": "Confrontation",
            "still_frame_prompt": "Two characters face each other in a dark office",
            "camera_json": '{"angle": "eye level", "lens": "35mm"}',
            "lighting_json": '{"type": "neon", "color": "blue"}',
            "visible_entities_json": "[]",
            "status": "pending",
            "created_at": now,
        }
        defaults.update(overrides)
        still = SceneStill(**defaults)
        db.add(still)
        db.commit()
        return defaults["id"]
    finally:
        db.close()


def _insert_image_asset(project_id: str, **overrides) -> str:
    """Directly insert an ImageAsset into the DB for testing."""
    db = SessionLocal()
    try:
        image_id = overrides.pop("id", str(uuid.uuid4()))
        now = datetime.now(timezone.utc).isoformat()
        defaults = {
            "id": image_id,
            "project_id": project_id,
            "asset_type": "scene",
            "entity_id": None,
            "still_id": None,
            "episode_id": None,
            "file_path": "test_image.png",
            "prompt_used": "test prompt",
            "generation_model": "test-model",
            "width": 800,
            "height": 600,
            "status": "generated",
            "review_notes": "",
            "created_at": now,
        }
        defaults.update(overrides)
        asset = ImageAsset(**defaults)
        db.add(asset)
        db.commit()
        return defaults["id"]
    finally:
        db.close()


# ---------------------------------------------------------------------------
# 1. Variation Recommender Schema Tests
# ---------------------------------------------------------------------------


class TestVariationRecommenderSchema:
    """Test the VariationRecommender module."""

    def test_recommender_returns_expected_schema(self):
        """VariationRecommender.recommend() returns correct schema."""
        mock_llm = MagicMock()
        mock_llm.generate_structured.return_value = {
            "variation_a": {
                "type": "angle",
                "angle": {"horizontal": 45, "vertical": 0, "zoom": 1.0},
                "color": "",
                "reason": "Side angle shows confrontation dynamics",
            },
            "variation_b": {
                "type": "color",
                "angle": None,
                "color": "warm golden sunset lighting",
                "reason": "Warm tones soften the tension",
            },
            "recommended": "A",
            "reasoning": "Angle change adds dramatic tension",
        }

        from app.modules.variation_recommender import VariationRecommender

        recommender = VariationRecommender(llm_client=mock_llm)
        result = recommender.recommend(
            scene_description="Two characters face each other",
            beat_title="Confrontation",
            camera_angle='{"angle": "eye level"}',
            lighting_mood='{"type": "neon"}',
        )

        assert "variation_a" in result
        assert "variation_b" in result
        assert "recommended" in result
        assert "reasoning" in result
        assert result["variation_a"]["type"] == "angle"
        assert result["variation_a"]["angle"]["horizontal"] == 45
        assert result["variation_b"]["type"] == "color"
        assert result["recommended"] == "A"

    def test_recommender_calls_llm_with_correct_params(self):
        """VariationRecommender passes correct params to LLM."""
        mock_llm = MagicMock()
        mock_llm.generate_structured.return_value = {
            "variation_a": {"type": "none", "angle": None, "color": "", "reason": "OK"},
            "variation_b": {"type": "none", "angle": None, "color": "", "reason": "OK"},
            "recommended": "original",
            "reasoning": "Original is fine",
        }

        from app.modules.variation_recommender import VariationRecommender

        recommender = VariationRecommender(llm_client=mock_llm)
        recommender.recommend(
            scene_description="A calm lake scene",
            beat_title="Peace",
            camera_angle="eye level",
            lighting_mood="natural daylight",
        )

        mock_llm.generate_structured.assert_called_once()
        call_kwargs = mock_llm.generate_structured.call_args
        assert "system_prompt" in call_kwargs.kwargs or len(call_kwargs.args) >= 1

    def test_recommender_none_variations(self):
        """When LLM recommends 'none' for both, results are valid."""
        mock_llm = MagicMock()
        mock_llm.generate_structured.return_value = {
            "variation_a": {"type": "none", "angle": None, "color": "", "reason": "Good as is"},
            "variation_b": {"type": "none", "angle": None, "color": "", "reason": "Good as is"},
            "recommended": "original",
            "reasoning": "No changes needed",
        }

        from app.modules.variation_recommender import VariationRecommender

        recommender = VariationRecommender(llm_client=mock_llm)
        result = recommender.recommend("scene", "beat", "angle", "mood")

        assert result["variation_a"]["type"] == "none"
        assert result["variation_b"]["type"] == "none"
        assert result["recommended"] == "original"

    def test_recommender_angle_plus_color(self):
        """angle+color type includes both angle and color fields."""
        mock_llm = MagicMock()
        mock_llm.generate_structured.return_value = {
            "variation_a": {
                "type": "angle+color",
                "angle": {"horizontal": 90, "vertical": -15, "zoom": 1.2},
                "color": "cold blue moonlight",
                "reason": "Dramatic combined change",
            },
            "variation_b": {"type": "none", "angle": None, "color": "", "reason": "OK"},
            "recommended": "A",
            "reasoning": "Combined for dramatic effect",
        }

        from app.modules.variation_recommender import VariationRecommender

        recommender = VariationRecommender(llm_client=mock_llm)
        result = recommender.recommend("scene", "beat", "angle", "mood")

        assert result["variation_a"]["type"] == "angle+color"
        assert result["variation_a"]["angle"]["horizontal"] == 90
        assert result["variation_a"]["color"] == "cold blue moonlight"


# ---------------------------------------------------------------------------
# 2. Gemini I2I Editor Tests (Mocked)
# ---------------------------------------------------------------------------


class TestGeminiI2IEditorMocked:
    """Test GeminiI2IEditor with mocked API calls."""

    @pytest.mark.skip(reason="W3-4 cluster C drift — variation_pipeline i2i 프롬프트/response shape이 현재 구현과 어긋남. docs/review-codex-1/11-fix-plan.md §8.2 cluster C. image response shape은 W5(cluster D)에서 일괄 복원.")
    def test_edit_angle_calls_api(self):
        """edit_angle creates diagram and calls Gemini API."""
        from app.modules.gemini_i2i_editor import GeminiI2IEditor

        editor = GeminiI2IEditor(api_key="test-key", model="test-model")
        fake_image = _make_minimal_png()

        with patch("app.modules.gemini_i2i_editor._gemini_generate_content") as mock_api:
            mock_api.return_value = fake_image
            result = editor.edit_angle(
                image_bytes=fake_image,
                horizontal=45,
                vertical=10,
                zoom=1.2,
            )

            mock_api.assert_called_once()
            call_args = mock_api.call_args
            assert call_args.kwargs["api_key"] == "test-key"
            assert call_args.kwargs["model"] == "test-model"
            assert "45 degrees" in call_args.kwargs["prompt"]
            assert len(call_args.kwargs["input_images"]) == 2  # original + diagram
            assert result == fake_image

    def test_edit_color_calls_api(self):
        """edit_color calls Gemini API with color prompt."""
        from app.modules.gemini_i2i_editor import GeminiI2IEditor

        editor = GeminiI2IEditor(api_key="test-key", model="test-model")
        fake_image = _make_minimal_png()

        with patch("app.modules.gemini_i2i_editor._gemini_generate_content") as mock_api:
            mock_api.return_value = fake_image
            result = editor.edit_color(
                image_bytes=fake_image,
                color_prompt="warm golden sunset",
            )

            mock_api.assert_called_once()
            call_args = mock_api.call_args
            assert "warm golden sunset" in call_args.kwargs["prompt"]
            assert len(call_args.kwargs["input_images"]) == 1
            assert result == fake_image

    def test_edit_angle_and_color_chains(self):
        """edit_angle_and_color calls angle first, then color."""
        from app.modules.gemini_i2i_editor import GeminiI2IEditor

        editor = GeminiI2IEditor(api_key="test-key", model="test-model")
        fake_image = _make_minimal_png()

        with patch("app.modules.gemini_i2i_editor._gemini_generate_content") as mock_api:
            mock_api.return_value = fake_image
            result = editor.edit_angle_and_color(
                image_bytes=fake_image,
                horizontal=90,
                vertical=0,
                zoom=1.0,
                color_prompt="cold blue night",
            )

            # Should be called twice: once for angle, once for color
            assert mock_api.call_count == 2
            assert result == fake_image

    def test_camera_diagram_creation(self):
        """Camera diagram creates valid PNG image."""
        from app.modules.gemini_i2i_editor import _create_camera_diagram

        diagram = _create_camera_diagram(horizontal_deg=45, vertical_deg=10, zoom=1.2)
        assert isinstance(diagram, bytes)
        assert len(diagram) > 100
        # Verify it's a valid PNG
        assert diagram[:8] == b'\x89PNG\r\n\x1a\n'

    def test_camera_diagram_various_angles(self):
        """Camera diagram works for various angle combinations."""
        from app.modules.gemini_i2i_editor import _create_camera_diagram

        for angle in [0, 45, 90, 180, 270, 360]:
            diagram = _create_camera_diagram(horizontal_deg=angle)
            assert isinstance(diagram, bytes)
            assert diagram[:8] == b'\x89PNG\r\n\x1a\n'


# ---------------------------------------------------------------------------
# 3. API Endpoint Tests
# ---------------------------------------------------------------------------


class TestVariationAPIEndpoints:
    """Test variation pipeline API endpoints."""

    def test_recommend_variations_still_not_found(self, client: TestClient):
        """recommend-variations returns 404 for non-existent still."""
        project_id = _create_project(client)
        resp = client.post(
            f"/api/v1/projects/{project_id}/stills/nonexistent/recommend-variations"
        )
        assert resp.status_code == 400 or resp.status_code == 404

    def test_recommend_variations_no_openai_key(self, client: TestClient):
        """recommend-variations fails without OpenAI key."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        original_key = settings.openai_api_key
        settings.openai_api_key = ""
        try:
            resp = client.post(
                f"/api/v1/projects/{project_id}/stills/{still_id}/recommend-variations"
            )
            assert resp.status_code == 400
        finally:
            settings.openai_api_key = original_key

    def test_generate_with_variations_still_not_found(self, client: TestClient):
        """generate-with-variations returns 404 for non-existent still."""
        project_id = _create_project(client)
        resp = client.post(
            f"/api/v1/projects/{project_id}/stills/nonexistent/generate-with-variations"
        )
        assert resp.status_code in (400, 404)

    def test_generate_with_variations_no_gemini_key(self, client: TestClient):
        """generate-with-variations fails without Gemini key."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        original_key = settings.gemini_api_key
        settings.gemini_api_key = ""
        try:
            resp = client.post(
                f"/api/v1/projects/{project_id}/stills/{still_id}/generate-with-variations"
            )
            assert resp.status_code == 400
        finally:
            settings.gemini_api_key = original_key

    def test_edit_angle_image_not_found(self, client: TestClient):
        """edit-angle returns 404 for non-existent image."""
        project_id = _create_project(client)
        resp = client.post(
            f"/api/v1/projects/{project_id}/images/nonexistent/edit-angle",
            json={"horizontal": 45, "vertical": 0, "zoom": 1.0},
        )
        assert resp.status_code in (400, 404)

    def test_edit_color_image_not_found(self, client: TestClient):
        """edit-color returns 404 for non-existent image."""
        project_id = _create_project(client)
        resp = client.post(
            f"/api/v1/projects/{project_id}/images/nonexistent/edit-color",
            json={"color_prompt": "warm sunset"},
        )
        assert resp.status_code in (400, 404)

    def test_get_still_images_not_found(self, client: TestClient):
        """GET stills/{id}/images returns 404 for non-existent still."""
        project_id = _create_project(client)
        resp = client.get(
            f"/api/v1/projects/{project_id}/stills/nonexistent/images"
        )
        assert resp.status_code in (400, 404)


# ---------------------------------------------------------------------------
# 4. Select Variant / Select Original Tests
# ---------------------------------------------------------------------------


class TestSelectVariantEndpoints:
    """Test variant selection endpoints."""

    def test_select_variant_invalid(self, client: TestClient):
        """select-variant with invalid value returns error."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        resp = client.patch(
            f"/api/v1/projects/{project_id}/stills/{still_id}/select-variant",
            json={"variant": "C"},
        )
        assert resp.status_code == 400

    def test_select_variant_still_not_found(self, client: TestClient):
        """select-variant for non-existent still returns 404."""
        project_id = _create_project(client)
        resp = client.patch(
            f"/api/v1/projects/{project_id}/stills/nonexistent/select-variant",
            json={"variant": "A"},
        )
        assert resp.status_code in (400, 404)

    def test_select_variant_original(self, client: TestClient):
        """select-variant with 'original' succeeds."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        resp = client.patch(
            f"/api/v1/projects/{project_id}/stills/{still_id}/select-variant",
            json={"variant": "original"},
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["selected_variant"] == "original"

    def test_select_variant_a(self, client: TestClient):
        """select-variant with 'A' succeeds."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        resp = client.patch(
            f"/api/v1/projects/{project_id}/stills/{still_id}/select-variant",
            json={"variant": "A"},
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["selected_variant"] == "A"

    def test_select_variant_b(self, client: TestClient):
        """select-variant with 'B' succeeds."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        resp = client.patch(
            f"/api/v1/projects/{project_id}/stills/{still_id}/select-variant",
            json={"variant": "B"},
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["selected_variant"] == "B"

    def test_select_original_still_not_found(self, client: TestClient):
        """select-original for non-existent still returns 404."""
        project_id = _create_project(client)
        resp = client.patch(
            f"/api/v1/projects/{project_id}/stills/nonexistent/select-original",
            json={"image_id": "some-id"},
        )
        assert resp.status_code in (400, 404)

    def test_select_original_image_not_found(self, client: TestClient):
        """select-original for non-existent image returns 404."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        resp = client.patch(
            f"/api/v1/projects/{project_id}/stills/{still_id}/select-original",
            json={"image_id": "nonexistent"},
        )
        assert resp.status_code in (400, 404)

    def test_select_original_success(self, client: TestClient):
        """select-original sets an image as primary."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)
        image_id = _insert_image_asset(
            project_id,
            still_id=still_id,
            episode_id=episode_id,
            variant_type="original",
        )

        resp = client.patch(
            f"/api/v1/projects/{project_id}/stills/{still_id}/select-original",
            json={"image_id": image_id},
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["id"] == image_id


# ---------------------------------------------------------------------------
# 5. Still Images Grouped Test
# ---------------------------------------------------------------------------


class TestStillImagesGrouped:
    """Test the get still images grouped endpoint."""

    def test_get_still_images_empty(self, client: TestClient):
        """GET stills/{id}/images returns empty groups when no images."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        resp = client.get(
            f"/api/v1/projects/{project_id}/stills/{still_id}/images"
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["still_id"] == still_id
        assert "groups" in data
        assert data["groups"]["original"] == []
        assert data["groups"]["variant_a"] == []
        assert data["groups"]["variant_b"] == []

    def test_get_still_images_with_variants(self, client: TestClient):
        """GET stills/{id}/images groups images by variant type."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        _insert_image_asset(
            project_id,
            still_id=still_id,
            episode_id=episode_id,
            variant_type="original",
        )
        _insert_image_asset(
            project_id,
            still_id=still_id,
            episode_id=episode_id,
            variant_type="variant_a",
            angle_applied='{"horizontal": 45, "vertical": 0, "zoom": 1.0}',
        )
        _insert_image_asset(
            project_id,
            still_id=still_id,
            episode_id=episode_id,
            variant_type="variant_b",
            color_applied="warm sunset lighting",
        )

        resp = client.get(
            f"/api/v1/projects/{project_id}/stills/{still_id}/images"
        )
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["groups"]["original"]) == 1
        assert len(data["groups"]["variant_a"]) == 1
        assert len(data["groups"]["variant_b"]) == 1

        # Check variant_a has angle data
        va = data["groups"]["variant_a"][0]
        assert va["variant_type"] == "variant_a"
        assert va["angle_applied"] is not None
        angle_data = json.loads(va["angle_applied"])
        assert angle_data["horizontal"] == 45

        # Check variant_b has color data
        vb = data["groups"]["variant_b"][0]
        assert vb["variant_type"] == "variant_b"
        assert vb["color_applied"] == "warm sunset lighting"


# ---------------------------------------------------------------------------
# 6. Schema Tests — SceneStillResponse variation fields
# ---------------------------------------------------------------------------


class TestSceneStillResponseSchema:
    """Test SceneStillResponse includes variation fields."""

    @pytest.mark.skip(reason="W3-4 cluster C drift — variation_pipeline i2i 프롬프트/response shape이 현재 구현과 어긋남. docs/review-codex-1/11-fix-plan.md §8.2 cluster C. image response shape은 W5(cluster D)에서 일괄 복원.")
    def test_still_response_includes_variation_fields(self, client: TestClient):
        """SceneStillResponse includes all variation_* fields."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(
            project_id,
            episode_id,
            variation_a_type="angle",
            variation_a_angle='{"horizontal": 45, "vertical": 0, "zoom": 1.0}',
            variation_a_reason="Side angle for drama",
            variation_b_type="color",
            variation_b_color="warm golden sunset",
            variation_b_reason="Soften the tension",
            recommended_variant="A",
        )

        resp = client.get(f"/api/v1/projects/{project_id}/episodes/{episode_id}/stills")
        assert resp.status_code == 200
        data = resp.json()
        still = [s for s in data if s["id"] == still_id][0]

        assert still["variation_a_type"] == "angle"
        assert still["variation_a_angle"] == '{"horizontal": 45, "vertical": 0, "zoom": 1.0}'
        assert still["variation_a_reason"] == "Side angle for drama"
        assert still["variation_b_type"] == "color"
        assert still["variation_b_color"] == "warm golden sunset"
        assert still["variation_b_reason"] == "Soften the tension"
        assert still["recommended_variant"] == "A"
        assert still["selected_variant"] is None

    @pytest.mark.skip(reason="W3-4 cluster C drift — variation_pipeline i2i 프롬프트/response shape이 현재 구현과 어긋남. docs/review-codex-1/11-fix-plan.md §8.2 cluster C. image response shape은 W5(cluster D)에서 일괄 복원.")
    def test_still_response_variation_defaults(self, client: TestClient):
        """SceneStillResponse shows None for unset variation fields."""
        project_id = _create_project(client)
        episode_id = _create_episode(client, project_id)
        still_id = _insert_scene_still(project_id, episode_id)

        resp = client.get(f"/api/v1/projects/{project_id}/episodes/{episode_id}/stills")
        assert resp.status_code == 200
        data = resp.json()
        still = [s for s in data if s["id"] == still_id][0]

        assert still["variation_a_type"] is None
        assert still["variation_a_angle"] is None
        assert still["variation_a_color"] is None
        assert still["variation_a_reason"] is None
        assert still["variation_b_type"] is None
        assert still["recommended_variant"] is None
        assert still["selected_variant"] is None


# ---------------------------------------------------------------------------
# 7. ImageResponse Schema Tests — variation fields
# ---------------------------------------------------------------------------


class TestImageResponseSchema:
    """Test ImageResponse includes variation tracking fields."""

    def test_image_response_includes_variant_fields(self, client: TestClient):
        """ImageResponse includes variant_type, angle_applied, color_applied, source_image_id."""
        project_id = _create_project(client)
        source_id = str(uuid.uuid4())
        image_id = _insert_image_asset(
            project_id,
            variant_type="variant_a",
            angle_applied='{"horizontal": 90, "vertical": 0, "zoom": 1.0}',
            color_applied=None,
            source_image_id=source_id,
        )

        resp = client.get(f"/api/v1/projects/{project_id}/images/{image_id}")
        assert resp.status_code == 200
        data = resp.json()
        assert data["variant_type"] == "variant_a"
        assert data["angle_applied"] == '{"horizontal": 90, "vertical": 0, "zoom": 1.0}'
        assert data["color_applied"] is None
        assert data["source_image_id"] == source_id

    def test_image_response_variant_defaults(self, client: TestClient):
        """ImageResponse shows None for unset variant fields."""
        project_id = _create_project(client)
        image_id = _insert_image_asset(project_id)

        resp = client.get(f"/api/v1/projects/{project_id}/images/{image_id}")
        assert resp.status_code == 200
        data = resp.json()
        assert data["variant_type"] is None
        assert data["angle_applied"] is None
        assert data["color_applied"] is None
        assert data["source_image_id"] is None

    def test_list_images_includes_variant_fields(self, client: TestClient):
        """List images includes variant fields."""
        project_id = _create_project(client)
        _insert_image_asset(
            project_id,
            variant_type="variant_b",
            color_applied="cold blue night",
            source_image_id="parent-123",
        )

        resp = client.get(f"/api/v1/projects/{project_id}/images")
        assert resp.status_code == 200
        data = resp.json()
        assert len(data) == 1
        assert data[0]["variant_type"] == "variant_b"
        assert data[0]["color_applied"] == "cold blue night"
        assert data[0]["source_image_id"] == "parent-123"


# ---------------------------------------------------------------------------
# 8. Version Registry Tests
# ---------------------------------------------------------------------------


class TestVersionRegistry:
    """Test that new modules are registered."""

    def test_variation_recommender_in_registry(self):
        from app.core.version_registry import MODULE_VERSIONS, get_module_info

        assert "variation_recommender" in MODULE_VERSIONS
        assert MODULE_VERSIONS["variation_recommender"] == "1.0.0"
        info = get_module_info("variation_recommender")
        assert info["version"] == "1.0.0"
        assert info["prompt_dependency"] == "variation_recommender/v1"

    def test_gemini_i2i_editor_in_registry(self):
        from app.core.version_registry import MODULE_VERSIONS, get_module_info

        assert "gemini_i2i_editor" in MODULE_VERSIONS
        assert MODULE_VERSIONS["gemini_i2i_editor"] == "1.0.0"
        info = get_module_info("gemini_i2i_editor")
        assert info["version"] == "1.0.0"

    def test_image_service_version_bumped(self):
        """image_service 버전이 1.0.0 미만이 아님을 계약으로 고정 (정확한 값은 version_registry 참조).

        W3-4: 하드코딩된 '1.7.0'은 릴리스마다 drift. 계약은 "version bump가 일어났다"이지
        특정 값이 아니다. 실제 값은 `backend/app/core/version_registry.py` 참조.
        """
        from app.core.version_registry import MODULE_VERSIONS

        current = MODULE_VERSIONS["image_service"]
        # semver major.minor.patch — 각 정수. minor 또는 major가 1 이상이면 bump 완료.
        parts = current.split(".")
        assert len(parts) == 3
        major, minor, _ = int(parts[0]), int(parts[1]), int(parts[2])
        assert (major, minor) >= (1, 7), f"expected >= 1.7, got {current}"
