"""Phase 4 — chain_bg + floor plan integration 단위 테스트."""
from __future__ import annotations

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

import pytest


def test_load_floor_plan_paths_when_off_returns_empty(tmp_path, monkeypatch):
    """background_mode='off' (location_floor_plan checkpoint 없음) → 빈 dict."""
    from app.core.steps.background_chain_render_step import BackgroundChainRenderStep

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

    paths = runner._load_floor_plan_paths()
    assert paths == {}


def test_load_floor_plan_paths_skips_failed_status(tmp_path, monkeypatch):
    """status='failed' location은 path 매핑에 미포함."""
    from app.core.steps.background_chain_render_step import BackgroundChainRenderStep

    # projects_dir = tmp_path/p 기준: _load_prev_checkpoint은 projects_dir/project_id 하위에서 찾고,
    # image_path는 projects_dir.parent (=tmp_path) 기준으로 resolve.
    ckpt = tmp_path / "p" / "p" / "checkpoints" / "episodes" / "e" / "location_floor_plan"
    ckpt.mkdir(parents=True)
    (ckpt / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"id": "L05", "image_path": "p/images/e/floor_plan/L05.png", "status": "ok"},
            {"id": "L02", "image_path": "p/images/e/floor_plan/L02.png", "status": "failed"},
        ]},
    }))
    # L05 PNG 파일 만들기 (exists check 통과) — projects_dir.parent=tmp_path 기준
    png_dir = tmp_path / "p" / "images" / "e" / "floor_plan"
    png_dir.mkdir(parents=True)
    (png_dir / "L05.png").write_bytes(b"x" * 2048)

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

    paths = runner._load_floor_plan_paths()
    assert "L05" in paths
    assert "L02" not in paths


def test_load_floor_plan_paths_skips_missing_file(tmp_path, monkeypatch):
    """체크포인트에 status=ok지만 PNG 파일 없으면 제외."""
    from app.core.steps.background_chain_render_step import BackgroundChainRenderStep

    ckpt = tmp_path / "p" / "p" / "checkpoints" / "episodes" / "e" / "location_floor_plan"
    ckpt.mkdir(parents=True)
    (ckpt / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"id": "L05", "image_path": "p/images/e/floor_plan/missing.png", "status": "ok"},
        ]},
    }))

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

    paths = runner._load_floor_plan_paths()
    assert paths == {}


def test_load_floor_plan_prompts_when_off_returns_empty(tmp_path, monkeypatch):
    """background_mode='off' (체크포인트 없음) → 빈 dict."""
    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"

    prompts = runner._load_floor_plan_prompts()
    assert prompts == {}


def test_load_floor_plan_prompts_extracts_ok_only(tmp_path, monkeypatch):
    """status='ok' location의 prompt_text만 추출."""
    from app.core.steps.background_chain_planning_step import BackgroundChainPlanningStep

    ckpt = tmp_path / "p" / "p" / "checkpoints" / "episodes" / "e" / "location_floor_plan"
    ckpt.mkdir(parents=True)
    (ckpt / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"id": "L05", "prompt_text": "Top-down floor plan of rooftop", "status": "ok"},
            {"id": "L02", "prompt_text": "ignored", "status": "failed"},
        ]},
    }))

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

    prompts = runner._load_floor_plan_prompts()
    assert "L05" in prompts
    assert "L02" not in prompts
    assert "rooftop" in prompts["L05"]


def test_load_floor_plan_prompts_skips_empty_text(tmp_path, monkeypatch):
    """prompt_text가 빈 문자열인 location은 제외."""
    from app.core.steps.background_chain_planning_step import BackgroundChainPlanningStep

    ckpt = tmp_path / "p" / "p" / "checkpoints" / "episodes" / "e" / "location_floor_plan"
    ckpt.mkdir(parents=True)
    (ckpt / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"id": "L05", "prompt_text": "", "status": "ok"},
        ]},
    }))

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

    prompts = runner._load_floor_plan_prompts()
    assert prompts == {}


def test_render_node_image_multi_image_edit(tmp_path, monkeypatch):
    """ref_paths 2개 → multi-image edit API 호출."""
    from app.modules.pipeline.background_chain_render import render_node_image

    ref1 = tmp_path / "floor_plan.png"
    ref2 = tmp_path / "parent.png"
    ref1.write_bytes(b"x" * 2048)
    ref2.write_bytes(b"y" * 2048)
    out = tmp_path / "out.png"

    client = MagicMock()
    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client.images.edit.return_value.data = [MagicMock(b64_json=fake_b64)]

    info = render_node_image(
        openai_client=client,
        image_model="gpt-image-2.5-sunburst",
        prompt="x" * 50,
        out_path=out,
        ref_paths=[ref1, ref2],
        sanitizer=None,
        max_attempts=1,
    )
    assert info["status"] == "ok"
    assert client.images.edit.called
    call_kwargs = client.images.edit.call_args.kwargs
    assert isinstance(call_kwargs.get("image"), list)
    assert len(call_kwargs["image"]) == 2


def test_render_node_image_single_image_fallback(tmp_path, monkeypatch):
    """ref_paths 1개 → single-image edit (기존 동작 호환)."""
    from app.modules.pipeline.background_chain_render import render_node_image

    ref = tmp_path / "ref.png"
    ref.write_bytes(b"x" * 2048)
    out = tmp_path / "out.png"

    client = MagicMock()
    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client.images.edit.return_value.data = [MagicMock(b64_json=fake_b64)]

    info = render_node_image(
        openai_client=client,
        image_model="gpt-image-2.5-sunburst",
        prompt="x" * 50,
        out_path=out,
        ref_paths=[ref],
        sanitizer=None,
        max_attempts=1,
    )
    assert info["status"] == "ok"
    call_kwargs = client.images.edit.call_args.kwargs
    assert not isinstance(call_kwargs.get("image"), list)


def test_render_node_image_text_only_when_no_refs(tmp_path):
    """ref_paths 빈 list → images.generate (text-only)."""
    from app.modules.pipeline.background_chain_render import render_node_image

    out = tmp_path / "out.png"
    client = MagicMock()
    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client.images.generate.return_value.data = [MagicMock(b64_json=fake_b64)]

    info = render_node_image(
        openai_client=client,
        image_model="gpt-image-2.5-sunburst",
        prompt="x" * 50,
        out_path=out,
        ref_paths=[],
        sanitizer=None,
        max_attempts=1,
    )
    assert info["status"] == "ok"
    assert client.images.generate.called
    assert not client.images.edit.called


def test_render_one_location_floor_plan_priority(tmp_path, monkeypatch):
    """floor_plan + parent 모두 있을 때 floor_plan이 1순위, parent 2순위."""
    from app.modules.pipeline.background_chain_render import render_one_location

    floor_plan = tmp_path / "fp.png"
    floor_plan.write_bytes(b"x" * 2048)
    image_dir = tmp_path / "chain"
    image_dir.mkdir()

    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client = MagicMock()
    client.images.edit.return_value.data = [MagicMock(b64_json=fake_b64)]

    location_data = {
        "nodes": [
            {"id": "n1", "shot_ids": ["S5_Shot1"]},
            {"id": "n2", "parent_id": "n1", "shot_ids": ["S5_Shot2"]},
        ],
        "execution_order": ["n1", "n2"],
        "location_description": "rooftop",
    }

    with patch("app.modules.pipeline.background_chain_render.generate_node_prompt",
              return_value=("photo prompt", [])):
        result = render_one_location(
            location_id="L05",
            location_data=location_data,
            image_dir=image_dir,
            location_ref_paths={},
            openai_client=client,
            image_model="gpt-image-2.5-sunburst",
            sanitizer=None,
            max_attempts=1,
            floor_plan_path=floor_plan,
        )

    # n1: refs = [floor_plan] (parent 없음, location_ref 없음) → single edit
    # n2: refs = [floor_plan, parent(n1)] → multi-image edit
    calls = client.images.edit.call_args_list
    assert len(calls) == 2
    # 둘째 call: image=list, len 2 (multi-image)
    assert isinstance(calls[1].kwargs["image"], list)
    assert len(calls[1].kwargs["image"]) == 2
    # 첫 call: image=file (single)
    assert not isinstance(calls[0].kwargs["image"], list)


def test_render_one_location_no_floor_plan_uses_legacy_flow(tmp_path):
    """floor_plan_path=None 시 기존 single-ref flow 동작."""
    from app.modules.pipeline.background_chain_render import render_one_location

    image_dir = tmp_path / "chain"
    image_dir.mkdir()
    location_ref = tmp_path / "loc.png"
    location_ref.write_bytes(b"x" * 2048)

    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client = MagicMock()
    client.images.edit.return_value.data = [MagicMock(b64_json=fake_b64)]

    location_data = {
        "nodes": [{"id": "n1", "shot_ids": ["S5_Shot1"]}],
        "execution_order": ["n1"],
        "location_description": "rooftop",
    }

    with patch("app.modules.pipeline.background_chain_render.generate_node_prompt",
              return_value=("p", [])):
        result = render_one_location(
            location_id="L05",
            location_data=location_data,
            image_dir=image_dir,
            location_ref_paths={"L05": location_ref},
            openai_client=client,
            image_model="gpt-image-2.5-sunburst",
            sanitizer=None,
            max_attempts=1,
            floor_plan_path=None,
        )
    # location_ref 단일 사용 — image= 가 list 아님 (기존 단일 edit)
    call_kwargs = client.images.edit.call_args.kwargs
    assert not isinstance(call_kwargs.get("image"), list)


def test_chain_bg_planning_prepends_floor_plan_prompt(tmp_path):
    """floor_plan_prompts 제공 시 user_prompt 앞에 [FLOOR PLAN] 블록 prepend."""
    from app.modules.pipeline.background_chain_planning import build_planning_user_prompt

    user_prompt = build_planning_user_prompt(
        location_id="L05",
        base_prompt="LOCATION L05 — chain bg planning task",
        floor_plan_prompts={"L05": "Top-down floor plan of rooftop apartment..."},
    )
    assert user_prompt.startswith("[FLOOR PLAN")
    assert "rooftop apartment" in user_prompt
    assert "LOCATION L05" in user_prompt


def test_chain_bg_planning_no_prepend_when_empty(tmp_path):
    """floor_plan_prompts={} 시 base_prompt 그대로."""
    from app.modules.pipeline.background_chain_planning import build_planning_user_prompt

    user_prompt = build_planning_user_prompt(
        location_id="L05",
        base_prompt="LOCATION L05 — chain bg planning task",
        floor_plan_prompts={},
    )
    assert not user_prompt.startswith("[FLOOR PLAN")
    assert user_prompt == "LOCATION L05 — chain bg planning task"


def test_step_passes_floor_plan_paths_to_pipeline(tmp_path, monkeypatch):
    """BackgroundChainRenderStep._execute가 floor_plan_paths를 pipeline에 전달."""
    from app.core.steps.background_chain_render_step import BackgroundChainRenderStep

    pid_root = tmp_path / "p"
    # ⚠️ projects_dir = pid_root, project_id = "p" 이면 _load_prev_checkpoint는
    # tmp_path/p/p/checkpoints/episodes/e/ 를 찾는다 (Task 1과 동일).
    ckpt_dir = pid_root / "p" / "checkpoints" / "episodes" / "e"
    (ckpt_dir / "background_chain_planning").mkdir(parents=True)
    (ckpt_dir / "background_chain_planning" / "manifest.json").write_text(json.dumps({
        "data": {"locations": {}},  # 빈 plan — pipeline 진입만 확인
    }))
    (ckpt_dir / "location_floor_plan").mkdir(parents=True)
    (ckpt_dir / "location_floor_plan" / "manifest.json").write_text(json.dumps({
        "data": {"locations": [
            {"id": "L05", "image_path": "p/images/e/floor_plan/L05.png", "status": "ok"},
        ]},
    }))
    fp_dir = pid_root / "images" / "e" / "floor_plan"
    fp_dir.mkdir(parents=True)
    (fp_dir / "L05.png").write_bytes(b"x" * 2048)

    monkeypatch.setattr("app.core.config.settings.projects_dir", str(pid_root))
    monkeypatch.setattr("app.core.config.settings.openai_api_key", "test-key")

    runner = BackgroundChainRenderStep.__new__(BackgroundChainRenderStep)
    runner.project_id = "p"
    runner.episode_id = "e"
    runner.db = MagicMock()
    runner.db.query.return_value.filter.return_value.all.return_value = []
    runner.project_config = MagicMock()
    runner.build_opik_metadata = MagicMock(return_value={})

    captured = {}
    def fake_run(**kwargs):
        captured.update(kwargs)
        return {"locations": {}, "_failed_count": 0}

    monkeypatch.setattr(
        "app.core.steps.background_chain_render_step.run_background_chain_render",
        fake_run,
    )

    runner._execute(mode="resume")
    assert "floor_plan_paths" in captured
    assert "L05" in captured["floor_plan_paths"]


def test_chain_bg_render_depends_on_location_floor_plan():
    """chain_bg_render manifest의 depends_on에 location_floor_plan 포함."""
    from app.core.step_manifest import STEP_MANIFEST
    deps = STEP_MANIFEST["background_chain_render"]["depends_on"]
    assert "location_floor_plan" in deps
    assert "background_chain_planning" in deps  # 기존 의존성도 그대로


def test_chain_bg_planning_marks_floor_plan_used_when_present():
    """floor_plan_prompts에 location 있으면 결과 dict에 floor_plan_used=True.

    I3 (Phase 4 review feedback): mode=floor_plan_anchored 디버그 traceability —
    어떤 location이 실제로 도면 prompt를 prepend했는지 체크포인트로 확인 가능.
    """
    from app.modules.pipeline.background_chain_planning import build_planning_user_prompt

    # build_planning_user_prompt가 prepend 했는지 검증
    out = build_planning_user_prompt(
        location_id="L05",
        base_prompt="task",
        floor_plan_prompts={"L05": "Top-down floor plan of rooftop"},
    )
    assert out.startswith("[FLOOR PLAN")

    out2 = build_planning_user_prompt(
        location_id="L05",
        base_prompt="task",
        floor_plan_prompts={},
    )
    assert out2 == "task"


def test_chain_bg_planning_low_freq_skip_marks_floor_plan_used_false(monkeypatch):
    """저빈도 skip branch도 floor_plan_used 마커 포함 (일관성 보장)."""
    from app.modules.pipeline import background_chain_planning as bcp

    # _phase0_group_by_location을 mock — 1개 LocationGroup 반환 (low-freq)
    fake_group = bcp.LocationGroup(
        location_id="L99",
        location_name="rare-room",
        location_description="(rare)",
        visual_traits=[],
        shots=[
            bcp.ShotInfo(
                scene_index=1, shot_index=1, shot_id="S01_Shot1",
                description="x",
            ),
        ],
    )
    monkeypatch.setattr(
        bcp, "_phase0_group_by_location",
        lambda *a, **k: {"L99": fake_group},
    )

    result = bcp.run_background_chain_planning(
        shot_extract_data={},
        shot_selection_data={},
        shot_staging_data={},
        director_data={},
        entity_merge_data={},
        floor_plan_prompts={},  # 빈 dict
    )

    assert "L99" in result["locations"]
    loc = result["locations"]["L99"]
    assert loc["status"] == "skipped"
    assert loc["skip_reason"].startswith("low_frequency")
    assert loc["floor_plan_used"] is False  # I3 마커


# ──────────────────────────────────────────────
# Phase 5 (T7) — chain_bg_render planner-driven 신규 7 tests
# ──────────────────────────────────────────────


def _planning_groups_two_locs():
    """T6 형식 data.groups — L05 1 group + L11 1 group (parent)."""
    return {
        "CB_L05_living_day": {
            "group_id": "CB_L05_living_day",
            "location_id": "L05",
            "location_name": "living room",
            "shot_count": 3,
            "status": "ok",
            "rationale_summary": "Day anchor for living room.",
            "nodes": [{
                "id": "interior_living_day",
                "kind": "anchor_root",
                "label": "living day wide",
                "description": "Wide of living room with daylight.",
                "shot_ids": ["S5_Shot1", "S5_Shot2"],
                "parent_id": "",
                "depth": 0,
                "rationale": "anchor",
                "shared_visual_anchors_with_parent": [],
            }],
            "execution_order": ["interior_living_day"],
            "parent_id": "",
            "scenes": [5],
            "time": "day",
            "floor_plan_used": True,
        },
        "CB_L11_bedroom_night": {
            "group_id": "CB_L11_bedroom_night",
            "location_id": "L11",
            "location_name": "bedroom",
            "shot_count": 2,
            "status": "ok",
            "rationale_summary": "Night anchor for bedroom — same building as L05.",
            "nodes": [{
                "id": "interior_bedroom_night",
                "kind": "anchor_root",
                "label": "bedroom night wide",
                "description": "Wide of bedroom at night.",
                "shot_ids": ["S6_Shot1"],
                "parent_id": "",
                "depth": 0,
                "rationale": "anchor",
                "shared_visual_anchors_with_parent": [],
            }],
            "execution_order": ["interior_bedroom_night"],
            "parent_id": "",
            "scenes": [6],
            "time": "night",
            "floor_plan_used": True,
        },
    }


def _planner_floor_plan_specs_shared_building():
    """L05 + L11이 같은 building_group, primary=L05."""
    return {
        "FP_L05": {
            "id": "FP_L05",
            "primary_location_id": "L05",
            "building_group": "okt_room",
            "location_ids": ["L05", "L11"],
        },
    }


def test_planner_render_variant_label_increments_per_location(tmp_path, monkeypatch):
    """variant_label이 location_id 단위로 v01부터 단조 증가."""
    from app.modules.pipeline.background_chain_render import run_background_chain_render

    # L05에 2 그룹, L11에 1 그룹 — L05는 v01, v02 / L11은 v01
    planning = {
        "groups": {
            "G1": {
                "group_id": "G1", "location_id": "L05", "location_name": "lr",
                "status": "ok", "nodes": [{
                    "id": "n1", "kind": "anchor_root", "label": "x", "description": "x",
                    "shot_ids": ["S1_Shot1"], "parent_id": "", "depth": 0,
                    "rationale": "x", "shared_visual_anchors_with_parent": [],
                }], "execution_order": ["n1"], "parent_id": "",
            },
            "G2": {
                "group_id": "G2", "location_id": "L05", "location_name": "lr",
                "status": "ok", "nodes": [{
                    "id": "n2", "kind": "anchor_state", "label": "x", "description": "x",
                    "shot_ids": ["S2_Shot1"], "parent_id": "", "depth": 0,
                    "rationale": "x", "shared_visual_anchors_with_parent": [],
                }], "execution_order": ["n2"], "parent_id": "G1",
            },
            "G3": {
                "group_id": "G3", "location_id": "L11", "location_name": "br",
                "status": "ok", "nodes": [{
                    "id": "n3", "kind": "anchor_root", "label": "x", "description": "x",
                    "shot_ids": ["S3_Shot1"], "parent_id": "", "depth": 0,
                    "rationale": "x", "shared_visual_anchors_with_parent": [],
                }], "execution_order": ["n3"], "parent_id": "",
            },
        }
    }

    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client = MagicMock()
    client.images.edit.return_value.data = [MagicMock(b64_json=fake_b64)]
    client.images.generate.return_value.data = [MagicMock(b64_json=fake_b64)]

    with patch(
        "app.modules.pipeline.background_chain_render.generate_node_prompt",
        return_value=("photo prompt", []),
    ):
        result = run_background_chain_render(
            planning_data=planning,
            image_dir=tmp_path,
            location_ref_paths={},
            openai_client=client,
            sanitizer=None,
            max_attempts=1,
            planner_chain_order=["G1", "G2", "G3"],
            planner_floor_plan_specs={},
        )

    groups = result["groups"]
    assert groups["G1"]["variant_label"] == "v01"
    assert groups["G1"]["variant_index"] == 1
    assert groups["G2"]["variant_label"] == "v02"
    assert groups["G2"]["variant_index"] == 2
    assert groups["G3"]["variant_label"] == "v01"
    assert groups["G3"]["variant_index"] == 1
    # 파일명 컨벤션: {loc_id}_{variant_label}.png
    assert (tmp_path / "L05_v01.png").exists()
    assert (tmp_path / "L05_v02.png").exists()
    assert (tmp_path / "L11_v01.png").exists()


def test_planner_render_building_group_reverse_lookup(tmp_path, monkeypatch):
    """L05 + L11이 같은 building_group일 때 L11 chain_bg가 L05 floor_plan PNG를 ref로 사용."""
    from app.modules.pipeline.background_chain_render import run_background_chain_render

    fp_png = tmp_path / "FP_L05.png"
    fp_png.write_bytes(b"x" * 2048)

    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client = MagicMock()
    client.images.edit.return_value.data = [MagicMock(b64_json=fake_b64)]

    with patch(
        "app.modules.pipeline.background_chain_render.generate_node_prompt",
        return_value=("photo prompt", []),
    ):
        result = run_background_chain_render(
            planning_data={"groups": _planning_groups_two_locs()},
            image_dir=tmp_path,
            location_ref_paths={},
            floor_plan_paths={"L05": fp_png},  # primary_location_id=L05만 매핑
            openai_client=client,
            sanitizer=None,
            max_attempts=1,
            planner_chain_order=["CB_L05_living_day", "CB_L11_bedroom_night"],
            planner_floor_plan_specs=_planner_floor_plan_specs_shared_building(),
        )

    groups = result["groups"]
    # L05: floor_plan_only (자기 primary)
    assert groups["CB_L05_living_day"]["status"] == "ok"
    assert "floor_plan" in groups["CB_L05_living_day"]["ref_used"]
    # L11: building_group reverse lookup 통해 L05 PNG ref 사용
    assert groups["CB_L11_bedroom_night"]["status"] == "ok"
    assert "floor_plan" in groups["CB_L11_bedroom_night"]["ref_used"]


def test_planner_render_t2i_guide_persisted_in_image_asset(tmp_path, monkeypatch):
    """shot_guides[]가 ImageAsset.t2i_guide 컬럼에 newline-joined으로 영속.

    DB UPSERT를 mock된 session으로 검증: query→filter_by→first는 None(=insert path),
    db.add 호출 시 ImageAsset 인스턴스의 t2i_guide/variant_index/variant_label/file_path 검사.
    """
    from app.core.steps.background_chain_render_step import BackgroundChainRenderStep

    # location EntityCanon mock
    canon = MagicMock()
    canon.short_id = "L05"
    canon.id = "canon-L05"

    runner = BackgroundChainRenderStep.__new__(BackgroundChainRenderStep)
    runner.project_id = "proj-test"
    runner.episode_id = "ep1"
    runner.db = MagicMock()

    # query(EntityCanon).filter(...).all() → [canon]
    canon_query = MagicMock()
    canon_query.filter.return_value.all.return_value = [canon]
    # query(ImageAsset).filter_by(...).first() → None (insert path)
    asset_query = MagicMock()
    asset_query.filter_by.return_value.first.return_value = None

    def _query_router(model):
        from app.models.project import EntityCanon as _EC, ImageAsset as _IA
        if model is _EC:
            return canon_query
        if model is _IA:
            return asset_query
        return MagicMock()

    runner.db.query.side_effect = _query_router

    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path / "p"))
    (tmp_path / "p").mkdir(parents=True, exist_ok=True)
    png_file = tmp_path / "p" / "img.png"
    png_file.write_bytes(b"PNG")

    groups = {
        "G1": {
            "group_id": "G1", "location_id": "L05", "location_name": "lr",
            "status": "ok", "variant_label": "v01", "variant_index": 1,
            "ref_used": "floor_plan_only",
            "png_path": str(png_file),
            "t2i_prompt": "photo room",
            "shot_guides": [
                {"shot_id": "S5_Shot1", "guide": "TV upper-left of frame; sofa center"},
                {"shot_id": "S5_Shot2", "guide": "Window right; door mid"},
            ],
            "parent_id": "",
        },
    }

    runner._register_chain_bg_image_assets(groups)

    # db.add 호출 시 인자로 들어온 ImageAsset 인스턴스 캡쳐
    add_calls = runner.db.add.call_args_list
    assert len(add_calls) == 1
    asset = add_calls[0].args[0]
    assert asset.asset_type == "chain_bg"
    assert asset.entity_id == "canon-L05"
    assert asset.variant_index == 1
    assert asset.variant_label == "v01"
    assert asset.is_primary == 0
    # t2i_guide 영속 — shot_guides[]의 모든 guide가 newline join
    assert asset.t2i_guide is not None
    assert "TV upper-left" in asset.t2i_guide
    assert "Window right" in asset.t2i_guide
    assert "[S5_Shot1]" in asset.t2i_guide
    assert "[S5_Shot2]" in asset.t2i_guide
    # commit 호출됐는지
    runner.db.commit.assert_called_once()


def test_planner_render_mode_off_uses_legacy_path(tmp_path, monkeypatch):
    """mode=chain_only / planner cp 없으면 legacy `data.locations` path 사용."""
    from app.modules.pipeline.background_chain_render import run_background_chain_render

    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client = MagicMock()
    client.images.edit.return_value.data = [MagicMock(b64_json=fake_b64)]
    client.images.generate.return_value.data = [MagicMock(b64_json=fake_b64)]

    legacy_planning = {
        "locations": {
            "L01": {
                "location_id": "L01",
                "location_name": "x",
                "rationale_summary": "x",
                "nodes": [{
                    "id": "n1", "kind": "anchor_root", "label": "x",
                    "description": "x", "shot_ids": ["S1_Shot1"],
                    "parent_id": "", "depth": 0, "rationale": "x",
                    "shared_visual_anchors_with_parent": [],
                }],
                "execution_order": ["n1"],
                "status": "ok",
            },
        },
    }

    with patch(
        "app.modules.pipeline.background_chain_render.generate_node_prompt",
        return_value=("p", []),
    ):
        result = run_background_chain_render(
            planning_data=legacy_planning,
            image_dir=tmp_path,
            location_ref_paths={},
            openai_client=client,
            sanitizer=None,
            max_attempts=1,
            planner_chain_order=None,  # legacy
        )

    # legacy path는 "locations" 키 반환
    assert "locations" in result
    assert "L01" in result["locations"]
    assert "groups" not in result  # planner-driven 키는 없어야 함


def test_planner_render_empty_chain_order_graceful(tmp_path):
    """planner_chain_order=[] → 빈 result, exception 없이 graceful."""
    from app.modules.pipeline.background_chain_render import run_background_chain_render

    result = run_background_chain_render(
        planning_data={"groups": {}},
        image_dir=tmp_path,
        location_ref_paths={},
        openai_client=MagicMock(),
        sanitizer=None,
        planner_chain_order=[],
    )
    assert result["groups"] == {}
    assert result["_failed_count"] == 0


def test_planner_render_parent_group_missing_treated_as_root(tmp_path, monkeypatch):
    """parent_id가 rendered_paths에 없으면 root로 처리 (location ref or floor_plan만)."""
    from app.modules.pipeline.background_chain_render import run_background_chain_render

    loc_ref = tmp_path / "loc.png"
    loc_ref.write_bytes(b"L" * 2048)

    fake_b64 = __import__("base64").b64encode(b"PNG" * 1024).decode()
    client = MagicMock()
    client.images.edit.return_value.data = [MagicMock(b64_json=fake_b64)]
    client.images.generate.return_value.data = [MagicMock(b64_json=fake_b64)]

    planning = {
        "groups": {
            "G_orphan": {
                "group_id": "G_orphan", "location_id": "L01", "location_name": "x",
                "status": "ok", "nodes": [{
                    "id": "n1", "kind": "anchor_state", "label": "x", "description": "x",
                    "shot_ids": ["S1_Shot1"], "parent_id": "", "depth": 0,
                    "rationale": "x", "shared_visual_anchors_with_parent": [],
                }], "execution_order": ["n1"],
                "parent_id": "G_missing",  # rendered되지 않은 parent
            },
        }
    }

    with patch(
        "app.modules.pipeline.background_chain_render.generate_node_prompt",
        return_value=("p", []),
    ):
        result = run_background_chain_render(
            planning_data=planning,
            image_dir=tmp_path,
            location_ref_paths={"L01": loc_ref},
            openai_client=client,
            sanitizer=None,
            max_attempts=1,
            planner_chain_order=["G_orphan"],
        )

    groups = result["groups"]
    assert groups["G_orphan"]["status"] == "ok"
    # parent 없으므로 location ref로 fallback
    assert groups["G_orphan"]["ref_used"] == "location"


def test_planner_render_skipped_planning_groups_not_rendered(tmp_path, monkeypatch):
    """planning result status='skipped'/'failed' group은 LLM/PNG 호출 없이 skipped로 마킹."""
    from app.modules.pipeline.background_chain_render import run_background_chain_render

    client = MagicMock()  # 호출되어선 안 됨

    planning = {
        "groups": {
            "G_skip": {
                "group_id": "G_skip", "location_id": "L01", "location_name": "outdoor",
                "status": "skipped", "skip_reason": "outdoor open-air",
                "nodes": [], "execution_order": [],
                "parent_id": "",
            },
        },
    }

    with patch(
        "app.modules.pipeline.background_chain_render.generate_node_prompt"
    ) as mock_gen:
        result = run_background_chain_render(
            planning_data=planning,
            image_dir=tmp_path,
            location_ref_paths={},
            openai_client=client,
            sanitizer=None,
            max_attempts=1,
            planner_chain_order=["G_skip"],
        )

    groups = result["groups"]
    assert groups["G_skip"]["status"] == "skipped_planning"
    # LLM 미호출
    mock_gen.assert_not_called()
    # PNG 미생성
    client.images.edit.assert_not_called()
    client.images.generate.assert_not_called()
