"""outdoor_marker_map 결정론 테스트 — 스키마/validator/PIL 렌더만.

LLM geometry 저작 완성도는 검증하지 않는다. fixture 전부 시나리오 중립.
"""
import io

import pytest

from app.modules.pipeline.outdoor_marker_map import (
    MAX_SLOTS,
    build_marker_geometry_schema,
    render_marker_map,
    validate_marker_geometry,
)


def _geometry(**over):
    base = {
        "entity_placements": [
            {"slot": "E1", "subject_en": "running adult figure",
             "x": 0.42, "y": 0.61},
            {"slot": "E2", "subject_en": "seated adult figure",
             "x": 0.55, "y": 0.30},
        ],
        "camera": {
            "origin": {"x": 0.80, "y": 0.85},
            "look_target": {"x": 0.45, "y": 0.45},
        },
        "rationale_ko": "SAMPLE 근거",
    }
    base.update(over)
    return base


def _base_png(size=(320, 200)) -> bytes:
    from PIL import Image

    buf = io.BytesIO()
    Image.new("RGB", size, (240, 236, 228)).save(buf, format="PNG")
    return buf.getvalue()


def test_schema_locks_slot_enum_and_bounds():
    schema = build_marker_geometry_schema()
    pl = schema["properties"]["entity_placements"]["items"]["properties"]
    assert pl["slot"]["enum"] == [f"E{i}" for i in range(1, MAX_SLOTS + 1)]
    assert pl["x"]["minimum"] == 0 and pl["x"]["maximum"] == 1
    cam = schema["properties"]["camera"]["properties"]
    assert set(cam.keys()) == {"origin", "look_target"}


def test_validate_passes_clean():
    assert validate_marker_geometry(_geometry()) == []


def test_validate_rejects_out_of_bounds_and_bad_slot():
    g = _geometry()
    g["entity_placements"][0]["x"] = 1.4
    assert any("범위" in v for v in validate_marker_geometry(g))
    g2 = _geometry()
    g2["entity_placements"][0]["slot"] = "X1"
    assert any("slot" in v for v in validate_marker_geometry(g2))


def test_validate_rejects_duplicate_or_nonsequential_slots():
    g = _geometry()
    g["entity_placements"][1]["slot"] = "E1"
    assert any("중복" in v for v in validate_marker_geometry(g))
    g2 = _geometry()
    g2["entity_placements"][1]["slot"] = "E3"  # E2 건너뜀
    assert any("순차" in v for v in validate_marker_geometry(g2))


def test_validate_rejects_empty_subject_and_degenerate_camera():
    g = _geometry()
    g["entity_placements"][0]["subject_en"] = "  "
    assert any("subject_en" in v for v in validate_marker_geometry(g))
    g2 = _geometry()
    g2["camera"]["look_target"] = {"x": 0.801, "y": 0.85}  # origin 과 거의 동일
    assert any("방향 벡터" in v for v in validate_marker_geometry(g2))


def test_validate_rejects_slot_beyond_max():
    """E1..E7 — 스키마 enum 과 별개로 validator 자체 fail-closed (Codex N3)."""
    g = _geometry()
    g["entity_placements"] = [
        {"slot": f"E{i}", "subject_en": "generic subject",
         "x": 0.5, "y": 0.5}
        for i in range(1, MAX_SLOTS + 2)
    ]
    out = validate_marker_geometry(g)
    assert any("최대" in v or "E7" in v for v in out)


def test_validate_rejects_bool_coordinates():
    """bool 은 int 하위 타입 — 좌표로 위장 통과 차단 (Codex N3 실증)."""
    g = _geometry()
    g["entity_placements"][0]["x"] = True
    assert any("범위" in v or "숫자" in v for v in validate_marker_geometry(g))
    g2 = _geometry()
    g2["camera"]["origin"]["y"] = False
    assert any("범위" in v or "숫자" in v
               for v in validate_marker_geometry(g2))


def test_validate_rejects_malformed_root_and_placement():
    """malformed 구조 = AttributeError 아닌 위반 리스트 (Codex N3)."""
    assert validate_marker_geometry([]) != []          # root 가 dict 아님
    g = _geometry()
    g["entity_placements"] = [None, "x"]
    out = validate_marker_geometry(g)
    assert out and all(isinstance(v, str) for v in out)
    g2 = _geometry()
    g2["entity_placements"] = "not-a-list"
    assert validate_marker_geometry(g2) != []


def test_render_cone_covers_target_and_beyond():
    """FOV 콘은 target 거리와 무관하게 시선축 원거리를 덮어야 함 (Codex B1).

    실증 재현: 200x100, origin(0.1,0.5)→target(0.2,0.5) 일 때 같은 축
    x=0.6 픽셀이 base white 그대로면 콘이 target-distance 삼각형에 불과.
    """
    import io as _io

    from PIL import Image

    base = _base_png(size=(200, 100))
    g = _geometry()
    g["entity_placements"] = [
        {"slot": "E1", "subject_en": "generic subject", "x": 0.2, "y": 0.5}]
    g["camera"] = {"origin": {"x": 0.1, "y": 0.5},
                   "look_target": {"x": 0.2, "y": 0.5}}
    out = render_marker_map(base, g)
    im = Image.open(_io.BytesIO(out)).convert("RGB")
    # 시선축 위 target(0.2) / target 뒤(0.6, 0.9) 전부 콘 tint (base 아님)
    base_rgb = (240, 236, 228)
    for fx in (0.6, 0.9):
        px = im.getpixel((int(200 * fx), 50))
        assert px != base_rgb, f"x={fx} 픽셀이 콘 밖 (base 그대로): {px}"


def test_render_is_deterministic_and_leaves_base_untouched():
    base = _base_png()
    base_copy = bytes(base)
    out1 = render_marker_map(base, _geometry())
    out2 = render_marker_map(base, _geometry())
    assert out1 == out2                     # 결정론
    assert base == base_copy                # base immutable
    assert out1 != base                     # 마커가 실제로 그려짐
    from PIL import Image

    im = Image.open(io.BytesIO(out1))
    assert im.size == (320, 200)            # 크기 보존


def test_render_rejects_invalid_geometry():
    g = _geometry()
    g["entity_placements"] = []
    with pytest.raises(ValueError):
        render_marker_map(_base_png(), g)


def _spec():
    return {
        "zone_labels_en": ["Open Field"],
        "items": [{"code": "P1", "kind": "gate",
                   "name_en": "front entry gate",
                   "placement_en": "south edge"}],
    }


def _shot():
    return {"scene_index": 3, "shot_index": 1,
            "description": "달려가는 인물의 뒷모습 와이드"}


def _run_kwargs(fake_fn):
    return dict(
        spec=_spec(),
        shot=_shot(),
        scene_text="그가 달려간다. 멀어진다.",
        map_png=_base_png(),
        segment_label_en="open approach path",
        call_structured_fn=fake_fn,
    )


def test_run_ok_returns_validated_geometry():
    g = _geometry()
    captured = {}

    def fake(step, system, user, schema, **kw):
        captured["user"] = user
        return g

    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )
    out = run_marker_geometry_shot(**_run_kwargs(fake))
    assert out["attempts"] == 1
    assert out["geometry"]["camera"]["origin"]["x"] == 0.80
    # 멀티모달 파트: 맵 PNG 첨부 + 씬 전문/세그먼트 라벨 포함
    texts = [p.get("text", "") for p in captured["user"]
             if p.get("type") == "text"]
    joined = "\n".join(texts)
    assert "open approach path" in joined
    assert "그가 달려간다. 멀어진다." in joined
    assert any(p.get("type") == "image_url" for p in captured["user"])


def test_run_retries_on_invalid_geometry_then_ok():
    bad = _geometry()
    bad["entity_placements"][0]["x"] = 1.7
    responses = [bad, _geometry()]
    calls = []

    def fake(step, system, user, schema, **kw):
        calls.append(user)
        return responses[len(calls) - 1]

    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )
    out = run_marker_geometry_shot(**_run_kwargs(fake))
    assert out["attempts"] == 2
    assert "재시도" in calls[1][-1]["text"]


def test_sketch_prompt_assembles_slots_and_contracts():
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )
    p = build_marker_sketch_prompt(
        shot_desc="달려가는 인물의 뒷모습 와이드",
        place_text="EXT. SAMPLE 개활지 - 낮",
        geometry=_geometry(),
        pose_clauses=["POSE CANON: running full stride"],
        carried_en="a canvas bag on the left shoulder",
        camera_direction_en="high angle from above, looking down the path",
    )
    # 슬롯→subject 매핑이 텍스트로 실림 (이미지 라벨은 코드만)
    assert "E1 = running adult figure" in p
    assert "E2 = seated adult figure" in p
    assert "달려가는 인물의 뒷모습 와이드" in p
    assert "EXT. SAMPLE 개활지 - 낮" in p
    assert "POSE CANON: running full stride" in p
    assert "canvas bag" in p
    # v2 (Codex B2): 맵=XY/heading SOT, camera_direction=수직 각도/framing SOT
    assert "high angle from above, looking down the path" in p
    assert "CAMERA-VIEW" in p         # sketch_head v2 — GROUND-LEVEL 강제 제거
    assert "GROUND-LEVEL" not in p
    # 계약 절 존재 (팩 로드 성공 검증)
    assert "MARKER MAP" in p          # sketch_head
    assert "LIGHT STORYBOARD" in p    # light_frame
    assert "do NOT draw the" in p     # no_marker
    assert "No text" in p             # no_text


def test_sketch_prompt_seed_look_v3_only():
    """Stage D: seed 룩 조항=v3 팩 전용 selector 분기 계약."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    kwargs = dict(
        shot_desc="SAMPLE 샷",
        place_text="EXT. SAMPLE",
        geometry=_geometry(),
        pose_clauses=[],
        carried_en="",
    )
    # v3 + seed_look_attached=True → 관할 분리 조항 조립
    p = build_marker_sketch_prompt(
        **kwargs, seed_look_attached=True, prompt_version="3")
    n = " ".join(p.lower().split())
    assert "structure look authority" in n
    assert "sole authority for positions" in n
    # v3 + False → 조항 0
    p_off = build_marker_sketch_prompt(
        **kwargs, seed_look_attached=False, prompt_version="3")
    assert "STRUCTURE LOOK" not in p_off
    # v2 default 조립 불변 (조항 0)
    p_v2 = build_marker_sketch_prompt(**kwargs)
    assert "STRUCTURE LOOK" not in p_v2
    # v1/v2 에 True 요청 = fail-closed (조항 없는 silent 첨부 차단)
    with pytest.raises(ValueError):
        build_marker_sketch_prompt(
            **kwargs, seed_look_attached=True, prompt_version="2")


def test_leakage_schema_shape():
    from app.modules.pipeline.outdoor_marker_map import build_leakage_schema

    s = build_leakage_schema()
    assert s["properties"]["has_marker_leakage"]["type"] == "boolean"
    assert "details_ko" in s["required"]


def test_run_exhausts_raises_app_error():
    bad = _geometry()
    bad["camera"]["look_target"] = dict(bad["camera"]["origin"])

    def fake(step, system, user, schema, **kw):
        return bad

    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )
    with pytest.raises(AppError) as ei:
        run_marker_geometry_shot(**_run_kwargs(fake), max_attempts=2)
    assert ei.value.code == "step.contract_violation.outdoor_marker_geometry"


def test_lane_fingerprint_payload_sensitivity():
    """Codex Stage D HIGH-5: 지문 payload 가 스케치/geometry 결정 입력
    전체에 민감 — pose/carried/place/vshot 필드 변경=payload 변경."""
    from app.modules.pipeline.outdoor_marker_map import (
        lane_conti_fingerprint_payload,
    )

    base = dict(
        spec={"items": []},
        vshot={"description": "SAMPLE", "camera_direction": "정면",
               "key_bg_elements": ["SAMPLE 요소"]},
        scene_text="원문",
        lane="map_marker",
        segment_label_en="seg",
        place_text="EXT. SAMPLE",
        pose_clauses=["POSE A"],
        carried_en="a bag",
    )
    p0 = lane_conti_fingerprint_payload(**base)
    for key, val in (
        ("place_text", "EXT. OTHER"),
        ("pose_clauses", ["POSE B"]),
        ("carried_en", "no bag"),
        ("vshot", {**base["vshot"], "key_bg_elements": ["다른 요소"]}),
    ):
        changed = lane_conti_fingerprint_payload(**{**base, key: val})
        assert changed != p0, key


# ── R4·R5 (2026-07-16): ID-free 텍스트 geometry + v4 클린 맵 조립 ────────


def test_geometry_text_lines_are_id_free():
    from app.modules.pipeline.outdoor_marker_map import (
        build_geometry_text_lines,
    )

    lines = build_geometry_text_lines(_geometry())
    text = "\n".join(lines)
    # 금지 대상 프라이밍 토큰 0 (Codex HIGH-5)
    for token in ("E1", "E2", "CAM", "marker", "wedge"):
        assert token not in text
    # 카메라·피사체 좌표는 전부 직렬화
    assert "camera" in text and "(x=" in text
    for pl in _geometry()["entity_placements"]:
        assert pl["subject_en"] in text


def test_geometry_text_lines_reject_invalid():
    from app.modules.pipeline.outdoor_marker_map import (
        build_geometry_text_lines,
    )

    g = _geometry()
    g["entity_placements"] = []
    with pytest.raises(ValueError):
        build_geometry_text_lines(g)


def test_sketch_prompt_v4_uses_text_geometry_not_markers():
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    out = build_marker_sketch_prompt(
        shot_desc="SAMPLE shot",
        place_text="SAMPLE place",
        geometry=_geometry(),
        pose_clauses=["POSE SAMPLE"],
        carried_en="SAMPLE bag",
        camera_direction_en="high angle",
        seed_look_attached=False,
        prompt_version="4",
    )
    assert "STAGING GEOMETRY" in out
    assert "MAP SLOT MEANINGS" not in out
    import re as _re

    # 슬롯 코드·CAM 토큰 0 (단어 경계 — CAMERA DIRECTION 절과 구분)
    for token in (r"\bE1\b", r"\bE2\b", r"\bCAM\b"):
        assert not _re.search(token, out)
    assert "high angle" in out and "SAMPLE shot" in out
    assert "POSE SAMPLE" in out and "SAMPLE bag" in out


def test_sketch_prompt_v4_rejects_seed_look():
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    with pytest.raises(ValueError, match="seed_look"):
        build_marker_sketch_prompt(
            shot_desc="SAMPLE",
            place_text="SAMPLE",
            geometry=_geometry(),
            pose_clauses=[],
            carried_en="",
            seed_look_attached=True,
            prompt_version="4",
        )


def test_render_marker_map_is_deprecated_not_wired():
    """PIL 렌더는 보존만 — 프로덕션 스텝에서 참조 0 (지시 ①)."""
    from app.modules.pipeline.outdoor_marker_map import render_marker_map

    assert "DEPRECATED" in render_marker_map.__doc__


def test_validator_rejects_struct_tokens_in_subject():
    """배치 리뷰 HIGH-5: subject_en 의 구조 토큰(E<n>/CAM)은 결정론
    reject — ID-free 직렬화가 오염 데이터로 뚫리지 않게 봉인."""
    g = _geometry()
    g["entity_placements"][0]["subject_en"] = "E1 CAM marker subject"
    out = validate_marker_geometry(g)
    assert any("구조 토큰" in v for v in out)
    # 자연어 명사구(소문자 camera 등)는 통과
    g2 = _geometry()
    g2["entity_placements"][0]["subject_en"] = "a man holding a camera"
    assert not any("구조 토큰" in v for v in validate_marker_geometry(g2))


def test_geometry_text_lines_have_no_numeric_fov():
    """배치 리뷰 HIGH-6: 고정 화각 수치 하드코딩 금지 — 프레이밍은
    camera_direction·샷 텍스트 SOT."""
    from app.modules.pipeline.outdoor_marker_map import (
        GEOMETRY_TEXT_VERSION,
        build_geometry_text_lines,
    )

    text = "\n".join(build_geometry_text_lines(_geometry()))
    assert "degrees" not in text and "56" not in text
    assert "field of view" in text  # 정성 서술은 유지
    assert GEOMETRY_TEXT_VERSION == 6  # 직렬화 계약 bump (지문 드리프트)


def test_validator_non_string_subject_is_violation_not_exception():
    """재리뷰 NARROW-4: non-string subject_en = 예외가 아니라 위반 반환
    (validator 자체 완결·fail-closed 재시도 계약)."""
    g = _geometry()
    g["entity_placements"][0]["subject_en"] = {"nested": "dict"}
    out = validate_marker_geometry(g)
    assert any("문자열 아님" in v for v in out)
    g2 = _geometry()
    g2["entity_placements"][0]["subject_en"] = 42
    assert any("문자열 아님" in v for v in validate_marker_geometry(g2))


# ── v5 (2026-07-24 사용자 지시): 마커 맵 단계 복원 — i2i ──────────────


def test_v5_annotate_prompt_draws_markers_i2i():
    """i2i 마커 작화 계약: CAM+시선+슬롯 좌표 명시, '그 외 무변경' 잠금.
    annotate 프롬프트만 슬롯 라벨을 노출한다(작화 대상이 마커 자체)."""
    import pytest

    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_annotate_prompt,
    )

    p = build_marker_annotate_prompt(
        geometry=_geometry(), prompt_version="5")
    assert "STAGING MARKERS" in p
    assert "labelled CAM" in p
    assert "Change NOTHING else" in p
    assert "MARKERS TO DRAW:" in p
    assert "- CAM (camera): circle at (x=" in p
    assert "- E1: circle at (x=" in p
    # 캔ary 실측 반영: 분율 해석·스냅 금지·배치 의도 병기
    assert "fractions of the FULL canvas" in p
    assert "Do NOT snap a marker" in p
    assert "PLACEMENT INTENT" in p
    assert "SAMPLE 근거" in p
    # rationale 결손=의도 절 생략 (dangling 금지)
    g = _geometry()
    g.pop("rationale_ko", None)
    p2 = build_marker_annotate_prompt(geometry=g, prompt_version="5")
    assert "PLACEMENT INTENT" not in p2
    # v4 이하에는 annotate 계약 없음 — fail-closed
    with pytest.raises(ValueError, match="v5"):
        build_marker_annotate_prompt(
            geometry=_geometry(), prompt_version="4")


def test_v5_marker_check_contract():
    """마커 맵 VLM 검증: 존재·근사 위치·플랜 보존 축+스키마 shape."""
    import pytest

    from app.modules.pipeline.outdoor_marker_map import (
        MARKER_VIOLATION_AXES,
        build_marker_check_schema,
        build_marker_check_user_lines,
        load_marker_check_sys,
    )

    sys_txt = load_marker_check_sys("5")
    assert "markers_ok" in sys_txt
    assert "otherwise unaltered" in sys_txt
    schema = build_marker_check_schema()
    # v16 (2026-08-25): 반려 사유를 축으로 받는 `violations` 추가 —
    # 세 판 다 통과 못 했을 때 무엇 때문에 걸렸는지를 고르는 쪽에
    # 보여 주는 재료다(pick_best_marker_map). 앞 두 칸은 불변.
    assert schema["required"] == ["markers_ok", "details_ko", "violations"]
    vio = schema["properties"]["violations"]["items"]["properties"]
    assert vio["axis"]["enum"] == list(MARKER_VIOLATION_AXES)
    lines = build_marker_check_user_lines(_geometry())
    assert lines[0].startswith("- CAM expected at (x=")
    assert any(ln.startswith("- E1 expected at (x=") for ln in lines)
    with pytest.raises(ValueError, match="v5"):
        load_marker_check_sys("4")


def test_v5_sketch_head_references_marker_map():
    """v5 스케치: 참조=마커 맵 전제(마커=staging aid), STAGING GEOMETRY
    텍스트 병기 유지, 스케치 프레임 마커 렌더 금지(no_marker) 승계."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    p = build_marker_sketch_prompt(
        shot_desc="달아나는 인물을 바라본다",
        place_text="SAMPLE coastal place",
        geometry=_geometry(),
        pose_clauses=[],
        carried_en="",
        camera_direction_en="eye-level",
        seed_look_attached=False,
        prompt_version="5",
    )
    assert "MARKER-ANNOTATED TOP-DOWN SITE PLAN" in p
    assert "staging aids, NOT site content" in p
    assert "STAGING GEOMETRY" in p
    assert "the camera stands at (x=" in p
    # 마커는 맵에만 — 프레임 렌더 금지 계약 승계. 팩 텍스트는 공백 정규화
    # 후 대조한다(하드랩 위치는 재래핑으로 움직이는 서식일 뿐이고, 구판의
    # `or "NOT CONTENT"` 우회는 뒷항이 늘 참이라 사실상 무검사였다).
    assert "do NOT draw the plan itself" in " ".join(p.split())


def test_v4_stems_unchanged_by_v5():
    """v4 발행본 불변 — v5 신규 문구는 v4 에 없음."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    p4 = build_marker_sketch_prompt(
        shot_desc="d", place_text="p", geometry=_geometry(),
        pose_clauses=[], carried_en="", camera_direction_en="",
        seed_look_attached=False, prompt_version="4",
    )
    assert "MARKER-ANNOTATED" not in p4
    assert "attached TOP-DOWN SITE PLAN" in p4


# ── geometry v2 / sketch v6 (Codex AGREE_WITH_CONDITIONS) ────────────


def _geometry_anchored(**over):
    g = _geometry(**over)
    g["camera"]["origin_anchor_en"] = (
        "near the south-east corner of the paved area")
    g["camera"]["look_target_anchor_en"] = (
        "toward the front of the shelter")
    for pl in g["entity_placements"]:
        pl["anchor_en"] = "on the open ground beside the shelter"
    return g


def test_v2_validator_requires_split_anchors():
    """조건 ①⑤: entity anchor_en+camera origin/look_target anchor 분리
    required — 한쪽 결손·비문자·구조/legend 코드 누출=위반."""
    from app.modules.pipeline.outdoor_marker_map import (
        validate_marker_geometry,
    )

    ok = _geometry_anchored()
    assert validate_marker_geometry(ok, require_anchors=True) == []
    # 한쪽(look_target)만 결손
    g = _geometry_anchored()
    del g["camera"]["look_target_anchor_en"]
    out = validate_marker_geometry(g, require_anchors=True)
    assert any("look_target_anchor_en 결손" in v for v in out)
    assert not any("origin_anchor_en 결손" in v for v in out)
    # entity anchor 비문자
    g = _geometry_anchored()
    g["entity_placements"][0]["anchor_en"] = 42
    out = validate_marker_geometry(g, require_anchors=True)
    assert any("anchor_en 이 문자열 아님" in v for v in out)
    # 구조/legend 코드 누출 (E1/CAM 뿐 아니라 B1 류 legend 코드도)
    for bad in ("beside marker E1", "left of CAM", "in front of B1"):
        g = _geometry_anchored()
        g["camera"]["origin_anchor_en"] = bad
        out = validate_marker_geometry(g, require_anchors=True)
        assert any("코드 토큰" in v for v in out), bad
    # require 없이도 존재하는 오염 필드는 형식 검증 (fail-closed)
    g = _geometry_anchored()
    g["entity_placements"][0]["anchor_en"] = "bad\x07anchor near shelter"
    out = validate_marker_geometry(g)
    assert any("제어문자" in v for v in out)
    # require 없음+anchor 부재=기존 동작 불변
    assert validate_marker_geometry(_geometry()) == []


def test_v2_schema_gates_anchor_fields():
    """geometry 스키마: include_anchors=True 만 anchor required —
    기본은 기존 byte-identical."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_geometry_schema,
    )

    base = build_marker_geometry_schema()
    assert "anchor_en" not in str(base)
    anchored = build_marker_geometry_schema(include_anchors=True)
    pl = anchored["properties"]["entity_placements"]["items"]
    assert "anchor_en" in pl["required"]
    cam = anchored["properties"]["camera"]
    assert "origin_anchor_en" in cam["required"]
    assert "look_target_anchor_en" in cam["required"]


def test_v6_annotate_anchor_is_placement_authority():
    """조건 ②⑤: v6 annotate=anchor 권위+coarse 분율, rationale 병기
    폐기, anchor 결손=fail-closed. v5 발행본 계약 불변."""
    import pytest

    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_annotate_prompt,
    )

    p6 = build_marker_annotate_prompt(
        geometry=_geometry_anchored(), prompt_version="6")
    assert "PLACEMENT AUTHORITY" in p6
    assert "the landmark description wins" in p6
    assert "near the south-east corner of the paved area" in p6
    assert "toward the front of the shelter" in p6
    assert "on the open ground beside the shelter" in p6
    assert "coarse estimate x=" in p6
    assert "PLACEMENT INTENT" not in p6
    assert "Change NOTHING else" in p6
    with pytest.raises(ValueError, match="anchored annotate"):
        build_marker_annotate_prompt(
            geometry=_geometry(), prompt_version="6")
    # v5 불변: 분율 권위+PLACEMENT INTENT 유지, anchor 없이 동작
    p5 = build_marker_annotate_prompt(
        geometry=_geometry(), prompt_version="5")
    assert "PLACEMENT INTENT" in p5
    assert "PLACEMENT AUTHORITY" not in p5


def test_v6_check_and_text_lines_carry_anchors():
    """조건 ②⑤: check user lines·geometry 텍스트 직렬화에 동일 anchor
    문장 전달, 좌표=coarse estimate 표기. anchor 부재=기존 표기."""
    from app.modules.pipeline.outdoor_marker_map import (
        GEOMETRY_TEXT_VERSION,
        build_geometry_text_lines,
        build_marker_check_user_lines,
    )

    g = _geometry_anchored()
    check = "\n".join(build_marker_check_user_lines(g))
    assert "CAM expected near the south-east corner" in check
    # NARROW-4: 문법 가정 없는 중립 라벨 — 이중 전치사 불가
    assert "LOOK TARGET: toward the front of the shelter" in check
    assert "toward toward" not in check
    assert "coarse estimate" in check
    text = "\n".join(build_geometry_text_lines(g))
    assert "the camera stands near the south-east corner" in text
    assert "LOOK TARGET: toward the front of the shelter" in text
    assert "toward toward" not in text
    assert "coarse estimate" in text
    assert "on the open ground beside the shelter" in text
    assert GEOMETRY_TEXT_VERSION == 6
    # anchor 부재 geometry=기존 수치 표기 (legacy 직렬화 경로 보존)
    legacy = "\n".join(build_geometry_text_lines(_geometry()))
    assert "the camera stands at (x=" in legacy
    assert "coarse estimate" not in legacy


def test_v2_geometry_schema_versioning_contract():
    """조건 ④: geometry v2 발행·계약 상수 — run 경로가 버전으로 스키마/
    검증을 게이트."""
    from app.modules.pipeline.outdoor_marker_map import (
        _ANCHOR_GEOMETRY_PACKS,
        MARKER_ANNOTATE_CONTRACT_VERSION,
        MARKER_GEOMETRY_CONTRACT_VERSION,
        resolve_prompt_version,
    )

    assert resolve_prompt_version("2").startswith("2.")
    assert _ANCHOR_GEOMETRY_PACKS == {"2", "3", "4"}
    # v4(대면 관계)는 계약 상수를 올리지 않는다 — 팩명이 이미 지문에
    # 실려 v4 샷만 무효화되고, 이 상수는 팩 무관 전량 스탬프라 구 팩
    # 프로젝트까지 헛되이 재저작시킨다 (outdoor_marker_map 주석 참조).
    assert MARKER_GEOMETRY_CONTRACT_VERSION == 3
    assert MARKER_ANNOTATE_CONTRACT_VERSION == 3


# ── v7 (Codex 재리뷰): 스케치 조립 분기·소비자 재검증 잠금 ──────────


def test_v7_sketch_prompt_uses_anchor_geometry_branch():
    """BLOCKING-1 회귀: 활성 anchor 팩의 스케치 조립=geometry-text
    분기 — anchor/coarse 절 포함, legacy MAP SLOT MEANINGS 부재."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    p7 = build_marker_sketch_prompt(
        shot_desc="달아나는 인물을 바라본다",
        place_text="SAMPLE coastal place",
        geometry=_geometry_anchored(),
        pose_clauses=[],
        carried_en="",
        camera_direction_en="eye-level",
        seed_look_attached=False,
        prompt_version="7",
    )
    assert "MAP SLOT MEANINGS" not in p7
    assert "STAGING GEOMETRY" in p7
    assert "the camera stands near the south-east corner" in p7
    assert "LOOK TARGET: toward the front of the shelter" in p7
    assert "coarse estimate" in p7
    # v7 staging head=마커 맵 권위 정합 (HIGH-2)
    assert "restate, marker by marker" in p7
    assert "neither ever overrides the marked plan" in p7
    assert "MARKER-ANNOTATED TOP-DOWN SITE PLAN" in p7


def test_v7_builders_fail_closed_without_anchors():
    """HIGH-3: sketch/check builder 의 재검증 계약 독립 성립 —
    anchor 팩에서 anchorless geometry=ValueError (수치 하강 금지)."""
    import pytest

    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_check_user_lines,
        build_marker_sketch_prompt,
    )

    with pytest.raises(ValueError, match="anchor"):
        build_marker_sketch_prompt(
            shot_desc="d", place_text="p", geometry=_geometry(),
            pose_clauses=[], carried_en="", camera_direction_en="",
            seed_look_attached=False, prompt_version="7",
        )
    with pytest.raises(ValueError, match="anchor"):
        build_marker_check_user_lines(_geometry(), require_anchors=True)
    # require 미지정(구팩 소비)=기존 동작 보존
    assert build_marker_check_user_lines(_geometry())


def test_v7_pack_membership_and_v6_immutable():
    """조건 ④ 승계: v7 활성 게이트 3종 편입, v6 발행본 불변(legacy
    staging head 문구 유지)."""
    from app.modules.pipeline.outdoor_marker_map import (
        _ANCHOR_SKETCH_PACKS,
        _ANNOTATE_PACKS,
        _GEOMETRY_TEXT_PACKS,
        resolve_sketch_pack_version,
    )
    from app.modules.prompt_loader import load_prompt

    assert "7" in _GEOMETRY_TEXT_PACKS
    assert "7" in _ANNOTATE_PACKS and "7" in _ANCHOR_SKETCH_PACKS
    assert resolve_sketch_pack_version("7").startswith("7.")
    head6 = load_prompt(
        "marker_map_sketch", "staging_geometry_head",
        version=resolve_sketch_pack_version("6"))
    assert "restate, marker by marker" not in head6


# ── v8 (2026-07-25): lane 스케치 naturalism 이식 ─────────────────────


def test_v8_sketch_carries_naturalism_default_only():
    """lane 자연 연기 사각 봉합: v8 스케치=naturalism 절 포함(차렷·렌즈
    응시 default 금지, 명시 연출 우선), no_marker 앞 배치. 일반 콘티
    (shot_conti_light v5)와 byte 동일 문구 — 계약 드리프트 방지."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
        resolve_sketch_pack_version,
    )
    from app.modules.prompt_loader import load_prompt

    p8 = build_marker_sketch_prompt(
        shot_desc="달아나는 인물을 바라본다",
        place_text="SAMPLE coastal place",
        geometry=_geometry_anchored(),
        pose_clauses=[],
        carried_en="",
        camera_direction_en="eye-level",
        seed_look_attached=False,
        prompt_version="8",
    )
    assert "NATURAL PERFORMANCE" in p8
    assert "stiff attention stance" in p8
    assert "blank stare into the lens" in p8
    # default-only — 명시 연출 우선 계약 승계
    assert "clause never overrides it" in p8
    # 마커 금지절보다 앞 (연기 계약 뒤에 하드 금지가 최후미)
    assert p8.index("NATURAL PERFORMANCE") < p8.index(
        "STAGING REFERENCE, NOT CONTENT")
    # v7 anchor 계약 전부 승계
    assert "MAP SLOT MEANINGS" not in p8
    assert "LOOK TARGET:" in p8 and "restate, marker by marker" in p8
    # 일반 콘티 스템과 byte 동일 (드리프트 방지)
    lane_stem = load_prompt(
        "marker_map_sketch", "naturalism_clause",
        version=resolve_sketch_pack_version("8"))
    conti_stem = load_prompt(
        "shot_conti_light", "naturalism_clause",
        version="5.202607222353")
    assert lane_stem == conti_stem
    # v7 발행본 불변
    p7 = build_marker_sketch_prompt(
        shot_desc="d", place_text="p", geometry=_geometry_anchored(),
        pose_clauses=[], carried_en="", camera_direction_en="",
        seed_look_attached=False, prompt_version="7",
    )
    assert "NATURAL PERFORMANCE" not in p7


def test_v8_membership():
    from app.modules.pipeline.outdoor_marker_map import (
        _ANCHOR_SKETCH_PACKS,
        _ANNOTATE_PACKS,
        _GEOMETRY_TEXT_PACKS,
        _NATURALISM_SKETCH_PACKS,
        resolve_sketch_pack_version,
    )

    assert "8" in _GEOMETRY_TEXT_PACKS
    assert "8" in _ANNOTATE_PACKS and "8" in _ANCHOR_SKETCH_PACKS
    assert {"8", "9", "10", "11", "12"} <= _NATURALISM_SKETCH_PACKS
    assert resolve_sketch_pack_version("8").startswith("8.")


# ── v3 geometry (2026-07-25 사용자 지적①③): 시야 쐐기·facing·화면 투영 ──


def _geometry_view():
    """v3 geometry — anchor + 시야 쐐기 + facing (E1 이 E2 를 봄).
    카메라는 아래(y 큰 쪽)에서 위를 보고, E1=화면 왼쪽 / E2=오른쪽."""
    return {
        "camera": {
            "origin": {"x": 0.5, "y": 0.8},
            "look_target": {"x": 0.5, "y": 0.5},
            "origin_anchor_en": "on the paved apron by the roadside edge",
            "look_target_anchor_en": "on the apron in front of the shelter",
            "view_left": {"x": 0.2, "y": 0.5},
            "view_right": {"x": 0.8, "y": 0.5},
            "view_left_anchor_en": "at the left end of the shelter front",
            "view_right_anchor_en": "at the right end of the paved apron",
        },
        "entity_placements": [
            {
                "slot": "E1", "subject_en": "woman calling",
                "x": 0.4, "y": 0.6,
                "anchor_en": "on the apron left of the shelter front",
                "facing": {"x": 0.6, "y": 0.55},
                "facing_anchor_en": "toward the apron right of the shelter",
            },
            {
                "slot": "E2", "subject_en": "woman running away",
                "x": 0.6, "y": 0.55,
                "anchor_en": "on the apron right of the shelter front",
                "facing": {"x": 0.75, "y": 0.5},
                "facing_anchor_en": "toward the far right end of the apron",
            },
        ],
        "rationale_ko": "외치는 인물과 달아나는 인물의 좌우 배치.",
    }


def test_v3_schema_and_validator_require_view_and_facing():
    from app.modules.pipeline.outdoor_marker_map import (
        _VIEW_GEOMETRY_PACKS,
        build_marker_geometry_schema,
        resolve_prompt_version,
    )

    assert _VIEW_GEOMETRY_PACKS == {"3", "4"}
    assert resolve_prompt_version("3").startswith("3.")
    sch = build_marker_geometry_schema(
        include_anchors=True, include_view=True)
    cam_req = sch["properties"]["camera"]["required"]
    assert {"view_left", "view_right", "view_left_anchor_en",
            "view_right_anchor_en"} <= set(cam_req)
    pl_req = sch["properties"]["entity_placements"]["items"]["required"]
    assert {"facing", "facing_anchor_en"} <= set(pl_req)
    # v2 스키마(=include_view False)는 신규 필드를 요구하지 않는다
    old = build_marker_geometry_schema(include_anchors=True)
    assert "view_left" not in old["properties"]["camera"]["properties"]
    # v3 필수 필드 결손 = 위반
    g = _geometry_view()
    g["camera"].pop("view_left")
    assert validate_marker_geometry(g, require_view=True)


def test_v3_wedge_geometry_guards():
    """순수 기하 가드: 쐐기 개폐·look_target 포함·피사체 쐐기 내부·
    facing 자기겹침 (i2i 가 그릴 수 없는 입력을 재시도로 되돌린다)."""
    ok = _geometry_view()
    assert validate_marker_geometry(
        ok, require_anchors=True, require_view=True) == []

    # 쐐기가 닫힘 (좌/우 경계 동일)
    g = _geometry_view()
    g["camera"]["view_right"] = dict(g["camera"]["view_left"])
    assert any("쐐기가 열리지" in v for v in validate_marker_geometry(
        g, require_view=True))

    # look_target 이 쐐기 밖 (카메라 뒤쪽을 봄)
    g = _geometry_view()
    g["camera"]["look_target"] = {"x": 0.5, "y": 0.95}
    assert any("쐐기" in v for v in validate_marker_geometry(
        g, require_view=True))

    # 피사체가 쐐기 밖 — 이 샷이 담는 인물은 프레임 안이어야 한다
    g = _geometry_view()
    g["entity_placements"][1]["x"] = 0.02
    g["entity_placements"][1]["y"] = 0.95
    assert any("쐐기 밖" in v for v in validate_marker_geometry(
        g, require_view=True))

    # facing 이 자기 위치와 겹침 = 방향 미성립
    g = _geometry_view()
    g["entity_placements"][0]["facing"] = {"x": 0.4, "y": 0.6}
    assert any("겹침" in v for v in validate_marker_geometry(
        g, require_view=True))


def test_v5_text_lines_add_screen_terms_only_for_view_geometry():
    """★HIGH-5 회귀 가드: 화면 기준 병기는 v3(facing) 데이터에만 —
    구 팩(v1/v2) 직렬화 문장은 byte-identical 로 남는다."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_geometry_text_lines,
    )

    legacy = "\n".join(build_geometry_text_lines(_geometry()))
    anchored = "\n".join(build_geometry_text_lines(_geometry_anchored()))
    for text in (legacy, anchored):
        assert "part of the frame" not in text
        assert "faces toward the" not in text

    view = "\n".join(build_geometry_text_lines(_geometry_view()))
    # 화면 좌/우 + 시선 대상(다른 피사체 위치와 겹치면 그 피사체를 명명)
    assert "in the LEFT part of the frame" in view
    assert "in the RIGHT part of the frame" in view
    assert "faces toward the RIGHT of the frame" in view
    # 시선 대상은 동격절이 아니라 주어를 명시한 독립절 (리뷰 Important-1)
    assert "this subject is turned toward the woman running away" in view
    assert "that is, at" not in view
    # 쐐기 경계도 카메라 라인에 서술된다
    assert "LEFT edge cuts through" in view


def test_v9_annotate_and_check_carry_wedge_and_facing():
    """마커 작화/검증 계약: 쐐기·facing 화살표·슬롯 설명 라벨."""
    from app.modules.pipeline.outdoor_marker_map import (
        _VIEW_SKETCH_PACKS,
        build_marker_annotate_prompt,
        build_marker_check_user_lines,
    )

    assert {"9", "10", "11", "12"} <= _VIEW_SKETCH_PACKS
    p = build_marker_annotate_prompt(
        geometry=_geometry_view(), prompt_version="12")
    assert "E1 (woman calling): circle" in p       # 슬롯+설명 라벨
    assert "LEFT edge cuts through" in p           # 쐐기 경계
    assert "facing toward the apron right of the shelter" in p
    check = "\n".join(build_marker_check_user_lines(
        _geometry_view(), require_anchors=True, require_view=True))
    assert "view wedge" in check and "facing" in check


# '뒷모습'(away 인용)과 '옆모습'(profile 인용)이 모두 실재한다 — 선언
# 관계와 정합하는 인용을 골라야 provenance 대조가 성립한다(Minor-4).
SAMPLE_FIXTURE_SHOT_TEXT = (
    "한쪽 발이 공중에 뜬 달리기 자세인 인물 B의 뒷모습을 배경으로, "
    "두 손을 입가에 모으고 입을 크게 벌린 인물 A의 상반신 옆모습."
)


def _fixture_geometry(relation="unspecified", evidence="",
                      facing=(0.88, 0.52)):
    """카메라가 (0.64,0.72)에서 (0.56,0.575)를 보는 기준 배치.

    fwd 는 위-왼쪽을 향한다 — facing 을 바꿔 dep 부호를 조절한다.
    """
    return {
        "camera": {
            "origin": {"x": 0.64, "y": 0.72},
            "look_target": {"x": 0.56, "y": 0.575},
            "origin_anchor_en": "On the roadway in front of the site.",
            "look_target_anchor_en": "Inside the site by the sign.",
            "view_left": {"x": 0.45, "y": 0.51},
            "view_right": {"x": 0.83, "y": 0.56},
            "view_left_anchor_en": "Inside the site left of the shelter.",
            "view_right_anchor_en": "Inside the site at its right edge.",
        },
        "entity_placements": [{
            "slot": "E1",
            "subject_en": "running figure with one foot airborne",
            "x": 0.78, "y": 0.55,
            "anchor_en": "Inside the right side of the site.",
            "facing": {"x": facing[0], "y": facing[1]},
            "facing_anchor_en": "Farther right inside the site.",
            "camera_facing_relation": relation,
            "camera_facing_evidence": evidence,
        }],
        "rationale_ko": "픽스처 배치.",
    }


def test_facing_relation_schema_is_pack_gated():
    from app.modules.pipeline.outdoor_marker_map import (
        CAMERA_FACING_RELATIONS,
        _FACING_RELATION_GEOMETRY_PACKS,
        build_marker_geometry_schema,
    )

    assert _FACING_RELATION_GEOMETRY_PACKS == {"4"}
    # enum 은 리터럴로 고정 — 구현이 읽는 그 상수로 기대값을 되만들면
    # (list(CAMERA_FACING_RELATIONS)) 항등식이라 값이 바뀌어도 초록이다.
    assert list(CAMERA_FACING_RELATIONS) == [
        "toward_camera", "away_from_camera", "profile", "unspecified"]
    off = build_marker_geometry_schema(
        include_anchors=True, include_view=True)
    pl_off = off["properties"]["entity_placements"]["items"]
    assert "camera_facing_relation" not in pl_off["properties"]

    on = build_marker_geometry_schema(
        include_anchors=True, include_view=True,
        include_facing_relation=True)
    pl_on = on["properties"]["entity_placements"]["items"]
    assert pl_on["properties"]["camera_facing_relation"]["enum"] == [
        "toward_camera", "away_from_camera", "profile", "unspecified"]
    assert pl_on["properties"]["camera_facing_evidence"] == {
        "type": "string"}
    for key in ("camera_facing_relation", "camera_facing_evidence"):
        assert key in pl_on["required"]


def test_facing_camera_depth_sign():
    from app.modules.pipeline.outdoor_marker_map import facing_camera_depth

    g = _fixture_geometry()
    cam, pl = g["camera"], g["entity_placements"][0]
    dep = facing_camera_depth(cam, pl)
    # facing 이 오른쪽-위: 카메라 축 성분이 살짝 카메라 쪽(음수).
    # ★정확값으로 고정 — 구간(-eps<dep<0)으로 두면 vec/norm 정규화를
    # 빠뜨린 구현(-0.0220)도 통과해 docstring 이 약속한 '-1..+1 단위
    # 투영'이 풀린다. 경계값(_SCREEN_EPS) 중복 표기도 함께 제거.
    assert dep == pytest.approx(-0.2111, abs=1e-3)


def test_facing_relation_intervals_are_mutually_exclusive():
    """away / toward / profile 은 하나의 eps 로 완전 비중첩이어야 한다.

    dep=+0.139, eps=0.25 는 'dep>0'(away) 과 'abs(dep)<=eps'(profile) 를
    동시에 만족하던 구간 — away 는 반드시 거부돼야 한다. facing
    (0.86,0.49) 픽스처가 정확히 이 밴드를 만든다.
    """
    from app.modules.pipeline.outdoor_marker_map import (
        _SCREEN_EPS,
        validate_marker_geometry,
    )

    def _v(relation, facing):
        # facing 좌표 4종으로 네 구간을 덮는다 — dep = +0.961(>+eps) /
        # +0.139(0 과 +eps 사이) / -0.211(-eps 와 0 사이) / -0.899(<-eps).
        # ★+0.139 구간이 없으면 away 를 'dep>0' 으로 잘못 구현해도 나머지
        # 단언이 전부 통과한다(구 픽스처 3종의 실측 결함).
        # Minor-4: profile 에 '뒷모습'(뒤 시점 인용)을 붙이면 픽스처가
        # 자기모순 — 선언과 정합하는 인용을 쓴다(둘 다 SHOT TEXT 원문에
        # 실재하므로 provenance 는 어느 쪽이든 통과, 검사 대상은 구간).
        g = _fixture_geometry(
            relation=relation,
            evidence="옆모습" if relation == "profile" else "뒷모습",
            facing=facing)
        return validate_marker_geometry(
            g, require_anchors=True, require_view=True,
            require_facing_relation=True,
            shot_text=SAMPLE_FIXTURE_SHOT_TEXT)

    assert _SCREEN_EPS == 0.25
    # (0.70, 0.20) → facing 이 카메라 반대쪽으로 크게: dep > +eps
    assert _v("away_from_camera", (0.70, 0.20)) == []
    assert _v("profile", (0.70, 0.20)) != []
    # (0.86, 0.49) → dep 가 0 과 +eps 사이: profile 만 통과, away 는 거부
    assert _v("profile", (0.86, 0.49)) == []
    assert _v("away_from_camera", (0.86, 0.49)) != []
    # (0.88, 0.52) → dep 가 0 근처: profile 만 통과
    assert _v("profile", (0.88, 0.52)) == []
    assert _v("away_from_camera", (0.88, 0.52)) != []
    assert _v("toward_camera", (0.88, 0.52)) != []
    # (0.80, 0.95) → facing 이 카메라 쪽으로 크게: dep < -eps
    assert _v("toward_camera", (0.80, 0.95)) == []
    assert _v("profile", (0.80, 0.95)) != []


def test_facing_relation_evidence_provenance_both_ways():
    from app.modules.pipeline.outdoor_marker_map import (
        validate_marker_geometry,
    )

    def _v(relation, evidence, facing=(0.70, 0.20)):
        return validate_marker_geometry(
            _fixture_geometry(relation=relation, evidence=evidence,
                              facing=facing),
            require_anchors=True, require_view=True,
            require_facing_relation=True,
            shot_text=SAMPLE_FIXTURE_SHOT_TEXT)

    # 명시 relation + 원문에 실재하는 인용 = 통과
    assert _v("away_from_camera", "뒷모습") == []
    # 명시 relation + evidence 누락 = 거부
    assert any("evidence" in v for v in _v("away_from_camera", ""))
    # 명시 relation + 원문에 없는 인용(발명) = 거부
    assert any("원문에 없음" in v
               for v in _v("away_from_camera", "정면을 향해 선다"))
    # unspecified + evidence 존재 = 거부 (근거 없는 선언 세탁 차단)
    assert any("unspecified" in v for v in _v("unspecified", "뒷모습"))
    # unspecified + 빈 evidence = 통과 (기하 검사 대상 아님)
    assert _v("unspecified", "", facing=(0.88, 0.52)) == []


def test_facing_relation_authoring_path_needs_shot_text():
    """리뷰 지적 Minor-6(a): require_facing_relation=True + shot_text=""
    = 명시 relation 전량 거부(fail-closed).

    다음 스텝이 저작 경로를 배선하면서 SHOT TEXT 공급을 빠뜨리면 좌표가
    옳아도 재시도 루프를 소진한다 — 그 함정을 여기서 고정한다."""
    from app.modules.pipeline.outdoor_marker_map import (
        validate_marker_geometry,
    )

    # 좌표·인용 모두 정상인데 shot_text 만 미공급
    g = _fixture_geometry(relation="away_from_camera", evidence="뒷모습",
                          facing=(0.70, 0.20))
    out = validate_marker_geometry(
        g, require_anchors=True, require_view=True,
        require_facing_relation=True, shot_text="")
    assert any("원문에 없음" in v for v in out)
    # unspecified 는 인용 대조 대상이 아니므로 영향 없음
    assert validate_marker_geometry(
        _fixture_geometry(facing=(0.88, 0.52)),
        require_anchors=True, require_view=True,
        require_facing_relation=True, shot_text="") == []


def test_facing_relation_checked_even_when_flag_off():
    """리뷰 지적 Important-2: require=False·shot_text 없음이어도 존재하는
    relation 은 좌표와 대조한다(오염 선택 필드 통과 차단, anchor/view
    게이트와 동일 관례). 단 인용 대조는 성립하지 않으므로 침묵한다."""
    from app.modules.pipeline.outdoor_marker_map import (
        validate_marker_geometry,
    )

    # (a) 선언은 away_from_camera 인데 좌표는 dep=-0.211 (카메라 쪽)
    contaminated = _fixture_geometry(
        relation="away_from_camera", evidence="", facing=(0.88, 0.52))
    out = validate_marker_geometry(contaminated)
    assert any("관계를 만족하지 않는다" in v for v in out), out
    # (b) 같은 호출이 인용에 대해서는 불평하지 않는다 (SHOT TEXT 부재)
    assert not any("evidence" in v or "원문에 없음" in v for v in out), out
    # 좌표와 맞는 선언은 flag 없이도 통과 — 기존 소비자 무해
    assert validate_marker_geometry(
        _fixture_geometry(relation="toward_camera", evidence="",
                          facing=(0.80, 0.95))) == []
    # relation 필드가 아예 없는 geometry = 기존 동작 byte-identical
    assert validate_marker_geometry(_geometry()) == []
    assert validate_marker_geometry(_geometry_view()) == []


def test_facing_relation_skipped_when_camera_invalid():
    """리뷰 지적 Minor-4/6(b): 카메라가 없거나 dict 가 아니면 대면 관계
    검증을 건너뛴다 — camera 위반은 위 검사가 이미 보고하므로 같은
    사실이 재시도 힌트에 두 번 실리지 않는다."""
    from app.modules.pipeline.outdoor_marker_map import (
        validate_marker_geometry,
    )

    g = _fixture_geometry(relation="away_from_camera", evidence="뒷모습")
    g["camera"] = None
    out = validate_marker_geometry(
        g, require_facing_relation=True,
        shot_text=SAMPLE_FIXTURE_SHOT_TEXT)
    # 카메라 위반은 정확히 한 번
    assert [v for v in out if "camera 가 객체 아님" in v] == [
        "camera 가 객체 아님"]
    # 대면 관계 쪽에서 카메라를 다시 문제 삼지 않는다
    assert not any("카메라 대면 관계를 검증할 수 없음" in v for v in out)
    assert not any("카메라 축 성분" in v for v in out)
    # camera 키 자체가 없어도 동일 (예외 아님)
    g2 = _fixture_geometry(relation="away_from_camera", evidence="뒷모습")
    g2.pop("camera")
    out2 = validate_marker_geometry(g2, require_facing_relation=True)
    assert out2 and all(isinstance(v, str) for v in out2)
    assert not any("카메라 축 성분" in v for v in out2)


# ── v4 geometry 팩 (2026-07-26): 대면 관계 저작 계약 발행·selector 배선 ──


def test_geometry_pack_v4_gates_facing_relation():
    from app.modules.pipeline.outdoor_marker_map import (
        _ANCHOR_GEOMETRY_PACKS,
        _FACING_RELATION_GEOMETRY_PACKS,
        _VIEW_GEOMETRY_PACKS,
        resolve_prompt_version,
    )

    # v4 는 v3 의 anchor·view 계약을 승계하고 대면 관계를 추가한다
    assert "4" in _ANCHOR_GEOMETRY_PACKS
    assert "4" in _VIEW_GEOMETRY_PACKS
    assert _FACING_RELATION_GEOMETRY_PACKS == {"4"}
    # v4 팩 디렉터리가 실재해야 selector 가 해석된다
    assert resolve_prompt_version("4").startswith("4.")


def test_geometry_pack_v4_system_declares_relation_contract():
    from app.modules.pipeline.outdoor_marker_map import (
        _MODULE,
        resolve_prompt_version,
    )
    from app.modules.prompt_loader import load_prompt

    sys_txt = load_prompt(
        _MODULE, "system", version=resolve_prompt_version("4"))
    for token in ("camera_facing_relation", "camera_facing_evidence",
                  "away_from_camera", "toward_camera", "profile",
                  "unspecified"):
        assert token in sys_txt
    # 발명 인용 금지 계약이 명문화돼 있어야 한다
    assert "checked against the shot text" in sys_txt
    # v4 는 v3 저작 계약(anchor/wedge)을 그대로 상속한다 — 스키마는
    # 여전히 이 필드들을 required 로 요구하므로, 팩에서만 지시가 빠지면
    # 모델이 이유 없이 필수 필드를 지어내야 한다
    for token in ("anchor_en", "view_left", "facing_anchor_en"):
        assert token in sys_txt
    # v3 팩은 불변 — 신규 계약이 구 팩에 새지 않는다
    old_txt = load_prompt(
        _MODULE, "system", version=resolve_prompt_version("3"))
    assert "camera_facing_relation" not in old_txt


def test_run_v4_uses_shot_block_as_evidence_source():
    """★대면 관계 인용 대조 원문 = LLM 이 실제로 본 SHOT BLOCK 전체.

    build_shot_block 은 description 외에 '연출 카메라 메모' 줄도 보여준다
    — 그 줄에서 인용한 근거를 description 하고만 대조하면 원문에 없다며
    거부돼 전 샷이 재시도 루프를 소진한다(fail-closed 계약의 함정,
    test_facing_relation_authoring_path_needs_shot_text 와 짝).
    """
    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )

    # 인용 문구는 description 이 아니라 카메라 메모 줄에만 존재한다
    quote = "뒤에서 따라가는 와이드"
    shot = {"scene_index": 3, "shot_index": 1,
            "description": "인물이 길을 따라 멀어진다",
            "camera_direction": quote}
    g = _fixture_geometry(relation="away_from_camera", evidence=quote,
                          facing=(0.70, 0.20))
    seen = {}

    def fake(step, system, user, schema, **kw):
        seen["schema"] = schema
        seen["user"] = user
        seen["calls"] = seen.get("calls", 0) + 1
        return g

    kwargs = _run_kwargs(fake)
    kwargs["shot"] = shot
    out = run_marker_geometry_shot(**kwargs, prompt_version="4")
    assert out["attempts"] == 1 and seen["calls"] == 1
    # 대조 원문뿐 아니라 **모델이 실제로 본 프롬프트**에도 같은 줄이
    # 실려야 한다 — {shot_block} 치환이 깨지면 validator 만 통과하고
    # 모델은 본 적 없는 텍스트를 인용하라는 요구를 받는다(같은 괴리의
    # 반대편)
    assert quote in "\n".join(
        p.get("text", "") for p in seen["user"] if p.get("type") == "text")
    # 스키마 게이트도 함께 열려야 한다 (팩만 올리고 include 누락 방지)
    pl = seen["schema"]["properties"]["entity_placements"]["items"]
    assert {"camera_facing_relation", "camera_facing_evidence"} <= set(
        pl["required"])


def test_run_v4_rejects_relation_contradicted_by_coordinates():
    """v4 저작 경로가 실제로 validator 를 게이트한다 — 좌표와 어긋난
    선언은 재시도 소진 후 계약 위반."""
    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )

    quote = "뒷모습"
    shot = {"scene_index": 3, "shot_index": 1,
            "description": f"멀어지는 인물의 {quote}"}
    # 선언은 away_from_camera 인데 facing 은 카메라 쪽(dep=-0.211)
    bad = _fixture_geometry(relation="away_from_camera", evidence=quote,
                            facing=(0.88, 0.52))

    def fake(step, system, user, schema, **kw):
        return bad

    kwargs = _run_kwargs(fake)
    kwargs["shot"] = shot
    with pytest.raises(AppError) as ei:
        run_marker_geometry_shot(**kwargs, prompt_version="4",
                                 max_attempts=2)
    assert ei.value.code == "step.contract_violation.outdoor_marker_geometry"
    assert "관계를 만족하지 않는다" in ei.value.message


def test_run_v3_unchanged_by_v4_gate():
    """구 팩 소비는 byte-identical — relation 필드 없는 geometry 통과."""
    from app.modules.pipeline.outdoor_marker_map import (
        run_marker_geometry_shot,
    )

    seen = {}

    def fake(step, system, user, schema, **kw):
        seen["schema"] = schema
        return _geometry_view()

    out = run_marker_geometry_shot(**_run_kwargs(fake), prompt_version="3")
    assert out["attempts"] == 1
    pl = seen["schema"]["properties"]["entity_placements"]["items"]
    assert "camera_facing_relation" not in pl["properties"]


def test_lane_step_selector_raised_to_v4():
    """팩 발행=selector 배선까지가 한 단위 — 상수를 올리지 않으면
    v4 계약이 사문화된다(canon v3·lane_plan v4 실측 교훈)."""
    from app.core.steps.shot_conti_light_step import (
        LANE_GEOMETRY_PACK_VERSION,
    )
    from app.modules.pipeline.outdoor_marker_map import (
        _FACING_RELATION_GEOMETRY_PACKS,
    )

    assert LANE_GEOMETRY_PACK_VERSION == "4"
    assert LANE_GEOMETRY_PACK_VERSION in _FACING_RELATION_GEOMETRY_PACKS


# ── v6 직렬화 (2026-07-26): 앞/뒤 명시 + 선언 relation 우선 렌더 ──────


def test_facing_text_states_front_or_back_explicitly():
    from app.modules.pipeline.outdoor_marker_map import (
        _facing_screen_text,
    )

    g = _fixture_geometry(facing=(0.80, 0.95))  # dep < -eps
    txt = _facing_screen_text(g["camera"], g["entity_placements"][0])
    assert "from the front" in txt
    assert "back toward the camera" not in txt  # 중의성 어구 제거

    g2 = _fixture_geometry(facing=(0.70, 0.20))  # dep > +eps
    txt2 = _facing_screen_text(g2["camera"], g2["entity_placements"][0])
    assert "from behind" in txt2


def test_facing_text_prefers_declared_relation():
    """선언 relation 이 있으면 eps 추론이 아니라 그것을 문장화한다."""
    from app.modules.pipeline.outdoor_marker_map import (
        _facing_screen_text,
    )

    # dep 는 0 근처(eps 미달)지만 선언은 profile — 침묵하지 않고 서술
    g = _fixture_geometry(relation="profile", evidence="옆모습",
                          facing=(0.88, 0.52))
    txt = _facing_screen_text(g["camera"], g["entity_placements"][0])
    assert "side-on to the camera" in txt

    # unspecified 는 기존 eps 경로 — dep 미달이면 심도 문구 없음
    g2 = _fixture_geometry(relation="unspecified", facing=(0.88, 0.52))
    txt2 = _facing_screen_text(g2["camera"], g2["entity_placements"][0])
    assert "side-on to the camera" not in txt2
    assert "from behind" not in txt2 and "from the front" not in txt2

    # 계약 밖 문자열은 조용히 삼키지 않고 eps 경로로 흘린다 — 매핑
    # 조회로 KeyError 를 내면 직렬화가 통째로 죽는다(validator 가 이미
    # enum 을 거부하므로 여기서 다시 예외를 낼 이유가 없다).
    g3 = _fixture_geometry(relation="sideways", facing=(0.70, 0.20))
    txt3 = _facing_screen_text(g3["camera"], g3["entity_placements"][0])
    assert "from behind" in txt3

    # ★판별력(리뷰 지적 Minor-3): 위 케이스들은 dep 가 전부 eps 밴드
    # 안이라 "eps 가 침묵할 때만 선언을 본다"는 fallback 구현도 통과한다.
    # 여기서는 dep=+0.96 이라 eps 추론이 'from behind' 를 말하는데 선언은
    # profile — 두 값이 **충돌**해야 우선순위가 실제로 증명된다.
    # (검증 없이 순수 함수를 직접 부르는 것은 위 g3 와 동일 관례.)
    g4 = _fixture_geometry(relation="profile", evidence="옆모습",
                           facing=(0.70, 0.20))
    txt4 = _facing_screen_text(g4["camera"], g4["entity_placements"][0])
    assert "side-on to the camera" in txt4
    assert "from behind" not in txt4


def test_declared_relation_renders_into_geometry_text_lines():
    """소비자 배선 가드: 선언 relation 이 실제 직렬화 산문까지 도달한다
    (_facing_screen_text 만 고치고 호출부가 eps 값을 쓰면 무의미)."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_geometry_text_lines,
    )

    g = _fixture_geometry(relation="profile", evidence="옆모습",
                          facing=(0.88, 0.52))
    text = "\n".join(build_geometry_text_lines(g))
    # 좌/우(순수 투영)와 대면(선언값)이 같은 절에 병기된다
    assert ("faces toward the RIGHT of the frame and side-on to the "
            "camera, neither toward it nor away from it") in text

    # relation 필드가 없는 구 geometry = eps 경로 산문 (심도 문구만 교체)
    legacy = "\n".join(build_geometry_text_lines(_geometry_view()))
    assert "so the camera sees this subject from behind" in legacy
    assert "back toward the camera" not in legacy


# ── sketch 팩 v14 (2026-07-26): 마네킹 콘티 + 사진 참조 제거 + 관할 절 ──


def test_sketch_pack_v14_is_mannequin_and_has_no_plate_look():
    from app.modules.pipeline.outdoor_marker_map import (
        _GEOMETRY_TEXT_PACKS,
        _MANNEQUIN_SKETCH_PACKS,
        _NATURALISM_SKETCH_PACKS,
        _PLATE_LOOK_PACKS,
        _VIEW_SKETCH_PACKS,
    )

    assert _MANNEQUIN_SKETCH_PACKS == {"14", "15", "16"}
    # 사진 참조 계약이 없는 팩 — 첨부 시 기존 가드가 거부한다
    assert "14" not in _PLATE_LOOK_PACKS
    # v13 의 나머지 계약은 승계
    for s in (_GEOMETRY_TEXT_PACKS, _NATURALISM_SKETCH_PACKS,
              _VIEW_SKETCH_PACKS):
        assert "14" in s
        assert "15" in s
        assert "16" in s


def test_sketch_v14_prompt_uses_mannequin_style_and_rejects_plate():
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    g = _fixture_geometry(relation="away_from_camera", evidence="뒷모습",
                          facing=(0.70, 0.20))
    p = build_marker_sketch_prompt(
        shot_desc=SAMPLE_FIXTURE_SHOT_TEXT,
        place_text="SAMPLE_FIXTURE_PLACE",
        geometry=g, pose_clauses=[], carried_en="",
        camera_direction_en="Eye level, tightening on the upper body.",
        seed_look_attached=False, plate_attached=False,
        prompt_version="14")
    assert "MANNEQUIN BLOCKING SKETCH" in p
    assert "LIGHT STORYBOARD FRAME" not in p
    # 결함 ① 관할: 수평은 staging 우선. 팩 원문은 ~70자 폭으로 접혀
    # 있으므로 공백 정규화 후 대조한다 — 줄바꿈 위치는 계약이 아니다.
    assert "the staging above wins" in " ".join(p.split())
    # 사진 조항이 없는 팩에 사진 첨부 = 거부
    with pytest.raises(ValueError):
        build_marker_sketch_prompt(
            shot_desc=SAMPLE_FIXTURE_SHOT_TEXT,
            place_text="SAMPLE_FIXTURE_PLACE",
            geometry=g, pose_clauses=[], carried_en="",
            camera_direction_en="", seed_look_attached=False,
            plate_attached=True, prompt_version="14")


def test_sketch_v14_annotate_and_check_contracts_are_inherited():
    """v14 는 v13 의 마커 작화·검증 계약을 그대로 승계한다 — 집합 하나만
    빠뜨려도 lane 경로가 '계약 없음' ValueError 로 죽는다(팩 집합 누락은
    스타일 교체와 달리 정상 경로에서 곧바로 fail-closed)."""
    from app.modules.pipeline.outdoor_marker_map import (
        _ANCHOR_SKETCH_PACKS,
        _ANNOTATE_PACKS,
        build_marker_annotate_prompt,
        load_marker_check_sys,
        resolve_sketch_pack_version,
    )

    assert "14" in _ANNOTATE_PACKS and "14" in _ANCHOR_SKETCH_PACKS
    assert resolve_sketch_pack_version("14").startswith("14.")
    assert build_marker_annotate_prompt(
        geometry=_geometry_view(), prompt_version="14")
    assert load_marker_check_sys("14")


def test_sketch_v14_staging_head_announces_profile_shape():
    """T3 리뷰 지적: 선언 relation 이 profile 을 낳는데 head 의 열거가
    '앞/뒤'뿐이면 모델이 예고 없는 절을 받는다 — 열거에 side-on 포함."""
    from app.modules.pipeline.outdoor_marker_map import (
        resolve_sketch_pack_version,
    )
    from app.modules.prompt_loader import load_prompt

    head = " ".join(load_prompt(
        "marker_map_sketch", "staging_geometry_head",
        version=resolve_sketch_pack_version("14")).split())
    assert "toward or away from the camera or side-on to it in profile" \
        in head
    # v13 발행본 불변 — 신규 문구가 구 팩에 새지 않는다
    head13 = " ".join(load_prompt(
        "marker_map_sketch", "staging_geometry_head",
        version=resolve_sketch_pack_version("13")).split())
    assert "side-on" not in head13
    assert "the staging above wins" not in head13


def test_sketch_v14_naturalism_drops_eye_vocabulary():
    """T4 리뷰 지적: 마네킹 팩의 naturalism 절이 눈/시선 어휘로 말하면
    STYLE(무안면)과 정면 충돌한다 — 이 스템은 조립 후미(고살성 위치)라
    충돌이 그대로 작화에 실려 '얼굴을 그려도 된다'로 읽힌다. 코드 쪽
    geometry 텍스트 절이 이미 같은 이유로 gaze→turned toward 로 바뀌었고,
    팩 사본도 같은 교정을 받는다. v13 사본은 원문 유지 — 두 팩을 한
    테스트에 걸어 분기가 의도된 것임을 못 박는다(v13 대조가 없으면 v14
    통과가 '스템이 비었다'로도 성립한다)."""
    from app.modules.pipeline.outdoor_marker_map import (
        resolve_sketch_pack_version,
    )
    from app.modules.prompt_loader import load_prompt

    def _stem(v: str) -> str:
        return " ".join(load_prompt(
            "marker_map_sketch", "naturalism_clause",
            version=resolve_sketch_pack_version(v)).split())

    stem14 = _stem("14")
    for word in ("gaze", "stare", "eyes"):
        assert word not in stem14.lower(), word
    # 몸 방향 서술로 대체 — 마네킹은 가슴·골반·발·머리 타원만이 단서
    assert "head-oval and chest turned toward the target" in stem14
    assert "a head-oval squared flat to the lens" in stem14
    # default-only·명시 연출 우선 계약은 v13 과 동일하게 유지
    assert stem14.startswith("NATURAL PERFORMANCE (default only")
    assert "stiff attention stance" in stem14
    assert "clause never overrides it" in stem14
    # v13 은 눈 어휘를 계속 갖는다 (구 팩 불변 + 분기 판별)
    stem13 = _stem("13")
    assert "gaze on the target" in stem13
    assert "blank stare into the lens" in stem13
    assert "head-oval" not in stem13


def test_sketch_v13_unchanged_by_v14_style_switch():
    """구 팩 소비는 byte-identical — 스타일 스템 분기가 v13 을 건드리지
    않는다(마네킹이 구 팩으로 새면 최종 스틸까지 유출된다)."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    kwargs = dict(
        shot_desc=SAMPLE_FIXTURE_SHOT_TEXT,
        place_text="SAMPLE_FIXTURE_PLACE",
        geometry=_fixture_geometry(relation="away_from_camera",
                                   evidence="뒷모습", facing=(0.70, 0.20)),
        pose_clauses=[], carried_en="",
        camera_direction_en="Eye level.", seed_look_attached=False)
    p13 = build_marker_sketch_prompt(
        **kwargs, plate_attached=False, prompt_version="13")
    assert "LIGHT STORYBOARD FRAME" in p13
    assert "MANNEQUIN" not in p13
    # 부정 단언도 공백 정규화 후 대조한다 — 팩 원문은 ~70자 폭으로 접혀
    # 있어 유출된 문단이 줄바꿈 하나로 이 가드를 그대로 통과한다
    # (긍정 counterpart 와 같은 정규화).
    assert "the staging above wins" not in " ".join(p13.split())
    # v13 은 사진 참조 계약을 계속 갖는다
    p13_plate = build_marker_sketch_prompt(
        **kwargs, plate_attached=True, prompt_version="13")
    assert "LOCATION PHOTOGRAPH" in p13_plate


def test_lane_step_sketch_selector_raised_to_v16():
    """팩 발행=selector 배선까지가 한 단위 — 상수를 올리지 않으면 팩
    계약이 사문화된다(미배선 팩 2건 실측 교훈)."""
    from app.core.steps.shot_conti_light_step import (
        LANE_SKETCH_PACK_VERSION,
    )
    from app.modules.pipeline.outdoor_marker_map import (
        _MANNEQUIN_SKETCH_PACKS,
        _PLATE_LOOK_PACKS,
    )

    assert LANE_SKETCH_PACK_VERSION == "16"
    assert LANE_SKETCH_PACK_VERSION in _MANNEQUIN_SKETCH_PACKS
    # selector 가 사진 팩 밖 = 스텝의 plate_look_on 이 False 로 떨어진다
    assert LANE_SKETCH_PACK_VERSION not in _PLATE_LOOK_PACKS
