"""facet 결속 — ★유료 0. 이름으로 붙이지 않는다.

Codex: 「다섯 갈래가 **산출**됐다는 유료 수치만으로 완료를 주장하지 않는다.
못 붙이면 durable debt 이고 base 로 우회 등록하지 않는다.」
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_chunk_merge as cm
from app.modules.pipeline import grounding_facet_binding as fb
from tests.grounding.fixtures import synthetic_episode as ep

SEGS = ep.segment_texts()


def _row(lid, owner, scene, word, shots=None, **over):
    d = {"local_id": lid, "owner_type": owner, "surface_form": word,
         "occurrences": [{"source_span": ep.span_of(scene, word),
                          "source_quote": word}],
         "shot_binding_status": "bound_complete",
         "shot_appearance_ids": list(shots or [f"s{scene}#1"]),
         "hard_to_generate": False, "viewers_would_notice": False}
    d.update(over)
    return d


class TestTheOwnerPairIsTheRelation:
    """★★새 관계를 안 만든다 — **갈래 조합**이 이미 관계를 말한다."""

    def test_the_table_is_the_contract(self):
        assert fb.parent_of("outlook") == "character"
        assert fb.parent_of("location_part") == "location"
        assert fb.parent_of("prop") is None
        assert not fb.is_facet("character")

    def test_a_wrong_pair_is_refused(self):
        with pytest.raises(AssertionError, match="여야 한다"):
            fb.assert_owner_pairs([{"owner_type": "outlook",
                                    "parent_owner_type": "location"}])


class TestOutlookBindsToItsCharacter:
    """★★★자식도 **등록돼야** 붙는다 (Codex 2026-08-31).

    앞 판 시험은 **미등록 outlook 을 성공 결속으로 기대**했다 — 틀린 동작을
    잠근 것이다. `final_id` 가 없으면 `character_outlook(character_id,
    outlook_id)` 에 넣을 ID 자체가 없다.
    """

    def _rows(self, child_registered=True):
        """부모는 두 샷(반복 축), 자식은 두 축으로 등록시킨다."""
        parent = _row("c0#0", "character", 1, "운전사",
                      shots=["s1#1", "s4#1"])
        parent["occurrences"].append(
            {"source_span": ep.span_of(4, "운전사", 1),
             "source_quote": "운전사"})
        child = _row("c0#1", "outlook", 3, "제복 상의",
                     hard_to_generate=child_registered,
                     viewers_would_notice=child_registered)
        return [parent, child]

    def _run(self, rows, decisions=()):
        got = cm.reduce_episode(rows, list(decisions), segments=SEGS)
        return got, fb.bind(got["rows"], got["part_of"], got["registered"])

    def test_it_binds_when_both_sides_are_registered(self):
        got, out = self._run(self._rows(), [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF}])
        assert len(out["bindings"]) == 1, out["debt"]
        b = out["bindings"][0]
        assert b["local_id"] == "c0#1" and b["parent_local_id"] == "c0#0"
        assert b["final_id"] == got["final_ids"]["c0#1"]
        assert b["parent_final_id"] == got["final_ids"]["c0#0"]
        fb.assert_owner_pairs(out["bindings"])

    def test_an_unregistered_child_is_debt_not_a_binding(self):
        """★★★Codex 반례 ③ — `final_id: None` 인데 성공으로 냈다."""
        _got, out = self._run(self._rows(child_registered=False), [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF}])
        assert out["bindings"] == [], "★붙일 자기 ID 가 없는데 성공으로 냈다"
        d = out["debt"][0]
        assert d["reason"] == fb.DEBT_FACET_NOT_REGISTERED
        assert d["final_id"] is None

    def test_without_a_relation_it_is_debt_not_a_guess(self):
        """★관계가 없으면 **이름으로 짐작하지 않는다** — 빚이다."""
        _got, out = self._run(self._rows(), [])
        assert out["bindings"] == []
        assert out["debt"][0]["reason"] == fb.DEBT_NO_PARENT

    def test_the_same_relation_twice_is_not_two_parents(self):
        """★★★Codex 반례 ④ — 같은 사실의 **중복**이지 부모 둘이 아니다."""
        got, out = self._run(self._rows(), [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF},
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF}])
        assert len(out["bindings"]) == 1, out["debt"]
        assert out["relation_duplicates"], "★중복이 감사에 안 남았다"

    def test_two_different_parents_is_still_debt(self):
        """★positive control — **서로 다른** 부모 둘이면 그대로 빚이다."""
        rows = self._rows()
        other = _row("c0#2", "character", 1, "승객", shots=["s1#1", "s4#1"])
        other["occurrences"].append(
            {"source_span": ep.span_of(4, "운전사", 1),
             "source_quote": "운전사"})
        got = cm.reduce_episode(rows + [other], [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF},
            {"remove_local_id": "c0#1", "keep_local_id": "c0#2",
             "relation": cm.REL_PART_OF}], segments=SEGS)
        out = fb.bind(got["rows"], got["part_of"], got["registered"])
        assert out["bindings"] == []
        d = out["debt"][0]
        assert d["reason"] == fb.DEBT_NO_PARENT
        assert len(d["parent_candidates"]) == 2

    def test_an_unregistered_parent_is_debt(self):
        """★부모가 아직 등록 안 됐으면 붙일 자리가 확정되지 않았다."""
        parent = _row("c0#0", "character", 1, "운전사")      # 한 샷 → 미등록
        child = _row("c0#1", "outlook", 3, "제복 상의",
                     hard_to_generate=True, viewers_would_notice=True)
        got = cm.reduce_episode([parent, child], [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF}], segments=SEGS)
        out = fb.bind(got["rows"], got["part_of"], got["registered"])
        assert out["bindings"] == []
        assert out["debt"][0]["reason"] == fb.DEBT_PARENT_NOT_REGISTERED


class TestLocationPartBindsNow:
    """★★★**뒤집힌 시험**이다 (§2-6.5, 2026-09-01).

    앞에는 「`entity_canon.entity_type` 에 `location_part` 가 없으니 통째로
    durable debt」을 잠그고 있었다. 막고 있던 것은 **코드의 목록**이었지 DB 가
    아니다(그 칸은 `Text` 이고 check 제약이 없다). 목록을 열었으니 이제 **붙는
    다** — 다만 등록 문턱과 부모 조건은 그대로다. 지우지 않고 뒤집는다.
    """

    def test_the_canon_types_now_include_it(self):
        assert "location_part" in fb.CANON_TYPES
        assert set(fb.CANON_TYPES) == {"character", "location",
                                       "location_part", "outlook", "prop"}

    def test_a_registered_part_binds_to_its_registered_location(self):
        """★끝점 — 둘 다 등록되고 부모가 하나면 **붙는다**."""
        rows = [_row("c0#0", "location", 3, "대합실",
                     shots=["s3#1", "s3#2"]),
                _row("c0#1", "location_part", 3, "긴 걸상",
                     shots=["s3#1", "s3#2"])]
        got = cm.reduce_episode(rows, [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF}], segments=SEGS)
        out = fb.bind(got["rows"], got["part_of"], got["registered"])
        assert out["debt"] == [], out["debt"]
        b = out["bindings"][0]
        assert b["owner_type"] == "location_part"
        assert b["parent_owner_type"] == "location"
        # ★신원으로 붙는다 — 이름이 아니다
        assert b["final_id"].startswith("LP")
        assert b["parent_final_id"].startswith("L")
        assert not b["parent_final_id"].startswith("LP")

    def test_an_unregistered_part_is_still_debt(self):
        """★문턱은 그대로다 — 한 샷에만 나온 것은 안 붙는다."""
        rows = [_row("c0#0", "location", 3, "대합실",
                     shots=["s3#1", "s3#2"]),
                _row("c0#1", "location_part", 3, "긴 걸상")]
        got = cm.reduce_episode(rows, [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF}], segments=SEGS)
        out = fb.bind(got["rows"], got["part_of"], got["registered"])
        assert out["bindings"] == []
        assert out["debt"][0]["reason"] == fb.DEBT_FACET_NOT_REGISTERED

    def test_a_part_without_a_relation_is_still_debt(self):
        """★★관계 장부에 부모가 없으면 **이름으로 짐작해 안 붙인다**."""
        rows = [_row("c0#0", "location", 3, "대합실",
                     shots=["s3#1", "s3#2"]),
                _row("c0#1", "location_part", 3, "긴 걸상",
                     shots=["s3#1", "s3#2"])]
        got = cm.reduce_episode(rows, [], segments=SEGS)
        out = fb.bind(got["rows"], got["part_of"], got["registered"])
        assert out["bindings"] == []
        assert out["debt"][0]["reason"] == fb.DEBT_NO_PARENT

    def test_two_parents_refuse_instead_of_picking_one(self):
        """★Codex 조건 ① — **정확히 하나**여야 한다."""
        rows = [_row("c0#0", "location", 3, "대합실",
                     shots=["s3#1", "s3#2"]),
                _row("c0#2", "location", 3, "승강장",
                     shots=["s3#1", "s3#2"]),
                _row("c0#1", "location_part", 3, "긴 걸상",
                     shots=["s3#1", "s3#2"])]
        got = cm.reduce_episode(rows, [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF},
            {"remove_local_id": "c0#1", "keep_local_id": "c0#2",
             "relation": cm.REL_PART_OF}], segments=SEGS)
        out = fb.bind(got["rows"], got["part_of"], got["registered"])
        assert out["bindings"] == []
        assert out["debt"][0]["reason"] == fb.DEBT_NO_PARENT
        assert len(out["debt"][0]["parent_candidates"]) == 2

    def test_it_never_registers_as_a_plain_location(self):
        """★★base 갈래 **우회 등록 0건** — 이것이 계약의 핵심이다."""
        rows = [_row("c0#0", "location", 3, "대합실",
                     shots=["s3#1", "s3#2"]),
                _row("c0#1", "location_part", 3, "긴 걸상",
                     shots=["s3#1", "s3#2"])]
        got = cm.reduce_episode(rows, [
            {"remove_local_id": "c0#1", "keep_local_id": "c0#0",
             "relation": cm.REL_PART_OF}], segments=SEGS)
        for lid, rec in got["registered"].items():
            owner = next(r["owner_type"] for r in got["rows"]
                         if r["local_id"] == lid)
            if owner != "location_part" or not rec.get("final_id"):
                continue
            assert not rec["final_id"].startswith("L0"), (
                "★location 으로 우회 등록됐다")


class TestNothingIsBoundByName:
    def test_the_module_never_reads_surface_forms_for_binding(self):
        """★소스에 이름 비교가 없는지 — 결속은 **ID 와 갈래**로만 한다."""
        import inspect

        src = inspect.getsource(fb.bind)
        for banned in ("surface_form ==", "in surface", "startswith",
                       "re.", "lower()"):
            assert banned not in src, f"★이름으로 결속한다: {banned}"

    def test_base_owners_are_not_touched(self):
        rows = [_row("c0#0", "prop", 1, "가방"),
                _row("c0#1", "character", 1, "운전사")]
        got = cm.reduce_episode(rows, [], segments=SEGS)
        out = fb.bind(got["rows"], got["part_of"], got["registered"])
        assert out["bindings"] == [] and out["debt"] == []


class TestTheOwnerTypeContractHasOneOwner:
    """★DB 관측값을 두 벌로 적으면 한쪽만 고쳐진다 (Codex NON-BLOCK).

    주인은 **도메인 계약**이다 — adapter 는 소비자지 주인이 아니다.
    """

    def test_the_facet_binder_reads_the_domain_contract(self):
        from app.modules.pipeline import grounding_entity_contract as ec

        assert fb.CANON_TYPES is ec.MATERIALIZABLE_OWNER_TYPES

    def test_all_five_owners_are_materializable(self):
        """★★**뒤집힌 시험**이다 (§2-6.5, 2026-09-01).

        앞에는 `"location_part" not in MATERIALIZABLE_OWNER_TYPES` 로 **없어야
        한다**를 잠그고 있었다 — 그 갈래를 통째로 빚으로 두던 상태를 시험이
        지키고 있었던 것이다. 지우지 않고 **반대 방향으로** 잠근다.
        """
        from app.modules.pipeline import grounding_entity_contract as ec
        from app.modules.pipeline.grounding_chunk_merge import OWNERS

        assert "location_part" in ec.MATERIALIZABLE_OWNER_TYPES
        # ★다섯 갈래 **전부** 붙일 자리가 있다
        assert set(OWNERS) - set(ec.MATERIALIZABLE_OWNER_TYPES) == set()

    def test_the_db_column_never_blocked_it(self):
        """★막고 있던 것은 **목록**이지 schema 가 아니다 — alembic 이 없다.

        `entity_canon.entity_type` 은 `Text` 이고 check 제약이 없다.
        """
        import inspect

        from app.models.project import EntityCanon

        col = EntityCanon.__table__.c.entity_type
        assert col.type.python_type is str
        assert not [c for c in EntityCanon.__table__.constraints
                    if c.__class__.__name__ == "CheckConstraint"]
        del inspect

    def test_nobody_else_copies_the_list(self):
        """★같은 목록이 두 곳에 적히면 한쪽만 고쳐진다."""
        import subprocess

        out = subprocess.run(
            ["grep", "-rn", "--include=*.py",
             '"character", "location", "outlook", "prop"', "app/"],
            capture_output=True, text=True).stdout
        hits = [ln for ln in out.splitlines()
                if "grounding_entity_contract.py" not in ln]
        assert hits == [], f"★목록이 또 있다: {hits[:2]}"


class TestTheOwnerSetsAreThreeDifferentThings:
    """★★★Codex BLOCK 1 (2026-09-01) — 뜻이 다른 집합을 **하나로 합치지
    않는다**. 사본 문자열은 없애되, 서로 다른 것을 같게 만들면 안 된다.

        전체 materializable      C · L · LP · O · P
        EntitySync 소유          C · L · LP · P      ← outlook 은 남의 것
        relation                 relation_type 별 **조합**
    """

    def test_entity_sync_does_not_swallow_outlook(self):
        from app.modules.pipeline import grounding_entity_contract as ec
        from app.modules.pipeline.grounding_entity_sync_ext import (
            CHUNK_SCHEMA_MARKER, CHUNK_SCHEMA_VERSION)
        from app.services.checkpoint_sync.entity_sync_service import (
            _singulars, _sync_keys)

        assert "outlook" in ec.MATERIALIZABLE_OWNER_TYPES
        assert "outlook" not in ec.ENTITY_SYNC_OWNER_TYPES
        # ★표식이 있는 CP 라도 outlook 은 **안 읽는다**
        marked = {CHUNK_SCHEMA_MARKER: CHUNK_SCHEMA_VERSION}
        got = _singulars(_sync_keys(marked))
        assert "outlook" not in got
        assert set(got) == set(ec.ENTITY_SYNC_OWNER_TYPES)
        # ★표식이 없으면 **옛 셋 그대로** — legacy 동작 무변
        assert set(_singulars(_sync_keys({}))) == {
            "character", "location", "prop"}

    def test_entity_sync_owns_everything_else(self):
        from app.modules.pipeline import grounding_entity_contract as ec

        assert (set(ec.MATERIALIZABLE_OWNER_TYPES)
                - set(ec.ENTITY_SYNC_OWNER_TYPES)) == {"outlook"}

    def test_part_of_is_exactly_location_part_to_location(self):
        from app.modules.pipeline import grounding_entity_contract as ec

        assert ec.RELATION_OWNER_PAIRS["part_of"] == (
            ("location_part", "location"),)

    def test_visual_variant_does_not_reach_the_facet_owners(self):
        """★`part_of` 갈래가 `visual_variant` 조회에 섞이면 안 된다."""
        from app.modules.pipeline import grounding_entity_contract as ec

        got = ec.relation_sync_owner_types("visual_variant")
        assert "location_part" not in got and "outlook" not in got

    def test_an_unknown_relation_type_reaches_nothing(self):
        """★모르는 관계에 **전체 목록**을 주지 않는다."""
        from app.modules.pipeline import grounding_entity_contract as ec

        assert ec.relation_sync_owner_types("무엇인지 모름") == ()

    def test_nobody_writes_the_list_by_hand_anymore(self):
        """★★글자가 아니라 **AST 로** 본다 — 주석의 설명이 안 걸리게."""
        import ast
        import inspect

        from app.services.checkpoint_sync import entity_sync_service as es
        from app.services.checkpoint_sync import relation_sync_service as rs

        for mod in (es, rs):
            tree = ast.parse(inspect.getsource(mod))
            for n in ast.walk(tree):
                if not isinstance(n, ast.List):
                    continue
                vals = [e.value for e in n.elts
                        if isinstance(e, ast.Constant)]
                assert set(vals) != {"character", "location", "prop"}, (
                    f"{mod.__name__} 이 갈래 목록을 손으로 적었다")
