"""W20C: shot_aware_bg_render_adapter pure-function tests.

Pure deterministic. No LLM / image / VLM / DB / ImageAsset write. The
adapter is the *materialization* layer between the W20B
``shot_aware_bg_render_plan`` LLM output and the W19B-3
``background_render`` image-call site:

- ``is_plan_production_clear``: gate.
- ``ordered_nodes``: node_index ascending queue.
- ``materialize_decision``: per-node concrete refs / fp / prompt prefix.

The adapter is intentionally narrow — no top-K / no scoring / no
semantic parse / no fallback. Every invariant violation is a
``ShotAwareRenderAdapterError`` (fail-closed).
"""
from __future__ import annotations

import importlib
import sys
from typing import Any, Dict

import pytest


# ───────────────────────── import surface ──────────────────────────


def test_module_import_does_not_pull_external_clients():
    """Adapter is pure: importing it must not pull openai / litellm /
    sqlalchemy / ImageAsset transitively. We assert by:
      1. dropping the candidate-banned modules from sys.modules.
      2. importing the adapter fresh.
      3. checking those modules are still absent.
    """
    banned = [
        "openai",
        "litellm",
        "sqlalchemy",
        "app.models.project",
    ]
    snapshot: Dict[str, Any] = {}
    for name in banned:
        snapshot[name] = sys.modules.pop(name, None)
    # Also drop the adapter so we re-trigger its import.
    sys.modules.pop(
        "app.modules.pipeline.shot_aware_bg_render_adapter", None
    )
    try:
        importlib.import_module(
            "app.modules.pipeline.shot_aware_bg_render_adapter"
        )
        for name in banned:
            assert name not in sys.modules, (
                f"adapter import unexpectedly pulled {name!r}"
            )
    finally:
        for name, mod in snapshot.items():
            if mod is not None:
                sys.modules[name] = mod


# ───────────────────────── fixtures ────────────────────────────────


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


def _render_guidance(suffix: str = "") -> Dict[str, str]:
    return {f: f"directive for {f}{suffix}" for f in _RENDER_GUIDANCE_FIELDS}


def _node(
    *,
    bg_id: str,
    node_index: int,
    mode: str,
    selected_refs=None,
    psi_per_ref=None,
    same_space_dedup="single_ref",
    why="reason",
    is_anchor: bool = False,
    rationale: str = "non-empty rationale",
    camera: Dict[str, Any] = None,
    render_guidance: Dict[str, str] = None,
) -> Dict[str, Any]:
    if selected_refs is None:
        selected_refs = []
    if psi_per_ref is None:
        psi_per_ref = [r.get("physical_space_id", "") for r in selected_refs]
    return {
        "bg_id": bg_id,
        "node_index": node_index,
        "mode": mode,
        "is_dwelling_identity_anchor": is_anchor,
        "rationale": rationale,
        "reference_decision": {
            "selected_refs": list(selected_refs),
            "rejected_refs": [],
            "same_physical_space_dedup_decision": same_space_dedup,
            "why_single_ref_or_two_refs": why,
            "physical_space_id_per_ref": list(psi_per_ref),
        },
        "camera_decision": camera or {
            "camera_unit": 1,
            "camera_cell": [0, 0],
            "look_at_unit": 2,
            "look_at_cell": [2, 0],
            "lens_enum": "normal",
            "fov_deg": 50,
            "framing_notes": "fixture framing",
        },
        "render_guidance": (
            render_guidance
            if render_guidance is not None
            else _render_guidance(f" ({bg_id})")
        ),
    }


def _anchor_node(bg_id="L01B01") -> Dict[str, Any]:
    return _node(
        bg_id=bg_id,
        node_index=0,
        mode="fp_seeded_anchor",
        selected_refs=[],
        psi_per_ref=[],
        is_anchor=True,
        rationale="anchor for this dwelling",
    )


def _derived_node(
    bg_id="L01B02",
    *,
    ref_bg_id="L01B01",
    psi="primary_unit_space",
    node_index=1,
) -> Dict[str, Any]:
    return _node(
        bg_id=bg_id,
        node_index=node_index,
        mode="reference_derived",
        selected_refs=[
            {
                "ref_bg_id": ref_bg_id,
                "physical_space_id": psi,
                "space_description": None,
            }
        ],
        psi_per_ref=[psi],
    )


def _style_node(
    bg_id="L01B03",
    *,
    ref_bg_id="L01B01",
    psi="primary_unit_space",
    node_index=2,
) -> Dict[str, Any]:
    return _node(
        bg_id=bg_id,
        node_index=node_index,
        mode="related_style_new_space",
        selected_refs=[
            {
                "ref_bg_id": ref_bg_id,
                "physical_space_id": psi,
                "space_description": None,
            }
        ],
        psi_per_ref=[psi],
    )


def _two_refs_node(
    bg_id="L01B04",
    *,
    refs=(("L01B01", "A"), ("L01B02", "B")),
    node_index=3,
) -> Dict[str, Any]:
    selected = [
        {
            "ref_bg_id": r[0],
            "physical_space_id": r[1],
            "space_description": None,
        }
        for r in refs
    ]
    return _node(
        bg_id=bg_id,
        node_index=node_index,
        mode="two_refs_distinct_spaces",
        selected_refs=selected,
        psi_per_ref=[r[1] for r in refs],
        same_space_dedup="distinct_visible_spaces",
        why="two co-visible distinct spaces",
    )


def _plan_ok(nodes) -> Dict[str, Any]:
    return {
        "fp_id": "fp_a",
        "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",
        "diagnostics": [],
        "validators": {"all_validators_passed": True, "diagnostics": []},
    }


# ───────────────────────── gate ──────────────────────────────────


def test_gate_passes_only_when_status_ok_and_production_clear():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        is_plan_production_clear,
    )

    assert is_plan_production_clear(_plan_ok([_anchor_node()])) is True


def test_gate_fails_on_non_ok_status():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        is_plan_production_clear,
    )

    bad = _plan_ok([_anchor_node()])
    bad["shot_aware_bg_render_plan_status"] = "failed"
    assert is_plan_production_clear(bad) is False


def test_gate_fails_on_production_clear_false():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        is_plan_production_clear,
    )

    bad = _plan_ok([_anchor_node()])
    bad["production_clear"] = False
    assert is_plan_production_clear(bad) is False


def test_gate_fails_on_missing_keys_or_non_dict():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        is_plan_production_clear,
    )

    assert is_plan_production_clear({}) is False
    assert is_plan_production_clear(None) is False  # type: ignore[arg-type]
    assert is_plan_production_clear([]) is False  # type: ignore[arg-type]


def test_gate_fails_on_truthy_non_bool_production_clear():
    """Codex review #1: ``production_clear`` must be the exact Python
    ``True`` — any truthy-but-non-bool value (string "true", integer 1,
    non-empty list, ...) is a contract violation and the gate must
    fail-closed.  The upstream producer only ever writes a Python bool
    here, so this is a tripwire for an accidental JSON-string round
    trip that demotes the type.
    """
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        is_plan_production_clear,
    )

    base = _plan_ok([_anchor_node()])
    for bad in ("true", "True", "1", 1, [1], {"x": 1}):
        plan = dict(base)
        plan["production_clear"] = bad
        assert is_plan_production_clear(plan) is False, (
            f"non-bool production_clear={bad!r} unexpectedly passed gate"
        )

    # Sanity: an explicit Python True with status='ok' still passes.
    sanity = dict(base)
    sanity["production_clear"] = True
    assert is_plan_production_clear(sanity) is True


# ───────────────────────── ordering ──────────────────────────────


def test_ordered_nodes_sorts_by_node_index_ascending():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ordered_nodes,
    )

    plan = _plan_ok(
        [
            _two_refs_node(node_index=3),
            _anchor_node(),  # node_index=0
            _style_node(node_index=2),
            _derived_node(node_index=1),
        ]
    )
    out = ordered_nodes(plan)
    assert [n["node_index"] for n in out] == [0, 1, 2, 3]
    assert [n["bg_id"] for n in out] == [
        "L01B01", "L01B02", "L01B03", "L01B04",
    ]


def test_ordered_nodes_empty_plan_returns_empty_list():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ordered_nodes,
    )

    assert ordered_nodes(_plan_ok([])) == []
    assert ordered_nodes({"graph": {"nodes": []}}) == []
    assert ordered_nodes({}) == []


def test_ordered_nodes_zone_map_renders_anchor_before_alias():
    """W21B Phase 2: in a dwelling_zone_map plan the clean anchor (render_new)
    may have a HIGHER node_index than its zone aliases (anchor selection is not
    node-index-ordered). background_render renders in this queue order and a
    reuse needs its target's plate already rendered, so the fresh anchor MUST
    precede every alias that reuses it — else reuse_target_missing. The legacy
    pure node_index order is pinned by the test above."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import ordered_nodes

    # aliases B (idx 0) and C (idx 1) both reuse anchor A (idx 2).
    plan = {"graph": {"nodes": [
        {"bg_id": "B", "node_index": 0, "needs_new_plate": False,
         "render_action": "reuse_existing_plate", "reuse_target_bg_id": "A",
         "render_action_source": "dwelling_zone_map"},
        {"bg_id": "C", "node_index": 1, "needs_new_plate": False,
         "render_action": "reuse_existing_plate", "reuse_target_bg_id": "A",
         "render_action_source": "dwelling_zone_map"},
        {"bg_id": "A", "node_index": 2, "needs_new_plate": True,
         "render_action": "render_new_plate", "reuse_target_bg_id": "",
         "render_action_source": "dwelling_zone_map"},
    ]}}
    out = ordered_nodes(plan)
    order = [n["bg_id"] for n in out]
    # anchor first, then aliases in node_index order.
    assert order == ["A", "B", "C"]
    # the anchor precedes every node that reuses it.
    for n in out:
        if n.get("reuse_target_bg_id") == "A":
            assert order.index("A") < order.index(n["bg_id"])


# ───────────────────────── materialize: anchor ──────────────────────


def test_anchor_mode_uses_base_fp_only_no_catalog_refs():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_anchor_node(),
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={"L01B99": "/abs/L01B99.png"},  # ignored for anchor
    )
    assert out.mode == "fp_seeded_anchor"
    assert out.fp_included is True
    assert out.reference_paths == ["/abs/fp_a.png"]
    assert out.source_bg_ids == []
    assert out.is_dwelling_identity_anchor is True
    assert out.reference_guidance_prefix.startswith("STYLE CONTRACT")
    assert (
        "Use the supplied base floor plan"
        in out.reference_guidance_prefix
    )
    assert out.camera_decision["lens_enum"] == "normal"


def test_anchor_mode_fails_without_base_fp_path():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=_anchor_node(),
            fp_id="fp_a",
            base_fp_png_str=None,
            catalog={},
        )
    assert "base FP" in str(exc.value) or "fp_seeded_anchor" in str(exc.value)


def test_anchor_mode_fails_with_non_empty_selected_refs():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bogus = _anchor_node()
    bogus["reference_decision"]["selected_refs"] = [
        {"ref_bg_id": "L01B99", "physical_space_id": "X"}
    ]
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=bogus,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={"L01B99": "/abs/L01B99.png"},
        )
    assert "fp_seeded_anchor" in str(exc.value)


# ───────────────────── materialize: reference_derived ────────────────


def test_derived_mode_uses_catalog_only_excludes_fp():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_derived_node(ref_bg_id="L01B01"),
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",  # MUST NOT be in reference_paths
        catalog={"L01B01": "/abs/L01B01.png"},
    )
    assert out.mode == "reference_derived"
    assert out.fp_included is False
    assert out.reference_paths == ["/abs/L01B01.png"]
    assert out.source_bg_ids == ["L01B01"]
    assert "/abs/fp_a.png" not in out.reference_paths
    assert (
        "Inherit the visible identity" in out.reference_guidance_prefix
    )


def test_derived_mode_fails_when_ref_bg_id_not_in_catalog():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=_derived_node(ref_bg_id="L01B99"),
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={"L01B01": "/abs/L01B01.png"},  # no L01B99
        )
    msg = str(exc.value)
    assert "L01B99" in msg and "catalog" in msg


def test_derived_mode_fails_when_no_selected_refs():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    empty_derived = _derived_node()
    empty_derived["reference_decision"]["selected_refs"] = []
    empty_derived["reference_decision"]["physical_space_id_per_ref"] = []
    with pytest.raises(ShotAwareRenderAdapterError):
        materialize_decision(
            node=empty_derived,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={"L01B01": "/abs/L01B01.png"},
        )


# ──────────────────── materialize: related_style_new_space ──────────


def test_style_mode_uses_catalog_only_excludes_fp():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_style_node(ref_bg_id="L01B01"),
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={"L01B01": "/abs/L01B01.png"},
    )
    assert out.mode == "related_style_new_space"
    assert out.fp_included is False
    assert out.reference_paths == ["/abs/L01B01.png"]
    assert out.source_bg_ids == ["L01B01"]
    assert (
        "related part of the same" in out.reference_guidance_prefix
        or "same home" in out.reference_guidance_prefix
    )


# ──────────────────── materialize: same_physical_space_view ──────────


def test_same_physical_space_view_mode_inherits_exact_room():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    node = _node(
        bg_id="L01B05",
        node_index=3,
        mode="same_physical_space_view",
        selected_refs=[
            {
                "ref_bg_id": "L01B01",
                "physical_space_id": "primary_unit_space",
                "space_description": None,
            }
        ],
        psi_per_ref=["primary_unit_space"],
    )
    out = materialize_decision(
        node=node,
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={"L01B01": "/abs/L01B01.png"},
    )
    assert out.mode == "same_physical_space_view"
    assert out.fp_included is False
    assert out.reference_paths == ["/abs/L01B01.png"]
    assert "same physical room" in out.reference_guidance_prefix.lower()


# ─────────────────── materialize: two_refs_distinct_spaces ────────────


def test_two_refs_mode_uses_two_catalog_refs():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_two_refs_node(refs=(("L01B01", "A"), ("L01B02", "B"))),
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={
            "L01B01": "/abs/L01B01.png",
            "L01B02": "/abs/L01B02.png",
        },
    )
    assert out.mode == "two_refs_distinct_spaces"
    assert out.fp_included is False
    assert out.reference_paths == ["/abs/L01B01.png", "/abs/L01B02.png"]
    assert out.source_bg_ids == ["L01B01", "L01B02"]


def test_two_refs_mode_fails_with_one_ref():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    one = _two_refs_node(refs=(("L01B01", "A"),))
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=one,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={"L01B01": "/abs/L01B01.png"},
        )
    assert "two_refs_distinct_spaces" in str(exc.value)


# ───────────────────────── max refs & invariants ────────────────────


def test_more_than_two_selected_refs_fails():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    node = _two_refs_node(
        refs=(("L01B01", "A"), ("L01B02", "B"), ("L01B03", "C"))
    )
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=node,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={
                "L01B01": "/abs/L01B01.png",
                "L01B02": "/abs/L01B02.png",
                "L01B03": "/abs/L01B03.png",
            },
        )
    assert "max" in str(exc.value).lower() or "2" in str(exc.value)


def test_unknown_mode_fails_closed():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad["mode"] = "freestyle_invented"
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )
    assert "freestyle_invented" in str(exc.value)


def test_missing_ref_bg_id_in_selected_ref_fails():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _derived_node()
    bad["reference_decision"]["selected_refs"] = [
        {"physical_space_id": "X"}  # missing ref_bg_id
    ]
    with pytest.raises(ShotAwareRenderAdapterError):
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={"L01B01": "/abs/L01B01.png"},
        )


def test_missing_bg_id_fails():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad.pop("bg_id")
    with pytest.raises(ShotAwareRenderAdapterError):
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )


# ─────────────────── pass-through + serialization ────────────────────


def test_to_dict_round_trips_audit_fields():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_derived_node(ref_bg_id="L01B01"),
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={"L01B01": "/abs/L01B01.png"},
    )
    d = out.to_dict()
    assert d["bg_id"] == "L01B02"
    assert d["fp_id"] == "fp_a"
    assert d["node_index"] == 1
    assert d["mode"] == "reference_derived"
    assert d["fp_included"] is False
    assert d["reference_paths"] == ["/abs/L01B01.png"]
    assert d["source_bg_ids"] == ["L01B01"]
    assert d["camera_decision"]["lens_enum"] == "normal"
    assert "STYLE CONTRACT" in d["reference_guidance_prefix"]
    # reference_decision is pass-through (LLM-emitted).
    assert d["reference_decision"]["selected_refs"][0]["ref_bg_id"] == "L01B01"
    # W20D: render_guidance round-trips inside to_dict.
    assert "render_guidance" in d
    assert isinstance(d["render_guidance"], dict)
    for fname in _RENDER_GUIDANCE_FIELDS:
        assert fname in d["render_guidance"]
        assert isinstance(d["render_guidance"][fname], str)
        assert d["render_guidance"][fname].strip()


# ─────────────── W20D render_guidance + camera exact fields ──────────


def test_anchor_prefix_contains_camera_exact_fields_and_render_guidance():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    node = _anchor_node()
    node["camera_decision"] = {
        "camera_unit": 7,
        "camera_cell": [3, 4],
        "look_at_unit": 2,
        "look_at_cell": [1, 8],
        "lens_enum": "telephoto",
        "fov_deg": 25,
        "framing_notes": "anchor framing for test",
    }
    out = materialize_decision(
        node=node,
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={},
    )
    prefix = out.reference_guidance_prefix
    # Style contract section.
    assert "STYLE CONTRACT" in prefix
    # Mode + reference plan section.
    assert "REFERENCE PLAN" in prefix
    assert "'fp_seeded_anchor'" in prefix
    assert "base floor plan (anchor)" in prefix
    # Camera decision section: every required field, exact-repr.
    assert "CAMERA DECISION" in prefix
    assert "camera_unit: 7" in prefix
    assert "camera_cell: [3, 4]" in prefix
    assert "look_at_unit: 2" in prefix
    assert "look_at_cell: [1, 8]" in prefix
    assert "lens_enum: 'telephoto'" in prefix
    assert "fov_deg: 25" in prefix
    assert "framing_notes: 'anchor framing for test'" in prefix
    # Render guidance section: every field rendered verbatim.
    assert "RENDER GUIDANCE" in prefix
    rg = out.render_guidance
    for fname in _RENDER_GUIDANCE_FIELDS:
        assert rg[fname] in prefix


def test_derived_prefix_lists_selected_refs_with_psi():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_derived_node(ref_bg_id="L01B01", psi="primary_unit_space"),
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={"L01B01": "/abs/L01B01.png"},
    )
    prefix = out.reference_guidance_prefix
    assert "REFERENCE PLAN" in prefix
    assert "'reference_derived'" in prefix
    assert "ref_bg_id='L01B01'" in prefix
    assert "physical_space_id='primary_unit_space'" in prefix
    assert "base floor plan" not in prefix  # FP is NOT included for derived


def test_two_refs_prefix_lists_both_refs():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_two_refs_node(refs=(("L01B01", "A"), ("L01B02", "B"))),
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={
            "L01B01": "/abs/L01B01.png",
            "L01B02": "/abs/L01B02.png",
        },
    )
    prefix = out.reference_guidance_prefix
    assert "'two_refs_distinct_spaces'" in prefix
    assert "ref_bg_id='L01B01'" in prefix
    assert "ref_bg_id='L01B02'" in prefix
    assert "physical_space_id='A'" in prefix
    assert "physical_space_id='B'" in prefix


def test_materialize_fails_when_render_guidance_missing():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad.pop("render_guidance")
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )
    assert "render_guidance" in str(exc.value)


def test_materialize_fails_when_render_guidance_not_dict():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad["render_guidance"] = "not a dict"
    with pytest.raises(ShotAwareRenderAdapterError):
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )


@pytest.mark.parametrize("field", list(_RENDER_GUIDANCE_FIELDS))
def test_materialize_fails_when_render_guidance_field_missing(field):
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad["render_guidance"].pop(field)
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )
    assert field in str(exc.value)


@pytest.mark.parametrize("field", list(_RENDER_GUIDANCE_FIELDS))
def test_materialize_fails_when_render_guidance_field_empty(field):
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad["render_guidance"][field] = "   "
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )
    assert field in str(exc.value)


@pytest.mark.parametrize("field", list(_RENDER_GUIDANCE_FIELDS))
def test_materialize_fails_when_render_guidance_field_wrong_type(field):
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad["render_guidance"][field] = 123
    with pytest.raises(ShotAwareRenderAdapterError):
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )


def test_materialize_fails_when_render_guidance_has_unknown_key():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad["render_guidance"]["wild_directive"] = "x"
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )
    assert "wild_directive" in str(exc.value) or "unknown" in str(exc.value)


def test_render_guidance_strings_pass_through_verbatim():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    custom = {
        "visible_space_directive": "X-visible cue",
        "camera_framing_directive": "X-framing cue",
        "subject_position_directive": "X-subject cue",
        "state_cue_directive": "X-state cue",
        "negative_continuity_directive": "X-negative cue",
    }
    node = _anchor_node()
    node["render_guidance"] = dict(custom)
    out = materialize_decision(
        node=node,
        fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png",
        catalog={},
    )
    assert out.render_guidance == custom
    for v in custom.values():
        assert v in out.reference_guidance_prefix


def test_materialize_fails_when_camera_decision_missing_required_field():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ShotAwareRenderAdapterError,
        materialize_decision,
    )

    bad = _anchor_node()
    bad["camera_decision"].pop("camera_unit")
    with pytest.raises(ShotAwareRenderAdapterError) as exc:
        materialize_decision(
            node=bad,
            fp_id="fp_a",
            base_fp_png_str="/abs/fp_a.png",
            catalog={},
        )
    assert "camera_unit" in str(exc.value) or "missing" in str(exc.value)


# ─────────────── constants surface — locked for downstream contract ──


def test_module_constants_are_locked():
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        ALLOWED_MODES,
        ANCHOR_MODE,
        MAX_REFS_PER_BG,
        RENDER_GUIDANCE_FIELDS,
        TWO_REFS_MODE,
    )

    assert MAX_REFS_PER_BG == 2
    assert ANCHOR_MODE == "fp_seeded_anchor"
    assert TWO_REFS_MODE == "two_refs_distinct_spaces"
    assert ALLOWED_MODES == frozenset(
        {
            "fp_seeded_anchor",
            "reference_derived",
            "same_physical_space_view",
            "related_style_new_space",
            "two_refs_distinct_spaces",
        }
    )
    assert RENDER_GUIDANCE_FIELDS == _RENDER_GUIDANCE_FIELDS


# ── E2E11 ②: STRUCTURE FACTS + VIEW AUTHORITY prefix ────────────────


def _facts():
    return [
        {"number": 1, "category": "area", "label": "lower approach landing",
         "position_hint": "south edge at the base of the exterior stair"},
        {"number": 2, "category": "area", "label": "upper stair flight",
         "position_hint": "ascending southward from the landing"},
        {"number": 9, "category": "opening", "label": "stair access landing",
         "position_hint": "southwest edge of the roof deck"},
    ]


def test_structure_facts_none_is_byte_identical():
    """legacy 호출(structure_facts 미전달/None) = 기존 prefix byte-identical."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    base = materialize_decision(
        node=_anchor_node(), fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png", catalog={},
    )
    explicit_none = materialize_decision(
        node=_anchor_node(), fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png", catalog={},
        structure_facts=None,
    )
    assert (base.reference_guidance_prefix
            == explicit_none.reference_guidance_prefix)
    assert "STRUCTURE FACTS" not in base.reference_guidance_prefix
    assert "VIEW AUTHORITY" not in base.reference_guidance_prefix


def test_structure_facts_section_and_view_authority():
    """전달 시: 요소 원문 나열(존재·연결·승강 방향 lock)+VIEW AUTHORITY.
    카메라/가이던스 섹션 뒤, 저작 산문(prefix 밖) 앞에 위치."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_anchor_node(), fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png", catalog={},
        structure_facts=_facts(),
    )
    p = out.reference_guidance_prefix
    assert "STRUCTURE FACTS" in p
    assert "- 1. [area] lower approach landing — south edge" in p
    assert "- 9. [opening] stair access landing — southwest edge" in p
    # 원문 그대로 (의미 파싱·재서술 없음)
    assert "ascending southward from the landing" in p
    assert "never mirror" in p and "vertical (up/down) direction" in p
    assert "VIEW AUTHORITY" in p
    assert p.index("RENDER GUIDANCE") < p.index("STRUCTURE FACTS")
    assert p.index("STRUCTURE FACTS") < p.index("VIEW AUTHORITY")


def test_structure_facts_empty_list_keeps_view_authority_only():
    """빈 목록(=fp 요소 없음): FACTS 섹션 생략(dangling 금지),
    VIEW AUTHORITY(기하 우선순위)는 유지."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_anchor_node(), fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png", catalog={},
        structure_facts=[],
    )
    p = out.reference_guidance_prefix
    assert "STRUCTURE FACTS" not in p
    assert "VIEW AUTHORITY" in p


def test_structure_facts_invalid_entries_filtered():
    """label 결손/비dict 요소는 조용히 제외 — 전부 무효면 섹션 자체 생략."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        materialize_decision,
    )

    out = materialize_decision(
        node=_anchor_node(), fp_id="fp_a",
        base_fp_png_str="/abs/fp_a.png", catalog={},
        structure_facts=[{"number": 1, "label": "  "}, "not-a-dict", {}],
    )
    p = out.reference_guidance_prefix
    assert "STRUCTURE FACTS" not in p
    assert "VIEW AUTHORITY" in p


def test_select_structure_facts_base_and_use_join():
    """Codex BLOCKING-1: base_* 레이어만 + bg use 목록 exact integer join —
    state_overlay(사용되는 것 포함)는 STRUCTURE FACTS 로 절대 승격 금지."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        select_structure_facts,
    )

    elements = [
        {"number": 1, "base_layer_decision": "base_structural_unit",
         "category": "area", "label": "deck"},
        {"number": 2, "base_layer_decision": "base_opening",
         "category": "opening", "label": "stair access"},
        {"number": 3, "base_layer_decision": "base_persistent_fixture",
         "category": "prop", "label": "water tank"},
        # 이 bg 가 use 로 소비하는 transient — 그래도 FACTS 제외
        {"number": 11, "base_layer_decision": "state_overlay_plot_cue",
         "category": "state", "label": "attacker silhouette"},
        # ignore 되는 transient
        {"number": 12, "base_layer_decision": "state_overlay_transient_object",
         "category": "state", "label": "police responders"},
    ]
    out = select_structure_facts(elements, [1, 2, 11])
    assert [e["number"] for e in out] == [1, 2]  # base ∩ use — 3 은 use 밖
    # use 목록 부재(cr 없음) = base_* 전체 보수 포함, overlay 는 여전히 제외
    out2 = select_structure_facts(elements, None)
    assert [e["number"] for e in out2] == [1, 2, 3]
    # 방어: bool/비수치 number, 비 dict 항목
    out3 = select_structure_facts(
        [{"number": True, "base_layer_decision": "base_opening",
          "label": "x"}, "junk"], [1])
    assert out3 == []


def test_select_structure_facts_exact_int_join_denies_fractional():
    """Codex 재리뷰 NARROW-2: exact join=정수 항등 — fractional float 절삭
    매칭(1.9↔1.1) 금지, malformed(str/bool/float) 양쪽 default-deny."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        select_structure_facts,
    )

    els = [
        {"number": 1.9, "base_layer_decision": "base_opening", "label": "a"},
        {"number": 2, "base_layer_decision": "base_opening", "label": "b"},
        {"number": "3", "base_layer_decision": "base_opening", "label": "c"},
        {"number": 4.0, "base_layer_decision": "base_opening", "label": "d"},
    ]
    # 1.9 vs use [1.1] — int() 절삭이면 매칭됐을 조합: 이제 둘 다 deny
    assert select_structure_facts(els, [1.1]) == []
    out = select_structure_facts(els, [2, "5", 4.0, True])
    assert [e["number"] for e in out] == [2]  # int 항등만 — 4.0/"5"/True deny
    # use=None(보수 포함)은 레이어 필터만 — number 형식 무관 base 전체
    assert len(select_structure_facts(els, None)) == 4


def test_fp_anchor_guidance_bans_marker_rendering():
    """E2E13 fix⑤: 평면도 마커(숫자·라벨)가 렌더에 유입되던 실측(L05B01
    롤 2/3 실격→오염 풀) — fp_seeded_anchor 가이던스가 마커 렌더 금지를
    명시적으로 계약해야 한다."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        _MODE_GUIDANCE,
    )

    g = _MODE_GUIDANCE["fp_seeded_anchor"]
    assert "NONE of them" in g
    assert "no numerals" in g
    assert "invisible spatial guidance" in g


def test_style_contract_states_current_occupancy():
    """슬라이스 E 육안(사람 사는 곳 같지 않아): 실내 플레이트가
    bare-wall/빈 카운터 sterile 로 렌더되던 실측 — 계약이 거주 흔적을
    서술하되(무엇으로 보일지는 이미지 위임, 객체 열거 0), Codex
    재리뷰 BLOCKING 반영: 선두=유형 중립 real place(무조건 주거 고정
    없음), 주거·점유 서술=공급 입력 확정 조건부, 확정된 다른 place
    type/점유/연식/상태는 계약 전체에 우선, 연식 고정 문구 없음."""
    from app.modules.pipeline.shot_aware_bg_render_adapter import (
        _STYLE_CONTRACT_PREAMBLE,
    )

    p = _STYLE_CONTRACT_PREAMBLE
    # 선두 유형 중립 — 무조건 주거 고정 문구 부재
    assert p.startswith("STYLE CONTRACT — an ordinary, modest, real place")
    assert "residential interior" not in p
    assert "real home" not in p
    assert "modest furniture" not in p
    # 주거·점유 서술은 공급 입력 확정 조건부
    assert ("When the supplied inputs establish a currently occupied "
            "dwelling") in p
    assert "never a bare" in p
    assert "image's own choice" in p
    # 확정된 다른 사실이 계약 전체에 우선 (빈집·신축·비주거 발명 금지)
    assert ("that established fact wins over "
            "every default in this entire style contract") in p
    # 근거 없는 연식 고정 금지
    assert "over years of use" not in p
    # 기존 luxury 회피 계약 승계
    assert "Strictly avoid luxury" in p
