"""cine 검증 관문 — 산출을 재고 기각하는 자리.

## 2026-08-26 계약 교체

**변환본이 사진으로 성립하는가**만 본다. 손가락 여섯 개·공중에 뜬 사람·
문 여덟 개짜리 차 같은 파탄만 잡고, 성립하면 **무조건** 변환본을 최종본으로
쓴다. 빛이 바뀌고 소품이 사라지고 카메라가 옮겨 간 것은 이 단계가 시켜서
하는 일이라 **기각 근거가 아니다** — 그것을 잡으면 단계 자체가 무의미해진다.

잠그는 계약:
  ① 파탄이 있어야 기각한다 — 단 **두 회차가 둘 다** 그렇다고 할 때만.
  ② 파탄이 없으면 **무조건** 변환본이 최종본이 된다.
  ③ **판정 전에 원본 크기로 줄인다** — 안 줄이면 판정기가 없는 「반복」을
     보고, `duplication` 이 기각 범주라 그 거짓이 곧바로 기각이 된다
     (2026-08-26 A/B 실측).
  ④ 판정기가 죽으면 **기각하지 않는다**(이미 산 그림을 판정기 사정으로
     버리면 돈만 나가고 결과가 나빠진다).
  ⑤ 기각은 실패가 아니라 확정된 결말이다 — `applied=False` + `rejected=True`
     이고, 같은 지문 재방문은 유료 호출 없이 그 기록을 돌려준다.
"""
from __future__ import annotations

import io
from typing import Any, Dict, List

import app.modules.pipeline.cine_transform as ct
import app.modules.pipeline.cine_verify as cv


def _png(w: int, h: int, colour=(30, 40, 50)) -> bytes:
    from PIL import Image

    buf = io.BytesIO()
    Image.new("RGB", (w, h), colour).save(buf, format="PNG")
    return buf.getvalue()


def _answer(broken: List[str]) -> Dict[str, Any]:
    return {"checks": [
        {"category": c, "sound": c not in broken,
         "what_is_impossible_ko": "있을 수 없다" if c in broken else ""}
        for c, _ in cv.DEFECTS]}


def _wire_judge(monkeypatch, per_round):
    """per_round = 회차별 파탄 범주 목록. 호출 수와 보낸 parts 를 남긴다."""
    import app.modules.llm.llm_client as llm_client

    calls: Dict[str, Any] = {"n": 0, "parts": []}

    def fake(tag, sys_p, parts, schema, **kw):
        i = min(calls["n"], len(per_round) - 1)
        calls["n"] += 1
        calls["parts"].append(parts)
        return _answer(per_round[i])

    monkeypatch.setattr(llm_client, "call_structured", fake)
    return calls


# ── 판정 그 자체 ────────────────────────────────────────────────────


def test_rounds_above_max_do_not_silently_disable_the_gate(monkeypatch):
    """★상한을 넘긴 회차가 관문을 조용히 무력화하던 것 (Codex BLOCK-2).

    회차는 둘뿐인데 `max(1, rounds)` 만 걸어 두면 rounds=3 에서 호출은 2번
    나가고 `len(seen)==requested` 는 영원히 거짓이라 **돈은 쓰고 기각은 한
    번도 못 한다.** 잘라 쓰되 잘랐다는 것을 남긴다.
    """
    assert cv.normalize_rounds(3) == cv.MAX_ROUNDS == 2
    assert cv.normalize_rounds(0) == 1
    assert cv.normalize_rounds("이상한값") == cv.MAX_ROUNDS
    _wire_judge(monkeypatch, [["anatomy"], ["anatomy"]])
    v = cv.verify_cine_result(result_png=_png(40, 20), rounds=3)
    assert v["requested"] == 2 and v["complete"] is True
    assert v["ok"] is False and v["broken"] == ["anatomy"]


def test_defect_question_text_is_in_the_contract(monkeypatch):
    """★모델이 읽는 것은 범주 **이름**이 아니라 범주별 **질문 문장**이다.

    문장이 신원 밖이면 질문을 고쳐도 옛 판정이 「지금 계약」으로 통한다.
    """
    before = cv.verify_contract_sha(2)
    patched = tuple((c, q + " (문구 개정)") if c == "anatomy" else (c, q)
                    for c, q in cv.DEFECTS)
    monkeypatch.setattr(cv, "DEFECTS", patched)
    assert cv.verify_contract_sha(2) != before


def test_aggregation_version_is_in_the_contract(monkeypatch):
    """집계 규칙은 **코드가 소유**해 팩 해시에 안 잡힌다 — 계약 버전으로."""
    before = cv.verify_contract_sha(2)
    monkeypatch.setattr(cv, "CINE_VERIFY_CONTRACT_VERSION", "다른-계약")
    assert cv.verify_contract_sha(2) != before


def test_gate_rejects_only_when_both_rounds_agree(monkeypatch):
    _wire_judge(monkeypatch, [["support"], ["support"]])
    v = cv.verify_cine_result(result_png=_png(40, 20), rounds=2)
    assert v["ok"] is False
    assert v["broken"] == ["support"]
    assert v["split"] == []


def test_gate_does_not_reject_when_rounds_disagree(monkeypatch):
    """★엇갈리면 기각하지 않는다 — 이 관문의 원칙은 「의심스러우면 통과」다."""
    _wire_judge(monkeypatch, [["object_integrity"], []])
    v = cv.verify_cine_result(result_png=_png(40, 20), rounds=2)
    assert v["ok"] is True
    assert v["broken"] == []
    assert v["split"] == ["object_integrity"]


def test_sound_image_is_always_applied(monkeypatch):
    """★계약의 핵심 — 파탄이 없으면 **무조건** 변환본이 최종본이다.

    빛이 바뀌었는지 소품이 사라졌는지 카메라가 옮겨 갔는지는 **묻지도
    않는다**. 그 물음이 있던 자리가 이 단계를 무의미하게 만들었다.
    """
    _wire_judge(monkeypatch, [[], []])
    v = cv.verify_cine_result(result_png=_png(40, 20), rounds=2)
    assert v["ok"] is True
    assert v["broken"] == []
    # 옛 계약의 축이 하나도 안 남아 있어야 한다.
    assert set(v["findings"]) == {c for c, _ in cv.DEFECTS}
    assert "light_source" not in v["findings"]
    assert "framing" not in v["findings"]


def test_partial_rounds_never_reject(monkeypatch):
    """★2회 중 2회차가 죽으면 첫 판정 하나로 기각하면 안 된다 (Codex BLOCK-1).

    `all(not s for s in sounds)` 는 목록이 하나여도 참이라, 종전에는
    「둘 다 동의할 때만 기각」과 「판정기 오류는 기각 근거가 아니다」를
    동시에 어겼다. 관찰은 남기되 기각 권한만 없다.
    """
    import app.modules.llm.llm_client as llm_client

    calls = {"n": 0}

    def fake(tag, sys_p, parts, schema, **kw):
        calls["n"] += 1
        if calls["n"] == 1:
            return _answer(["anatomy"])
        raise RuntimeError("두 번째가 죽었다")

    monkeypatch.setattr(llm_client, "call_structured", fake)
    v = cv.verify_cine_result(result_png=_png(40, 20), rounds=2)
    assert v["ok"] is True          # 기각 안 함
    assert v["broken"] == []
    assert v["complete"] is False
    assert v["rounds"] == 1 and v["error"]
    assert "anatomy" in v["split"]              # 관찰은 남는다
    assert v["findings"]["anatomy"]["sound"] is False


def test_judge_error_does_not_reject(monkeypatch):
    import app.modules.llm.llm_client as llm_client

    def boom(*a, **k):
        raise RuntimeError("judge down")

    monkeypatch.setattr(llm_client, "call_structured", boom)
    v = cv.verify_cine_result(result_png=_png(40, 20), rounds=2)
    assert v["ok"] is True and v["error"] and v["rounds"] == 0


# ── 크기 — 2026-08-26 A/B 가 실측으로 요구한 것 ─────────────────────


def test_result_is_shrunk_to_source_size_before_judging(monkeypatch):
    """★안 줄이면 판정기가 없는 「반복·휘어짐」을 본다.

    같은 변환본을 원본 크기로 줄여 다시 물었더니 그 말이 통째로 사라졌다
    (3샷×2조건×2회차). `duplication` 이 기각 범주가 된 지금은 이 축소가
    없으면 그 거짓이 곧바로 기각이 된다.
    """
    from PIL import Image

    src = _png(200, 100)
    res = _png(800, 400)            # 4배 — 프로덕션과 같은 비율
    calls = _wire_judge(monkeypatch, [[], []])
    v = cv.verify_cine_result(result_png=res, source_png=src, rounds=2)

    assert v["judged_size"] == [200, 100]
    sent = [p for p in calls["parts"][0] if p.get("type") == "image_url"]
    assert len(sent) == 1, "판정은 변환본 **한 장**만 본다"
    import base64
    raw = base64.b64decode(sent[0]["image_url"]["url"].split(",", 1)[1])
    assert Image.open(io.BytesIO(raw)).size == (200, 100)


def test_비율이_다르면_찌그러뜨리지_않는다():
    """★목표 칸에 맞춰 `resize(target)` 을 그냥 부르면 세로가 긴 그림이
    가로가 긴 원본을 만났을 때 **폭을 몇 배로 늘리고 비율을 깨뜨린다.**
    늘어난 자국과 찌그러진 사람은 그대로 「파탄」으로 읽히므로, 판정을
    도우려던 축소가 판정을 망친다.
    """
    from PIL import Image

    res = _png(100, 1000)      # 세로로 긴 변환본
    src = _png(500, 500)       # 정사각 원본
    out, size = cv.shrink_for_judging(res, src)

    폭, 높이 = Image.open(io.BytesIO(out)).size
    assert (폭, 높이) == size
    assert 폭 <= 500 and 높이 <= 500, "목표 칸을 넘겼다"
    assert 폭 < 100, f"폭이 안 줄었다 ({폭})"      # 500 으로 늘어나면 안 된다
    assert abs(폭 / 높이 - 100 / 1000) < 0.01, (
        f"비율이 깨졌다 — {폭}x{높이} (원래 100x1000)"
    )


def test_small_result_is_not_upscaled():
    """★키우지 않는다 — 늘리면 없던 보간 자국이 판정 재료가 된다."""
    src = _png(800, 400)
    res = _png(200, 100)
    out, size = cv.shrink_for_judging(res, src)
    assert size == (200, 100)
    assert out is res               # 손대지 않고 그대로 보낸다


def test_shrink_falls_back_when_source_is_unreadable():
    """원본을 못 읽어도 판정은 돌아야 한다 — 상한만 걸고 보낸다."""
    from PIL import Image

    res = _png(4000, 2000)
    out, size = cv.shrink_for_judging(res, "이것은 PNG 가 아니다".encode())
    assert max(size) <= cv._FALLBACK_MAX_SIDE
    assert Image.open(io.BytesIO(out)).size == size


def test_source_is_never_shown_to_the_judge(monkeypatch):
    """★원본은 판정에 안 쓴다 — 얼마나 줄일지의 기준일 뿐이다.

    원본을 같이 보여 주면 판정기가 「원본과 다르다」를 파탄으로 옮겨 적는다.
    """
    calls = _wire_judge(monkeypatch, [[], []])
    cv.verify_cine_result(result_png=_png(80, 40), source_png=_png(80, 40),
                          rounds=1)
    images = [p for p in calls["parts"][0] if p.get("type") == "image_url"]
    assert len(images) == 1


# ── 관문이 변환 기록에 접히는 자리 ──────────────────────────────────
class _Records:
    def __init__(self):
        self.data: Dict[str, Any] = {}
        self.saves = 0

    def save(self):
        self.saves += 1


class _Client:
    def __init__(self):
        self.calls = 0

    def set_context(self, **kw):
        pass

    def generate_image(self, prompt, labeled_references=None):
        self.calls += 1
        return _png(40, 20, (10, 10, 10)), 123


def _run(monkeypatch, tmp_path, records, client, *, verify_on, broken):
    monkeypatch.setattr(ct, "_verify_on", lambda: verify_on)
    monkeypatch.setattr(ct, "_verify_rounds", lambda: 2)
    _wire_judge(monkeypatch, [broken, broken])
    sel = tmp_path / "S1sh1_sel.png"
    sel.write_bytes(_png(40, 20, (90, 90, 90)))
    return ct.resolve_or_run_cine_transform(
        tag="S1sh1", sel_path=sel, recipe_dir=tmp_path, records=records,
        client=client, prompt="p", model="m", stem_content_hash="h",
        pack="pk", context={})


def test_rejected_is_a_settled_outcome_not_a_failure(monkeypatch, tmp_path):
    """★기각은 미완이 아니다 — applied=False 인데 rejected 로 갈려야
    호출측이 실패로 세지 않고 스텝을 닫는다(08-19 연쇄 재생성 방지)."""
    recs, cli = _Records(), _Client()
    out = _run(monkeypatch, tmp_path, recs, cli,
               verify_on=True, broken=["anatomy"])
    assert out["applied"] is False
    assert out["rejected"] is True
    assert out["rejected_reason"] == "implausible:anatomy"
    assert out.get("declined") is not True   # 검열 포기와 섞이면 안 된다
    assert cli.calls == 1


def test_sound_result_is_applied_end_to_end(monkeypatch, tmp_path):
    """★파탄이 없으면 변환본이 최종본이다 — 사용자 요구의 본줄기."""
    recs, cli = _Records(), _Client()
    out = _run(monkeypatch, tmp_path, recs, cli, verify_on=True, broken=[])
    assert out["applied"] is True
    assert "rejected" not in out
    assert (tmp_path / "S1sh1_cine.png").exists()


def test_rejected_revisit_costs_nothing(monkeypatch, tmp_path):
    recs, cli = _Records(), _Client()
    _run(monkeypatch, tmp_path, recs, cli, verify_on=True, broken=["support"])
    again = _run(monkeypatch, tmp_path, recs, cli,
                 verify_on=True, broken=["support"])
    assert again["reused"] is True
    assert cli.calls == 1        # 그림을 다시 사지 않는다


def test_verify_off_is_byte_identical_path(monkeypatch, tmp_path):
    recs, cli = _Records(), _Client()
    out = _run(monkeypatch, tmp_path, recs, cli, verify_on=False, broken=[])
    assert out["applied"] is True
    assert "verify" not in out
    assert "rejected" not in out


def test_turning_gate_on_later_judges_without_rebuying(monkeypatch, tmp_path):
    """관문을 나중에 켜면 **그림은 다시 안 사고 판정만** 산다."""
    recs, cli = _Records(), _Client()
    _run(monkeypatch, tmp_path, recs, cli, verify_on=False, broken=[])
    assert cli.calls == 1
    out = _run(monkeypatch, tmp_path, recs, cli,
               verify_on=True, broken=["duplication"])
    assert cli.calls == 1                 # 이미지 재구매 없음
    assert out["rejected"] is True
    assert out["reused"] is True


def test_verdict_survives_revisit_when_contract_unchanged(
        monkeypatch, tmp_path):
    """계약이 그대로면 판정도 다시 사지 않는다 — 재사용 바퀴는 지출 0."""
    recs, cli = _Records(), _Client()
    _run(monkeypatch, tmp_path, recs, cli, verify_on=True, broken=[])
    saves_before = recs.saves
    calls = _wire_judge(monkeypatch, [[], []])
    monkeypatch.setattr(ct, "_verify_on", lambda: True)
    out = ct.resolve_or_run_cine_transform(
        tag="S1sh1", sel_path=tmp_path / "S1sh1_sel.png",
        recipe_dir=tmp_path, records=recs, client=cli, prompt="p",
        model="m", stem_content_hash="h", pack="pk", context={})
    assert out["reused"] is True
    assert calls["n"] == 0            # 판정 재구매 없음
    assert recs.saves == saves_before  # record 가 안 움직인다(#77-B)


def test_turning_gate_off_restores_the_rejected_image(monkeypatch, tmp_path):
    """★관문을 끄면 이미 산 변환본이 다시 쓰인다 (Codex BLOCK-2).

    종전에는 기각 갈래가 즉시 return 이라 기각이 **영구**였다 — 관문을
    꺼도 그림이 영영 안 쓰이고 판정 계약을 고쳐도 다시 볼 길이 없었다.
    """
    recs, cli = _Records(), _Client()
    out = _run(monkeypatch, tmp_path, recs, cli,
               verify_on=True, broken=["duplication"])
    assert out["rejected"] is True and cli.calls == 1
    back = _run(monkeypatch, tmp_path, recs, cli,
                verify_on=False, broken=[])
    assert back["applied"] is True
    assert "rejected" not in back      # 표식이 남으면 복권이 안 된다
    assert cli.calls == 1              # 그림은 다시 안 산다


def test_rejected_is_revoked_when_new_judgment_passes(monkeypatch, tmp_path):
    """판정 계약이 바뀌고 새 판정이 통과하면 **복권**된다."""
    recs, cli = _Records(), _Client()
    _run(monkeypatch, tmp_path, recs, cli, verify_on=True, broken=["anatomy"])
    # ★짝이 신원이다 (2026-08-27) — 한 모델만 갈아도 다른 판정이다.
    monkeypatch.setattr(cv, "CINE_VERIFY_MODELS", ("gemini-pro", "다른-판정-모델"))
    out = _run(monkeypatch, tmp_path, recs, cli,
               verify_on=True, broken=[])       # 이번엔 통과
    assert out["applied"] is True
    assert "rejected" not in out
    assert cli.calls == 1


def test_rounds_change_no_longer_rejudges(monkeypatch, tmp_path):
    """★**뒤집었다** (2026-08-27, #92 이중 모델).

    옛 계약에서는 회차가 신원이었다 — 2→1 이 「한 번 보고 기각」이라
    다른 판정이었다. 지금은 **회차가 모델 수를 못 바꾼다**: 판정은 언제나
    두 모델 각 한 번이고, 검사를 끄는 것은 `still_cine_verify_enabled`
    플래그가 한다.

    그러니 회차 설정을 만졌다고 다시 판정하면 **헛돈**이다 — 나가는 물음이
    한 글자도 안 바뀌었기 때문이다.
    """
    recs, cli = _Records(), _Client()
    _run(monkeypatch, tmp_path, recs, cli, verify_on=True, broken=[])
    calls = _wire_judge(monkeypatch, [["support"], ["support"]])
    monkeypatch.setattr(ct, "_verify_on", lambda: True)
    monkeypatch.setattr(ct, "_verify_rounds", lambda: 1)
    out = ct.resolve_or_run_cine_transform(
        tag="S1sh1", sel_path=tmp_path / "S1sh1_sel.png",
        recipe_dir=tmp_path, records=recs, client=cli, prompt="p",
        model="m", stem_content_hash="h", pack="pk", context={})
    assert calls["n"] == 0, "회차만 바꿨는데 다시 판정한다 — 헛돈이다"
    assert out.get("rejected") is not True, "옛 판정이 뒤집혔다"
    assert cli.calls == 1              # 그림은 그대로


def test_verify_contract_change_rejudges_without_rebuying(
        monkeypatch, tmp_path):
    """★판정 문안·모델·범주가 바뀌면 옛 판정은 다른 물음의 답이다 —
    다시 판정하되 **유료 이미지는 다시 사지 않는다**."""
    recs, cli = _Records(), _Client()
    _run(monkeypatch, tmp_path, recs, cli, verify_on=True, broken=[])
    assert cli.calls == 1
    # ★짝이 신원이다 (2026-08-27) — 한 모델만 갈아도 다른 판정이다.
    monkeypatch.setattr(cv, "CINE_VERIFY_MODELS", ("gemini-pro", "다른-판정-모델"))
    out = _run(monkeypatch, tmp_path, recs, cli,
               verify_on=True, broken=["anatomy"])
    assert cli.calls == 1              # 이미지는 그대로
    assert out["rejected"] is True     # 새 계약으로 다시 판정했다
