"""스틸 조립을 **gpt-image-2.5** 로 (2026-09-20 사용자 결정).

## 무엇을 바꿨나

최종 스틸 조립의 기본 엔진이 nb2(Gemini)였다. 실측 근거 —
nb2 가 **두 판 이상** 못 고치던 샷 8개를 같은 문안·같은 참조로
gpt-image-2.5 에 넘기니 **여덟 장 다 한 판**에 해결됐다.

## 이 시험이 잠그는 것

    ① `STILL_IMAGE_BACKEND=gpt25` 가 **조립 엔진**을 바꾼다
    ② 검열 사다리가 **gpt → grok → seedream** 이다(사용자 지시 순서)
    ③ 비율이 **1536x864**(=16:9) 다 — `1536x1024` 는 3:2 라 전 샷이
       어긋난다
    ④ 라벨은 버리되 **순서는 지킨다**(gpt `images.edit` 은 라벨 칸이
       없어 무엇이 무엇인지는 문안이 말한다)
    ⑤ 참조를 **조용히 빼지 않는다**

유료 호출은 없다 — 운반 함수(`call_gpt_image_bytes`)를 대역으로 둔다.
"""
from __future__ import annotations

from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import MagicMock, patch

import pytest

from app.modules.llm.gpt_image_client import (
    DEFAULT_SIZE,
    SIZE_BY_ASPECT,
    GptImageClient,
    resolve_size,
)

PNG = b"\x89PNG\r\n\x1a\n" + b"OK"


@pytest.fixture
def spy(monkeypatch):
    """`call_gpt_image_bytes` 대역 — 받은 인자를 그대로 들고 있는다."""
    seen: List[Dict[str, Any]] = []

    def _fake(client, *, mode, prompt, ref_paths=None, call_kwargs,
              capture_role=None, capture_metadata=None, **kw):
        seen.append({
            "mode": mode, "prompt": prompt,
            "ref_paths": list(ref_paths or []),
            "ref_bytes": [Path(p).read_bytes() for p in (ref_paths or [])],
            "call_kwargs": dict(call_kwargs),
            "capture_role": capture_role,
            "capture_metadata": dict(capture_metadata or {}),
        })
        return PNG

    monkeypatch.setattr(
        "app.modules.llm.gpt_image_primitive.call_gpt_image_bytes", _fake)
    monkeypatch.setattr(
        "app.core.steps.shot_conti_light_step._resolve_openai_client",
        lambda: object())
    return seen


# ── ③ 비율 ────────────────────────────────────────────────────────

def test_sixteen_by_nine_is_1536x864():
    """★★`1536x1024` 는 **3:2** 다 — 그 값이면 전 샷이 어긋난다."""
    assert SIZE_BY_ASPECT["16:9"] == "1536x864"
    assert resolve_size("16:9") == "1536x864"
    w, h = (int(x) for x in SIZE_BY_ASPECT["16:9"].split("x"))
    assert abs(w / h - 16 / 9) < 0.01, f"{w}x{h} 는 16:9 가 아니다"


def test_an_unknown_aspect_falls_back_to_16_9_instead_of_dying():
    """★뜻밖의 값에 그림을 잃는 것보다 **16:9 로 그리고 로그를 남긴다**."""
    assert resolve_size("3:2") == DEFAULT_SIZE
    assert resolve_size(None) == DEFAULT_SIZE


def test_the_old_gen_fn_table_uses_the_same_one_place():
    """★같은 규칙을 두 곳에 적으면 한쪽만 고쳐진다 — 표는 **한 자리**다."""
    from app.modules.pipeline import gpt_image_gen as mod

    assert mod._SIZE_BY_ASPECT is SIZE_BY_ASPECT
    assert mod._SIZE_BY_ASPECT["16:9"] == "1536x864"


# ── ④⑤ 참조 ──────────────────────────────────────────────────────

def test_the_reference_order_is_kept_and_labels_are_dropped(spy):
    """★gpt `images.edit` 은 라벨 칸이 없다 — **순서**가 계약이다."""
    c = GptImageClient()
    c.set_context(project_id="P", episode_id="E",
                  operation_type="still_recipe_roll")
    out, ms = c.generate_image(
        "assemble this", aspect_ratio="16:9",
        labeled_references=[("CHARACTER A", b"AAA"),
                            ("BACKGROUND", b"BBB"),
                            ("PROP", b"CCC")])
    assert out == PNG and ms >= 0
    call = spy[0]
    assert call["mode"] == "edit"
    assert call["ref_bytes"] == [b"AAA", b"BBB", b"CCC"], "순서가 바뀌었다"
    assert call["call_kwargs"]["size"] == "1536x864"
    assert call["call_kwargs"]["model"] == "gpt-image-2.5-sunburst"


def test_no_reference_means_generate_not_edit(spy):
    c = GptImageClient()
    c.generate_image("draw this", aspect_ratio="16:9")
    assert spy[0]["mode"] == "generate" and spy[0]["ref_paths"] == []


def test_an_empty_reference_is_not_silently_dropped(spy):
    """★하나가 사라지면 **아예 다른 계약의 그림**인데 기록은 원래대로 남는다."""
    c = GptImageClient()
    with pytest.raises(FileNotFoundError):
        c.generate_image("x", labeled_references=[("REF", None)])
    assert spy == [], "빈 참조인데 그림을 샀다"


def test_a_missing_reference_file_is_not_silently_dropped(spy, tmp_path):
    c = GptImageClient()
    with pytest.raises(FileNotFoundError):
        c.generate_image(
            "x", labeled_references=[("REF", str(tmp_path / "없다.png"))])
    assert spy == []


def test_the_temp_reference_files_do_not_survive_the_call(spy):
    """★흘려 쓴 참조는 **끝나면 지운다** — 디스크에 쌓이면 안 된다."""
    c = GptImageClient()
    c.generate_image("x", labeled_references=[("R", b"AAA")])
    for p in spy[0]["ref_paths"]:
        assert not Path(p).exists(), f"임시 참조가 남았다: {p}"


def test_an_empty_response_is_not_a_success(spy, monkeypatch):
    """★빈 응답을 **성공으로 세지 않는다**.

    ★★**사다리가 다음 단계로 가지는 않는다** (Codex 정정 — 내가 틀리게
     적었다). 이 예외는 검열 분류에 안 들어가 그대로 전파되고 **샷이
     실패**로 끝난다. 그것이 지금의 보수적 계약이고, 빈 응답을 검열인 척
     위장해 다음 **유료** 단계로 넘기지 않는다. 이 시험이 잠그는 것은
     **raise 까지**다.
    """
    monkeypatch.setattr(
        "app.modules.llm.gpt_image_primitive.call_gpt_image_bytes",
        lambda *a, **k: b"")
    with pytest.raises(RuntimeError):
        GptImageClient().generate_image("x")


# ── ① 조립 엔진 스위치 ────────────────────────────────────────────

@pytest.mark.parametrize("backend,module,name", [
    ("nb2", "app.modules.llm.gemini_image_client", "GeminiImageClient"),
    ("grok2", "app.modules.llm.grok_image_client", "GrokImageClient"),
    ("gpt25", "app.modules.llm.gpt_image_client", "GptImageClient"),
])
def test_the_backend_switch_builds_that_client(backend, module, name):
    """★★**진짜로 그 클라이언트를 짓는지** 본다.

    ★소스에 이름이 있는지로 재면 안 된다 — 갈래를 통째로 죽여도(`elif`
     조건을 못 맞게 바꿔도) 이름은 소스에 그대로 남아 **통과한다**(실측).
     그래서 클래스를 대역으로 갈아 끼워 **생성 여부**를 본다.
    """
    from app.modules.pipeline.multiroll_gemini import make_nb2_gen_fn

    made: List[str] = []

    def _spy(*a, **k):
        made.append(name)
        return object()

    with patch("app.core.config.settings.still_image_backend",
               backend, create=True), \
            patch(f"{module}.{name}", _spy):
        make_nb2_gen_fn(project_id="P")

    assert made == [name], (
        f"{backend} 갈래가 {name} 을 안 지었다 (지은 것: {made})")


def test_the_config_rejects_a_typo():
    """★오타가 **이미지 생성 시점에야** 드러나면 돈이 나간 뒤다."""
    from app.core.config import Settings

    for ok in ("nb2", "grok2", "gpt25"):
        assert Settings._still_image_backend_known(ok) == ok
    with pytest.raises(ValueError):
        Settings._still_image_backend_known("gpt2")


# ── ② 검열 사다리 순서 — gpt → grok → seedream ────────────────────

def test_the_moderation_ladder_goes_gpt_then_grok_then_seedream():
    """★★사용자 지시 순서 — **grok 다음 seedream**.

    사다리의 `_cross_client()` 는 「primary 가 Grok 이 **아니면** Grok」
    이므로 gpt 를 primary 로 두면 교차가 grok 이다. 세 번째는 seedream
    으로 박혀 있다. 이것을 **실제 클라이언트 종류로** 확인한다.

    ★★**단계는 여섯**이다 (Codex 정정): GPT → GPT 같은 모델 재시도 →
     Grok → Seedream → GPT 연화 → Grok 연화. 「딱 세 번」이 아니다.
     그리고 이 사다리 자체가 `still_safety_fallback_enabled=True` 일 때만
     쓰인다 — 꺼져 있으면 연화 1회짜리 옛 갈래다.
    """
    from app.modules.llm.gemini_image_client import GeminiImageClient
    from app.modules.llm.grok_image_client import GrokImageClient

    gpt = GptImageClient()
    # 사다리의 교차 규칙을 그대로 적용한다
    assert not isinstance(gpt, GrokImageClient), (
        "gpt 클라이언트가 Grok 으로 분류되면 교차가 Gemini 로 간다")
    assert isinstance(gpt, GeminiImageClient), (
        "사다리가 기대하는 얼굴(set_context/generate_image)을 안 갖췄다")

    import inspect

    from app.modules.pipeline import multiroll_gemini as mod

    src = inspect.getsource(mod.make_nb2_gen_fn)
    i_cross = src.find('if isinstance(gemini_client, GrokImageClient):')
    assert i_cross > 0, "교차 규칙이 바뀌었다 — 순서를 다시 확인해야 한다"
    assert 'build_cine_client("seedream")' in src, "세 번째가 seedream 이 아니다"
    # 단계 순서 자체
    i_stage = src.find('stages = (')
    order = src[i_stage:i_stage + 400]
    assert order.index('"cross_backend"') < order.index('"third_backend"'), (
        "교차(grok)가 seedream 보다 뒤에 있다")


# ── ⑥ 스텝 지문 — 모델·크기가 접혀야 낡은 그림이 안 남는다 ────────

def _step_hash(monkeypatch, **over) -> str:
    """그 설정에서 스텝 지문이 실제로 내는 값.

    ★payload 를 엿보지 않는다 — 계약은 「**지문이 움직이나**」이고, 내부
     조립을 들여다보면 조립을 바꿀 때마다 시험이 같이 흔들린다.

    ★★**다른 소비자를 꺼서 축을 가른다.** 처음엔 안 껐다가 내 시험이
     「gpt 갈래가 grok 설정을 접는다」고 빨간불을 냈는데, 열어 보니
     **다른 기능이 정당하게 접는 것**이었다 — confined fp 는 도면을
     gpt-image 로 그리고(`confined_fp_model`), G+G46 은 수정 i2i 를
     grok 으로 하고(`gg46_fix_image_model`), 변환은 제 제공자의 물리
     모델을 접는다(`cine_transform_model`). 재려는 축은 **조립 엔진**
     이므로 그 셋을 꺼야 한다.
    """
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep

    step = SceneImagePipelineStep.__new__(SceneImagePipelineStep)
    step.project_config = {}
    for k, v in (("still_recipe_mode", "v1"),
                 ("still_confined_fp_enabled", False),
                 ("still_recipe_critique_enabled", False),
                 # 변환도 grok 모델을 접는다(`cine_transform_model`) —
                 # 정당한 소비자다. 새 계약에서도 꺼진다.
                 ("still_cine_transform_enabled", False),
                 *over.items()):
        monkeypatch.setattr(settings, k, v, raising=False)
    return step._config_hash_base()


def test_changing_the_gpt_model_moves_the_step_hash(monkeypatch):
    """★★**엔진 이름만 접으면 모자란다**.

    백엔드마다 실제 물리 모델이 다른 자리에 있다 — grok 은
    `grok_image_model`, gpt25 는 `openai_image_model`. 모델을 빼면 모델을
    갈아도 지문이 안 움직여 **완주 샷이 옛 모델 그림 그대로** 남는다.
    """
    a = _step_hash(monkeypatch, still_image_backend="gpt25",
                   openai_image_model="gpt-image-2.5-sunburst")
    b = _step_hash(monkeypatch, still_image_backend="gpt25",
                   openai_image_model="gpt-image-2.5-flare")
    assert a != b, "gpt 물리 모델을 갈았는데 지문이 그대로다"


def test_changing_the_16_9_size_moves_the_step_hash(monkeypatch):
    """★크기도 산출 실질 입력이다 — `1536x864`(16:9) 와 `1536x1024`(3:2)
    는 **나오는 비율이 다르다**.

    ★★**이 시험이 잠그는 범위**(Codex NON-BLOCK): 크기 키는 **바깥 스텝
     해시에만** 있다. 그래서 잠기는 것은 **단계 재평가**까지이고, 기존
     샷 캐시가 다시 그려지는 것까지는 아니다 — 샷 생성 지문에는 아직
     크기가 없다. 이번 판은 새 프로젝트 + 고정 `1536x864` 라 문제가 안
     되지만, 나중에 **크기 자체를 바꿔 기존 화에 적용**할 때는 샷 지문에도
     실제 크기를 실어야 한다.
    """
    import app.modules.llm.gpt_image_client as gic

    a = _step_hash(monkeypatch, still_image_backend="gpt25")
    monkeypatch.setitem(gic.SIZE_BY_ASPECT, "16:9", "1536x1024")
    b = _step_hash(monkeypatch, still_image_backend="gpt25")
    assert a != b, "비율을 3:2 로 바꿨는데 지문이 그대로다"


def test_a_grok_only_setting_leaves_the_gpt_hash_alone(monkeypatch):
    """★**안 쓰는 것을 접으면 반대편이 열린다** — grok 값 하나만 고쳐도
    gpt 완주 샷이 mismatch 로 다시 산다(같은 파일의 백엔드별 스템 계약과
    같은 이유)."""
    a = _step_hash(monkeypatch, still_image_backend="gpt25",
                   grok_image_model="x-ai/grok-imagine-image-quality")
    b = _step_hash(monkeypatch, still_image_backend="gpt25",
                   grok_image_model="x-ai/무언가-다른-것")
    assert a == b, "gpt 갈래가 grok 설정을 접는다"


def test_the_nb2_baseline_is_not_moved_by_the_gpt_model(monkeypatch):
    """★기본(nb2)은 **byte-identical** — gpt 값이 지문에 안 들어간다."""
    a = _step_hash(monkeypatch, still_image_backend="nb2",
                   openai_image_model="gpt-image-2.5-sunburst")
    b = _step_hash(monkeypatch, still_image_backend="nb2",
                   openai_image_model="gpt-image-2.5-flare")
    assert a == b, "nb2 인데 gpt 모델이 지문에 들어갔다"


def test_the_three_backends_do_not_share_a_hash(monkeypatch):
    """★엔진이 다르면 지문도 다르다 — 갈아 끼우고 완주본을 물려받지 않는다."""
    hs = {b: _step_hash(monkeypatch, still_image_backend=b)
          for b in ("nb2", "grok2", "gpt25")}
    assert len(set(hs.values())) == 3, f"지문이 겹친다: {hs}"


# ── ⑦ 샷 지문 — 조립 모델 신원 (Codex BLOCK) ──────────────────────

def _walk_extra(tmp_path, monkeypatch, **over) -> Dict[str, Any]:
    """**진짜 걷기**가 `run_multiroll_select` 에 내려보낸 `extra_fingerprint`.

    ★식을 다시 계산하지 않는다 — 그러면 조립을 고칠 때 시험도 같이
     따라 움직여 아무것도 못 잡는다(Codex 지적).
    """
    import json

    from app.core.config import settings
    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    pid, eid = "SAMPLE_P", "SAMPLE_E"
    for step_id, data in (
            ("shot_ref_classify",
             {"shots": {}, "scenes": {}, "world_anchor_en": ""}),
            ("shot_continuity", {"pose_canon": []}),
            ("shot_conti_light", {"contis": {}})):
        d = tmp_path / pid / "checkpoints" / "episodes" / eid / step_id
        d.mkdir(parents=True, exist_ok=True)
        (d / "manifest.json").write_text(
            json.dumps({"status": "completed", "data": data},
                       ensure_ascii=False), encoding="utf-8")

    seen: List[Dict[str, Any]] = []

    def spy_run(**kw):
        seen.append(kw)
        sel = tmp_path / f"{kw['tag']}_sel.png"
        sel.write_bytes(b"\x89PNG\r\n\x1a\n" + b"SEL")
        return str(sel), {"selected": "A"}

    q = MagicMock()
    for m in ("filter", "filter_by", "join", "order_by", "options"):
        getattr(q, m).return_value = q
    q.all.return_value = []
    q.first.return_value = None
    q.count.return_value = 0
    db = MagicMock()
    db.query.return_value = q

    per = MagicMock()
    per.safety_ladder_call_provenance.return_value = None
    per._resolve_generation_call_id.return_value = None
    per.save_single_scene_asset.side_effect = lambda *a, **k: MagicMock(
        id="G", is_primary=1, file_path="/x/g.png")

    flags = {
        "projects_dir": str(tmp_path), "still_recipe_mode": "v1",
        "still_confined_fp_enabled": False, "still_bgfirst_enabled": False,
        "still_bgfirst_full_enabled": False, "still_variants_enabled": False,
        "still_plate_select_enabled": False, "still_conti_ab_enabled": False,
        "still_cine_transform_enabled": False,
        "still_recipe_critique_enabled": False,
        "outdoor_lane_pipe_enabled": False, "outdoor_lane_plan_enabled": False,
        "background_share_plan_enabled": False, "signage_author_enabled": False,
        "still_lane_prev_bgfirst_enabled": False,
        "era_research_enabled": False, "still_winner_gate_enabled": False,
        "still_recipe_roll_count": 2, **over,
    }
    for k, v in flags.items():
        monkeypatch.setattr(settings, k, v, raising=False)
    monkeypatch.setattr(
        "app.modules.pipeline.multiroll_gemini.make_nb2_gen_fn",
        lambda **k: MagicMock())
    monkeypatch.setattr(
        "app.modules.pipeline.multiroll_gemini.make_gemini_judge_fn",
        lambda **k: MagicMock())
    monkeypatch.setattr(
        "app.modules.pipeline.multiroll_gemini.make_gemini_critique_fn",
        lambda **k: MagicMock())
    monkeypatch.setattr(
        "app.modules.pipeline.multiroll_select.run_multiroll_select", spy_run)

    run_still_recipe_generation(
        db=db, project_id=pid, episode_id=eid,
        stills=[{"id": "st_1", "still_index": 0, "scene_index": 1,
                 "shot_index": 1, "screenplay_scene_heading": "S#1. SAMPLE",
                 "beat_title": "", "still_frame_prompt": "SAMPLE",
                 "camera_json": "{}", "lighting_json": "{}",
                 "visible_entities_json": "[]", "dependent_scene_id": None}],
        stills_orm=[], entity_lookup={}, ref_image_map={},
        reference_svc=MagicMock(), scene_ref_image_map={},
        scene_ref_asset_id_map={}, staging_map={}, scene_cp=MagicMock(),
        persistence_svc=per, progress=MagicMock(), project_config=None,
        scene_dir=tmp_path / "scene", already_done_stills=set(),
        target_scenes=None)
    assert seen, "걷기가 선정까지 안 왔다(이 시험이 무의미)"
    return dict(seen[-1]["extra_fingerprint"])


def test_the_shot_fingerprint_carries_the_gpt_model(tmp_path, monkeypatch):
    """★★gpt25 인데 **Gemini 모델**을 접으면 두 방향으로 다 틀린다.

    · GPT 모델을 갈아도 샷 지문이 안 움직인다 → 옛 그림이 남는다
    · **안 쓰는** Gemini 모델만 갈아도 GPT 롤이 stale → 없어도 될 재생성
    """
    got = _walk_extra(tmp_path / "a", monkeypatch,
                      still_image_backend="gpt25",
                      openai_image_model="gpt-image-2.5-sunburst")
    assert got["image_model"] == "gpt-image-2.5-sunburst", got["image_model"]
    assert got.get("image_backend") == "gpt25"


def test_an_unused_gemini_model_does_not_move_the_gpt_shot_fingerprint(
        tmp_path, monkeypatch):
    """★안 쓰는 엔진의 모델이 움직여도 **이쪽은 그대로**여야 한다."""
    a = _walk_extra(tmp_path / "a", monkeypatch,
                    still_image_backend="gpt25",
                    openai_image_model="gpt-image-2.5-sunburst",
                    gemini_image_model="gemini-A")
    b = _walk_extra(tmp_path / "b", monkeypatch,
                    still_image_backend="gpt25",
                    openai_image_model="gpt-image-2.5-sunburst",
                    gemini_image_model="gemini-B")
    assert a == b, "안 쓰는 Gemini 모델이 GPT 샷 지문을 움직였다"


def test_the_nb2_shot_fingerprint_is_unchanged(tmp_path, monkeypatch):
    """★기본(nb2)은 종전 그대로 — Gemini 모델이고 `image_backend` 키가 없다."""
    got = _walk_extra(tmp_path / "a", monkeypatch,
                      still_image_backend="nb2",
                      gemini_image_model="gemini-3.1-flash-image")
    assert got["image_model"] == "gemini-3.1-flash-image"
    assert "image_backend" not in got


def test_the_generation_model_fallback_does_not_say_gemini_for_gpt25(
        monkeypatch):
    """★실제 호출 신원이 없을 때의 되돌리기도 **Gemini 가 아니다**.

    기록이 거짓이 되는 자리다 — 조회가 실패했다고 안 쓴 엔진을 적지 않는다.
    """
    from app.core.config import settings
    from app.services.still_recipe_service import resolve_generation_model

    monkeypatch.setattr(settings, "openai_image_model",
                        "gpt-image-2.5-sunburst", raising=False)
    out = resolve_generation_model(
        cine_applied=False, cine_model="", ladder_model="",
        still_image_backend="gpt25", gg46_judge_on=False, fix_won=False,
        grok_model="x-ai/grok", gemini_model="gemini-3.1-flash-image",
        base_call_model="")
    assert out == "gpt-image-2.5-sunburst", out
    # ★실제 호출 신원이 있으면 **그것이 먼저**다 (종전 계약 불변)
    assert resolve_generation_model(
        cine_applied=False, cine_model="", ladder_model="",
        still_image_backend="gpt25", gg46_judge_on=False, fix_won=False,
        grok_model="x-ai/grok", gemini_model="gemini-3.1-flash-image",
        base_call_model="실제-부른-모델") == "실제-부른-모델"
