"""ShotContiLightStep lane 분기 결정론 테스트 (Stage D).

생성(LLM/이미지)은 진입 전에 fail-closed 되는 분기 위주 — 게이트/재검증/
결손 계약/hash 스탬프만 검증. fixture 전부 시나리오 중립 SAMPLE.
"""

from contextlib import ExitStack
from unittest.mock import MagicMock, patch

import pytest

from app.core.steps.shot_conti_light_step import ShotContiLightStep


def _make_step():
    step = ShotContiLightStep.__new__(ShotContiLightStep)
    step.project_id = "SAMPLE_PROJECT"
    step.episode_id = "SAMPLE_EPISODE"
    step.project_config = {}
    step.build_opik_metadata = MagicMock(return_value={})
    step.db = MagicMock()
    return step


def _lane_cp(lane="map_marker", quote="달려간다"):
    return {"data": {"groups": {"sample_site": {
        "status": "ok",
        "plan": {
            "segments": [
                {"segment_id": "seg1", "label_en": "open path",
                 "dominant_mode": "movement",
                 "evidence": [{"scene_index": 3, "quote_ko": quote}],
                 "confidence": "high"},
            ],
            "shot_bindings": [
                {"scene_index": 3, "shot_index": 1, "segment_id": "seg1",
                 "lane": lane, "confidence": "high",
                 "rationale_ko": "SAMPLE",
                 "evidence": {"scene_index": 3, "quote_ko": quote}},
            ],
        },
        "scene_indices": [3],
    }}}}


def _cps(lane="map_marker", quote="달려간다", camera="정면 와이드"):
    staging_shots = [{"scene_index": 3, "shot_index": 1}]
    if camera is not None:
        staging_shots[0]["camera_direction"] = camera
    return {
        "outdoor_lane_plan": _lane_cp(lane, quote),
        "outdoor_place_spec": {"data": {"groups": {"sample_site": {
            "spec": {"zone_labels_en": ["Open Field"], "items": []},
            # BLOCKING-2: authoritative 그룹 샷 재구성 입력
            "outdoor_loc_ids": ["L01"],
            "scene_indices": [3],
        }}}},
        "outdoor_place_canon": {"data": {"groups": {}}},
        "outdoor_structure_seed": {"data": {"groups": {}}},
        "shot_validator": {"data": {"scenes": [
            {"scene_index": 3, "shots": [
                {"shot_index": 1, "location_id": "L01",
                 "description": "달려가는 인물"}]},
        ]}},
        "shot_staging": {"data": {"shots": staging_shots}},
    }


def _chain_patches(on=True):
    """마네킹 체인 3플래그 patch — sketch 팩 v14(마네킹)는 하류 교체
    계약과 짝으로만 유효해서, lane 분기 진입 자체가 세 플래그 ON 을
    요구한다(producer 가드). 기존 분기 계약 테스트는 그 가드보다 뒤의
    동작을 보려는 것이므로 체인을 켜 둔다."""
    return [
        patch(f"app.core.config.settings.{name}", on, create=True)
        for name in (
            "still_bgfirst_enabled",
            "still_bgfirst_full_enabled",
            "still_lane_prev_bgfirst_enabled",
        )
    ]


def _run_lane(step, scene_texts=None, tmp_path=None, chain_on=True):
    from pathlib import Path

    with ExitStack() as stack:
        for p in _chain_patches(chain_on):
            stack.enter_context(p)
        return step._run_lane_conti(
            scene_texts=scene_texts if scene_texts is not None
            else {3: "그가 달려간다."},
            continuity={},
            location_by_scene={},
            scene_headings={},
            selected_keys=None,   # is_selected: None=전체 선택 관례 확인용
            out_dir=Path(tmp_path or "/tmp/SAMPLE_lane"),
            # 2026-07-17 seed-bg: typed 배경 권위 해석 입력 (테스트=빈 배정)
            plate_map={},
            assign_by_key={},
            force=False,
        )


def test_lane_noop_without_bindings(tmp_path):
    step = _make_step()
    step._load_prev_checkpoint = lambda sid: {"data": {"groups": {}}}
    out = _run_lane(step, tmp_path=tmp_path)
    assert out == {"lane_contis": {}}


def test_lane_revalidation_fail_closed(tmp_path):
    """persisted plan 의 가짜 인용 = AppError (소비 직전 재검증)."""
    from app.core.errors import AppError

    step = _make_step()
    cps = _cps(quote="원문에 없는 인용")
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    assert ei.value.code == (
        "step.contract_violation.shot_conti_light_lane")


def test_lane_camera_direction_fail_closed(tmp_path):
    """camera_direction 결손 = AppError (Codex 배선 조건 ③) — base 자산
    검사보다 상류 결손이 먼저 걸리지 않도록 canon 자산을 채워 검증."""
    from app.core.errors import AppError

    step = _make_step()
    base = tmp_path / "map.png"
    base.write_bytes(b"png")
    cps = _cps(camera=None)
    master = tmp_path / "master.png"
    master.write_bytes(b"CANON-MASTER-PNG")
    cps["outdoor_place_canon"] = {"data": {"groups": {"sample_site": {
        "status": "ok", "map_png_path": str(base),
        "map_asset_id": "canon-map",
        # 2026-07-25 (케이스1 스펙 C·D) 잔존 픽스처 — **현 선택자(v14)
        # 에서는 무효**다. v14 는 _PLATE_LOOK_PACKS 밖이라 plate_look_on
        # =False → canon master fail-closed 분기 자체가 실행되지 않는다
        # (콘티 참조=마커 맵 1장). 후속 태스크가 사진 참조를 되살릴 수
        # 있어 데이터만 남긴다.
        "master_png_path": str(master),
        "master_asset_id": "canon-master"}}}}
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    assert ei.value.code == "step.missing_input.shot_conti_light_lane"
    assert "camera_direction" in ei.value.message


def test_lane_base_asset_missing_fail_closed(tmp_path):
    """레인 배정 샷의 base 자산(canon map/siteplan) 부재 = AppError —
    조용한 degrade 금지."""
    from app.core.errors import AppError

    step = _make_step()
    cps = _cps()  # canon 그룹 비어 있음
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    assert ei.value.code == "step.missing_input.shot_conti_light_lane"
    assert "outdoor_place_canon" in ei.value.message


def test_lane2_structure_plate_is_pending_policy_entry(tmp_path):
    """structure_plate = A/B 정책 entry (2026-07-16 사용자 확정 재설계) —
    맵·마커·스케치 없음, seed 상태는 기록만 하고 finalize 가 fail-closed
    확정한다 (스텝 abort 아님, Codex R2 policy/asset 분리)."""
    from app.modules.pipeline.shot_conti_light import (
        finalize_structure_ab_entries,
    )

    step = _make_step()
    cps = _cps(lane="structure_plate")
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    out = _run_lane(step, tmp_path=tmp_path)
    entry = out["lane_contis"]["S3sh1"]
    assert entry["status"] == "ab_select_pending"
    assert entry["seed_status"] == "missing"
    assert "image_path" not in entry and "control_path" not in entry
    # finalize: eligible + seed/콘티 결손 = failed (B 단독 진행 금지)
    delta = finalize_structure_ab_entries(
        lane_contis=out["lane_contis"], contis={},
        classify_shots={"S3sh1": {"person_visible": True, "prev": None}},
    )
    assert entry["status"] == "failed"
    assert "seed" in entry["error"]
    assert delta == {"applicable": 1, "completed": 0, "failed": 1}


def test_lane_none_falls_through_to_general_pipe(tmp_path):
    """lane 'none'(재설계 C) = 일반 파이프 — lane entry 를 만들지 않고,
    canon 맵이 없어도 AppError 없이 통과해야 한다 (E2E9 실측 회귀:
    none 이 레인1 경로로 떨어져 base 자산 결손 fail-closed 로 크래시)."""
    step = _make_step()
    cps = _cps(lane="none")  # canon 그룹 비어 있음 — none 은 요구 금지
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    out = _run_lane(step, tmp_path=tmp_path)
    assert out == {"lane_contis": {}}


def test_config_hash_lane_stamp_opt_in():
    """flag OFF hash 불변(byte-identical) + ON 시 lane 팩 스탬프."""
    step = _make_step()
    with patch("app.core.config.settings.outdoor_lane_pipe_enabled",
               False, create=True):
        h_off_1 = step._config_hash()
    h_plain = step._config_hash()  # 실제 settings (default False)
    assert h_off_1 == h_plain
    with patch("app.core.config.settings.outdoor_lane_pipe_enabled",
               True, create=True):
        h_on = step._config_hash()
    assert h_on != h_plain


def test_lane_plan_cp_missing_fail_closed(tmp_path):
    """lane pipe ON + plan CP 부재 = AppError — 야외 샷의 일반 콘티 하강
    금지 (Codex BLOCKING-2)."""
    from app.core.errors import AppError

    step = _make_step()
    step._load_prev_checkpoint = lambda sid: None
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    assert ei.value.code == "step.missing_input.shot_conti_light_lane"
    assert "outdoor_lane_plan" in ei.value.message


def test_stale_plan_missing_shot_coverage_fail_closed(tmp_path):
    """stale plan(현재 선택 샷 누락) = coverage 위반 AppError —
    plan 자기 바인딩 기준 통과 결함 잠금 (Codex BLOCKING-2)."""
    from app.core.errors import AppError

    step = _make_step()
    cps = _cps()
    # 현재 선택 샷에 (3,2) 추가 — plan 바인딩엔 없음 (stale CP 모사)
    cps["shot_staging"]["data"]["shots"].append(
        {"scene_index": 3, "shot_index": 2, "camera_direction": "측면"})
    cps["shot_validator"]["data"]["scenes"][0]["shots"].append(
        {"shot_index": 2, "location_id": "L01", "description": "SAMPLE 2"})
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    assert ei.value.code == (
        "step.contract_violation.shot_conti_light_lane")
    assert "바인딩 누락" in ei.value.message


def _execute_shape(lane_on, cps=None):
    """_execute 를 생성 없이 관통 — data shape 계약 검증용."""
    step = _make_step()
    base_cps = {
        "shot_ref_classify": {"data": {"shots": {
            "S3sh1": {"person_visible": True}}}},
        "shot_continuity": {"data": {}},
        "shot_validator": {"data": {"scenes": [
            {"scene_index": 3, "shots": [
                {"shot_index": 1, "description": "SAMPLE"}]}]}},
        "shot_selection": None,
        "scene_director": {"data": {"scenes": []}},
        "scene_save": {"data": {"segments": []}},
    }
    base_cps.update(cps or {})
    step._load_prev_checkpoint = lambda sid: base_cps.get(sid)
    step._register_intermediate_assets = MagicMock()
    step._run_lane_conti = MagicMock(return_value={"lane_contis": {}})
    fake_result = {"applicable_count": 1, "completed_count": 1,
                   "failed_count": 0, "contis": {}}
    with patch(
        "app.modules.pipeline.shot_conti_light.run_shot_conti_light",
        return_value=fake_result,
    ), patch(
        "app.modules.pipeline.shot_conti_light.resolve_shot_plate_map",
        return_value={},
    ), patch(
        "app.modules.pipeline.shot_ref_classify.derive_location_by_scene",
        return_value={},
    ), patch(
        "app.core.config.settings.outdoor_lane_pipe_enabled",
        lane_on, create=True,
    ), patch(
        "app.core.config.settings.outdoor_lane_plan_enabled",
        lane_on, create=True,
    ), patch(
        "app.core.config.settings.outdoor_map_conti_enabled",
        False, create=True,
    ):
        return step._execute()


def test_execute_off_shape_byte_identical():
    """flag OFF: data 키 = {contis, map_conti} (lane_conti 키 미노출) —
    Codex HIGH-4 (OFF CP shape byte-identical)."""
    result = _execute_shape(lane_on=False)
    assert sorted(result["data"].keys()) == ["contis", "map_conti"]


def test_execute_on_adds_lane_conti_key():
    result = _execute_shape(lane_on=True)
    assert "lane_conti" in result["data"]


def test_group_parity_missing_group_fail_closed(tmp_path):
    """현재 샷이 있는 spec 그룹이 lane CP 에서 통째로 누락 = AppError —
    일반 콘티 하강 차단 (Codex 재리뷰 BLOCKING-1)."""
    from app.core.errors import AppError

    step = _make_step()
    cps = _cps()
    cps["outdoor_lane_plan"] = {"data": {"groups": {}}}
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    assert ei.value.code == "step.missing_input.shot_conti_light_lane"
    assert "parity" in ei.value.message


def test_group_parity_failed_group_fail_closed(tmp_path):
    from app.core.errors import AppError

    step = _make_step()
    cps = _cps()
    cps["outdoor_lane_plan"]["data"]["groups"]["sample_site"] = {
        "status": "failed", "error": "SAMPLE LLM 실패"}
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    assert "parity" in ei.value.message
    assert "status=failed" in ei.value.message


def test_group_parity_allows_no_shot_groups(tmp_path):
    """현재 선택 샷 0 인 그룹은 lane entry 부재 허용 — parity 오탐 없이
    다음 단계(base 자산 검사)로 진행."""
    from app.core.errors import AppError

    step = _make_step()
    cps = _cps()
    cps["outdoor_place_spec"]["data"]["groups"]["empty_site"] = {
        "spec": {"zone_labels_en": ["Z"], "items": []},
        "outdoor_loc_ids": ["L99"],
        "scene_indices": [9],   # staging 에 샷 없음
    }
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    # parity 아님 — sample_site 의 canon base 자산 결손까지 진행
    assert "parity" not in ei.value.message
    assert "outdoor_place_canon" in ei.value.message


def test_spec_group_failure_not_laundered_as_skip(tmp_path):
    """상류 spec 그룹 LLM 실패({error,구조키})+lane 'spec missing' skip =
    AppError — 성공 skip 세탁 차단 (Codex 재재리뷰 BLOCKING-1)."""
    from app.core.errors import AppError

    step = _make_step()
    cps = _cps()
    cps["outdoor_place_spec"]["data"]["groups"]["sample_site"] = {
        "error": "spec LLM failed",
        "outdoor_loc_ids": ["L01"],
        "scene_indices": [3],
    }
    cps["outdoor_lane_plan"]["data"]["groups"]["sample_site"] = {
        "skipped": "place spec missing"}
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path)
    assert ei.value.code == "step.missing_input.shot_conti_light_lane"
    assert "outdoor_place_spec 실패/결측" in ei.value.message


def test_outdoor_frame_mode_gate_is_dormant():
    """2026-07-16 재설계: frame_mode 게이트=항상 False — stale flag 가
    스케줄/hash 에 영향 주지 않는다 (Codex 열린쟁점④)."""
    from app.core.applicability import outdoor_frame_mode_on

    with patch("app.core.config.settings.outdoor_frame_mode_enabled",
               True, create=True), \
         patch("app.core.config.settings.outdoor_lane_pipe_enabled",
               True, create=True), \
         patch("app.core.config.settings.outdoor_lane_plan_enabled",
               True, create=True):
        assert outdoor_frame_mode_on() is False


def test_config_hash_stamps_geometry_text_version():
    """재리뷰 HIGH-1: 직렬화 계약(GEOMETRY_TEXT_VERSION) 변경이 lane ON
    hash 를 드리프트시킨다 — completed CP 는 sidecar 지문을 안 타므로
    hash 스탬프가 유일한 무효화 경로."""
    step = _make_step()
    with patch("app.core.config.settings.outdoor_lane_pipe_enabled",
               True, create=True):
        h1 = step._config_hash()
        with patch(
            "app.modules.pipeline.outdoor_marker_map.GEOMETRY_TEXT_VERSION",
            999,
        ):
            h2 = step._config_hash()
    assert h1 != h2


# ── v5 (2026-07-24): i2i 마커 맵 런타임 하네스 (Codex TEST GAP) ──────


# 2026-07-25 (지적①): geometry 팩 v3 — 시야 쐐기(view_left/right)와
# 피사체 facing 이 required. 쐐기는 look_target 과 피사체를 포함해야
# validator 를 통과한다(순수 기하 가드).
_GEO = {
    "entity_placements": [
        {"slot": "E1", "subject_en": "running adult figure",
         "x": 0.42, "y": 0.61,
         "anchor_en": "on the open ground just left of the shelter",
         "facing": {"x": 0.30, "y": 0.45},
         "facing_anchor_en": "toward the far side of the open ground"},
    ],
    "camera": {
        "origin": {"x": 0.80, "y": 0.85},
        "look_target": {"x": 0.45, "y": 0.45},
        "origin_anchor_en": "near the south-east corner of the paved "
                            "area",
        "look_target_anchor_en": "toward the front of the shelter",
        "view_left": {"x": 0.20, "y": 0.55},
        "view_right": {"x": 0.75, "y": 0.30},
        "view_left_anchor_en": "at the left edge of the open ground",
        "view_right_anchor_en": "at the right edge of the shelter front",
    },
    "rationale_ko": "SAMPLE 근거",
}


def _lane_gen_env(tmp_path, monkeypatch, check_verdicts, leak_verdicts,
                  pick_verdicts=None):
    """생성 경로 하네스: canon 맵 실파일+geometry/sketch/judge 전부 fake.

    check_verdicts/leak_verdicts = 판정 호출 순서대로 소모되는 목록.
    반환 recorder: sketch 호출(role/size/refs)·judge 호출 기록.
    """
    step = _make_step()
    base = tmp_path / "map.png"
    base.write_bytes(b"CLEAN-MAP-PNG")
    cps = _cps()
    master = tmp_path / "master.png"
    master.write_bytes(b"CANON-MASTER-PNG")
    cps["outdoor_place_canon"] = {"data": {"groups": {"sample_site": {
        "status": "ok", "map_png_path": str(base),
        "map_asset_id": "canon-map",
        # 2026-07-25 (케이스1 스펙 C·D) 잔존 픽스처 — **현 선택자(v14)
        # 에서는 무효**다. v14 는 _PLATE_LOOK_PACKS 밖이라 plate_look_on
        # =False → 이 master 는 참조로도 붙지 않고 fail-closed 분기도
        # 돌지 않는다. 후속 태스크 대비로 데이터만 남긴다.
        "master_png_path": str(master),
        "master_asset_id": "canon-master"}}}}
    step._load_prev_checkpoint = lambda sid: cps.get(sid)

    rec = {"sketch": [], "judge": []}

    # ★`model` 은 2026-08-25 에 붙었다 — 마커 맵을 그리는 모델이 소스
    #  상수에서 설정으로 옮겨지면서 호출부가 「이 모델로 그려라」를
    #  넘기게 됐다. 대역이 그 값을 **기록**하므로, 스텝이 무엇을 넘겼는지
    #  시험이 끝점에서 확인할 수 있다(받고 버리면 확인할 방법이 없다).
    def fake_sketch(tag, prompt, ref_paths, role="lane_storyboard_sketch",
                    size=None, out_meta=None, model=""):
        rec["sketch"].append({
            "role": role, "size": size, "model": model,
            "refs": [str(p) for p in ref_paths], "prompt": prompt,
        })
        if out_meta is not None:
            out_meta["effective_prompt"] = prompt
        return f"{role}-{len(rec['sketch'])}".encode()

    step._make_lane_sketch_fn = lambda: fake_sketch

    from app.modules.pipeline import outdoor_marker_map as omm
    monkeypatch.setattr(
        omm, "run_marker_geometry_shot",
        lambda **kw: {"geometry": dict(_GEO), "attempts": 1})

    from app.modules.llm import llm_client as llm
    state = {"check": list(check_verdicts), "leak": list(leak_verdicts),
             "pick": list(pick_verdicts or [])}

    def fake_call_structured(step_name, sys_p, user_parts, schema, **kw):
        rec["judge"].append({"step": step_name, "parts": user_parts})
        if step_name == "lane_marker_map_check":
            return state["check"].pop(0)
        if step_name == "marker_sketch_leakage":
            return state["leak"].pop(0)
        # 검사가 소진되면 **셋 중 하나를 고르는** 판정이 온다
        # (2026-08-25 사용자 지시). 목록을 안 주면 호출 자체가 없어야
        # 하므로 여기서 터뜨린다 — 조용히 첫 판으로 넘어가면 「안 불렀다」와
        # 「불렀는데 실패했다」가 구별되지 않는다.
        if step_name == "lane_marker_map_pick":
            return state["pick"].pop(0)
        raise AssertionError(f"unexpected judge {step_name}")

    monkeypatch.setattr(llm, "call_structured", fake_call_structured)
    return step, rec, base


def test_lane_v5_marker_reject_then_pass_feeds_sketch(
        tmp_path, monkeypatch):
    """(a) 1차 마커 검증 reject→2차 pass — 마커 맵이 1:1 캔버스로
    생성되고, 검증은 원본+마커 맵 2이미지, 스케치 참조=마커 맵."""
    from app.modules.pipeline.outdoor_marker_map import MARKER_MAP_SIZE

    step, rec, base = _lane_gen_env(
        tmp_path, monkeypatch,
        check_verdicts=[
            {"markers_ok": False, "details_ko": "CAM 없음"},
            {"markers_ok": True, "details_ko": "정상"},
        ],
        leak_verdicts=[{"has_marker_leakage": False, "details_ko": ""}],
    )
    out = _run_lane(step, tmp_path=tmp_path)
    entry = out["lane_contis"]["S3sh1"]
    assert entry["status"] == "ok"
    assert entry["marker_attempts"] == 2
    marker_calls = [c for c in rec["sketch"]
                    if c["role"] == "lane_marker_map"]
    sketch_calls = [c for c in rec["sketch"]
                    if c["role"] == "lane_storyboard_sketch"]
    # 마커 작화 2회 = 전부 1:1 캔버스 + 참조=클린 canon 맵
    assert len(marker_calls) == 2
    assert all(c["size"] == MARKER_MAP_SIZE for c in marker_calls)
    assert all(c["refs"] == [str(base)] for c in marker_calls)
    # 2차 프롬프트에 판정 피드백 반영
    assert "CAM 없음" in marker_calls[1]["prompt"]
    # 스케치 1회 = 기본 캔버스(16:9), 참조=마커 맵 (클린 맵 아님)
    assert len(sketch_calls) == 1
    assert sketch_calls[0]["size"] is None
    # 2026-07-26 (사용자 확정 — sketch 팩 v14): 콘티 참조=마커 맵 1장.
    # v9~v13 의 2번째 참조(canon master 실사)는 제거됐다 — 장소 외형은
    # 뒤 배경 단계가 i2i 로 얹는다. plate_look_on=False 이므로 소비
    # 기록(master_*)도 남지 않는다.
    assert sketch_calls[0]["refs"] == [entry["marker_map_path"]]
    assert "master_png_path" not in entry
    assert "master_asset_id" not in entry
    # 검증 payload = 원본·마커 맵 2이미지 명시 라벨 순서
    check_call = next(j for j in rec["judge"]
                      if j["step"] == "lane_marker_map_check")
    texts = [p.get("text") for p in check_call["parts"]
             if isinstance(p, dict) and "text" in p]
    assert any("ORIGINAL CLEAN SITE PLAN" in t for t in texts)
    assert any("ANNOTATED SITE PLAN" in t for t in texts)
    assert texts.index(next(t for t in texts if "ORIGINAL" in t)) < \
        texts.index(next(t for t in texts if "ANNOTATED" in t))
    # provenance: marker_prompt=annotate effective prompt(스케치와 상이)
    assert entry["marker_prompt"].startswith("Edit the attached")
    assert entry["prompt"] != entry["marker_prompt"]


def test_lane_v5_marker_exhaustion_picks_best_and_continues(
        tmp_path, monkeypatch):
    """(b) 마커 검증 소진 = **셋 중 하나를 VLM 이 골라 계속 간다.**

    2026-08-25 사용자 지시: "그냥 nb2 만, nb2 가 3번 실패하면 그냥 그 셋
    중에 하나 하라고 VLM 으로." 종전에는 여기서 샷을 실패로 떨어뜨렸고,
    그 한 샷 때문에 스텝이 partial 로 멈춰 뒤가 통째로 막혔다(실측).

    ★옛 계약이 막던 것은 **클린 맵 fallback** 이다 — 마커를 못 그렸는데
     아무것도 안 그려진 맵으로 콘티를 그리는 것. 그 보호는 그대로 둔다:
     스케치 참조가 마커 맵이어야 하고 클린 맵이면 안 된다.
    """
    from app.core.steps.shot_conti_light_step import (
        LANE_MARKER_ANNOTATE_MAX_ATTEMPTS as _max_try,
    )

    step, rec, clean_map = _lane_gen_env(
        tmp_path, monkeypatch,
        check_verdicts=[
            {"markers_ok": False, "details_ko": "위치 어긋남",
             "violations": [{"axis": "camera_wedge", "note_ko": "반대"}]},
        ] * _max_try,
        leak_verdicts=[{"leaked": False, "details_ko": "정상"}],
        pick_verdicts=[{"choice": 2, "why_ko": "쐐기가 피사체를 담았다"}],
    )
    out = _run_lane(step, tmp_path=tmp_path)
    entry = out["lane_contis"]["S3sh1"]

    assert entry["status"] != "failed", entry.get("error")
    assert entry["marker_best_effort"] is True
    assert entry["marker_pick_index"] == 2
    assert entry["marker_pick_count"] == _max_try
    assert entry["marker_pick_why_ko"] == "쐐기가 피사체를 담았다"

    # 고르는 판정이 실제로 불렸다 — 그리고 딱 한 번만
    picks = [j for j in rec["judge"] if j["step"] == "lane_marker_map_pick"]
    assert len(picks) == 1
    # 원본 1장 + 후보 3장을 보여 줬다
    assert sum(1 for p in picks[0]["parts"]
               if p.get("type") == "image_url") == _max_try + 1

    # ★옛 보호 유지 — 콘티는 그려지되 **클린 맵을 참조로 쓰지 않는다**
    sketches = [c for c in rec["sketch"]
                if c["role"] == "lane_storyboard_sketch"]
    assert len(sketches) == 1
    assert str(clean_map) not in sketches[0]["refs"]
    assert any("marker" in r for r in sketches[0]["refs"]), sketches[0]["refs"]

    # ★★끝점에서 잰다 — 스텝 반환값이 아니라 **디스크에 남은 기록**.
    #  실측 2026-08-26: 반환값에는 있는데 sidecar 에는 한 칸도 안 남아서,
    #  파일만 보면 「검사 실패」로 끝난 것처럼 읽혔다. 무엇을 골라 썼는지
    #  기록이 없으면 다음 판과 대조할 수가 없다.
    import json as _json
    from pathlib import Path as _Path

    rec_file = _json.loads(
        (_Path(tmp_path) / "lane_records.json").read_text(encoding="utf-8"))
    saved = rec_file["S3sh1"]
    assert saved["marker_best_effort"] is True
    assert saved["marker_pick_index"] == 2
    assert saved["marker_pick_count"] == _max_try
    assert saved["marker_pick_why_ko"] == "쐐기가 피사체를 담았다"


def test_lane_v5_fails_only_when_nothing_was_drawn(tmp_path, monkeypatch):
    """한 장도 못 그린 경우에만 실패다 — 그때는 고를 것이 없다.

    ★그리기 자체가 죽는 갈래는 「고르기」가 못 구한다. 이 갈래까지
     계속 가면 클린 맵으로 콘티를 그리게 된다(옛 계약이 막던 바로 그것).
    """
    step, rec, _ = _lane_gen_env(
        tmp_path, monkeypatch, check_verdicts=[], leak_verdicts=[])

    def dead_sketch(tag, prompt, ref_paths, role="lane_storyboard_sketch",
                    size=None, out_meta=None, model=""):
        raise RuntimeError("그리기가 죽었다")

    step._make_lane_sketch_fn = lambda: dead_sketch
    out = _run_lane(step, tmp_path=tmp_path)
    entry = out["lane_contis"]["S3sh1"]
    assert entry["status"] == "failed"
    assert not [j for j in rec["judge"]
                if j["step"] == "lane_marker_map_pick"]


def test_lane_v5_sidecar_reuse_zero_calls(tmp_path, monkeypatch):
    """(c) 유효 sidecar resume = geometry/annotate/check/sketch 0콜 —
    fresh 1회 후 동일 입력 재실행으로 실검증(마커 sha 무결성 포함)."""
    step, rec, base = _lane_gen_env(
        tmp_path, monkeypatch,
        # 2세트: fresh 1회 + 변조 후 재생성 1회
        check_verdicts=[{"markers_ok": True, "details_ko": "정상"}] * 2,
        leak_verdicts=[
            {"has_marker_leakage": False, "details_ko": ""}] * 2,
    )
    out1 = _run_lane(step, tmp_path=tmp_path)
    assert out1["lane_contis"]["S3sh1"]["status"] == "ok"
    n_sketch, n_judge = len(rec["sketch"]), len(rec["judge"])

    out2 = _run_lane(step, tmp_path=tmp_path)
    entry = out2["lane_contis"]["S3sh1"]
    assert entry["status"] == "ok" and entry.get("reused") is True
    assert len(rec["sketch"]) == n_sketch  # 신규 생성 0
    assert len(rec["judge"]) == n_judge   # 신규 판정 0
    # provenance 는 reuse 후에도 보존 (HIGH-2)
    assert entry["marker_prompt"].startswith("Edit the attached")
    assert entry["marker_attempts"] == 1

    # 마커 맵 파일 변조 = sha 불일치 → 재생성 경로 (무결성 감사)
    from pathlib import Path as _P
    _P(entry["marker_map_path"]).write_bytes(b"TAMPERED")
    out3 = _run_lane(step, tmp_path=tmp_path)
    assert len(rec["sketch"]) > n_sketch
    assert out3["lane_contis"]["S3sh1"]["status"] == "ok"
    assert out3["lane_contis"]["S3sh1"].get("reused") is not True


def test_config_hash_marker_contract_sensitivity():
    """v5 (Codex HIGH-3): lane ON 시 마커 판정 물리 모델·계약 버전이
    hash 에 접힘 — 전역 Gemini 모델 교체가 completed CP 를 무효화한다."""
    step = _make_step()
    with patch("app.core.config.settings.outdoor_lane_pipe_enabled",
               True, create=True):
        h_base = step._config_hash()
        with patch("app.core.config.settings.gemini_text_model",
                   "gemini-DIFFERENT-model", create=True):
            h_model = step._config_hash()
        with patch("app.modules.pipeline.outdoor_marker_map."
                   "MARKER_ANNOTATE_CONTRACT_VERSION", 999, create=True):
            h_contract = step._config_hash()
        with patch("app.modules.pipeline.outdoor_marker_map."
                   "MARKER_GEOMETRY_CONTRACT_VERSION", 999, create=True):
            h_geo = step._config_hash()
    assert h_model != h_base
    assert h_contract != h_base
    assert h_geo != h_base


# ── 마네킹 유출 3층 가드 (2026-07-26): producer 거부 + CP 무효화 ──────


def test_mannequin_pack_requires_all_three_chain_flags(monkeypatch):
    from app.core.config import settings

    for on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
               "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, on, True, raising=False)
    assert ShotContiLightStep._mannequin_chain_ready() is True

    # 어느 하나라도 꺼지면 마네킹 팩은 준비되지 않았다
    for off in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
                "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, off, False, raising=False)
        assert ShotContiLightStep._mannequin_chain_ready() is False
        monkeypatch.setattr(settings, off, True, raising=False)


def test_lane_mannequin_producer_fail_closed(tmp_path, monkeypatch):
    """1층: 체인이 꺼진 채로는 마네킹 콘티를 굽지 않는다 — 상류 자산이
    전부 갖춰진 상태에서도(= 다른 fail-closed 보다 먼저) 거부한다."""
    from app.core.errors import AppError

    step, _rec, _base = _lane_gen_env(
        tmp_path, monkeypatch,
        check_verdicts=[{"markers_ok": True, "details_ko": "정상"}],
        leak_verdicts=[{"has_marker_leakage": False, "details_ko": ""}],
    )
    with pytest.raises(AppError) as ei:
        _run_lane(step, tmp_path=tmp_path, chain_on=False)
    assert ei.value.code == "step.config.lane_mannequin_chain_off"
    assert "마네킹" in ei.value.message


def _multi_lane_cps(lanes, quote="달려간다"):
    """샷별 lane 을 지정한 다중 바인딩 CP (lanes = {shot_index: lane}).
    parity·커버리지가 성립하도록 staging/validator 샷 목록도 같이 채운다."""
    shis = sorted(lanes)
    cps = _cps(quote=quote)
    cps["outdoor_lane_plan"]["data"]["groups"]["sample_site"]["plan"][
        "shot_bindings"
    ] = [
        {"scene_index": 3, "shot_index": shi, "segment_id": "seg1",
         "lane": lanes[shi], "confidence": "high",
         "rationale_ko": "SAMPLE",
         "evidence": {"scene_index": 3, "quote_ko": quote}}
        for shi in shis
    ]
    cps["shot_validator"] = {"data": {"scenes": [
        {"scene_index": 3, "shots": [
            {"shot_index": shi, "location_id": "L01",
             "description": "달려가는 인물"} for shi in shis]},
    ]}}
    cps["shot_staging"] = {"data": {"shots": [
        {"scene_index": 3, "shot_index": shi,
         "camera_direction": "정면 와이드"} for shi in shis]}}
    return cps


def test_mannequin_guard_only_for_shots_that_bake_a_sketch(tmp_path):
    """리뷰 지적①: 가드 대상은 **실제로 마네킹을 굽는 샷**뿐이다.

    lane "none"=일반 파이프로 continue, "structure_plate"=정책 entry 만
    만들고 continue — 둘 다 스케치가 0장이라 유출 표면이 없다. 그런
    프로젝트를 기본 플래그(체인 OFF)에서 hard-fail 시키면 안 된다.
    반대로 선택된 map_marker 가 하나라도 섞이면 그때는 거부해야 한다."""
    from app.core.errors import AppError

    step = _make_step()
    cps = _multi_lane_cps({1: "none", 2: "structure_plate"})
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    out = _run_lane(step, tmp_path=tmp_path, chain_on=False)
    # none=entry 없음, structure_plate=정책 entry(이미지 생성 없음)
    assert set(out["lane_contis"]) == {"S3sh2"}
    assert out["lane_contis"]["S3sh2"]["status"] == "ab_select_pending"

    step2 = _make_step()
    cps2 = _multi_lane_cps(
        {1: "none", 2: "structure_plate", 3: "map_marker"})
    step2._load_prev_checkpoint = lambda sid: cps2.get(sid)
    with pytest.raises(AppError) as ei:
        _run_lane(step2, tmp_path=tmp_path, chain_on=False)
    assert ei.value.code == "step.config.lane_mannequin_chain_off"


def test_mannequin_guard_ignores_unselected_map_marker(tmp_path):
    """선택되지 않은 map_marker 는 루프에서 continue 라 스케치가 없다 —
    가드도 선택 샷 기준이어야 한다(is_selected 와 같은 집합)."""
    step = _make_step()
    cps = _multi_lane_cps({1: "map_marker"})
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    from pathlib import Path

    with ExitStack() as stack:
        for p in _chain_patches(False):
            stack.enter_context(p)
        out = step._run_lane_conti(
            scene_texts={3: "그가 달려간다."},
            continuity={}, location_by_scene={}, scene_headings={},
            selected_keys=set(),   # 선택 0 — 굽는 샷 없음
            out_dir=Path(tmp_path), plate_map={}, assign_by_key={},
            force=False,
        )
    assert out == {"lane_contis": {}}


def test_lane_entry_persists_resolved_packs(tmp_path, monkeypatch):
    """CP entry 에 resolved 팩·계약 영속 — 소비자 exact revalidate 입력.
    생성/reuse 두 경로 모두 같은 스탬프를 남겨야 한다."""
    from app.core.steps.shot_conti_light_step import (
        LANE_GEOMETRY_PACK_VERSION,
        LANE_SKETCH_PACK_VERSION,
        MANNEQUIN_CHAIN_CONTRACT_VERSION,
    )
    from app.modules.pipeline.outdoor_marker_map import (
        resolve_prompt_version as geo_pack,
        resolve_sketch_pack_version as sketch_pack,
    )

    step, _rec, _base = _lane_gen_env(
        tmp_path, monkeypatch,
        check_verdicts=[{"markers_ok": True, "details_ko": "정상"}] * 2,
        leak_verdicts=[
            {"has_marker_leakage": False, "details_ko": ""}] * 2,
    )
    fresh = _run_lane(step, tmp_path=tmp_path)["lane_contis"]["S3sh1"]
    reused = _run_lane(step, tmp_path=tmp_path)["lane_contis"]["S3sh1"]
    assert reused.get("reused") is True
    for entry in (fresh, reused):
        assert entry["lane_sketch_pack"] == sketch_pack(
            LANE_SKETCH_PACK_VERSION)
        assert entry["lane_geometry_pack"] == geo_pack(
            LANE_GEOMETRY_PACK_VERSION)
        assert entry["mannequin_chain_contract"] == (
            MANNEQUIN_CHAIN_CONTRACT_VERSION)


def test_config_hash_folds_lane_prev_flag():
    """2층: lane-prev 플래그가 hash 에 접혀야 플래그만 내린 CP 재사용이
    막힌다. lane pipe OFF 는 lane 콘티 자체가 없어 스탬프 대상이 아니다
    (opt-in stamping 관례 — 무관 프로젝트 hash byte-identical)."""
    step = _make_step()
    with patch("app.core.config.settings.outdoor_lane_pipe_enabled",
               True, create=True):
        with ExitStack() as stack:
            for p in _chain_patches(True):
                stack.enter_context(p)
            h_on = step._config_hash()
        with ExitStack() as stack:
            for p in _chain_patches(True):
                stack.enter_context(p)
            stack.enter_context(patch(
                "app.core.config.settings."
                "still_lane_prev_bgfirst_enabled", False, create=True))
            h_off = step._config_hash()
        # 계약 버전 자체가 바뀌어도 CP 는 무효화된다
        with ExitStack() as stack:
            for p in _chain_patches(True):
                stack.enter_context(p)
            stack.enter_context(patch(
                "app.core.steps.shot_conti_light_step."
                "MANNEQUIN_CHAIN_CONTRACT_VERSION", "999"))
            h_contract = step._config_hash()
    assert h_on != h_off
    assert h_on != h_contract


def test_config_hash_mannequin_stamp_is_lane_opt_in():
    """lane pipe OFF 면 체인 플래그를 켜도 hash 불변 — 무관 프로젝트의
    shot_conti_light CP 전량 재렌더 금지."""
    step = _make_step()
    with patch("app.core.config.settings.outdoor_lane_pipe_enabled",
               False, create=True):
        with ExitStack() as stack:
            for p in _chain_patches(True):
                stack.enter_context(p)
            h_chain_on = step._config_hash()
        with ExitStack() as stack:
            for p in _chain_patches(True):
                stack.enter_context(p)
            stack.enter_context(patch(
                "app.core.config.settings."
                "still_lane_prev_bgfirst_enabled", False, create=True))
            h_chain_off = step._config_hash()
    assert h_chain_on == h_chain_off


# ──────────────────────────────────────────────────────────────────────────
# Codex 합의 (2026-08-12) — 운영자 무콘티 예외의 비용·지문 계약
# ──────────────────────────────────────────────────────────────────────────
def test_operator_exempt_short_circuits_paid_chain(tmp_path, monkeypatch):
    """BLOCK1: 예외 태그 샷은 유효 성공 캐시가 없으면 유료 체인
    (geometry LLM·마커 i2i·판정 VLM)에 들어가지 않는다 — 결정론 실패
    샷(실측 S75sh7 9회)을 재개마다 다시 태우던 비용 누수 차단."""
    step, rec, _base = _lane_gen_env(
        tmp_path, monkeypatch, check_verdicts=[], leak_verdicts=[])
    with patch("app.core.config.settings.bgfirst_no_conti_exempt_tags",
               "S3sh1", create=True):
        out = _run_lane(step, tmp_path=tmp_path)
    entry = out["lane_contis"]["S3sh1"]
    assert entry["status"] == "failed"
    assert "operator_exempt short-circuit" in entry["error"]
    assert rec["sketch"] == [], "유료 스케치 호출이 없어야 한다"
    assert rec["judge"] == [], "유료 판정 호출이 없어야 한다"


def test_operator_exempt_force_still_attempts(tmp_path, monkeypatch):
    """BLOCK1 경계: force 는 운영자의 명시 재시도다 — 단락하지 않고
    실제 생성 경로에 들어가야 한다."""
    from pathlib import Path

    step, rec, _base = _lane_gen_env(
        tmp_path, monkeypatch,
        check_verdicts=[{"markers_ok": True, "details_ko": "정상"}],
        leak_verdicts=[{"has_marker_leakage": False, "details_ko": ""}],
    )
    with ExitStack() as stack:
        for p in _chain_patches(True):
            stack.enter_context(p)
        stack.enter_context(patch(
            "app.core.config.settings.bgfirst_no_conti_exempt_tags",
            "S3sh1", create=True))
        out = step._run_lane_conti(
            scene_texts={3: "그가 달려간다."},
            continuity={}, location_by_scene={}, scene_headings={},
            selected_keys=None, out_dir=Path(tmp_path / "force_out"),
            plate_map={}, assign_by_key={}, force=True,
        )
    assert out["lane_contis"]["S3sh1"]["status"] == "ok"
    assert rec["sketch"], "force 는 생성 경로에 들어가야 한다"


def test_exempt_tags_stay_out_of_config_hash():
    """BLOCK2 절충(i): 예외 태그는 failure/partial 복구 전용 one-shot
    override — **의도적으로 지문(config hash)에 넣지 않는다**(넣으면
    태그 조작이 완료 CP 대량 재검증을 유발하는 역리스크). 그 귀결로
    completed 스텝은 태그를 추가/제거해도 resume 시 SKIP 된다
    (step_runner._evaluate_resume_decision: completed+CP 정합+verify
    pass=SKIP). 완료 후 태그 변경을 반영하려면 명시 force/invalidate
    가 필요하다 — 이 시험이 그 운영 계약을 고정한다."""
    step = _make_step()
    with ExitStack() as stack:
        for p in _chain_patches(True):
            stack.enter_context(p)
        stack.enter_context(patch(
            "app.core.config.settings.bgfirst_no_conti_exempt_tags",
            "S75sh7", create=True))
        h_tagged = step._config_hash()
    with ExitStack() as stack:
        for p in _chain_patches(True):
            stack.enter_context(p)
        stack.enter_context(patch(
            "app.core.config.settings.bgfirst_no_conti_exempt_tags",
            "", create=True))
        h_empty = step._config_hash()
    assert h_tagged == h_empty, (
        "예외 태그는 one-shot override — 지문 불포함이 계약이다"
    )


def test_operator_exempt_zero_geometry_calls(tmp_path, monkeypatch):
    """HIGH 보강: 단락은 geometry(LLM)도 0회여야 한다 — sketch/judge
    무기록만으로는 geometry 유료 호출을 못 잠근다(Codex 재리뷰)."""
    step, rec, _base = _lane_gen_env(
        tmp_path, monkeypatch, check_verdicts=[], leak_verdicts=[])
    from app.modules.pipeline import outdoor_marker_map as omm

    geo_calls = []
    monkeypatch.setattr(
        omm, "run_marker_geometry_shot",
        lambda **kw: geo_calls.append(kw) or {"geometry": dict(_GEO)})
    with patch("app.core.config.settings.bgfirst_no_conti_exempt_tags",
               "S3sh1", create=True):
        out = _run_lane(step, tmp_path=tmp_path)
    assert out["lane_contis"]["S3sh1"]["status"] == "failed"
    assert geo_calls == [], "geometry 유료 호출이 없어야 한다"
    assert rec["sketch"] == [] and rec["judge"] == []


def test_operator_exempt_base_missing_goes_unpaid_not_422(tmp_path):
    """BLOCK-3 회귀: 예외 태그 샷의 base 자산 결손은 스텝 전체 422 가
    아니라 무료 exempt 로 가야 한다 — 캐시를 증명할 수 없으면 계약상
    unpaid exempt. non-exempt 의 fail-closed(base 결손 raise)는 기존
    시험이 그대로 잠근다."""
    step = _make_step()
    cps = _cps()   # outdoor_place_canon 그룹 비어 있음 = base 결손
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with patch("app.core.config.settings.bgfirst_no_conti_exempt_tags",
               "S3sh1", create=True):
        out = _run_lane(step, tmp_path=tmp_path)   # raise 없이 돌아야 함
    entry = out["lane_contis"]["S3sh1"]
    assert entry["status"] == "failed"
    assert "operator_exempt short-circuit" in entry["error"]


def test_operator_exempt_base_is_directory_goes_unpaid_not_raise(tmp_path):
    """Codex 3차 BLOCK-2 회귀: base 경로가 exists 하지만 **디렉터리**인
    변태 상태에서도 exempt 는 read_bytes 계열 OSError 로 새지 않고 무료
    exempt 로 끝나야 한다 — exempt 경로는 파일 상태와 무관한 자기 완결."""
    step = _make_step()
    cps = _cps()
    bad_base = tmp_path / "map_is_dir.png"
    bad_base.mkdir()   # 경로는 존재하지만 파일이 아니다
    cps["outdoor_place_canon"] = {"data": {"groups": {"sample_site": {
        "status": "ok", "map_png_path": str(bad_base),
        "map_asset_id": "canon-map"}}}}
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    with patch("app.core.config.settings.bgfirst_no_conti_exempt_tags",
               "S3sh1", create=True):
        out = _run_lane(step, tmp_path=tmp_path)
    entry = out["lane_contis"]["S3sh1"]
    assert entry["status"] == "failed"
    assert "operator_exempt short-circuit" in entry["error"]
