"""★★★`shot_director` 가 `location_part` 를 **후보로 받지도 못했다**
(Codex BLOCK 2026-09-02).

`_build_entity_name_map`·`_build_entity_desc_map` 이 `characters`·`locations`·
`props` 세 갈래만 돌았고, 가시성 enum 은 `scene_director` 의
`present_entity_ids` 로 제한된다. 실물 원고에서 그 목록의 LP 는 **0개**다.
그래서 유료로 돌려도 LP 는 schema 상 답에 나올 수 없었다 — 「보인다고 안
했다」가 아니라 **물어보지도 않았다**이고, 통과·불통과 어느 쪽 근거도 아니다.

★부모 장소가 보인다고 딸린 부분을 **전부 넣지 않는다.** producer 가 적어 둔
구조화 좌표(`occurrences[].source_span.segment_id`)가 있는 씬의 것만 후보가
되고, 프레임 안에 실제로 보이는지는 **기존 LLM** 이 고른다.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_entity_contract as ec
from app.modules.pipeline import shot_director as sd


def _lp(sid, name, scenes):
    return {"short_id": sid, "name": name, "description": f"{name} 설명",
            "grounding_provenance": {"occurrences": [
                {"source_span": {"segment_id": ec.scene_key(i)}}
                for i in scenes]}}


ENTITIES = {
    "characters": [{"short_id": "C01", "name": "누구", "description": "ㄱ"}],
    "locations": [{"short_id": "L01", "name": "어디", "description": "ㄴ"}],
    "props": [{"short_id": "P01", "name": "무엇", "description": "ㄷ"}],
    "location_parts": [
        _lp("LP01", "어떤 문", [1]),
        _lp("LP02", "어떤 턱", [1, 4]),
        _lp("LP09", "좌표 없는 것", []),
    ],
}


class TestTheLaneIsRead:
    def test_location_parts_are_a_lane(self):
        assert "location_parts" in sd.ENTITY_LANES

    def test_names_and_descriptions_both_carry_it(self):
        """★한 map 에만 넣으면 이름은 있는데 설명이 없는 갈래가 생긴다."""
        nm = sd._build_entity_name_map(ENTITIES)
        dm = sd._build_entity_desc_map(ENTITIES)
        assert nm["LP01"] == "어떤 문"
        assert dm["LP01"] == "어떤 문 설명"
        assert set(nm) == set(dm)

    def test_the_old_lanes_are_untouched(self):
        """★음성 대조 — C/L/P 는 하던 대로."""
        nm = sd._build_entity_name_map(ENTITIES)
        assert nm["C01"] == "누구" and nm["L01"] == "어디"
        assert nm["P01"] == "무엇"


class TestCandidatesComeFromCoordinatesOnly:
    def test_only_the_scene_the_producer_named(self):
        assert sd.scene_part_candidates(ENTITIES, 1) == ["LP01", "LP02"]
        assert sd.scene_part_candidates(ENTITIES, 4) == ["LP02"]

    def test_a_part_without_coordinates_is_never_a_candidate(self):
        """★★부모가 보인다고 딸려 들어가지 않는다."""
        for si in (1, 2, 3, 4):
            assert "LP09" not in sd.scene_part_candidates(ENTITIES, si)

    def test_a_scene_nobody_named_gets_nothing(self):
        assert sd.scene_part_candidates(ENTITIES, 2) == []
        assert sd.scene_part_candidates(ENTITIES, 3) == []

    def test_it_does_not_match_by_name(self):
        """★★★이름·부분문자열로 이으면 그것이 곧 하드코딩이다."""
        odd = {"location_parts": [
            {"short_id": "LP77", "name": "어디 의 문",
             "grounding_provenance": {"occurrences": []}}]}
        assert sd.scene_part_candidates(odd, 1) == []

    def test_a_bad_scene_index_gives_nothing(self):
        assert sd.scene_part_candidates(ENTITIES, None) == []
        assert sd.scene_part_candidates(ENTITIES, "어디") == []

    def test_no_place_words_in_the_source(self):
        import ast
        import inspect

        src = inspect.getsource(sd.scene_part_candidates)
        tree = ast.parse(src)
        docs = {ast.get_docstring(n, clean=False)
                for n in ast.walk(tree)
                if isinstance(n, (ast.FunctionDef, ast.Module))}
        live = [n.value for n in ast.walk(tree)
                if isinstance(n, ast.Constant) and isinstance(n.value, str)
                and n.value not in docs]
        # ★남아도 되는 것은 **칸 이름**과 빈 문자열뿐이다 — 장소·나라 이름도,
        #  견줄 낱말도 없어야 한다
        assert set(live) <= {"location_parts", "short_id", ""}, live


class TestTheSegmentIdRuleLivesInOnePlace:
    """★같은 규칙을 세 자리에 적으면 한쪽만 고쳐진다."""

    def test_making_and_reading_agree(self):
        for i in (1, 7, 42):
            assert ec.scene_index_of_key(ec.scene_key(i)) == i

    def test_an_unknown_shape_is_not_guessed(self):
        for bad in ("scene-x", "s1", "", None, "scene-"):
            assert ec.scene_index_of_key(bad) is None

    def test_the_outlook_binder_uses_the_same_one(self):
        import inspect

        from app.modules.pipeline import grounding_outlook_binding as ob

        src = inspect.getsource(ob._scene_key)
        assert "scene_key" in src and 'f"scene-' not in src

    def test_both_recorded_shapes_are_read(self):
        """★걸러진 엔티티는 `grounding_provenance`, 장부 줄은 `source_evidence`."""
        a = {"grounding_provenance": {"occurrences": [
            {"source_span": {"segment_id": "scene-2"}}]}}
        b = {"source_evidence": {"occurrences": [
            {"source_span": {"segment_id": "scene-5"}}]}}
        assert ec.scene_indices_of(a) == [2]
        assert ec.scene_indices_of(b) == [5]
        assert ec.scene_indices_of({}) == []


class TestTheOldCheckpointIsNotReused:
    def test_the_schema_version_moved(self):
        from app.core.steps import shot_director_step as st

        assert st.SHOT_DIRECTOR_SCHEMA_VERSION >= 4, (
            "★옛 CP 는 LP 를 후보로 받지도 못한 판이다 — 재사용하면 안 된다")

    def test_the_hash_folds_it(self):
        import inspect

        from app.core.steps import shot_director_step as st

        src = inspect.getsource(st.ShotDirectorStep._config_hash)
        assert "SHOT_DIRECTOR_SCHEMA_VERSION" in src


REAL = ("/Users/manta/Documents/Projects/TheRoad-I1/artifact"
        "/canary_69e821758f3d/projects"
        "/8e2e65b7-e910-4b12-b081-c23de0affab5/checkpoints/episodes"
        "/9e64c302-82e6-4407-b560-8c70c2ab7192")


class TestTheRealFixtureHasCandidates:
    """★★★Codex 조건 4 — **실물 period 원고**에서 씬별 LP 후보가 0 이 아니어야
    한다. 0 이면 유료로 돌려도 LP 는 답에 못 나오고, 그건 배선 판정이 아니다.
    """

    @pytest.fixture
    def real(self):
        import json
        from pathlib import Path

        base = Path(REAL)
        if not (base / "entity_filter" / "manifest.json").is_file():
            pytest.skip("실물 canary CP 가 없다 — 이 기계에서만 도는 끝점")
        fe = json.loads((base / "entity_filter" / "manifest.json"
                         ).read_text(encoding="utf-8")
                        )["data"]["filtered_entities"]
        sc = json.loads((base / "scene_director" / "manifest.json"
                         ).read_text(encoding="utf-8"))["data"]["scenes"]
        return fe, sc

    def test_the_lane_is_not_empty(self, real):
        fe, _sc = real
        assert fe.get("location_parts"), "★원고에 장소 부분이 없다"

    def test_the_director_had_none_of_them(self, real):
        """★이것이 뿌리다 — 씬 목록에 LP 가 **0개**라 물어볼 수가 없었다."""
        _fe, sc = real
        every = [x for s in sc for x in (s.get("present_entity_ids") or ())]
        assert not [x for x in every if x.startswith("LP")]

    def test_now_every_scene_gets_its_own_candidates(self, real):
        fe, sc = real
        got = {s["scene_index"]: sd.scene_part_candidates(fe,
                                                          s["scene_index"])
               for s in sc}
        assert all(got.values()), f"★후보가 빈 씬이 있다: {got}"
        # ★씬마다 **다르다** — 부모가 보인다고 다 딸려 오는 것이 아니다
        assert len({tuple(v) for v in got.values()}) > 1, got

    def test_the_verified_three_are_candidates_somewhere(self, real):
        """★사람이 「맞다」로 본 셋이 후보 집합에 실제로 든다."""
        fe, sc = real
        every = {x for s in sc
                 for x in sd.scene_part_candidates(fe, s["scene_index"])}
        assert {"LP01", "LP03", "LP05"} <= every, sorted(every)

    def test_not_every_part_lands_in_every_scene(self, real):
        fe, sc = real
        for s in sc:
            cand = sd.scene_part_candidates(fe, s["scene_index"])
            assert len(cand) < len(fe["location_parts"]), (
                f"★씬 {s['scene_index']} 이 부분을 전부 받았다 — 자동 확장이다")

    def test_the_manifest_agrees_with_the_step(self):
        """★★★판을 **두 곳**에 적는다 — 한쪽만 올리면 모든 resume 이 막힌다.

        실제로 그랬다: 스텝 상수만 4 로 올렸더니 manifest 는 3 이라
        `test_shot_director_schema_version_sot` 가 잡았다(기준선 밖 새 실패 1).
        """
        from app.core.step_manifest import STEP_MANIFEST
        from app.core.steps import shot_director_step as st

        assert STEP_MANIFEST["shot_director"]["schema_version"] == \
            st.SHOT_DIRECTOR_SCHEMA_VERSION
