"""참조 검증·비교의 합의 — **호출자마다 다르다** (2026-08-27, #92 5단계).

## 왜 한 규칙으로 못 묶나

두 호출자는 같은 운반층을 쓰지만 **실패의 뜻이 다르다**:

    validate_reference_image   severity=severe 면 **그림을 하나 더 산다**
                               → 합의는 **돈을 쓰는 문턱**이다
    compare_two_images         이미 산 두 장 중 하나를 고른다
                               → 돈이 안 나가므로 문턱의 뜻이 다르다

같은 combine 으로 묶으면 실패의 뜻이 섞인다(2026-08-27 Codex 판정).

## 여기서 잠그는 것

    검증  둘 다 severe → severe · 엇갈림 → split · 한쪽 실패 → incomplete
          ★엇갈리면 **안 산다** — 한 모델이 괜찮다면 버릴 근거가 약하고,
           맞았을 때 아끼는 것은 유료 이미지 한 장이다
    비교  둘 다 image_2 → image_2 · 그 밖 → image_1
          ★이진 선택이라 **점수 합산 같은 것을 안 넣는다**
"""
from __future__ import annotations

import ast
import pathlib

import pytest

import app.modules.pipeline.ref_image_pipeline as rp
from app.modules.llm.dual_vlm import DualResult, OneCall

_SRC = (pathlib.Path(__file__).resolve().parents[2] / "app" / "modules"
        / "pipeline" / "ref_image_pipeline.py")


def _v(alias, severity, *, issues=("i",), score=50, matches=True):
    return OneCall(alias=alias, ok=True, payload={
        "severity": severity, "issues": list(issues), "score": score,
        "matches_description": matches})


def _p(alias, winner, reason="r"):
    return OneCall(alias=alias, ok=True,
                   payload={"winner": winner, "reason": reason})


# ── 검증 = 돈을 쓰는 문턱 ────────────────────────────────────────────

@pytest.mark.parametrize("a,b,want", [
    ("severe", "severe", "severe"),
    ("severe", "minor", "split"),
    ("minor", "severe", "split"),
    ("ok", "ok", "ok"),
    ("minor", "minor", "minor"),
    ("ok", "minor", "split"),
])
def test_only_both_severe_opens_the_wallet(a, b, want):
    got = rp.combine_validation(DualResult([_v("gemini-pro", a), _v("grok", b)]))
    assert got["severity"] == want, f"{a}+{b} → {got['severity']}"


def test_a_single_severe_does_not_buy_another_image():
    """★★**이것이 이 판의 요점이다.**

    종전에는 한 모델이 severe 라 하면 그림을 하나 더 샀다. 이제 둘 다
    그래야 산다 — 엇갈리면 `split` 이고 호출부는 `severe` 가 아니므로
    재생성 갈래로 안 들어간다.
    """
    got = rp.combine_validation(
        DualResult([_v("gemini-pro", "severe"), _v("grok", "ok")]))
    assert got["severity"] != "severe", "한쪽만 severe 인데 돈을 쓴다"


def test_a_dead_model_does_not_buy_either():
    got = rp.combine_validation(DualResult([
        _v("gemini-pro", "severe"), OneCall("grok", False, None, "dead")]))
    assert got["severity"] == "incomplete"
    assert got["severity"] != "severe", "★못 물어봤는데 돈을 쓴다"


def test_issues_are_appended_not_filtered():
    """★서로 다른 뜻의 자유문장을 글자로 거르면 안 된다 — 이어 붙인다."""
    got = rp.combine_validation(DualResult([
        _v("gemini-pro", "minor", issues=("팔이 셋",)),
        _v("grok", "minor", issues=("그림자가 없다",))]))
    assert "[gemini-pro] 팔이 셋" in got["issues"]
    assert "[grok] 그림자가 없다" in got["issues"]
    assert len(got["issues"]) == 2


def test_scores_are_not_averaged():
    """★둘이 다른 뜻으로 쓴 눈금일 수 있다 — 평균은 그 둘을 섞는다.

    (검색 후보 선택 자리에서 실제로 겪었다: Gemini 는 0~100 을, GPT 는
    사실상 0~10 을 썼다 — `search_grounded_ref.py:578` 주석.)
    """
    got = rp.combine_validation(DualResult([
        _v("gemini-pro", "ok", score=90), _v("grok", "ok", score=10)]))
    assert got["score"] == 10, "평균이나 최댓값을 쓴다"


def test_the_shape_the_caller_reads_is_unchanged():
    """호출부는 `severity` 를 읽는다 — 그 계약이 살아 있어야 한다."""
    got = rp.combine_validation(
        DualResult([_v("gemini-pro", "ok"), _v("grok", "ok")]))
    for k in ("matches_description", "severity", "issues", "score"):
        assert k in got, f"옛 계약 칸이 사라졌다: {k}"
    assert "_dual" in got, "모델별 raw 가 안 남는다"


# ── 비교 = 이미 산 두 장 중 고르기 ───────────────────────────────────

@pytest.mark.parametrize("a,b,want", [
    ("image_2", "image_2", "image_2"),
    ("image_2", "image_1", "image_1"),
    ("image_1", "image_2", "image_1"),
    ("image_1", "image_1", "image_1"),
])
def test_only_both_switch(a, b, want):
    got = rp.combine_comparison(DualResult([_p("gemini-pro", a), _p("grok", b)]))
    assert got["winner"] == want, f"{a}+{b} → {got['winner']}"


def test_a_dead_model_keeps_the_original():
    got = rp.combine_comparison(DualResult([
        _p("gemini-pro", "image_2"), OneCall("grok", False, None, "dead")]))
    assert got["winner"] == "image_1", "★못 물어봤는데 새 것으로 바꾼다"


def test_the_comparison_does_not_invent_a_score():
    """★이진 선택에 점수 합산을 넣으면 없던 눈금을 만드는 것이다."""
    src = _SRC.read_text(encoding="utf-8")
    fn = [n for n in ast.walk(ast.parse(src))
          if isinstance(n, ast.FunctionDef) and n.name == "combine_comparison"]
    assert fn, "합의 함수가 사라졌다"
    body = ast.unparse(fn[0])
    for banned in ("score", "borda", "mean", "sum("):
        assert banned not in body.lower(), f"이진 선택에 눈금이 들어왔다: {banned}"


# ── 운반층에 합의가 새어 들지 않는다 ─────────────────────────────────

def test_the_transport_stays_a_transport():
    """★`_call_gpt_lvm` 은 「명시 모델로 한 번 보내는」 부품이다.

    이중 호출·합의가 거기 들어가면 업무마다 다른 실패의 뜻이 운반층으로
    새어 든다.
    """
    src = _SRC.read_text(encoding="utf-8")
    fn = [n for n in ast.walk(ast.parse(src))
          if isinstance(n, ast.FunctionDef) and n.name == "_call_gpt_lvm"]
    assert fn, "운반층이 사라졌다"
    body = ast.unparse(fn[0]).lower()
    for banned in ("ask_both", "combine", "severity", "winner"):
        assert banned not in body, f"운반층이 판단을 한다: {banned}"


def test_both_callers_use_the_single_dual_contract():
    """★계약이 두 벌이 되면 한 벌에 봉인을 빠뜨린다 — 이미 그래서
    두 번 BLOCK 을 받았다."""
    src = _SRC.read_text(encoding="utf-8")
    for name in ("validate_reference_image", "compare_two_images"):
        fn = [n for n in ast.walk(ast.parse(src))
              if isinstance(n, ast.FunctionDef) and n.name == name]
        assert fn, f"{name} 이 사라졌다"
        assert "ask_both" in ast.unparse(fn[0]), (
            f"{name} 이 단일 계약을 안 쓴다")


# ── 유료 판정의 기록이 끝점까지 가는가 ───────────────────────────────

def test_the_comparison_record_survives_the_image_1_path():
    """★★**유료 판정 두 건이 기록에서 사라지던 자리** (2026-08-27 Codex).

    `winner == "image_1"` 이면 아래 반환에 `comparison` 키가 **아예 없어**
    두 모델의 raw·물리 모델·토큰·비용이 통째로 없어졌다. 승리 갈래에서도
    하류 소비자는 `validation` 만 `review_notes` 로 영속한다.

    ★「비교를 안 했다」와 「비교했는데 안 바꿨다」는 다른 사건이고,
     뒤엣것은 **돈이 나간 사건**이다.

    ★고친 법: 새 테이블·배선 없이 **이미 영속되는 칸에 얹는다**.
    """
    src = _SRC.read_text(encoding="utf-8")
    tree = ast.parse(src)
    fn = [n for n in ast.walk(tree)
          if isinstance(n, ast.FunctionDef)
          and n.name == "generate_and_validate_reference"]
    assert fn, "참조 생성부가 사라졌다"
    body = ast.unparse(fn[0])
    assert 'validation[\'comparison\'] = comparison' in body.replace('"', "'"), (
        "비교 결과가 영속되는 칸에 안 얹힌다 — image_1 갈래에서 사라진다")

    # ★얹는 자리가 **승자 판정보다 뒤**여야 두 갈래 다 덮는다.
    idx_cmp = body.index("comparison = compare_two_images")
    idx_put = body.replace('"', "'").index("validation['comparison']")
    idx_ret2 = body.index("'file_path': str(path_2)")
    assert idx_cmp < idx_put < idx_ret2, (
        "얹는 자리가 승리 반환보다 뒤다 — image_2 갈래가 안 덮인다")
