"""background_planner pipeline 모듈 테스트.

TDD: build_planner_user_prompt + validate_planner_output + run_background_planner.
"""
from __future__ import annotations

from typing import Any, Dict, List
from unittest.mock import MagicMock, patch

import pytest


# ──────────────────────────────────────────────
# Step 2.1: build_planner_user_prompt
# ──────────────────────────────────────────────


def test_build_planner_user_prompt_includes_all_selected_shots():
    from app.modules.pipeline.background_planner import build_planner_user_prompt

    selected_shots_by_scene = {
        1: [{"shot_index": 1, "description": "S1 desc1"}],
        5: [
            {"shot_index": 1, "description": "S5 desc1"},
            {"shot_index": 2, "description": "S5 desc2"},
        ],
    }
    locations = [
        {"short_id": "L01", "name": "옥탑방", "kind": "indoor", "description": "..."},
        {"short_id": "L02", "name": "골목", "kind": "outdoor", "description": "..."},
    ]
    prompt = build_planner_user_prompt(
        selected_shots_by_scene=selected_shots_by_scene,
        location_lines=[f"{l['short_id']} ({l['kind']}): {l['name']}" for l in locations],
        visual_world_rules="rule1",
        scene_primary_locations={1: "L01", 5: "L02"},
    )
    assert "S1_Shot1" in prompt
    assert "S5_Shot1" in prompt and "S5_Shot2" in prompt
    assert "L01" in prompt and "L02" in prompt
    assert "rule1" in prompt


def test_build_planner_user_prompt_emits_loc_marker_per_shot():
    """각 shot 라인에 `(loc=Lxx)` 마커가 들어가야 한다 — system prompt가 권위로 사용."""
    from app.modules.pipeline.background_planner import build_planner_user_prompt

    prompt = build_planner_user_prompt(
        selected_shots_by_scene={
            1: [{"shot_index": 1, "description": "d1"}],
            5: [
                {"shot_index": 1, "description": "d2"},
                # shot 자체에 location_id가 있으면 그걸 우선 사용
                {"shot_index": 2, "description": "d3", "location_id": "L03"},
            ],
        },
        location_lines=["L01 (indoor): A", "L02 (outdoor): B", "L03 (indoor): C"],
        visual_world_rules="rule1",
        scene_primary_locations={1: "L01", 5: "L02"},
    )
    # scene primary fallback
    assert "S1_Shot1 (loc=L01):" in prompt
    assert "S5_Shot1 (loc=L02):" in prompt
    # shot-level override
    assert "S5_Shot2 (loc=L03):" in prompt


def test_build_planner_user_prompt_omits_marker_when_no_location_known():
    """primary도 없고 shot.location_id도 없으면 마커를 생략 (LLM이 scene 헤더 fallback)."""
    from app.modules.pipeline.background_planner import build_planner_user_prompt

    prompt = build_planner_user_prompt(
        selected_shots_by_scene={
            7: [{"shot_index": 1, "description": "no loc"}],
        },
        location_lines=["L01 (indoor): A"],
        visual_world_rules="",
        scene_primary_locations={},  # empty — no primary
    )
    # 마커가 들어가지 않은 라인
    assert "S7_Shot1: no loc" in prompt
    assert "S7_Shot1 (loc=" not in prompt


def test_build_planner_user_prompt_injects_full_scene_segments():
    """scene_segments 인자가 [SCENE TEXTS] 블록에 무절단으로 들어가야 한다 (CLAUDE.md 절대 규칙).

    LLM이 building_group 묶음(외부+내부+서브룸)을 추론하려면 원본 시나리오 본문이
    필수 — 길이 제한이나 요약 없이 전체 텍스트를 그대로 전달해야 한다.
    """
    from app.modules.pipeline.background_planner import build_planner_user_prompt

    # text 본문 — 200회 반복 (~3KB). strip()은 적용되지만 본문 내용은 보존되어야 한다.
    body = ("원본 시나리오 본문 줄 a." * 100) + "\n" + ("원본 시나리오 본문 줄 b." * 100)
    prompt = build_planner_user_prompt(
        selected_shots_by_scene={5: [{"shot_index": 1, "description": "d"}]},
        location_lines=["L05 (indoor): X"],
        visual_world_rules="",
        scene_primary_locations={5: "L05"},
        scene_segments=[
            {"scene_index": 5, "heading": "S#5 옥탑방 안", "text": body},
        ],
    )
    assert "[SCENE TEXTS]" in prompt
    assert "### Scene 5 — S#5 옥탑방 안" in prompt
    # 무절단 — 본문 길이 100배 반복분이 전부 prompt에 들어가야 한다.
    assert body in prompt
    # 길이 sanity (truncation 없음)
    assert len(prompt) >= len(body)


def test_build_planner_user_prompt_emits_none_when_no_segments():
    """scene_segments가 None이거나 빈 리스트일 때 [SCENE TEXTS] 블록은 (none)이어야 한다."""
    from app.modules.pipeline.background_planner import build_planner_user_prompt

    prompt_none = build_planner_user_prompt(
        selected_shots_by_scene={5: [{"shot_index": 1, "description": "d"}]},
        location_lines=["L05 (indoor): X"],
        visual_world_rules="",
        scene_primary_locations={5: "L05"},
        scene_segments=None,
    )
    prompt_empty = build_planner_user_prompt(
        selected_shots_by_scene={5: [{"shot_index": 1, "description": "d"}]},
        location_lines=["L05 (indoor): X"],
        visual_world_rules="",
        scene_primary_locations={5: "L05"},
        scene_segments=[],
    )
    # 둘 다 [SCENE TEXTS] 섹션은 있되 본문은 (none)
    for p in (prompt_none, prompt_empty):
        assert "[SCENE TEXTS]" in p
        assert "(none)" in p


def test_build_planner_user_prompt_orders_segments_by_scene_index():
    """[SCENE TEXTS] 블록 안에서 scene_index 입력 순서를 보존 (현재 구현은 입력 순서 그대로)."""
    from app.modules.pipeline.background_planner import build_planner_user_prompt

    prompt = build_planner_user_prompt(
        selected_shots_by_scene={1: [{"shot_index": 1, "description": "d"}]},
        location_lines=["L01 (indoor): X"],
        visual_world_rules="",
        scene_primary_locations={1: "L01"},
        scene_segments=[
            {"scene_index": 1, "heading": "S1 head", "text": "scene one body"},
            {"scene_index": 5, "heading": "S5 head", "text": "scene five body"},
            {"scene_index": 12, "heading": "S12 head", "text": "scene twelve body"},
        ],
    )
    # 모든 헤더 등장
    for marker in ("### Scene 1 — S1 head", "### Scene 5 — S5 head", "### Scene 12 — S12 head"):
        assert marker in prompt
    # 본문도 모두 무절단 inject
    for body in ("scene one body", "scene five body", "scene twelve body"):
        assert body in prompt
    # 순서: 입력 리스트 순서대로 등장해야 함
    assert prompt.find("Scene 1 ") < prompt.find("Scene 5 ") < prompt.find("Scene 12 ")


# ──────────────────────────────────────────────
# Step 2.5: validate_planner_output — frequency violation
# ──────────────────────────────────────────────


def _ok_plan_empty() -> Dict[str, Any]:
    """모든 invariant를 만족하는 빈 plan (floor_plans/chain_bg 둘 다 비어있음)."""
    return {
        "rationale_summary": "프리뷰 샷이 없어 모든 location은 prev_shot_only로 처리합니다.",
        "floor_plans": [],
        "floor_plan_order": [],
        "chain_bg_groups": [],
        "chain_bg_order": [],
        "prev_shot_only": [],
    }


def test_validate_planner_output_rejects_floor_plan_for_2_shot_location():
    from app.modules.pipeline.background_planner import validate_planner_output

    # frequency rule (invariant 6) 단독 위반을 검증하려면, 다른 invariant는 모두 만족시켜야
    # 한다 — 그래서 chain_bg_groups도 같이 채운다 (invariant 10 회피).
    plan = {
        "rationale_summary": "x",
        "floor_plans": [
            {
                "id": "FP_L02",
                "building_group": "g1",
                "location_ids": ["L02"],
                "primary_location_id": "L02",
                "rationale": "위반",
                "shot_count": 2,  # < 3 → invariant 6 위반
            }
        ],
        "floor_plan_order": ["FP_L02"],
        "chain_bg_groups": [
            {
                "id": "CB_L02_day",
                "floor_plan_id": "FP_L02",
                "location_id": "L02",
                "scenes": [1],
                "shot_ids": ["S1_Shot1", "S1_Shot2"],
                "kind": "anchor_root",
                "parent_id": "",
                "time": "day",
                "rationale": "x",
            }
        ],
        "chain_bg_order": ["CB_L02_day"],
        "prev_shot_only": [],
    }
    with pytest.raises(ValueError, match="2 shot"):
        validate_planner_output(
            plan,
            all_location_ids=["L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2"],
        )


# ──────────────────────────────────────────────
# Step 2.7: invariant별 fail/pass
# ──────────────────────────────────────────────


def _make_full_plan_3_shot_indoor() -> Dict[str, Any]:
    """3-shot indoor location 1개를 정상 처리하는 플랜 (모든 invariant 만족)."""
    return {
        "rationale_summary": "L01 옥탑방은 3샷 이상이라 도면+chain을 발급하고 나머지는 prev_shot_ref로 처리합니다.",
        "floor_plans": [
            {
                "id": "FP_L01",
                "building_group": "rooftop_unit",
                "location_ids": ["L01"],
                "primary_location_id": "L01",
                "rationale": "주 거주공간이라 도면 필요",
                "shot_count": 3,
            }
        ],
        "floor_plan_order": ["FP_L01"],
        "chain_bg_groups": [
            {
                "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": "낮 기본 상태",
            }
        ],
        "chain_bg_order": ["CB_L01_day"],
        "prev_shot_only": [
            {
                "location_id": "L02",
                "shot_count": 1,
                "kind": "single_shot",
                "rationale": "1샷이라 prev_shot_ref",
            }
        ],
    }


def test_validate_planner_output_passes_for_valid_plan():
    """invariant 모두 충족하는 plan은 통과 (no exception)."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    # 예외 없으면 OK
    validate_planner_output(
        plan,
        all_location_ids=["L01", "L02"],
        all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3", "S2_Shot1"],
    )


def test_validate_planner_output_passes_for_empty_plan():
    """floor_plans=[] + chain_bg_groups=[] + prev_shot_only=[] 빈 plan은 통과 (invariant 10)."""
    from app.modules.pipeline.background_planner import validate_planner_output

    validate_planner_output(
        _ok_plan_empty(),
        all_location_ids=["L01"],
        all_shot_ids=["S1_Shot1"],
    )


def test_validate_planner_output_invariant_1_unknown_location():
    """invariant 1: floor_plans[].location_ids 에 알 수 없는 location → reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["floor_plans"][0]["location_ids"] = ["L99"]  # 존재 안 함
    plan["floor_plans"][0]["primary_location_id"] = "L99"
    with pytest.raises(ValueError, match="invariant 1"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_2_unknown_shot():
    """invariant 2: chain_bg_groups[].shot_ids 에 알 수 없는 shot → reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["chain_bg_groups"][0]["shot_ids"] = ["S1_Shot1", "S99_Shot99"]
    with pytest.raises(ValueError, match="invariant 2"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_3_floor_plan_order_mismatch():
    """invariant 3: floor_plan_order ≠ floor_plans IDs → reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["floor_plan_order"] = ["FP_GHOST"]  # 존재하지 않는 ID
    with pytest.raises(ValueError, match="invariant 3"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_4_chain_bg_order_mismatch():
    """invariant 4: chain_bg_order ≠ chain_bg_groups IDs → reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["chain_bg_order"] = []  # 노드는 1개인데 order가 비어있음
    with pytest.raises(ValueError, match="invariant 4"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_5_parent_after_child():
    """invariant 5: parent_id가 chain_bg_order에서 자식보다 뒤에 있으면 reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["chain_bg_groups"].append(
        {
            "id": "CB_L01_night",
            "floor_plan_id": "FP_L01",
            "location_id": "L01",
            "scenes": [2],
            "shot_ids": ["S1_Shot2"],
            "kind": "anchor_state",
            "parent_id": "CB_L01_day",
            "time": "night",
            "rationale": "밤 상태",
        }
    )
    # 자식이 부모보다 먼저 — 위반
    plan["chain_bg_order"] = ["CB_L01_night", "CB_L01_day"]
    with pytest.raises(ValueError, match="invariant 5"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_6_low_shot_count():
    """invariant 6: floor_plans[].shot_count < 3 → reject (이미 Step 2.5에서 다룸 — 1-shot 추가 케이스)."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["floor_plans"][0]["shot_count"] = 1
    with pytest.raises(ValueError, match="invariant 6"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_7_building_group_non_adjacent():
    """invariant 7: 같은 building_group floor_plans는 floor_plan_order에서 인접 필수."""
    from app.modules.pipeline.background_planner import validate_planner_output

    # 같은 building_group을 가진 FP 2개 + 다른 FP 1개를 사이에 끼움
    plan = {
        "rationale_summary": "x",
        "floor_plans": [
            {
                "id": "FP_A",
                "building_group": "shared",
                "location_ids": ["L01"],
                "primary_location_id": "L01",
                "rationale": "x",
                "shot_count": 3,
            },
            {
                "id": "FP_B",
                "building_group": "other",
                "location_ids": ["L02"],
                "primary_location_id": "L02",
                "rationale": "x",
                "shot_count": 3,
            },
            {
                "id": "FP_C",
                "building_group": "shared",  # FP_A와 같은 그룹
                "location_ids": ["L03"],
                "primary_location_id": "L03",
                "rationale": "x",
                "shot_count": 3,
            },
        ],
        "floor_plan_order": ["FP_A", "FP_B", "FP_C"],  # FP_A↔FP_C 비인접
        "chain_bg_groups": [
            {
                "id": "CB_A",
                "floor_plan_id": "FP_A",
                "location_id": "L01",
                "scenes": [1],
                "shot_ids": ["S1_Shot1"],
                "kind": "anchor_root",
                "parent_id": "",
                "time": "day",
                "rationale": "x",
            },
            {
                "id": "CB_B",
                "floor_plan_id": "FP_B",
                "location_id": "L02",
                "scenes": [1],
                "shot_ids": ["S2_Shot1"],
                "kind": "anchor_root",
                "parent_id": "",
                "time": "day",
                "rationale": "x",
            },
            {
                "id": "CB_C",
                "floor_plan_id": "FP_C",
                "location_id": "L03",
                "scenes": [1],
                "shot_ids": ["S3_Shot1"],
                "kind": "anchor_root",
                "parent_id": "",
                "time": "day",
                "rationale": "x",
            },
        ],
        "chain_bg_order": ["CB_A", "CB_B", "CB_C"],
        "prev_shot_only": [],
    }
    with pytest.raises(ValueError, match="invariant 7"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02", "L03"],
            all_shot_ids=["S1_Shot1", "S2_Shot1", "S3_Shot1"],
        )


def test_validate_planner_output_invariant_8_korean_in_id():
    """invariant 8: ID/snake_case 필드에 한글 → reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["floor_plans"][0]["building_group"] = "옥탑방_unit"  # 한글
    with pytest.raises(ValueError, match="invariant 8"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_9_cycle():
    """invariant 9: chain_bg parent chain에 cycle → reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    # CB_L01_day의 parent를 자기 자신으로 설정 (self-cycle)
    plan["chain_bg_groups"][0]["parent_id"] = "CB_L01_day"
    plan["chain_bg_groups"][0]["kind"] = "anchor_state"
    # 자식이 자기보다 먼저 위치할 수 없도록... 단일 노드 self-cycle은 invariant 5도 잡지만,
    # 두 노드 cycle을 더 명확히 검사하기 위해 별도 케이스
    plan["chain_bg_groups"].append(
        {
            "id": "CB_L01_night",
            "floor_plan_id": "FP_L01",
            "location_id": "L01",
            "scenes": [2],
            "shot_ids": ["S1_Shot2"],
            "kind": "anchor_state",
            "parent_id": "CB_L01_day",
            "time": "night",
            "rationale": "x",
        }
    )
    plan["chain_bg_order"] = ["CB_L01_day", "CB_L01_night"]
    # 이 케이스는 invariant 5 (self before parent) 또는 9 (cycle) 둘 중 먼저 잡는 쪽이 raise
    with pytest.raises(ValueError, match=r"invariant (5|9)"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_9_cycle_two_node():
    """invariant 9: 2-노드 cycle (A→B, B→A)을 명시적으로 잡는다."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = {
        "rationale_summary": "x",
        "floor_plans": [
            {
                "id": "FP_L01",
                "building_group": "g",
                "location_ids": ["L01"],
                "primary_location_id": "L01",
                "rationale": "x",
                "shot_count": 3,
            }
        ],
        "floor_plan_order": ["FP_L01"],
        "chain_bg_groups": [
            {
                "id": "CB_A",
                "floor_plan_id": "FP_L01",
                "location_id": "L01",
                "scenes": [1],
                "shot_ids": ["S1_Shot1"],
                "kind": "anchor_state",
                "parent_id": "CB_B",  # B
                "time": "day",
                "rationale": "x",
            },
            {
                "id": "CB_B",
                "floor_plan_id": "FP_L01",
                "location_id": "L01",
                "scenes": [1],
                "shot_ids": ["S1_Shot2"],
                "kind": "anchor_state",
                "parent_id": "CB_A",  # A — cycle!
                "time": "day",
                "rationale": "x",
            },
        ],
        # invariant 5는 chain_bg_order로 잡지만, parent before child를 만족하도록 위치시키면
        # cycle만 invariant 9로 잡힌다. 그러나 cycle이면 어떤 순서를 줘도 invariant 5 위반은 일어나므로
        # 둘 다 OK — 정규식 OR.
        "chain_bg_order": ["CB_A", "CB_B"],
        "prev_shot_only": [],
    }
    with pytest.raises(ValueError, match=r"invariant (5|9)"):
        validate_planner_output(
            plan,
            all_location_ids=["L01"],
            all_shot_ids=["S1_Shot1", "S1_Shot2"],
        )


def test_validate_planner_output_invariant_10_floor_plans_without_chain_bg():
    """invariant 10: floor_plans 비었지만 chain_bg_groups 비지 않으면 reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["floor_plans"] = []
    plan["floor_plan_order"] = []
    # chain_bg_groups는 그대로 남김
    with pytest.raises(ValueError, match="invariant 10"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


def test_validate_planner_output_invariant_10_chain_bg_without_floor_plans():
    """invariant 10: chain_bg_groups 비어있는데 floor_plans는 있으면 reject."""
    from app.modules.pipeline.background_planner import validate_planner_output

    plan = _make_full_plan_3_shot_indoor()
    plan["chain_bg_groups"] = []
    plan["chain_bg_order"] = []
    with pytest.raises(ValueError, match="invariant 10"):
        validate_planner_output(
            plan,
            all_location_ids=["L01", "L02"],
            all_shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        )


# ──────────────────────────────────────────────
# Step 2.9: run_background_planner — retry behavior
# ──────────────────────────────────────────────


def test_inject_runtime_enums_adds_location_and_shot_enums():
    """schema deepcopy + enum 주입 확인 — 원본 schema는 mutate 안 됨."""
    from app.modules.pipeline.background_planner import _inject_runtime_enums

    src_schema = {
        "type": "object",
        "properties": {
            "floor_plans": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "location_ids": {"type": "array", "items": {"type": "string"}},
                        "primary_location_id": {"type": "string"},
                    },
                },
            },
            "chain_bg_groups": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "location_id": {"type": "string"},
                        "shot_ids": {"type": "array", "items": {"type": "string"}},
                    },
                },
            },
            "prev_shot_only": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {"location_id": {"type": "string"}},
                },
            },
        },
    }
    out = _inject_runtime_enums(src_schema, ["L01", "L02"], ["S1_Shot1", "S5_Shot2"])

    fp_props = out["properties"]["floor_plans"]["items"]["properties"]
    assert fp_props["location_ids"]["items"]["enum"] == ["L01", "L02"]
    assert fp_props["primary_location_id"]["enum"] == ["L01", "L02"]
    cb_props = out["properties"]["chain_bg_groups"]["items"]["properties"]
    assert cb_props["location_id"]["enum"] == ["L01", "L02"]
    assert cb_props["shot_ids"]["items"]["enum"] == ["S1_Shot1", "S5_Shot2"]
    ps_props = out["properties"]["prev_shot_only"]["items"]["properties"]
    assert ps_props["location_id"]["enum"] == ["L01", "L02"]
    # 원본은 mutate 안 됨
    assert "enum" not in src_schema["properties"]["floor_plans"]["items"]["properties"]["location_ids"].get("items", {})


def test_run_background_planner_succeeds_first_attempt():
    """1회 호출에 성공하는 plan은 그대로 반환."""
    from app.modules.pipeline.background_planner import run_background_planner

    valid_plan = _make_full_plan_3_shot_indoor()
    mock_call = MagicMock(return_value=valid_plan)

    result = run_background_planner(
        project_config={},
        user_prompt="user prompt",
        location_short_ids=["L01", "L02"],
        shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        opik_metadata={},
        call_structured_fn=mock_call,
        sleep_fn=lambda s: None,
    )
    assert result == valid_plan
    assert mock_call.call_count == 1
    # call kwargs sanity
    kwargs = mock_call.call_args.kwargs
    assert kwargs["step"] == "background_planner"
    assert kwargs["schema_name"] == "background_planner"
    assert kwargs["user_prompt"] == "user prompt"
    # schema에 enum이 주입됐는지 확인
    schema = kwargs["response_schema"]
    fp_loc_items = schema["properties"]["floor_plans"]["items"]["properties"]["location_ids"]["items"]
    assert fp_loc_items.get("enum") == ["L01", "L02"]


def test_run_background_planner_retries_then_succeeds():
    """1회 invariant fail → 2회 success."""
    from app.modules.pipeline.background_planner import run_background_planner

    bad_plan = _make_full_plan_3_shot_indoor()
    bad_plan["floor_plans"][0]["location_ids"] = ["L99"]  # invariant 1 위반
    bad_plan["floor_plans"][0]["primary_location_id"] = "L99"
    good_plan = _make_full_plan_3_shot_indoor()

    mock_call = MagicMock(side_effect=[bad_plan, good_plan])
    sleep_calls: List[float] = []

    result = run_background_planner(
        project_config={},
        user_prompt="up",
        location_short_ids=["L01", "L02"],
        shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        opik_metadata={},
        call_structured_fn=mock_call,
        sleep_fn=lambda s: sleep_calls.append(s),
    )
    assert result == good_plan
    assert mock_call.call_count == 2
    # 1회 fail 후 1회 sleep
    assert len(sleep_calls) == 1


def test_run_background_planner_raises_after_max_retries():
    """3회 모두 실패 → PlannerError."""
    from app.modules.pipeline.background_planner import (
        PlannerError,
        run_background_planner,
    )

    bad_plan = _make_full_plan_3_shot_indoor()
    bad_plan["floor_plans"][0]["location_ids"] = ["L99"]
    bad_plan["floor_plans"][0]["primary_location_id"] = "L99"

    mock_call = MagicMock(return_value=bad_plan)
    sleep_calls: List[float] = []

    with pytest.raises(PlannerError, match="failed after 3 retries"):
        run_background_planner(
            project_config={},
            user_prompt="up",
            location_short_ids=["L01", "L02"],
            shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
            opik_metadata={},
            call_structured_fn=mock_call,
            sleep_fn=lambda s: sleep_calls.append(s),
        )
    assert mock_call.call_count == 3
    # 마지막 시도 후에는 sleep 없어야 — 첫/두번째 시도 후 2번
    assert len(sleep_calls) == 2


def test_run_background_planner_recovers_from_llm_runtime_error():
    """call_structured_fn이 RuntimeError 발생 → retry → 성공."""
    from app.modules.pipeline.background_planner import run_background_planner

    good_plan = _make_full_plan_3_shot_indoor()
    mock_call = MagicMock(side_effect=[RuntimeError("transient LLM failure"), good_plan])

    result = run_background_planner(
        project_config={},
        user_prompt="up",
        location_short_ids=["L01", "L02"],
        shot_ids=["S1_Shot1", "S1_Shot2", "S1_Shot3"],
        opik_metadata={},
        call_structured_fn=mock_call,
        sleep_fn=lambda s: None,
    )
    assert result == good_plan
    assert mock_call.call_count == 2
