"""모델 이름이 두 벌로 갈라지는 것을 막는다 (2026-08-27).

Flash 를 3.5 → 3.7 로 올릴 때 두 자리가 옛 이름으로 남아 있었다.

  ① `llm_client.AVAILABLE_MODELS` 의 UI 라벨 세 줄이 "Gemini 3.5 Flash"
     — 화면이 **거짓을 말한다.** 어느 모델로 도는지 사람이 못 가린다.
  ② `prompt_service` 의 기록 라벨에 `"gemini-3.5-flash"` 가 박혀 있었다
     — 실제 호출은 alias `gpt-mini` → `settings.gemini_flash_model` 로
     가므로, 설정만 올리면 **Opik 기록만 옛 이름**으로 남는다.

여기서 재는 것은 둘이다. 라벨이 실제 설정값과 같은 판을 가리키는가,
그리고 모델 ID 를 `config.py` 밖에서 손으로 적은 자리가 없는가.
"""
import ast
import re
from pathlib import Path

APP = Path(__file__).resolve().parents[2] / "app"

# alias → 그 alias 가 실제로 가는 설정 칸. `llm_client._build_router` 의
# `gemini_models` 목록이 SOT 다 — 거기가 바뀌면 여기도 바꾼다.
ALIAS_TO_SETTING = {
    "gemini-pro": "gemini_text_model",
    "gemini-flash": "gemini_flash_model",
    "gemini-lite": "gemini_lite_model",
    "gpt-mini": "gemini_flash_model",
    # ★2026-09-08 추가 — GPT 쪽도 같은 결함이 실제로 났다. `openai_model` 을
    #  `gpt-5.6-sol` → `gpt-6-astra` 로 올렸는데 라벨 넉 줄이 "GPT-5.6 Sol"
    #  로 남아 있었고, 그때 이 시험은 gemini 만 봐서 **못 잡았다.**
    #  넷 다 물리 모델은 하나고 추론 강도만 다르다 — 강도는 라벨 뒤 괄호로.
    "gpt": "openai_model",
    "gpt-terra": "openai_model",
    "gpt-luna": "openai_model",
    "gpt-nano": "openai_model",
}

#: 라벨 끝의 괄호는 **강도 표시**지 모델 이름이 아니다 — "GPT-6 Astra (low)".
_TRAILING_PAREN_RE = re.compile(r"\s*\([^()]*\)\s*$")


def _label_to_id_prefix(label: str) -> str:
    """"Gemini 3.8 Flash" → "gemini-3.8-flash" — 라벨을 모델 ID 꼴로."""
    return _TRAILING_PAREN_RE.sub("", label).strip().lower().replace(" ", "-")


def _mismatches(available, settings_obj):
    """라벨과 설정이 어긋난 (alias, 라벨, 모델) 목록. 헬퍼로 빼서 **양성도**
    잰다 — 초록만 보고 「장치가 산다」고 하면 안 된다."""
    out = []
    for entry in available:
        alias = entry["alias"]
        setting_name = ALIAS_TO_SETTING.get(alias)
        if not setting_name:
            continue
        model = getattr(settings_obj, setting_name, "")
        prefix = _label_to_id_prefix(entry["label"])
        if not model.startswith(prefix):
            out.append((alias, entry["label"], model))
    return out


def test_ui_labels_match_the_model_actually_called():
    from app.core.config import settings
    from app.modules.llm.llm_client import AVAILABLE_MODELS

    bad = _mismatches(AVAILABLE_MODELS, settings)
    assert bad == [], (
        "UI 라벨이 실제로 부르는 모델과 어긋난다 — 화면이 거짓을 말한다: "
        + "; ".join(f"{a}: 라벨 {lb!r} vs 모델 {m!r}" for a, lb, m in bad)
    )


def test_the_check_actually_catches_a_stale_label():
    """양성 — 옛 라벨을 넣으면 **그 줄을** 잡아야 한다."""
    from app.core.config import settings

    stale = [
        {"alias": "gemini-flash", "label": "Gemini 3.5 Flash",
         "provider": "gemini"},
        {"alias": "gemini-pro", "label": "Gemini 3.1 Pro",
         "provider": "gemini"},
        # ★GPT 쪽 양성 — 실제로 이 꼴로 남아 있었다.
        {"alias": "gpt", "label": "GPT-5.6 Sol", "provider": "openai"},
        {"alias": "gpt-terra", "label": "GPT-6 Astra (low)",
         "provider": "openai"},
    ]
    bad = _mismatches(stale, settings)
    assert [b[0] for b in bad] == ["gemini-flash", "gpt"], (
        f"양성 확인 실패 — 옛 flash·gpt 라벨 두 줄만 잡아야 하는데 {bad}")


# `gemini-3.5-flash` 같은 모델 ID 를 손으로 적어도 되는 유일한 자리.
_ID_OK_FILES = {"core/config.py"}
_MODEL_ID_RE = re.compile(r"^(gemini/)?gemini-\d+(\.\d+)?-(flash|pro)")


def _hardcoded_model_ids():
    """app/ 안 **문자열 리터럴** 중 gemini 모델 ID 를 손으로 적은 자리.

    ★문자열 검색이 아니라 AST 로 센다 — 주석과 문서 문자열은 세지 않아야
     한다(이 결함을 설명하는 주석 자체가 걸리면 래칫이 못 쓰게 된다).
    """
    found = []
    for py in APP.rglob("*.py"):
        rel = py.relative_to(APP).as_posix()
        if rel in _ID_OK_FILES:
            continue
        try:
            tree = ast.parse(py.read_text(encoding="utf-8"))
        except SyntaxError:  # pragma: no cover — 파싱 못 하면 셀 수 없다
            continue
        for node in ast.walk(tree):
            if isinstance(node, ast.Constant) and isinstance(node.value, str):
                if _MODEL_ID_RE.match(node.value.strip()):
                    found.append((rel, node.lineno, node.value))
    return found


def test_flash_model_id_is_not_hand_written_outside_config():
    """Flash ID 는 `config.py` 밖에서 손으로 적지 않는다.

    Pro ID(`gemini-3.1-pro-preview`)는 `getattr(settings, ..., 기본값)` 의
    fallback 으로 여러 provider 모듈에 남아 있다 — 그 자리는 설정이 SOT 라
    이번 범위 밖이다. 그래서 **flash 만** 못박는다.
    """
    bad = [f for f in _hardcoded_model_ids() if "-flash" in f[2]]
    assert bad == [], (
        "flash 모델 ID 가 config.py 밖에 박혀 있다 — 설정을 올려도 이 자리는 "
        f"옛 이름으로 남는다: {bad}")


def test_the_ast_scan_would_catch_a_hand_written_id(tmp_path):
    """양성 — AST 스캔이 리터럴은 잡고 주석은 안 잡는지."""
    src = (
        '# "gemini-3.5-flash" 라고 주석에 적은 것은 안 잡아야 한다\n'
        'MODEL = "gemini-3.5-flash"\n'
        'OTHER = "gemini/gemini-3.1-pro-preview"\n'
        'NOT_A_MODEL = "gemini-flash"\n'
    )
    tree = ast.parse(src)
    hits = [
        n.value for n in ast.walk(tree)
        if isinstance(n, ast.Constant) and isinstance(n.value, str)
        and _MODEL_ID_RE.match(n.value.strip())
    ]
    assert hits == ["gemini-3.5-flash", "gemini/gemini-3.1-pro-preview"], hits


# ── Router 에 등록한 별칭이 공통 처리도 다 받는가 (2026-09-08) ────────
#
# ★`gpt-high` 를 Router 에만 넣고 `_NO_TEMPERATURE_ALIASES`·`_OPENAI_ALIASES`
#  에서 빠뜨렸다 (Codex NON-BLOCK). 같은 물리 모델인데 한 별칭만
#  `temperature` 를 달고 나가고, strict 스키마 정리도 안 받는다.
#  라벨 시험은 **이 경계를 안 본다** — 그래서 여기 따로 잠근다.

def _openai_router_aliases():
    """Router 가 실제로 만든 배포 중 OpenAI 물리 모델로 가는 별칭."""
    from app.modules.llm.llm_client import _build_router

    out = set()
    for d in _build_router().model_list:
        model = str((d.get("litellm_params") or {}).get("model") or "")
        if model.startswith("openai/"):
            out.add(str(d["model_name"]))
    return out


def test_openai_별칭은_전부_temperature_제거_대상이다():
    from app.modules.llm.llm_client import _NO_TEMPERATURE_ALIASES

    # gpt-4.1 계열은 temperature 를 받는 옛 모델이라 대상이 아니다.
    reasoning = {a for a in _openai_router_aliases()
                 if not a.startswith("gpt-4.1")}
    missing = sorted(reasoning - set(_NO_TEMPERATURE_ALIASES))
    assert missing == [], (
        f"Router 에만 있고 temperature 제거 목록에 없는 별칭: {missing} — "
        "같은 물리 모델인데 한 별칭만 temperature 를 달고 나간다")


def test_openai_별칭은_전부_strict_스키마_정리_대상이다():
    from app.modules.llm.llm_client import _OPENAI_ALIASES

    reasoning = {a for a in _openai_router_aliases()
                 if not a.startswith("gpt-4.1")}
    missing = sorted(reasoning - set(_OPENAI_ALIASES))
    assert missing == [], (
        f"Router 에만 있고 OpenAI 스키마 정리 목록에 없는 별칭: {missing}")


def test_temperature_가_실제로_떨어지는지_끝점에서(monkeypatch):
    """★양성 — 목록에 있다는 것과 kwargs 에서 빠지는 것은 다르다."""
    from app.modules.llm.llm_client import _sanitize_kwargs_for_model

    for alias in sorted(_openai_router_aliases()):
        kw = {"temperature": 0.7, "max_tokens": 100}
        _sanitize_kwargs_for_model(alias, kw)
        if alias.startswith("gpt-4.1"):
            assert "temperature" in kw, f"{alias} 에서 잘못 제거됐다"
        else:
            assert "temperature" not in kw, f"{alias} 가 temperature 를 달고 간다"


# ── OpenAI 추론 모델은 max_tokens 를 거부한다 (2026-09-08 실측) ───────
#
# ★`gpt-5.6-*` → `gpt-6-astra` 로 올린 **다음 주행에서** 터졌다:
#    OpenAIException - Unsupported parameter: 'max_tokens' is not supported
#    with this model. Use 'max_completion_tokens' instead.
#  앞 모델은 받아 줬기 때문에 이 자리가 없어도 굴러갔다. 모델을 갈아 끼우면
#  **그 값을 옮기는 줄**도 같이 봐야 한다는 사례다.

def test_openai_추론_별칭은_max_completion_tokens_로_나간다():
    from app.modules.llm.llm_client import _sanitize_kwargs_for_model

    for alias in sorted(_openai_router_aliases()):
        kw = {"max_tokens": 4096}
        _sanitize_kwargs_for_model(alias, kw)
        if alias.startswith("gpt-4.1"):
            assert kw == {"max_tokens": 4096}, f"{alias} 를 잘못 바꿨다: {kw}"
        else:
            assert "max_tokens" not in kw, (
                f"{alias} 가 max_tokens 를 달고 나간다 — provider 가 400 을 낸다")
            assert kw.get("max_completion_tokens") == 4096, kw


def test_gemini_와_grok_은_그대로_max_tokens_다():
    """★막은 것을 넓히지 않았다 — 넓히면 Gemini 쪽이 통째로 죽는다."""
    from app.modules.llm.llm_client import _sanitize_kwargs_for_model

    for alias in ("gemini-pro", "gemini-flash", "gemini-lite", "gpt-mini"):
        kw = {"max_tokens": 4096}
        _sanitize_kwargs_for_model(alias, kw)
        assert kw["max_tokens"] == 4096, f"{alias} 를 바꿨다: {kw}"
        assert "max_completion_tokens" not in kw


def test_둘_다_실려_오면_작은_쪽만_남는다():
    """provider 는 둘이 같이 오면 거부한다."""
    from app.modules.llm.llm_client import _sanitize_kwargs_for_model

    kw = {"max_tokens": 4096, "max_completion_tokens": 1024}
    _sanitize_kwargs_for_model("gpt", kw)
    assert kw == {"max_completion_tokens": 1024}, kw


def test_router_를_직접_부르는_길도_이_정리를_지난다(monkeypatch):
    """★★★`router_completion` 을 직접 부르는 자리 넷(ref_image_pipeline ·
    text_cleaner · fal_angle_helpers×2)은 `call_structured` 의 정리를 안
    지난다. 규칙을 문(`_completion`)에 걸었는지 본다.

    ★소스에 그 줄이 있는지 **읽지 않는다** — 그건 조립부다. `router` 를
     대역으로 세워 **실제로 나간 kwargs** 를 잡는다 (Codex NON-BLOCK).
    """
    from app.modules.llm import llm_client as L

    sent = {}

    class _FakeRouter:
        def completion(self, **kwargs):
            sent.update(kwargs)
            return object()

    class _FakeBinding:
        router = _FakeRouter()
        slot = "primary"

    monkeypatch.setattr(L, "_get_router_binding", lambda: _FakeBinding())
    monkeypatch.setattr(L, "_current_stop_check", lambda: None)

    L.router_completion(model="gpt", messages=[{"role": "user", "content": "x"}],
                        max_tokens=4096, temperature=0.7)

    assert sent, "대역 router 가 안 불렸다 — 이 시험이 아무것도 안 재고 있다"
    assert "max_tokens" not in sent, (
        f"router 직통 경로가 max_tokens 를 그대로 보냈다: {sorted(sent)}")
    assert sent.get("max_completion_tokens") == 4096, sent
    assert "temperature" not in sent, "추론 모델에 temperature 가 실렸다"


def test_같은_dict_를_두_번_정리해도_같다():
    """멱등 — `call_structured` 가 한 번, `_completion` 이 또 한 번 건다."""
    from app.modules.llm.llm_client import _sanitize_kwargs_for_model

    kw = {"max_tokens": 4096, "temperature": 0.7}
    _sanitize_kwargs_for_model("gpt", kw)
    once = dict(kw)
    _sanitize_kwargs_for_model("gpt", kw)
    assert kw == once, f"두 번 걸었더니 달라졌다: {once} → {kw}"

    kg = {"max_tokens": 20000}
    _sanitize_kwargs_for_model("grok", kg)
    o = dict(kg)
    _sanitize_kwargs_for_model("grok", kg)
    assert kg == o == {"max_tokens": 8000}, (o, kg)


def test_두_상한이_역순으로_와도_작은_쪽이_남는다():
    from app.modules.llm.llm_client import _sanitize_kwargs_for_model

    a = {"max_tokens": 1024, "max_completion_tokens": 4096}
    _sanitize_kwargs_for_model("gpt", a)
    assert a == {"max_completion_tokens": 1024}, a


# ── 호출별 timeout override (2026-09-09 파청 66씬) ───────────────────
#
# ★`shot_continuity` 의 전역 저작 둘이 씬 원문 전문을 한 번에 읽는데,
#  66씬에서 전역 기본 600초를 **네 번 연속** 넘겼다. 전역을 올리면 모든 글
#  스텝이 오래 기다려 진짜 지연이 늦게 드러난다 — 그래서 그 자리만 준다.

def test_timeout_을_안_주면_아무것도_안_실린다():
    """★기존 호출은 **불변**이어야 한다."""
    from app.modules.llm import llm_client as L

    sent = {}

    class _R:
        def completion(self, **kw):
            sent.update(kw)
            raise RuntimeError("stop")

    class _B:
        router = _R()
        slot = "primary"

    import pytest
    with pytest.MonkeyPatch.context() as m:
        m.setattr(L, "_get_router_binding", lambda: _B())
        m.setattr(L, "_current_stop_check", lambda: None)
        try:
            L.call_structured("x", "s", "u", {"type": "object"})
        except Exception:
            pass
    assert "timeout" not in sent, f"안 줬는데 실렸다: {sent.get('timeout')}"


def test_timeout_을_주면_그_호출에만_실린다():
    from app.modules.llm import llm_client as L

    sent = {}

    class _R:
        def completion(self, **kw):
            sent.update(kw)
            raise RuntimeError("stop")

    class _B:
        router = _R()
        slot = "primary"

    import pytest
    with pytest.MonkeyPatch.context() as m:
        m.setattr(L, "_get_router_binding", lambda: _B())
        m.setattr(L, "_current_stop_check", lambda: None)
        try:
            L.call_structured("x", "s", "u", {"type": "object"},
                              timeout=2400, num_retries=0)
        except Exception:
            pass
    assert sent.get("timeout") == 2400.0, sent
    assert sent.get("num_retries") == 0, (
        "재시도를 안 껐다 — 42분짜리 시도를 네 번 되풀이한다")


def test_전역_저작_둘이_그_상한을_쓴다():
    """★배선이 실제로 닿는지 — 상수만 두고 안 쓰면 아무 일도 안 난다."""
    import inspect

    from app.modules.pipeline import shot_continuity_author as A

    assert A.GLOBAL_AUTHOR_TIMEOUT_SECONDS > 600
    assert A.GLOBAL_AUTHOR_RETRIES == 0
    src = inspect.getsource(A.run_shot_continuity)
    assert src.count("timeout=GLOBAL_AUTHOR_TIMEOUT_SECONDS") == 2, (
        "전역 저작 둘(pose_canon·carried)에 다 안 걸렸다")


def test_전역_기본값은_안_올렸다():
    """★올리면 모든 글 스텝이 오래 기다린다 (Codex 반대)."""
    from app.core.config import settings

    assert settings.llm_timeout_text == 600, (
        f"전역 타임아웃이 {settings.llm_timeout_text} 로 바뀌었다 — "
        "이 수리는 그 자리만 늘리는 것이다")


# ── 출력 상한은 모델마다 제 최대 (2026-09-09) ────────────────────────
#
# ★전역 하나로 전부 재던 판에서 두 방향으로 틀렸다:
#    gpt-6-astra  최대 128,000 인데 65,536 만 요청 (절반을 버렸다)
#    gpt-4.1      최대  32,768 인데 65,536 을 요청 (넘겨서 보냈다)

def test_모델마다_제_최대를_쓴다():
    from app.core.config import settings
    from app.modules.llm.llm_client import (
        _build_router, _sanitize_kwargs_for_model)

    _build_router()
    want = {"gpt": 128000, "gpt-high": 128000, "gpt-4.1": 32768,
            "gemini-pro": 65536, "gemini-flash": 65536, "grok": 8000}
    for alias, cap in want.items():
        kw = {"max_tokens": settings.llm_max_output_tokens}
        _sanitize_kwargs_for_model(alias, kw)
        got = kw.get("max_completion_tokens") or kw.get("max_tokens")
        assert got == cap, f"{alias}: {got:,} (기대 {cap:,})"


def test_우리가_일부러_좁힌_것은_모델_표가_못_넓힌다():
    """★grok 8,000 은 비용 때문에 좁힌 것 — 모델 최대는 500,000 이다."""
    from app.modules.llm.llm_client import (
        MODEL_MAX_OUTPUT_TOKENS, _build_router, _provider_max_output)

    _build_router()
    assert MODEL_MAX_OUTPUT_TOKENS["grok"] == 8000
    assert _provider_max_output("grok") == 8000, (
        "명시 상한을 모델 표가 덮었다 — 콜당 비용이 예순 배로 열린다")


def test_모르는_별칭은_기존_동작_그대로():
    from app.modules.llm.llm_client import _sanitize_kwargs_for_model

    kw = {"max_tokens": 4096}
    _sanitize_kwargs_for_model("no-such-alias", kw)
    assert kw == {"max_tokens": 4096}, kw
