"""씬 이미지 파이프라인 — T2I 생성 + (조건부) GPT LVM 검증 + I2I 연출 개선.

D. 씬 이미지 생성:
  1) Gemini T2I 생성 (참조 이미지 포함)
  2) GPT LVM 검증 (cost policy 분기) → 심각도 또는 not_run_cost_policy marker
     - SCENE_LVM_VALIDATION_MODE 가 off/ref_only 또는 targeted/sample 의
       skip 결정 시 LVM 호출 0 + skip marker 만 기록 (Phase 4 iter 7 follow-up).
     - default off — 비용 절감.
  3) 심각하면 재생성 → GPT LVM 비교 선택 (skip marker 는 severity != "severe"
     라 자동으로 진입 안 함)
  4) 베이스 이미지 확정

E. I2I 연출 개선:
  1) GPT LVM: 구도/색감 개선안 n개 추천
  2) Gemini I2I: n개 변형 생성
  3) GPT LVM: 최종 대표 이미지 선택
"""

import hashlib
import json
import logging
import random
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from app.core.config import settings
from app.modules.pipeline.ref_image_pipeline import _load_lvm_prompt
from app.modules.llm.gemini_image_client import GeminiImageClient, ModerationError
from app.modules.pipeline.ref_image_pipeline import _call_gpt_lvm

logger = logging.getLogger(__name__)

# ── GPT LVM 스키마 ──

_SCENE_VALIDATION_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "matches_prompt": {"type": "boolean"},
        "severity": {"type": "string", "description": "ok | minor | severe"},
        "issues": {"type": "array", "items": {"type": "string"}},
        "score": {"type": "integer"},
    },
    "required": ["matches_prompt", "severity", "issues", "score"],
}

_COMPARISON_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "winner": {"type": "string", "description": "image_1 | image_2"},
        "reason": {"type": "string"},
    },
    "required": ["winner", "reason"],
}

_IMPROVEMENT_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "improvements": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "type": {"type": "string", "description": "angle | color | angle+color"},
                    "prompt": {"type": "string", "description": "I2I용 텍스트 프롬프트"},
                    "reason": {"type": "string"},
                },
                "required": ["type", "prompt", "reason"],
            },
        },
    },
    "required": ["improvements"],
}

_FINAL_SELECTION_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "selected_index": {"type": "integer", "description": "0-based index"},
        "reason": {"type": "string"},
    },
    "required": ["selected_index", "reason"],
}


def _sample_bucket_for_seed(seed_key: str) -> float:
    """Phase 4 iter 7 follow-up M1: deterministic sample bucket [0.0, 1.0).

    같은 seed_key (보통 still_id) 는 항상 같은 bucket 값 → 같은 PID 두 번
    실행 시 LVM 대상 shot 동일하게 유지 (비용/관측 재현성). sha256 의 첫 16
    hex char (64 bit) 를 0.0~1.0 균일 분포로 매핑.
    """
    h = int(hashlib.sha256(seed_key.encode("utf-8")).hexdigest()[:16], 16)
    return h / float(1 << 64)


def _decide_scene_lvm(
    trace_meta: Optional[Dict[str, Any]],
    settings_obj,
) -> Tuple[bool, Optional[str]]:
    """Scene LVM 실행 여부 + skip reason 결정 (cost policy).

    Returns:
        (should_run, skip_reason). should_run=True 시 skip_reason=None.
        skip 시 reason = "off" | "ref_only" | "targeted_not_in_list" |
        "targeted_no_meta" | "sample_excluded".

    Mode 별:
        full: 항상 실행
        off / ref_only: 항상 skip
        targeted: trace_meta.scene_index_shot_index 가 settings 리스트에 있으면 실행
                  (M2: int normalize — 호출 측 trace 가 (5,2) / env 가 "05_02"
                   여도 같은 키 "5_2" 로 매칭. config 의 field_validator 가
                   parse 시점에 normalize 한 값을 settings 에 저장)
        sample: deterministic bucket(still_id) < sample_rate 시 실행 (M1).
                still_id 없으면 (project_id|scene_index|shot_index) fallback.
                trace_meta 자체가 없으면 random.random() 최후 fallback (운영
                관점에서는 비결정 — single-scene path 가 still_id 누락 시
                의 sentinel — 통상 발생 X).

    settings_obj contract: `scene_lvm_validation_mode`, `scene_lvm_targeted_shot_ids`,
    `scene_lvm_sample_rate` 3 attribute 만 사용 (Settings singleton 또는 stub).
    """
    mode = settings_obj.scene_lvm_validation_mode
    if mode == "full":
        return True, None
    if mode == "off":
        return False, "off"
    if mode == "ref_only":
        return False, "ref_only"
    if mode == "targeted":
        scene_idx = (trace_meta or {}).get("scene_index")
        shot_idx = (trace_meta or {}).get("shot_index")
        if scene_idx is None or shot_idx is None:
            return False, "targeted_no_meta"
        # M2: int cast — trace 가 str 로 들어오는 경우에도 정규화 매칭.
        try:
            target_key = f"{int(scene_idx)}_{int(shot_idx)}"
        except (TypeError, ValueError):
            return False, "targeted_no_meta"
        raw = settings_obj.scene_lvm_targeted_shot_ids or ""
        targets = {s.strip() for s in raw.split(",") if s.strip()}
        if target_key in targets:
            return True, None
        return False, "targeted_not_in_list"
    if mode == "sample":
        rate = settings_obj.scene_lvm_sample_rate
        if rate < 0.0 or rate > 1.0:
            # I2: config field_validator 가 startup 에서 catch — 여기 도달은
            # stub/test 만. fail-fast 보존 (silent default 금지).
            raise ValueError(
                f"SCENE_LVM_SAMPLE_RATE out of range: {rate} (must be 0.0~1.0)"
            )
        # M1: deterministic seed — still_id 우선, fallback (project|scene|shot).
        seed_key: Optional[str] = None
        if trace_meta:
            still_id = trace_meta.get("still_id")
            if still_id:
                seed_key = str(still_id)
            else:
                pid = trace_meta.get("project_id")
                scene_idx = trace_meta.get("scene_index")
                shot_idx = trace_meta.get("shot_index")
                if pid is not None and scene_idx is not None and shot_idx is not None:
                    seed_key = f"{pid}|{scene_idx}|{shot_idx}"
        if seed_key:
            bucket = _sample_bucket_for_seed(seed_key)
        else:
            # fallback — non-deterministic best-effort (trace_meta 누락 sentinel)
            bucket = random.random()
        if bucket < rate:
            return True, None
        return False, "sample_excluded"
    raise ValueError(f"Unknown scene_lvm_validation_mode: {mode!r}")


def _build_scene_lvm_skip_validation(skip_reason: str, mode: str) -> Dict[str, Any]:
    """Skip marker 생성 — silent success 금지.

    matches_prompt=False 의미 주의: 여기서는 "이미지가 프롬프트와 불일치" 가
    아니라 **"LVM 이 실행되지 않아 판단 불가"** (= not evaluated). 단순 bool
    소비자가 "fail" 로 오해할 수 있어 함께 `_scene_lvm_skipped=True` marker 와
    severity="not_run_cost_policy" 로 명시 분리. downstream consumer 는
    `severity` 또는 `_scene_lvm_skipped` 로 분기해야 정확.

    severity="not_run_cost_policy" — 운영자가 의도한 skip (cost policy).
    severity="unavailable" 와 분리: 후자는 인프라 결함 (LVM 호출 실패).
    """
    return {
        "matches_prompt": False,
        "severity": "not_run_cost_policy",
        "issues": [f"scene LVM skipped: {skip_reason} (mode={mode})"],
        "score": 0,
        "_scene_lvm_skipped": True,
        "_scene_lvm_skip_reason": skip_reason,
        "_scene_lvm_mode": mode,
    }


def _capture_regeneration_loser(
    *,
    loser_bytes: Optional[bytes],
    trace_meta: Optional[Dict[str, Any]],
    beat_title: str,
    winner: str,
) -> None:
    """severe-regeneration 비교에서 탈락한 후보(실 바이트)를 rejected 중간물로 영속화.

    ★winner(채택본)는 caller 가 최종 scene asset 으로 등록하므로 여기서 capture 하면
    중복 → loser 한 장만 좁은 scope 로 직접 enqueue(중복 0, Codex 합의). trace_meta 에
    project_id 가 없으면 scope 를 열 수 없어 skip. 모든 동작 non-fatal.
    """
    if not loser_bytes or not trace_meta or not trace_meta.get("project_id"):
        return
    try:
        from app.services.image_capture.context import generation_context
        from app.services.image_capture.sink import capture_generated_image

        with generation_context(
            trace_meta["project_id"], trace_meta.get("episode_id"),
            stage="scene_regeneration_candidate",
            still_id=trace_meta.get("still_id"),
            scene_index=trace_meta.get("scene_index"),
            shot_index=trace_meta.get("shot_index"),
        ):
            capture_generated_image(
                loser_bytes,
                role="scene_regeneration_candidate",
                disposition="rejected",
                candidate_index=1,  # winner=0 관례, loser=1 (같은 비교 그룹)
                attempt_index=1,    # 원본=attempt0, regeneration=attempt1
                pipeline_metadata={
                    # 캔버스에서 비교 맥락을 읽기 쉽게 — winner는 별도 final scene row(SOT).
                    "loser_of": "severe_regeneration_compare",
                    "comparison_winner": winner,      # image_1 | image_2
                    "winner_candidate_index": 0,
                    "beat_title": beat_title[:60],
                },
            )
    except Exception:  # pragma: no cover - non-fatal capture
        logger.warning(
            "B1 regeneration-loser capture failed (non-fatal)", exc_info=True
        )


# ─────────────────────────────────────────────────────────────────────────────
# C2 2단계 (2026-07-02): 단일 연속 프레임 readback — 생성 스틸이 패널그리드/콜라주/
# 분할화면(S21sh10 2×2 실측)인지 VLM boolean schema 로만 판정(글자패턴 0)하고,
# 위반이면 generic correction 1줄을 덧붙여 1회 재생성. 시나리오 토큰 0.
# ─────────────────────────────────────────────────────────────────────────────

_SINGLE_FRAME_READBACK_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "is_single_continuous_frame": {
            "type": "boolean",
            "description": "True if the image is ONE single continuous photograph of one instant",
        },
        "has_panel_grid_or_collage": {
            "type": "boolean",
            "description": (
                "True if the image is divided into multiple panels, any grid "
                "(2x2 etc.), a collage, contact sheet, split-screen, storyboard "
                "strip, or repeated tiles"
            ),
        },
        "confidence": {"type": "string", "enum": ["low", "medium", "high"]},
    },
    "required": [
        "is_single_continuous_frame", "has_panel_grid_or_collage", "confidence",
    ],
    "additionalProperties": False,
}

_SINGLE_FRAME_READBACK_PROMPT = (
    "You are inspecting one generated still image. Judge its STRUCTURE only — "
    "not its content or quality. Answer: (1) is_single_continuous_frame — the "
    "image is one single continuous photograph depicting one instant; (2) "
    "has_panel_grid_or_collage — the image is divided into multiple panels, any "
    "grid layout (2x2, 3x1, ...), a collage, contact sheet, split-screen, "
    "storyboard strip, or repeated tiles of the same scene. Plain borders, "
    "letterboxing or a single in-scene screen/photo shown WITHIN the photograph "
    "do NOT count as a grid."
)

_SINGLE_FRAME_CORRECTION = (
    "STRICT FRAME CONTRACT: render exactly ONE single continuous photograph of "
    "ONE instant — never a panel grid, collage, contact sheet, split-screen, "
    "storyboard, or multiple frames inside one image."
)


def _single_frame_verdict_violates(verdict: Dict[str, Any]) -> bool:
    """default-deny 재시도 게이트 — medium/high 확신의 구조 위반만 True."""
    if not isinstance(verdict, dict) or not verdict:
        return False
    if verdict.get("confidence") not in ("medium", "high"):
        return False
    return bool(
        verdict.get("has_panel_grid_or_collage")
        or verdict.get("is_single_continuous_frame") is False
    )


def _single_frame_readback_and_retry(
    *,
    gemini_client: Any,
    img: bytes,
    current_prompt: str,
    labeled_refs: Optional[List[Tuple[str, bytes]]],
    beat_title: str,
    trace_meta: Optional[Dict[str, Any]],
) -> Tuple[bytes, str, Optional[Dict[str, Any]]]:
    """C2 2단계 — flag OFF → (원본, 원본 프롬프트, None) no-op (byte-identical).

    위반(medium/high) → correction 덧붙여 1회 재생성 후 재판정. 재생성본 채택
    (재판정도 위반이면 진단만 남기고 연쇄 재시도 금지 — whack-a-mole 회피).
    원본(위반본)은 rejected 중간물로 capture(persist-all, non-fatal). 판정/재생성
    실패는 전부 비차단 — 원본 유지 + 진단."""
    if not bool(getattr(settings, "scene_single_frame_readback_enabled", False)):
        return img, current_prompt, None
    try:
        v1 = _call_gpt_lvm(
            img, _SINGLE_FRAME_READBACK_PROMPT, _SINGLE_FRAME_READBACK_SCHEMA,
            "single_frame_readback", opik_tags=["single_frame_readback"],
        )
    except Exception as exc:
        logger.warning("single_frame_readback 판정 실패 (비차단): %s", exc)
        return img, current_prompt, {"checked": False, "error": str(exc)[:200]}
    diag: Dict[str, Any] = {"checked": True, "verdict": v1, "violation": False,
                            "retried": False, "resolved_after_retry": None}
    if not _single_frame_verdict_violates(v1):
        return img, current_prompt, diag
    diag["violation"] = True
    corrected_prompt = f"{current_prompt}\n\n{_SINGLE_FRAME_CORRECTION}"
    try:
        img2, _ = gemini_client.generate_image(
            prompt=corrected_prompt,
            labeled_references=labeled_refs if labeled_refs else None,
            aspect_ratio="16:9",
        )
    except Exception as exc:
        logger.warning(
            "single_frame_readback: correction 재생성 실패 (비차단, 원본 유지): %s",
            exc)
        diag["retry_error"] = str(exc)[:200]
        return img, current_prompt, diag
    diag["retried"] = True
    try:
        v2 = _call_gpt_lvm(
            img2, _SINGLE_FRAME_READBACK_PROMPT, _SINGLE_FRAME_READBACK_SCHEMA,
            "single_frame_readback", opik_tags=["single_frame_readback"],
        )
        diag["verdict_after_retry"] = v2
        diag["resolved_after_retry"] = not _single_frame_verdict_violates(v2)
    except Exception as exc:
        logger.warning("single_frame_readback: 재판정 실패 (비차단): %s", exc)
        diag["resolved_after_retry"] = None
    # 위반 원본은 rejected 중간물로 영속화 (persist-all, non-fatal).
    _capture_regeneration_loser(
        loser_bytes=img, trace_meta=trace_meta, beat_title=beat_title,
        winner="single_frame_retry",
    )
    logger.warning(
        "single_frame_readback: 패널그리드/콜라주 위반 → correction 재생성 채택 "
        "(resolved_after_retry=%s, beat=%s)",
        diag["resolved_after_retry"], beat_title[:40])
    return img2, corrected_prompt, diag


def generate_and_validate_scene(
    gemini_client: GeminiImageClient,
    t2i_prompt: str,
    beat_title: str,
    output_dir: Path,
    reference_images: Optional[List[Tuple[str, bytes]]] = None,
    previous_scene_bytes: Optional[bytes] = None,
    trace_meta: Optional[Dict[str, Any]] = None,
    semantic_constraints: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """씬 이미지 생성 + GPT LVM 검증 + 필요 시 재생성.

    Args:
        trace_meta (Phase 4 iter 7 W3): observability metadata —
            {project_id, episode_id, scene_index, shot_index, still_id}.
            llm_call_log 의 PID/EID NULL 사고 (1020/1020 row) 차단용. caller
            가 가지고 있는 trace context 를 thread-local 로 forward.
        semantic_constraints (Patch B-min): Optional dict from
            semantic_contract_router.SemanticContract.sanitizer_constraints.
            Propagated to PromptSanitizer.sanitize() on moderation retry to
            preserve immobilized/pose-locked subject polarity (Layer 2 SEMANTIC
            OVERRIDE block). None → existing behavior unchanged.

    Returns:
        {"file_path": str, "image_bytes": bytes, "validation": {...}, "was_regenerated": bool}
    """
    output_dir.mkdir(parents=True, exist_ok=True)

    # Opik context — W3: trace_meta 를 set_context 에 forward.
    # GeminiImageClient._log_ctx 가 _allowed key 만 통과시킴 (project_id /
    # episode_id / operation_type / step_name / reference_image_ids).
    ctx_kwargs: Dict[str, Any] = {"step": "scene_image_gen", "beat_title": beat_title[:40]}
    if trace_meta:
        ctx_kwargs.update(trace_meta)
    gemini_client.set_context(**ctx_kwargs)

    # 참조 이미지 구성
    labeled_refs = list(reference_images or [])
    if previous_scene_bytes:
        labeled_refs.append(("Previous scene (same location)", previous_scene_bytes))

    # 1) T2I 생성 — ModerationError 시 sanitizer로 프롬프트 수정 후 재시도 (최대 3회)
    logger.info("Generating scene image: %s", beat_title[:40])
    from app.modules.prompt_sanitizer import PromptSanitizer
    from app.modules.llm.openai_client import OpenAIClient

    current_prompt = t2i_prompt
    img_1 = None
    for attempt in range(4):
        try:
            img_1, _ = gemini_client.generate_image(
                prompt=current_prompt,
                labeled_references=labeled_refs if labeled_refs else None,
                aspect_ratio="16:9",
            )
            break
        except ModerationError as exc:
            logger.warning("Scene T2I blocked (attempt %d): %s", attempt + 1, exc.block_reason)
            if attempt >= 3:
                raise
            try:
                sanitizer = PromptSanitizer(OpenAIClient())
                sanitize_result = sanitizer.sanitize(
                    current_prompt,
                    exc.block_reason,
                    exc.block_categories,
                    attempt=attempt + 1,
                    semantic_constraints=semantic_constraints,
                )
                current_prompt = sanitize_result.get("sanitized_prompt", current_prompt)
                logger.info("Sanitized scene prompt (strategy: %s)", sanitize_result.get("strategy", ""))
            except Exception:
                raise exc

    if img_1 is None:
        raise RuntimeError(f"Failed to generate scene image: {beat_title}")

    # C2 2단계 (2026-07-02): 단일 연속 프레임 readback — flag OFF → no-op
    # (byte-identical). 위반시 correction 1회 재생성본으로 교체 + 진단.
    img_1, current_prompt, _sfr_diag = _single_frame_readback_and_retry(
        gemini_client=gemini_client, img=img_1, current_prompt=current_prompt,
        labeled_refs=labeled_refs, beat_title=beat_title, trace_meta=trace_meta,
    )

    path_1 = output_dir / f"{uuid.uuid4()}.png"
    path_1.write_bytes(img_1)

    # PNG 메타데이터 삽입
    from app.modules.png_metadata import embed_png_metadata
    from datetime import datetime, timezone

    _ref_filenames = []
    if reference_images:
        for label, _ in reference_images:
            _ref_filenames.append(label)

    embed_png_metadata(path_1, {
        "prompt": current_prompt,
        "scene_description": t2i_prompt,
        "beat_title": beat_title,
        "reference_images": ", ".join(_ref_filenames) if _ref_filenames else None,
        "model": settings.gemini_image_model,
        "created_at": datetime.now(timezone.utc).isoformat(),
    })

    # 2) GPT LVM 검증
    # Phase 4 iter 7 follow-up — cost policy 분기. SCENE_LVM_VALIDATION_MODE 가
    # off / ref_only 또는 targeted/sample 의 skip 결정 시 LVM 호출 0 + marker 만
    # 기록. silent success 금지 — feedback_no_silent_fallback. ref_image_pipeline
    # 은 영향 0 (ref 는 적고 모든 shot 에 영향 — 비용 대비 가치 큼).
    should_run_lvm, skip_reason = _decide_scene_lvm(trace_meta, settings)
    if not should_run_lvm:
        logger.info(
            "Scene LVM skipped: reason=%s mode=%s scene=%s shot=%s",
            skip_reason,
            settings.scene_lvm_validation_mode,
            (trace_meta or {}).get("scene_index"),
            (trace_meta or {}).get("shot_index"),
        )
        validation = _build_scene_lvm_skip_validation(
            skip_reason or "unknown",
            settings.scene_lvm_validation_mode,
        )
    else:
        # Phase 4 iter 7 W1 — silent OK fallback 제거. 옛 패턴은 LVM 호출이 실패하면
        # `matches_prompt=True, severity="ok"` 로 덮어 scene 통과시켰음 → temperature
        # 0.2 BadRequest 같은 인프라 결함이 silent 로 모든 검증을 무력화. 이제 default
        # 는 AppError fail-fast. 운영자가 LVM unavailable 상태에서도 진행하려면
        # `ALLOW_LVM_VALIDATION_UNAVAILABLE=true` env 명시.
        try:
            validation = _call_gpt_lvm(
                img_1,
                _load_lvm_prompt("scene_validation", t2i_prompt=t2i_prompt, beat_title=beat_title),
                _SCENE_VALIDATION_SCHEMA,
                "scene_validation",
                opik_tags=["scene_validation"],
            )
        except Exception as exc:
            if settings.allow_lvm_validation_unavailable:
                logger.warning(
                    "Scene LVM validation unavailable: %s — explicit override "
                    "(ALLOW_LVM_VALIDATION_UNAVAILABLE=true). Proceeding with "
                    "_validation_unavailable marker (severity=unavailable).",
                    exc,
                )
                # Phase 4 iter 7 I2 — `matches_prompt=False` 로 unavailable 을
                # downstream 에 strong negative signal 로 전달. 옛 True 값은 소비자
                # 가 "검증 통과" 로 오판할 수 있어 marker 로 dispatch 못 하면 silent
                # 성공처럼 처리됨. severity="unavailable" + marker 로 fail-fast.
                validation = {
                    "matches_prompt": False,
                    "severity": "unavailable",
                    "issues": ["LVM validation unavailable — explicit override active"],
                    "score": 0,
                    "_validation_unavailable": True,
                }
            else:
                from app.core.errors import AppError
                raise AppError(
                    code="step.scene_image_pipeline.lvm_validation_failed",
                    message=(
                        f"Scene LVM validation failed: {exc!r}. The vision-side "
                        f"validator must run for production scene image generation. "
                        f"To proceed without it (e.g. transient outage), set "
                        f"ALLOW_LVM_VALIDATION_UNAVAILABLE=true env. Do not silent-"
                        f"absorb — opens the door to undetected provider-blocked / "
                        f"drifted outputs (feedback_no_silent_fallback)."
                    ),
                    status_code=500,
                )

    # C2 2단계: readback 진단을 validation 에 병합 — review_notes 로 영속(양 경로).
    if _sfr_diag:
        validation = {**validation, "single_frame_readback": _sfr_diag}

    # 3) severe면 재생성 + 비교
    was_regenerated = False
    if validation.get("severity") == "severe":
        logger.info("Severe — regenerating scene: %s", beat_title[:40])
        try:
            img_2, _ = gemini_client.generate_image(
                prompt=t2i_prompt,
                labeled_references=labeled_refs if labeled_refs else None,
                aspect_ratio="16:9",
            )
            path_2 = output_dir / f"{uuid.uuid4()}.png"
            path_2.write_bytes(img_2)

            comparison = _call_gpt_lvm(
                img_1,
                f"두 씬 이미지를 비교. 프롬프트에 더 맞는 쪽 선택.\n"
                f"프롬프트: {t2i_prompt}\n"
                f"image_1=첫번째, image_2=두번째",
                _COMPARISON_SCHEMA,
                "scene_comparison",
                image_bytes_2=img_2,
                opik_tags=["scene_comparison"],
            )
            winner_is_2 = comparison.get("winner") == "image_2"
            # persist-all Wave2 (B1): 비교에서 탈락한 후보(실 바이트)를 rejected
            # 중간물로 영속화. ★winner(채택본)는 caller 가 최종 scene asset 으로 등록 →
            # 여기서 잡으면 중복 → loser 한 장만 좁은 scope 로 직접 capture(Codex 합의).
            _capture_regeneration_loser(
                loser_bytes=(img_1 if winner_is_2 else img_2),
                trace_meta=trace_meta,
                beat_title=beat_title,
                winner=("image_2" if winner_is_2 else "image_1"),
            )
            if winner_is_2:
                img_1 = img_2
                path_1 = path_2
                was_regenerated = True
        except Exception as exc:
            logger.warning("Scene regeneration failed: %s", exc)

    return {
        "file_path": str(path_1),
        "image_bytes": img_1,
        "validation": validation,
        "was_regenerated": was_regenerated,
        "generation_model": settings.gemini_image_model,
    }


def recommend_improvements(
    image_bytes: bytes,
    t2i_prompt: str,
    beat_title: str,
    n: int = 3,
) -> List[Dict[str, Any]]:
    """GPT LVM에게 카메라 구도/색감 개선안 추천 받기.

    Returns: [{"type": "angle"|"color"|"angle+color", "prompt": str, "reason": str}]
    """
    prompt = _load_lvm_prompt("scene_improvement", n=n, t2i_prompt=t2i_prompt, beat_title=beat_title)
    try:
        result = _call_gpt_lvm(
            image_bytes, prompt, _IMPROVEMENT_SCHEMA, "scene_improvements",
            opik_tags=["scene_improvement"],
        )
        return result.get("improvements", [])[:n]
    except Exception as exc:
        logger.warning("Improvement recommendation failed: %s", exc)
        return []


def generate_i2i_variants(
    gemini_client: GeminiImageClient,
    original_bytes: bytes,
    improvements: List[Dict[str, Any]],
    output_dir: Path,
) -> List[Dict[str, Any]]:
    """개선안 기반 I2I 변형 생성.

    Returns: [{"file_path": str, "image_bytes": bytes, "improvement": {...}}]
    """
    from app.modules.gemini_i2i_editor import GeminiI2IEditor

    editor = GeminiI2IEditor(
        api_key=settings.gemini_api_key,
        model=settings.gemini_image_model,
    )
    results = []

    for imp in improvements:
        try:
            i2i_prompt = imp.get("prompt", "")
            imp_type = imp.get("type", "color")

            if imp_type == "color":
                edited = editor.edit_color(original_bytes, i2i_prompt)
            elif imp_type == "angle":
                edited = editor.edit_color(original_bytes, f"Camera angle adjustment: {i2i_prompt}")
            else:
                # angle+color — 구조화 angle params가 스키마에 없으므로 프롬프트 기반 편집
                edited = editor.edit_color(original_bytes, i2i_prompt)

            path = output_dir / f"{uuid.uuid4()}.png"
            path.write_bytes(edited)
            results.append({
                "file_path": str(path),
                "image_bytes": edited,
                "improvement": imp,
            })
        except Exception as exc:
            logger.warning("I2I variant failed: %s", exc)

    return results


def select_best_image(
    original_bytes: bytes,
    variant_images: List[bytes],
    beat_title: str,
) -> int:
    """GPT LVM이 원본 + 변형 중 최고를 선택.

    Returns: 0-based index (0=original, 1=variant[0], ...)
    """
    if not variant_images:
        return 0

    # 원본 + 변형 모두 텍스트로 설명
    descriptions = ["image_0 (original)"]
    for i in range(len(variant_images)):
        descriptions.append(f"image_{i + 1} (variant {i + 1})")

    prompt = (
        f"아래 이미지들 중 씬 '{beat_title}'의 대표 이미지로 가장 적합한 것을 선택하세요.\n\n"
        f"이미지 목록: {', '.join(descriptions)}\n"
        f"첫 번째 이미지가 원본, 나머지는 변형입니다.\n"
        f"가장 영화적이고 장면의 핵심을 잘 전달하는 이미지를 고르세요.\n"
    )

    # GPT LVM에 원본 + 첫 번째 변형만 비교 (API 제한상 이미지 2장)
    try:
        if len(variant_images) == 1:
            result = _call_gpt_lvm(
                original_bytes, prompt, _COMPARISON_SCHEMA, "final_select",
                image_bytes_2=variant_images[0],
                opik_tags=["final_select"],
            )
            return 0 if result.get("winner") == "image_1" else 1
        else:
            # 다수 변형: 토너먼트 방식 (원본 vs 각 변형)
            best_bytes = original_bytes
            best_idx = 0
            for i, var_bytes in enumerate(variant_images):
                result = _call_gpt_lvm(
                    best_bytes,
                    f"두 이미지 중 씬 '{beat_title}'에 더 적합한 것은?\nimage_1=현재최선, image_2=후보",
                    _COMPARISON_SCHEMA,
                    "tournament_select",
                    image_bytes_2=var_bytes,
                    opik_tags=["tournament_select"],
                )
                if result.get("winner") == "image_2":
                    best_bytes = var_bytes
                    best_idx = i + 1
            return best_idx
    except Exception as exc:
        logger.warning("Final selection failed: %s", exc)
        return 0
