"""#92 (2026-08-27): VLM 다섯 자리가 **키를 실어 보내는지** 끝점에서 잰다.

## 왜 이 파일이 있나 — 상수만 보는 래칫은 이걸 못 잡았다

앞 판에서 모델 상수를 gemini 로 바꾸고 「VLM 에 Sol 이 없다」를 재는
래칫을 붙였다. 그건 초록이었는데 **호출은 전부 죽는 상태**였다:

    if not has_openai_key(): raise ...            # gemini 인데 OpenAI 키를 본다
    resp = litellm.completion(model=model, ...)   # api_key 가 없다

`llm_completion` 도 `litellm.completion` 도 OpenAI 가 아닌 모델에는
키를 안 끼운다. 그래서 여기서는 **조립이 아니라 나가는 것**을 잰다 —
`litellm.completion` 이 실제로 받은 kwargs 에 `api_key` 가 있는지.

돈은 안 쓴다: `litellm.completion` 을 stub 으로 갈아 끼운다.
"""
from __future__ import annotations

from contextlib import suppress
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

GEMINI = "gemini/gemini-3.1-pro-preview"
FAKE_KEY = "AIza-테스트-절대-안-나간다"


# ───────────────────────────── 공용 도구 ─────────────────────────────


@pytest.fixture
def wire(monkeypatch, tmp_path):
    """`litellm.completion` 을 붙잡고, gemini 키 풀을 정해진 값으로 고정한다."""
    import litellm
    from app.modules.llm import gemini_key_pool

    monkeypatch.setattr(gemini_key_pool, "get_next_key", lambda: FAKE_KEY)
    monkeypatch.setattr(litellm, "supports_response_schema",
                        lambda **kw: True, raising=False)
    monkeypatch.setattr(
        litellm, "get_supported_openai_params",
        lambda **kw: ["response_format", "max_completion_tokens", "timeout"],
        raising=False)

    msg = SimpleNamespace(content="{}", refusal=None)
    resp = SimpleNamespace(
        choices=[SimpleNamespace(message=msg, finish_reason="stop")])
    mock = MagicMock(return_value=resp)
    monkeypatch.setattr(litellm, "completion", mock, raising=False)
    return mock


def _png(tmp_path: Path, name: str = "fp.png") -> Path:
    p = tmp_path / name
    p.write_bytes(b"\x89PNG\r\n\x1a\nfake")
    return p


def _sent_key(mock: MagicMock) -> object:
    """`litellm.completion` 이 **실제로 받은** api_key."""
    assert mock.call_count >= 1, "호출이 아예 안 나갔다"
    return mock.call_args.kwargs.get("api_key")


# ─────────────────────── 다섯 자리 — 끝점에서 잰다 ───────────────────────


def test_dwelling_zone_map_carries_the_gemini_key(wire, tmp_path):
    from app.modules.pipeline import dwelling_zone_map_provider as m

    with suppress(Exception):
        m.map_bgs_to_zones(
            fp_image_path=str(_png(tmp_path)),
            bg_blocks=[{"bg_id": "b1", "description": "방"}],
            model=GEMINI)
    assert _sent_key(wire) == FAKE_KEY


def test_space_set_bg_vision_carries_the_gemini_key(wire, tmp_path):
    from app.modules.pipeline import space_set_bg_provider as m

    with suppress(Exception):
        m.vision_completion(system="s", user="u",
                            image_paths=[str(_png(tmp_path))], model=GEMINI)
    assert _sent_key(wire) == FAKE_KEY


def test_indoor_pose_qc_carries_the_gemini_key(wire, tmp_path):
    from app.modules.pipeline import indoor_shared_pose_provider as m

    with suppress(Exception):
        m.qc_indoor_pose_guide(b"\x89PNG-fake", expected_figures=1,
                               model=GEMINI)
    assert _sent_key(wire) == FAKE_KEY


def test_floor_plan_semantic_carries_the_gemini_key(wire, tmp_path):
    from app.modules.pipeline import floor_plan_semantic_vlm_provider as m

    dossier = {"fp_id": "fp_a", "base_marker_inventory": [
        {"number": 1, "label": "main unit", "category": "area",
         "position_hint": "center",
         "base_layer_decision": "base_structural_unit"}]}
    with suppress(Exception):
        m.litellm_semantic_vlm_provider(
            dossier=dossier, fp_image_path=str(_png(tmp_path)), model=GEMINI)
    assert _sent_key(wire) == FAKE_KEY


def test_shot_projection_card_carries_the_gemini_key(wire, tmp_path):
    from app.modules.pipeline import shot_projection_card_provider as m

    inv = [{"number": 1, "label": "door", "category": "opening",
            "position_hint": "south"}]
    with suppress(Exception):
        m.litellm_projection_card_provider(
            inventory=inv, fp_id="fp", bg_id="b", shot_id="s",
            fp_image_path=str(_png(tmp_path)), model=GEMINI)
    assert _sent_key(wire) == FAKE_KEY


# ──────────────────── 반대쪽 — OpenAI 는 그대로 둔다 ────────────────────


def test_an_openai_model_does_not_get_a_gemini_key(wire, tmp_path):
    """★고친 범위를 넘지 않았는지 본다.

    텍스트 자리는 여전히 OpenAI 다 — 브로커가 활성 슬롯 키를 끼운다.
    여기에 gemini 키를 끼우면 그 자리가 죽는다.
    """
    from app.modules.pipeline import space_set_bg_provider as m

    with suppress(Exception):
        m.text_completion(system="s", user="u", model="openai/gpt-5.6-sol")
    assert _sent_key(wire) != FAKE_KEY


# ─────────────────────────── vlm_auth 자체 ───────────────────────────


def test_provider_is_read_from_the_prefix():
    from app.modules.pipeline.vlm_auth import provider_of

    assert provider_of(GEMINI) == "gemini"
    assert provider_of("openrouter/x-ai/grok-4.6") == "openrouter"
    assert provider_of("gpt-5.6-sol") == "openai", "접두가 없으면 OpenAI 다"


def test_an_empty_gemini_pool_stops_the_call_instead_of_going_out(monkeypatch):
    """★키가 없으면 **부르지 않는다** — 나갔다가 실패하는 것이 아니다."""
    from app.modules.llm import gemini_key_pool
    from app.modules.pipeline.vlm_auth import auth_kwargs

    def _raise():
        raise RuntimeError("No Gemini API keys configured")

    monkeypatch.setattr(gemini_key_pool, "get_next_key", _raise)

    class _Err(Exception):
        pass

    with pytest.raises(_Err) as ei:
        auth_kwargs(GEMINI, _Err)
    assert "GEMINI_API_KEY" in str(ei.value)


# ────────────── 키를 두 번 뽑으면 풀의 절반이 죽는다 ──────────────


@pytest.fixture
def rotating(monkeypatch):
    """★키 **두 개짜리** 풀 — 부를 때마다 다음 것을 준다.

    앞 판의 stub 은 늘 같은 키를 돌려줬다. 그래서 관문이 한 번,
    호출이 또 한 번 뽑아 **A 를 태워 버리고 늘 B 로 나가는 것**을
    못 잡았다. B 가 막히면 멀쩡한 A 가 있어도 다섯 경로가 다 죽는다
    (2026-08-27 Codex BLOCK).
    """
    import litellm
    from app.modules.llm import gemini_key_pool

    keys = ["KEY-A", "KEY-B"]
    calls = []

    def _next():
        k = keys[len(calls) % len(keys)]
        calls.append(k)
        return k

    monkeypatch.setattr(gemini_key_pool, "get_next_key", _next)
    monkeypatch.setattr(litellm, "supports_response_schema",
                        lambda **kw: True, raising=False)
    monkeypatch.setattr(
        litellm, "get_supported_openai_params",
        lambda **kw: ["response_format", "max_completion_tokens", "timeout"],
        raising=False)

    msg = SimpleNamespace(content="{}", refusal=None)
    resp = SimpleNamespace(
        choices=[SimpleNamespace(message=msg, finish_reason="stop")])
    mock = MagicMock(return_value=resp)
    monkeypatch.setattr(litellm, "completion", mock, raising=False)
    return mock, calls


def _one_call_one_key(mock, calls, first_key="KEY-A"):
    assert len(calls) == 1, (
        f"유료 호출 하나에 키를 {len(calls)}번 뽑았다 — 라운드로빈이 "
        f"헛돈다: {calls}")
    assert mock.call_args.kwargs.get("api_key") == first_key, (
        f"뽑은 키({calls})와 나간 키"
        f"({mock.call_args.kwargs.get('api_key')})가 다르다")


def test_dwelling_takes_one_key_per_paid_call(rotating, tmp_path):
    from app.modules.pipeline import dwelling_zone_map_provider as m

    mock, calls = rotating
    with suppress(Exception):
        m.map_bgs_to_zones(
            fp_image_path=str(_png(tmp_path)),
            bg_blocks=[{"bg_id": "b1", "description": "방"}], model=GEMINI)
    _one_call_one_key(mock, calls)


def test_space_set_bg_takes_one_key_per_paid_call(rotating, tmp_path):
    from app.modules.pipeline import space_set_bg_provider as m

    mock, calls = rotating
    with suppress(Exception):
        m.vision_completion(system="s", user="u",
                            image_paths=[str(_png(tmp_path))], model=GEMINI)
    _one_call_one_key(mock, calls)


def test_indoor_pose_takes_one_key_per_paid_call(rotating):
    from app.modules.pipeline import indoor_shared_pose_provider as m

    mock, calls = rotating
    with suppress(Exception):
        m.qc_indoor_pose_guide(b"\x89PNG-fake", expected_figures=1,
                               model=GEMINI)
    _one_call_one_key(mock, calls)


def test_floor_plan_semantic_takes_one_key_per_paid_call(rotating, tmp_path):
    from app.modules.pipeline import floor_plan_semantic_vlm_provider as m

    mock, calls = rotating
    dossier = {"fp_id": "fp_a", "base_marker_inventory": [
        {"number": 1, "label": "main unit", "category": "area",
         "position_hint": "center",
         "base_layer_decision": "base_structural_unit"}]}
    with suppress(Exception):
        m.litellm_semantic_vlm_provider(
            dossier=dossier, fp_image_path=str(_png(tmp_path)), model=GEMINI)
    _one_call_one_key(mock, calls)


def test_shot_projection_card_takes_one_key_per_paid_call(rotating, tmp_path):
    from app.modules.pipeline import shot_projection_card_provider as m

    mock, calls = rotating
    inv = [{"number": 1, "label": "door", "category": "opening",
            "position_hint": "south"}]
    with suppress(Exception):
        m.litellm_projection_card_provider(
            inventory=inv, fp_id="fp", bg_id="b", shot_id="s",
            fp_image_path=str(_png(tmp_path)), model=GEMINI)
    _one_call_one_key(mock, calls)


def test_every_wired_vlm_site_actually_calls_auth_kwargs():
    """★**만들어 놓고 안 부르면 없는 것과 같다** — 호출로 확인한다.

    글자가 아니라 AST 로 센다. 오늘 세 번 걸린 함정이다 — docstring 안의
    이름이 「부른다」로 읽혔다.
    """
    import ast
    import inspect

    from app.modules.pipeline import (
        dwelling_zone_map_provider, floor_plan_semantic_vlm_provider,
        indoor_shared_pose_provider, shot_projection_card_provider,
        space_set_bg_provider,
    )

    for mod in (dwelling_zone_map_provider, space_set_bg_provider,
                indoor_shared_pose_provider, floor_plan_semantic_vlm_provider,
                shot_projection_card_provider):
        tree = ast.parse(inspect.getsource(mod))
        called = {
            n.func.id for n in ast.walk(tree)
            if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
        assert "auth_kwargs" in called, f"{mod.__name__} 이 키를 안 싣는다"


def test_no_vlm_site_still_gates_on_has_openai_key():
    """★옛 관문이 남아 있으면 gemini 를 부르며 OpenAI 키를 확인한다."""
    import ast
    import inspect

    from app.modules.pipeline import (
        dwelling_zone_map_provider, floor_plan_semantic_vlm_provider,
        indoor_shared_pose_provider, shot_projection_card_provider,
        space_set_bg_provider,
    )

    for mod in (dwelling_zone_map_provider, space_set_bg_provider,
                indoor_shared_pose_provider, floor_plan_semantic_vlm_provider,
                shot_projection_card_provider):
        tree = ast.parse(inspect.getsource(mod))
        called = {
            n.func.id for n in ast.walk(tree)
            if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
        assert "has_openai_key" not in called, (
            f"{mod.__name__} 이 아직 OpenAI 키로 막는다")
