"""outlook_phase2 파리티 — 인물 0명 씬은 회신 의무에서 뺀다.

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

`outlook_phase2` 는 씬 전체를 한 콜에 넣고 회신 키가 입력 키와 **정확히
일치**하는지 본다. 그런데 이 스텝이 시키는 일은 "씬마다 인물에게 의상을
배정하라"이고, 인물이 0명인 씬(건물 전경·간판 같은 설정샷)에는 배정할
대상이 자체가 없다. 프롬프트에 실리는 씬별 인물 목록에서도 그 씬은
빠진다 — 모델이 답할 거리를 주지 않고 답을 요구한 셈이다.

실측:

* 같은 대본 앞선 주행 — 그 씬에도 **빈 배정 행**을 채워 보내 94/94 통과
* 같은 대본 이번 주행 — 그 씬 행을 **생략**해 89/94, 게이트가 두 번 막음
  (누락 키가 두 번 다 동일: 인물 0명 씬 5개와 정확히 일치)
* 세 번째 시도 — 다시 빈 행을 채워 보내 통과

즉 회귀가 아니라 **계약이 모델 회신 형태에 걸린 운**이었다. 인물 0명 씬은
행이 없는 것이 정상이므로 '누락' 판정에서 빼고, 회신은 그대로 허용한다
(빈 행을 보내도 통과 — 앞선 주행의 형태도 계속 유효해야 한다).

인물이 **있는** 씬의 누락은 여전히 막아야 한다. 그게 이 게이트를 만든
이유(아웃룩이 한 칸 밀려 배정되는 결함)이기 때문이다.
"""

import pytest

from app.core.errors import AppError
from app.modules.pipeline import segment_key as segkey


KEY = segkey.KEY_FIELD


# ── 헬퍼 계약: 회신 의무(keys) 와 회신 허용(allowed) 의 분리 ──


def test_allowed_wider_than_required_lets_optional_key_through():
    """allowed 에만 든 키로 회신해도 '입력에 없음' 이 아니다."""
    rows = [{KEY: "SEG-001"}, {KEY: "SEG-002"}]
    missing, unexpected, dupes = segkey.key_parity(
        rows, ["SEG-001"], allowed=["SEG-001", "SEG-002"])
    assert (missing, unexpected, dupes) == ([], [], [])


def test_optional_key_may_be_omitted():
    """allowed 에만 든 키는 빠져도 누락이 아니다."""
    rows = [{KEY: "SEG-001"}]
    missing, _, _ = segkey.key_parity(
        rows, ["SEG-001"], allowed=["SEG-001", "SEG-002"])
    assert missing == []


def test_required_key_missing_still_caught():
    """회신 의무가 있는 키가 빠지면 여전히 잡힌다."""
    rows = [{KEY: "SEG-002"}]
    missing, _, _ = segkey.key_parity(
        rows, ["SEG-001", "SEG-002"], allowed=["SEG-001", "SEG-002"])
    assert missing == ["SEG-001"]


def test_key_outside_allowed_still_unexpected():
    """allowed 밖의 키는 그대로 '입력에 없음'."""
    rows = [{KEY: "SEG-009"}]
    _, unexpected, _ = segkey.key_parity(
        rows, ["SEG-001"], allowed=["SEG-001", "SEG-002"])
    assert unexpected == ["SEG-009"]


def test_default_behaviour_unchanged():
    """allowed 를 안 주면 기존 계약 그대로 — 다른 호출부(scene_director)
    가 이 기본값에 기대고 있다."""
    rows = [{KEY: "SEG-002"}]
    assert segkey.key_parity(rows, ["SEG-001", "SEG-002"]) == (
        ["SEG-001"], [], [])
    rows2 = [{KEY: "SEG-001"}, {KEY: "SEG-002"}]
    assert segkey.key_parity(rows2, ["SEG-001"]) == ([], ["SEG-002"], [])


def test_assert_parity_passes_with_optional_key_omitted():
    segkey.assert_parity(
        [{KEY: "SEG-001"}], ["SEG-001"],
        step="outlook_phase2", allowed=["SEG-001", "SEG-002"])


def test_assert_parity_still_raises_for_required_key():
    with pytest.raises(AppError) as exc:
        segkey.assert_parity(
            [{KEY: "SEG-002"}], ["SEG-001", "SEG-002"],
            step="outlook_phase2", allowed=["SEG-001", "SEG-002"])
    assert exc.value.code == "outlook_phase2.key_parity"
    assert "SEG-001" in exc.value.message


# ── 스텝 계약: 인물 0명 씬을 품은 실제 호출 ──


SEGMENTS = [
    {"scene_index": 1, "heading": "1. 실내. 첫 장소 - 낮",
     "text": "1. 실내. 첫 장소 - 낮\n인물 하나가 앉아 있다."},
    # 인물이 등장하지 않는 설정샷 — 배정 대상이 없다.
    {"scene_index": 2, "heading": "2. 실외. 둘째 장소 - 낮",
     "text": "2. 실외. 둘째 장소 - 낮\n건물 전경. 간판이 걸려 있다."},
    {"scene_index": 3, "heading": "3. 실내. 셋째 장소 - 밤",
     "text": "3. 실내. 셋째 장소 - 밤\n인물 둘이 마주 앉는다."},
]
OUTLOOKS = [
    {"short_id": "O01", "character_id": "C01", "name": "겉옷 차림"},
    {"short_id": "O02", "character_id": "C02", "name": "평상복"},
]
CHARACTERS = [{"short_id": "C01", "name": "인물 하나"},
              {"short_id": "C02", "name": "인물 둘"}]
# 씬 2 는 빠져 있다 — 조립층(outlook_steps)이 인물 없는 씬을 지우고 넘긴다.
SCENE_CHAR_MAP = {1: ["C01"], 3: ["C01", "C02"]}


def _run(monkeypatch, rows):
    """call_structured 를 고정 회신으로 바꾸고 phase2 를 돌린다."""
    import app.modules.pipeline.outlook_extractor_v2 as mod

    captured = {}

    def fake_call_structured(**kwargs):
        payload = {"scene_assignments": rows}
        validate = kwargs.get("validate_response")
        captured["valid"] = validate(payload) if validate else None
        return payload

    monkeypatch.setattr(mod, "call_structured", fake_call_structured)
    result = mod.extract_outlooks_phase2(
        segments=SEGMENTS, outlooks=OUTLOOKS, characters=CHARACTERS,
        scene_character_map=SCENE_CHAR_MAP,
    )
    return result, captured


def test_phase2_accepts_omitted_empty_scene(monkeypatch):
    """인물 0명 씬(SEG-002) 행이 없어도 통과한다."""
    rows = [
        {KEY: "SEG-001", "assignments": [
            {"character_id": "C01", "outlook_id": "O01"}]},
        {KEY: "SEG-003", "assignments": [
            {"character_id": "C01", "outlook_id": "O01"},
            {"character_id": "C02", "outlook_id": "O02"}]},
    ]
    result, captured = _run(monkeypatch, rows)
    assert captured["valid"] is True
    assert {sa["scene_index"] for sa in result["scene_assignments"]} == {1, 3}


def test_phase2_accepts_empty_row_for_empty_scene(monkeypatch):
    """빈 배정 행을 채워 보내는 형태(앞선 주행)도 계속 통과한다."""
    rows = [
        {KEY: "SEG-001", "assignments": [
            {"character_id": "C01", "outlook_id": "O01"}]},
        {KEY: "SEG-002", "assignments": []},
        {KEY: "SEG-003", "assignments": [
            {"character_id": "C01", "outlook_id": "O01"},
            {"character_id": "C02", "outlook_id": "O02"}]},
    ]
    result, captured = _run(monkeypatch, rows)
    assert captured["valid"] is True
    assert {sa["scene_index"] for sa in result["scene_assignments"]} == {1, 2, 3}


def test_phase2_still_rejects_missing_populated_scene(monkeypatch):
    """인물이 있는 씬(SEG-003)이 빠지면 여전히 크게 실패한다."""
    rows = [
        {KEY: "SEG-001", "assignments": [
            {"character_id": "C01", "outlook_id": "O01"}]},
    ]
    with pytest.raises(AppError) as exc:
        _run(monkeypatch, rows)
    assert exc.value.code == "outlook_phase2.key_parity"
    assert "SEG-003" in exc.value.message
    # 인물 0명 씬은 누락으로 세지 않는다.
    assert "SEG-002" not in exc.value.message


def test_phase2_requires_all_keys_when_char_map_absent(monkeypatch):
    """인물 맵이 없으면 좁힐 근거가 없다 — 기존대로 전 키를 요구한다."""
    import app.modules.pipeline.outlook_extractor_v2 as mod

    def fake_call_structured(**kwargs):
        return {"scene_assignments": [
            {KEY: "SEG-001", "assignments": []},
            {KEY: "SEG-003", "assignments": []},
        ]}

    monkeypatch.setattr(mod, "call_structured", fake_call_structured)
    with pytest.raises(AppError) as exc:
        mod.extract_outlooks_phase2(
            segments=SEGMENTS, outlooks=OUTLOOKS, characters=CHARACTERS,
            scene_character_map=None,
        )
    assert "SEG-002" in exc.value.message
