"""Sol 로 도는 geometry 두 자리를 gemini 로 (2026-08-27, #92).

## 어디를 고쳤나 — Opik 실측이 지목했다

30일 span 을 op 태그로 갈랐더니 **Sol 로 도는 자리가 둘** 남아 있었다:

    outdoor_marker_geometry   gpt-5.6-sol   12건
    zoom_continuity_anchor    gpt-5.6-sol    5건
    ─────────────────────────────────────────────
    lane_marker_map_check     gemini-3.1-pro-preview  30건
    marker_sketch_leakage     gemini-3.1-pro-preview   8건
    lane_marker_map_pick      gemini-3.1-pro-preview   1건

★lane 은 **같은 도면·같은 마커를 보는데 판정 모델만 갈려 있었다** —
 검사·유출·고르기는 gemini 인데 기하 판정만 Sol 이었다.

## ★이중화(두 모델)는 여기서 안 한다

도면 읽기(#92 6단계)는 실측 8장으로 「`exact` 를 요구하면 안 된다」를
알아낸 **뒤에** 계약을 정했다. 이 두 자리는 그런 실측이 없다.
합의 규칙을 실측 없이 넣는 것이 바로 그 함정이다.

## ★모델 이름만 바꾸면 안 되는 자리가 하나 있었다

`zoom_continuity_render_service.vlm_locate_bbox` 는 Router 를 안 거치고
`llm_completion` 을 직접 친다. 그 함수는 **OpenAI 가 아닌 모델에 키를
안 끼운다**(`openai_keys.py`). 이 저장소는 gemini 키를 `.env` 에서 풀로
읽어 Router 에 실어 주므로(`llm_client.py:533-541`) 환경변수로 노출된다는
보장이 없다 — **모델 이름만 바꿨으면 이 자리가 통째로 죽었다.**
"""
from __future__ import annotations

import types


def test_the_lane_geometry_judge_is_no_longer_sol():
    """★lane 의 네 판정이 이제 **같은 모델**을 본다."""
    import inspect
    from app.core.steps import shot_conti_light_step as st

    src = inspect.getsource(st)
    assert '"outdoor_marker_geometry": {"model": "gpt"}' not in src, (
        "기하 판정이 아직 Sol 이다")
    # ★값은 **상수**로 간다 — 조립과 지문이 같은 자리를 봐야 한다
    #  (Codex BLOCK-1 반영 뒤 이 시험도 그리로 옮겼다).
    assert st.LANE_GEOMETRY_JUDGE_ALIAS == "gemini-pro"
    assert '{"model": LANE_GEOMETRY_JUDGE_ALIAS}' in src
    # 형제 셋이 그대로인지 — 범위를 넘겨 고치지 않았는지 본다.
    assert '"marker_sketch_leakage": {"model": "gemini-pro"}' in src
    assert '"lane_marker_map_check": {"model": "gemini-pro"}' in src


def test_the_bbox_model_is_no_longer_sol():
    from app.services import zoom_continuity_render_service as z

    assert "sol" not in z.VISION_MODEL_DEFAULT.lower()
    assert z.VISION_MODEL_DEFAULT.startswith("gemini/"), (
        "Router 를 안 거치므로 **물리 이름**이어야 한다")


def test_the_prefix_matches_what_the_router_registers():
    """★접두를 지어내지 않았다 — Router 등록부와 같은 모양인가."""
    import inspect
    from app.modules.llm import llm_client
    from app.services import zoom_continuity_render_service as z

    src = inspect.getsource(llm_client)
    assert 'f"gemini/{model_id}"' in src, (
        "Router 가 쓰는 접두가 바뀌었다 — 이 자리도 같이 봐야 한다")
    assert z.VISION_MODEL_DEFAULT.split("/")[0] == "gemini"


def test_the_bbox_call_carries_a_key_for_gemini(monkeypatch):
    """★**모델만 바꾸면 안 된다 — 키가 같이 가야 한다.**

    `llm_completion` 은 OpenAI 가 아닌 모델에 아무것도 안 끼운다.
    키 없이 나가면 이 자리가 통째로 죽는다.
    """
    import app.core.openai_keys as ok
    import app.modules.llm.gemini_key_pool as pool
    from app.services import zoom_continuity_render_service as z

    seen = {}

    def _fake(**kw):
        seen.update(kw)
        return types.SimpleNamespace(choices=[types.SimpleNamespace(
            message=types.SimpleNamespace(content='{"found": false}'))])

    monkeypatch.setattr(ok, "llm_completion", _fake)
    monkeypatch.setattr(pool, "get_next_key", lambda: "KEY-A")

    z.vlm_locate_bbox(b"\x89PNG", "the subject")
    assert seen.get("api_key") == "KEY-A", (
        "gemini 인데 키가 안 실렸다 — 이 호출은 죽는다")
    assert seen["model"].startswith("gemini/")


def test_a_non_gemini_model_gets_no_key_injected(monkeypatch):
    """반대편 — OpenAI 계열엔 **안 끼운다.**

    `llm_completion` 이 자기 슬롯 키를 넣는 자리라, 여기서 또 넣으면
    그 실패 전환(failover)이 엉뚱한 키로 판단된다.
    """
    import app.core.openai_keys as ok
    from app.services import zoom_continuity_render_service as z

    seen = {}

    def _fake(**kw):
        seen.update(kw)
        return types.SimpleNamespace(choices=[types.SimpleNamespace(
            message=types.SimpleNamespace(content='{"found": false}'))])

    monkeypatch.setattr(ok, "llm_completion", _fake)
    z.vlm_locate_bbox(b"\x89PNG", "x", model="openai/gpt-5.6-sol")
    assert "api_key" not in seen


def test_an_empty_key_pool_does_not_send_a_blank_key(monkeypatch):
    """키 풀이 비면 **빈 문자열을 넣지 않는다.**

    빈 키를 넣으면 litellm 이 「키가 있다」고 보고 401 로 죽는데,
    안 넣으면 환경변수라도 볼 기회가 남는다.
    """
    import app.core.openai_keys as ok
    import app.modules.llm.gemini_key_pool as pool
    from app.services import zoom_continuity_render_service as z

    seen = {}

    def _fake(**kw):
        seen.update(kw)
        return types.SimpleNamespace(choices=[types.SimpleNamespace(
            message=types.SimpleNamespace(content='{"found": false}'))])

    monkeypatch.setattr(ok, "llm_completion", _fake)
    monkeypatch.setattr(pool, "get_next_key", lambda: "")
    z.vlm_locate_bbox(b"\x89PNG", "x")
    assert "api_key" not in seen


def test_no_sol_model_string_is_left_in_the_two_places_we_fixed():
    """★「있는 것」이 아니라 **「필요한 자리에 없는지」**를 본다.

    ★**글자로 찾으면 안 된다** (2026-08-27 실측 — 이 시험의 첫 판이
     그랬다). `"sol" in line` 으로 훑었더니 `_re`**sol**`ve_openai_client`
     라는 **함수 이름**을 배선으로 잡아 빨강이 됐다. 이 저장소가
     「글자/substring 의미 판단 금지」를 규칙으로 둔 그 자리다.

    그래서 **AST 로 문자열 상수만** 본다 — 주석·식별자는 안 센다.
    """
    import ast
    import inspect
    from app.core.steps import shot_conti_light_step as st
    from app.services import zoom_continuity_render_service as z

    for mod in (st, z):
        tree = ast.parse(inspect.getsource(mod))
        bad = [
            n.value for n in ast.walk(tree)
            if isinstance(n, ast.Constant) and isinstance(n.value, str)
            and ("gpt-5.6-sol" in n.value or n.value == "gpt")
        ]
        assert not bad, (
            f"{mod.__name__} 에 Sol 모델 문자열이 남아 있다: {bad[:5]}")


def test_the_substring_trap_is_still_there_to_prove_the_ast_matters():
    """★위 시험이 **왜** AST 여야 하는지 — 그 글자가 실제로 있다.

    이 확인이 없으면 다음 사람이 「그냥 글자로 찾아도 되겠네」 하고
    되돌린다.
    """
    import inspect
    from app.core.steps import shot_conti_light_step as st

    src = inspect.getsource(st)
    assert "_resolve_openai_client" in src, (
        "그 함수가 사라졌다면 이 시험의 근거도 사라진다 — 다시 볼 것")


# ─────────────────────────────────────────────────────────────────────
# 지문 — ★Codex BLOCK 2건 (2026-08-27)
#
# 모델을 바꿨는데 **지문이 안 움직이면** 완주 CP 가 옛 산출을 그대로
# 재사용하면서 **새 모델 판처럼 기록된다.** 고친 것이 아무 데도 안 닿고,
# 나중에 「Sol 판인가 gemini 판인가」를 못 가른다.
# ─────────────────────────────────────────────────────────────────────


def test_the_lane_geometry_judge_has_its_own_fingerprint_slot():
    """★BLOCK-1 — **우연히 덮여 있었다.**

    지문에 있던 `lane_marker_judge` 는 `lane_marker_map_check` 의
    모델이고 **기하 판정이 아니다.** 그것이 이미 gemini 물리명이라,
    기하 판정을 Sol → gemini 로 바꿔도 지문이 한 톨도 안 움직였다.
    """
    import inspect
    from app.core.steps import shot_conti_light_step as st

    src = inspect.getsource(st)
    assert '"lane_geometry_judge"' in src, (
        "기하 판정 신원이 outer 지문에 없다 — 완주 CP 가 Sol 산출을 "
        "재사용하면서 gemini 판처럼 기록된다")
    assert '"geometry_judge": _geo_judge' in src, (
        "샷 지문에 없다 — 샷별 재사용이 옛 산출을 그대로 쓴다")


def test_the_identity_is_effective_not_the_default(monkeypatch):
    """★`project_config` 가 덮으면 **그 값**이어야 한다.

    기본값만 접으면 프로젝트가 다른 모델로 바꿨을 때 지문이 안 움직인다.
    """
    from app.core.steps.shot_conti_light_step import (
        LANE_GEOMETRY_JUDGE_ALIAS, lane_geometry_judge_identity)

    base = lane_geometry_judge_identity(None)
    assert base["alias"] == LANE_GEOMETRY_JUDGE_ALIAS
    assert base["physical"] and base["physical"] != base["alias"], (
        "물리 모델이 안 풀렸다 — 별칭 뒤 모델이 바뀌어도 지문이 안 움직인다")

    over = lane_geometry_judge_identity(
        {"outdoor_marker_geometry": {"model": "gpt"}})
    assert over["alias"] == "gpt"
    assert over != base


def test_the_assembly_and_the_fingerprint_read_the_same_place():
    """★조립과 지문이 **한 자리**를 봐야 한다.

    앞 판은 조립만 고쳤고 지문은 남의 칸을 보고 있었다.
    """
    import inspect
    from app.core.steps import shot_conti_light_step as st

    src = inspect.getsource(st)
    assert '{"model": LANE_GEOMETRY_JUDGE_ALIAS}' in src, (
        "조립이 상수를 안 쓴다 — 지문과 갈릴 수 있다")
    assert "_geo_judge = lane_geometry_judge_identity(" in src


def test_the_zoom_bbox_model_is_in_the_outer_fingerprint(monkeypatch):
    """★BLOCK-2 — 켜진 판에서 bbox 모델이 지문에 있는가."""
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep as S

    step = S.__new__(S)
    step.project_config = {}
    monkeypatch.setattr(settings, "zoom_continuity_anchor_enabled", True)
    on = step._config_hash_base()

    import app.services.zoom_continuity_render_service as z
    monkeypatch.setattr(z, "VISION_MODEL_DEFAULT", "다른/모델")
    assert step._config_hash_base() != on, (
        "bbox 모델이 바뀌어도 지문이 그대로다 — 완주 still 이 새 호출까지 "
        "안 내려온다")


def test_the_zoom_slot_is_absent_when_the_flag_is_off(monkeypatch):
    """반대편 — OFF 에서는 칸을 안 만든다(byte-identical)."""
    import inspect
    from app.core.steps import image_steps

    src = inspect.getsource(image_steps.SceneImagePipelineStep)
    assert 'if getattr(settings, "zoom_continuity_anchor_enabled"' in src, (
        "조건 없이 접으면 zoom 을 안 쓰는 판까지 무효화된다")


def test_the_bbox_diagnostics_record_which_model_answered():
    """★어느 모델이 답했는지 **기록에 남는가.**

    안 남기면 Sol 판과 gemini 판을 나중에 못 가른다.
    """
    import inspect
    from app.services import zoom_continuity_render_service as z

    src = inspect.getsource(z)
    assert 'diagnostics["bbox_model"] = VISION_MODEL_DEFAULT' in src
    assert '"model": VISION_MODEL_DEFAULT' in src, (
        "bbox.json 에 모델이 없다")


# ─────────────────────────────────────────────────────────────────────
# ★Codex BLOCK 2회차 — 지문만 움직여선 아무 일도 안 난다
# ─────────────────────────────────────────────────────────────────────


def test_the_zoom_stamp_survives_the_recipe_off_early_return(monkeypatch):
    """★첫 판이 **정확히 여기서** 무력했다.

    `_config_hash_base` 는 `still_recipe_mode == "off"` 면 먼저 return
    한다. zoom stamp 를 그 아래 recipe 구간에 넣었더니, **정작
    `generate_continuity_crop_png` 가 도는 legacy batch 경로에서는
    지문에 한 번도 안 들어갔다.**

    ★내 첫 시험은 recipe 모드를 **안 고정해서** 활성 기본값만 재고
     이 반대편을 놓쳤다.
    """
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep as S
    import app.services.zoom_continuity_render_service as z

    step = S.__new__(S)
    step.project_config = {}
    monkeypatch.setattr(settings, "still_recipe_mode", "off")
    monkeypatch.setattr(settings, "zoom_continuity_anchor_enabled", True)

    before = step._config_hash_base()
    monkeypatch.setattr(z, "VISION_MODEL_DEFAULT", "다른/모델")
    assert step._config_hash_base() != before, (
        "recipe OFF 인 legacy 경로에서 zoom 모델이 지문에 안 들어간다 — "
        "그 경로가 바로 zoom 이 실제로 도는 자리다")


def test_the_zoom_stamp_is_absent_when_zoom_is_off(monkeypatch):
    """반대편 — zoom OFF 면 byte-identical."""
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep as S
    import app.services.zoom_continuity_render_service as z

    step = S.__new__(S)
    step.project_config = {}
    monkeypatch.setattr(settings, "still_recipe_mode", "off")
    monkeypatch.setattr(settings, "zoom_continuity_anchor_enabled", False)

    before = step._config_hash_base()
    monkeypatch.setattr(z, "VISION_MODEL_DEFAULT", "또다른/모델")
    assert step._config_hash_base() == before, (
        "zoom 을 안 쓰는 판까지 무효화된다")


class _FakeQuery:
    """SQLAlchemy 의 filter 식을 **그대로 평가한다.**

    뜻을 다시 구현하지 않는다 — 프로덕션이 거는 네 조건
    (project_id · still_id · asset_type · is_primary) 중 하나라도
    빠지면 여기서 결과가 달라진다.
    """

    def __init__(self, rows):
        self._rows = list(rows)

    def filter(self, *criteria):
        keep = []
        for row in self._rows:
            if all(getattr(row, c.left.name, None) == c.right.value
                   for c in criteria):
                keep.append(row)
        return _FakeQuery(keep)

    def first(self):
        return self._rows[0] if self._rows else None


class _FakeDb:
    def __init__(self, rows):
        self._rows = rows

    def query(self, _model):
        return _FakeQuery(self._rows)


def _asset(**kw):
    from app.models.project import ImageAsset

    base = {"project_id": "p1", "asset_type": "scene", "is_primary": 1,
            "variant_type": "zoom_continuity_crop", "review_notes": None}
    base.update(kw)
    return ImageAsset(**base)


def _notes(model):
    import json as _json
    return _json.dumps({"zoom_continuity": {"bbox_model": model}})


def test_a_target_made_by_another_model_is_stale():
    """★모델이 다르면 **낡았다** — 그것만 done 에서 뺀다."""
    from app.services.zoom_continuity_render_service import (
        stale_zoom_targets)

    db = _FakeDb([
        _asset(still_id="s32", review_notes=_notes("openai/gpt-5.6-sol")),
        _asset(still_id="s41",
               review_notes=_notes("gemini/gemini-3.1-pro-preview")),
    ])
    got = stale_zoom_targets(
        db, "p1", {(3, 2): "s32", (4, 1): "s41"},
        current_model="gemini/gemini-3.1-pro-preview")
    assert got == [(3, 2)]


def test_a_zoom_primary_without_a_bbox_model_is_stale():
    """★`bbox_model` 칸이 **없으면 낡은 것**이다.

    그 칸은 이 판에서 처음 넣었으므로, 없다는 것은 곧 옛 모델로
    만들었다는 뜻이다.
    """
    from app.services.zoom_continuity_render_service import (
        stale_zoom_targets)

    db = _FakeDb([_asset(still_id="s11", review_notes="{}")])
    assert stale_zoom_targets(db, "p1", {(1, 1): "s11"},
                              current_model="gemini/x") == [(1, 1)]


def test_a_target_that_was_never_made_is_not_stale():
    """★primary 가 없으면 **낡았다고 하지 않는다.**

    아직 안 만든 것이고 done 목록에도 없다 — 여기서 빼면 없는 것을
    두 번 세는 셈이다.
    """
    from app.services.zoom_continuity_render_service import (
        stale_zoom_targets)

    assert stale_zoom_targets(_FakeDb([]), "p1", {(9, 9): "s99"},
                              current_model="gemini/x") == []


def test_a_mid_flight_bbox_file_does_not_make_it_look_fresh(tmp_path,
                                                            monkeypatch):
    """★★중간 파일은 **완료 신원이 아니다** (Codex BLOCK 3회차).

    `bbox.json` 은 crop 을 뜨는 도중에 쓴다. i2i fill 이 거절당하거나
    프로세스가 그 사이에 죽으면 파일만 새 모델 이름을 달고 남고
    primary 는 옛 Sol 그림 그대로다 — 그때도 **낡았다**고 해야 한다.
    """
    from app.core.config import settings
    from app.services.zoom_continuity_render_service import (
        WC2_ARTIFACTS_DIRNAME, stale_zoom_targets)

    monkeypatch.setattr(settings, "projects_dir", str(tmp_path))
    d = (tmp_path / "p1" / "checkpoints" / "episodes" / "e1"
         / "zoom_continuity_anchor" / WC2_ARTIFACTS_DIRNAME / "S3sh2")
    d.mkdir(parents=True, exist_ok=True)
    (d / "bbox.json").write_text(
        '{"model": "gemini/gemini-3.1-pro-preview"}', encoding="utf-8")

    db = _FakeDb([
        _asset(still_id="s32", review_notes=_notes("openai/gpt-5.6-sol")),
    ])
    assert stale_zoom_targets(
        db, "p1", {(3, 2): "s32"},
        current_model="gemini/gemini-3.1-pro-preview") == [(3, 2)]


def test_a_non_zoom_fallback_primary_is_not_stale():
    """★zoom 이 fallback 으로 빠져 T2I 로 나온 것은 낡지 않았다.

    bbox 가 애초에 안 실렸으므로 모델을 바꿔도 다시 살 이유가 없다.
    """
    from app.services.zoom_continuity_render_service import (
        stale_zoom_targets)

    db = _FakeDb([_asset(still_id="s32", variant_type="theme_a",
                         review_notes=None)])
    assert stale_zoom_targets(db, "p1", {(3, 2): "s32"},
                              current_model="gemini/x") == []


def test_another_projects_primary_is_not_read():
    """★네 조건 중 `project_id` 가 빠지면 남의 판을 읽는다."""
    from app.services.zoom_continuity_render_service import (
        stale_zoom_targets)

    db = _FakeDb([_asset(project_id="다른판", still_id="s32",
                         review_notes=_notes("openai/gpt-5.6-sol"))])
    assert stale_zoom_targets(db, "p1", {(3, 2): "s32"},
                              current_model="gemini/x") == []


def test_the_resume_loop_actually_drops_the_stale_ones():
    """★**만들어 놓고 안 부르면 없는 것과 같다** — 배선을 본다.

    지문만 움직여선 아무 일도 안 난다. 재개가 그 목록을 실제로
    `already_done_stills` 에서 빼야 gemini bbox 가 불린다.
    """
    import ast
    import inspect
    from app.services import scene_image_service as sis

    src = inspect.getsource(sis)
    assert "stale_zoom_targets" in src, "낡은 대상 판별을 안 부른다"
    assert "already_done_stills = {" in src, (
        "done 목록에서 빼는 자리가 없다")
    # ★글자가 아니라 **호출**로 확인한다 (오늘 세 번 걸린 함정).
    tree = ast.parse(src)
    called = {
        (n.func.id if isinstance(n.func, ast.Name)
         else n.func.attr if isinstance(n.func, ast.Attribute) else "")
        for n in ast.walk(tree) if isinstance(n, ast.Call)}
    assert "stale_zoom_targets" in called
