"""SceneDetailStep._analyze_one 단위 테스트 — Phase 3b.6.

Phase 3.6에서 SceneAnalysisContext DTO가 도입되었고, Phase 3b.6에서 nested function
`_analyze_one`을 class method로 승격하며 closure → ctx.field 전환. 본 테스트는
리팩토링 전후 동작 동일성을 검증한다.

검증 대상:
1. 기본 결과 구조 (scene_index / _shot_index / visible_entities / t2i_variations)
2. fixed_elements(scene_consistency) 주입
3. VE 위반 retry 경로
4. outfit_assignments 자동 할당 + 유효성 정리
5. t2i_prompt 공백/따옴표 정리
6. 엔티티 미등록 시 fallback
"""
from __future__ import annotations

import re
from unittest.mock import MagicMock, patch

import pytest

from app.core.dto import SceneAnalysisContext
from app.core.steps.detail_steps import SceneDetailStep


# ──────────────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────────────


@pytest.fixture
def step() -> SceneDetailStep:
    """최소 mock된 SceneDetailStep."""
    instance = SceneDetailStep.__new__(SceneDetailStep)
    instance.step_id = "scene_detail"
    instance.project_id = "p1"
    instance.episode_id = "e1"
    instance.db = MagicMock()
    instance.project_config = {}
    instance.manifest = {}
    instance.run_id = "r1"
    instance.opik_context = {}
    return instance


@pytest.fixture
def minimal_ctx() -> SceneAnalysisContext:
    """Shot-based 분석에 필요한 최소 ctx."""
    return SceneAnalysisContext(
        project_id="p1",
        episode_id="e1",
        scene_visible={1: ["C01", "L01"]},
        summaries={1: "테스트 씬 요약"},
        entities={
            "characters": [
                {"short_id": "C01", "name": "민숙", "t2i_prompt": "A Korean woman"},
            ],
            "locations": [
                {"short_id": "L01", "name": "방", "t2i_prompt": "An empty room"},
            ],
            "props": [],
        },
        outlook_data={
            "outlooks": [
                {"outlook_id": "O01", "short_id": "O01", "name": "평상복"},
            ],
            "scene_assignments": [
                {"scene_index": 1, "assignments": [
                    {"character_id": "C01", "outlook_id": "O01"},
                ]},
            ],
        },
        shot_director_ve={(1, 1): ["C01", "L01"]},
        shot_director_vr={},
        dependencies=[],
        scene_shots_map={},
        beats_by_scene={1: {1: {"change_type": "discover", "before_state": "a", "after_state": "b"}}},
        fixed_elements_by_scene={},
        # G4.1 Wave 4 R4 B4 (R1-I1): shot path 는 staging 필수 — 빈 staging_map
        # 은 build_render_strategy fail-fast trigger. minimal staging entry 제공.
        # framing_scale enum SOT v1 (2026-05-15): framing_scale field 필수 —
        # helper get_framing_scale_or_raise fail-fast (Gate 4 No Silent Fallback).
        staging_map={"1_1": {"camera_direction": "medium shot",
                              "framing_scale": "medium",
                              "lighting_mood": "warm dim",
                              # Area C (2026-05-12) — required by
                              # build_id_policy. 빈 list = 비-재현면 shot
                              # (applies=False).
                              "key_bg_elements": [],
                              # Area #1 W5 (2026-05-16) — shot_staging v12
                              # top-level required field. helper SOT
                              # graceful empty.
                              "subject_reference_policy": []}},
        shot_types_block="",
    )


SAMPLE_SEG = {"scene_index": 1, "text": "민숙이 방에 들어선다."}
SAMPLE_SHOT = {"shot_index": 1, "description": "민숙이 문을 연다", "based_on_beat": 1}


def _mock_llm_result(t2i_prompts, outfits=None, owned_usages=None):
    """call_structured mock 결과 빌더.

    현재 scene_detail 스키마 필수 필드에 맞춘 최소 응답. 스키마 drift 시
    mock을 여기만 갱신하면 됨 (prompts/_base/scene_detail/<version>/detail_schema.json).

    C2 v1: v30 schema 는 t2i_variations[].owned_object_usage[] required.
    owned 없는 mock path 는 빈 echo ([]) — coverage([], []) 통과.
    """
    outfits = outfits or [[] for _ in t2i_prompts]
    owned_usages = owned_usages or [[] for _ in t2i_prompts]
    return {
        "heading": "INT. 방 - NIGHT",
        "beat_title": "민숙이 방에 들어섬",
        "scene_type": "action",
        "representative_moment": "민숙이 방에 들어서는 순간",
        "t2i_variations": [
            {
                "t2i_prompt": p,
                "outfit_assignments": o,
                "variant_label": f"var_{i}",
                "camera_effect": "standard",
                "owned_object_usage": ou,
            }
            for i, (p, o, ou) in enumerate(zip(t2i_prompts, outfits, owned_usages))
        ],
    }


# ──────────────────────────────────────────────────────────────────────
# 1) 기본 결과 구조
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_returns_scene_and_shot_index(step, minimal_ctx):
    """결과에 scene_index / _shot_index / visible_entities 포함."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(
            ["A Korean woman C01 entering the room L01"],
            [[{"character_id": "C01", "outlook_id": "O01"}]],
        )
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={"type": "object"},
        )

    assert result is not None
    assert result["scene_index"] == 1
    assert result["_shot_index"] == 1
    assert result["visible_entities"] == ["C01", "L01"]


def test_analyze_one_returns_none_on_llm_exception(step, minimal_ctx):
    """call_structured가 예외 → None 반환."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.side_effect = RuntimeError("llm down")
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )
    assert result is None


# ──────────────────────────────────────────────────────────────────────
# 2) fixed_elements 주입 (scene_consistency)
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_injects_fixed_elements_into_user_prompt(step, minimal_ctx):
    """applies_to_shots에 현재 shot이 포함되면 user_prompt에 [교차 샷 고정 요소] 포함."""
    # G4.1 + Area #4: fixed_element 는 10 필드 모두 필수 (strict shape).
    minimal_ctx.fixed_elements_by_scene = {1: [
        {"element_id": "dead_minsook", "element_type": "character_state",
         "character_name": "민숙", "description": "lying face-down",
         "applies_to_shots": [1, 2],
         "element_scope": "full",
         "source_facts": [], "visual_inferences": [],
         "creative_decisions": [], "confidence": "high"},
    ]}
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(["ok"])
        step._analyze_one(SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={})

    user_prompt = mock_call.call_args.kwargs["user_prompt"]
    assert "[교차 샷 고정 요소]" in user_prompt
    assert "lying face-down" in user_prompt
    assert "dead_minsook" in user_prompt


def test_analyze_one_skips_fixed_elements_when_shot_not_in_applies(step, minimal_ctx):
    """applies_to_shots에 없으면 fixed_elements 미주입."""
    # G4.1 + Area #4: fixed_element 10 필드 모두 필수.
    minimal_ctx.fixed_elements_by_scene = {1: [
        {"element_id": "x", "element_type": "character_state",
         "character_name": "민숙", "description": "irrelevant",
         "applies_to_shots": [2, 3],
         "element_scope": "full",
         "source_facts": [], "visual_inferences": [],
         "creative_decisions": [], "confidence": "high"},
    ]}
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(["ok"])
        step._analyze_one(SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={})

    user_prompt = mock_call.call_args.kwargs["user_prompt"]
    assert "irrelevant" not in user_prompt


# ──────────────────────────────────────────────────────────────────────
# 3) VE 위반 retry 경로
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_retries_on_ve_violation(step, minimal_ctx):
    """visible에 없는 ID가 prompt에 있으면 retry. 두 번째 call_structured 호출됨."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        first = _mock_llm_result(["A person C99 entering"])  # C99 = 위반
        second = _mock_llm_result(["A person entering"])  # 수정됨
        mock_call.side_effect = [first, second]
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )

    assert mock_call.call_count == 2
    assert result is not None
    # retry에서는 schema_name이 _retry 접미사
    second_call_kwargs = mock_call.call_args_list[1].kwargs
    assert second_call_kwargs["schema_name"].endswith("_retry")


def test_analyze_one_forcibly_removes_ids_when_retry_also_violates(step, minimal_ctx):
    """Retry 후에도 위반 → 강제 제거."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        # 둘 다 C99 포함
        mock_call.side_effect = [
            _mock_llm_result(["person C99 walks"]),
            _mock_llm_result(["person C99 walks"]),
        ]
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )

    assert result is not None
    final_prompt = result["t2i_variations"][0]["t2i_prompt"]
    # C99가 제거됐어야 함
    assert "C99" not in final_prompt


def test_analyze_one_replaces_violation_via_shot_director_vr(step, minimal_ctx):
    """Retry 후 위반 ID의 base가 shot_director_vr에 매핑되면 variant로 치환 (C02→C01)."""
    # visible=[C01,L01], LLM이 C02 사용 → shot_director_vr[(1,1)]["C02"]="C01"로 치환
    minimal_ctx.shot_director_vr = {(1, 1): {"C02": "C01"}}
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.side_effect = [
            _mock_llm_result(
                ["person C02 walks"],
                [[{"character_id": "C02", "outlook_id": "O01"}]],
            ),
            _mock_llm_result(
                ["person C02 walks"],
                [[{"character_id": "C02", "outlook_id": "O01"}]],
            ),
        ]
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )

    assert result is not None
    assert mock_call.call_count == 2  # retry 발생
    prompt = result["t2i_variations"][0]["t2i_prompt"]
    # C02 제거 + C01로 치환 (이후 bare C01 → C01O01로 조합)
    assert "C02" not in prompt
    assert "C01" in prompt
    # outfit_assignments도 치환
    cids = [a.get("character_id") for a in result["t2i_variations"][0]["outfit_assignments"]]
    assert "C02" not in cids
    assert "C01" in cids


# ──────────────────────────────────────────────────────────────────────
# 4) outfit_assignments 정리
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_removes_outfit_for_non_visible_character(step, minimal_ctx):
    """visible에 없는 캐릭터의 outfit은 제거."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(
            ["C01 at L01"],
            [[
                {"character_id": "C01", "outlook_id": "O01"},
                {"character_id": "C99", "outlook_id": "O99"},  # visible에 없음
            ]],
        )
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )

    assignments = result["t2i_variations"][0]["outfit_assignments"]
    cids = [a["character_id"] for a in assignments]
    assert "C99" not in cids


def test_analyze_one_auto_assigns_fixed_outfit(step, minimal_ctx):
    """씬 아웃룩이 1개면 자동 할당 (LLM이 빠뜨려도)."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(
            ["C01 at L01"],
            [[]],  # LLM이 outfit 빠뜨림
        )
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )

    assignments = result["t2i_variations"][0]["outfit_assignments"]
    assert any(a["character_id"] == "C01" and a["outlook_id"] == "O01" for a in assignments)


def test_analyze_one_replaces_bare_cid_with_composite(step, minimal_ctx):
    """bare C01 → C01O01로 치환 (outfit 배정 후)."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(
            ["C01 at L01"],
            [[{"character_id": "C01", "outlook_id": "O01"}]],
        )
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )

    prompt = result["t2i_variations"][0]["t2i_prompt"]
    assert "C01O01" in prompt


# ──────────────────────────────────────────────────────────────────────
# 5) C3: t2i_prompt blind whitespace cleanup 폐기
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_preserves_valid_entity_prompt_spacing(step, minimal_ctx):
    """C3 — valid entity prompt 의 producer 공백은 blind collapse 하지 않는다.

    C3 가 detail_steps 의 무조건 연속-공백 cleanup loop 을 폐기 — 무효 SID strip
    이 적용되지 않는 valid entity prompt 의 producer-emit 연속 공백은 그대로
    보존된다 (Track B no-blind-mutation).
    """
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(["C01  at  L01"])  # 이중 공백
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )
    prompt = result["t2i_variations"][0]["t2i_prompt"]
    # 무효 SID strip 미적용 — producer 연속 공백 보존 (cleanup loop 폐기 증명)
    assert "  at  " in prompt


# ──────────────────────────────────────────────────────────────────────
# 6) 엔티티 미등록 fallback
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_uses_fallback_when_no_entities(step, minimal_ctx):
    """visible이 비어도 돈다 (빈 엔티티 안내 메시지)."""
    minimal_ctx.scene_visible = {1: []}
    minimal_ctx.shot_director_ve = {(1, 1): []}
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(["generic scene"])
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )
    assert result is not None
    user_prompt = mock_call.call_args.kwargs["user_prompt"]
    assert "엔티티 ID(C##, L##, P## 등)를 사용하지 마세요" in user_prompt


# ──────────────────────────────────────────────────────────────────────
# 7) legacy 경로 (shot_info=None) — Phase 3b.6 Claude 리뷰 Important #1 반영
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_legacy_no_shot_info(step, minimal_ctx):
    """shot_info=None 시에도 정상 동작 (legacy scene-level 모드)."""
    # legacy 경로는 ctx.shot_scenes_map 참조 — 이전 리팩토링 누락된 bare name 재발 방지
    minimal_ctx.shot_scenes_map = {1: [{"shot_index": 1, "description": "door open"}]}
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(["generic scene"])
        result = step._analyze_one(
            SAMPLE_SEG, None, minimal_ctx, system="sys", schema={},
        )
    assert result is not None
    assert result["scene_index"] == 1
    assert result["_shot_index"] is None
    # legacy 블록 진입 확인 — [씬의 Shot 분석] 헤더가 user_prompt에 포함
    user_prompt = mock_call.call_args.kwargs["user_prompt"]
    assert "[씬의 Shot 분석]" in user_prompt


# ──────────────────────────────────────────────────────────────────────
# 8) still_frame_prompt / representative_moment VE 정리
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_strips_violations_from_representative_moment(step, minimal_ctx):
    """representative_moment의 VE 위반 ID 강제 제거 (t2i_prompt retry와 독립).

    현재 `\\b` 기반 정리는 ASCII/공백 경계에서만 동작 — 한글 직접 붙음은 별도 이슈.
    """
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        res = _mock_llm_result(["clean prompt"])  # t2i_prompt clean → retry 없음
        res["representative_moment"] = "a person C99 standing alone"
        mock_call.return_value = res
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )
    assert result is not None
    assert "C99" not in result["representative_moment"]


@pytest.mark.skip(reason="W3-3 cluster B drift — v3/v2 계약이 현재(v4/shot-more) 구현과 어긋남. docs/review-codex-1/11-fix-plan.md §8.2 참조. 복원/재작성은 Wave 5 이후 재평가.")
def test_analyze_one_strips_violations_from_still_frame_prompt(step, minimal_ctx):
    """still_frame_prompt 필드가 별도로 존재하면 동일하게 정리."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        res = _mock_llm_result(["clean prompt"])
        res["representative_moment"] = "a person C99 standing alone"
        res["still_frame_prompt"] = "a person C99 standing alone"
        mock_call.return_value = res
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )
    assert result is not None
    assert "C99" not in result["representative_moment"]
    assert "C99" not in result["still_frame_prompt"]


def test_analyze_one_leaves_clean_representative_moment_intact(step, minimal_ctx):
    """representative_moment가 깨끗하면 변경 없음 (false positive 방지)."""
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        res = _mock_llm_result(["clean prompt"])
        res["representative_moment"] = "민숙이 문을 연다"  # ID 없음
        mock_call.return_value = res
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
        )
    assert result is not None
    assert result["representative_moment"] == "민숙이 문을 연다"


# ──────────────────────────────────────────────────────────────────────
# N) C6 — [조명/색감 판단] 감정→색 closed-list 폐기 (fix-critical-1 Tier β #5)
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_color_instruction_drops_emotion_closed_list(step, minimal_ctx):
    """C6: [조명/색감 판단] block 의 감정→색 closed-list 폐기 — generic instruction.

    audit P034 — detail_steps.py:2327 의 감정→색 고정 매핑(긴장=.../슬픔=.../분노=...)
    제거 + generic instruction 치환 회귀 가드. old residue 는 split-string 상수로
    검사 (test 파일 self-hit 방지).
    """
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(["ok"])
        step._analyze_one(SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={})

    user_prompt = mock_call.call_args.kwargs["user_prompt"]

    # G1 — closed-list residue 부재 (감정→색 고정 매핑)
    assert ("긴장=" + "어둡고 대비") not in user_prompt
    assert ("슬픔=" + "탈색") not in user_prompt
    assert ("분노=" + "적색") not in user_prompt
    # G2 — generic 치환 wording 존재 (안정 substring marker)
    assert "특정 정서에 고정 색조를 대응시키도록" in user_prompt
    # G3 — boundary 2326 보존 (시간대/색온도 줄)
    assert "색온도(따뜻한/차가운)" in user_prompt
    # G4 — boundary 2328 보존 (조명 방향 줄) + block header
    assert "역광/사광/정면광" in user_prompt
    assert "[조명/색감 판단]" in user_prompt


def test_analyze_one_color_instruction_source_has_no_emotion_closed_list():
    """C6 G5 — detail_steps.py source 한 파일에 감정→색 closed-list literal 0.

    path-scoped canary — detail_steps.py 만 read (신규 test 파일·docs·audit
    history 미포함). old literal 은 split-string 으로 검사.
    """
    from pathlib import Path

    import app.core.steps.detail_steps as _detail_steps_mod

    src = Path(_detail_steps_mod.__file__).read_text(encoding="utf-8")
    assert ("긴장=" + "어둡고 대비 강한") not in src
    assert ("슬픔=" + "탈색/청색") not in src
    assert ("분노=" + "적색") not in src


# ──────────────────────────────────────────────────────────────────────
# O) FINDING 9 W4 — reference_phrase_kinds 'prop' over-declaration 정규화
# ──────────────────────────────────────────────────────────────────────


def test_analyze_one_strips_phantom_prop_phrase_kind(step, minimal_ctx):
    """FINDING 9 W4 (Cat4): scene_detail producer 가 reference_phrase_kinds 의
    phantom 'prop' 을 narrow render_prompt_card.required_refs SOT 기준으로 제거.

    minimal_ctx 는 props=[] / render_contracts 없음 → narrow card 의
    required_refs 에 kind=prop 0. LLM 이 sidecar 에 'prop' 을 emit 해도
    _analyze_one 결과에서 제거돼야 ref_contract_validator step 6 phantom guard
    가 image-gen 을 fail-fast 하지 않는다. 'character' 는 보존."""
    llm_result = _mock_llm_result(
        ["A Korean woman C01 entering the room L01"],
        [[{"character_id": "C01", "outlook_id": "O01"}]],
    )
    llm_result["t2i_variations"][0]["reference_phrase_kinds"] = ["character", "prop"]
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = llm_result
        result = step._analyze_one(
            SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys",
            schema={"type": "object"},
        )

    assert result is not None
    required_refs = (
        result["render_prompt_card"]["asset_requirements"]["required_refs"]
    )
    assert not any(r.get("kind") == "prop" for r in required_refs)
    assert result["t2i_variations"][0]["reference_phrase_kinds"] == ["character"]


# ──────────────────────────────────────────────────────────────────────
# 7) 이 씬에 아웃룩 배정이 하나도 없는 인물 (2026-09-17 컨트리로드 실측)
#
# 의상 단계가 91씬 중 6씬에서 인물 9명을 비워 뒀다(회상·모니터 속 등). 그중
# 4샷에서 그 인물이 화면에 보인다. 프롬프트는 「allowed_outlook_pairs 에 짝이
# 없으면 C##O00」이라 시키는데, 코드는 (cid, O00) 을 허용 짝으로 안 봐서
# 재시도 → 강제 제거로 인물이 그림 문장에서 통째로 사라졌다.
# O00 은 이미지 단계에서 인물 기본 참조를 그대로 붙인다(scene_reference_service).
# ──────────────────────────────────────────────────────────────────────


def _ctx_with_unassigned_character(ctx):
    """C02 는 이 샷에 보이지만 이 씬에 아웃룩 배정이 없다."""
    ctx.scene_visible = {1: ["C01", "C02", "L01"]}
    ctx.shot_director_ve = {(1, 1): ["C01", "C02", "L01"]}
    ctx.entities["characters"].append(
        {"short_id": "C02", "name": "수희", "t2i_prompt": "A Korean girl"})
    return ctx


def test_visible_character_without_outlook_survives_as_o00(step, minimal_ctx):
    """`C02O00` 이 재시도·강제 제거 없이 남고, 의상 배정도 (C02, O00) 으로 남는다."""
    ctx = _ctx_with_unassigned_character(minimal_ctx)
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(
            ["C01O01 and C02O00, a young girl, at L01"],
            [[{"character_id": "C01", "outlook_id": "O01"},
              {"character_id": "C02", "outlook_id": "O00"}]],
        )
        result = step._analyze_one(SAMPLE_SEG, SAMPLE_SHOT, ctx, system="sys", schema={})

    assert result is not None
    assert mock_call.call_count == 1, "허용해야 할 C02O00 을 위반으로 보고 재시도했다"
    var = result["t2i_variations"][0]
    assert "C02O00" in var["t2i_prompt"], "보이는 인물이 그림 문장에서 사라졌다"
    assert {"character_id": "C02", "outlook_id": "O00"} in var["outfit_assignments"]


def test_card_pairs_and_mirror_pairs_agree_for_character_without_outlook(step, minimal_ctx):
    """카드에 실린 허용 짝 == 검증용 재계산(_derive_outlook_pairs_for_shot) — 한쪽만 고치면 지문이 어긋난다."""
    from app.core.steps.detail_steps import _derive_outlook_pairs_for_shot

    ctx = _ctx_with_unassigned_character(minimal_ctx)
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(
            ["C01O01 and C02O00 at L01"],
            [[{"character_id": "C01", "outlook_id": "O01"},
              {"character_id": "C02", "outlook_id": "O00"}]],
        )
        result = step._analyze_one(SAMPLE_SEG, SAMPLE_SHOT, ctx, system="sys", schema={})

    pair = lambda ps: {(p["character_id"], p["outlook_id"]) for p in ps}
    card = pair(result["render_prompt_card"]["id_policy"]["allowed_outlook_pairs"])
    mirror = pair(_derive_outlook_pairs_for_shot(ctx, 1, 1))
    assert ("C02", "O00") in card
    assert card == mirror


def test_corrections_ride_in_front_of_the_user_prompt(step, minimal_ctx):
    """`_analyze_one(corrections=...)` — 직전 계약 위반이 첫 호출 user_prompt 앞에 실린다."""
    why = "S1_Shot1 (variation 0): subject 'C01' policy='id_and_outlook_required' requires base C## in t2i_prompt, but missing."
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(
            ["C01O01 at L01"], [[{"character_id": "C01", "outlook_id": "O01"}]])
        result = step._analyze_one(SAMPLE_SEG, SAMPLE_SHOT, minimal_ctx, system="sys", schema={},
                                   corrections=[why])
    assert result is not None
    first = mock_call.call_args_list[0].kwargs["user_prompt"]
    assert first.startswith("[수정 요청]")
    assert why in first


# ──────────────────────────────────────────────────────────────────────
# 8) 배경 표식 `L###B##` 의 앞부분을 장소 표식으로 읽지 않는다 (2026-09-18 컨트리로드)
#
# 팩이 `[L…: …]` 라벨을 허용하고 모델은 background_binding.bg_id(`L143B01`)를 라벨로
# 썼다. 옛 규칙은 `L143` 만 떼어 VE 밖이라 보고 재시도 → 강제 제거(못 지움) → owned
# 수리본 폐기로 단계를 세웠다. 진짜 VE 밖 장소 표식은 그대로 잡는다.
# ──────────────────────────────────────────────────────────────────────


def _ctx_without_location_in_ve(ctx):
    ctx.scene_visible = {1: ["C01"]}
    ctx.shot_director_ve = {(1, 1): ["C01"]}
    return ctx


def test_background_id_label_is_not_read_as_a_location_violation(step, minimal_ctx):
    ctx = _ctx_without_location_in_ve(minimal_ctx)
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.return_value = _mock_llm_result(
            ["C01O01 stands still. [L143B01: a spacious research laboratory]"],
            [[{"character_id": "C01", "outlook_id": "O01"}]])
        result = step._analyze_one(SAMPLE_SEG, SAMPLE_SHOT, ctx, system="sys", schema={})
    assert mock_call.call_count == 1, "배경 표식을 장소 위반으로 읽고 재시도했다"
    assert "[L143B01: a spacious research laboratory]" in result["t2i_variations"][0]["t2i_prompt"]


def test_a_real_location_id_outside_ve_is_still_caught(step, minimal_ctx):
    ctx = _ctx_without_location_in_ve(minimal_ctx)
    with patch("app.core.steps.detail_steps.call_structured") as mock_call:
        mock_call.side_effect = [
            _mock_llm_result(["C01O01 stands in L99"], [[{"character_id": "C01", "outlook_id": "O01"}]]),
            _mock_llm_result(["C01O01 stands in the room"], [[{"character_id": "C01", "outlook_id": "O01"}]]),
        ]
        step._analyze_one(SAMPLE_SEG, SAMPLE_SHOT, ctx, system="sys", schema={})
    assert mock_call.call_count == 2, "VE 밖 장소 표식을 놓쳤다"
