"""W20C: shot-aware background render adapter.

Materialization layer between the W20B ``shot_aware_bg_render_plan``
LLM-emitted graph and the W19B-3 ``background_render`` image call site.

Pure deterministic helpers. **No openai / litellm / sqlalchemy /
ImageAsset / DB imports.** The adapter:

  1. Gates the LLM plan via :func:`is_plan_production_clear` —
     a plan must be ``shot_aware_bg_render_plan_status == "ok"`` AND
     ``production_clear is True`` before any image work is allowed.
  2. Orders the graph nodes by ``node_index`` ascending.
  3. Materializes each node's reference / camera decision into the
     concrete shape ``render_one_background`` consumes:
     ``reference_paths`` (FP for anchor / catalog png paths otherwise)
     and a ``reference_guidance_prefix`` prepended to the LLM-output
     ``t2i_prompt`` before the image call.

Hard invariants (fail-closed, no fallback):
  - ``mode`` ∈ ``ALLOWED_MODES`` (exact set membership).
  - ``len(selected_refs) <= MAX_REFS_PER_BG`` (=2) for all modes.
  - ``fp_seeded_anchor`` → empty ``selected_refs`` AND a non-empty
    ``base_fp_png_str`` (FP is the only reference).
  - Every non-anchor mode → at least one ``selected_refs`` entry, FP
    NOT in ``reference_paths``.
  - ``two_refs_distinct_spaces`` → exactly two ``selected_refs``.
  - Every ``ref_bg_id`` referenced by a non-anchor node MUST already
    exist in the catalog passed by the caller. The adapter never
    re-orders / re-scores / re-classifies. No top-K. No semantic
    parsing.

The adapter does NOT mutate the catalog: the caller (the
``background_render`` step) is responsible for appending a successful
render's catalog entry before materializing the next node.

This module deliberately duplicates the small style / mode-guidance
text registry (rather than importing the W19B-3 planner) so it stays
import-clean and the two reference-graph code paths
(``w18j_overlap`` vs ``shot_aware_plan``) remain decoupled.

Generic only: no scenario tokens, no work-specific nouns, no bg_id
keyed prose.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import (
    Any,
    Dict,
    FrozenSet,
    List,
    Mapping,
    Optional,
    Sequence,
    Tuple,
)


ANCHOR_MODE: str = "fp_seeded_anchor"
TWO_REFS_MODE: str = "two_refs_distinct_spaces"
MAX_REFS_PER_BG: int = 2

# W21B Phase 2 — the render_action_source value stamped by the dwelling-zone-map
# application path (SOT: shot_aware_bg_render_plan.RENDER_ACTION_SOURCE_ZONE_MAP).
# Kept local to stay import-clean (mirrors MAX_REFS_PER_BG above); used by
# ordered_nodes to scope the anchor-first render ordering to that path only.
RENDER_ACTION_SOURCE_ZONE_MAP: str = "dwelling_zone_map"

# W21B-w3 Commit 2a (2026-05-29): align with the v2 planner mode set —
# legacy ``style_reference_new_space`` removed, ``same_physical_space_view``
# (same room, new angle) and ``related_style_new_space`` (a different room
# of the same dwelling) added.
ALLOWED_MODES: FrozenSet[str] = frozenset(
    {
        "fp_seeded_anchor",
        "reference_derived",
        "same_physical_space_view",
        "related_style_new_space",
        "two_refs_distinct_spaces",
    }
)


# W20D renderer-facing guidance fields. Adapter never parses meanings —
# it only echoes the LLM-emitted strings in a fixed order.
RENDER_GUIDANCE_FIELDS: Tuple[str, ...] = (
    "visible_space_directive",
    "camera_framing_directive",
    "subject_position_directive",
    "state_cue_directive",
    "negative_continuity_directive",
)

# W20D camera_decision exact fields piped into the guidance prefix.
# Optional fields (framing_notes) are only formatted when present and
# non-empty; required fields fail-closed if missing.
_CAMERA_REQUIRED_FIELDS: Tuple[str, ...] = (
    "camera_unit",
    "camera_cell",
    "look_at_unit",
    "look_at_cell",
    "lens_enum",
    "fov_deg",
)
_CAMERA_OPTIONAL_FIELDS: Tuple[str, ...] = ("framing_notes",)


# Style contract — generic, scenario-free. Prepended to every node's
# guidance regardless of mode so gpt-image-2 stays away from
# luxury / showroom / boutique drift the W18I/W18J anchors had to
# rein in. (W19B-3 planner 와의 동일 문구 동기는 planner 사본 소멸로
# 해제 — 이 상수가 유일 SOT. 2026-07-23 개정 상세는 아래 주석.)
_STYLE_CONTRACT_PREAMBLE: str = (
    # 2026-07-23 슬라이스 E 육안(사람 사는 곳 같지 않아): 실내 플레이트가
    # bare-wall/빈 카운터로 렌더되던 실측 — 거주 중 서술을 계약에 추가.
    # Codex 재리뷰 BLOCKING: 선두의 무조건 주거 고정('lived-in
    # residential interior'/'real home'/'modest furniture')이 place-type
    # gate 없이 전 node 에 붙던 사각까지 함께 봉합 — 선두=유형 중립
    # real place, 주거·점유 서술=공급 입력(플랜·참조·산문) 확정 조건부,
    # 확정된 다른 place type/점유/연식/상태는 계약 전체(entire style
    # contract)에 우선. 연식 고정 없음, 객체 열거 0(무엇으로 보일지는
    # 이미지 위임 — 유형 중립).
    "STYLE CONTRACT — an ordinary, modest, real place, exactly as the "
    "supplied plan, reference images and prose establish it. Treat the "
    "result as an actual reference photograph of that real place: "
    "everyday materials, believable wear, plain practical furnishings "
    "appropriate to the place's established type and use, natural "
    "daylight or simple ceiling lighting. "
    "Strictly avoid luxury, showroom, hotel-suite, boutique-design, "
    "high-end-retail, or magazine-staging styling. "
    "No marble surfaces, no chandeliers, no designer furniture, no polished "
    "decor displays, no curated styling props, no glossy magazine finish. "
    "When the supplied inputs establish a currently occupied dwelling, "
    "its rooms carry the ordinary, unstaged traces of the residents' "
    "daily life, accumulated naturally on surfaces and in corners — "
    "never a bare, just-moved-in or showroom-empty room — and which "
    "particular belongings show this is the image's own choice. "
    "Whenever the supplied inputs establish a different place type, "
    "occupancy state, age or condition, that established fact wins over "
    "every default in this entire style contract."
)


_MODE_GUIDANCE: Dict[str, str] = {
    # E2E13 fix⑤: 마커가 렌더에 그대로 박히던 실측(L05B01 롤 2/3 실격 →
    # 오염 풀에서 구조 반전 롤이 강제 당선) — 약한 'not as overlay
    # material' 문구를 명시적 렌더 금지 계약으로 강화.
    "fp_seeded_anchor": (
        "Use the supplied base floor plan as the only visual reference. "
        "Render a fresh photoreal interior under the modest style "
        "contract. The base plan's numbered markers, labels, arrows and "
        "diagram graphics are planning aids ONLY — the rendered "
        "photograph must contain NONE of them: no numerals, no letters, "
        "no circles or arrows, no diagram lines of any kind anywhere in "
        "the image. Treat the markers purely as invisible spatial "
        "guidance."
    ),
    "reference_derived": (
        "Inherit the visible identity of this dwelling from the supplied "
        "prior background reference images. Match floor, wall, window, "
        "lighting, and fixed-furniture palette so the space reads as the "
        "same home; do not redraw any transient cue already present in "
        "the references."
    ),
    "same_physical_space_view": (
        "The supplied reference image shows the SAME physical room as this "
        "background, from a different camera angle or framing. Inherit the "
        "exact architecture, floor, walls, windows, fixed furniture and "
        "lighting of that room — this is literally the same space, only "
        "the viewpoint changes."
    ),
    "related_style_new_space": (
        "The supplied reference image shows a related part of the same "
        "dwelling, not the current room. Inherit the overall material "
        "and lighting identity only; the new space is its own "
        "architectural room and must read as the same home."
    ),
    "two_refs_distinct_spaces": (
        "The two supplied references depict different visible spaces "
        "within the same dwelling that are co-visible from the planned "
        "camera. Inherit the dwelling's shared material and lighting "
        "identity from both, but keep each room's architecture distinct "
        "as the references show."
    ),
}


class ShotAwareRenderAdapterError(Exception):
    """Fail-closed signal from the shot-aware render adapter."""


@dataclass
class MaterializedShotAwareDecision:
    """Concrete render-ready record for one plan node.

    Persisted on ``data.groups[bg_id]`` by the step layer as audit /
    downstream-readable fields (``reference_decision``,
    ``camera_decision``, ``reference_guidance_prefix``,
    ``render_guidance``, …).
    """

    bg_id: str
    fp_id: str
    node_index: int
    mode: str
    is_dwelling_identity_anchor: bool
    rationale: str
    reference_paths: List[str]
    source_bg_ids: List[str]
    fp_included: bool
    reference_decision: Dict[str, Any]
    camera_decision: Dict[str, Any]
    render_guidance: Dict[str, str]
    reference_guidance_prefix: str
    diagnostics: List[str] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "bg_id": self.bg_id,
            "fp_id": self.fp_id,
            "node_index": self.node_index,
            "mode": self.mode,
            "is_dwelling_identity_anchor": self.is_dwelling_identity_anchor,
            "rationale": self.rationale,
            "reference_paths": list(self.reference_paths),
            "source_bg_ids": list(self.source_bg_ids),
            "fp_included": self.fp_included,
            "reference_decision": dict(self.reference_decision),
            "camera_decision": dict(self.camera_decision),
            "render_guidance": dict(self.render_guidance),
            "reference_guidance_prefix": self.reference_guidance_prefix,
            "diagnostics": list(self.diagnostics),
        }


def is_plan_production_clear(plan: Any) -> bool:
    """Gate — caller MUST check this before reading the plan.

    Both conditions are required, both with **exact identity** checks
    (no truthy-coercion — fail-closed contract):
      - ``shot_aware_bg_render_plan_status == "ok"`` — the plan's LLM
        output passed every validator inside W20B.
      - ``production_clear is True`` — readback was not synthetic and
        validators all passed. A string ``"true"``, integer ``1``, or
        any non-empty list / dict does NOT pass the gate; the upstream
        producer (``shot_aware_bg_render_plan.build_render_plan_for_fp``)
        only ever writes a Python boolean here, so any non-bool value
        is a contract violation we surface as ``False``.

    Anything else (non-dict, missing keys, wrong types) → False.
    """
    if not isinstance(plan, dict):
        return False
    if plan.get("shot_aware_bg_render_plan_status") != "ok":
        return False
    return plan.get("production_clear") is True


def ordered_nodes(plan: Any) -> List[Dict[str, Any]]:
    """Return the plan's graph nodes sorted by ``node_index`` ascending.

    W21B Phase 2 exception: in a dwelling_zone_map plan (a node carries
    ``render_action_source == dwelling_zone_map``) the fresh anchors are ordered
    BEFORE their reuse aliases (stable secondary sort — node_index still breaks
    ties) so the consumer renders a zone's plate before any alias reuses it. The
    legacy / partition path keeps pure ``node_index`` order.

    Plans / graphs without nodes → empty list. Non-dict entries inside
    ``graph.nodes`` are a hard error (the W20B validator should have
    already caught this; the adapter mirrors that contract).
    """
    if not isinstance(plan, dict):
        return []
    graph = plan.get("graph") or {}
    nodes = graph.get("nodes") or []
    out: List[Dict[str, Any]] = []
    for idx, n in enumerate(nodes):
        if not isinstance(n, dict):
            raise ShotAwareRenderAdapterError(
                f"plan.graph.nodes[{idx}] is not a dict"
            )
        out.append(n)
    out.sort(key=lambda n: int(n.get("node_index", 0)))
    # W21B Phase 2: a dwelling_zone_map plan may place a clean anchor
    # (render_new) at a HIGHER node_index than its zone aliases (anchor
    # selection is clean-first, NOT node-index-ordered). The consumer
    # (background_render) renders in this queue order and a reuse needs its
    # target's plate already rendered, so within a zone-map plan the fresh
    # anchors must come before the reuse aliases (stable sort — the node_index
    # sort above already broke ties). Scoped to the zone-map source so the
    # legacy / partition path stays byte-identical (its reuse targets are
    # earlier by DAG construction, so this would be a no-op there regardless).
    if any(
        n.get("render_action_source") == RENDER_ACTION_SOURCE_ZONE_MAP
        for n in out
    ):
        out.sort(key=lambda n: 0 if n.get("needs_new_plate") else 1)
    return out


def _coerce_selected_refs(
    *, bg_id: str, ref_decision: Dict[str, Any]
) -> List[Dict[str, Any]]:
    raw = ref_decision.get("selected_refs") or []
    if not isinstance(raw, list):
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} reference_decision.selected_refs is not a list"
        )
    out: List[Dict[str, Any]] = []
    for i, r in enumerate(raw):
        if not isinstance(r, dict):
            raise ShotAwareRenderAdapterError(
                f"bg_id={bg_id!r} selected_refs[{i}] is not a dict"
            )
        rid = r.get("ref_bg_id")
        if not isinstance(rid, str) or not rid:
            raise ShotAwareRenderAdapterError(
                f"bg_id={bg_id!r} selected_refs[{i}].ref_bg_id missing or "
                f"not a string"
            )
        out.append(r)
    return out


def _coerce_render_guidance(
    *, bg_id: str, node: Dict[str, Any]
) -> Dict[str, str]:
    """Pass-through copy of the LLM-emitted ``render_guidance`` block.

    Every field listed in :data:`RENDER_GUIDANCE_FIELDS` must be present
    and a non-empty string. No semantic parsing — the adapter only
    enforces shape so a malformed plan never reaches the renderer.
    Extra keys are rejected (the prompt-pack schema is closed).
    """
    raw = node.get("render_guidance")
    if not isinstance(raw, dict):
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} render_guidance is not a dict"
        )
    extra_keys = sorted(set(raw.keys()) - set(RENDER_GUIDANCE_FIELDS))
    if extra_keys:
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} render_guidance has unknown keys "
            f"{extra_keys}; allowed={list(RENDER_GUIDANCE_FIELDS)}"
        )
    out: Dict[str, str] = {}
    for fname in RENDER_GUIDANCE_FIELDS:
        value = raw.get(fname)
        if not isinstance(value, str) or not value.strip():
            raise ShotAwareRenderAdapterError(
                f"bg_id={bg_id!r} render_guidance.{fname} must be "
                f"non-empty string"
            )
        out[fname] = value
    return out


def _format_camera_section(
    *, bg_id: str, camera_decision: Dict[str, Any]
) -> str:
    """Deterministic camera-decision block for the guidance prefix.

    The exact LLM-validated camera_decision fields are echoed verbatim,
    one ``key: value`` line per required field, ``framing_notes`` only
    when present and non-empty. No re-formatting / re-interpretation.
    """
    missing = [
        f for f in _CAMERA_REQUIRED_FIELDS if f not in camera_decision
    ]
    if missing:
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} camera_decision missing fields {missing}"
        )
    lines = ["CAMERA DECISION (exact, from validated plan):"]
    for fname in _CAMERA_REQUIRED_FIELDS:
        lines.append(f"  - {fname}: {camera_decision[fname]!r}")
    for fname in _CAMERA_OPTIONAL_FIELDS:
        value = camera_decision.get(fname)
        if isinstance(value, str) and value.strip():
            lines.append(f"  - {fname}: {value!r}")
    return "\n".join(lines)


def _format_reference_section(
    *,
    mode: str,
    selected_refs: List[Dict[str, Any]],
    fp_included: bool,
) -> str:
    """Deterministic reference-plan summary for the guidance prefix.

    Echoes the chosen mode + the LLM-emitted ``ref_bg_id`` /
    ``physical_space_id`` exact strings. ``fp_seeded_anchor`` is
    surfaced explicitly when the base FP is the only reference.
    """
    lines = [f"REFERENCE PLAN: mode={mode!r}"]
    if fp_included:
        lines.append("  - reference: base floor plan (anchor)")
    if selected_refs:
        for i, r in enumerate(selected_refs):
            ref_bg_id = r.get("ref_bg_id")
            psi = r.get("physical_space_id")
            lines.append(
                f"  - selected_refs[{i}]: ref_bg_id={ref_bg_id!r} "
                f"physical_space_id={psi!r}"
            )
    else:
        if not fp_included:
            lines.append("  - selected_refs: (none)")
    return "\n".join(lines)


def _format_render_guidance_section(
    *, render_guidance: Dict[str, str]
) -> str:
    """Deterministic render-guidance block (exact LLM strings)."""
    lines = ["RENDER GUIDANCE (from validated plan):"]
    for fname in RENDER_GUIDANCE_FIELDS:
        lines.append(f"  - {fname}: {render_guidance[fname]}")
    return "\n".join(lines)


# E2E11 ② (L13B01): CAMERA DECISION(기하)과 저작 산문(t2i)의 시점이
# 모순인 채 concatenation 되면 이미지 모델이 두 시점을 융합해 불가능
# 위상(내려갔다 다시 오르는 계단)을 생성 — 우선순위를 명시 선언한다.
_VIEW_AUTHORITY_CLAUSE: str = (
    "VIEW AUTHORITY: the CAMERA DECISION and RENDER GUIDANCE above are "
    "the sole authority for camera position, camera height and view "
    "direction. If any prose below implies a different vantage point or "
    "an opposite view direction, keep this committed view and restage "
    "that prose's content inside it — never blend the two views into "
    "one impossible geometry."
)


def select_structure_facts(
    numbered_elements: Sequence[Mapping[str, Any]],
    use_numbered_elements: Optional[Sequence[Any]] = None,
) -> List[Dict[str, Any]]:
    """bg별 STRUCTURE FACTS 선별 (Codex 리뷰 BLOCKING-1).

    floor_plan_prompt 계약: numbered_elements 는 base_*(영구 구조)와
    state_overlay_*(일시 상태 — 도면에도 그리지 않는 항목)를
    base_layer_decision 으로 분리하고, bg별 use/ignore_numbered_elements
    exact integer join 을 하류 계약으로 정의한다. STRUCTURE FACTS 는
    **base_* ∩ 해당 bg 의 use 목록** 만 — transient 는 기존 overlay
    소비부(floor_plan_overlay_payload/background_prompt) 관할 유지.

    use_numbered_elements=None(해당 bg 의 camera_recommendation 부재) =
    base_* 전체(구조 식별 사실은 bg 무관 유효 — join 불가 시 보수 포함).

    exact join = **정수 항등**(Codex 재리뷰 NARROW-2): malformed LLM 값
    (bool/str/fractional float)은 양쪽 모두 default-deny — int() 절삭으로
    1.9↔1.1 이 매칭되던 오염 경로 차단.
    """
    def _exact_int(v: Any) -> Optional[int]:
        if isinstance(v, bool) or not isinstance(v, int):
            return None
        return v

    use_set: Optional[set] = None
    if use_numbered_elements is not None:
        use_set = {
            n for n in (
                _exact_int(v) for v in use_numbered_elements
            ) if n is not None
        }
    out: List[Dict[str, Any]] = []
    for el in numbered_elements:
        if not isinstance(el, Mapping):
            continue
        if not str(el.get("base_layer_decision") or "").startswith("base_"):
            continue
        if use_set is not None:
            num = _exact_int(el.get("number"))
            if num is None or num not in use_set:
                continue
        out.append(dict(el))
    return out


def _format_structure_facts_section(
    *, structure_facts: Sequence[Mapping[str, Any]],
) -> str:
    """STRUCTURE FACTS 블록 (E2E11 ② — L04B01/02 계단 누락 실측).

    floor_plan_prompt ``numbered_elements``(dossier 파생 SOT)의 category/
    label/position_hint 를 **원문 그대로** 나열 — 의미 파싱·substring 판단
    없음(글자 판단 금지 계약). 계약문: 뷰 안에 위치가 들어오는 요소는
    반드시 존재, 연결·상하(승강) 방향·상대 배치 반전/미러 금지, RENDER
    GUIDANCE 가 뷰에서 배제한 요소는 생략 가능. 생성·판정·critique 가
    prompt 를 공유하므로 검출 축을 겸한다.
    """
    lines = [
        "STRUCTURE FACTS (floor-plan truth for this location): every "
        "element listed below whose stated position falls inside the "
        "committed view MUST appear, with its position, connections and "
        "vertical (up/down) direction exactly as stated — never mirror, "
        "reverse or omit it. Elements the RENDER GUIDANCE excludes from "
        "this view may be left out."
    ]
    for el in structure_facts:
        if not isinstance(el, Mapping):
            continue
        label = str(el.get("label") or "").strip()
        if not label:
            continue
        num = el.get("number")
        category = str(el.get("category") or "").strip()
        hint = str(el.get("position_hint") or "").strip()
        head = f"  - {num}. " if num is not None else "  - "
        body = f"[{category}] {label}" if category else label
        lines.append(head + body + (f" — {hint}" if hint else ""))
    if len(lines) == 1:
        return ""  # 유효 요소 0 — 계약문만 남는 dangling 차단
    return "\n".join(lines)


def materialize_decision(
    *,
    node: Dict[str, Any],
    fp_id: str,
    base_fp_png_str: Optional[str],
    catalog: Mapping[str, str],
    validate_reference_catalog: bool = True,
    structure_facts: Optional[Sequence[Mapping[str, Any]]] = None,
) -> MaterializedShotAwareDecision:
    """Concrete render inputs for a single plan node.

    ``catalog`` is a ``bg_id -> absolute_png_path`` mapping built by
    the caller from previously rendered nodes in this fp's queue.
    For ``fp_seeded_anchor`` the catalog is ignored (the FP is the
    only reference). For every other mode, every ``ref_bg_id`` that
    the LLM selected MUST already be present in the catalog —
    otherwise this raises and the caller fails the bg.

    ``validate_reference_catalog`` (default ``True`` — legacy/byte-identical):
    when ``False`` the selected_refs shape is still validated (count / mode
    rules / metadata), but a ``ref_bg_id`` missing from the ``catalog`` is
    SKIPPED rather than raising. This is for the W21B-w4 #4(C) substrate
    consumer, which owns reference resolution (FP + ref_tree_parents with a
    missing-parent graceful drop) and only needs the prompt prefix / node-shape
    gate from this helper — never its ``reference_paths``.
    """
    if not isinstance(node, dict):
        raise ShotAwareRenderAdapterError("node is not a dict")

    bg_id = node.get("bg_id")
    if not isinstance(bg_id, str) or not bg_id:
        raise ShotAwareRenderAdapterError("node.bg_id missing")

    mode = node.get("mode")
    if mode not in ALLOWED_MODES:
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} unknown mode={mode!r}"
        )

    ref_decision_raw = node.get("reference_decision") or {}
    if not isinstance(ref_decision_raw, dict):
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} reference_decision is not a dict"
        )
    selected_refs = _coerce_selected_refs(
        bg_id=bg_id, ref_decision=ref_decision_raw
    )

    if len(selected_refs) > MAX_REFS_PER_BG:
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} selected_refs count={len(selected_refs)} "
            f"exceeds max={MAX_REFS_PER_BG}"
        )

    reference_paths: List[str] = []
    source_bg_ids: List[str] = []
    fp_included = False

    if mode == ANCHOR_MODE:
        if selected_refs:
            raise ShotAwareRenderAdapterError(
                f"bg_id={bg_id!r} fp_seeded_anchor must have empty "
                f"selected_refs (got {len(selected_refs)})"
            )
        if not base_fp_png_str:
            raise ShotAwareRenderAdapterError(
                f"bg_id={bg_id!r} fp_seeded_anchor requires a resolved "
                f"base FP png path"
            )
        fp_included = True
        reference_paths = [base_fp_png_str]
    else:
        if not selected_refs:
            raise ShotAwareRenderAdapterError(
                f"bg_id={bg_id!r} mode={mode!r} requires at least one "
                f"selected_ref"
            )
        if mode == TWO_REFS_MODE and len(selected_refs) != 2:
            raise ShotAwareRenderAdapterError(
                f"bg_id={bg_id!r} two_refs_distinct_spaces requires "
                f"exactly 2 selected_refs (got {len(selected_refs)})"
            )
        for r in selected_refs:
            rid = r["ref_bg_id"]
            png_path = catalog.get(rid)
            if not png_path:
                if validate_reference_catalog:
                    raise ShotAwareRenderAdapterError(
                        f"bg_id={bg_id!r} ref_bg_id={rid!r} not in catalog "
                        f"(catalog keys={sorted(catalog.keys())})"
                    )
                # substrate consumer owns reference resolution — skip the
                # unresolved ref here (its reference_paths is unused downstream).
                continue
            reference_paths.append(png_path)
            source_bg_ids.append(rid)

    camera_raw = node.get("camera_decision") or {}
    if not isinstance(camera_raw, dict):
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} camera_decision is not a dict"
        )

    render_guidance = _coerce_render_guidance(bg_id=bg_id, node=node)

    reference_section = _format_reference_section(
        mode=mode,
        selected_refs=selected_refs,
        fp_included=fp_included,
    )
    camera_section = _format_camera_section(
        bg_id=bg_id, camera_decision=camera_raw
    )
    render_guidance_section = _format_render_guidance_section(
        render_guidance=render_guidance
    )

    guidance = (
        _STYLE_CONTRACT_PREAMBLE
        + "\n\n"
        + _MODE_GUIDANCE[mode]
        + "\n\n"
        + reference_section
        + "\n\n"
        + camera_section
        + "\n\n"
        + render_guidance_section
        + "\n\n"
    )
    # E2E11 ②: structure_facts=None(legacy 호출)=byte-identical. 전달 시
    # STRUCTURE FACTS(비어 있지 않을 때) + VIEW AUTHORITY(상시 — 기하·산문
    # 시점 모순 우선순위) 를 prefix 말미에 병기.
    if structure_facts is not None:
        facts_section = (
            _format_structure_facts_section(structure_facts=structure_facts)
            if structure_facts else ""
        )
        if facts_section:
            guidance += facts_section + "\n\n"
        guidance += _VIEW_AUTHORITY_CLAUSE + "\n\n"

    node_index_raw = node.get("node_index", 0)
    try:
        node_index = int(node_index_raw)
    except (TypeError, ValueError) as exc:
        raise ShotAwareRenderAdapterError(
            f"bg_id={bg_id!r} node_index not coercible to int: "
            f"{node_index_raw!r}"
        ) from exc

    return MaterializedShotAwareDecision(
        bg_id=bg_id,
        fp_id=fp_id,
        node_index=node_index,
        mode=mode,
        is_dwelling_identity_anchor=bool(
            node.get("is_dwelling_identity_anchor", False)
        ),
        rationale=str(node.get("rationale") or ""),
        reference_paths=reference_paths,
        source_bg_ids=source_bg_ids,
        fp_included=fp_included,
        reference_decision=dict(ref_decision_raw),
        camera_decision=dict(camera_raw),
        render_guidance=render_guidance,
        reference_guidance_prefix=guidance,
        diagnostics=[],
    )
