"""`part_of` 를 관계 CP 로 **투영**한다. ★inert. 유료 0.

Codex 가 못박은 셋 (2026-08-31) —

    ★**한 CP = 한 producer.** adapter 가 `entity_relation` CP 를 직접 쓰면
     `visual_variant` 와 `part_of` 가 **서로 덮는다**
    ★**타입별로 가른다.** 한 타입의 sync 가 다른 타입을 stale 로 지우면 안 된다
    ★`outlook → character` 는 **여기 안 온다** — `CharacterOutlook` 이 SOT
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_relation_projection as rp


class TestItOnlyLinksTheAllowedOwners:
    """★이름을 안 본다 — **갈래**로 가른다."""

    def test_location_part_to_location_passes(self):
        got = rp.project_part_of([{"part": "LP01", "whole": "L03"}])
        assert len(got) == 1
        assert got[0]["relation_type"] == rp.RELATION_PART_OF

    @pytest.mark.parametrize("bad", [
        {"part": "C01", "whole": "L03"},        # 인물은 안 잇는다
        {"part": "P01", "whole": "L03"},        # 소품도
        {"part": "O01", "whole": "C03"},        # ★아웃룩은 CharacterOutlook 몫
        {"part": "LP01", "whole": "P03"},       # 부모가 장소가 아니다
        {"part": "LP01", "whole": "LP02"},      # 부모가 부분이다
    ])
    def test_other_pairs_stop(self, bad):
        with pytest.raises(rp.RelationProjectionError):
            rp.project_part_of([bad])

    def test_outlook_is_not_in_the_table(self):
        """★★orchestrator 가 RelationSync(2) → OutlookSync(4) 라, 관계를 쓸 때
        outlook canon 이 **아직 없을 수 있다**. 그리고 SOT 가 두 벌이 된다.
        """
        assert "outlook" not in rp.ALLOWED_PART_OF

    def test_self_link_stops(self):
        with pytest.raises(rp.RelationProjectionError):
            rp.project_part_of([{"part": "LP01", "whole": "LP01"}])

    @pytest.mark.parametrize("bad", [
        {"part": "", "whole": "L03"}, {"part": "LP01", "whole": ""},
        {"part": "Lfoo", "whole": "L03"}])
    def test_junk_stops(self, bad):
        with pytest.raises(rp.RelationProjectionError):
            rp.project_part_of([bad])


class TestItIsDeterministic:
    def test_duplicates_collapse(self):
        got = rp.project_part_of([{"part": "LP01", "whole": "L03"}] * 3)
        assert len(got) == 1

    def test_the_input_order_does_not_matter(self):
        a = rp.project_part_of([{"part": "LP01", "whole": "L03"},
                                {"part": "LP02", "whole": "L03"}])
        b = rp.project_part_of([{"part": "LP02", "whole": "L03"},
                                {"part": "LP01", "whole": "L03"}])
        assert a == b

    def test_the_roles_are_fixed(self):
        got = rp.project_part_of([{"part": "LP01", "whole": "L03"}])[0]
        roles = {p["role"]: p["short_id"] for p in got["participants"]}
        assert roles == {rp.ROLE_PART: "LP01", rp.ROLE_WHOLE: "L03"}


class TestOneTypeNeverTouchesAnother:
    """★★한 타입의 delta 가 다른 타입을 **stale 로 지우면 안 된다**."""

    def test_it_ignores_other_types(self):
        rows = rp.project_part_of([{"part": "LP01", "whole": "L03"}])
        rows.append({"relation_type": "visual_variant", "participants": [
            {"short_id": "C01", "role": "base", "order": 1},
            {"short_id": "C02", "role": "variant", "order": 2}]})
        assert rp.desired_keys(rows, relation_type=rp.RELATION_PART_OF) == \
            [("LP01", "L03")]

    def test_the_caller_must_name_the_type(self):
        """★타입을 명시하게 해서 **실수로 넓히지** 못하게 한다."""
        import inspect

        sig = inspect.signature(rp.desired_keys)
        p = sig.parameters["relation_type"]
        assert p.kind is inspect.Parameter.KEYWORD_ONLY
        assert p.default is inspect.Parameter.empty, "★기본값이 있으면 새어 나간다"

    def test_an_unknown_type_stops(self):
        with pytest.raises(rp.RelationProjectionError):
            rp.desired_keys([{"relation_type": "지어냄",
                              "participants": []}], relation_type="지어냄")

    def test_missing_participants_stop(self):
        with pytest.raises(rp.RelationProjectionError):
            rp.desired_keys([{"relation_type": rp.RELATION_PART_OF,
                              "participants": [{"short_id": "LP01",
                                                "role": rp.ROLE_PART}]}],
                            relation_type=rp.RELATION_PART_OF)


class TestTheExistingSyncIsAlreadyScoped:
    """★★기존 조회가 **이미** 타입으로 잠겨 있다 — 그 사실을 못박는다.

    실측: `relation_sync_service._load_existing_visual_variants` 의 SQL 이
    `WHERE rf.relation_type = 'visual_variant'` 를 갖는다. 그래서 지금 delta 는
    `part_of` 를 **볼 수도 지울 수도 없다**. ★새 갈래를 더할 때 이 잠금을
    **풀면 안 된다**.
    """

    def test_the_existing_loader_filters_by_type(self):
        import inspect

        from app.services.checkpoint_sync.relation_sync_service import (
            RelationSyncService)

        src = inspect.getsource(
            RelationSyncService._load_existing_visual_variants)
        assert "relation_type = 'visual_variant'" in src, \
            "★타입 잠금이 풀렸다 — 다른 타입을 지울 수 있다"

    def test_the_delete_set_comes_from_that_scoped_load(self):
        """★삭제 대상이 **잠긴 조회**에서 나오는지 — 넓은 조회면 남을 지운다."""
        import inspect

        from app.services.checkpoint_sync.relation_sync_service import (
            RelationSyncService)

        src = inspect.getsource(RelationSyncService.sync_from_checkpoint)
        assert "_load_existing_visual_variants()" in src
        i = src.index("_load_existing_visual_variants()")
        j = src.index("to_delete")
        assert i < j, "★삭제를 정하기 전에 잠긴 조회를 안 쓴다"


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

        앞에는 「활성 경로가 아직 안 부른다」를 잠갔다. 이제 `part_of` 를 **DB 에
        남기는 것**이 계약이다 — in-memory 결속만으로는 「하류 소비」가 아니다.
        """
        import ast
        from pathlib import Path as _P

        me = "grounding_relation_projection"
        root = _P(__file__).resolve().parents[2] / "app"
        hits = []
        for f in root.rglob("*.py"):
            if f.name == f"{me}.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 me in str(n.module or ""):
                    hits.append(f.name)
        assert "relation_sync_service.py" in hits, (
            "★RelationSync 가 이 투영을 안 쓴다 — `part_of` 가 DB 에 안 남는다")
