"""선정=Gemini+GPT Sol · 수정 관찰=Gemini+Grok — 두 자리가 갈리는가.

## 무엇을 재나

2026-08-29 사용자 지시로 **고르는 자리**만 둘째 심판을 grok-4.6 에서
GPT-5.6 Sol 로 옮겼다. **결함을 찾는 자리**는 Gemini+Grok 그대로다.
두 자리가 서로 다른 목록을 쓰므로, 하나를 바꿔도 다른 하나가 안 움직이는
것을 못박는다.

## 특히 재는 것 — Codex BLOCK 넷의 재발 방지

  ① 갈래를 정하는 자리가 **하나**인가 (`select_judge_pair_kind`).
     종전에는 세 군데가 각자 `openrouter:` 접두사를 단서로 삼아, 둘째가
     Router alias 로 바뀌면 셋이 조용히 어긋났다.
  ② 선정이 **안 쓰는 열쇠**(OpenRouter)를 요구하지 않는가.
  ③ 지문이 **실제로 움직이는가** — 둘째 심판 물리 모델을 바꾸면
     outer `_config_hash` 가 갈려야 한다. 안 갈리면 완주 스텝이 SKIP 되어
     **새 판정이 내려가지도 않는다**(이 저장소에서 다섯 번 난 결함).
  ④ 두 슬롯 **다** 봉인되는가 — `enable_fallback=False` 만으로는 Router
     재시도(기본 3회)를 못 막아 「정확히 두 번」이 거짓이 된다.
"""
from __future__ import annotations

from typing import Any, Dict, List

import pytest

from app.core.config import settings
from app.core.errors import AppError
from app.modules.pipeline import multiroll_gemini as mg

GROK = mg.OPENROUTER_JUDGE_PREFIX + "x-ai/grok-4.6"


@pytest.fixture()
def gg46(monkeypatch):
    monkeypatch.setattr(settings, "multiroll_gg46_judge_enabled", True)
    monkeypatch.setattr(settings, "multiroll_qk_judge_enabled", False)
    monkeypatch.setattr(settings, "multiroll_gq_judge_enabled", False)
    monkeypatch.setattr(settings, "grok_judge_model", "x-ai/grok-4.6")
    monkeypatch.setattr(settings, "openrouter_api_key", "k")
    monkeypatch.setattr(settings, "openai_model", "gpt-5.6-sol")
    monkeypatch.setattr("app.core.openai_keys.has_openai_key", lambda: True)


# ── ① 갈래를 정하는 자리가 하나인가 ─────────────────────────────────

@pytest.mark.parametrize("models,expected", [
    ([mg.JUDGE_MODEL, mg.SELECT_JUDGE_MODEL_2], mg.PAIR_EQUAL_CROSS),
    ([mg.JUDGE_MODEL, GROK], mg.PAIR_EQUAL_CROSS),
    ([mg.QWEN_JUDGE_MODEL, mg.JUDGE_MODEL], mg.PAIR_QK_REVERSED),
    ([mg.JUDGE_MODEL, mg.QWEN_JUDGE_MODEL], mg.PAIR_GQ),
    ([mg.SELECT_JUDGE_MODEL, mg.SELECT_JUDGE_MODEL_2], mg.PAIR_DUAL_MARGIN),
    ([mg.JUDGE_MODEL], mg.PAIR_SINGLE),
])
def test_pair_kind_is_the_single_source(models, expected):
    """★둘째가 OpenRouter 든 Router alias 든 **같은 갈래**로 읽힌다.

    종전 조건은 `openrouter:` 접두사를 봤다. 그건 「동등 교차쌍이다」가
    아니라 「둘째가 OpenRouter 를 탄다」일 뿐이라, GPT 로 옮기는 순간
    조건부 이중(margin-skip)으로 조용히 떨어졌다.
    """
    assert mg.select_judge_pair_kind(models) == expected


def test_qwen_wins_over_openrouter_so_owns_order_cannot_diverge():
    """★Qwen 과 OpenRouter 가 같이 실리면 dispatch 는 Qwen 을 잡아간다.

    그때 `owns_order` 가 True 면 바깥이 flip 을 건너뛰어 정·역 비교가
    **오류 없이 사라진다.** 한 함수만 쓰므로 갈릴 수 없다.
    """
    ms = [mg.JUDGE_MODEL, mg.QWEN_JUDGE_MODEL, GROK]
    assert mg.select_judge_pair_kind(ms) == mg.PAIR_GQ
    assert mg.select_judge_pair_kind(ms) != mg.PAIR_EQUAL_CROSS


# ── ② 선정은 안 쓰는 열쇠를 요구하지 않는다 ──────────────────────────

def test_selection_pair_is_gemini_plus_sol(gg46):
    assert mg.resolve_select_judge_models() == [
        mg.JUDGE_MODEL, mg.SELECT_JUDGE_MODEL_2]


def test_selection_survives_without_openrouter(gg46, monkeypatch):
    """선정은 grok 을 안 쓴다 — 없다고 422 를 내면 정상 구성이 막힌다."""
    monkeypatch.setattr(settings, "openrouter_api_key", "")
    monkeypatch.setattr(settings, "grok_judge_model", "")
    assert mg.resolve_select_judge_models() == [
        mg.JUDGE_MODEL, mg.SELECT_JUDGE_MODEL_2]


def test_selection_fails_closed_without_openai_key(gg46, monkeypatch):
    monkeypatch.setattr("app.core.openai_keys.has_openai_key", lambda: False)
    with pytest.raises(AppError) as ei:
        mg.resolve_select_judge_models()
    assert "select_judge_unconfigured" in getattr(ei.value, "code", "")


# ── 수정 관찰은 안 움직인다 ─────────────────────────────────────────

def test_critique_observers_are_gemini_plus_gpt6_astra(gg46, monkeypatch):
    """★[2026-09-08 사용자 지시로 **뒤집었다**] 관찰 둘째 자리 = gpt-6-astra(high).

    종전 이 시험은 「관찰은 gemini + grok 그대로」를 잠갔다. 사용자가
    「vlm 판단 부분도 grok 대신 gpt-6-astra 로 하고 전부 high 로 …
    즉, gemini + gpt-6-astra(high)」로 정해 계약이 바뀌었다.

    ★원래 이 시험이 막던 것은 **여전히 막는다** — 관찰이 선정 목록을
     "우연히" 따라가는 것이 아니라, 둘이 같은 슬롯을 쓰기로 **정해진**
     것이어야 한다. 그래서 상수를 견주고, OpenRouter 로는 안 나가는 것도
     같이 잠근다.
    """
    import app.modules.llm.llm_client as llm_client
    import app.modules.llm.openrouter_vlm_client as orc

    seen: Dict[str, Any] = {}

    def _compose(tag, *a, **k):
        seen.setdefault("tags", []).append(tag)
        if "_observe" in tag:
            return {"observations": []}
        return {"issues": []}

    monkeypatch.setattr(llm_client, "call_structured", _compose)
    monkeypatch.setattr(
        orc, "ask_openrouter_structured",
        lambda *a, **k: seen.setdefault("or_called", True) or {
            "observations": []})
    fn = mg.make_gg46_critique_fn(critique_schema={"type": "object"})
    out = fn("t", "P", [], _tiny_png())
    assert out["observer_models"] == [mg.JUDGE_MODEL, mg.SELECT_JUDGE_MODEL_2]
    assert mg.SELECT_JUDGE_MODEL_2 == "gpt-high"
    assert "or_called" not in seen, "OpenRouter 로 나갔다 — Router alias 로 가야 한다"


def test_critique_second_slot_follows_the_setting_at_high(gg46):
    """★끝점 ① — alias 가 **설정된 모델**로 풀리고 강도가 high 인가.

    alias 이름만 보면 판이 바뀐 것을 못 잡는다. 모델 이름은 설정에서 오므로
    여기서는 「설정을 따라가는가 + 강도가 high 인가」를 잰다.
    """
    import app.modules.llm.llm_client as lc

    phys = {d["model_name"]: d["litellm_params"]
            for d in lc._build_router().model_list}
    lp = phys.get(mg.SELECT_JUDGE_MODEL_2)
    assert lp, f"{mg.SELECT_JUDGE_MODEL_2} 가 Router 에 없다"
    assert lp["model"] == f"openai/{settings.openai_model}", lp
    assert lp.get("reasoning_effort") == "high", lp


def test_gpt_text_default_is_gpt6_astra_with_three_efforts():
    """★끝점 ② — **실제 설정값**. 위 시험은 fixture 가 모델을 못박으므로
    「무엇으로 설정돼 있나」는 여기서 따로 잰다 (2026-09-08 사용자 지시:
    기본 medium · 나머지 low · VLM 판정 high).
    """
    from app.core.config import Settings

    st = Settings()
    assert st.openai_model == "gpt-6-astra", st.openai_model
    assert st.openai_reasoning_effort == "medium"
    assert st.openai_reasoning_effort_aux == "low"
    assert st.openai_reasoning_effort_judge == "high"


def test_critique_preflight_fires_on_call_not_on_build(gg46, monkeypatch):
    """★확인은 **부를 때** 건다 (계약 그대로, 보는 열쇠만 바뀌었다).

    이 팩토리는 수정 단계가 꺼져 있어도 만들어진다. 만드는 자리에서 막으면
    아무도 안 쓰는 열쇠 때문에 정상 구성이 죽는다.

    ★보는 열쇠가 OpenRouter → OpenAI 로 바뀌었다. 둘째 관찰자가 그것을
     쓰기 때문이다 — 안 쓰는 열쇠를 요구하면 같은 결함을 자리만 옮긴다.
    """
    monkeypatch.setattr("app.core.openai_keys.has_openai_key", lambda: False)
    fn = mg.make_gg46_critique_fn(critique_schema={"type": "object"})
    with pytest.raises(AppError) as ei:
        fn("t", "P", [], _tiny_png())
    assert "gg46_critique_unconfigured" in getattr(ei.value, "code", "")


def _tiny_png():
    import pathlib
    import tempfile

    p = pathlib.Path(tempfile.mkdtemp()) / "a.png"
    p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 16)
    return p


# ── ③ 지문이 실제로 움직이는가 (끝점) ───────────────────────────────

def _still_step():
    from app.core.steps.image_steps import SceneImagePipelineStep

    class _가짜(SceneImagePipelineStep):
        def __init__(self):            # 부모 __init__ 을 타지 않는다
            self.project_config = {}

    return _가짜()


def _bg_step():
    from app.core.steps.background_render_step import BackgroundRenderStep

    class _가짜(BackgroundRenderStep):
        def __init__(self):
            self.project_config = {}

    return _가짜()


def test_still_outer_hash_moves_when_second_judge_changes(gg46, monkeypatch):
    """★종전에는 `gemini|grok` 이 **문자열로 박혀** 있어 안 움직였다.

    그러면 완주 스텝이 clean 으로 SKIP 되고 새 판정이 **내려가지도
    않는다.** 이 저장소에서 같은 결함이 다섯 번 났다.
    """
    s = _still_step()
    before = s._config_hash_base()
    monkeypatch.setattr(settings, "openai_model", "gpt-5.6-terra")
    after = s._config_hash_base()
    assert before != after, (
        "둘째 심판 물리 모델을 바꿨는데 스틸 outer 지문이 그대로다")


def test_background_outer_hash_moves_when_second_judge_changes(
        gg46, monkeypatch):
    """★배경판 outer 에는 선정 쌍이 **통째로 없었다** (Codex BLOCK-3).

    안쪽(`plate_multiroll.py`) 지문에는 있었는데, outer 가 clean 이면
    스텝이 내려가지 않아 안쪽은 읽히지도 않는다.
    """
    monkeypatch.setattr(settings, "plate_multiroll_enabled", True)
    s = _bg_step()
    before = s._config_hash()
    monkeypatch.setattr(settings, "openai_model", "gpt-5.6-terra")
    after = s._config_hash()
    assert before != after, (
        "둘째 심판을 바꿨는데 배경판 outer 지문이 그대로다 — 배경판 A/B 도 "
        "같은 판정자를 쓴다")


def test_select_policy_names_the_algorithm_not_the_models():
    """★선정 정책 문자열도 **모델 이름을 안 싣는다** (2026-08-29).

    순서 정책과 같은 이유다 — 어느 모델이 어느 슬롯인지는
    `select_judge_models_physical`(물리 쌍)이 따로 적고, 이 문자열은
    **합의 알고리즘**을 말한다. 모델을 실으면 쌍이 바뀔 때마다 두 곳을
    같이 고쳐야 하고, 한쪽만 고치면 기록이 거짓이 된다.
    """
    v = mg.GG46_SELECT_POLICY_VERSION
    for name in ("grok", "gemini", "gpt", "sol", "qwen", "kimi", "opus"):
        assert name not in v.lower(), (
            f"선정 정책 문자열에 모델 이름 {name!r} 이 박혔다: {v!r}")
    assert "always_combine" in v and "binary_penalty" in v


# ── ④ 두 슬롯 다 봉인 ───────────────────────────────────────────────

def test_both_slots_sealed_and_no_router_retry(gg46, monkeypatch):
    """`enable_fallback=False` 만으로는 Router 재시도를 못 막는다.

    `llm_client.py:1200-1206` 이 그렇게 적어 뒀다 — 한 alias 가 최대 4번
    나가고 `usage_sink` 는 마지막 응답만 본다. 「모델마다 한 번씩」이
    계약인 자리라 `num_retries=0` 이 같이 가야 한다.
    """
    import app.modules.llm.llm_client as llm_client

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

    def _fake(tag, sys_p, parts, schema, **kw):
        seen.append({"tag": tag, "fallback": kw.get("enable_fallback"),
                     "retries": kw.get("num_retries")})
        return {"verdicts": [{"label": "A", "score": 9, "reason_ko": "r"},
                             {"label": "B", "score": 5, "reason_ko": "r"}],
                "ranking": ["A", "B"], "winner": "A",
                "all_candidates_fail": False, "readings": []}

    monkeypatch.setattr(llm_client, "call_structured", _fake)
    judge_fn = mg.make_gemini_judge_fn(
        judge_sys="J", judge_schema={"type": "object"}, step_tag="t")
    png = _tiny_png()
    judge_fn("t", "P", [], [png, png], ["A", "B"])
    assert len(seen) == 2, f"정확히 두 번이 아니다: {len(seen)}"
    for row in seen:
        assert row["fallback"] is False, f"{row['tag']} 슬롯이 안 봉인됐다"
        assert row["retries"] == 0, f"{row['tag']} 에 num_retries=0 이 안 갔다"


# ── ⑤ 기록이 거짓을 말하지 않는가 (Codex 재리뷰 BLOCK) ──────────────

def test_order_policy_string_has_no_model_name():
    """★순서 정책 문자열에 **모델 이름을 박지 않는다.**

    v1 은 `cross_model_order_v1_gemini_fwd_grok_rev` 였다. 둘째 슬롯이
    GPT 로 바뀌자 이 값이 실린 durable 기록(`select_order_policy`,
    still·배경판 지문)이 **「grok 이 역순을 봤다」는 거짓**을 말하게 됐다.
    route 이름에서 모델을 뺀 것과 같은 이유다.

    이 문자열이 말해야 하는 것은 **순서 알고리즘**이고, 어느 모델이 어느
    슬롯에 앉았는지는 `select_judge_models_physical` 과 `slots[].model` 이
    따로 적는다.
    """
    v = mg.CROSS_MODEL_ORDER_POLICY_VERSION
    for name in ("grok", "gemini", "gpt", "sol", "qwen", "kimi", "opus"):
        assert name not in v.lower(), (
            f"순서 정책 문자열에 모델 이름 {name!r} 이 박혔다: {v!r} — "
            "슬롯이 바뀌면 durable 기록이 거짓이 된다")
    assert "v2" in v and "fwd" in v and "rev" in v


def test_measure_tools_do_not_carry_the_db_password():
    """★조사 도구에 DB 비밀번호를 적지 않는다 (GitGuardian 이 PR 을 세웠다).

    접속 정보의 집은 `settings.database_url` **하나**다. 도구가 따로
    적어 두면 그 값이 저장소에 두 벌로 남고, 바뀔 때 한쪽만 바뀐다.
    `_db.py` 가 설정에서 받아 오는 단일 창구다.

    ★`app/core/config.py` 는 뺀다 — 개발용 기본 DSN 의 **집**이고,
    문제는 그것을 도구가 **베껴 적은 것**이었다.
    """
    import pathlib as _pl

    root = _pl.Path(__file__).resolve().parents[2]
    needle = "theroad_dev" + "_2026"      # 이 시험 자신이 걸리지 않게 나눔
    hits = []
    for p in list((root / "tools").rglob("*.py")) + \
            list((root / "app").rglob("*.py")):
        if p.name == "config.py" and p.parent.name == "core":
            continue
        if needle in p.read_text(encoding="utf-8", errors="ignore"):
            hits.append(str(p.relative_to(root)))
    assert not hits, f"평문 DB 비밀번호가 남았다: {hits}"
