"""갈래 → 참조 자리 **중앙 투영**. ★유료 0.

Codex 확정 (2026-09-01) — 「location/location_part 때문에 새 reference kind 나
새 슬롯을 만들지 마십시오. 둘 다 기존 kind=background 로 보내되 역할·계보를
분리합니다.」 그리고 「일곱 군데 손목록을 각각 늘리지 말고 중앙 투영에서
파생시키십시오.」
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_entity_contract as ec
from app.modules.pipeline import grounding_reference_bundle as rb


class TestItMakesNoNewSlot:
    def test_both_place_lanes_go_to_the_existing_background_slot(self):
        assert ec.reference_kind_of_owner("location") == rb.BUNDLE_KIND
        assert ec.reference_kind_of_owner("location_part") == rb.BUNDLE_KIND
        assert set(ec.BACKGROUND_LANE_OWNERS) == {"location", "location_part"}

    def test_the_kinds_are_ones_production_already_accepts(self):
        """★값을 **손으로 짓지 않는다** — 이미 쓰는 것만 쓴다."""
        import ast
        import inspect

        from app.core import ref_contract_validator as rcv
        from app.services import scene_reference_service as srs

        phrase_kinds = set()
        for n in ast.walk(ast.parse(inspect.getsource(rcv))):
            if isinstance(n, ast.Assign) and any(
                    getattr(t, "id", "") == "_ALLOWED_KINDS" for t in n.targets):
                phrase_kinds = {e.value for e in n.value.elts}
        attach_kinds = set()
        for n in ast.walk(ast.parse(inspect.getsource(srs))):
            if (isinstance(n, ast.Call)
                    and ast.unparse(n.func).endswith("attached_meta.append")
                    and n.args and isinstance(n.args[0], ast.Tuple)
                    and isinstance(n.args[0].elts[0], ast.Constant)):
                attach_kinds.add(n.args[0].elts[0].value)
        known = phrase_kinds | attach_kinds
        assert known, "★production 에서 kind 를 하나도 못 읽었다"
        unknown = set(ec.REFERENCE_KIND_BY_OWNER.values()) - known
        assert unknown == set(), f"★production 이 모르는 kind 를 지었다: {unknown}"

    def test_no_owner_maps_to_a_place_of_its_own(self):
        """★갈래마다 슬롯을 만들면 안 된다 — 다섯 갈래가 **넷** 자리로 간다."""
        assert len(set(ec.REFERENCE_KIND_BY_OWNER.values())) == 4


class TestTheProjectionCoversEveryLane:
    def test_all_five_owners_are_projected(self):
        for o in ec.MATERIALIZABLE_OWNER_TYPES:
            assert ec.reference_kind_of_owner(o), f"★{o} 가 투영 밖이다"
        ec.assert_projection_covers_owners()

    def test_an_unknown_owner_gets_nothing(self):
        assert ec.reference_kind_of_owner("무엇인가") is None
        assert ec.reference_kind_of_owner("") is None

    def test_it_stops_when_a_lane_is_added_without_a_place(self, monkeypatch):
        """★양성 대조 — 갈래를 늘리고 투영을 안 적으면 **선다**."""
        monkeypatch.setattr(ec, "MATERIALIZABLE_OWNER_TYPES",
                            ec.MATERIALIZABLE_OWNER_TYPES + ("새갈래",))
        with pytest.raises(ValueError, match="투영에 없다"):
            ec.assert_projection_covers_owners()


class TestTheGateIsOpenAndDerived:
    """★★**뒤집은 시험** (2026-09-01 D 활성화).

    앞에는 「아직 둘뿐이다」를 잠갔다 — 미리 넓히면 새 producer 없이 옛 유료
    경로만 켜지기 때문이었다(2026-08-31 실제 사고). 이번에는 producer·판별·
    중앙 조사기와 **같은 커밋**에서 열었고, 하나라도 빠지면
    `grounding_activation_contract` 가 조립 자리에서 세운다.
    """

    def test_the_open_set_is_the_whole_table(self):
        assert set(ec.REFERENCE_SUPPORTED_OWNERS) == set(
            ec.MATERIALIZABLE_OWNER_TYPES)
        assert set(ec.REFERENCE_SUPPORTED_OWNERS) == set(
            ec.REFERENCE_KIND_BY_OWNER)

    def test_it_is_derived_not_hand_written(self):
        """★손으로 다시 적으면 갈래가 늘 때 한쪽만 고쳐진다."""
        import ast
        import inspect

        src = inspect.getsource(ec)
        for n in ast.walk(ast.parse(src)):
            if (isinstance(n, ast.AnnAssign)
                    and getattr(n.target, "id", "") ==
                    "REFERENCE_SUPPORTED_OWNERS"):
                assert isinstance(n.value, ast.Name), \
                    "★갈래를 손으로 적었다 — 표에서 파생해야 한다"

    def test_a_place_id_now_makes_a_reference(self):
        for sid, owner in (("L01", "location"), ("LP01", "location_part")):
            assert ec.owner_of_final_id(sid) == owner
            assert ec.reference_owner_of(sid) == owner

    def test_a_non_identity_string_still_makes_none(self):
        """★음성 대조 — 열었다고 아무 글자나 지나가면 안 된다."""
        for junk in ("Pfoo", "", "LP", "무엇인가"):
            assert ec.reference_owner_of(junk) is None

    def test_what_is_open_must_be_projected(self):
        for o in ec.REFERENCE_SUPPORTED_OWNERS:
            assert ec.reference_kind_of_owner(o)


class TestTheReferenceGatesUseTheContract:
    """★참조를 만들지 말지 **정하는 자리**만 본다.

    ★★앞 판에 이 시험을 저장소 전체로 걸었더니 34곳이 걸렸다 — 샷 의존·
    아웃룩 동기화처럼 **참조 판단과 무관한** 자리까지 잡은 것이다. 지적 범위
    밖을 래칫으로 끌어들이면 고칠 수 없는 시험이 된다.
    """

    GATES = ("episode_reference_policy_step.py", "render_prompt_card.py")

    def _tree(self, name):
        import ast
        from pathlib import Path

        root = Path(__file__).resolve().parents[2] / "app"
        f = next(root.rglob(name))
        return ast.parse(f.read_text(encoding="utf-8")), f

    def test_both_gates_ask_the_contract(self):
        import ast

        for name in self.GATES:
            tree, f = self._tree(name)
            calls = {ast.unparse(n.func) for n in ast.walk(tree)
                     if isinstance(n, ast.Call)}
            assert any(c.endswith("reference_owner_of") for c in calls), \
                f"★{f.name} 이 계약에 안 묻는다"

    def test_neither_gate_hand_compares_a_prefix(self):
        """★`LP01` 이 `"L"` 로도 시작하고 `Pfoo` 도 `"P"` 로 시작한다."""
        import ast

        bad = []
        for name in self.GATES:
            tree, f = self._tree(name)
            for n in ast.walk(tree):
                if not (isinstance(n, ast.Call)
                        and ast.unparse(n.func).endswith(".startswith")
                        and n.args):
                    continue
                arg = n.args[0]
                vals = ([arg.value] if isinstance(arg, ast.Constant)
                        else [e.value for e in getattr(arg, "elts", [])
                              if isinstance(e, ast.Constant)])
                if {v for v in vals if isinstance(v, str)} & {"C", "L", "P",
                                                              "LP", "O"}:
                    bad.append(f"{f.name}:{n.lineno}")
        assert bad == [], f"★접두를 손으로 견준다: {bad}"

    def test_a_composite_id_is_not_a_bare_owner(self):
        """★기록 — `episode_reference_policy._is_character_subject` 는 아직
        `startswith("C")` 라 복합 ID 와 쓰레기 글자를 인물로 받는다.

        계약으로 바꾸면 **동작이 달라진다**(복합을 인물 대상으로 세던 것이
        빠진다). 그래서 조용히 안 고치고 여기 남긴다 — D cutover 에서
        아웃룩 갈래를 열 때 같이 본다.
        """
        assert ec.owner_of_final_id("C01O02") is None
        assert ec.owner_of_final_id("Cfoo") is None
        assert "C01O02".startswith("C") and "Cfoo".startswith("C")


class TestOnePartOfListTwoConsumers:
    """★★★producer 의 `part_of` 는 **한 벌인데 소비자가 둘**이다. 유료 0.

    실측 (2026-09-02 유료 재개) — 모델이 실제로 낸 것:

        {"part": "O01", "whole": "C01"}   아웃룩이 그 인물의 것
        {"part": "O02", "whole": "C03"}
        {"part": "P04", "whole": "P03"}   소품이 소품의 부분

    `grounding_facet_binding` 은 그 짝을 **써야** 하고, DB `relation_fact` 는
    `RELATION_OWNER_PAIRS["part_of"]`(장소부분→장소)만 받는다. 앞 판은 좁은
    쪽이 받아서 **주행을 세웠다**.
    """

    LIVE = [{"part": "O01", "whole": "C01"},
            {"part": "O02", "whole": "C03"},
            {"part": "P04", "whole": "P03"},
            {"part": "LP01", "whole": "L01"}]

    def test_only_the_contracted_pair_goes_to_the_db(self):
        from app.modules.pipeline import grounding_relation_projection as rp

        got = rp.select_db_part_of(self.LIVE)
        assert got["for_db"] == [{"part": "LP01", "whole": "L01"}]

    def test_the_others_are_kept_with_a_reason(self):
        """★조용히 버리지 않는다 — 갈래와 까닭이 남는다."""
        from app.modules.pipeline import grounding_relation_projection as rp

        got = rp.select_db_part_of(self.LIVE)
        owners = {tuple(r["owners"]) for r in got["not_for_db"]}
        assert owners == {("outlook", "character"), ("prop", "prop")}
        assert all(r["why"] for r in got["not_for_db"])

    def test_the_supported_set_comes_from_the_contract(self):
        """★목록을 여기 다시 안 적는다."""
        from app.modules.pipeline import grounding_relation_projection as rp
        from app.modules.pipeline.grounding_entity_contract import (
            RELATION_OWNER_PAIRS)

        assert RELATION_OWNER_PAIRS["part_of"] == (("location_part",
                                                    "location"),)
        assert rp.select_db_part_of([{"part": "LP01", "whole": "L01"}]
                                    )["for_db"]

    def test_the_live_shape_no_longer_stops_the_run(self):
        """★★★음성 대조 — 앞 판은 여기서 **주행이 죽었다**."""
        from app.modules.pipeline import grounding_relation_projection as rp

        with pytest.raises(rp.RelationProjectionError):
            rp.project_part_of(self.LIVE)          # ★앞 판 그대로면 선다
        got = rp.select_db_part_of(self.LIVE)
        rows = rp.project_part_of(got["for_db"])   # ★고른 뒤에는 지나간다
        assert len(rows) == 1

    def test_the_step_selects_before_projecting(self):
        import inspect

        from app.core.steps.entity_relation_step import EntityRelationStep

        src = inspect.getsource(EntityRelationStep._chunk_projection)
        assert src.index("select_db_part_of") < src.index("project_part_of(")
        assert "part_of_not_for_db" in src
