"""샷 catalog 와 세 상태 — ★유료 0.

Codex 배선 판정 1: `shot_appearance_ids: []` 하나로는 **「원문엔 있는데 이
샷들엔 안 보인다」**와 **「모델이 못 붙였다」**가 둘 다 0회가 된다. 그래서
상태를 셋으로 가른다.

★**실제 `shot_validator` 체크포인트 모양**으로 잰다 — 손으로 지은 모양으로
재면 프로덕션과 갈린다.
"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from app.modules.pipeline import grounding_chunk as gc
from app.modules.pipeline import grounding_chunk_merge as cm
from app.modules.pipeline import grounding_shot_catalog as sc
from tests.grounding.fixtures import synthetic_episode as ep

PROJECTS = Path(__file__).resolve().parents[3] / "projects"


def _real_cp():
    """실제 프로젝트의 `shot_validator` **하나**. ★없으면 건너뛴다.

    ★**전체를 훑지 않는다.** 설계 문서의 61 에피소드·330 구간 실측은 별도
    측정이고, 이 시험은 「실제 CP 모양으로 catalog 를 지을 수 있나」만 본다.
    """
    for p in sorted(PROJECTS.glob(
            "*/checkpoints/episodes/*/shot_validator/manifest.json")):
        try:
            d = json.loads(p.read_text(encoding="utf-8")).get("data") or {}
        except Exception:                       # noqa: BLE001
            continue
        if d.get("scenes"):
            return d["scenes"]
    return None


class TestTheCatalogComesFromTheRealCheckpoint:
    def test_ids_are_built_from_scene_and_shot_index(self):
        scenes = _real_cp()
        if not scenes:
            pytest.skip("실제 shot_validator 체크포인트가 없다")
        first = scenes[0]
        idx = str(first["scene_index"])
        cat = sc.build_catalog(scenes, [f"scene-{idx}"])
        assert cat, "★catalog 가 비었다"
        for c in cat:
            assert c["id"].startswith(f"s{idx}#")
            assert c["scene_id"] == f"scene-{idx}"

    def test_the_description_is_not_truncated(self):
        """★★앞 N자 절단 금지 — 구별점을 없애 **잘못된 샷 결속**을 만들고,
        그 결함은 validator 가 못 잡는다 (Codex)."""
        scenes = _real_cp()
        if not scenes:
            pytest.skip("실제 shot_validator 체크포인트가 없다")
        idx = str(scenes[0]["scene_index"])
        cat = sc.build_catalog(scenes, [f"scene-{idx}"])
        by_id = {sc.shot_id(idx, s["shot_index"]): s
                 for s in scenes[0]["shots"]}
        for c in cat:
            assert c["description"] == (by_id[c["id"]].get("description") or "")

    def test_a_duplicate_shot_id_stops(self):
        dup = [{"scene_index": "1",
                "shots": [{"shot_index": "1"}, {"shot_index": "1"}]}]
        with pytest.raises(ValueError, match="겹친다"):
            sc.build_catalog(dup, ["scene-1"])

    def test_only_this_chunks_scenes_are_included(self):
        scenes = [{"scene_index": "1", "shots": [{"shot_index": "1"}]},
                  {"scene_index": "2", "shots": [{"shot_index": "1"}]}]
        cat = sc.build_catalog(scenes, ["scene-2"])
        assert [c["id"] for c in cat] == ["s2#1"]


class TestTheRuntimeEnumIsThisCallsCatalog:
    """★고정 목록을 팩에 안 박는다 — 원고마다 다르다."""

    def test_the_enum_is_narrowed_to_the_catalog(self):
        schema = gc.load_schema("chunk_schema.json")
        cat = [{"id": "s1#1"}, {"id": "s1#2"}]
        out = sc.patch_schema_with_shot_ids(schema, cat)
        node = (out["properties"]["rows"]["items"]["properties"]
                ["shot_appearance_ids"])
        assert node["items"]["enum"] == ["s1#1", "s1#2"]
        assert node["uniqueItems"] is True

    def test_the_pack_itself_carries_no_fixed_ids(self):
        schema = gc.load_schema("chunk_schema.json")
        node = (schema["properties"]["rows"]["items"]["properties"]
                ["shot_appearance_ids"])
        assert "enum" not in node["items"], "★팩에 고정 ID 가 박혔다"

    def test_the_status_enum_is_the_three_states(self):
        schema = gc.load_schema("chunk_schema.json")
        node = (schema["properties"]["rows"]["items"]["properties"]
                ["shot_binding_status"])
        assert sorted(node["enum"]) == sorted(sc.BIND_STATES)


class TestTheThreeStatesTruthTable:
    """★★★상태 셋이 **서로 다르게** 다뤄지는지 — 이게 판정 1 의 핵심이다."""

    CAT = [{"id": "s1#1", "scene_id": "scene-1", "description": ""},
           {"id": "s1#2", "scene_id": "scene-1", "description": ""},
           {"id": "s2#1", "scene_id": "scene-2", "description": ""}]

    def test_bound_complete_keeps_the_ids(self):
        st, ids, why = sc.verify_binding(
            sc.BOUND_COMPLETE, ["s1#1", "s1#2"], self.CAT, ["scene-1"])
        assert (st, ids, why) == (sc.BOUND_COMPLETE, ["s1#1", "s1#2"], "")

    def test_not_in_catalog_must_be_exactly_empty(self):
        st, ids, why = sc.verify_binding(
            sc.NOT_IN_CATALOG, [], self.CAT, ["scene-1"])
        assert (st, ids, why) == (sc.NOT_IN_CATALOG, [], "")
        st2, _i, why2 = sc.verify_binding(
            sc.NOT_IN_CATALOG, ["s1#1"], self.CAT, ["scene-1"])
        assert st2 == sc.BIND_UNRESOLVED and why2

    def test_a_naked_empty_array_is_refused(self):
        """★`bound_complete` 인데 비었으면 **나체 빈 배열**이다 — 금지."""
        st, _i, why = sc.verify_binding(
            sc.BOUND_COMPLETE, [], self.CAT, ["scene-1"])
        assert st == sc.BIND_UNRESOLVED and "나체" in why

    def test_an_id_outside_the_catalog_is_refused(self):
        st, _i, why = sc.verify_binding(
            sc.BOUND_COMPLETE, ["s9#9"], self.CAT, ["scene-1"])
        assert st == sc.BIND_UNRESOLVED and "catalog 밖" in why

    def test_a_shot_from_another_scene_is_refused(self):
        """★씬 밖 결속 — 이름·substring 으로 추정하지 않고 **좌표로** 막는다."""
        st, _i, why = sc.verify_binding(
            sc.BOUND_COMPLETE, ["s2#1"], self.CAT, ["scene-1"])
        assert st == sc.BIND_UNRESOLVED and "씬 밖" in why

    def test_a_duplicate_id_is_dropped_and_the_rest_kept(self):
        """★겹친 ID 는 하나만 남고 사유가 붙는다 — 행은 산다 (2026-09-02)."""
        st, ids, why = sc.verify_binding(
            sc.BOUND_COMPLETE, ["s1#1", "s1#1"], self.CAT, ["scene-1"])
        assert st == sc.BOUND_COMPLETE and ids == ["s1#1"] and "겹친다" in why

    def test_a_bad_id_among_good_ones_is_dropped_alone(self):
        """★★production 실측 모양 — 씬 1 결속 여섯에 씬 4 하나가 섞였다. 여섯은 남는다."""
        st, ids, why = sc.verify_binding(
            sc.BOUND_COMPLETE, ["s1#1", "s2#1", "s1#2"], self.CAT, ["scene-1"])
        assert st == sc.BOUND_COMPLETE and ids == ["s1#1", "s1#2"]
        assert "씬 밖" in why and "s2#1" in why

    def test_an_unknown_state_is_refused(self):
        st, _i, why = sc.verify_binding("아무말", [], self.CAT, ["scene-1"])
        assert st == sc.BIND_UNRESOLVED and "모르는 결속 상태" in why


class TestTheRepetitionAxisCountsShots:
    def _row(self, state, ids, **over):
        d = {"local_id": "c0#0", "owner_type": "prop", "surface_form": "x",
             "occurrences": [], "shot_binding_status": state,
             "shot_appearance_ids": list(ids),
             "hard_to_generate": False, "viewers_would_notice": False}
        d.update(over)
        return d

    def test_two_shots_register(self):
        r = self._row(sc.BOUND_COMPLETE, ["s1#1", "s1#2"])
        assert cm.appearance_count([r]) == (2, False)
        assert cm._should_register([r]) == (True, "shot_appearances=2")

    def test_one_shot_does_not(self):
        r = self._row(sc.BOUND_COMPLETE, ["s1#1"])
        assert cm._should_register([r]) == (False, "shot_appearances=1")

    def test_not_in_catalog_is_an_explicit_zero(self):
        r = self._row(sc.NOT_IN_CATALOG, [])
        assert cm.appearance_count([r]) == (0, False)
        assert cm._should_register([r]) == (False, "shot_appearances=0")

    def test_unresolved_is_not_a_no(self):
        """★★★미확정을 「미등록」으로 접으면 「모델이 못 붙였다」가
        「안 나온다」가 된다."""
        r = self._row(sc.BIND_UNRESOLVED, [])
        n, un = cm.appearance_count([r])
        assert un is True
        ok, why = cm._should_register([r])
        assert ok is None and why == "shot_binding_unresolved"

    def test_the_exception_axis_is_independent_of_the_binding(self):
        """★반복 축이 미확정이어도 **예외 축이 서면 등록**된다."""
        r = self._row(sc.BIND_UNRESOLVED, [], hard_to_generate=True,
                      viewers_would_notice=True)
        assert cm._should_register([r]) == (True, "grounding_exception")

    def test_merging_unions_the_shots(self):
        """★★합칠 때 샷을 안 합치면 두 구간에 걸친 실물이 **1회**가 된다."""
        a = self._row(sc.BOUND_COMPLETE, ["s1#1"], local_id="c0#0")
        b = self._row(sc.BOUND_COMPLETE, ["s2#1"], local_id="c1#0")
        got = cm._reduce_rows([dict(a), dict(b)],
                              [{"remove_local_id": "c1#0",
                                "keep_local_id": "c0#0",
                                "relation": cm.REL_SAME}])
        row = got["rows"][0]
        assert row["shot_appearance_ids"] == ["s1#1", "s2#1"]
        assert cm._should_register([row]) == (True, "shot_appearances=2")

    def test_merging_takes_the_weakest_state(self):
        """★하나라도 미확정이면 합친 것도 미확정이다."""
        a = self._row(sc.BOUND_COMPLETE, ["s1#1"], local_id="c0#0")
        b = self._row(sc.BIND_UNRESOLVED, [], local_id="c1#0")
        got = cm._reduce_rows([dict(a), dict(b)],
                              [{"remove_local_id": "c1#0",
                                "keep_local_id": "c0#0",
                                "relation": cm.REL_SAME}])
        assert got["rows"][0]["shot_binding_status"] == sc.BIND_UNRESOLVED

    def test_the_registration_ledger_carries_unresolved(self):
        """★끝점 — 장부에 `registration_unresolved` 가 남는다."""
        segs = ep.segment_texts()
        r = {"local_id": "c0#0", "owner_type": "prop", "surface_form": "가방",
             "occurrences": [{"source_span": ep.span_of(1, "가방", 1),
                              "source_quote": "가방"}],
             "shot_binding_status": sc.BIND_UNRESOLVED,
             "shot_appearance_ids": [],
             "hard_to_generate": False, "viewers_would_notice": False}
        got = cm.reduce_episode([r], [], segments=segs)
        rec = got["registered"]["c0#0"]
        assert rec["registered"] is None
        assert rec["disposition"] == cm.DISP_UNRESOLVED
        assert rec["final_id"] is None


class TestThePayloadCarriesTheCatalogWhole:
    """★전문 payload — 자르지 않고, 상한을 넘으면 **구간을 나눈다**."""

    def test_the_catalog_rides_the_chunk_payload(self):
        segs = ep.segment_texts()
        cat = [{"id": "s1#1", "scene_id": "scene-1",
                "description": "긴 설명 " * 40}]
        p = gc.build_chunk_payload(["scene-1"], segs, "세계", shot_catalog=cat)
        text = p["parts"][0]["text"]
        assert cat[0]["description"] in text, "★설명이 잘렸다"
        node = (p["schema"]["properties"]["rows"]["items"]["properties"]
                ["shot_appearance_ids"])
        assert node["items"]["enum"] == ["s1#1"]

    def test_without_a_catalog_the_schema_is_untouched(self):
        """★positive control — catalog 를 안 주면 옛 모양 그대로다."""
        segs = ep.segment_texts()
        p = gc.build_chunk_payload(["scene-1"], segs, "세계")
        node = (p["schema"]["properties"]["rows"]["items"]["properties"]
                ["shot_appearance_ids"])
        assert "enum" not in node["items"]
        assert "SHOTS IN THIS CHUNK" not in p["parts"][0]["text"]

    def test_a_real_catalog_can_be_built_at_all(self):
        """★한 에피소드에서 본 수를 전역 상한으로 일반화하지 **않는다** (Codex).

        이 시험은 **CP 하나**로 「지을 수 있나」만 본다. 크기 분포(p50 44 ·
        p95 71 · max 228 · bytes max ~30KB)는 설계 문서의 **별도 측정**이고,
        여기서 문턱을 정하지 않는다.
        """
        scenes = _real_cp()
        if not scenes:
            pytest.skip("실제 shot_validator 체크포인트가 없다")
        idx = str(scenes[0]["scene_index"])
        cat = sc.build_catalog(scenes, [f"scene-{idx}"])
        size = len(json.dumps(cat, ensure_ascii=False).encode("utf-8"))
        assert size > 0 and len(cat) > 0
        # ★설계 문서에 적은 실측(p50 44 · max 228 · bytes max ~30KB)과
        #  같은 자리에서 잰다는 것만 잠근다. 문턱은 여기서 안 정한다.


class TestQuarantineCoversShotBinding:
    """★샷 결속이 틀리면 **그 ID 만** 빠지고 사유가 행에 남는다 (2026-09-02 뒤집음).
    검증된 결속이 하나도 없으면 상태는 `unresolved` 로 내려가지만 행은 산다."""

    CAT = [{"id": "s1#1", "scene_id": "scene-1", "description": ""}]

    def _model_row(self, status, ids):
        return {"owner_type": "prop", "surface_form": "가방",
                "mentions": [{"mention_quote": "가방",
                              "occurrence_index": 1}],
                "evidence_quotes": [], "hard_to_generate": False,
                "viewers_would_notice": False, "visual_brief": "",
                "search_terms_native": [], "language_lock_native": "",
                "shot_binding_status": status, "shot_appearance_ids": ids}

    def test_a_shot_outside_the_catalog_is_dropped_and_recorded(self):
        got = gc.resolve_rows(
            [self._model_row(sc.BOUND_COMPLETE, ["s9#9"])],
            chunk_id="c0", segment_ids=["scene-1"],
            segments=ep.segment_texts(), shot_catalog=self.CAT)
        assert got["quarantined"] == []
        r = got["rows"][0]
        assert r["shot_binding_status"] == sc.BIND_UNRESOLVED and r["shot_appearance_ids"] == []
        assert r["salvage_problems"][0]["kind"] == gc.Q_SHOT and "catalog 밖" in r["salvage_problems"][0]["why"]

    def test_a_good_binding_survives(self):
        """★positive control — 막기만 하고 정상까지 떨어뜨리면 못 쓴다."""
        got = gc.resolve_rows(
            [self._model_row(sc.BOUND_COMPLETE, ["s1#1"])],
            chunk_id="c0", segment_ids=["scene-1"],
            segments=ep.segment_texts(), shot_catalog=self.CAT)
        assert got["quarantined"] == []
        assert got["rows"][0]["shot_appearance_ids"] == ["s1#1"]
        assert got["rows"][0]["shot_binding_status"] == sc.BOUND_COMPLETE

    def test_without_a_catalog_the_row_is_unresolved_not_wrong(self):
        """★catalog 를 안 주면 **미확정**이다 — 씬으로 되돌아가지 않는다."""
        got = gc.resolve_rows(
            [self._model_row(sc.BOUND_COMPLETE, ["s1#1"])],
            chunk_id="c0", segment_ids=["scene-1"],
            segments=ep.segment_texts())
        assert got["quarantined"] == []
        assert got["rows"][0]["shot_binding_status"] == sc.BIND_UNRESOLVED


class TestCodexShotBindingCounterexamples:
    """★★★Codex 가 실제로 재현한 넷 중 둘 (2026-08-31). 공개 끝점으로 잰다."""

    CAT = [{"id": "s1#1", "scene_id": "scene-1", "description": ""},
           {"id": "s2#1", "scene_id": "scene-2", "description": ""}]

    def _model_row(self, status, ids, scene=1, word="가방"):
        return {"owner_type": "prop", "surface_form": word,
                "mentions": [{"mention_quote": word, "occurrence_index": 1}],
                "evidence_quotes": [], "hard_to_generate": False,
                "viewers_would_notice": False, "visual_brief": "",
                "search_terms_native": [], "language_lock_native": "",
                "shot_binding_status": status, "shot_appearance_ids": ids}

    def _resolve(self, row, scene="scene-1"):
        return gc.resolve_rows([row], chunk_id="c0", segment_ids=[scene],
                               segments=ep.segment_texts(),
                               shot_catalog=self.CAT)

    # ① unresolved 가 잘못된 ID 를 **삼키면 안 된다** — 빼되 사유를 행에 남긴다
    def test_unresolved_with_an_unknown_id_is_dropped_with_a_reason(self):
        got = self._resolve(self._model_row(sc.BIND_UNRESOLVED, ["bogus"]))
        r = got["rows"][0]
        assert r["shot_appearance_ids"] == [], "★catalog 밖 ID 가 남았다"
        assert r["salvage_problems"][0]["kind"] == gc.Q_SHOT, "★catalog 밖 ID 를 조용히 삼켰다"

    def test_unresolved_with_a_duplicate_keeps_one_and_records(self):
        got = self._resolve(
            self._model_row(sc.BIND_UNRESOLVED, ["s1#1", "s1#1"]))
        r = got["rows"][0]
        assert r["shot_appearance_ids"] == ["s1#1"]
        assert "겹친다" in r["salvage_problems"][0]["why"], "★중복을 조용히 삼켰다"

    def test_unresolved_with_a_shot_from_another_scene_is_dropped_with_a_reason(self):
        got = self._resolve(self._model_row(sc.BIND_UNRESOLVED, ["s2#1"]))
        r = got["rows"][0]
        assert r["shot_appearance_ids"] == []
        assert "씬 밖" in r["salvage_problems"][0]["why"], "★씬 밖 ID 를 조용히 삼켰다"

    def test_unresolved_with_good_audit_ids_survives(self):
        """★positive control — 감사 ID 가 성한데 막으면 못 쓴다."""
        got = self._resolve(self._model_row(sc.BIND_UNRESOLVED, ["s1#1"]))
        assert got["quarantined"] == []
        r = got["rows"][0]
        assert r["shot_binding_status"] == sc.BIND_UNRESOLVED
        assert r["shot_appearance_ids"] == ["s1#1"]
        # ★감사용으로 남기되 **횟수에는 안 쓴다**
        assert cm.appearance_count([r]) == (0, True)

    def test_the_audit_ids_survive_a_merge(self):
        """★합칠 때 미확정 member 의 **검증된 감사 ID 도 보존**한다."""
        a = {"local_id": "c0#0", "owner_type": "prop", "surface_form": "x",
             "occurrences": [], "shot_binding_status": sc.BOUND_COMPLETE,
             "shot_appearance_ids": ["s1#1"],
             "hard_to_generate": False, "viewers_would_notice": False}
        b = {**a, "local_id": "c1#0",
             "shot_binding_status": sc.BIND_UNRESOLVED,
             "shot_appearance_ids": ["s2#1"]}
        got = cm._reduce_rows([dict(a), dict(b)],
                              [{"remove_local_id": "c1#0",
                                "keep_local_id": "c0#0",
                                "relation": cm.REL_SAME}])
        row = got["rows"][0]
        assert row["shot_binding_status"] == sc.BIND_UNRESOLVED
        assert row["shot_appearance_ids"] == ["s1#1", "s2#1"], \
            "★미확정 member 의 감사 ID 를 버렸다"
        # ★그래도 **횟수로는 안 쓴다**
        assert cm.appearance_count([row])[1] is True

    # ② 빈 catalog 의 runtime gate
    def test_an_empty_catalog_closes_the_array(self):
        """★★앞 판은 `{'type':'string'}` 만 남겨 **아무 문자열이나** 통과했다."""
        out = sc.patch_schema_with_shot_ids(
            gc.load_schema("chunk_schema.json"), [])
        node = (out["properties"]["rows"]["items"]["properties"]
                ["shot_appearance_ids"])
        assert node.get("maxItems") == 0, "★빈 catalog 인데 문이 열려 있다"

    def test_a_nonempty_catalog_has_no_maxitems(self):
        """★positive control — 있는데 닫아 버리면 못 쓴다."""
        out = sc.patch_schema_with_shot_ids(
            gc.load_schema("chunk_schema.json"), self.CAT)
        node = (out["properties"]["rows"]["items"]["properties"]
                ["shot_appearance_ids"])
        assert "maxItems" not in node
        assert node["items"]["enum"] == ["s1#1", "s2#1"]

    def test_bound_complete_against_an_empty_catalog_drops_to_unresolved(self):
        """★schema 로 닫아도 **코드에서도** 막힌다 — 두 겹이다. 행은 살되 결속은 0 · 사유 남김."""
        got = gc.resolve_rows(
            [self._model_row(sc.BOUND_COMPLETE, ["s1#1"])],
            chunk_id="c0", segment_ids=["scene-1"],
            segments=ep.segment_texts(), shot_catalog=[])
        r = got["rows"][0]
        assert r["shot_binding_status"] == sc.BIND_UNRESOLVED and r["shot_appearance_ids"] == []
        assert r["salvage_problems"][0]["kind"] == gc.Q_SHOT


class TestTheBVerifierActuallyExercisesItsAxes:
    """★★「비회귀」라고 쓰기 전에 **한 번도 안 돈 축**이 없는지 본다.

    실제로 그럴 뻔했다 — `part_of` 판정을 안 줘서 facet 결속이 **0** 이었는데
    「비회귀」라고 찍혔다. 0 은 「통과」가 아니라 「안 쟀다」다.
    """

    def test_it_reports_a_zero_axis_as_a_problem(self):
        from tools.grounding_audit import cc_b_verify as bv

        got = bv.verify(1)
        t = got["totals"]
        if t["episodes"] == 0:
            pytest.skip("실제 체크포인트가 없다")
        for name in ("registered", "bindings"):
            assert t[name] > 0, f"★{name} 축이 한 번도 안 돌았다"
        # ★★`debt` 는 이제 **0 이 정상**이다 (§2-6.5, 2026-09-01).
        #  `location_part` 가 제 갈래로 서기 전에는 그 갈래 전부가 빚이었다.
        #  0 을 「안 쟀다」로도, 「통과」로도 읽지 않기 위해 —
        #   ①facet 축이 **돌기는 했는지**는 `bindings + debt` 로 본다
        #   ②빚 갈래 넷이 실제로 잡히는지는 `test_facet_binding` 이 잰다
        #   ③빚이 있으면 **사유가 반드시 있어야** 한다(조용히 세어지지 않게)
        assert t["bindings"] + t["debt"] > 0, "★facet 축이 한 번도 안 돌았다"
        assert bool(t["debt"]) == bool(t["debt_reasons"]), (
            f"★빚 {t['debt']} 인데 사유가 {t['debt_reasons']} 다")
        assert t["provider_calls"] == 0

    def test_the_identity_axes_use_the_production_callable(self):
        """★★지문 축이 **도구 안에만** 있으면 자기검사다 (Codex).

        실제 스텝·재개·config 소비자가 쓰는 것과 같은 callable 로 재는지 본다.
        """
        from app.modules.pipeline import grounding_chunk as gc
        from tools.grounding_audit import cc_b_verify as bv

        p = gc.build_chunk_payload(["scene-1"], ep.segment_texts(), "세계")
        assert bv._identity_axes(p) == [], "★신원 축이 어긋났다"
        import inspect

        src = inspect.getsource(bv._identity_axes)
        assert "gc.acquisition_identity" in src
        assert "gc.processing_stamp" in src


class TestTheEpisodeSelectorIsDeterministic:
    """★★내용을 보고 고르면 **체리피킹**이다 — 구조로만 고른다 (Codex)."""

    def test_it_picks_the_same_one_twice(self):
        from tools.grounding_audit import cc_c_preflight as cp

        c = cp.candidates()
        if len(c) < 2:
            pytest.skip("후보가 모자란다")
        # ★이 시험이 재는 것은 **거리 순위의 결정성**이지 이미지 고르기가
        #  아니다. 이미지 조건은 아래 별도 시험이 본다.
        a, _m = cp.choose(c, require_shot_images=False)
        b, _m2 = cp.choose(list(reversed(c)), require_shot_images=False)
        assert a["episode_id"] == b["episode_id"], "★목록 순서가 답을 바꿨다"

    def test_a_tie_breaks_on_the_episode_id(self):
        from tools.grounding_audit import cc_c_preflight as cp

        same = [{"episode_id": "b", "chunks": 4, "shots_median": 40,
                 "bytes_median": 9000},
                {"episode_id": "a", "chunks": 4, "shots_median": 40,
                 "bytes_median": 9000}]
        got, _m = cp.choose(same, require_shot_images=False)
        assert got["episode_id"] == "a"

    def test_candidates_need_two_chunks_and_world_facts(self):
        from tools.grounding_audit import cc_c_preflight as cp

        for c in cp.candidates():
            assert c["chunks"] >= 2, "★구간이 하나인 것이 후보에 들었다"

    def test_the_selector_never_reads_the_prose(self):
        """★소스에 원문 내용을 보는 자리가 없어야 한다."""
        import inspect

        from tools.grounding_audit import cc_c_preflight as cp

        src = inspect.getsource(cp.choose) + inspect.getsource(cp.candidates)
        for banned in ("in text", "startswith", "re.search", "lower()"):
            assert banned not in src, f"★내용을 읽는다: {banned}"


class TestGroundingWorldFactsKeepAllOwners:
    """★★★장소 전용 builder 로 다섯 갈래를 판단하면 **옷을 잃는다** (Codex C-2).

    실측(실제 CP 61건): `costume` 76 · `projection` 76 · `body_deformation`
    62 · `transformation` 36 — 전부 `PLACE_RULE_TYPES` 밖이라 장소 builder 가
    버린다. 인물·아웃룩은 그 규칙으로 판단해야 한다.
    """

    def _cp(self):
        return {"data": {
            "region": "어느 지역", "era": "어느 시기",
            "t2i_context": "맥락",
            "rules": [
                {"rule_type": "architecture", "visual_guideline": "건물 규칙"},
                {"rule_type": "costume", "visual_guideline": "옷 규칙"},
                {"rule_type": "body_deformation", "visual_guideline": "몸 규칙"},
                {"rule_type": "other", "visual_guideline": "그 밖 규칙"},
            ]}}

    def test_the_place_builder_drops_costume(self):
        """★positive control — 기존 builder 가 실제로 버리는지부터 본다."""
        from app.core.world_context import build_world_facts_block

        got = build_world_facts_block(self._cp())
        assert "건물 규칙" in got
        assert "옷 규칙" not in got, "★전제가 깨졌다 — 장소 builder 가 안 버린다"

    def test_the_grounding_builder_keeps_every_rule(self):
        from app.core.world_context import build_grounding_world_facts

        got = build_grounding_world_facts(self._cp())
        for x in ("건물 규칙", "옷 규칙", "몸 규칙", "그 밖 규칙",
                  "어느 지역", "어느 시기", "맥락"):
            assert x in got, f"★{x} 를 잃었다"

    def test_it_labels_the_rule_kind(self):
        """★코드가 뜻을 미리 재단하지 않는다 — 종류를 밝혀 하류가 보게 한다."""
        from app.core.world_context import build_grounding_world_facts

        got = build_grounding_world_facts(self._cp())
        assert "[costume]" in got and "[body_deformation]" in got

    def test_readiness_is_not_a_character_count(self):
        """★임의 글자수 문턱을 안 쓴다 — 지역/시대와 규칙이 있어야 한다."""
        from app.core.world_context import grounding_world_facts_ready

        assert grounding_world_facts_ready(self._cp())
        assert not grounding_world_facts_ready(
            {"data": {"region": "어느 지역", "rules": []}})
        assert not grounding_world_facts_ready(
            {"data": {"rules": [{"rule_type": "costume",
                                 "visual_guideline": "옷"}]}})
        assert not grounding_world_facts_ready(None)

    def test_the_preflight_uses_the_production_builder(self):
        """★preflight·C runner·D step 이 **같은 callable** 을 써야 한다."""
        import inspect

        from tools.grounding_audit import cc_c_preflight as cp

        src = inspect.getsource(cp)
        assert "build_grounding_world_facts" in src
        assert "grounding_world_facts_ready" in src
        assert "json.dumps(W" not in src, "★아직 JSON 직렬화로 보낸다"


class TestTheCoverageQueryIsScopedAndNotHardcoded:
    """★★앞 판은 `psql` subprocess + 코드에 박은 비밀번호 + SQL f-string 이었고
    **에피소드 범위를 안 걸었다** (Codex C-1)."""

    def test_no_hardcoded_credentials_or_subprocess_sql(self):
        """★**코드 줄만** 본다 — 「왜 고쳤나」를 적은 설명까지 잡으면,
        고친 까닭을 지워야 통과하는 시험이 된다."""
        import ast
        import inspect

        from tools.grounding_audit import cc_c_preflight as cp

        fn = ast.parse(inspect.getsource(cp.owner_coverage)).body[0]
        if (fn.body and isinstance(fn.body[0], ast.Expr)
                and isinstance(fn.body[0].value, ast.Constant)):
            fn.body = fn.body[1:]              # docstring 을 뺀다
        code = ast.unparse(fn)
        for banned in ("PGPASSWORD", "psql", "subprocess", "theroad_dev",
                       "f'select", 'f"select'):
            assert banned not in code, f"★아직 박혀 있다: {banned}"

    def test_it_binds_both_project_and_episode(self):
        import inspect

        from tools.grounding_audit import cc_c_preflight as cp

        src = inspect.getsource(cp.owner_coverage)
        assert "EntityEpisodeLink.episode_id == eid" in src, \
            "★에피소드 범위를 안 건다"
        assert "EntityCanon.project_id == pid" in src

    def test_a_failure_is_unknown_not_a_guess(self, monkeypatch):
        from tools.grounding_audit import cc_c_preflight as cp

        import app.core.database as dbmod

        monkeypatch.setattr(dbmod, "SessionLocal",
                            lambda: (_ for _ in ()).throw(RuntimeError("끊김")))
        got = cp.owner_coverage("p", "e")
        assert got["known"] is False and got["why"]


from tools.grounding_audit import cc_c_preflight as cp  # noqa: E402


class TestTheSelectorRequiresShotImages:
    """★★사람이 **눈으로** 볼 판이면 샷 그림이 있어야 한다 (사용자 2026-08-31).

    앞 판 선정기는 이 조건이 **없어서** 샷 이미지가 0장인 에피소드를 골랐다.
    그 결과 검토 화면이 글만 남았고 「이 대상이 저 샷에 있나」를 샷 설명 글로만
    보게 됐다 — 시각 검토가 성립하지 않는다.
    """

    CANDS = [
        {"episode_id": "with-img", "project_id": "p", "chunks": 4,
         "shots_median": 40, "bytes_median": 8000, "bundles": [[1]]},
        {"episode_id": "no-img", "project_id": "p", "chunks": 4,
         "shots_median": 40, "bytes_median": 8000, "bundles": [[1]]},
    ]

    def test_it_only_picks_from_episodes_that_have_images(self, monkeypatch):
        monkeypatch.setattr(cp, "shot_image_counts",
                            lambda: {"with-img": 75, "no-img": 0})
        got, mid = cp.choose(self.CANDS)
        assert got["episode_id"] == "with-img"
        assert got["shot_images"] == 75
        assert mid["candidates_with_shot_images"] == 1

    def test_it_stops_instead_of_picking_a_blind_one(self, monkeypatch):
        """★**아무거나 안 고른다** — 사람이 볼 것이 없는 판을 「됐다」로
        넘기면 검토가 헛돈다."""
        monkeypatch.setattr(cp, "shot_image_counts", lambda: {})
        with pytest.raises(LookupError, match="샷 이미지가 있는 후보"):
            cp.choose(self.CANDS)

    def test_the_count_comes_from_the_shot_link_not_the_episode(self):
        """★`episode_id` 로만 세면 평면도·다른 단계 산출까지 세어진다.

        실제로 그 함정에 빠졌다 — canary 에피소드는 `episode_id` 기준 34장인데
        **샷에 걸린 것은 0장**이었다.
        """
        import inspect

        src = inspect.getsource(cp.shot_image_counts)
        assert "ia.still_id = ss.id" in src, "★샷으로 안 잇는다"

    def test_a_db_failure_is_not_read_as_no_images(self, monkeypatch):
        """★못 읽은 것을 「없다」로 읽으면 멀쩡한 후보가 다 걸러진다."""
        def _boom():
            raise RuntimeError("DB 죽음")

        monkeypatch.setattr(cp, "shot_image_counts", _boom)
        with pytest.raises(RuntimeError, match="DB 죽음"):
            cp.choose(self.CANDS)
