"""background_chain_planning validator 단위 테스트.

semantic invariant validator(`validate_plan`)와 phase 0 그루핑(`_phase0_group_by_location`)을
검증한다. LLM 호출은 stub하지 않고 validator/그루핑 순수 로직만 검증.
"""
from __future__ import annotations

from unittest.mock import patch

import pytest

from app.modules.pipeline.background_chain_planning import (
    LocationGroup,
    ShotInfo,
    _phase0_group_by_location,
    _plan_one_location,
    validate_plan,
)


def _shot(scene_index: int, shot_index: int) -> ShotInfo:
    return ShotInfo(
        scene_index=scene_index,
        shot_index=shot_index,
        shot_id=f"S{scene_index:02d}_Shot{shot_index}",
        description="",
    )


def _group(location_id: str, shots) -> LocationGroup:
    return LocationGroup(
        location_id=location_id,
        location_name="anonymous_room",
        location_description="",
        visual_traits=[],
        shots=list(shots),
    )


def _valid_plan(location_id: str, shot_ids):
    """모든 shot을 단일 root anchor에 묶은 valid plan."""
    return {
        "location_id": location_id,
        "rationale_summary": "single anchor covers all shots in this location.",
        "nodes": [{
            "id": "interior_main_room_day",
            "kind": "anchor_root",
            "label": "main room day wide",
            "description": "wide shot of the main room with neutral daylight from a side window.",
            "shot_ids": list(shot_ids),
            "parent_id": "",
            "depth": 0,
            "rationale": "anchor — first wide view of this location.",
            "shared_visual_anchors_with_parent": [],
        }],
        "execution_order": ["interior_main_room_day"],
        "unassigned_shots": [],
    }


# ───────────────────────────── validate_plan ─────────────────────────────


def test_validate_plan_happy_path():
    group = _group("L01", [_shot(1, 1), _shot(1, 2)])
    plan = _valid_plan("L01", ["S01_Shot1", "S01_Shot2"])
    assert validate_plan(plan, group) == []


def test_validate_plan_location_id_mismatch():
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L99", ["S01_Shot1"])
    errors = validate_plan(plan, group)
    assert any("location_id mismatch" in e for e in errors)


def test_validate_plan_missing_shot():
    group = _group("L01", [_shot(1, 1), _shot(1, 2)])
    plan = _valid_plan("L01", ["S01_Shot1"])  # S01_Shot2 누락
    errors = validate_plan(plan, group)
    assert any("missing from any node" in e for e in errors)


def test_validate_plan_duplicate_shot():
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"].append({
        "id": "interior_main_room_night",
        "kind": "anchor_state",
        "label": "main room night",
        "description": "same room at night with single ceiling light.",
        "shot_ids": ["S01_Shot1"],  # duplicate
        "parent_id": "interior_main_room_day",
        "depth": 1,
        "rationale": "child of day anchor.",
        "shared_visual_anchors_with_parent": ["wall finish"],
    })
    plan["execution_order"].append("interior_main_room_night")
    errors = validate_plan(plan, group)
    assert any("duplicated" in e for e in errors)


def test_validate_plan_unknown_parent():
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["parent_id"] = "ghost_node"
    plan["nodes"][0]["kind"] = "anchor_state"
    errors = validate_plan(plan, group)
    assert any("not found in nodes" in e for e in errors)


def test_validate_plan_execution_order_parent_after_child():
    group = _group("L01", [_shot(1, 1), _shot(1, 2)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"].append({
        "id": "child",
        "kind": "anchor_state",
        "label": "child node",
        "description": "child of root with same room at dusk.",
        "shot_ids": ["S01_Shot2"],
        "parent_id": "interior_main_room_day",
        "depth": 1,
        "rationale": "parent at dusk.",
        "shared_visual_anchors_with_parent": ["wall finish"],
    })
    plan["execution_order"] = ["child", "interior_main_room_day"]  # 부모 뒤
    errors = validate_plan(plan, group)
    assert any("comes before its parent" in e for e in errors)


def test_validate_plan_execution_order_missing_node():
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["execution_order"] = []  # 노드 누락
    errors = validate_plan(plan, group)
    assert any("execution_order missing nodes" in e for e in errors)


def test_validate_plan_no_root_anchor():
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["kind"] = "anchor_state"
    plan["nodes"][0]["parent_id"] = "interior_main_room_day"  # 자기 자신을 가리키는 비정상 — but root 감지 우선
    errors = validate_plan(plan, group)
    assert any("anchor_root" in e for e in errors)


def test_validate_plan_korean_in_description_rejected():
    """universal-noun 룰: 한글 잔재 차단."""
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["description"] = "방 안에 햇빛이 들어오는 모습."  # 한글
    errors = validate_plan(plan, group)
    assert any("non-ASCII" in e for e in errors)


def test_validate_plan_korean_in_rationale_summary_rejected():
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["rationale_summary"] = "이 location의 chain은..."
    errors = validate_plan(plan, group)
    assert any("rationale_summary" in e for e in errors)


def test_validate_plan_kana_in_label_rejected():
    """가타카나/히라가나도 차단."""
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["label"] = "リビング wide shot"  # kana
    errors = validate_plan(plan, group)
    assert any("non-ASCII" in e for e in errors)


def test_validate_plan_cjk_extension_a_rejected():
    """CJK Extension A (U+3400-U+4DBF) 한자도 차단 — Hanja 범위 확장 검증."""
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["description"] = "wide view of 㐀 main room"  # Ext A 한자
    errors = validate_plan(plan, group)
    assert any("non-ASCII" in e for e in errors)


def test_validate_plan_cjk_compat_ideograph_rejected():
    """CJK Compatibility Ideographs (U+F900-U+FAFF) 한자도 차단."""
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["description"] = "wide view of 﨑 main room"  # Compat
    errors = validate_plan(plan, group)
    assert any("non-ASCII" in e for e in errors)


def test_validate_plan_duplicate_node_ids_rejected():
    """동일 id의 노드가 두 번 등장하면 reject (PR #3 Codex HIGH 2 회귀 가드)."""
    group = _group("L01", [_shot(1, 1), _shot(1, 2)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"].append({
        "id": "interior_main_room_day",  # 중복 id
        "kind": "anchor_state",
        "label": "duplicate id",
        "description": "another wide of the same room.",
        "shot_ids": ["S01_Shot2"],
        "parent_id": "interior_main_room_day",
        "depth": 1,
        "rationale": "child.",
        "shared_visual_anchors_with_parent": ["wall finish"],
    })
    plan["execution_order"].append("interior_main_room_day")  # exec_order에서도 중복
    errors = validate_plan(plan, group)
    assert any("duplicate node id" in e for e in errors)


def test_validate_plan_literal_null_parent_id_rejected():
    """parent_id="null" sentinel은 reject (PR #3 Codex HIGH 3 회귀 가드)."""
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["parent_id"] = "null"
    errors = validate_plan(plan, group)
    assert any("literal null sentinel" in e for e in errors)


def test_validate_plan_literal_None_parent_id_rejected():
    """parent_id="None" sentinel도 reject."""
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["parent_id"] = "None"
    errors = validate_plan(plan, group)
    assert any("literal null sentinel" in e for e in errors)


def test_validate_plan_uppercase_NULL_parent_id_rejected():
    """대소문자 무관하게 reject."""
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1"])
    plan["nodes"][0]["parent_id"] = "NULL"
    errors = validate_plan(plan, group)
    assert any("literal null sentinel" in e for e in errors)


def test_validate_plan_extra_shot_in_node_rejected():
    """입력 group.shots에 없는 shot이 노드에 들어가면 안 된다."""
    group = _group("L01", [_shot(1, 1)])
    plan = _valid_plan("L01", ["S01_Shot1", "S99_ShotGhost"])
    errors = validate_plan(plan, group)
    assert any("not in input shots" in e for e in errors)


# ─────────────────────── _phase0_group_by_location ───────────────────────


def test_phase0_groups_selected_shots_by_location():
    """director.primary_location 기준 + selected_shot_indices 필터."""
    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "wide"},
            {"shot_index": 2, "description": "close-up"},
            {"shot_index": 3, "description": "skipped"},
        ]},
    ]}
    shot_selection = {"scenes": [
        {"scene_index": 1, "selected_shot_indices": [1, 2]},  # 3 제외
    ]}
    director = {"scenes": [
        {"scene_index": 1, "primary_location": "L04"},
    ]}
    entity_merge = {"locations": [
        {"short_id": "L04", "name": "방", "description": "room", "visual_traits": []},
    ]}

    groups = _phase0_group_by_location(
        shot_extract, shot_selection, {"shots": []}, director, entity_merge,
    )
    assert "L04" in groups
    shot_ids = {s.shot_id for s in groups["L04"].shots}
    assert shot_ids == {"S01_Shot1", "S01_Shot2"}


def test_phase0_legacy_korean_location_name_mapped_to_short_id():
    """primary_location이 한글 이름이면 entity_merge의 name → short_id로 변환."""
    shot_extract = {"scenes": [
        {"scene_index": 5, "shots": [{"shot_index": 1, "description": "x"}]},
    ]}
    shot_selection = {"scenes": [{"scene_index": 5, "selected_shot_indices": [1]}]}
    director = {"scenes": [{"scene_index": 5, "primary_location": "거실"}]}
    entity_merge = {"locations": [
        {"short_id": "L02", "name": "거실", "description": "", "visual_traits": []},
    ]}

    groups = _phase0_group_by_location(
        shot_extract, shot_selection, {"shots": []}, director, entity_merge,
    )
    assert "L02" in groups, f"keys: {list(groups.keys())}"


def test_phase0_unselected_shot_excluded():
    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [{"shot_index": 1, "description": "x"}]},
    ]}
    shot_selection = {"scenes": [{"scene_index": 1, "selected_shot_indices": []}]}  # 0개 선택
    director = {"scenes": [{"scene_index": 1, "primary_location": "L01"}]}
    entity_merge = {"locations": [{"short_id": "L01", "name": "x", "description": "", "visual_traits": []}]}

    groups = _phase0_group_by_location(
        shot_extract, shot_selection, {"shots": []}, director, entity_merge,
    )
    # L01에 shots가 0이거나 group 자체가 만들어지지 않음
    assert not groups or not groups.get("L01") or not groups["L01"].shots


def test_phase0_no_director_location_skips_shot():
    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [{"shot_index": 1, "description": "x"}]},
    ]}
    shot_selection = {"scenes": [{"scene_index": 1, "selected_shot_indices": [1]}]}
    director = {"scenes": []}  # primary_location 없음
    entity_merge = {"locations": []}

    groups = _phase0_group_by_location(
        shot_extract, shot_selection, {"shots": []}, director, entity_merge,
    )
    assert groups == {}


# ─────────────────────── C4/P043 — substring fallback 폐기 ───────────────────────


def test_phase0_substring_fallback_removed():
    """C4/P043: name 양방향 substring 매칭 fallback 폐기 — substring-overlap 하지만
    exact 불일치하는 primary_location 은 더 이상 resolve 되지 않고 ValueError."""
    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [{"shot_index": 1, "description": "x"}]},
    ]}
    shot_selection = {"scenes": [{"scene_index": 1, "selected_shot_indices": [1]}]}
    director = {"scenes": [{"scene_index": 1, "primary_location": "거실"}]}
    # entity name "옥탑방 거실" 은 "거실" 을 부분 문자열로 포함 — 구 substring fallback 이면 매칭됐음.
    entity_merge = {"locations": [
        {"short_id": "L07", "name": "옥탑방 거실", "description": "", "visual_traits": []},
    ]}
    with pytest.raises(ValueError):
        _phase0_group_by_location(
            shot_extract, shot_selection, {"shots": []}, director, entity_merge,
        )


def test_phase0_unresolved_location_raises():
    """C4/P043: exact name match·L-id 모두 실패 시 ValueError fail-loud —
    silent loc_id=loc_raw 로 비정규 key group 을 만들지 않는다 (No Silent Fallback)."""
    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [{"shot_index": 1, "description": "x"}]},
    ]}
    shot_selection = {"scenes": [{"scene_index": 1, "selected_shot_indices": [1]}]}
    director = {"scenes": [{"scene_index": 1, "primary_location": "미지의장소"}]}
    entity_merge = {"locations": [
        {"short_id": "L01", "name": "거실", "description": "", "visual_traits": []},
    ]}
    with pytest.raises(ValueError, match="미지의장소"):
        _phase0_group_by_location(
            shot_extract, shot_selection, {"shots": []}, director, entity_merge,
        )


def test_phase0_exact_and_lid_path_preserved():
    """C4/P043: substring fallback 폐기 후에도 L-id direct + exact name match path 보존."""
    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [{"shot_index": 1, "description": "a"}]},
        {"scene_index": 2, "shots": [{"shot_index": 1, "description": "b"}]},
    ]}
    shot_selection = {"scenes": [
        {"scene_index": 1, "selected_shot_indices": [1]},
        {"scene_index": 2, "selected_shot_indices": [1]},
    ]}
    director = {"scenes": [
        {"scene_index": 1, "primary_location": "L04"},   # L-id direct path
        {"scene_index": 2, "primary_location": "거실"},   # exact name match path
    ]}
    entity_merge = {"locations": [
        {"short_id": "L04", "name": "방", "description": "", "visual_traits": []},
        {"short_id": "L02", "name": "거실", "description": "", "visual_traits": []},
    ]}
    groups = _phase0_group_by_location(
        shot_extract, shot_selection, {"shots": []}, director, entity_merge,
    )
    assert "L04" in groups
    assert "L02" in groups


def test_plan_one_location_escapes_curly_braces_in_user_content():
    """시나리오 description / location name에 `{` 문자가 들어와도 KeyError/ValueError
    없이 LLM 호출 단계까지 도달해야 한다 (PR #3 듀얼 리뷰 Issue 3 회귀 가드)."""
    group = LocationGroup(
        location_id="L01",
        location_name="anonymous {with brace} room",  # 중괄호 포함
        location_description="surface notes {1=foo} test",
        visual_traits=["lit by {window}", "warm"],
        shots=[ShotInfo(
            scene_index=1, shot_index=1, shot_id="S01_Shot1",
            description="A character looks at {a thing}.",  # 시나리오 텍스트 모방
        )],
    )

    captured: dict = {}

    def _fake_call_structured(**kwargs):
        captured["user_prompt"] = kwargs.get("user_prompt", "")
        return {
            "location_id": "L01",
            "rationale_summary": "stub.",
            "nodes": [{
                "id": "root", "kind": "anchor_root", "label": "wide",
                "description": "wide.", "shot_ids": ["S01_Shot1"],
                "parent_id": "", "depth": 0, "rationale": "anchor.",
                "shared_visual_anchors_with_parent": [],
            }],
            "execution_order": ["root"],
            "unassigned_shots": [],
        }

    with patch(
        "app.modules.pipeline.background_chain_planning.call_structured",
        side_effect=_fake_call_structured,
    ):
        plan = _plan_one_location(group, world_rules_excerpt="(none)")

    assert plan["location_id"] == "L01"
    # escape 결과는 단일 중괄호 (.format이 {{ → { 로 변환)이라 user prompt에는 단일 brace 등장
    assert "{a thing}" in captured["user_prompt"]
    assert "{with brace}" in captured["user_prompt"]


def test_phase0_staging_attached_when_present():
    shot_extract = {"scenes": [
        {"scene_index": 7, "shots": [{"shot_index": 2, "description": "x"}]},
    ]}
    shot_selection = {"scenes": [{"scene_index": 7, "selected_shot_indices": [2]}]}
    director = {"scenes": [{"scene_index": 7, "primary_location": "L09"}]}
    entity_merge = {"locations": [{"short_id": "L09", "name": "x", "description": "", "visual_traits": []}]}
    staging = {"shots": [{
        "scene_index": 7, "shot_index": 2,
        "camera_direction": "dolly in", "lighting_mood": "warm dusk",
        "character_angles": [{"angle": "facing_camera"}],
    }]}

    groups = _phase0_group_by_location(
        shot_extract, shot_selection, staging, director, entity_merge,
    )
    s = groups["L09"].shots[0]
    assert s.camera_direction == "dolly in"
    assert s.lighting_mood == "warm dusk"
    assert s.character_angles and s.character_angles[0]["angle"] == "facing_camera"


# ─────────────────────── Phase 5: planner-driven path ───────────────────────


from app.modules.pipeline.background_chain_planning import (  # noqa: E402
    run_background_chain_planning,
)


def _shot_extract_two_groups():
    """2 location, 6 shots — group A (S1-1,1-2,1-3) + group B (S2-1,2-2,2-3)."""
    return {"scenes": [
        {"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "wide of room"},
            {"shot_index": 2, "description": "close-up"},
            {"shot_index": 3, "description": "another wide"},
        ]},
        {"scene_index": 2, "shots": [
            {"shot_index": 1, "description": "wide of garden"},
            {"shot_index": 2, "description": "garden detail"},
            {"shot_index": 3, "description": "garden wide 2"},
        ]},
    ]}


def _entity_merge_two_locs():
    return {"locations": [
        {"short_id": "L01", "name": "main_room", "description": "indoor room",
         "visual_traits": ["wood floor"]},
        {"short_id": "L02", "name": "garden", "description": "outdoor garden",
         "visual_traits": []},
    ]}


def _ok_plan(location_id: str, shot_ids):
    """validate_plan을 통과하는 단일 root anchor plan."""
    return {
        "location_id": location_id,
        "skip_chain": False,
        "skip_reason": "",
        "rationale_summary": f"single anchor for {location_id} with all shots.",
        "nodes": [{
            "id": f"interior_{location_id.lower()}_anchor",
            "kind": "anchor_root",
            "label": "wide anchor view",
            "description": "wide neutral view of the space with daylight.",
            "shot_ids": list(shot_ids),
            "parent_id": "",
            "depth": 0,
            "rationale": "anchor — first wide view of this group.",
            "shared_visual_anchors_with_parent": [],
        }],
        "execution_order": [f"interior_{location_id.lower()}_anchor"],
        "unassigned_shots": [],
    }


def test_planner_groups_none_falls_back_to_legacy(monkeypatch):
    """planner_groups=None이면 legacy `_phase0_group_by_location` path가 동작해야 한다."""
    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [{"shot_index": 1, "description": "x"}]},
    ]}
    shot_selection = {"scenes": [{"scene_index": 1, "selected_shot_indices": [1]}]}
    director = {"scenes": [{"scene_index": 1, "primary_location": "L01"}]}
    entity_merge = {"locations": [
        {"short_id": "L01", "name": "x", "description": "", "visual_traits": []},
    ]}

    # legacy path는 LocationGroup 그루핑 → _phase0 호출. 호출 발생 확인용 spy.
    seen = {"called": False}
    real = __import__(
        "app.modules.pipeline.background_chain_planning", fromlist=["_phase0_group_by_location"],
    )._phase0_group_by_location

    def _spy(*args, **kwargs):
        seen["called"] = True
        return {}  # 빈 그루핑 → 빈 locations 반환

    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning._phase0_group_by_location", _spy
    )

    result = run_background_chain_planning(
        shot_extract_data=shot_extract,
        shot_selection_data=shot_selection,
        shot_staging_data={"shots": []},
        director_data=director,
        entity_merge_data=entity_merge,
        planner_groups=None,
    )
    assert seen["called"] is True
    # legacy shape: data.locations만 존재. data.groups 키 없음.
    assert "locations" in result
    assert "groups" not in result


def test_planner_groups_provided_takes_planner_path(monkeypatch):
    """planner_groups dict가 있으면 group-based path, _phase0_group_by_location은 호출되지 않음."""
    seen = {"phase0_called": False}
    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning._phase0_group_by_location",
        lambda *a, **k: (seen.update(phase0_called=True), {})[1],
    )

    captured_calls = []

    def _fake_call_structured(**kwargs):
        captured_calls.append(kwargs)
        # location_id가 user_prompt에 있는지로 어떤 group인지 식별
        prompt = kwargs.get("user_prompt", "")
        if "L01" in prompt:
            return _ok_plan("L01", ["S1_Shot1", "S1_Shot2", "S1_Shot3"])
        return _ok_plan("L02", ["S2_Shot1", "S2_Shot2", "S2_Shot3"])

    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning.call_structured",
        _fake_call_structured,
    )

    planner_groups = {
        "groups": {
            "CB_L01_day": {
                "id": "CB_L01_day", "floor_plan_id": "FP_L01",
                "location_id": "L01", "scenes": [1],
                "shot_ids": ["S1_Shot1", "S1_Shot2", "S1_Shot3"],
                "kind": "anchor_root", "parent_id": "", "time": "day",
                "rationale": "main room day",
            },
            "CB_L02_day": {
                "id": "CB_L02_day", "floor_plan_id": "FP_L02",
                "location_id": "L02", "scenes": [2],
                "shot_ids": ["S2_Shot1", "S2_Shot2", "S2_Shot3"],
                "kind": "anchor_root", "parent_id": "", "time": "day",
                "rationale": "garden day",
            },
        },
        "order": ["CB_L01_day", "CB_L02_day"],
    }

    result = run_background_chain_planning(
        shot_extract_data=_shot_extract_two_groups(),
        shot_selection_data={"scenes": []},
        shot_staging_data={"shots": []},
        director_data={"scenes": []},
        entity_merge_data=_entity_merge_two_locs(),
        planner_groups=planner_groups,
    )
    assert seen["phase0_called"] is False, "planner-driven path must not call _phase0"
    assert "groups" in result
    assert set(result["groups"].keys()) == {"CB_L01_day", "CB_L02_day"}
    assert all(v.get("status") == "ok" for v in result["groups"].values())
    # compat alias도 존재
    assert "locations" in result
    assert set(result["locations"].keys()) == {"L01", "L02"}
    assert len(captured_calls) == 2, "각 group은 별도 LLM call (HARD 제약)"


def test_planner_parent_context_injected_into_child_prompt(monkeypatch):
    """chain_bg_order대로 처리하면서 parent group의 chain bg 결과가 자식 prompt에 inject."""
    captured_user_prompts = []

    def _fake_call_structured(**kwargs):
        captured_user_prompts.append(kwargs.get("user_prompt", ""))
        # 첫 호출(parent)은 풍부한 description 반환, 두 번째(child)도 통과해야
        if "S1_Shot" in kwargs.get("user_prompt", ""):
            plan = _ok_plan("L01", ["S1_Shot1", "S1_Shot2", "S1_Shot3"])
            plan["nodes"][0]["description"] = (
                "wide view of a wood-floor room with neutral daylight from a window."
            )
            plan["rationale_summary"] = "parent established wood-floor + daylight."
            return plan
        return _ok_plan("L01", ["S2_Shot1", "S2_Shot2", "S2_Shot3"])

    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning.call_structured",
        _fake_call_structured,
    )

    planner_groups = {
        "groups": {
            "CB_L01_day": {
                "id": "CB_L01_day", "floor_plan_id": "FP_L01",
                "location_id": "L01", "scenes": [1],
                "shot_ids": ["S1_Shot1", "S1_Shot2", "S1_Shot3"],
                "kind": "anchor_root", "parent_id": "", "time": "day",
                "rationale": "parent",
            },
            "CB_L01_night": {
                "id": "CB_L01_night", "floor_plan_id": "FP_L01",
                "location_id": "L01", "scenes": [2],
                "shot_ids": ["S2_Shot1", "S2_Shot2", "S2_Shot3"],
                "kind": "anchor_state",
                "parent_id": "CB_L01_day",
                "time": "night",
                "rationale": "child",
            },
        },
        "order": ["CB_L01_day", "CB_L01_night"],
    }

    # 두 group 모두 location L01 (서로 다른 state). shot_ids는 다른 scene을 쓴다.
    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [
            {"shot_index": 1}, {"shot_index": 2}, {"shot_index": 3},
        ]},
        {"scene_index": 2, "shots": [
            {"shot_index": 1}, {"shot_index": 2}, {"shot_index": 3},
        ]},
    ]}
    entity_merge = {"locations": [
        {"short_id": "L01", "name": "main_room", "description": "indoor",
         "visual_traits": ["wood floor"]},
    ]}

    result = run_background_chain_planning(
        shot_extract_data=shot_extract,
        shot_selection_data={"scenes": []},
        shot_staging_data={"shots": []},
        director_data={"scenes": []},
        entity_merge_data=entity_merge,
        planner_groups=planner_groups,
    )
    assert len(captured_user_prompts) == 2
    parent_prompt, child_prompt = captured_user_prompts
    assert "[PARENT CHAIN BG" not in parent_prompt, "first group has no parent context"
    assert "[PARENT CHAIN BG" in child_prompt, "second group must receive parent context"
    # 부모 description의 핵심 키워드가 자식 prompt에 전달되는지 (continuity anchor)
    assert "wood-floor" in child_prompt or "daylight" in child_prompt
    # parent_context_used 필드가 정확히 표시
    assert result["groups"]["CB_L01_day"]["parent_context_used"] is False
    assert result["groups"]["CB_L01_night"]["parent_context_used"] is True


def test_planner_empty_chain_bg_groups_graceful_noop(monkeypatch):
    """planner.chain_bg_groups가 비어있는 dict면 graceful no-op (LLM 호출 0회)."""
    called = {"n": 0}

    def _fake_call_structured(**kwargs):
        called["n"] += 1
        return {}

    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning.call_structured",
        _fake_call_structured,
    )

    result = run_background_chain_planning(
        shot_extract_data=_shot_extract_two_groups(),
        shot_selection_data={"scenes": []},
        shot_staging_data={"shots": []},
        director_data={"scenes": []},
        entity_merge_data=_entity_merge_two_locs(),
        planner_groups={"groups": {}, "order": []},
    )
    assert called["n"] == 0
    assert result["groups"] == {}
    assert result["locations"] == {}
    assert result["_failed_count"] == 0


def test_planner_floor_plan_id_prepends_when_present(monkeypatch):
    """floor_plan_prompts에 location_id 매핑이 있으면 [FLOOR PLAN] 블록이 prepend.
    매핑이 없으면 그대로 (best-effort) — 회귀하지 않음."""
    captured_user_prompts = []

    def _fake_call_structured(**kwargs):
        captured_user_prompts.append(kwargs.get("user_prompt", ""))
        prompt = kwargs.get("user_prompt", "")
        if "L01" in prompt:
            return _ok_plan("L01", ["S1_Shot1", "S1_Shot2", "S1_Shot3"])
        return _ok_plan("L02", ["S2_Shot1", "S2_Shot2", "S2_Shot3"])

    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning.call_structured",
        _fake_call_structured,
    )

    planner_groups = {
        "groups": {
            "CB_L01_day": {
                "id": "CB_L01_day", "floor_plan_id": "FP_L01",
                "location_id": "L01", "scenes": [1],
                "shot_ids": ["S1_Shot1", "S1_Shot2", "S1_Shot3"],
                "kind": "anchor_root", "parent_id": "", "time": "day",
                "rationale": "ok",
            },
            "CB_L02_day": {
                "id": "CB_L02_day", "floor_plan_id": "FP_L02",
                "location_id": "L02", "scenes": [2],
                "shot_ids": ["S2_Shot1", "S2_Shot2", "S2_Shot3"],
                "kind": "anchor_root", "parent_id": "", "time": "day",
                "rationale": "ok",
            },
        },
        "order": ["CB_L01_day", "CB_L02_day"],
    }

    # floor_plan_prompts: L01만 있음 — L02는 없음 (best-effort)
    floor_plan_prompts = {"L01": "FLOOR PLAN: walls, doors, windows of L01 ..."}

    result = run_background_chain_planning(
        shot_extract_data=_shot_extract_two_groups(),
        shot_selection_data={"scenes": []},
        shot_staging_data={"shots": []},
        director_data={"scenes": []},
        entity_merge_data=_entity_merge_two_locs(),
        floor_plan_prompts=floor_plan_prompts,
        planner_groups=planner_groups,
    )
    # L01 prompt는 floor plan 블록이 있어야, L02는 없어야
    l01_prompts = [p for p in captured_user_prompts if "L01" in p and "S1_Shot" in p]
    l02_prompts = [p for p in captured_user_prompts if "L02" in p and "S2_Shot" in p]
    assert l01_prompts and "[FLOOR PLAN" in l01_prompts[0]
    assert l02_prompts and "[FLOOR PLAN" not in l02_prompts[0]
    # group 결과의 floor_plan_used 필드도 정확히 표시
    assert result["groups"]["CB_L01_day"]["floor_plan_used"] is True
    assert result["groups"]["CB_L02_day"]["floor_plan_used"] is False


def test_planner_root_group_no_parent_context(monkeypatch):
    """parent_id=""인 root group은 [PARENT CHAIN BG] 블록 없이 prompt 빌드."""
    captured_user_prompts = []

    def _fake_call_structured(**kwargs):
        captured_user_prompts.append(kwargs.get("user_prompt", ""))
        return _ok_plan("L01", ["S1_Shot1", "S1_Shot2", "S1_Shot3"])

    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning.call_structured",
        _fake_call_structured,
    )

    planner_groups = {
        "groups": {
            "CB_L01_root": {
                "id": "CB_L01_root", "floor_plan_id": "FP_L01",
                "location_id": "L01", "scenes": [1],
                "shot_ids": ["S1_Shot1", "S1_Shot2", "S1_Shot3"],
                "kind": "anchor_root",
                "parent_id": "",  # root
                "time": "day",
                "rationale": "root",
            },
        },
        "order": ["CB_L01_root"],
    }

    result = run_background_chain_planning(
        shot_extract_data=_shot_extract_two_groups(),
        shot_selection_data={"scenes": []},
        shot_staging_data={"shots": []},
        director_data={"scenes": []},
        entity_merge_data=_entity_merge_two_locs(),
        planner_groups=planner_groups,
    )
    assert len(captured_user_prompts) == 1
    assert "[PARENT CHAIN BG" not in captured_user_prompts[0]
    assert result["groups"]["CB_L01_root"]["parent_context_used"] is False


def test_planner_partial_group_failure_others_proceed(monkeypatch):
    """한 group이 LLM exception이어도 다른 group은 정상 진행해야 (부분 실패 격리)."""
    call_count = {"n": 0}

    def _fake_call_structured(**kwargs):
        call_count["n"] += 1
        prompt = kwargs.get("user_prompt", "")
        if "L01" in prompt:
            raise RuntimeError("simulated LLM failure for L01 group")
        return _ok_plan("L02", ["S2_Shot1", "S2_Shot2", "S2_Shot3"])

    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning.call_structured",
        _fake_call_structured,
    )

    planner_groups = {
        "groups": {
            "CB_L01_day": {
                "id": "CB_L01_day", "floor_plan_id": "FP_L01",
                "location_id": "L01", "scenes": [1],
                "shot_ids": ["S1_Shot1", "S1_Shot2", "S1_Shot3"],
                "kind": "anchor_root", "parent_id": "", "time": "day",
                "rationale": "fail group",
            },
            "CB_L02_day": {
                "id": "CB_L02_day", "floor_plan_id": "FP_L02",
                "location_id": "L02", "scenes": [2],
                "shot_ids": ["S2_Shot1", "S2_Shot2", "S2_Shot3"],
                "kind": "anchor_root", "parent_id": "", "time": "day",
                "rationale": "ok group",
            },
        },
        "order": ["CB_L01_day", "CB_L02_day"],
    }

    result = run_background_chain_planning(
        shot_extract_data=_shot_extract_two_groups(),
        shot_selection_data={"scenes": []},
        shot_staging_data={"shots": []},
        director_data={"scenes": []},
        entity_merge_data=_entity_merge_two_locs(),
        planner_groups=planner_groups,
    )
    # 두 group 모두 LLM 호출 시도 (한 group 실패가 다른 group을 막지 않음)
    assert call_count["n"] == 2
    assert result["groups"]["CB_L01_day"]["status"] == "exception"
    assert result["groups"]["CB_L02_day"]["status"] == "ok"
    assert result["_failed_count"] == 1


def test_load_planner_groups_returns_none_when_no_cp(tmp_path, monkeypatch):
    """planner cp가 없으면 _load_planner_groups → None (legacy fallback trigger)."""
    from app.core.steps.background_chain_planning_step import BackgroundChainPlanningStep

    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path / "p"))
    runner = BackgroundChainPlanningStep.__new__(BackgroundChainPlanningStep)
    runner.project_id = "p"
    runner.episode_id = "e"

    assert runner._load_planner_groups() is None


def test_load_planner_groups_returns_none_when_chain_bg_empty(tmp_path, monkeypatch):
    """planner cp가 있지만 chain_bg_groups=[]면 None — mode=chain_only(legacy) 회귀 보호."""
    import json
    from app.core.steps.background_chain_planning_step import BackgroundChainPlanningStep

    ckpt = tmp_path / "p" / "p" / "checkpoints" / "episodes" / "e" / "background_planner"
    ckpt.mkdir(parents=True)
    (ckpt / "manifest.json").write_text(json.dumps({
        "data": {
            "rationale_summary": "no anchored locations",
            "floor_plans": [],
            "floor_plan_order": [],
            "chain_bg_groups": [],   # 비어있음
            "chain_bg_order": [],
            "prev_shot_only": [],
        },
    }))

    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path / "p"))
    runner = BackgroundChainPlanningStep.__new__(BackgroundChainPlanningStep)
    runner.project_id = "p"
    runner.episode_id = "e"

    assert runner._load_planner_groups() is None


def test_load_planner_groups_returns_dict_when_groups_present(tmp_path, monkeypatch):
    """planner cp에 chain_bg_groups가 있으면 {groups, order} dict 반환."""
    import json
    from app.core.steps.background_chain_planning_step import BackgroundChainPlanningStep

    ckpt = tmp_path / "p" / "p" / "checkpoints" / "episodes" / "e" / "background_planner"
    ckpt.mkdir(parents=True)
    (ckpt / "manifest.json").write_text(json.dumps({
        "data": {
            "rationale_summary": "ok",
            "floor_plans": [{"id": "FP_L05", "building_group": "g",
                             "location_ids": ["L05"], "primary_location_id": "L05",
                             "rationale": "x", "shot_count": 5}],
            "floor_plan_order": ["FP_L05"],
            "chain_bg_groups": [
                {"id": "CB_L05_day", "floor_plan_id": "FP_L05",
                 "location_id": "L05", "scenes": [1], "shot_ids": ["S1_Shot1"],
                 "kind": "anchor_root", "parent_id": "", "time": "day",
                 "rationale": "ok"},
            ],
            "chain_bg_order": ["CB_L05_day"],
            "prev_shot_only": [],
        },
    }))

    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path / "p"))
    runner = BackgroundChainPlanningStep.__new__(BackgroundChainPlanningStep)
    runner.project_id = "p"
    runner.episode_id = "e"

    pg = runner._load_planner_groups()
    assert pg is not None
    assert "CB_L05_day" in pg["groups"]
    assert pg["order"] == ["CB_L05_day"]


def test_planner_unmatched_floor_plan_id_no_prepend(monkeypatch):
    """planner.group.location_id가 floor_plan_prompts에 없으면 prepend 없이 진행 (best-effort)."""
    captured_user_prompts = []

    def _fake_call_structured(**kwargs):
        captured_user_prompts.append(kwargs.get("user_prompt", ""))
        return _ok_plan("L99", ["S1_Shot1", "S1_Shot2", "S1_Shot3"])

    monkeypatch.setattr(
        "app.modules.pipeline.background_chain_planning.call_structured",
        _fake_call_structured,
    )

    planner_groups = {
        "groups": {
            "CB_unknown": {
                "id": "CB_unknown", "floor_plan_id": "FP_L99",
                "location_id": "L99", "scenes": [1],
                "shot_ids": ["S1_Shot1", "S1_Shot2", "S1_Shot3"],
                "kind": "anchor_root", "parent_id": "", "time": "day",
                "rationale": "no floor plan match",
            },
        },
        "order": ["CB_unknown"],
    }

    # floor_plan_prompts에 L01만 있고 L99는 없음
    floor_plan_prompts = {"L01": "different floor plan content"}

    shot_extract = {"scenes": [
        {"scene_index": 1, "shots": [
            {"shot_index": 1}, {"shot_index": 2}, {"shot_index": 3},
        ]},
    ]}
    entity_merge = {"locations": [
        {"short_id": "L99", "name": "unknown_loc", "description": "",
         "visual_traits": []},
    ]}

    result = run_background_chain_planning(
        shot_extract_data=shot_extract,
        shot_selection_data={"scenes": []},
        shot_staging_data={"shots": []},
        director_data={"scenes": []},
        entity_merge_data=entity_merge,
        floor_plan_prompts=floor_plan_prompts,
        planner_groups=planner_groups,
    )
    # L99 group prompt에는 [FLOOR PLAN] 블록이 없어야 (best-effort)
    assert len(captured_user_prompts) == 1
    assert "[FLOOR PLAN" not in captured_user_prompts[0]
    assert result["groups"]["CB_unknown"]["floor_plan_used"] is False
    assert result["groups"]["CB_unknown"]["status"] == "ok"
