"""이중 판정에서 fallback 이 열려 있으면 안 된다 (2026-08-27, #92 3단계).

## 무엇이 문제였나

`call_structured` 의 Tier 3 은 `{"model": "gpt"}` 로 간다
(`llm_client.py:1191`). 그래서 `gemini-pro` 심판이 **안전 관련 오류**로
떨어지면 GPT 가 대신 답하는데, 호출부는 그 답을
`per_judge["gemini-pro"]` 로 담는다.

두 번째 심판도 GPT 계열이면 「이중 판정」이 실은 **같은 모델 두 번**이고,
`combine_pick_verdicts` 는 그것을 **독립 두 의견으로 평균**한다.

★**Opik 6,000 span 실측**: `gpt_fallback` 은 실제로 일어난다(5회). 다만
 **이 스텝에서 일어난 기록은 없다.** 열려 있는 장치를 닫는 것이지 사고를
 수습하는 것이 아니다 — 그렇게 적는다.

## 막으면 무엇을 잃나

그 심판을 그냥 잃는다. 기존 `single_judge`·`rejected_judges` 계약이 그것을
드러낸다. **이름이 틀린 판정보다 없는 판정이 정직하다.**
"""
from __future__ import annotations

import ast
import pathlib

_STEP = (pathlib.Path(__file__).resolve().parents[2] / "app" / "core"
         / "steps" / "outdoor_structure_form_reference_step.py")


def _pick_calls():
    """`structure_form_ref_pick` 을 부르는 `call_structured` 노드들."""
    tree = ast.parse(_STEP.read_text(encoding="utf-8"))
    out = []
    for n in ast.walk(tree):
        if not isinstance(n, ast.Call):
            continue
        f = n.func
        name = (f.id if isinstance(f, ast.Name)
                else f.attr if isinstance(f, ast.Attribute) else "")
        if name != "call_structured":
            continue
        first = n.args[0] if n.args else None
        if (isinstance(first, ast.Constant)
                and first.value == "structure_form_ref_pick"):
            out.append(n)
    return out


def test_the_dual_judge_call_seals_the_fallback():
    """★★열려 있으면 **GPT 가 gemini 이름으로 담긴다.**"""
    calls = _pick_calls()
    assert calls, "심판 호출부가 사라졌다 — 이 시험이 아무것도 안 지킨다"
    for c in calls:
        kws = {k.arg: k.value for k in c.keywords}
        node = kws.get("enable_fallback")
        assert isinstance(node, ast.Constant) and node.value is False, (
            f"line {c.lineno}: 이중 판정인데 fallback 이 열려 있다")


def test_the_call_asks_for_its_usage():
    """★심판 하나가 얼마짜리인지 모르면 「둘을 부른다」의 값을 못 잰다."""
    for c in _pick_calls():
        kws = {k.arg: k.value for k in c.keywords}
        assert "usage_sink" in kws, (
            f"line {c.lineno}: 토큰·비용·물리 모델을 안 받는다")


def test_the_tier3_really_goes_to_gpt():
    """★**전제를 확인한다.** Tier 3 이 GPT 가 아니면 위 시험들이 지키는
    것이 없다 — 「무엇을 막는지」가 사라지면 다음 사람이 봉인을 푼다."""
    src = (pathlib.Path(__file__).resolve().parents[2] / "app" / "modules"
           / "llm" / "llm_client.py").read_text(encoding="utf-8")
    assert 'gpt_config[step] = {"model": "gpt"}' in src, (
        "Tier 3 이 GPT 로 안 간다 — 이 시험 묶음의 전제를 다시 읽을 것")


def test_the_record_separates_asked_from_answered():
    """★심판 이름은 alias 이고, 답한 물리 모델은 다를 수 있다."""
    src = _STEP.read_text(encoding="utf-8")
    assert "judge_usage" in src, "무엇이 답했는지를 안 남긴다"
    assert 'combined["judge_usage"]' in src, (
        "감사 기록에 안 실린다 — 만들어 놓고 안 쓰면 없는 것과 같다")


def test_both_judges_are_recorded_even_when_one_dies():
    """실패한 심판의 usage 도 남는다 — 「안 불렀다」와 구분해야 한다."""
    src = _STEP.read_text(encoding="utf-8")
    tree = ast.parse(src)
    handlers = [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)
                and "judge_errors" in ast.dump(n)]
    assert handlers, "심판 실패 처리부가 사라졌다"
    assert any("judge_usage" in ast.dump(h) for h in handlers), (
        "실패한 심판의 usage 를 안 남긴다")


def test_the_early_return_keeps_the_money_it_already_spent():
    """★★**심판이 다 실패해도 이미 낸 돈은 기록에 남아야 한다**
    (2026-08-27 Codex BLOCK).

    응답을 받은 **뒤** JSON·schema 검증에서 떨어지면 `usage_sink` 는 이미
    토큰·비용·물리 모델을 담고 있다. 그런데 `per_judge` 가 비었다고
    `verdict={}` 로 바로 나가면 그 값이 통째로 사라진다 —
    「심판이 다 실패했다」와 「돈이 안 나갔다」는 다른 사건이다.
    """
    src = _STEP.read_text(encoding="utf-8")
    tree = ast.parse(src)

    # `if not per_judge:` 의 몸통을 찾는다
    bodies = []
    for n in ast.walk(tree):
        if not isinstance(n, ast.If):
            continue
        t = n.test
        if (isinstance(t, ast.UnaryOp) and isinstance(t.op, ast.Not)
                and isinstance(t.operand, ast.Name)
                and t.operand.id == "per_judge"):
            bodies.append(n)
    assert bodies, "전원 실패 갈래가 사라졌다 — 이 시험이 아무것도 안 지킨다"

    for b in bodies:
        dumped = ast.dump(b)
        assert "judge_usage" in dumped, (
            f"line {b.lineno}: 전원 실패 갈래가 이미 낸 비용을 버린다")
        assert "judge_errors" in dumped, (
            f"line {b.lineno}: 어느 심판이 왜 죽었는지를 안 남긴다")
        # ★`verdict={}` 로 나가면 감사에서 통째로 사라진다
        assert "verdict={}" not in ast.unparse(b).replace(" ", ""), (
            f"line {b.lineno}: 빈 verdict 로 나간다")


def test_this_place_is_already_dual():
    """★★**「아직 이중이 아니다」는 내 잘못된 분류였다** (2026-08-27
    Codex 재리뷰).

    `PICK_JUDGES` 는 이미 `("gemini-pro", "gpt")` 이고 호출부가 둘을 돈다.
    grok 으로 갈기 전에도 **두 심판이고 비용 위험은 같다.** 그런데 나는
    「아직 이중 아님」으로 분류하고 그것을 시험으로 못박아, 활성 경로의
    재시도 구멍을 정답으로 고정했다.
    """
    from app.modules.pipeline.search_grounded_ref import PICK_JUDGES

    assert len(PICK_JUDGES) >= 2, "이 자리는 이중 판정이다"
    assert len(set(PICK_JUDGES)) == len(PICK_JUDGES), "같은 모델 두 번이다"


def test_the_pick_call_seals_router_retries():
    """★`enable_fallback=False` 는 Router 재시도를 **안 막는다.**

    Router 는 `num_retries=3` 으로 지어지므로 심판마다 최대 4번 전송될 수
    있고, 재시도는 다 과금되는데 `usage_sink` 는 마지막 응답만 본다.
    """
    calls = _pick_calls()
    assert calls, "심판 호출부가 사라졌다"
    for c in calls:
        kws = {k.arg: k.value for k in c.keywords}
        node = kws.get("num_retries")
        assert isinstance(node, ast.Constant) and node.value == 0, (
            f"line {c.lineno}: Router 재시도가 열려 있다 — "
            "심판 하나가 조용히 네 번 과금될 수 있다")
