"""scene_director 출력 키 도메인 고정 + 입출력 파리티 게이트.

## 왜 이 테스트가 있나 (2026-08-06 실측)

`scene_director` 는 전 씬을 한 콜에 넣는다. 그때 래퍼가 붙이는 인덱스
(`## 씬 {segment_index}`) 와 **세그먼트 본문이 자기 안에 이미 갖고 있는 번호
헤딩**이 둘 다 "씬 번호"처럼 보였고, 어느 쪽을 되돌려줄지 고정하는 장치가
없었다. 스키마도 배열 길이·인덱스 집합·유일성을 전혀 제약하지 않았다.

실측 결과 같은 파이프라인이 실행마다 다른 도메인을 골랐다.

* 2026-07-28 실행: 입력 110 → 출력 110, **segment 인덱스**로 회신
* 2026-08-04 실행: 입력 113 → 출력 **116**, **본문 번호 헤딩**으로 회신

두 실행의 세그먼트 자체는 동일한 지점(S4·S81·S103)에서 본문 번호가 앞서는
같은 모양이었다. 즉 회귀가 아니라 **고정되지 않은 선택**이었고 7월은 운이었다.

하류는 이 인덱스를 그대로 dict 키로 삼아 shot 과 조인하므로(`shot_director`),
한 칸 밀린 VE 가 다른 씬의 shot 에 붙는다. 실측 S11sh3 는 그렇게 배정이 비었고,
같은 씬 다른 shot 의 VE 를 끌어오는 fallback 이 **다른 인물**을 채워 넣었다.

여기서 잠그는 계약은 두 겹이다.

1. 출력 키를 본문 번호와 **충돌할 수 없는 불투명 토큰**으로 바꾸고 스키마
   enum 으로 잠근다 (같은 함수가 `present_entity_ids` 에 이미 쓰는 방식).
2. 회신 키 집합이 입력 키 집합과 **정확히 일치**하지 않으면 실패시킨다.
   enum 이 막지 못하는 누락·중복이 여기서 걸린다.
"""

import copy

import pytest


# 두 번째 세그먼트의 본문이 자기 안에 다음 번호 헤딩을 이미 품고 있는 형태 —
# 실측에서 LLM 이 이 지점을 별도 씬으로 다시 쪼갰다. 작품 고유명사는 쓰지 않는다.
SAMPLE_FIXTURE_SEGMENTS = [
    {
        "scene_index": 1,
        "heading": "1. 실내. 첫 장소 - 낮",
        "text": "1. 실내. 첫 장소 - 낮\n인물 하나가 앉아 있다.",
    },
    {
        "scene_index": 2,
        "heading": "2. 실외. 둘째 장소 - 밤",
        "text": (
            "2. 실외. 둘째 장소 - 밤\n인물이 걷는다.\n"
            "3. 몽타주:\n1. 첫 컷.\n2. 둘째 컷."
        ),
    },
    {
        "scene_index": 3,
        "heading": "4. 실내. 셋째 장소 - 낮",
        "text": "4. 실내. 셋째 장소 - 낮\n인물이 문을 연다.",
    },
]

SAMPLE_FIXTURE_ENTITIES = {
    "characters": [{"name": "인물가", "short_id": "C01"}],
    "locations": [{"name": "첫 장소", "short_id": "L01"}],
    "props": [],
}

# 팩 v10 스키마 모양 — 키 필드가 segment_key.
BASE_SCHEMA = {
    "type": "object",
    "properties": {
        "scenes": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "segment_key": {"type": "string"},
                    "primary_location": {"type": "string"},
                    "scene_type": {"type": "string"},
                    "present_entity_ids": {"type": "array", "items": {"type": "string"}},
                },
                "required": [
                    "segment_key",
                    "primary_location",
                    "scene_type",
                    "present_entity_ids",
                ],
                "additionalProperties": False,
            },
        }
    },
    "required": ["scenes"],
    "additionalProperties": False,
}


def _row(key, loc="L01", ents=("C01",)):
    return {
        "segment_key": key,
        "primary_location": loc,
        "scene_type": "normal",
        "present_entity_ids": list(ents),
    }


@pytest.fixture
def director(monkeypatch):
    """direct_scenes + 마지막 call_structured 호출 kwargs 를 함께 돌려준다."""
    from app.modules.pipeline import scene_director_v2 as mod

    captured = {}

    def fake_call(**kwargs):
        captured.update(kwargs)
        payload = captured["_payload"]
        validate = kwargs.get("validate_response")
        # tier1 에서 predicate 가 거부하면 실제 구현은 tier2/3 로 넘어간다.
        # 여기서는 predicate 의 판정 결과만 기록하고 payload 는 그대로 돌려준다
        # (Tier 3 는 predicate 미적용 — 그래서 게이트가 별도로 필요하다).
        captured["_predicate_ok"] = validate(payload) if validate else None
        return payload

    monkeypatch.setattr(mod, "load_prompt", lambda *a, **k: "시스템")
    monkeypatch.setattr(mod, "load_schema", lambda *a, **k: copy.deepcopy(BASE_SCHEMA))
    monkeypatch.setattr(mod, "call_structured", fake_call)

    def run(payload, segments=None, entities=None):
        captured["_payload"] = payload
        return mod.direct_scenes(
            segments if segments is not None else SAMPLE_FIXTURE_SEGMENTS,
            entities=entities if entities is not None else SAMPLE_FIXTURE_ENTITIES,
        )

    return run, captured


class TestOutputKeyDomainIsPinned:
    """① 출력 키가 본문 번호와 충돌할 수 없어야 한다."""

    def test_prompt_block_header_is_opaque_not_a_bare_number(self, director):
        run, cap = director
        run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]})

        prompt = cap["user_prompt"]
        assert "## SEG-001:" in prompt
        assert "## SEG-002:" in prompt
        # 래퍼가 더 이상 번호를 씬 키처럼 제시하지 않는다.
        assert "## 씬 1:" not in prompt
        assert "## 씬 2:" not in prompt

    def test_segment_text_is_passed_whole(self, director):
        """본문은 자르지 않는다 — 안에 든 번호 헤딩도 그대로 간다."""
        run, cap = director
        run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]})

        for seg in SAMPLE_FIXTURE_SEGMENTS:
            assert seg["text"] in cap["user_prompt"]

    def test_schema_locks_key_to_enum_and_cardinality(self, director):
        run, cap = director
        run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]})

        scenes = cap["response_schema"]["properties"]["scenes"]
        items = scenes["items"]["properties"]

        assert items["segment_key"]["enum"] == ["SEG-001", "SEG-002", "SEG-003"]
        # 배열 길이는 스키마로 묶지 않는다 — 2026-08-07 실측에서 113 세그먼트
        # 실호출이 Gemini 400 으로 죽었고, 파리티 게이트가 같은 것을 이미
        # 결정론적으로 잡는다(TestParityGate). 중복 제약을 얹지 않는다.
        assert "minItems" not in scenes and "maxItems" not in scenes

    def test_key_never_carries_the_screenplay_number(self, director):
        """세그먼트 3 의 본문 번호는 4 지만, 키는 순번 기반이어야 한다."""
        run, cap = director
        run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]})

        assert cap["response_schema"]["properties"]["scenes"]["items"]["properties"][
            "segment_key"
        ]["enum"] == ["SEG-001", "SEG-002", "SEG-003"]


class TestMapBackToSceneIndex:
    """② 하류 계약은 그대로 — 코드가 scene_index 로 되돌려 준다."""

    def test_rows_carry_input_scene_index(self, director):
        run, _ = director
        out = run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]})

        assert [s["scene_index"] for s in out["scenes"]] == [1, 2, 3]

    def test_rows_are_sorted_by_scene_index(self, director):
        run, _ = director
        out = run({"scenes": [_row("SEG-003"), _row("SEG-001"), _row("SEG-002")]})

        assert [s["scene_index"] for s in out["scenes"]] == [1, 2, 3]

    def test_non_contiguous_segment_indices_survive(self, director):
        """세그먼트 인덱스가 1..N 이 아니어도 키→인덱스 매핑이 유지된다."""
        segs = [dict(s, scene_index=si) for s, si in zip(SAMPLE_FIXTURE_SEGMENTS, (5, 9, 40))]
        run, _ = director
        out = run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]}, segments=segs)

        assert [s["scene_index"] for s in out["scenes"]] == [5, 9, 40]

    def test_payload_key_field_is_dropped(self, director):
        run, _ = director
        out = run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]})

        assert all("segment_key" not in s for s in out["scenes"])


class TestParityGate:
    """③ enum 이 못 막는 누락·중복·재분할을 게이트가 실패시킨다."""

    def test_resplit_extra_rows_are_rejected(self, director):
        """2026-08-04 재현 — 입력 3개에 출력 4개(몽타주 재분할)."""
        run, _ = director
        payload = {
            "scenes": [
                _row("SEG-001"),
                _row("SEG-002"),
                _row("SEG-003"),
                _row("SEG-004"),  # 입력에 없는 키
            ]
        }
        with pytest.raises(Exception) as exc:
            run(payload)
        assert "parity" in str(exc.value).lower() or "불일치" in str(exc.value)

    def test_missing_rows_are_rejected(self, director):
        run, _ = director
        with pytest.raises(Exception):
            run({"scenes": [_row("SEG-001"), _row("SEG-003")]})

    def test_duplicate_keys_are_rejected(self, director):
        run, _ = director
        with pytest.raises(Exception):
            run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-002")]})

    def test_empty_scenes_is_rejected_when_segments_exist(self, director):
        run, _ = director
        with pytest.raises(Exception):
            run({"scenes": []})

    def test_predicate_rejects_drift_so_lower_tiers_retry(self, director):
        """게이트 전에 call_structured 의 validate_response 로 재시도를 먼저 준다."""
        run, cap = director
        with pytest.raises(Exception):
            run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003"), _row("SEG-004")]})
        assert cap["_predicate_ok"] is False

    def test_predicate_accepts_exact_parity(self, director):
        run, cap = director
        run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]})
        assert cap["_predicate_ok"] is True

    def test_error_names_the_offending_keys(self, director):
        """실패 메시지가 무엇이 남고 무엇이 빠졌는지 말해야 진단이 된다."""
        run, _ = director
        with pytest.raises(Exception) as exc:
            run({"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-004")]})
        msg = str(exc.value)
        assert "SEG-004" in msg and "SEG-003" in msg


class TestEmptyInput:

    def test_no_segments_short_circuits_without_calling_llm(self, monkeypatch):
        from app.modules.pipeline import scene_director_v2 as mod

        called = []
        monkeypatch.setattr(mod, "load_prompt", lambda *a, **k: "시스템")
        monkeypatch.setattr(mod, "load_schema", lambda *a, **k: copy.deepcopy(BASE_SCHEMA))
        monkeypatch.setattr(mod, "call_structured", lambda **k: called.append(k) or {"scenes": []})

        out = mod.direct_scenes([], entities=SAMPLE_FIXTURE_ENTITIES)
        assert out == {"scenes": []}
        assert called == []


class TestLegacySchemaPack:
    """키 필드가 구 이름(scene_index)인 팩이 물려도 계약이 유지된다."""

    def test_scene_index_field_is_normalized_to_segment_key(self, monkeypatch):
        from app.modules.pipeline import scene_director_v2 as mod

        legacy = copy.deepcopy(BASE_SCHEMA)
        items = legacy["properties"]["scenes"]["items"]
        items["properties"].pop("segment_key")
        items["properties"]["scene_index"] = {"type": "integer"}
        items["required"] = [
            "scene_index", "primary_location", "scene_type", "present_entity_ids",
        ]

        cap = {}

        def fake_call(**kwargs):
            cap.update(kwargs)
            return {"scenes": [_row("SEG-001"), _row("SEG-002"), _row("SEG-003")]}

        monkeypatch.setattr(mod, "load_prompt", lambda *a, **k: "시스템")
        monkeypatch.setattr(mod, "load_schema", lambda *a, **k: legacy)
        monkeypatch.setattr(mod, "call_structured", fake_call)

        out = mod.direct_scenes(SAMPLE_FIXTURE_SEGMENTS, entities=SAMPLE_FIXTURE_ENTITIES)

        sent = cap["response_schema"]["properties"]["scenes"]["items"]
        assert "scene_index" not in sent["properties"]
        assert sent["properties"]["segment_key"]["enum"] == ["SEG-001", "SEG-002", "SEG-003"]
        assert "segment_key" in sent["required"] and "scene_index" not in sent["required"]
        assert [s["scene_index"] for s in out["scenes"]] == [1, 2, 3]
