"""W-K (2026-07-03) — 같은 장소 plate 무조건 렌더 체이닝 결정론 테스트.

같은 location 의 렌더들이 상호참조 0 으로 매번 다른 공간을 재발명하고,
같은 building 그룹의 실내 렌더가 외부 렌더와 단절되는 결함의 근본 대응 검증.

LLM / image / VLM API call 0 (render_one_background mock), DB write 0
(_register_image_assets mock 또는 fake db). 커버:

- lane 재배열: W-K ON 이면 fp lane 이 direct-plate lane 앞 + 양 lane 내
  mixed 그룹 outdoor 소속 우선 안정정렬(외부 establishing 이 첫 렌더).
- 그룹 anchor 등록은 **outdoor ok 렌더만**(indoor-only 그룹은 미등록),
  첨부는 그룹 **전 멤버**(실내 포함) + 실내↔실외 정합형 guidance.
- loc anchor: 모든 loc 의 첫 ok 렌더 등록(전 lane) → 같은 loc 렌더에
  SAME_PLACE_PLATE_GUIDANCE 와 함께 첨부.
- 추가 anchor cap 2(loc+group) — 같은 png 면 경로 dedup 으로 1.
- entry.same_loc_anchor_ref 구조 필드 + lineage 라벨 순서
  (same_loc_anchor → building_anchor → building_fp).
- flag OFF = byte-identical (순서/kwargs/entry/config_hash 불변).
- config_hash: shot_aware_plan + ON 일 때만 스탬프.
- _register_image_assets: same_loc_anchor_ref → anchor UUID 가
  input_image_ids 에 병합(building_anchor_ref 와 리스트 공존), 미해결이면
  unresolved_inputs 구조키.
"""
from __future__ import annotations

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

from app.modules.pipeline.background_render import (
    BUILDING_ANCHOR_PLATE_GUIDANCE,
    BUILDING_INTERIOR_EXTERIOR_ANCHOR_GUIDANCE,
    SAME_PLACE_PLATE_GUIDANCE,
)


def _touch_png(p: Path) -> Path:
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(b"\x89PNG\r\n\x1a\n")
    return p


def _bg_spec_fp(*, bg_id: str, fp_id: str, loc_id: str) -> Dict[str, Any]:
    return {
        "bg_id": bg_id,
        "loc_id": loc_id,
        "sub_location": "primary_unit",
        "state_label": "day",
        "applies_to_shots": [f"{bg_id}_Shot1"],
        "depends_on_fp": [fp_id],
        "depends_on_bg": [],
    }


def _bg_spec_direct(*, bg_id: str, loc_id: str) -> Dict[str, Any]:
    """fp 없는 direct-plate lane 대상 spec (surface_role 구조 게이트)."""
    return {
        "bg_id": bg_id,
        "loc_id": loc_id,
        "sub_location": "primary_unit",
        "state_label": "day",
        "applies_to_shots": [f"{bg_id}_Shot1"],
        "depends_on_fp": [],
        "depends_on_bg": [],
        "surface_role": "transition_zone",
    }


_RENDER_GUIDANCE_FIELDS = (
    "visible_space_directive",
    "camera_framing_directive",
    "subject_position_directive",
    "state_cue_directive",
    "negative_continuity_directive",
)


def _plan_node(*, bg_id: str, node_index: int) -> Dict[str, Any]:
    return {
        "bg_id": bg_id,
        "node_index": node_index,
        "mode": "fp_seeded_anchor",
        "is_dwelling_identity_anchor": True,
        "rationale": f"rationale for {bg_id}",
        "render_action": "render_new_plate",
        "reuse_target_bg_id": "",
        "reference_decision": {
            "selected_refs": [],
            "rejected_refs": [],
            "same_physical_space_dedup_decision": "single_ref",
            "why_single_ref_or_two_refs": "single ref reason",
            "physical_space_id_per_ref": [],
        },
        "camera_decision": {
            "camera_unit": 1,
            "camera_cell": [0, 0],
            "look_at_unit": 2,
            "look_at_cell": [2, 0],
            "lens_enum": "normal",
            "fov_deg": 50,
            "framing_notes": f"framing for {bg_id}",
        },
        "render_guidance": {
            f: f"directive {f} for {bg_id}" for f in _RENDER_GUIDANCE_FIELDS
        },
    }


def _plan_for_fp(*, fp_id: str, nodes: List[Dict[str, Any]]) -> Dict[str, Any]:
    return {
        "fp_id": fp_id,
        "shot_aware_bg_render_plan_status": "ok",
        "graph": {"nodes": list(nodes)},
        "production_clear": True,
        "real_api_call_counts": {"image": 0, "llm": 1, "vlm": 0},
        "readback_status": "ok",
        "validators": {"all_validators_passed": True, "diagnostics": []},
        "diagnostics": [],
    }


def _build_cp_map(
    *,
    tmp_path: Path,
    groups: List[Dict[str, Any]],
    building_groups: List[Dict[str, Any]],
) -> Dict[str, Any]:
    """groups = [{"gid", "fp_id"?, "bg_specs", "plan"?}] — fp_id/plan 없는
    그룹은 direct-plate lane 전용. building_groups 는 background_classify cp
    구조 필드(멤버십 로더의 실경로 검증)."""
    plans: Dict[str, Any] = {}
    fp_renders: Dict[str, Any] = {}
    fp_prompts: Dict[str, Any] = {}
    bg_prompts: Dict[str, Any] = {}
    per_fp: Dict[str, Any] = {}
    for g in groups:
        plans[g["gid"]] = {
            "status": "ok",
            "plan": {
                "group_id": g["gid"],
                "rationale_summary": "",
                "floor_plans": [],
                "backgrounds": g["bg_specs"],
                "gen_order": [b["bg_id"] for b in g["bg_specs"]],
            },
        }
        if g.get("fp_id"):
            fp_png = _touch_png(tmp_path / "fp_render" / f"{g['fp_id']}.png")
            fp_renders[g["fp_id"]] = {
                "status": "ok", "png_path": str(fp_png)}
            fp_prompts[g["fp_id"]] = {
                "status": "ok",
                "numbered_elements": [],
                "camera_recommendations": [],
            }
            per_fp[g["fp_id"]] = g["plan"]
        for b in g["bg_specs"]:
            bg_prompts[b["bg_id"]] = {
                "status": "ok",
                "t2i_prompt": f"prompt body for {b['bg_id']}",
                "shot_guides": [],
                "objects_owned_by_background": [],
                "spec": b,
                "group_id": g["gid"],
            }
    return {
        "background_master_plan": {
            "data": {
                "plans": plans,
                "bg_catalog_hash": "",
                "shot_binding_hash": "",
            }
        },
        "background_prompt": {"data": {"backgrounds": bg_prompts}},
        "floor_plan_render": {"data": {"floor_plans": fp_renders}},
        "floor_plan_prompt": {"data": {"floor_plans": fp_prompts}},
        "shot_aware_bg_render_plan": {"data": {"per_fp": per_fp}},
        "background_classify": {
            "data": {"building_groups": building_groups}},
        "floor_plan_overlay_payload": None,
    }


def _new_step(cp_map: Dict[str, Any]):
    from app.core.steps.background_render_step import BackgroundRenderStep

    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    step.project_id = "p"
    step.episode_id = "e"
    step.project_config = {}
    step.build_opik_metadata = MagicMock(return_value={})
    step._load_prev_checkpoint = MagicMock(
        side_effect=lambda sid: cp_map.get(sid)
    )
    step.db = MagicMock()
    step._register_image_assets = MagicMock()
    return step


def _apply_settings(monkeypatch, tmp_path, *, wk_on=True, wi_anchor_on=False):
    for key, val in (
        ("projects_dir", str(tmp_path)),
        ("openai_api_key", "sk-test"),
        ("background_mode", "on"),
        ("background_render_reference_mode", "shot_aware_plan"),
        ("shot_aware_bg_render_plan_enabled", False),
        ("bg_render_substrate_enabled", False),
        # W-K 단독 동작 검증 — W-G fp ref / W-I 는 자기 스위트가 커버.
        ("outdoor_building_fp_ref_enabled", False),
        ("outdoor_building_anchor_ref_enabled", wi_anchor_on),
        ("same_place_render_chain_enabled", wk_on),
        # W-L: aerial 은 자기 스위트가 커버 — .env ON 누수 차단 명시 OFF.
        ("outdoor_aerial_reference_enabled", False),
        # W-M: plate 단계화 체인은 자기 스위트가 커버 — 명시 OFF
        # (이 파일의 W-K 실내 anchor 첨부 계약은 W-M OFF 일 때의 계약).
        ("outdoor_plate_stage_chain_enabled", False),
    ):
        monkeypatch.setattr(f"app.core.config.settings.{key}", val)


def _capturing_renderer(captured: List[Dict[str, Any]]):
    def _side(*, openai_client, image_model, prompt, out_path, fp_path,
              prior_bg_paths, bg_id, building_fp_path=None,
              building_anchor_path=None, same_place_anchor_path=None,
              **kwargs):
        _touch_png(Path(out_path))
        captured.append({
            "bg_id": bg_id,
            "prompt": prompt,
            "fp_path": str(fp_path) if fp_path is not None else None,
            "prior_bg_paths": [str(p) for p in prior_bg_paths],
            "building_anchor_path": (
                str(building_anchor_path)
                if building_anchor_path is not None else None
            ),
            "same_place_anchor_path": (
                str(same_place_anchor_path)
                if same_place_anchor_path is not None else None
            ),
        })
        return {
            "status": "ok",
            "attempts": 1,
            "strategies": [],
            "ref_used": "refs_1",
            "final_block_reason": None,
            "building_fp_attached": (
                building_fp_path is not None and building_fp_path.exists()
            ),
            "building_anchor_attached": (
                building_anchor_path is not None
                and building_anchor_path.exists()
            ),
            "same_place_anchor_attached": (
                same_place_anchor_path is not None
                and same_place_anchor_path.exists()
            ),
            "png_path": str(out_path),
        }
    return _side


def _run(step, captured):
    with patch(
        "app.core.steps.background_render_step._resolve_openai_client",
        return_value=MagicMock(),
    ), patch(
        "app.modules.pipeline.background_render.render_one_background",
        side_effect=_capturing_renderer(captured),
    ):
        return step._execute()


def _mixed_fixture(tmp_path):
    """마트형 — indoor loc L01: direct 2(L01B01/B02)+fp lane 1(L01B03),
    outdoor loc L02: fp lane 1(L02B01). building 그룹 grp1 에 둘 다 소속."""
    groups = [
        {
            "gid": "g_direct",
            "bg_specs": [
                _bg_spec_direct(bg_id="L01B01", loc_id="L01"),
                _bg_spec_direct(bg_id="L01B02", loc_id="L01"),
            ],
        },
        {
            "gid": "g_l01",
            "fp_id": "fp_l01",
            "bg_specs": [
                _bg_spec_fp(bg_id="L01B03", fp_id="fp_l01", loc_id="L01")],
            "plan": _plan_for_fp(fp_id="fp_l01", nodes=[
                _plan_node(bg_id="L01B03", node_index=0)]),
        },
        {
            "gid": "g_l02",
            "fp_id": "fp_l02",
            "bg_specs": [
                _bg_spec_fp(bg_id="L02B01", fp_id="fp_l02", loc_id="L02")],
            "plan": _plan_for_fp(fp_id="fp_l02", nodes=[
                _plan_node(bg_id="L02B01", node_index=0)]),
        },
    ]
    building_groups = [{
        "group_id": "grp1",
        "anchor_loc": "L01",
        "members": [
            {"loc_id": "L01", "is_indoor": True, "shot_count": 5},
            {"loc_id": "L02", "is_indoor": False, "shot_count": 2},
        ],
    }]
    return _build_cp_map(
        tmp_path=tmp_path, groups=groups, building_groups=building_groups)


# ─── lane 재배열 + 그룹 anchor(outdoor 등록·실내 첨부) + loc anchor ───


def test_lane_reorder_and_anchors_mixed_group(tmp_path, monkeypatch):
    _apply_settings(monkeypatch, tmp_path)
    cp_map = _mixed_fixture(tmp_path)
    step = _new_step(cp_map)
    captured: List[Dict[str, Any]] = []
    result = _run(step, captured)

    # 재배열: fp lane 먼저 + fp lane 내 outdoor(mixed) 우선 → 외부
    # establishing 이 첫 렌더. direct lane 은 마지막(원순서 보존).
    assert [c["bg_id"] for c in captured] == [
        "L02B01", "L01B03", "L01B01", "L01B02"]

    by_bid = {c["bg_id"]: c for c in captured}
    # 첫 렌더(outdoor) — anchor 없음.
    first = by_bid["L02B01"]
    assert first["building_anchor_path"] is None
    assert first["same_place_anchor_path"] is None

    # indoor fp lane 렌더 — 그룹 anchor(outdoor 렌더)가 **실내에도** 첨부
    # + 실내↔실외 정합형 guidance(기존 W-I 문구 아님). loc anchor 는 아직
    # 이 loc 의 선행 렌더가 없어 미첨부.
    b03 = by_bid["L01B03"]
    assert (b03["building_anchor_path"] or "").endswith("L02B01.png")
    assert b03["same_place_anchor_path"] is None
    assert BUILDING_INTERIOR_EXTERIOR_ANCHOR_GUIDANCE in b03["prompt"]
    assert BUILDING_ANCHOR_PLATE_GUIDANCE not in b03["prompt"]

    # direct lane 렌더 — loc anchor(같은 loc 첫 ok=L01B03) + 그룹 anchor
    # (L02B01) 둘 다 첨부 = 추가 anchor cap 2.
    for bid in ("L01B01", "L01B02"):
        c = by_bid[bid]
        assert (c["same_place_anchor_path"] or "").endswith("L01B03.png")
        assert (c["building_anchor_path"] or "").endswith("L02B01.png")
        assert SAME_PLACE_PLATE_GUIDANCE in c["prompt"]
        assert BUILDING_INTERIOR_EXTERIOR_ANCHOR_GUIDANCE in c["prompt"]
        # 프롬프트 순서: 장소 정체성 → 건물 정체성 (첨부 순서와 정합).
        assert c["prompt"].index(SAME_PLACE_PLATE_GUIDANCE) < (
            c["prompt"].index(BUILDING_INTERIOR_EXTERIOR_ANCHOR_GUIDANCE))

    # entry 구조 필드 + 라벨 순서.
    g = result["data"]["groups"]
    assert "same_loc_anchor_ref" not in g["L02B01"]
    assert g["L01B03"]["building_anchor_ref"] == {
        "anchor_bg_id": "L02B01", "group_id": "grp1"}
    e1 = g["L01B01"]
    assert e1["same_loc_anchor_ref"] == {
        "anchor_bg_id": "L01B03", "loc_id": "L01"}
    assert e1["building_anchor_ref"] == {
        "anchor_bg_id": "L02B01", "group_id": "grp1"}
    labels = e1["attached_reference_lineage"]["attached_ref_labels"]
    assert "same_loc_anchor:L01B03" in labels
    assert "building_anchor:L02B01" in labels
    assert labels.index("same_loc_anchor:L01B03") < labels.index(
        "building_anchor:L02B01")
    # prior_bg lineage 채널에는 섞이지 않는다.
    assert "L01B03" not in e1["attached_reference_lineage"]["prior_bg_ids"]


def test_same_anchor_png_attached_once(tmp_path, monkeypatch):
    """loc anchor 와 그룹 anchor 가 같은 png 면 1장만(same_place 채널)."""
    _apply_settings(monkeypatch, tmp_path)
    groups = [
        {
            "gid": "g_l02",
            "fp_id": "fp_l02",
            "bg_specs": [
                _bg_spec_fp(bg_id="L02B01", fp_id="fp_l02", loc_id="L02"),
                _bg_spec_fp(bg_id="L02B02", fp_id="fp_l02", loc_id="L02"),
            ],
            "plan": _plan_for_fp(fp_id="fp_l02", nodes=[
                _plan_node(bg_id="L02B01", node_index=0),
                _plan_node(bg_id="L02B02", node_index=1),
            ]),
        },
    ]
    building_groups = [{
        "group_id": "grp1",
        "anchor_loc": "L01",
        "members": [
            {"loc_id": "L01", "is_indoor": True, "shot_count": 5},
            {"loc_id": "L02", "is_indoor": False, "shot_count": 2},
        ],
    }]
    cp_map = _build_cp_map(
        tmp_path=tmp_path, groups=groups, building_groups=building_groups)
    step = _new_step(cp_map)
    captured: List[Dict[str, Any]] = []
    result = _run(step, captured)

    assert [c["bg_id"] for c in captured] == ["L02B01", "L02B02"]
    second = captured[1]
    # 같은 bg(L02B01)가 loc anchor 이자 그룹 anchor — same_place 채널로만.
    assert (second["same_place_anchor_path"] or "").endswith("L02B01.png")
    assert second["building_anchor_path"] is None
    assert SAME_PLACE_PLATE_GUIDANCE in second["prompt"]
    assert BUILDING_INTERIOR_EXTERIOR_ANCHOR_GUIDANCE not in second["prompt"]
    e2 = result["data"]["groups"]["L02B02"]
    assert e2["same_loc_anchor_ref"] == {
        "anchor_bg_id": "L02B01", "loc_id": "L02"}
    assert "building_anchor_ref" not in e2


def test_group_anchor_registers_outdoor_only(tmp_path, monkeypatch):
    """indoor-only 렌더 그룹 — 그룹 anchor 미등록, loc anchor 만 동작."""
    _apply_settings(monkeypatch, tmp_path)
    groups = [
        {
            "gid": "g_l01",
            "fp_id": "fp_l01",
            "bg_specs": [
                _bg_spec_fp(bg_id="L01B01", fp_id="fp_l01", loc_id="L01")],
            "plan": _plan_for_fp(fp_id="fp_l01", nodes=[
                _plan_node(bg_id="L01B01", node_index=0)]),
        },
        {
            "gid": "g_direct",
            "bg_specs": [_bg_spec_direct(bg_id="L01B02", loc_id="L01")],
        },
    ]
    # 그룹 멤버가 전부 indoor — outdoor 등록 게이트 검증.
    building_groups = [{
        "group_id": "grp_in",
        "anchor_loc": "L01",
        "members": [{"loc_id": "L01", "is_indoor": True, "shot_count": 5}],
    }]
    cp_map = _build_cp_map(
        tmp_path=tmp_path, groups=groups, building_groups=building_groups)
    step = _new_step(cp_map)
    captured: List[Dict[str, Any]] = []
    result = _run(step, captured)

    # fp lane 먼저(재배열) — indoor 렌더가 그룹 첫 ok 여도 anchor 미등록.
    assert [c["bg_id"] for c in captured] == ["L01B01", "L01B02"]
    assert all(c["building_anchor_path"] is None for c in captured)
    # loc anchor 는 lane 무관하게 동작(같은 loc 의 direct 렌더에 첨부).
    second = captured[1]
    assert (second["same_place_anchor_path"] or "").endswith("L01B01.png")
    assert SAME_PLACE_PLATE_GUIDANCE in second["prompt"]
    e2 = result["data"]["groups"]["L01B02"]
    assert e2["same_loc_anchor_ref"] == {
        "anchor_bg_id": "L01B01", "loc_id": "L01"}
    assert "building_anchor_ref" not in e2


def test_wm_blocks_interior_source_loc_anchor_on_outdoor_render(
        tmp_path, monkeypatch):
    """E2E9 육안 #3 회귀 (2026-07-19, W-M ① 대칭): 실내 렌더가 loc anchor
    로 등록된 뒤 같은 loc 의 **실외 렌더**(비 interior_room surface_role,
    예: 마트 외벽 plate)에 첨부되면 실내 사진이 외벽을 오염(L09B03 실측).
    W-M ON 이면 실내 소스 anchor 는 실외 렌더에 미첨부 — 실내→실내는
    별건(이 fixture 의 direct lane 은 전부 비실내)."""
    _apply_settings(monkeypatch, tmp_path)
    monkeypatch.setattr(
        "app.core.config.settings.outdoor_plate_stage_chain_enabled", True)
    cp_map = _mixed_fixture(tmp_path)
    step = _new_step(cp_map)
    captured: List[Dict[str, Any]] = []
    result = _run(step, captured)

    by_bid = {c["bg_id"]: c for c in captured}
    # L01B03(실내 fp lane 렌더)이 L01 의 loc anchor 로 등록되지만, 같은
    # loc 의 direct lane 렌더(transition_zone=실외 성격)에는 첨부 금지.
    for bid in ("L01B01", "L01B02"):
        c = by_bid[bid]
        assert c["same_place_anchor_path"] is None, bid
        assert SAME_PLACE_PLATE_GUIDANCE not in c["prompt"], bid
        assert "same_loc_anchor_ref" not in result["data"]["groups"][bid]
        # 그룹 anchor(실외 소스 L02B01)는 실외 렌더에 계속 첨부 — 외관
        # 연속 유지 (차단 범위는 '실내 소스'만).
        assert (c["building_anchor_path"] or "").endswith("L02B01.png")


def test_flag_off_byte_identical(tmp_path, monkeypatch):
    """W-K OFF — 기존 순서(direct 먼저)·kwargs None·entry 필드 부재."""
    _apply_settings(monkeypatch, tmp_path, wk_on=False)
    cp_map = _mixed_fixture(tmp_path)
    step = _new_step(cp_map)
    captured: List[Dict[str, Any]] = []
    result = _run(step, captured)

    # 기존 순서: direct lane 먼저(원순서), fp lane 은 order 발견 순.
    assert [c["bg_id"] for c in captured] == [
        "L01B01", "L01B02", "L01B03", "L02B01"]
    for c in captured:
        assert c["same_place_anchor_path"] is None
        assert c["building_anchor_path"] is None
        assert SAME_PLACE_PLATE_GUIDANCE not in c["prompt"]
        assert BUILDING_INTERIOR_EXTERIOR_ANCHOR_GUIDANCE not in c["prompt"]
    for e in result["data"]["groups"].values():
        assert "same_loc_anchor_ref" not in e
        assert "building_anchor_ref" not in e
        labels = (e.get("attached_reference_lineage") or {}).get(
            "attached_ref_labels") or []
        assert not [x for x in labels if x.startswith("same_loc_anchor:")]


def test_wk_off_wi_on_keeps_wi_contract(tmp_path, monkeypatch):
    """W-K OFF + W-I ON — 그룹 anchor 는 기존 W-I 문구/게이트 그대로."""
    _apply_settings(monkeypatch, tmp_path, wk_on=False, wi_anchor_on=True)
    cp_map = _mixed_fixture(tmp_path)
    # W-I 게이트는 W-G 링크 맵 기반 — outdoor loc 만 담긴 링크 주입.
    indoor_fp_png = _touch_png(tmp_path / "fp_render" / "fp_l01.png")
    step = _new_step(cp_map)
    step._load_building_fp_by_loc = MagicMock(return_value={
        "L02": {
            "fp_id": "fp_l01",
            "indoor_loc_sid": "L01",
            "group_id": "grp1",
            "png_path": str(indoor_fp_png),
            "fp_asset_id": "",
        },
    })
    captured: List[Dict[str, Any]] = []
    _run(step, captured)

    # 순서는 기존 그대로(direct 먼저) — outdoor L02B01 이 그룹 첫 ok 등록.
    assert [c["bg_id"] for c in captured] == [
        "L01B01", "L01B02", "L01B03", "L02B01"]
    # W-I 는 outdoor 만 첨부 대상 — indoor 3건 전부 anchor 미첨부(등록
    # 시점상 L02B01 이 마지막이라 첨부처 0 = 기존 W-I 실측 갭 그대로).
    for c in captured:
        assert c["building_anchor_path"] is None
        assert BUILDING_INTERIOR_EXTERIOR_ANCHOR_GUIDANCE not in c["prompt"]
        assert c["same_place_anchor_path"] is None


# ─── config_hash ───


def test_config_hash_stamps_flag_only_when_on(tmp_path, monkeypatch):
    _apply_settings(monkeypatch, tmp_path, wk_on=True)
    cp_map = _mixed_fixture(tmp_path)
    h_on = _new_step(cp_map)._config_hash()

    _apply_settings(monkeypatch, tmp_path, wk_on=False)
    h_off = _new_step(cp_map)._config_hash()
    assert h_on != h_off

    # legacy 모드에서는 ON 이어도 스탬프하지 않는다(shot_aware 전용).
    _apply_settings(monkeypatch, tmp_path, wk_on=True)
    monkeypatch.setattr(
        "app.core.config.settings.background_render_reference_mode", "legacy")
    h_legacy_on = _new_step(cp_map)._config_hash()
    _apply_settings(monkeypatch, tmp_path, wk_on=False)
    monkeypatch.setattr(
        "app.core.config.settings.background_render_reference_mode", "legacy")
    h_legacy_off = _new_step(cp_map)._config_hash()
    assert h_legacy_on == h_legacy_off


# ─── _register_image_assets: same_loc anchor UUID lineage 병합 ───


class _FakeQuery:
    def __init__(self, rows):
        self._rows = rows

    def filter(self, *a, **k):
        return self

    def filter_by(self, **k):
        return self

    def order_by(self, *a, **k):
        return self

    def all(self):
        return self._rows

    def first(self):
        return None


def _fake_db(canon_rows):
    db = MagicMock()

    def _query(*args):
        name = getattr(args[0], "__name__", "")
        if name == "EntityCanon":
            return _FakeQuery(canon_rows)
        return _FakeQuery([])

    db.query = MagicMock(side_effect=_query)
    return db


def _register_groups_fixture(tmp_path):
    p1 = _touch_png(tmp_path / "bgc" / "L02B01.png")
    p2 = _touch_png(tmp_path / "bgc" / "L01B01.png")
    p3 = _touch_png(tmp_path / "bgc" / "L01B02.png")
    groups = {
        "L02B01": {
            "status": "ok", "png_path": str(p1), "location_id": "L02",
            "shot_guides": [], "shot_ids": [], "t2i_prompt": "t",
            "attached_reference_lineage": {
                "prior_bg_ids": [], "ref_used": "refs_1",
                "attached_ref_labels": []},
        },
        "L01B01": {
            "status": "ok", "png_path": str(p2), "location_id": "L01",
            "shot_guides": [], "shot_ids": [], "t2i_prompt": "t",
            "building_anchor_ref": {
                "anchor_bg_id": "L02B01", "group_id": "grp1"},
            "attached_reference_lineage": {
                "prior_bg_ids": [], "ref_used": "refs_2",
                "attached_ref_labels": ["building_anchor:L02B01"]},
        },
        "L01B02": {
            "status": "ok", "png_path": str(p3), "location_id": "L01",
            "shot_guides": [], "shot_ids": [], "t2i_prompt": "t",
            "same_loc_anchor_ref": {
                "anchor_bg_id": "L01B01", "loc_id": "L01"},
            "building_anchor_ref": {
                "anchor_bg_id": "L02B01", "group_id": "grp1"},
            "attached_reference_lineage": {
                "prior_bg_ids": [], "ref_used": "refs_3",
                "attached_ref_labels": [
                    "same_loc_anchor:L01B01", "building_anchor:L02B01"]},
        },
    }
    return groups, ["L02B01", "L01B01", "L01B02"]


def _run_register(tmp_path, monkeypatch, groups, order):
    from app.core.steps.background_render_step import BackgroundRenderStep

    monkeypatch.setattr(
        "app.core.config.settings.projects_dir", str(tmp_path / "proj"))
    monkeypatch.setattr(
        "app.core.config.settings."
        "background_render_record_prior_bg_lineage_enabled",
        True,
    )
    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    step.project_id = "p"
    step.episode_id = "e"
    step.project_config = {}
    canon_rows = [
        SimpleNamespace(short_id="L01", id="canon-l01"),
        SimpleNamespace(short_id="L02", id="canon-l02"),
    ]
    step.db = _fake_db(canon_rows)
    added = []
    step.db.add = MagicMock(side_effect=added.append)
    calls = []
    with patch(
        "app.core.steps.background_render_step.annotate_generated_asset",
        side_effect=lambda row, **kw: calls.append((row, kw)),
    ):
        step._register_image_assets(groups, order)
    return added, calls


def test_register_merges_both_anchor_uuids(tmp_path, monkeypatch):
    groups, order = _register_groups_fixture(tmp_path)
    added, calls = _run_register(tmp_path, monkeypatch, groups, order)

    row_by_vt = {r.variant_type: r for r in added}
    assert set(row_by_vt) == {"L02B01", "L01B01", "L01B02"}
    sp_uuid = row_by_vt["L01B01"].id
    grp_uuid = row_by_vt["L02B01"].id
    final = [kw for row, kw in calls if row is row_by_vt["L01B02"]][-1]
    iids = final.get("input_image_ids") or []
    # 병합 순서 = 첨부 순서(same_loc → building).
    assert sp_uuid in iids and grp_uuid in iids
    assert iids.index(sp_uuid) < iids.index(grp_uuid)
    # W-I 단독 row 회귀 — building anchor 만 있어도 병합 유지.
    final_b01 = [kw for row, kw in calls if row is row_by_vt["L01B01"]][-1]
    assert grp_uuid in (final_b01.get("input_image_ids") or [])


def test_register_unresolved_same_loc_anchor_marks_structural_key(
    tmp_path, monkeypatch,
):
    """loc anchor bg 렌더가 실패해 row 가 없으면 unresolved_inputs 구조키."""
    groups, order = _register_groups_fixture(tmp_path)
    groups["L01B01"]["status"] = "failed"
    groups["L01B01"]["png_path"] = ""
    added, calls = _run_register(tmp_path, monkeypatch, groups, order)

    row_by_vt = {r.variant_type: r for r in added}
    assert set(row_by_vt) == {"L02B01", "L01B02"}
    final = [kw for row, kw in calls if row is row_by_vt["L01B02"]][-1]
    meta = final.get("pipeline_metadata") or {}
    assert "same_loc_anchor:L01B01" in (meta.get("unresolved_inputs") or [])
    # building anchor 는 정상 resolve — 리스트 확장이 서로를 깨지 않는다.
    assert row_by_vt["L02B01"].id in (final.get("input_image_ids") or [])
