"""승자가 같아도 둘째 슬롯의 하드위반이 살아남는가 (2026-08-29).

## 무엇이 새고 있었나

`_judge_cross_model_order` 는 두 슬롯 승자가 같으면 `canon[슬롯0]` 을
**그대로** 돌려줬다. 둘째 슬롯이 찾은 하드위반은 `slots[].normalized`
안에만 남고 top-level·`all_candidates_fail`·durable 기록에 안 닿았다.
그리고 합산 갈래는 `readings` 키를 **아예 안 돌려줬다**.

실측(2026-08-29 최소 주행 6샷): 슬롯이 낸 하드위반 **9건 중 3건**이
top-level 에 안 실렸다.

    S1sh4  agree     {gemini:1, gpt:2} → top 1   (2건 사라짐)
    S3sh2  combined  {gemini:1, gpt:0} → top 0   (1건 사라짐)

★단, 이것이 **수리 입력**에서 사라진 것은 아니다 — 수리는
`_critique_and_fix` 가 `critique_fn` 을 **새로 부른다**
(`multiroll_select.py:872`). 피해는 ①선정 합산 ②재촬영 신호
③durable 감사 셋이다. 그렇게 좁혀 적는다.

## 페널티도 같이 고친다

종전 `PENALTY * len(vio[lab])` 은 **보고 문장 수가 곧 페널티 크기**였다.
같은 결함을 둘이 말하면 두 번, 한 심판이 장황하면 그 후보만 더 깎였다.
문자열로 같음을 판정하는 것은 이 저장소 규칙 위반이라(의미 판단은
LLM/VLM 만) dedupe 대신 **후보당 한 번**으로 바꾼다.
"""
from __future__ import annotations

from typing import Any, Dict, List

from app.modules.pipeline import multiroll_gemini as mg

G = "gemini-pro"
X = "gpt"
LABELS = ["A", "B"]


def _verdict(winner: str, scores: Dict[str, int],
             hard: Dict[str, List[str]] | None = None,
             all_fail: bool = False) -> Dict[str, Any]:
    hard = hard or {}
    return {
        "winner": winner,
        "ranking": sorted(LABELS, key=lambda x: -scores[x]),
        "verdicts": [{"label": x, "score": scores[x], "verdict_ko": f"v{x}"}
                     for x in LABELS],
        "readings": [{"label": x, "direction": "d", "built_space": "b",
                      "entities": "e", "physics": "p",
                      "hard_violations": list(hard.get(x) or [])}
                     for x in LABELS],
        "all_candidates_fail": all_fail,
    }


def _hv(res: Dict[str, Any]) -> Dict[str, List[str]]:
    return {str(r.get("label")): list(r.get("hard_violations") or [])
            for r in (res.get("readings") or [])}


# ── ① agree 에서도 둘째 슬롯 위반이 top-level 에 실린다 ──────────────

def test_agree_keeps_both_slots_hard_violations():
    """★두 슬롯이 **서로 다른** 위반을 냈고 승자는 같은 경우."""
    per = {
        G: _verdict("B", {"A": 3, "B": 9}, {"A": ["젬: 형광등 두 개"]}),
        X: _verdict("B", {"A": 4, "B": 8},
                    {"A": ["솔: 손가락 기형", "솔: 프레임 끊김"]}),
    }
    out = mg.combine_select_verdicts(per, LABELS)
    got = _hv(out)["A"]
    assert len(got) == 3, f"세 건이 다 안 실렸다: {got}"
    assert any("gemini" in x for x in got) and any("gpt" in x for x in got), (
        "모델 provenance 가 안 붙었다 — 누가 낸 것인지 못 가린다")
    assert out["winner"] == "B"


def test_combined_route_also_returns_readings():
    """★합산 갈래는 `readings` 키가 **아예 없었다** — 구조화 위반이 0 이 됐다."""
    per = {
        G: _verdict("A", {"A": 9, "B": 3}, {"B": ["젬: 부유 물체"]}),
        X: _verdict("B", {"A": 3, "B": 9}),
    }
    out = mg.combine_select_verdicts(per, LABELS)
    assert "readings" in out, "합산 결과에 readings 가 없다"
    assert _hv(out)["B"] == ["[gemini-pro] 젬: 부유 물체"]


# ── ② 페널티는 후보당 한 번 ─────────────────────────────────────────

def _adj(per) -> Dict[str, float]:
    return (mg.combine_select_verdicts(per, LABELS)["dual"]["adjusted"])


def test_penalty_is_once_per_candidate_whatever_the_wording():
    """같은 문구 / 다른 문구 / 한 모델이 여러 문구 — 셋 다 한 번만 깎는다."""
    same = {G: _verdict("B", {"A": 8, "B": 8}, {"A": ["같은 결함"]}),
            X: _verdict("B", {"A": 8, "B": 8}, {"A": ["같은 결함"]})}
    diff = {G: _verdict("B", {"A": 8, "B": 8}, {"A": ["결함 하나"]}),
            X: _verdict("B", {"A": 8, "B": 8}, {"A": ["결함 둘"]})}
    many = {G: _verdict("B", {"A": 8, "B": 8},
                        {"A": ["가", "나", "다", "라"]}),
            X: _verdict("B", {"A": 8, "B": 8})}
    one = {G: _verdict("B", {"A": 8, "B": 8}, {"A": ["하나뿐"]}),
           X: _verdict("B", {"A": 8, "B": 8})}
    got = [round(_adj(p)["A"], 6) for p in (same, diff, many, one)]
    assert len(set(got)) == 1, (
        f"페널티가 문장 수를 따라갔다: 같은문구·다른문구·다수·하나 = {got}")
    clean = round(_adj({G: _verdict("B", {"A": 8, "B": 8}),
                        X: _verdict("B", {"A": 8, "B": 8})})["A"], 6)
    assert got[0] == round(clean - mg.SELECT_VIOLATION_PENALTY, 6), (
        "위반이 있는데 정확히 한 번이 안 깎였다")


# ── 점수·승자·ranking 이 같은 계산을 말하는가 ───────────────────────

def test_score_winner_ranking_agree_with_adjusted():
    """★top-level 점수가 adjusted 를 반영해야 winner 와 어긋나지 않는다."""
    per = {
        G: _verdict("A", {"A": 10, "B": 9}, {"A": ["젬: 치명"]}),
        X: _verdict("A", {"A": 10, "B": 9}, {"A": ["솔: 치명"]}),
    }

    def _one(model, parts, suffix):
        return per[model]

    png = __import__("pathlib").Path(
        __import__("tempfile").mkdtemp()) / "a.png"
    png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 16)
    res = mg._judge_cross_model_order(
        [G, X], lambda m, p, s: per[m], "head", [], [png, png], list(LABELS))
    adj = res["dual"]["adjusted"]
    order = sorted(LABELS, key=lambda x: -adj[x])
    assert res["winner"] == order[0]
    assert res["ranking"] == order
    scores = {v["label"]: v["score"] for v in res["verdicts"]}
    assert scores[order[0]] >= scores[order[1]], (
        f"점수가 ranking 과 어긋난다: {scores} vs {order}")
    # 페널티로 승자가 raw 승자와 달라지면 agree 라 쓰지 않는다
    route = res["cross_model_order"]["route"]
    assert route in ("cross_slot_agree", "cross_slot_combined")
    if res["winner"] != "A":
        assert route == "cross_slot_combined", (
            "합산 승자가 raw 승자와 다른데 agree 라고 적었다")


def test_agree_branch_through_the_adapter_keeps_both(tmp_path):
    """★어댑터를 **agree 갈래로 실제로 태운다**.

    양성 확인에서 구멍이 드러났다 — 앞 시험은 역순 슬롯이 display `A` 를
    말해 canonical 이 `B` 가 되는 바람에 **agree 갈래를 한 번도 안 탔다.**
    옛 코드(승자 같으면 슬롯0 것만 반환)를 되돌려도 빨강이 안 됐다.

    canonical 로 승자를 같게 하려면 역순 슬롯은 display 를 **뒤집어**
    말해야 한다: 슬롯0(정순) winner=A · 슬롯1(역순) display winner=B
    → canonical A. 그래야 `gw == xw` 가 성립한다.
    """
    fwd = _verdict("A", {"A": 9, "B": 3}, {"B": ["젬: 부유 물체"]})
    # 역순 슬롯의 display 값 — canonical 로 뒤집히면 winner=A, 위반은 A 쪽
    rev = _verdict("B", {"A": 3, "B": 9}, {"A": ["솔: 형광등 두 개"]})
    per = {G: fwd, X: rev}
    png = tmp_path / "a.png"
    png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 16)

    res = mg._judge_cross_model_order(
        [G, X], lambda m, p, s: per[m], "head", [], [png, png], list(LABELS))
    cmo = res["cross_model_order"]
    assert cmo["slot_winner"] == {G: "A", X: "A"}, (
        f"agree 갈래를 안 탔다: {cmo['slot_winner']}")
    assert cmo["slot_winner_match"] is True

    hv = _hv(res)
    # 슬롯0 은 canonical B 에, 슬롯1 은 display A→canonical B 에 냈다
    got = hv["B"]
    assert len(got) == 2, f"agree 인데 한 슬롯 것만 남았다: {hv}"
    assert any("gemini" in x for x in got) and any("gpt" in x for x in got)


def test_label_row_missing_from_first_slot_still_lands(tmp_path):
    """★슬롯0 이 그 후보 행을 **빼먹어도** 위반이 살아남는가 (Codex BLOCK-1).

    스키마는 `readings` 배열이 있기만 하면 되고 **후보마다 한 행**을
    강제하지 않는다(`build_judge_schema`). `_validate_judge_shape` 는
    `readings` 를 **아예 안 본다**. 그러니 「슬롯0 이 B 행을 안 쓰고
    슬롯1 에만 B 위반이 있는」 응답은 **유효**하다.

    종전 구현은 첫 슬롯 행만 훑어서, 그 경우 점수와 `dual.violations` 에는
    B 가 들어가는데 top-level 에는 **B 행 자체가 없었다** — 이 판이
    닫으려던 유실이 그대로 남는다.
    """
    slot0 = _verdict("A", {"A": 9, "B": 3})
    slot0["readings"] = [r for r in slot0["readings"] if r["label"] == "A"]
    assert len(slot0["readings"]) == 1, "시험 준비가 틀렸다 — 행이 안 빠졌다"

    slot1 = _verdict("A", {"A": 9, "B": 3}, {"B": ["솔: B 만 본 위반"]})
    out = mg.combine_select_verdicts({G: slot0, X: slot1}, LABELS)

    labs = [r.get("label") for r in out["readings"]]
    assert "B" in labs, f"슬롯0 이 안 쓴 후보 행이 통째로 사라졌다: {labs}"
    assert _hv(out)["B"] == ["[gpt] 솔: B 만 본 위반"]
    # 점수 쪽에도 그 위반이 반영돼야 한다 (한 번만)
    adj = out["dual"]["adjusted"]
    clean = mg.combine_select_verdicts(
        {G: _verdict("A", {"A": 9, "B": 3}),
         X: _verdict("A", {"A": 9, "B": 3})}, LABELS)["dual"]["adjusted"]
    assert round(adj["B"], 6) == round(
        clean["B"] - mg.SELECT_VIOLATION_PENALTY, 6)
