"""GROUNDING-V2 §4b — batch 실험 **채점**. ★유료 호출 전에 못박는다.

★이 시험들이 통과한다고 「의미상 혼입이 없다」가 증명되는 것은 **아니다**.
증명되는 것은 `foreign source binding 0 · unsupported cross-subject evidence 0`
뿐이다 (Codex).
"""
import json

import pytest

from app.modules.pipeline.grounding_claims_acceptance import (
    V_EMPTY,
    V_OK,
    V_UNSURE,
    V_WRONG,
    choose_batch_size,
    schema_violations,
    score_batch,
    score_run,
)

#: ★schema 축을 **안 재는** 시험용. 다른 축을 보려면 이 문이 열려 있어야 한다 —
#:  안 그러면 schema 가 먼저 잡아서 그 축이 아예 안 돈다.
_ANY = {"type": "object"}
_URL = "https://ex.org/a"
_SNIP = "요금통은 쇠사슬로 묶여 있었다"


def _claim(**over):
    return {"kind": "fact", "required": True,
            "statement_native": "요금통은 쇠사슬로 묶여 있었다",
            "delta_effect": "supports_difference",
            "discriminator_family": "고정 방식",
            "sources": [_URL], "evidence_span": _SNIP,
            # ★대상 결속 — 팩 2 부터 claim 마다 필수다
            "target_binding": {"is_about_target": True,
                               "target_words_in_statement": "요금통",
                               "why": "이 문장의 주어가 대상이다"},
            **over}


def _batch(ids, results=None, *, sources=((_URL, _SNIP),), **over):
    return {"requested": list(ids), "error": "", "not_sent": False,
            "sources": [{"url": u, "snippet": s} for u, s in sources],
            "parsed": {"results": results if results is not None else [
                {"research_subject_id": i, "claims": [_claim()], "gaps": []}
                for i in ids]}, **over}


class TestAForeignSourceIsWrongNotUnsure:
    """★★claim 은 **이 호출이 실제로 본 주소**만 인용할 수 있다 (Codex ②)."""

    def test_a_url_the_call_never_saw_is_wrong(self):
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(sources=["https://딴곳"])],
                               "gaps": []}])
        r = score_batch(b, expected_ids=["rs_1"], unique_forms={})
        assert r["counts"][V_WRONG] == 1

    def test_the_seen_url_passes(self):
        """★positive control — 본 주소면 통과한다."""
        r = score_batch(_batch(["rs_1"]), expected_ids=["rs_1"],
                        unique_forms={})
        assert r["counts"] == {V_WRONG: 0, V_UNSURE: 0, V_OK: 1,
                               V_EMPTY: 0}


class TestUnverifiableIsUnsureNotFine:
    """★★확인 못 하는 것은 「문제없음」이 **아니라 미확정**이다 (Codex ⑤)."""

    def test_a_missing_evidence_span_is_unsure(self):
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(evidence_span="")],
                               "gaps": []}])
        assert score_batch(b, expected_ids=["rs_1"],
                           unique_forms={})["counts"][V_UNSURE] == 1

    def test_a_span_not_in_any_snippet_is_unsure(self):
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(evidence_span="딴 문장")],
                               "gaps": []}])
        assert score_batch(b, expected_ids=["rs_1"],
                           unique_forms={})["counts"][V_UNSURE] == 1

    def test_a_claim_without_sources_is_unsure(self):
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(sources=[])], "gaps": []}])
        assert score_batch(b, expected_ids=["rs_1"],
                           unique_forms={})["counts"][V_UNSURE] == 1

    def test_an_unreadable_response_is_unsure_for_everyone(self):
        b = _batch(["rs_1", "rs_2"])
        b["parsed"] = None
        assert score_batch(b, expected_ids=["rs_1", "rs_2"],
                           unique_forms={})["counts"][V_UNSURE] == 2

    def test_a_not_sent_batch_is_unsure_not_wrong(self):
        """★안 보낸 것은 「조사했다」에 안 든다 — 어긋남도 아니다."""
        b = _batch(["rs_1"], not_sent=True)
        c = score_batch(b, expected_ids=["rs_1"], unique_forms={})["counts"]
        assert c == {V_WRONG: 0, V_UNSURE: 1, V_OK: 0, V_EMPTY: 0}

    def test_a_broken_transmission_is_unsure(self):
        b = _batch(["rs_1"], error="ConnectionResetError")
        assert score_batch(b, expected_ids=["rs_1"],
                           unique_forms={})["counts"][V_UNSURE] == 1


class TestMissingAndDuplicateSubjectsAreWrong:
    """★batch 가 대상을 잃거나 두 번 내면 그건 batch 결함이다."""

    def test_a_dropped_subject_is_wrong(self):
        b = _batch(["rs_1", "rs_2"], [
            {"research_subject_id": "rs_1", "claims": [_claim()], "gaps": []}])
        r = score_batch(b, expected_ids=["rs_1", "rs_2"], unique_forms={})
        assert r["counts"][V_WRONG] == 1 and r["counts"][V_OK] == 1

    def test_a_duplicated_subject_is_wrong(self):
        row = {"research_subject_id": "rs_1", "claims": [], "gaps": []}
        b = _batch(["rs_1"], [row, dict(row)])
        assert score_batch(b, expected_ids=["rs_1"],
                           unique_forms={})["counts"][V_WRONG] == 1

    def test_an_unrequested_subject_is_wrong(self):
        """★넣지 않은 대상이 오면 **남의 줄이 섞인** 것이다."""
        b = _batch(["rs_1"], [
            {"research_subject_id": "rs_1", "claims": [], "gaps": []},
            {"research_subject_id": "rs_남", "claims": [], "gaps": []}])
        assert score_batch(b, expected_ids=["rs_1"],
                           unique_forms={})["counts"][V_WRONG] == 1


class TestAForeignSurfaceFormIsOnlyUnsure:
    """★★글자 비교다 — **미확정 표시로만** 쓰고 통과를 주지 않는다.

    「의미상 혼입」을 LLM 으로 재면 그 판정기가 또 흔들리는 축이 된다 (Codex).
    """

    def test_another_subjects_unique_form_without_evidence_is_unsure(self):
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(
                                   statement_native="회수권 뭉치는 고무줄로 묶였다",
                                   target_binding={
                                       "is_about_target": True,
                                       "target_words_in_statement": "회수권 뭉치",
                                       "why": "주어가 대상이다"},
                                   evidence_span=_SNIP)],
                               "gaps": []}],
                   sources=((_URL, _SNIP),))
        r = score_batch(b, expected_ids=["rs_1"],
                        unique_forms={"rs_2": ["회수권 뭉치"]})
        assert r["counts"][V_UNSURE] == 1
        assert r["counts"][V_WRONG] == 0, "글자 비교로 어긋남을 주면 안 된다"

    def test_the_form_in_its_own_evidence_is_fine(self):
        """★근거에 같이 있으면 옮겨 쓴 것이 아니다."""
        span = "회수권 뭉치는 고무줄로 묶였다"
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(
                                   statement_native=span, evidence_span=span,
                                   target_binding={
                                       "is_about_target": True,
                                       "target_words_in_statement": "회수권 뭉치",
                                       "why": "주어가 대상이다"})],
                               "gaps": []}],
                   sources=((_URL, span),))
        assert score_batch(b, expected_ids=["rs_1"],
                           unique_forms={"rs_2": ["회수권 뭉치"]}
                           )["counts"][V_OK] == 1


class TestOnlyACleanRunIsACandidate:
    """★★**둘 다 0일 때만** 후보다. 후보가 없으면 **기본값을 안 바꾼다**."""

    def test_a_clean_run_is_a_candidate(self):
        r = score_run([_batch(["rs_1"]), _batch(["rs_2"])], unique_forms={})
        assert r["is_candidate"] is True

    def test_one_unsure_disqualifies(self):
        bad = _batch(["rs_2"], [{"research_subject_id": "rs_2",
                                 "claims": [_claim(evidence_span="")],
                                 "gaps": []}])
        r = score_run([_batch(["rs_1"]), bad], unique_forms={})
        assert r["is_candidate"] is False

    def test_the_counts_are_numbers_not_ratios(self):
        """★9개 표본에 비율을 쓰면 한 건이 11%p 로 흔들린다."""
        r = score_run([_batch(["rs_1"])], unique_forms={})
        assert all(isinstance(v, int) for v in r["counts"].values())

    def test_it_says_what_it_does_not_prove(self):
        """★「의미상 혼입이 없다」까지 증명했다고 쓰면 안 된다."""
        r = score_run([_batch(["rs_1"])], unique_forms={})
        assert "그 이상은 아니다" in r["proves"]


class TestChoosingTheBatchSize:
    """★「제일 나은 것」이 아니라 **깨끗한 것 중 가장 큰 것**."""

    def _r(self, wrong=0, unsure=0, level="baseline"):
        return {"is_candidate": wrong == 0 and unsure == 0,
                "ownership": {"level": level},
                "counts": {V_WRONG: wrong, V_UNSURE: unsure, V_OK: 9}}

    def test_the_largest_clean_size_wins(self):
        got = choose_batch_size({1: self._r(), 2: self._r(), 4: self._r(),
                                 8: self._r(unsure=1)})
        assert got["chosen"] == 4 and got["candidates"] == [1, 2, 4]

    def test_no_candidate_means_no_change(self):
        """★★전부 나쁠 때 「제일 나은 것」을 고르면 하나가 거짓으로 이긴다."""
        got = choose_batch_size({1: self._r(unsure=1), 2: self._r(wrong=3)})
        assert got["chosen"] is None
        assert "기본값을 바꾸지 않는다" in got["why"]

    def test_every_size_is_reported_even_when_it_loses(self):
        """★진 크기도 수가 남아야 왜 졌는지 안다."""
        got = choose_batch_size({1: self._r(), 8: self._r(wrong=2)})
        assert set(got["scored"]) == {1, 8}
        assert got["scored"][8][V_WRONG] == 2


class TestContractSurvivalIsADifferentAxis:
    """★★채점과 **다른 것**을 잰다.

    채점은 「batch 가 대상을 잃거나 남의 출처를 붙였나」이고, 이건 「그 claim 을
    정본에 넣을 수 있나」다. 안 재면 **「batch 는 깨끗한데 claim 은 하나도 못
    쓴다」**를 못 본다.
    """

    def test_a_valid_claim_passes(self):
        from app.modules.pipeline.grounding_claims_acceptance import (
            contract_survival)

        b = _batch(["rs_1"])
        got = contract_survival([b])
        assert got["passed"] == 1 and got["rejected"] == 0

    def test_a_sourceless_claim_is_rejected_by_the_production_validator(self):
        """★★**프로덕션 검증기**를 그대로 부른다 — 여기서 따로 판정하면
        실험이 프로덕션과 다른 잣대를 쓴다."""
        from app.modules.pipeline.grounding_claims_acceptance import (
            contract_survival)

        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(sources=[])], "gaps": []}])
        got = contract_survival([b])
        assert got["rejected"] == 1 and got["passed"] == 0
        assert got["reasons"], "왜 거부됐는지가 안 남았다"

    def test_a_clean_batch_can_still_have_zero_usable_claims(self):
        """★★이게 이 축이 있는 이유다 — 두 수가 **따로 움직인다**."""
        from app.modules.pipeline.grounding_claims_acceptance import (
            contract_survival)

        # ★대상도 다 있고 출처도 **본 것**이지만 `required` 가 문자열이라
        #  계약 위반이다. 채점은 `required` 를 안 본다 — 두 축이 다르다.
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(required="true")],
                               "gaps": []}])
        assert score_run([b], unique_forms={}, schema=_ANY
                         )["counts"][V_OK] == 1
        assert contract_survival([b])["passed"] == 0

    def test_the_schema_check_catches_it_even_earlier(self):
        """★★그리고 팩 schema 를 켜면 **호출 통째로 미확정**이다 —
        `required` 가 boolean 이 아니기 때문이다."""
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(required="true")],
                               "gaps": []}])
        assert score_run([b], unique_forms={})["counts"][V_UNSURE] == 1

    def test_a_string_sources_is_caught_explicitly_not_by_accident(self):
        """★★문자열을 그냥 돌면 **글자 하나하나가 주소**로 세어져 우연히
        걸린다. 우연히 맞는 것은 다음 판에 우연히 틀린다."""
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(sources=_URL)], "gaps": []}])
        r = score_batch(b, expected_ids=["rs_1"], unique_forms={},
                        schema=_ANY)
        assert r["counts"][V_WRONG] == 1
        assert "목록이 아니다" in r["verdicts"][0]["why"]

    def test_it_counts_not_ratios(self):
        from app.modules.pipeline.grounding_claims_acceptance import (
            contract_survival)

        got = contract_survival([_batch(["rs_1"])])
        assert isinstance(got["passed"], int)
        assert "개수 그대로" in got["note"]


class TestOwnershipIsWeakerWhenTheBatchIsBigger:
    """★★provider 는 검색 결과를 **호출 단위**로 준다 — 어느 subject 의 질의가
    그 주소를 물어 왔는지 안 알려 준다.

    그러니 `batch=1` 이 아닌 판의 「foreign source binding 0」은 **호출 밖
    주소를 안 썼다**는 뜻이지 **남의 주소를 안 썼다**는 뜻이 아니다.
    이걸 안 적으면 보고가 증거보다 세진다.
    """

    def test_one_subject_per_call_is_exact(self):
        from app.modules.pipeline.grounding_claims_acceptance import (
            ownership_strength)

        got = ownership_strength([_batch(["rs_1"]), _batch(["rs_2"])])
        assert got["level"] == "subject"
        assert "정확히" in got["note"]

    def test_more_than_one_is_only_call_level(self):
        from app.modules.pipeline.grounding_claims_acceptance import (
            ownership_strength)

        got = ownership_strength([_batch(["rs_1", "rs_2"])])
        assert got["level"] == "call"
        assert got["max_subjects_per_call"] == 2
        assert "못 잡는다" in got["note"]

    def test_the_run_result_carries_the_limit(self):
        """★결과에 안 실으면 나중에 「소유권을 쟀다」로 읽힌다."""
        r = score_run([_batch(["rs_1", "rs_2"])], unique_forms={})
        assert r["ownership"]["level"] == "call"
        assert "호출 단위" in r["proves"]

    def test_a_clean_big_batch_still_says_it_measured_less(self):
        """★★깨끗해도 **덜 쟀다**고 말한다 — 깨끗함이 강도를 올리지 않는다."""
        r = score_run([_batch(["rs_1", "rs_2"])], unique_forms={})
        assert r["is_candidate"] is True
        assert r["ownership"]["level"] == "call"


class TestTheSpanMustComeFromTheUrlItCites:
    """★★호출 전체를 뭉쳐 보면 A 를 인용해 놓고 문장은 **B 에서 베껴 와도**
    통과한다 — `batch>1` 에서 그게 바로 남의 출처를 쓰는 모양이다.

    「한계를 적는 것」과 「합격을 막는 것」은 다르다 (Codex). 이건 막는 쪽이다.
    """

    def test_a_span_from_another_url_is_unsure(self):
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(sources=["https://a.org"],
                                                 evidence_span="딴 곳 문장")],
                               "gaps": []}],
                   sources=(("https://a.org", "내 문장"),
                            ("https://b.org", "딴 곳 문장")))
        r = score_batch(b, expected_ids=["rs_1"], unique_forms={})
        assert r["counts"][V_UNSURE] == 1
        assert "자기가 인용한 주소" in r["verdicts"][0]["why"]

    def test_a_span_from_its_own_url_passes(self):
        """★positive control — 자기 주소에서 온 것은 통과한다."""
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(sources=["https://a.org"],
                                                 evidence_span="내 문장")],
                               "gaps": []}],
                   sources=(("https://a.org", "내 문장"),
                            ("https://b.org", "딴 곳 문장")))
        assert score_batch(b, expected_ids=["rs_1"],
                           unique_forms={})["counts"][V_OK] == 1

    def test_a_cross_subject_borrow_inside_one_call_is_caught(self):
        """★★★`batch=2` 에서 B 가 A 의 근거를 베껴 쓰면 **잡힌다**.

        전에는 「호출이 본 주소」만 봐서 2/2 맞음이 나왔다 — 그게 Codex 반례다.
        """
        b = _batch(["rs_A", "rs_B"], [
            {"research_subject_id": "rs_A",
             "claims": [_claim(sources=["https://a.org"],
                               evidence_span="A 의 근거")], "gaps": []},
            {"research_subject_id": "rs_B",
             "claims": [_claim(sources=["https://b.org"],
                               evidence_span="A 의 근거")], "gaps": []}],
            sources=(("https://a.org", "A 의 근거"),
                     ("https://b.org", "B 의 근거")))
        r = score_batch(b, expected_ids=["rs_A", "rs_B"], unique_forms={})
        assert r["counts"][V_OK] == 1 and r["counts"][V_UNSURE] == 1


class TestTheWholeResponseIsCheckedAgainstTheSchema:
    """★★`strict=false` 로 보내므로 **우리가 막는다**.

    전에는 dict 인 claim 만 `validate_claim` 으로 보고 malformed gap·행·빈 id·
    추가 칸을 통째로 무시했다. Codex 반례가 그대로 통과했다.
    """

    def test_the_codex_counterexample_no_longer_passes(self):
        """★★★기대 행에 `claims=[]` + `gaps=[reason=bogus]`, 그리고 **빈 id
        행**을 붙인 판 — 전에는 후보로 통과했다."""
        b = _batch(["rs_1"], [
            {"research_subject_id": "rs_1", "claims": [],
             "gaps": [{"reason": "bogus", "query": "", "discriminator": "",
                       "required_discriminator": False, "note": ""}]},
            {"research_subject_id": "", "claims": [], "gaps": []}])
        r = score_run([b], unique_forms={})
        assert r["is_candidate"] is False
        assert r["counts"][V_UNSURE] == 1

    def test_an_extra_field_is_caught(self):
        """★`additionalProperties: false` 를 우리가 안 보면 뜻이 없다."""
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1", "claims": [],
                               "gaps": [], "몰래": "x"}])
        assert score_run([b], unique_forms={})["is_candidate"] is False

    def test_a_bad_enum_is_caught(self):
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(kind="딴것")], "gaps": []}])
        assert score_run([b], unique_forms={})["is_candidate"] is False

    def test_a_gap_the_production_contract_rejects_is_caught(self):
        """★schema enum 을 통과해도 `build_gap` 이 거부하는 모양이 있다."""
        from app.modules.pipeline.grounding_claims_acceptance import (
            schema_violations)

        b = _batch(["rs_1"], [{"research_subject_id": "rs_1", "claims": [],
                               "gaps": [{"reason": "not_found", "query": "",
                                         "discriminator": "", "note": "",
                                         "required_discriminator": "예"}]}])
        assert any("gap" in v for v in schema_violations(b))

    def test_a_clean_response_passes_the_schema(self):
        """★positive control — 멀쩡한 응답은 그대로 통과한다."""
        assert schema_violations(_batch(["rs_1"])) == []

    def test_one_bad_row_makes_the_whole_call_unsure(self):
        """★★어느 줄이 오염됐는지 모르는 채로 **일부만 통과시키지 않는다**."""
        b = _batch(["rs_1", "rs_2"], [
            {"research_subject_id": "rs_1", "claims": [_claim()], "gaps": []},
            {"research_subject_id": "rs_2", "claims": [_claim(kind="딴것")],
             "gaps": []}])
        r = score_run([b], unique_forms={})
        assert r["counts"][V_UNSURE] == 2 and r["counts"][V_OK] == 0


class TestAnUnmeasuredBatchCannotWin:
    """★★★「호출 밖 주소를 안 썼다」는 「남의 주소를 안 썼다」가 **아니다**.

    그 상태로 크기를 고르면 **못 잰 것을 통과로 삼는** 것이다 (Codex).
    """

    def _r(self, level, wrong=0, unsure=0):
        return {"is_candidate": wrong == 0 and unsure == 0,
                "ownership": {"level": level},
                "counts": {V_WRONG: wrong, V_UNSURE: unsure, V_OK: 9}}

    def test_a_clean_call_level_batch_is_not_a_candidate(self):
        got = choose_batch_size({1: self._r("subject"),
                                 4: self._r("call")})
        assert got["chosen"] == 1
        assert got["clean_but_unmeasured"] == [4]

    def test_a_baseline_compared_batch_can_win(self):
        """★기준선과 대 봤으면 이긴다 — 그게 기준선을 만드는 이유다."""
        got = choose_batch_size({1: self._r("subject"),
                                 8: self._r("baseline")})
        assert got["chosen"] == 8

    def test_clean_but_unmeasured_is_not_the_same_as_bad(self):
        """★섞어 두면 다음 판에 「8이 나빴다」로 오독한다."""
        got = choose_batch_size({1: self._r("subject"),
                                 2: self._r("call", wrong=3),
                                 4: self._r("call")})
        assert got["clean_but_unmeasured"] == [4]
        assert 2 not in got["clean_but_unmeasured"]


class TestTheBaselineCatchesABorrowInsideOneCall:
    """★★★Codex 반례 — A 가 B 의 주소를 통째로 인용해도 잡는다."""

    def _one(self, sid, url, snip):
        return {"requested": [sid], "error": "", "not_sent": False,
                "sources": [{"url": url, "snippet": snip}],
                "parsed": {"results": [
                    {"research_subject_id": sid,
                     "claims": [_claim(sources=[url], evidence_span=snip)],
                     "gaps": []}]}}

    def test_the_baseline_is_built_only_from_single_subject_calls(self):
        from app.modules.pipeline.grounding_claims_acceptance import (
            url_baseline)

        singles = [self._one("rs_A", "https://a.org", "A 근거"),
                   self._one("rs_B", "https://b.org", "B 근거")]
        mixed = _batch(["rs_A", "rs_B"])
        base = url_baseline(singles + [mixed])
        assert base == {"rs_A": {"https://a.org"}, "rs_B": {"https://b.org"}}

    def test_borrowing_another_subjects_url_is_wrong(self):
        from app.modules.pipeline.grounding_claims_acceptance import (
            url_baseline)

        base = url_baseline([self._one("rs_A", "https://a.org", "A 근거"),
                             self._one("rs_B", "https://b.org", "B 근거")])
        # ★한 호출에 둘 — A 가 **B 의 주소**를 인용한다
        b = {"requested": ["rs_A", "rs_B"], "error": "", "not_sent": False,
             "sources": [{"url": "https://a.org", "snippet": "A 근거"},
                         {"url": "https://b.org", "snippet": "B 근거"}],
             "parsed": {"results": [
                 {"research_subject_id": "rs_A",
                  "claims": [_claim(sources=["https://b.org"],
                                    evidence_span="B 근거")], "gaps": []},
                 {"research_subject_id": "rs_B",
                  "claims": [_claim(sources=["https://b.org"],
                                    evidence_span="B 근거")], "gaps": []}]}}
        r = score_batch(b, expected_ids=["rs_A", "rs_B"], unique_forms={},
                        baseline=base)
        assert r["counts"][V_WRONG] == 1, "남의 주소를 쓴 것을 못 잡았다"
        assert r["counts"][V_OK] == 1

    def test_a_new_url_outside_every_baseline_is_unsure(self):
        """★검색은 비결정적이라 **새 주소**가 나올 수 있다 — 어긋남이 아니다."""
        from app.modules.pipeline.grounding_claims_acceptance import (
            url_baseline)

        base = url_baseline([self._one("rs_A", "https://a.org", "A 근거")])
        b = self._one("rs_A", "https://새곳.org", "새 근거")
        r = score_batch(b, expected_ids=["rs_A"], unique_forms={},
                        baseline=base)
        assert r["counts"][V_UNSURE] == 1 and r["counts"][V_WRONG] == 0


class TestATrueCitationCanStillBeTheWrongSubject:
    """★★★첫 유료 호출이 그랬다 — 「차고」를 물었는데 **버스**의 사실이 왔다.

    조사도 인용도 참이었다. HTTP support 검증은 「인용이 참인가」만 보므로
    **이걸 그대로 통과시킨다**. 그래서 결속을 **따로** 본다 (Codex).
    """

    def test_a_claim_that_admits_it_is_not_about_the_target_is_wrong(self):
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(target_binding={
                                   "is_about_target": False,
                                   "target_words_in_statement": "버스",
                                   "why": "옆에 있던 것이다"})], "gaps": []}])
        r = score_batch(b, expected_ids=["rs_1"], unique_forms={}, schema=_ANY)
        assert r["counts"][V_WRONG] == 1

    def test_a_missing_binding_is_wrong_not_unsure(self):
        """★결속을 안 적으면 **볼 수가 없다** — 통과시키면 안 된다."""
        c = _claim()
        c.pop("target_binding")
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1", "claims": [c],
                               "gaps": []}])
        r = score_batch(b, expected_ids=["rs_1"], unique_forms={}, schema=_ANY)
        assert r["counts"][V_WRONG] == 1

    def test_a_binding_word_not_in_the_statement_is_wrong(self):
        """★★적어 놓고 문장에 없으면 **자기 보고가 자기와 안 맞는다**."""
        b = _batch(["rs_1"], [{"research_subject_id": "rs_1",
                               "claims": [_claim(target_binding={
                                   "is_about_target": True,
                                   "target_words_in_statement": "차고",
                                   "why": "대상이다"})], "gaps": []}])
        r = score_batch(b, expected_ids=["rs_1"], unique_forms={}, schema=_ANY)
        assert r["counts"][V_WRONG] == 1
        assert "문장에 없다" in r["verdicts"][0]["why"]

    def test_the_real_first_paid_row_is_caught(self):
        """★★★**실제로 산 그 행**을 고정 회귀로 넣는다.

        사람 판정은 **거부만** 하고 통과를 주지 않는다 (Codex).
        """
        import pathlib as _pl

        fx = json.loads((
            _pl.Path(__file__).resolve().parents[1] / "fixtures" / "grounding"
            / "a0_first_paid_call_wrong_binding.json").read_text(
                encoding="utf-8"))
        assert fx["verdict"].startswith("WRONG")
        # 그 행의 claim 에는 `target_binding` 이 아예 없다(팩 1 로 샀다)
        row = fx["row"]
        r = score_batch(row, expected_ids=row["requested"], unique_forms={},
                        schema=_ANY)
        assert r["counts"][V_WRONG] == 1, "옛 행이 통과했다"
        assert fx["target"]["surface_form"] == "차고"
        assert "시외버스" in fx["the_right_owner"]["surface_form"]


class TestAPlausibleSelfReportIsStillCaughtByTheOtherAxis:
    """★★★`target_binding` 만으로는 **그럴듯하게 꾸민 오결속**이 통과한다 —
    그건 내가 코드에 적어 둔 한계이고 Codex 가 실물로 재현했다.

    ★그런데 **프로덕션 경로는 `unique_forms` 를 같이 넘긴다.** 그 축이
    「남의 고유 표기가 근거 없이 나타났다」로 잡아 **미확정**을 준다 —
    후보에서 떨어진다. 두 축이 **겹쳐서** 막는 것이 설계다.
    """

    def _garage_claim(self):
        return _claim(
            statement_native="차고에 세워 둔 두 대의 낡은 시외버스는 큐빅이 아니다",
            target_binding={"is_about_target": True,
                            "target_words_in_statement": "차고",
                            "why": "대상이다"})

    def test_without_the_other_axis_it_passes(self):
        """★한계를 **그대로 못박는다** — 이게 참이라 두 번째 축이 필요하다."""
        b = _batch(["rs_garage"], [{"research_subject_id": "rs_garage",
                                    "claims": [self._garage_claim()],
                                    "gaps": []}])
        r = score_batch(b, expected_ids=["rs_garage"], unique_forms={},
                        schema=_ANY)
        assert r["counts"][V_OK] == 1

    def test_with_the_other_axis_it_is_unsure(self):
        """★★★같은 판이 **미확정**이 된다 — 후보에서 떨어진다."""
        b = _batch(["rs_garage"], [{"research_subject_id": "rs_garage",
                                    "claims": [self._garage_claim()],
                                    "gaps": []}])
        r = score_batch(
            b, expected_ids=["rs_garage"], schema=_ANY,
            unique_forms={"rs_bus": ["차고에 세워 둔 두 대의 낡은 시외버스"]})
        assert r["counts"][V_UNSURE] == 1
        assert "남의 고유 표기" in r["verdicts"][0]["why"]

    def test_the_runner_always_passes_the_forms(self):
        """★★★비우고 부르면 그 축이 통째로 죽는다 — 실행기가 늘 채워야 한다."""
        import pathlib as _pl

        src = (_pl.Path(__file__).resolve().parents[2] / "tools"
               / "prompt_measure" / "grounding_batch_experiment.py"
               ).read_text(encoding="utf-8")
        assert "unique_forms=uf" in src
        assert "uf = unique_forms(subs)" in src

    def test_the_real_manifest_keeps_the_bus_form(self):
        """★★겹치는 표기를 빼는 규칙이 **버스 쪽은 남겨야** 이 축이 돈다.

        「차고」는 버스 표기에 포함돼 빠지지만, 버스 표기는 남는다 —
        그래서 「차고」 subject 가 버스 얘기를 하면 잡힌다.
        """
        import importlib.util as _iu
        import pathlib as _pl

        root = _pl.Path(__file__).resolve().parents[2]
        spec = _iu.spec_from_file_location(
            "_exp2", root / "tools" / "prompt_measure"
            / "grounding_batch_experiment.py")
        m = _iu.module_from_spec(spec)
        spec.loader.exec_module(m)
        man = json.loads((root / "tests" / "fixtures" / "grounding"
                          / "claims_search_manifest.json").read_text(
                              encoding="utf-8"))
        uf = m.unique_forms(man["locked"]["subjects"])
        forms = {f for v in uf.values() for f in v}
        assert any(f.startswith("차고에 세워") for f in forms)
        assert "차고" not in forms, "겹치는 짧은 표기는 빠져야 한다"


class TestTheBindingHoleIsRealAndNotClosedByMachines:
    """★★★내 앞 보고가 과장이었다. 「두 축이 겹쳐서 막는다」고 썼는데,
    **셋 다 지나는 오결속이 실재한다** (Codex 반례).

        target = 「차고」
        claim  = 「1983년 차고에는 큐빅스타일 버스를 두지 않는다」
        binding= is_about_target=true, words="차고"

    - `target_binding` 통과 — 「차고」가 문장에 있다
    - `unique_forms` 통과 — 남의 **긴 표면형**이 이 문장엔 없다
    - 인용도 진짜다

    그런데 그 사실은 **버스**의 것이다. 대상을 **자리로만** 썼다.
    """

    def _hole(self):
        return _claim(
            statement_native="1983년 차고에는 큐빅스타일 버스를 두지 않는다",
            target_binding={"is_about_target": True,
                            "target_words_in_statement": "차고",
                            "why": "차고에 놓인 시외버스 외형"})

    def test_the_counterexample_still_passes_the_machines(self):
        """★★★**이 시험이 초록인 것이 지금의 한계다.** 빨개지는 날
        「기계로 닫았다」고 쓸 수 있다 — 그 전에는 못 쓴다."""
        b = _batch(["rs_garage"], [{"research_subject_id": "rs_garage",
                                    "claims": [self._hole()], "gaps": []}])
        r = score_batch(
            b, expected_ids=["rs_garage"], schema=_ANY,
            unique_forms={"rs_bus": ["차고에 세워 둔 두 대의 낡은 시외버스"]})
        assert r["counts"][V_OK] == 1, "닫혔으면 이 시험을 고쳐라"

    def test_so_the_machine_result_is_not_a_final_candidate(self):
        """★★그래서 자동 결과는 **기계 후보**까지다 — 기본값을 안 바꾼다."""
        got = choose_batch_size({
            1: {"is_candidate": True, "ownership": {"level": "subject"},
                "counts": {V_WRONG: 0, V_UNSURE: 0, V_OK: 9}}})
        assert "기계" in got["why"] or got["chosen"] == 1


class TestAnEmptyResultIsNotAWin:
    """★★★실측(2026-08-30 진단 1회) — 83개 주소를 훑고 **claim 0 · gap 4** 로
    돌아온 주행이 「맞음 1」로 세어졌다.

    claim 이 0 이면 남의 표기가 나올 수도, 자기모순이 날 수도 없어서 세 축을
    전부 그냥 지나간다. **못 찾은 것과 제대로 찾은 것이 같은 칸**에 들어가면,
    크기를 고를 때 **빈손인 큰 batch 가 이긴다.**
    """

    def _batch(self, claims, gaps=()):
        return {"requested": ["rs_a"], "parsed": {"results": [
            {"research_subject_id": "rs_a", "claims": list(claims),
             "gaps": list(gaps)}]}, "sources": []}

    def test_no_claims_is_its_own_bucket(self):
        r = score_run([self._batch([], [{"reason": "not_found"}] * 4)],
                      unique_forms={}, schema=_ANY)
        assert r["counts"][V_EMPTY] == 1
        assert r["counts"][V_OK] == 0, "확정한 것이 없는데 맞음으로 셌다"
        assert "gap 4개" in r["verdicts"][0]["why"]

    def test_an_all_empty_run_is_not_a_candidate(self):
        r = score_run([self._batch([])], unique_forms={}, schema=_ANY)
        assert r["is_candidate"] is False
        assert r["empty_rows"] == 1

    def test_choose_does_not_pick_an_empty_but_clean_size(self):
        empty = score_run([self._batch([])], unique_forms={}, schema=_ANY)
        got = choose_batch_size({8: empty})
        assert got["chosen"] is None and got["candidates"] == []

    def test_a_real_claim_still_wins(self):
        """★positive control — 빈손만 걸러야지 정상까지 막으면 안 된다."""
        claim = {"kind": "fact", "statement_native": "차고 바닥은 흙이다",
                 "target_binding": {"is_about_target": True,
                                    "target_words_in_statement": "차고",
                                    "why": "대상 자체의 사실"},
                 "sources": ["https://a.org"], "evidence_span": "조각"}
        r = score_run([{**self._batch([claim]),
                        "sources": [{"url": "https://a.org", "snippet": ""}]}],
                      unique_forms={}, schema=_ANY)
        assert r["counts"][V_OK] == 1 and r["counts"][V_EMPTY] == 0
        assert r["is_candidate"] is True
