"""판별 장부 → 중앙 획득 입력. ★유료 0 · 사지도 만들지도 않는다.

`acquire_one` 은 두 자리를 **따로** 받는다 — 검색 **저작**용 `target` 과 종류
**판정**용 `narrow_hint`. C(c) 판정에는 두 쪽 재료가 같이 들어 있어서, 아무렇게나
넘기면 `visual_brief` 가 판정으로 가고 VLM 이 「무엇인가」 대신 「어떻게
생겼나」로 답한다. 그러면 고른 것은 **종류가 맞는 사진**이 아니라 **글과 닮은
사진**이다.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_acquisition_adapter as aa
from app.modules.pipeline import grounding_entity_contract as ec

FULL = {"coarse_type_label": "두루마기", "visual_brief": "발목까지 오는 긴 겉옷",
        "search_terms_native": ["두루마기", "긴 겉옷"],
        "language_lock_native": "ko", "hard_to_generate": True,
        "viewers_would_notice": True, "shot_binding_status": "bound_complete",
        "shot_appearance_ids": ["s1#1"]}


def _row(payload=None, rsid="rs1", status="bound", screen="obligation"):
    return {"research_subject_id": rsid, "status": status, "screen": screen,
            "grounding_producer_payload": dict(
                FULL if payload is None else payload),
            "source_evidence": {"source_quote": "원문 문장"}}


class TestTheTwoSidesReadDifferentFields:
    """★★★**읽는 자리**로 잰다 — 값으로 견주면 안 된다.

    producer 가 같은 낱말을 종류 이름과 검색어에 **둘 다** 쓸 수 있다. 값으로
    보면 멀쩡한 것이 「샜다」로 잡힌다 — 그것이 「글자로 뜻을 판단」하는 자리다.
    """

    def test_the_search_side_reads_only_what_it_may(self):
        """★2026-09-02 — 금지는 **한 방향**이다.

        막아야 하는 것은 **심판이 `visual_brief` 를 보는 것**이다. 검색
        저작기가 부류 이름을 쓰는 것은 `build_brief_user` 가 **요구**한다 —
        앞 판은 검색 쪽에 네 칸만 줘서 실제 첫 대상이 저작 전에
        `BriefInputsMissing` 으로 죽었다.
        """
        assert aa.reads_only(aa.search_target, ec.SEARCH_FIELDS) == []
        assert "coarse_type_label" in ec.SEARCH_FIELDS
        assert "visual_brief" not in ec.JUDGE_FIELDS, (
            "★심판이 생김새를 보면 안 된다 — 그 금지가 이 갈림의 뜻이다")

    def test_the_judge_side_never_reads_a_search_field(self):
        assert aa.reads_only(aa.judge_hint, ec.JUDGE_FIELDS) == []

    def test_the_same_word_on_both_sides_is_fine(self):
        """★음성 대조 — 같은 낱말이어도 **안 걸린다**."""
        row = _row({"coarse_type_label": "두루마기",
                    "visual_brief": "두루마기",
                    "search_terms_native": ["두루마기"],
                    "language_lock_native": "ko"})
        assert aa.search_target(row)["directive_native"] == "두루마기"
        assert aa.judge_hint(row) == "두루마기"

    def test_the_guard_would_catch_a_real_leak(self):
        """★양성 대조 — **금지된 방향**을 읽는 함수는 잡힌다.

        ★2026-09-02: 막는 방향이 뒤집혔다. 이제 위험한 것은 **심판이
        `visual_brief` 를 읽는 것**이다 — 검색이 부류 이름을 쓰는 것은
        저작기가 요구한다.
        """
        def leaky(row):
            p = row.get("grounding_producer_payload") or {}
            return p.get("visual_brief")

        assert aa.reads_only(leaky, ec.JUDGE_FIELDS) == ["visual_brief"]


class TestWhatGoesOutToTheSearchWriter:
    def test_it_carries_what_the_writer_reads(self):
        """★2026-09-02 — 네 칸이던 것이 여덟이 됐다.

        검색 **지시문 저작기**가 `owner_type`·`coarse_type_label`·
        `surface_form`·`visual_brief` 를 읽는다. 앞 판은 그것을 안 실어
        실제 첫 대상이 저작 전에 `BriefInputsMissing` 으로 죽었다
        (Codex BLOCK). ★심판에게는 여전히 부류 이름 **하나만** 간다.
        """
        got = aa.search_target(_row())
        assert sorted(got) == [
            "coarse_type_label", "directive_native", "language_lock_native",
            "owner_type", "source_quotes", "subject_id", "surface_form",
            "terms_native", "visual_brief"]
        assert got["terms_native"] == FULL["search_terms_native"]
        assert got["language_lock_native"] == "ko"

    def test_the_judge_still_gets_only_the_kind(self):
        """★음성 대조 — 심판 쪽은 **안 넓어졌다**."""
        assert aa.judge_hint(_row()) == FULL["coarse_type_label"]

    def test_no_directive_and_no_terms_refuses(self):
        """★빈 질의로 **안 산다** — 아무거나 온다."""
        with pytest.raises(aa.MissingSearchDirective):
            aa.search_target(_row({"coarse_type_label": "두루마기"}))

    def test_terms_alone_are_enough(self):
        got = aa.search_target(_row({"search_terms_native": ["두루마기"]}))
        assert got["terms_native"] == ["두루마기"]


class TestWhatGoesOutToTheJudge:
    def test_it_is_the_coarse_label_only(self):
        assert aa.judge_hint(_row()) == "두루마기"

    def test_a_missing_label_is_none_not_a_substitute(self):
        """★★없다고 `visual_brief` 로 **대신하지 않는다** — 그것이 금지 자리다."""
        row = _row({"visual_brief": "발목까지 오는 긴 겉옷",
                    "search_terms_native": ["긴 겉옷"]})
        assert aa.judge_hint(row) is None


class TestNothingVanishesAndNothingIsBought:
    def test_a_row_without_search_material_is_recorded_not_dropped(self):
        got = aa.acquisition_inputs([_row(), _row({"coarse_type_label": "가"},
                                                  rsid="rs2")])
        assert [t["target"]["subject_id"] for t in got["targets"]] == ["rs1"]
        assert [s["research_subject_id"] for s in got["skipped"]] == ["rs2"]
        assert len(got["targets"]) + len(got["skipped"]) == 2

    def test_the_evidence_rides_along_for_the_review_screen(self):
        got = aa.acquisition_inputs([_row()])
        assert got["targets"][0]["source_evidence"]["source_quote"] == "원문 문장"

    def test_the_module_buys_nothing(self):
        """★검색도 다운로드도 판정도 **안 한다** — 모양만 만든다.

        ★이름 앞글자로 막으면 **제 함수 이름**(`search_target`)이 걸린다.
        실제로 걸렸다. 그래서 **무엇을 부르는지**(import)를 본다.
        """
        import ast
        import inspect

        tree = ast.parse(inspect.getsource(aa))
        pulled = set()
        for n in ast.walk(tree):
            if isinstance(n, ast.ImportFrom):
                pulled.add(str(n.module or ""))
                pulled |= {a.name for a in n.names}
            elif isinstance(n, ast.Import):
                pulled |= {a.name for a in n.names}
        banned = {"reference_acquisition_rounds", "era_research",
                  "search_grounded_ref", "coarse_type_pick",
                  "acquire_one", "acquire_from_plan_cached"}
        assert not (pulled & banned), sorted(pulled & banned)

    def test_nothing_wires_it_yet(self):
        """★배선은 **D cutover 몫**이다."""
        import ast
        from pathlib import Path

        root = Path(__file__).resolve().parents[2] / "app"
        hits = []
        for f in root.rglob("*.py"):
            if f.name == "grounding_acquisition_adapter.py":
                continue
            try:
                tree = ast.parse(f.read_text(encoding="utf-8"))
            except SyntaxError:                     # noqa: PERF203
                continue
            for n in ast.walk(tree):
                if (isinstance(n, ast.ImportFrom)
                        and "grounding_acquisition_adapter" in str(
                            n.module or "")):
                    hits.append(f.name)
        assert hits == [], f"★벌써 배선됐다: {hits}"


class TestItFitsWhatAcquireOneExpects:
    def test_the_target_keys_match_the_consumer(self):
        """★★받는 쪽이 실제로 읽는 칸과 **같은지** 본다."""
        import ast
        import inspect
        import textwrap

        from app.modules.pipeline import reference_acquisition_rounds as rr

        src = textwrap.dedent(inspect.getsource(rr.acquire_one))
        tree = ast.parse(src)
        read = {n.args[0].value for n in ast.walk(tree)
                if isinstance(n, ast.Call)
                and getattr(n.func, "attr", "") == "get"
                and n.args and isinstance(n.args[0], ast.Constant)}
        # ★★소비자가 **둘**이다 (2026-09-02) — `acquire_one` 이 읽는 칸과
        #  검색 지시문 **저작기**가 읽는 칸. 저작기 것은 그 함수에서 센다.
        from app.modules.pipeline import grounding_search_brief as gsb

        wsrc = textwrap.dedent(inspect.getsource(gsb.outbound))
        writer_reads = {n.args[0].value for n in ast.walk(ast.parse(wsrc))
                        if isinstance(n, ast.Call)
                        and getattr(n.func, "attr", "") == "get"
                        and n.args and isinstance(n.args[0], ast.Constant)}
        # ★★소비자가 **넷**이다 (2026-09-03) — 뼈대 저작 입력(`skeleton_search_input`)과
        #  장부 이관용 옛 신원(`_legacy_outbound`)도 대상 칸을 읽는다. 문장 칸
        #  (surface_form · visual_brief)은 이제 옛 신원 alias 에만 쓰인다.
        def _reads(fn):
            src_ = textwrap.dedent(inspect.getsource(fn))
            return {n.args[0].value for n in ast.walk(ast.parse(src_))
                    if isinstance(n, ast.Call)
                    and getattr(n.func, "attr", "") == "get"
                    and n.args and isinstance(n.args[0], ast.Constant)}
        from app.modules.pipeline import grounding_target_research as gtr
        # ★다섯째 소비자 — 텍스트 조사 저작기가 원고 근거(`source_quotes`)를 읽는다 (2026-09-03)
        consumer_reads = (read | writer_reads | _reads(gsb.skeleton_search_input)
                          | _reads(gsb._legacy_outbound) | _reads(gtr.make_writer))
        made = set(aa.search_target(_row()))
        assert made <= consumer_reads | {"subject_id"}, \
            sorted(made - consumer_reads)
        assert {"directive_native", "terms_native"} <= read
        # ★뼈대 저작 입력은 부류·낱말만 읽고 **문장은 안 읽는다**; 문장은 옛 신원 alias 몫
        skel = _reads(gsb.skeleton_search_input)
        assert {"coarse_type_label", "terms_native"} <= skel
        assert not ({"surface_form", "visual_brief"} & skel), sorted(skel)
        assert {"surface_form", "visual_brief"} <= _reads(gsb._legacy_outbound)


class TestTheAdapterKeepsTheTwoGatesItself:
    """★★★NON-BLOCK (Codex · 09-01) — adapter 가 임의 줄을 받으면 앞의 두 문을
    **우회한다**. 그러면 「참조 불필요」로 판정된 것도 모양만 만들어져 나간다."""

    @pytest.mark.parametrize("screen", ["not_target", "unresolved", "capped"])
    def test_a_non_obligation_is_refused(self, screen):
        with pytest.raises(aa.NotBuyable):
            aa.assert_buyable(_row(screen=screen))
        got = aa.acquisition_inputs([_row(screen=screen)])
        assert got["targets"] == []
        assert len(got["refused"]) == 1

    def test_an_unbound_row_is_refused(self):
        with pytest.raises(aa.NotBuyable):
            aa.assert_buyable(_row(status="unresolved"))

    def test_a_bound_obligation_passes(self):
        aa.assert_buyable(_row())
        got = aa.acquisition_inputs([_row()])
        assert len(got["targets"]) == 1 and got["refused"] == []

    def test_the_verdict_names_come_from_the_one_place(self):
        """★판정 이름을 **여기서 다시 적지 않는다** — 값도 손으로 안 적는다.

        ★이제 그 한 곳은 갈래 중립 계약이다(Codex BLOCK · 09-01).
        """
        import ast
        import inspect

        from app.modules.pipeline import grounding_acquisition_ledger as gl

        tree = ast.parse(inspect.getsource(aa.assert_buyable))
        used = {ast.unparse(n) for n in ast.walk(tree)
                if isinstance(n, ast.Attribute)} | {
            n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
        assert "gl.RESOLVED" in used and "SCREEN_OBLIGATION" in used
        # ★값을 손으로 적으면 계약을 바꿔도 여기만 안 따라온다
        literals = {n.value for n in ast.walk(tree)
                    if isinstance(n, ast.Constant) and isinstance(n.value, str)}
        assert gl.RESOLVED not in literals, "★상태 값을 글자로 적었다"

    def test_nothing_is_lost_in_the_accounting(self):
        rows = [_row(rsid="a"), _row(rsid="b", screen="not_target"),
                _row({"coarse_type_label": "가"}, rsid="c")]
        got = aa.acquisition_inputs(rows)
        assert (len(got["targets"]) + len(got["skipped"])
                + len(got["refused"])) == 3


class TestTheOneDoorFromLedgerToBuyer:
    """★★★부르는 쪽이 `acquisition_targets` 를 건너뛰면 두 구매 문을
    **우회한다** (Codex NON-BLOCK · 09-01). 문은 두 겹이되 **입구는 하나**."""

    def _ledger(self, screens=("obligation",), bind_ok=True):
        from app.modules.pipeline import grounding_outlook_binding as ob
        from app.modules.pipeline.grounding_entity_contract import (
            FACET_BINDING, PRODUCER_PAYLOAD)

        rows = []
        for i, sc in enumerate(screens):
            rows.append({
                "research_subject_id": f"rs{i}", "owner_type": "outlook",
                "screen": sc,
                FACET_BINDING: {"local_id": "c0#2", "final_id": "O01",
                                "owner_type": "outlook",
                                "parent_local_id": "c0#1",
                                "parent_owner_type": "character",
                                "parent_final_id": "C01"},
                PRODUCER_PAYLOAD: {"coarse_type_label": "두루마기",
                                   "visual_brief": "긴 겉옷",
                                   "search_terms_native": ["두루마기"],
                                   "language_lock_native": "ko"},
                "source_evidence": {"occurrences": [{"source_span": {
                    "segment_id": "scene-1" if bind_ok else "scene-9"}}]}})
        p3 = {"outlooks": [{"outlook_id": "O01"}],
              "scene_assignments": [{"scene_index": 1, "assignments": [
                  {"character_id": "C01", "outlook_id": "O01"}]}]}
        return ob.bind(rows, p3)

    def test_only_an_obligation_reaches_the_buyer(self):
        got = aa.inputs_from_ledger(self._ledger(
            ("obligation", "not_target", "capped")))
        assert len(got["targets"]) == 1
        assert got["not_applicable"] == 2
        assert got["auto_completed"] == 0

    def test_an_unbound_obligation_auto_completes(self):
        got = aa.inputs_from_ledger(self._ledger(("obligation",),
                                                 bind_ok=False))
        assert got["targets"] == [] and got["auto_completed"] == 1

    def test_every_ledger_row_is_accounted_for(self):
        got = aa.inputs_from_ledger(self._ledger(
            ("obligation", "not_target", "unresolved")))
        assert (len(got["targets"]) + len(got["skipped"])
                + len(got["refused"]) + got["auto_completed"]
                + got["not_applicable"]) == got["ledger_rows"]

    def test_the_target_carries_the_split_fields(self):
        got = aa.inputs_from_ledger(self._ledger())
        one = got["targets"][0]
        assert one["target"]["directive_native"] == "긴 겉옷"
        assert one["narrow_hint"] == "두루마기"
        # ★2026-09-02 — 부류 이름은 이제 **검색 저작기도** 읽는다.
        #  갈림의 뜻은 「심판이 생김새를 보면 안 된다」한 방향이다.
        assert one["target"]["coarse_type_label"] == "두루마기"
        assert one["target"]["visual_brief"] == "긴 겉옷"


class TestTheFieldsHaveAProducer:
    """★★★오늘 세 번 한 실수를 **미리** 막는다 — 받는 쪽만 짓고 내는 쪽이 없던 것.

    `PRODUCER_PAYLOAD_FIELDS` 는 **모델이 실제로 내는** 칸이어야 한다. 스키마에
    없으면 그 칸은 영원히 비고, 그 위에 세운 계약은 **닿지 않는 길**이다.
    """

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

        sch = gc.load_schema("chunk_schema.json")
        props = ((((sch.get("schema") or sch).get("properties") or {})
                  .get("rows") or {}).get("items", {}).get("properties", {}))
        missing = [k for k in ec.PRODUCER_PAYLOAD_FIELDS if k not in props]
        assert missing == [], (
            f"★{missing} 를 내는 곳이 없다 — 받는 쪽만 지으면 닿지 않는 길이다")

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

        sch = gc.load_schema("chunk_schema.json")
        props = ((((sch.get("schema") or sch).get("properties") or {})
                  .get("rows") or {}).get("items", {}).get("properties", {}))
        for k in tuple(ec.JUDGE_FIELDS) + tuple(ec.SEARCH_FIELDS):
            assert k in props, f"★{k} 를 내는 곳이 없다"


class TestTheCentralDoorIsOwnerNeutral:
    """★★Codex BLOCK (2026-09-01) — 중앙 입구가 outlook 전용이면
    location·location_part 가 **정상 경로로 들어올 길이 없다**."""

    def test_the_adapter_does_not_import_the_outlook_binder(self):
        import ast
        import inspect

        from app.modules.pipeline import grounding_acquisition_adapter as aa
        from app.modules.pipeline import grounding_central_acquisition as ca

        for mod in (aa, ca):
            tree = ast.parse(inspect.getsource(mod))
            for n in ast.walk(tree):
                mods = []
                if isinstance(n, ast.ImportFrom):
                    mods = [str(n.module or "")]
                elif isinstance(n, ast.Import):
                    mods = [a.name for a in n.names]
                assert not [m for m in mods
                            if "grounding_outlook_binding" in m], \
                    f"★{mod.__name__} 이 아직 outlook 결속에 묶여 있다"

    def test_the_ledger_contract_lives_in_exactly_one_module(self):
        """★한 벌만 정의한다 — 두 곳이면 한쪽만 고쳐진다."""
        from app.modules.pipeline import grounding_acquisition_ledger as gl
        from app.modules.pipeline import grounding_outlook_binding as ob

        for name in ("lane_of", "accounting", "acquisition_targets",
                     "auto_completed", "known_screens", "unresolved_outcome"):
            assert getattr(ob, name) is getattr(gl, name), \
                f"★{name} 이 두 벌이다"
        assert ob.BOUND is gl.RESOLVED and ob.STATUSES is gl.STATUSES
