"""outdoor_direct_compose 결정론 테스트 (W22 ④) — 9절 조립·ID-free 봉인."""

import pytest

from app.core.errors import AppError
from app.modules.pipeline.outdoor_direct_compose import (
    LOCATION_PHOTO_LABEL,
    SITE_PLAN_LABEL,
    assert_prompt_id_free,
    build_direct_prompt,
    build_place_desc,
    character_ref_label,
    load_blocks,
    resolve_prompt_version,
)


def _spec():
    return {"items": [
        {"code": "P1", "name_en": "front entry gate"},
        {"code": "P2", "name_en": "walled front yard"},
        {"code": "Q1", "name_en": "rear service path"},
    ]}


def _ground():
    return {"map_zone": "Front Yard", "anchor_markers": ["P1", "P2"]}


def test_resolve_and_load_blocks():
    assert resolve_prompt_version("1")
    with pytest.raises(ValueError):
        resolve_prompt_version("99")
    blocks = load_blocks("1")
    assert "ON LOCATION" in blocks["direct_head"]
    assert "No composition instructions" in blocks["free_camera"]
    assert "hard constraint" in blocks["time_lock"]
    assert "never draw" in blocks["ref_note"]
    assert "ZERO annotations" in blocks["no_annotation"]


def test_place_desc_is_id_free():
    desc = build_place_desc(_ground(), _spec())
    assert desc == (
        'the "Front Yard" area of the property, '
        "by front entry gate and walled front yard"
    )
    assert "P1" not in desc and "P2" not in desc


def test_place_desc_unknown_codes_fallback():
    desc = build_place_desc(
        {"map_zone": "Z", "anchor_markers": ["X9"]}, _spec()
    )
    assert "the mapped area" in desc


def _assemble(**over):
    kwargs = dict(
        blocks=load_blocks("1"),
        place_desc=build_place_desc(_ground(), _spec()),
        scene_heading="SAMPLE 야외 마당 / 밤",
        shot_description="인물이 대문을 밀고 들어선다",
        character_names=["인물A"],
        moment_context_en="the vehicle outside is a patrol car",
        world_facts_block="- 외부 계단은 철제 증축 계단이다",
        style_context="Photorealistic cinematic still. Setting: 2020s, Korea.",
        spec_codes=["P1", "P2", "Q1"],
    )
    kwargs.update(over)
    return build_direct_prompt(**kwargs)


def test_nine_part_assembly_order_and_content():
    prompt = _assemble()
    # 순서: style → head → SPOT → SHOT TEXT → camera → chars → time → facts → ref → seal
    idx = {
        "style": prompt.index("Setting: 2020s"),
        "head": prompt.index("ON LOCATION"),
        "spot": prompt.index("SPOT (fixed):"),
        "shot": prompt.index("SHOT TEXT (authoritative, Korean):"),
        "camera": prompt.index("YOU choose the camera"),
        "chars": prompt.index("CHARACTERS: only the listed"),
        "time": prompt.index("TIME & LIGHT (hard constraint)"),
        "facts": prompt.index("WORLD FACTS"),
        "ref": prompt.index("REFERENCES: the attached PHOTOGRAPH"),
        "seal": prompt.index("ZERO annotations"),
    }
    order = sorted(idx, key=idx.get)
    assert order == ["style", "head", "spot", "shot", "camera", "chars",
                     "time", "facts", "ref", "seal"]
    # 내용 관통
    assert "scene_heading: SAMPLE 야외 마당 / 밤" in prompt
    assert "people in shot: 인물A" in prompt
    assert "context: the vehicle outside is a patrol car" in prompt
    assert "철제 증축 계단" in prompt


def test_no_characters_uses_none_block():
    prompt = _assemble(character_names=[], moment_context_en="")
    assert "No people appear unless the moment itself says so." in prompt
    assert "people in shot:" not in prompt
    assert "context:" not in prompt


def test_empty_world_facts_omits_section():
    prompt = _assemble(world_facts_block="")
    assert "WORLD FACTS" not in prompt


def test_assembly_seals_id_free():
    with pytest.raises(AppError) as ei:
        _assemble(shot_description="P1 옆에서 인물이 서 있다")  # 코드 유입 시뮬레이션
    assert "id_free_violation" in ei.value.code


def test_assert_prompt_id_free_word_boundary():
    assert_prompt_id_free("lot P10 is fine", ["P1"], where="t")  # no raise
    with pytest.raises(AppError):
        assert_prompt_id_free("stand at P1 now", ["P1"], where="t")


def test_ref_labels_contract():
    assert LOCATION_PHOTO_LABEL.startswith("LOCATION PHOTOGRAPH")
    assert "sole source" in LOCATION_PHOTO_LABEL
    assert SITE_PLAN_LABEL.startswith("SITE PLAN")
    assert "never draw" in SITE_PLAN_LABEL
    label = character_ref_label("인물A")
    assert label == "CHARACTER REFERENCE — 인물A: the exact person in the shot."


# ── v2 팩 (2026-07-10, 2회차 E2E 육안 결함 대응) ──


def test_v2_is_default_and_includes_real_scale():
    """default=v2 + REAL SCALE 블록 필수 포함 (Codex NARROW — silent disable 방지)."""
    from app.modules.pipeline.outdoor_direct_compose import load_blocks
    blocks = load_blocks()  # default "2"
    assert blocks["real_scale"].startswith("REAL SCALE")
    assert "physical body" in blocks["characters_present"]


def test_v2_assembly_contains_real_scale_section():
    from app.modules.pipeline.outdoor_direct_compose import (
        build_direct_prompt, load_blocks,
    )
    prompt = build_direct_prompt(
        blocks=load_blocks("2"),
        place_desc="the yard",
        scene_heading="EXT. YARD - NIGHT",
        shot_description="a person stands",
        character_names=["A"],
    )
    assert "REAL SCALE" in prompt
    # TIME_LOCK(⑥) 뒤, REF_NOTE/no_annotation 앞 순서 유지
    assert prompt.index("TIME & LIGHT") < prompt.index("REAL SCALE")


def test_v2_missing_optional_block_fail_closed(monkeypatch):
    """v2+ 에서 real_scale 로드 실패 = raise (v1 만 빈 문자열 허용)."""
    import app.modules.pipeline.outdoor_direct_compose as m

    real_load = m.load_prompt

    def _fake(module, stem, version=None):
        if stem == "real_scale":
            raise FileNotFoundError(stem)
        return real_load(module, stem, version=version)

    monkeypatch.setattr(m, "load_prompt", _fake)
    import pytest as _pytest
    with _pytest.raises(Exception):
        m.load_blocks("2")
    # v1 은 하위 호환 — 빈 문자열
    assert m.load_blocks("1")["real_scale"] == ""


def test_v1_assembly_unchanged_without_real_scale():
    from app.modules.pipeline.outdoor_direct_compose import (
        build_direct_prompt, load_blocks,
    )
    prompt = build_direct_prompt(
        blocks=load_blocks("1"),
        place_desc="the yard",
        scene_heading="EXT. YARD - NIGHT",
        shot_description="a person stands",
        character_names=["A"],
    )
    assert "REAL SCALE" not in prompt
