"""BackgroundPlannerStep 단위 테스트 (Phase 5 / Task 3).

8 케이스:
  1. mode=off → applicable_count=0
  2. mode=chain_only → applicable_count=0
  3. mode=floor_plan_anchored + 의존 모두 OK → completed_count=1
  4. shot_selection 없음(빈 selected) → 빈 plan 반환
  5. 6 location 중 3+ shot indoor 1개 → floor_plans 1개
  6. 모두 outdoor → floor_plans 0개 (LLM이 결정 — 그래도 step은 완료)
  7. LLM retry 모두 실패 → failed_count=1, raise X (graceful)
  8. validate 위반 → retry 후 raise (PlannerError → failed_count=1)

추가:
  - _extract_selected_shots / _build_location_lines 단위 검증
"""
from __future__ import annotations

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

import pytest


# ──────────────────────────────────────────────
# 공통 fixture
# ──────────────────────────────────────────────


def _setup_checkpoints(tmp_path: Path, *, with_selection: bool = True) -> Path:
    """tmp_path 안에 의존 6개 체크포인트를 만든다."""
    ckpt_root = tmp_path / "checkpoints" / "episodes" / "eid"
    for d in (
        "shot_validator", "shot_selection", "scene_director",
        "entity_merge", "entity_detail", "visual_world_rules",
    ):
        (ckpt_root / d).mkdir(parents=True)

    (ckpt_root / "shot_validator" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 5, "shots": [
                {"shot_index": 1, "description": "민숙이 sofa에 앉음"},
                {"shot_index": 2, "description": "수리영이 등장"},
                {"shot_index": 3, "description": "TV 화면 클로즈업"},
            ]},
            {"scene_index": 7, "shots": [
                {"shot_index": 1, "description": "마트 외부"},
            ]},
        ]},
    }))

    sel_data: Dict[str, Any]
    if with_selection:
        sel_data = {"data": {"scenes": [
            {"scene_index": 5, "selected_shot_indices": [1, 2, 3]},
            {"scene_index": 7, "selected_shot_indices": [1]},
        ]}}
    else:
        sel_data = {"data": {"scenes": []}}
    (ckpt_root / "shot_selection" / "manifest.json").write_text(json.dumps(sel_data))

    (ckpt_root / "scene_director" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 5, "primary_location": "L01"},
            {"scene_index": 7, "primary_location": "L02"},
        ]},
    }))

    (ckpt_root / "entity_merge" / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"short_id": "L01", "name": "옥탑방"},
            {"short_id": "L02", "name": "마트 앞"},
        ]},
    }))

    (ckpt_root / "entity_detail" / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"short_id": "L01", "kind": "indoor", "description": "옥탑 거실+안방"},
            {"short_id": "L02", "kind": "outdoor", "description": "골목 마트 입구"},
        ]},
    }))

    (ckpt_root / "visual_world_rules" / "manifest.json").write_text(json.dumps({
        "data": {
            "rules": [
                {"rule_type": "interior", "description": "옥탑방", "visual_guideline": "tight"},
            ],
            "era": "현대",
            "region": "서울",
        },
    }))
    return tmp_path


def _make_runner(tmp_path: Path):
    """BackgroundPlannerStep 인스턴스 (StepRunner.__init__ 우회)."""
    from app.core.steps.background_planner_step import BackgroundPlannerStep
    runner = BackgroundPlannerStep.__new__(BackgroundPlannerStep)
    runner.project_id = ""
    runner.episode_id = "eid"
    runner.db = MagicMock()
    runner.project_config = {}
    runner.build_opik_metadata = MagicMock(return_value={})
    runner.step_id = "background_planner"
    runner.run_id = "test-run"
    runner.opik_context = {}
    runner.manifest = {"label": "test", "default_model": "gpt"}
    return runner


def _ok_plan_payload(shot_ids: List[str]) -> Dict[str, Any]:
    """invariant 만족 LLM 결과 mock (L01=indoor=3 shot, L02=outdoor=1)."""
    return {
        "rationale_summary": "ok plan",
        "floor_plans": [
            {
                "id": "FP_L01",
                "building_group": "g1",
                "location_ids": ["L01"],
                "primary_location_id": "L01",
                "rationale": "옥탑방 indoor — 3 shot",
                "shot_count": 3,
            }
        ],
        "floor_plan_order": ["FP_L01"],
        "chain_bg_groups": [
            {
                "id": "CB_L01_DAY",
                "floor_plan_id": "FP_L01",
                "location_id": "L01",
                "kind": "interior",
                "parent_id": "",
                "time": "day",
                "shot_ids": [sid for sid in shot_ids if "S5_" in sid],
                "rationale": "일관 lighting",
            }
        ],
        "chain_bg_order": ["CB_L01_DAY"],
        "prev_shot_only": [
            {"location_id": "L02", "kind": "outdoor", "rationale": "1 shot only"},
        ],
    }


# ──────────────────────────────────────────────
# Step 3.1 / 3.6 #1, #2: applicability (mode 가드)
# ──────────────────────────────────────────────


def test_step_skipped_when_mode_off(tmp_path, monkeypatch):
    _setup_checkpoints(tmp_path)
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.background_mode", "off")
    runner = _make_runner(tmp_path)
    result = runner._execute(mode="resume")
    assert result["applicable_count"] == 0
    assert result["completed_count"] == 0
    assert result["failed_count"] == 0
    assert result["data"] == {}


def test_step_skipped_when_mode_chain_only(tmp_path, monkeypatch):
    _setup_checkpoints(tmp_path)
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.background_mode", "chain_only")
    runner = _make_runner(tmp_path)
    result = runner._execute(mode="resume")
    assert result["applicable_count"] == 0
    assert result["completed_count"] == 0


# ──────────────────────────────────────────────
# Step 3.6 #3: 의존 모두 OK + LLM 성공
# ──────────────────────────────────────────────


def test_step_runs_when_floor_plan_anchored(tmp_path, monkeypatch):
    _setup_checkpoints(tmp_path)
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.background_mode", "floor_plan_anchored")
    runner = _make_runner(tmp_path)

    # call_structured를 monkeypatch — runtime injection 후의 schema가 들어오므로,
    # step이 사용하는 import 경로(pipeline.background_planner.run_background_planner)에서
    # call_structured_fn을 받아 호출. 따라서 llm_client.call_structured를 mock.
    plan = _ok_plan_payload(["S5_Shot1", "S5_Shot2", "S5_Shot3", "S7_Shot1"])
    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured",
        lambda **kwargs: plan,
    )

    result = runner._execute(mode="resume")
    assert result["applicable_count"] == 1
    assert result["completed_count"] == 1
    assert result["failed_count"] == 0
    assert result["data"]["rationale_summary"] == "ok plan"
    assert len(result["data"]["floor_plans"]) == 1
    assert result["data"]["floor_plans"][0]["id"] == "FP_L01"


# ──────────────────────────────────────────────
# Step 3.6 #4: shot_selection 빈 → LLM 호출 생략 + 빈 plan
# ──────────────────────────────────────────────


def test_step_returns_empty_plan_when_no_selected_shots(tmp_path, monkeypatch):
    _setup_checkpoints(tmp_path, with_selection=False)
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.background_mode", "floor_plan_anchored")
    runner = _make_runner(tmp_path)

    # LLM이 호출되면 안 됨 — 호출 시 raise해서 검증.
    def _no_call(**kwargs):
        raise AssertionError("LLM should not be called when no selected shots")
    monkeypatch.setattr("app.modules.llm.llm_client.call_structured", _no_call)

    result = runner._execute(mode="resume")
    assert result["applicable_count"] == 1
    assert result["completed_count"] == 1
    assert result["failed_count"] == 0
    assert result["data"]["floor_plans"] == []
    assert result["data"]["chain_bg_groups"] == []
    assert "rationale_summary" in result["data"]


# ──────────────────────────────────────────────
# Step 3.6 #5: 6 location 중 1개만 indoor 3+ shot — LLM이 1 floor_plan 출력
# ──────────────────────────────────────────────


def test_step_passes_full_location_set_to_planner(tmp_path, monkeypatch):
    """6 location 중 frequency 충족(L01)은 1개. planner가 1 floor_plan 결정."""
    ckpt_root = tmp_path / "checkpoints" / "episodes" / "eid"
    for d in (
        "shot_validator", "shot_selection", "scene_director",
        "entity_merge", "entity_detail", "visual_world_rules",
    ):
        (ckpt_root / d).mkdir(parents=True)

    # 6 location 중 L01에 3 shot, 나머지에는 0~1 shot
    (ckpt_root / "shot_validator" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 5, "shots": [
                {"shot_index": 1, "description": "L01 shot1"},
                {"shot_index": 2, "description": "L01 shot2"},
                {"shot_index": 3, "description": "L01 shot3"},
            ]},
            {"scene_index": 7, "shots": [
                {"shot_index": 1, "description": "L02 outdoor"},
            ]},
        ]},
    }))
    (ckpt_root / "shot_selection" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 5, "selected_shot_indices": [1, 2, 3]},
            {"scene_index": 7, "selected_shot_indices": [1]},
        ]},
    }))
    (ckpt_root / "scene_director" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 5, "primary_location": "L01"},
            {"scene_index": 7, "primary_location": "L02"},
        ]},
    }))
    (ckpt_root / "entity_merge" / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"short_id": "L01", "name": "옥탑방"},
            {"short_id": "L02", "name": "마트"},
            {"short_id": "L03", "name": "거리"},
            {"short_id": "L04", "name": "회사"},
            {"short_id": "L05", "name": "공원"},
            {"short_id": "L06", "name": "지하철"},
        ]},
    }))
    (ckpt_root / "entity_detail" / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"short_id": "L01", "kind": "indoor"},
            {"short_id": "L02", "kind": "outdoor"},
            {"short_id": "L03", "kind": "outdoor"},
            {"short_id": "L04", "kind": "indoor"},
            {"short_id": "L05", "kind": "outdoor"},
            {"short_id": "L06", "kind": "indoor"},
        ]},
    }))
    (ckpt_root / "visual_world_rules" / "manifest.json").write_text(json.dumps({"data": {"rules": []}}))

    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.background_mode", "floor_plan_anchored")
    runner = _make_runner(tmp_path)

    captured: Dict[str, Any] = {}
    def capture_call(**kwargs):
        captured["user_prompt"] = kwargs.get("user_prompt", "")
        captured["schema"] = kwargs.get("response_schema", {})
        return _ok_plan_payload(["S5_Shot1", "S5_Shot2", "S5_Shot3", "S7_Shot1"])
    monkeypatch.setattr("app.modules.llm.llm_client.call_structured", capture_call)

    result = runner._execute(mode="resume")
    assert result["completed_count"] == 1
    # 6개 location 모두 prompt에 — frequency 결정은 LLM이 한다 (코드 frequency filter 금지).
    for sid in ("L01", "L02", "L03", "L04", "L05", "L06"):
        assert sid in captured["user_prompt"]


# ──────────────────────────────────────────────
# Step 3.6 #6: 모두 outdoor — LLM이 floor_plans=[] 반환 시 step은 완료
# ──────────────────────────────────────────────


def test_step_handles_all_outdoor_plan(tmp_path, monkeypatch):
    _setup_checkpoints(tmp_path)
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.background_mode", "floor_plan_anchored")
    runner = _make_runner(tmp_path)

    empty_plan = {
        "rationale_summary": "all outdoor — no floor plans",
        "floor_plans": [],
        "floor_plan_order": [],
        "chain_bg_groups": [],
        "chain_bg_order": [],
        "prev_shot_only": [
            {"location_id": "L01", "kind": "outdoor", "rationale": "outdoor"},
            {"location_id": "L02", "kind": "outdoor", "rationale": "outdoor"},
        ],
    }
    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured",
        lambda **kwargs: empty_plan,
    )

    result = runner._execute(mode="resume")
    assert result["completed_count"] == 1
    assert result["failed_count"] == 0
    assert result["data"]["floor_plans"] == []
    assert len(result["data"]["prev_shot_only"]) == 2


# ──────────────────────────────────────────────
# Step 3.6 #7: LLM retry 모두 실패 → failed_count=1, raise X
# ──────────────────────────────────────────────


def test_step_graceful_when_llm_exhausts_retries(tmp_path, monkeypatch):
    _setup_checkpoints(tmp_path)
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.background_mode", "floor_plan_anchored")
    runner = _make_runner(tmp_path)

    # call_structured가 항상 RuntimeError → run_background_planner가 PlannerError raise.
    def always_fail(**kwargs):
        raise RuntimeError("LLM down")
    monkeypatch.setattr("app.modules.llm.llm_client.call_structured", always_fail)
    # backoff sleep skip
    monkeypatch.setattr("app.modules.pipeline.background_planner.time.sleep", lambda s: None)

    result = runner._execute(mode="resume")
    assert result["applicable_count"] == 1
    assert result["completed_count"] == 0
    assert result["failed_count"] == 1
    assert "error" in result["data"]


# ──────────────────────────────────────────────
# Step 3.6 #8: validate 위반 — retry 모두 같은 위반 결과 → failed_count=1
# ──────────────────────────────────────────────


def test_step_graceful_on_validation_violation(tmp_path, monkeypatch):
    _setup_checkpoints(tmp_path)
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.background_mode", "floor_plan_anchored")
    runner = _make_runner(tmp_path)

    # invariant 6 위반: shot_count<3
    bad_plan = {
        "rationale_summary": "x",
        "floor_plans": [
            {
                "id": "FP_L01", "building_group": "g1",
                "location_ids": ["L01"], "primary_location_id": "L01",
                "rationale": "위반", "shot_count": 1,
            }
        ],
        "floor_plan_order": ["FP_L01"],
        "chain_bg_groups": [
            {
                "id": "CB_L01", "floor_plan_id": "FP_L01", "location_id": "L01",
                "kind": "interior", "parent_id": "", "time": "day",
                "shot_ids": ["S5_Shot1"], "rationale": "x",
            }
        ],
        "chain_bg_order": ["CB_L01"],
        "prev_shot_only": [],
    }
    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured",
        lambda **kwargs: bad_plan,
    )
    monkeypatch.setattr("app.modules.pipeline.background_planner.time.sleep", lambda s: None)

    result = runner._execute(mode="resume")
    assert result["completed_count"] == 0
    assert result["failed_count"] == 1
    assert "error" in result["data"]


# ──────────────────────────────────────────────
# 추가: helper 함수 단위 (location lines + selected shots)
# ──────────────────────────────────────────────


def test_extract_selected_shots_filters_by_selection_indices():
    from app.core.steps.background_planner_step import _extract_selected_shots
    sv = {"data": {"scenes": [
        {"scene_index": 5, "shots": [
            {"shot_index": 1, "description": "a"},
            {"shot_index": 2, "description": "b"},
            {"shot_index": 3, "description": "c"},
        ]}
    ]}}
    sel = {"data": {"scenes": [
        {"scene_index": 5, "selected_shot_indices": [1, 3]},
    ]}}
    out = _extract_selected_shots(sv, sel)
    assert 5 in out
    assert [s["shot_index"] for s in out[5]] == [1, 3]
    assert out[5][0]["description"] == "a"


def test_extract_selected_shots_handles_missing_checkpoints():
    from app.core.steps.background_planner_step import _extract_selected_shots
    assert _extract_selected_shots(None, None) == {}
    assert _extract_selected_shots({"data": {"scenes": []}}, None) == {}


def test_build_location_lines_uses_entity_detail_kind():
    from app.core.steps.background_planner_step import _build_location_lines
    merge_cp = {"data": {"locations": [
        {"short_id": "L02", "name": "마트"},
        {"short_id": "L01", "name": "옥탑"},
    ]}}
    detail_cp = {"data": {"locations": [
        {"short_id": "L01", "kind": "indoor"},
        {"short_id": "L02", "kind": "outdoor"},
    ]}}
    lines, ids = _build_location_lines(merge_cp, detail_cp)
    # 알파벳순 정렬
    assert ids == ["L01", "L02"]
    assert lines[0] == "L01 (indoor): 옥탑"
    assert lines[1] == "L02 (outdoor): 마트"


def test_build_location_lines_falls_back_to_unknown_when_no_detail():
    from app.core.steps.background_planner_step import _build_location_lines
    merge_cp = {"data": {"locations": [
        {"short_id": "L01", "name": "x"},
    ]}}
    lines, ids = _build_location_lines(merge_cp, None)
    assert ids == ["L01"]
    assert "(unknown)" in lines[0]


# ──────────────────────────────────────────────
# Step 3.8: manifest 등록 검증
# ──────────────────────────────────────────────


@pytest.mark.skip(reason="Phase 7 deprecation (T16): applicability='disabled', see test_phase5_deprecation.py")
def test_manifest_entry_present_with_correct_order():
    from app.core.step_manifest import STEP_MANIFEST
    assert "background_planner" in STEP_MANIFEST
    entry = STEP_MANIFEST["background_planner"]
    assert entry["category"] == "analysis"
    assert entry["order"] == 19.55
    assert entry["applicability"] == "if_floor_plan_mode"
    assert "shot_validator" in entry["depends_on"]
    assert "scene_director" in entry["depends_on"]
    assert "entity_merge" in entry["depends_on"]
    assert "visual_world_rules" in entry["depends_on"]


def test_step_class_registered():
    from app.core.steps import STEP_CLASSES
    from app.core.steps.background_planner_step import BackgroundPlannerStep
    assert STEP_CLASSES.get("background_planner") is BackgroundPlannerStep


def test_pipeline_steps_label_registered():
    from app.modules.llm.llm_client import PIPELINE_STEPS
    assert "background_planner" in PIPELINE_STEPS
    assert PIPELINE_STEPS["background_planner"]["category"] == "analysis"
