"""채점기가 **실제로 잡는지**. ★유료 0.

★**끝점에서 잰다.** 등록 장부를 손으로 만들지 않고 `reduce_episode` 를 실제로
태운다. 앞 판은 장부를 아예 안 넣고도 통과했는데, 손으로 만든 입력으로 재고
있었으면 그것도 못 봤다.

★모든 시험에 **일부러 틀린 것**을 짝지어 둔다. Codex 가 실제 반례로 태웠더니
첫 판 채점기가 **핵심 셋을 전부 통과**시켰다 — 맞는 산출만 넣고 초록을 보면
「채점기가 아무것도 안 본다」와 구별이 안 된다.
"""
from __future__ import annotations

import copy

import pytest

from app.modules.pipeline import grounding_chunk_merge as cm
from tests.grounding.fixtures import synthetic_episode as ep
from tools.grounding_audit import cc_scorer as sc

SEGS = ep.segment_texts()


def _q(s):
    return SEGS[s["segment_id"]][s["start"]:s["end"]]


def _ideal_rows():
    """표적 장부대로 나온 **구간 행**. ★fixture 좌표에서 짓는다."""
    rows = []
    for n, t in enumerate(ep.EXPECTED_TARGETS):
        spans = ep.expected_spans(t)
        # ★한 번만 나오는 표적은 두 축이 **같은 행에서** 둘 다 참이어야 산다
        once = t.get("exception_axis") is True
        if t["rows_after_merge"] == 1:
            rows.append({"local_id": f"c0#{n}", "owner_type": t["owner"],
                         "surface_form": t["key"],
                         "hard_to_generate": once,
                         "viewers_would_notice": once,
                         "occurrences": [{"source_span": s, "source_quote": _q(s)}
                                         for s in spans]})
        else:
            for m, s in enumerate(spans):
                rows.append({"local_id": f"c{m + 1}#{n}", "owner_type": t["owner"],
                             "surface_form": t["key"],
                             "hard_to_generate": False,
                             "viewers_would_notice": False,
                             "occurrences": [{"source_span": s,
                                              "source_quote": _q(s)}]})
    return rows


def _decisions(rows):
    """부분⊂전체 하나. ★`same_referent` 는 이상적 산출에서 이미 한 행이다."""
    by = {r["surface_form"]: r["local_id"] for r in rows}
    return [{"remove_local_id": by["part_whole_part"],
             "keep_local_id": by["part_whole_whole"],
             "relation": cm.REL_PART_OF}]


def _score(rows, decisions=None):
    """★끝점 — `reduce_episode` 를 태우고 그 산출로 채점한다."""
    red = cm.reduce_episode(rows, decisions if decisions is not None
                            else _decisions(rows), segments=SEGS)
    return sc.score(ep.EXPECTED_TARGETS, ep.target_spans(), red["rows"],
                    segments=SEGS, relations=red["part_of"],
                    registered=red["registered"]), red


class TestAGoodRunIsAMechanicalCandidate:
    def test_the_ideal_output_passes_the_machine_part(self):
        got, _ = _score(_ideal_rows())
        bad = [f for f in got["findings"] if f["verdict"] != sc.OK]
        assert got["mechanical_candidate"], f"★맞는 산출을 떨어뜨렸다: {bad}"

    def test_the_machine_never_declares_the_final_verdict(self):
        """★★기계 통과를 **최종 통과**로 쓰지 않는다 — 사람이 본다."""
        got, _ = _score(_ideal_rows())
        assert got["final_candidate"] is None


class TestTheThreeCounterexamplesCodexFound:
    """★★★Codex 가 실제로 태워서 **전부 통과시킨** 셋 (2026-08-31)."""

    def test_missing_flags_and_registration_fail(self):
        """①두 축 칸이 없고 등록도 안 서면 — 이 실험의 **핵심**이 빠진 것이다."""
        rows = _ideal_rows()
        for r in rows:
            if r["surface_form"] == "once_only_hard":
                r["hard_to_generate"] = False
                r["viewers_would_notice"] = False
        got, _ = _score(rows)
        assert not got["mechanical_candidate"]
        v = [f for f in got["findings"] if f["axis"] == "target:once_only_hard"]
        assert v and v[0]["verdict"] == sc.WRONG

    def test_the_registration_ledger_is_required_not_optional(self):
        """★장부를 **안 넣으면** 미확정이다 — 「안 봤다」가 「맞다」가 아니다."""
        rows = _ideal_rows()
        red = cm.reduce_episode(rows, _decisions(rows), segments=SEGS)
        got = sc.score(ep.EXPECTED_TARGETS, ep.target_spans(), red["rows"],
                       segments=SEGS, relations=red["part_of"])
        assert not got["mechanical_candidate"]
        assert "target:once_only_hard" in got["needs_human"]

    def test_losing_one_chunks_occurrence_fails(self):
        """②두 구간에 걸친 실물의 한쪽 구간을 잃어도 앞 판은 통과했다 —
        남은 쪽에 출현이 둘 있으면 「출현 ≥2」가 맞아 버렸다."""
        rows = _ideal_rows()
        for r in rows:
            if r["surface_form"] == "cross_chunk_same_thing":
                r["occurrences"] = [o for o in r["occurrences"]
                                    if o["source_span"]["segment_id"]
                                    != "scene-4"]
                assert len(r["occurrences"]) == 1, "★시험의 전제가 깨졌다"
        got, _ = _score(rows)
        assert not got["mechanical_candidate"], "★한 구간을 통째로 잃었는데 통과했다"

    def test_borrowing_someone_elses_quote_is_not_auto_ok(self):
        """③남의 인용으로 아무 이름이나 지을 수 있다. 인용이 원문에 있다는
        것은 그 인용이 **그 이름을 뒷받침한다**는 뜻이 아니다."""
        rows = _ideal_rows()
        s = ep.span_of(1, "승객", 1)
        rows.append({"local_id": "c0#99", "owner_type": "prop",
                     "surface_form": "원고에 없는 물건",
                     "hard_to_generate": False, "viewers_would_notice": False,
                     "occurrences": [{"source_span": s, "source_quote": _q(s)}]})
        got, _ = _score(rows)
        assert not got["mechanical_candidate"]
        assert "unlisted_rows" in got["needs_human"]

    def test_an_unlisted_row_is_not_called_a_defect_either(self):
        """★positive control — 목록 밖이라고 **틀린 것**으로 세면, 내가 안 적은
        정당한 엔티티가 전부 결함이 된다."""
        rows = _ideal_rows()
        s = ep.span_of(1, "승객", 1)
        rows.append({"local_id": "c0#98", "owner_type": "character",
                     "surface_form": "승객",
                     "hard_to_generate": False, "viewers_would_notice": False,
                     "occurrences": [{"source_span": s, "source_quote": _q(s)}]})
        got, _ = _score(rows)
        f = [x for x in got["findings"] if x["axis"] == "unlisted_rows"][0]
        assert f["verdict"] == sc.UNRESOLVED, "★목록 밖을 결함으로 셌다"
        assert got["counts"][sc.WRONG] == 0


class TestTheScorerCatchesTheOlderDefects:
    def test_a_missing_target_fails(self):
        rows = [r for r in _ideal_rows()
                if r["surface_form"] != "owner_outlook"]
        got, _ = _score(rows)
        assert not got["mechanical_candidate"]
        assert got["counts"][sc.MISSING] >= 1

    def test_merging_the_two_different_things_fails(self):
        rows = _ideal_rows()
        same = [r for r in rows
                if r["surface_form"] == "same_name_different_thing"]
        assert len(same) == 2, "★시험의 전제가 깨졌다"
        got, _ = _score(rows, _decisions(rows) + [{
            "remove_local_id": same[1]["local_id"],
            "keep_local_id": same[0]["local_id"],
            "relation": cm.REL_SAME}])
        assert not got["mechanical_candidate"], "★두 실물을 합쳤는데 통과했다"

    def test_a_wrong_owner_fails(self):
        rows = copy.deepcopy(_ideal_rows())
        for r in rows:
            if r["surface_form"] == "owner_outlook":
                r["owner_type"] = "character"
        got, _ = _score(rows)
        assert not got["mechanical_candidate"]

    def test_losing_one_of_two_occurrences_fails(self):
        rows = copy.deepcopy(_ideal_rows())
        for r in rows:
            if r["surface_form"] == "twice_in_one_chunk":
                r["occurrences"] = r["occurrences"][:1]
        got, _ = _score(rows)
        assert not got["mechanical_candidate"]

    def test_a_fabricated_quote_stops_at_the_merge(self):
        """★지어낸 인용은 **채점 전에** `reduce_episode` 가 세운다."""
        rows = copy.deepcopy(_ideal_rows())
        rows[0]["occurrences"][0]["source_quote"] = "원고에 없는 말"
        with pytest.raises(AssertionError, match="인용"):
            _score(rows)

    def test_a_missing_part_of_relation_fails(self):
        rows = _ideal_rows()
        got, _ = _score(rows, [])
        assert not got["mechanical_candidate"]
        assert any(f["axis"] == "relation:part_of"
                   and f["verdict"] == sc.MISSING for f in got["findings"])

    def test_folding_the_part_into_the_whole_is_unresolved_not_ok(self):
        rows = _ideal_rows()
        by = {r["surface_form"]: r["local_id"] for r in rows}
        got, _ = _score(rows, [{"remove_local_id": by["part_whole_part"],
                                "keep_local_id": by["part_whole_whole"],
                                "relation": cm.REL_SAME}])
        assert not got["mechanical_candidate"]


class TestAnEmptyRunDoesNotPass:
    """★★빈손은 모든 축을 지나간다 — 앞서 실제로 「맞음 1」로 세어졌다."""

    def test_no_rows_at_all_fails(self):
        got = sc.score(ep.EXPECTED_TARGETS, ep.target_spans(), [],
                       segments=SEGS, relations=[], registered={})
        assert not got["mechanical_candidate"]
        assert got["counts"][sc.MISSING] == len(ep.EXPECTED_TARGETS)


class TestTheAddressRuleStopsCrossClaims:
    """★★★한 문장이 여러 표적을 **동시에 주장하면** 안 된다 (Codex).

    앞 판은 「조금이라도 겹치면 주장」이었다. 그래서 —
      · 「가방 손잡이」가 **가방** 표적에
      · 「운전사가 제복 상의를 벗어 의자에 건다」가 **제복 상의** 표적에
      · 「벽에 옛 요금표가 붙어 있다」가 **옛 요금표** 표적에
    같이 걸려 「행 2개인데 1개를 기대했다」로 떨어졌다.
    """

    def _span_of_text(self, scene, needle, n=1):
        return ep.span_of(scene, needle, n)

    def test_a_long_sentence_holding_two_targets_claims_neither(self):
        """★삼킨 문장은 **모호**다 — 조용히 한쪽에 주지 않는다."""
        spans = ep.target_spans()
        # 「긴 걸상이 대합실 벽에 붙어 있다」 는 부분(긴 걸상)과 전체(대합실)를
        # **둘 다** 품는다.
        seg = SEGS["scene-3"]
        i = seg.index("긴 걸상이 대합실")
        big = {"segment_id": "scene-3", "start": i,
               "end": i + len("긴 걸상이 대합실 벽에 붙어 있다")}
        # ★이 문장은 부분(긴 걸상)과 전체(대합실 2번째 자리)를 **둘 다** 품는다
        assert sc.claims(big, "part_whole_part", spans) is None
        assert sc.claims(big, "part_whole_whole", spans) is None

    def test_the_tight_mention_claims_exactly_one(self):
        """★positive control — 좁게 부르면 **하나만** 주장한다."""
        spans = ep.target_spans()
        tight = self._span_of_text(3, "긴 걸상")
        assert sc.claims(tight, "part_whole_part", spans) is True
        assert sc.claims(tight, "part_whole_whole", spans) is False

    def test_a_neighbouring_phrase_does_not_claim_the_target(self):
        """★★「가방 손잡이」는 **가방** 표적을 주장하면 **안 된다**.

        ★한 판 앞서 나는 여기에 `in (True, None)` 을 적었다 — **반대를
        잠근 것**이다 (Codex). 이제 정확히 같을 때만 주장이므로 `False` 여야
        한다.
        """
        spans = ep.target_spans()
        sp = self._span_of_text(1, "가방 손잡이")
        assert sc.claims(sp, "cross_chunk_same_thing", spans) is False

    def test_a_wider_quote_is_unresolved_not_a_claim(self):
        """★넓게 인용하면 **모호** — 조용히 귀속하지 않는다."""
        spans = ep.target_spans()
        seg = SEGS["scene-1"]
        i = seg.index("운전사가 낡은 가방을 들고")
        wide = {"segment_id": "scene-1", "start": i,
                "end": i + len("운전사가 낡은 가방을 들고")}
        assert sc.claims(wide, "cross_chunk_same_thing", spans) is None

    def test_an_ambiguous_row_is_unresolved_not_wrong(self):
        rows = _ideal_rows()
        seg = SEGS["scene-3"]
        i = seg.index("긴 걸상이 대합실")
        rows = [r for r in rows
                if r["surface_form"] not in ("part_whole_part",
                                             "part_whole_whole")]
        rows.append({"local_id": "c2#9", "owner_type": "location",
                     "surface_form": "긴 걸상이 대합실 벽에 붙어 있다",
                     "hard_to_generate": False, "viewers_would_notice": False,
                     "occurrences": [{"source_span": {
                         "segment_id": "scene-3", "start": i,
                         "end": i + len("긴 걸상이 대합실 벽에 붙어 있다")},
                         "source_quote": seg[i:i + len(
                             "긴 걸상이 대합실 벽에 붙어 있다")]}]})
        got, _ = _score(rows, [])
        v = {f["axis"]: f["verdict"] for f in got["findings"]}
        assert v["target:part_whole_part"] == sc.UNRESOLVED
        assert v["target:part_whole_whole"] == sc.UNRESOLVED
        assert not got["mechanical_candidate"]


class TestCodexSecondRoundCounterexamples:
    """★★★Codex 가 두 번째로 태워 **둘 다 통과시킨** 것 (2026-08-31)."""

    def test_duplicating_one_anchor_and_dropping_the_other_fails(self):
        """①1장 표를 두 행으로 복제하고 4장 표를 통째로 빼도 통과했다 —
        행 수만 맞으면 됐기 때문이다."""
        rows = [r for r in _ideal_rows()
                if r["surface_form"] != "same_name_different_thing"]
        s1 = ep.span_of(1, "표", 1)
        for k in (0, 1):
            rows.append({"local_id": f"c0#5{k}", "owner_type": "prop",
                         "surface_form": "same_name_different_thing",
                         "hard_to_generate": False,
                         "viewers_would_notice": False,
                         "occurrences": [{"source_span": s1,
                                          "source_quote": _q(s1)}]})
        got, _ = _score(rows)
        assert not got["mechanical_candidate"]
        v = [f for f in got["findings"]
             if f["axis"] == "target:same_name_different_thing"][0]
        assert v["verdict"] == sc.WRONG

    def test_merging_a_neighbouring_object_into_the_target_fails(self):
        """②「낡은 가방」+「가방 손잡이」+4장 「가방」을 한 행으로 합쳐도
        통과했다 — 손잡이가 표적이 아니라 안 보였다."""
        rows = [r for r in _ideal_rows()
                if r["surface_form"] not in ("cross_chunk_same_thing",
                                             "neighbour_prop")]
        sp = [ep.span_of(1, "낡은 가방", 1), ep.span_of(1, "가방 손잡이", 1),
              ep.span_of(4, "가방", 1)]
        rows.append({"local_id": "c0#77", "owner_type": "prop",
                     "surface_form": "가방 전부",
                     "hard_to_generate": False, "viewers_would_notice": False,
                     "occurrences": [{"source_span": x, "source_quote": _q(x)}
                                     for x in sp]})
        got, _ = _score(rows)
        assert not got["mechanical_candidate"]
        bad = [f for f in got["findings"] if f["verdict"] == sc.WRONG]
        assert any("여러 표적을 주장" in f["detail"] for f in bad)

    def test_the_correct_output_still_passes(self):
        """★positive control — 막기만 하고 **맞는 것까지** 떨어뜨리면 못 쓴다."""
        got, _ = _score(_ideal_rows())
        bad = [f for f in got["findings"] if f["verdict"] != sc.OK]
        assert got["mechanical_candidate"], f"★맞는 산출을 떨어뜨렸다: {bad}"


class TestAnchorAndRowAreOneToOne:
    """★★★「서로 다른 행을 하나씩 찾으면 통과」로는 부족하다 (Codex 3차).

    1장 표만 든 행 A 와, **1장+4장 표를 한 행으로 잘못 합친** 행 B 를 넣으면
    1장→A · 4장→B 로 짝이 지어져 `ok` 가 났다.
    """

    def _rows(self):
        rows = [r for r in _ideal_rows()
                if r["surface_form"] != "same_name_different_thing"]
        a, b = ep.span_of(1, "표", 1), ep.span_of(4, "표", 1)
        rows.append({"local_id": "A", "owner_type": "prop",
                     "surface_form": "표", "hard_to_generate": False,
                     "viewers_would_notice": False,
                     "occurrences": [{"source_span": a, "source_quote": _q(a)}]})
        rows.append({"local_id": "B", "owner_type": "prop",
                     "surface_form": "표", "hard_to_generate": False,
                     "viewers_would_notice": False,
                     "occurrences": [{"source_span": a, "source_quote": _q(a)},
                                     {"source_span": b, "source_quote": _q(b)}]})
        return rows

    def test_one_row_holding_both_anchors_fails(self):
        got, _ = _score(self._rows())
        assert not got["mechanical_candidate"]
        v = [f for f in got["findings"]
             if f["axis"] == "target:same_name_different_thing"][0]
        assert v["verdict"] == sc.WRONG

    def test_two_rows_on_the_same_anchor_fails(self):
        """★같은 자리를 두 행이 주장해도 안 된다."""
        rows = [r for r in _ideal_rows()
                if r["surface_form"] != "same_name_different_thing"]
        a, b = ep.span_of(1, "표", 1), ep.span_of(4, "표", 1)
        for lid, sp in (("A", a), ("A2", a), ("B", b)):
            rows.append({"local_id": lid, "owner_type": "prop",
                         "surface_form": "표", "hard_to_generate": False,
                         "viewers_would_notice": False,
                         "occurrences": [{"source_span": sp,
                                          "source_quote": _q(sp)}]})
        got, _ = _score(rows)
        assert not got["mechanical_candidate"]

    def test_a_clean_one_to_one_still_passes(self):
        """★positive control — 자리마다 딱 한 행이면 통과해야 한다."""
        got, _ = _score(_ideal_rows())
        assert got["mechanical_candidate"]


class TestThePromptAndTheScorerAskForTheSameThing:
    """★★새 exact 채점과 지시문이 **다른 답**을 요구하면, 모델이 지시대로
    해도 자동으로 틀린 것이 된다 (Codex 3차)."""

    def test_the_prompt_states_the_minimal_complete_rule(self):
        from app.modules.pipeline import grounding_chunk as gc

        body = gc.load_text("system.md")
        assert "완전한 최소" in body, "★지시문이 최소·완전 규칙을 안 말한다"
        assert "꾸밈말을 함부로 떼지" in body

    def test_no_target_anchor_is_inside_another_in_the_same_scene(self):
        """★기대 자리끼리 겹치면 「완전한 최소」가 **하나로 안 정해진다**."""
        spans = ep.target_spans()
        flat = [(k, s) for k, v in spans.items() for s in v]
        for ka, sa in flat:
            for kb, sb in flat:
                if ka == kb or sa["segment_id"] != sb["segment_id"]:
                    continue
                assert not sc._contains(sa, sb), (
                    f"★{ka} 자리가 {kb} 자리를 품는다 — 모델이 어느 쪽을 "
                    "불러야 할지 정할 수 없다")

    def test_every_anchor_is_the_exact_manuscript_text(self):
        segs = ep.segment_texts()
        for t in ep.EXPECTED_TARGETS:
            for sp, (_sc, word, _n) in zip(ep.expected_spans(t), t["at"]):
                assert segs[sp["segment_id"]][sp["start"]:sp["end"]] == word


class TestThePostHocOracleCorrection:
    """★★★**post-hoc fixture oracle correction** (2026-08-31, Codex 승인).

    유료 산출을 보고 **채점 규칙을 새로 만든 것이 아니다.** 기존 팩 계약과
    어긋나 있던 **oracle 한 칸**을 바로잡은 것이다 —

        팩 계약   「그 대상 **전체**를 가리키는 **완전한 최소** 이름 덩어리」
        4장 원문  「운전사가 주머니에서 접힌 표를 꺼낸다」
        완전 최소 「접힌 표」   ← 모델이 낸 것
        옛 oracle 「표」        ← 계약을 어긴 것

    ★반례는 사라지지 않는다. 두 장 다 `표` 계열이지만 **서로 다른 실물**이고,
    정본 주소가 달라야 둘을 정확히 가른다.
    """

    def test_the_two_anchors_are_the_complete_minimal_phrases(self):
        segs = SEGS
        a, b = ep.span_of(1, "표", 1), ep.span_of(4, "접힌 표", 1)
        assert segs["scene-1"][a["start"]:a["end"]] == "표"
        assert segs["scene-4"][b["start"]:b["end"]] == "접힌 표"
        got = ep.target_spans()["same_name_different_thing"]
        assert got == [a, b], f"★oracle 이 {got} 다"

    def test_two_separate_rows_pass_and_merging_them_still_fails(self):
        """★반례가 살아 있는지 — 따로 두면 통과, 합치면 잡힌다."""
        rows = _ideal_rows()
        got, _ = _score(rows)
        v = [f for f in got["findings"]
             if f["axis"] == "target:same_name_different_thing"][0]
        assert v["verdict"] == sc.OK, "★따로 둔 것을 떨어뜨렸다"

        same = [r for r in rows
                if r["surface_form"] == "same_name_different_thing"]
        assert len(same) == 2
        merged, _ = _score(rows, _decisions(rows) + [{
            "remove_local_id": same[1]["local_id"],
            "keep_local_id": same[0]["local_id"],
            "relation": cm.REL_SAME}])
        assert not merged["mechanical_candidate"], "★합쳤는데 통과했다"

    def test_the_old_oracle_reproduces_the_original_wrong(self):
        """★★옛 oracle 을 되돌리면 **얼어붙은 유료 산출**에서 그 wrong 1 이
        그대로 재현되는지 — 「고쳤더니 좋아졌다」가 아니라 **무엇이 달랐는지**를
        보인다."""
        import copy
        import json as _j
        from pathlib import Path

        paid = (Path(__file__).resolve().parents[3] / "artifact"
                / "20260831_cc_preflight" / "live2_j_score.json")
        if not paid.exists():
            pytest.skip("얼어붙은 유료 채점본이 없다")
        frozen = _j.loads(paid.read_text(encoding="utf-8"))
        rows = frozen["human_review"]["rows"]

        old = copy.deepcopy(ep.EXPECTED_TARGETS)
        for t in old:
            if t["key"] == "same_name_different_thing":
                t["at"] = [(1, "표", 1), (4, "표", 1)]
        spans_old = {t["key"]: ep.expected_spans(t) for t in old}
        got_old = sc.score(old, spans_old, rows, segments=SEGS,
                           relations=[], registered={})
        v = [f for f in got_old["findings"]
             if f["axis"] == "target:same_name_different_thing"][0]
        assert v["verdict"] == sc.WRONG, "★옛 oracle 로도 통과했다 — 재현 실패"
