"""TDD invariants for experiment_background_spatial_decision (dry-run, W3).

plan: scripts_output/background_spatial_decision_experiment/plan.md
- 6 enum decision_status, 4 enum decision_type, 9 generic rules.
- 입력 = W2e run dir (artifact only, DB read 없음).
- production code 0 수정, DB write 0, image/API 0.

사용자 standing rule (2026-05-24): 특정 시나리오 기반 코딩 금지. fixture data
는 W2e artifact 또는 합성 spec/JSON 으로만. rule body 에 sample label literal 0.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

import pytest

_REPO_ROOT = Path(__file__).resolve().parents[3]
_SCRIPTS = _REPO_ROOT / "backend" / "scripts"
if str(_SCRIPTS) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS))

import experiment_background_spatial_decision as bsd  # noqa: E402


# ---------------------------------------------------------------------------
# Synthetic fixtures (generic — no sample vocabulary)
# ---------------------------------------------------------------------------
def _synth_artifacts(
        space_nodes=None,
        state_layers=None,
        structural_versions=None,
        shot_bindings=None,
        generation_unit_plan=None,
        chain_bg_decomposition=None,
        run_meta=None,
        ) -> "bsd.W2EArtifacts":
    return bsd.W2EArtifacts(
        space_nodes=space_nodes or [],
        state_layers=state_layers or [],
        structural_versions=structural_versions or [],
        shot_bindings=shot_bindings or [],
        generation_unit_plan=generation_unit_plan or {
            "master_units": [],
            "derived_state_units": [],
            "derived_camera_units": [],
            "rejected_cross_product": [],
        },
        chain_bg_decomposition=chain_bg_decomposition or [],
        run_meta=run_meta or {"plan_version": "synth"},
    )


def _space_node(node_id, *, node_type="room", set_group_id="sg_x",
                contained_in=None, active_status="active"):
    return {
        "node_id": node_id, "label": node_id, "node_type": node_type,
        "set_group_id": set_group_id, "contained_in": contained_in,
        "connected_to": [], "visibility_to": [],
        "active_status": active_status, "evidence": [],
    }


def _state_layer(label, *, geometry_preserving=True):
    return {
        "layer_id": f"sl_{label}", "label": label,
        "affects": ["lighting"], "geometry_preserving": geometry_preserving,
        "severity": "low", "applied_shots": [], "evidence": [],
    }


def _master_unit(unit_id, *, space_node, camera_view, state_layer="normal"):
    return {
        "unit_id": unit_id, "space_node": space_node,
        "camera_view": camera_view, "state_layer": state_layer,
    }


def _derived_state(unit_id, *, based_on_master, state_layer):
    return {
        "unit_id": unit_id, "based_on_master": based_on_master,
        "state_layer": state_layer, "camera_view": None,
    }


def _derived_camera(unit_id, *, based_on_master, camera_view):
    return {
        "unit_id": unit_id, "based_on_master": based_on_master,
        "state_layer": None, "camera_view": camera_view,
    }


def _shot_binding(shot_id, *, candidates, state_layers, camera,
                  chosen="resolved", unresolved=None,
                  loc_id="LXX", set_group_id="sg_x"):
    return {
        "shot_id": shot_id, "loc_id": loc_id,
        "set_group_id": set_group_id,
        "space_node_candidates": list(candidates),
        "chosen_policy_candidate": chosen,
        "state_layers": list(state_layers),
        "structural_version": "sv_initial",
        "camera_view_family_candidate": camera,
        "unresolved_reason": unresolved,
    }


# ---------------------------------------------------------------------------
# (1) single active room + normal + default camera → resolved_to_master
# ---------------------------------------------------------------------------
class TestResolveToMaster:
    def test_single_active_normal_default_camera_resolves_master(self):
        artifacts = _synth_artifacts(
            space_nodes=[_space_node("hall_a")],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S1", candidates=["hall_a"],
                state_layers=["sl_normal"], camera="eye_level_wide",
            )],
        )
        decisions, traces, rollups, combos = bsd.build_decision_run(artifacts)
        assert len(decisions) == 1
        d = decisions[0]
        assert d.decision_status == "resolved_to_master", d
        assert d.selected_space_node == "hall_a"
        assert "master_hall_a_wide" in d.bound_unit_ids


# ---------------------------------------------------------------------------
# (2) single active + non-normal → resolved_to_derived_state
# ---------------------------------------------------------------------------
class TestResolveToDerivedState:
    def test_single_active_non_normal_resolves_derived_state(self):
        artifacts = _synth_artifacts(
            space_nodes=[_space_node("hall_a")],
            state_layers=[_state_layer("normal"), _state_layer("foo_state")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [
                    _derived_state("derived_hall_a_state_foo_state",
                                   based_on_master="master_hall_a_wide",
                                   state_layer="foo_state"),
                ],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S2", candidates=["hall_a"],
                state_layers=["sl_foo_state"], camera="eye_level_wide",
            )],
        )
        decisions, *_ = bsd.build_decision_run(artifacts)
        d = decisions[0]
        assert d.decision_status == "resolved_to_derived_state", d
        assert "derived_hall_a_state_foo_state" in d.bound_unit_ids


# ---------------------------------------------------------------------------
# (3) non-normal + non-default camera → requires_derived_combo
# ---------------------------------------------------------------------------
class TestRequiresDerivedCombo:
    def test_non_normal_non_default_camera_requires_combo(self):
        artifacts = _synth_artifacts(
            space_nodes=[_space_node("hall_a")],
            state_layers=[_state_layer("normal"), _state_layer("foo_state")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [
                    _derived_state("derived_hall_a_state_foo_state",
                                   based_on_master="master_hall_a_wide",
                                   state_layer="foo_state"),
                ],
                "derived_camera_units": [
                    _derived_camera("derived_hall_a_camera_macro_close",
                                    based_on_master="master_hall_a_wide",
                                    camera_view="macro_close"),
                ],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S3", candidates=["hall_a"],
                state_layers=["sl_foo_state"], camera="macro_close",
            )],
        )
        decisions, traces, rollups, combos = bsd.build_decision_run(artifacts)
        d = decisions[0]
        assert d.decision_status == "requires_derived_combo", d
        # combo plan emitted.
        assert len(combos) == 1
        assert combos[0].applies_to_shots == ["S3"]
        assert combos[0].state_layer == "sl_foo_state"
        assert combos[0].camera_view == "macro_close"


# ---------------------------------------------------------------------------
# (4) zone/boundary contained → collapse to parent room
# ---------------------------------------------------------------------------
class TestZoneBoundaryCollapse:
    def test_zone_boundary_collapses_to_parent_room(self):
        artifacts = _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("kitchen_corner", node_type="zone",
                            contained_in="hall_a"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S4", candidates=["hall_a", "kitchen_corner"],
                state_layers=["sl_normal"], camera="eye_level_wide",
            )],
        )
        decisions, *_ = bsd.build_decision_run(artifacts)
        d = decisions[0]
        # collapsed to parent hall_a → resolved_to_master.
        assert d.decision_status == "resolved_to_master", d
        assert d.selected_space_node == "hall_a"


# ---------------------------------------------------------------------------
# (5) two independent active rooms → needs_user_decision
# ---------------------------------------------------------------------------
class TestTwoIndependentActiveRooms:
    def test_two_independent_rooms_needs_user_decision(self):
        artifacts = _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("bedroom_b"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                    _master_unit("master_bedroom_b_wide",
                                 space_node="bedroom_b",
                                 camera_view="doorway_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S5", candidates=["hall_a", "bedroom_b"],
                state_layers=["sl_normal"], camera="eye_level_wide",
            )],
        )
        decisions, traces, rollups, _ = bsd.build_decision_run(artifacts)
        d = decisions[0]
        assert d.decision_status == "needs_user_decision", d
        # rollup of decision_type=choose_space_node.
        types = {r.decision_type for r in rollups}
        assert "choose_space_node" in types


# ---------------------------------------------------------------------------
# (6) candidate active_status=needs_decision → needs_user_decision
# ---------------------------------------------------------------------------
class TestNeedsDecisionRoom:
    def test_needs_decision_room_candidate_emits_activate_rollup(self):
        artifacts = _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("bedroom_b", active_status="needs_decision"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S6", candidates=["bedroom_b"],
                state_layers=["sl_normal"], camera="doorway_wide",
            )],
        )
        decisions, traces, rollups, _ = bsd.build_decision_run(artifacts)
        d = decisions[0]
        assert d.decision_status == "needs_user_decision", d
        types = {r.decision_type for r in rollups}
        assert "activate_space_node" in types


# ---------------------------------------------------------------------------
# (7) missing master/unit → impossible_without_topology_update
# ---------------------------------------------------------------------------
class TestImpossibleWithoutTopologyUpdate:
    def test_missing_master_unit_impossible(self):
        artifacts = _synth_artifacts(
            space_nodes=[_space_node("hall_a")],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                # no master units at all.
                "master_units": [],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S7", candidates=["hall_a"],
                state_layers=["sl_normal"], camera="eye_level_wide",
            )],
        )
        decisions, traces, rollups, _ = bsd.build_decision_run(artifacts)
        d = decisions[0]
        assert d.decision_status == "impossible_without_topology_update", d
        types = {r.decision_type for r in rollups}
        assert "topology_update" in types


# ---------------------------------------------------------------------------
# (8) every decision has ≥ 1 rule trace
# ---------------------------------------------------------------------------
class TestEveryDecisionHasRuleTrace:
    def test_every_shot_has_at_least_one_trace(self):
        artifacts = _synth_artifacts(
            space_nodes=[_space_node("hall_a")],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[
                _shot_binding("S8a", candidates=["hall_a"],
                              state_layers=["sl_normal"],
                              camera="eye_level_wide"),
                _shot_binding("S8b", candidates=[],
                              state_layers=["sl_normal"], camera=None),
            ],
        )
        decisions, traces, *_ = bsd.build_decision_run(artifacts)
        # every shot_id 가 traces 에 ≥ 1 등장.
        by_shot: dict[str, int] = {}
        for t in traces:
            by_shot[t.shot_id] = by_shot.get(t.shot_id, 0) + 1
        assert by_shot["S8a"] >= 1
        assert by_shot["S8b"] >= 1


# ---------------------------------------------------------------------------
# (9) static import guard
# ---------------------------------------------------------------------------
class TestStaticImportGuard:
    SCRIPT = _SCRIPTS / "experiment_background_spatial_decision.py"

    def test_no_production_app_import(self):
        body = self.SCRIPT.read_text(encoding="utf-8")
        for line in body.splitlines():
            stripped = line.strip()
            if stripped.startswith("from ") or stripped.startswith("import "):
                assert "from app." not in stripped and "import app." not in stripped, (
                    f"forbidden production app import: {line}"
                )

    def test_no_network_or_image_import(self):
        body = self.SCRIPT.read_text(encoding="utf-8")
        forbidden = ["google.genai", "from openai", "import openai",
                     "fal_client", "import fal ", "from PIL",
                     "requests.post", "httpx.post"]
        for line in body.splitlines():
            stripped = line.strip()
            if not (stripped.startswith("from ") or stripped.startswith("import ")):
                continue
            for forb in forbidden:
                assert forb not in stripped, (
                    f"forbidden import: {line}"
                )


# ---------------------------------------------------------------------------
# (10) source grep guard against sample-specific literal in rule body
# ---------------------------------------------------------------------------
class TestNoSampleSpecificLiteral:
    SCRIPT = _SCRIPTS / "experiment_background_spatial_decision.py"

    FORBIDDEN_LITERALS = [
        "거실", "수리영", "민숙", "안방", "욕실", "현관", "주방코너",
        "옥탑방", "L05", "L04",
    ]

    def test_script_body_has_no_sample_literal(self):
        body = self.SCRIPT.read_text(encoding="utf-8")
        # 단, plan.md path 인용이나 docstring 의 example 안에 등장하면 허용 — 단순
        # source grep 으로는 false positive 가능. 본 test 는 strict 검사.
        for literal in self.FORBIDDEN_LITERALS:
            # docstring 안 sample example 도 금지 (사용자 standing rule).
            # 다만 script 가 sample fixture 와 무관해야 한다는 점 강조.
            assert literal not in body, (
                f"sample-specific literal '{literal}' found in script body — "
                "must be generic engine only"
            )


# ---------------------------------------------------------------------------
# (11) DerivedComboPlan.combo_id unique
# ---------------------------------------------------------------------------
class TestDerivedComboIdUnique:
    def test_combo_ids_unique(self):
        artifacts = _synth_artifacts(
            space_nodes=[_space_node("hall_a")],
            state_layers=[
                _state_layer("normal"),
                _state_layer("foo_state"),
                _state_layer("bar_state"),
            ],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[
                _shot_binding("Sa", candidates=["hall_a"],
                              state_layers=["sl_foo_state"],
                              camera="macro_close"),
                _shot_binding("Sb", candidates=["hall_a"],
                              state_layers=["sl_bar_state"],
                              camera="floor_low"),
            ],
        )
        _, _, _, combos = bsd.build_decision_run(artifacts)
        combo_ids = [c.combo_id for c in combos]
        assert len(combo_ids) == len(set(combo_ids)), (
            f"duplicate combo_ids: {combo_ids}"
        )


# ---------------------------------------------------------------------------
# (12) RollupDecisionRequest ≤ 5 distinct rollup_id (per run)
# ---------------------------------------------------------------------------
class TestRollupCountBounded:
    def test_rollup_distinct_le_5(self):
        # synthetic large shot list mostly needs_user_decision should rollup.
        artifacts = _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("bedroom_b"),
                _space_node("bath_c"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                    _master_unit("master_bedroom_b_wide",
                                 space_node="bedroom_b",
                                 camera_view="doorway_wide"),
                    _master_unit("master_bath_c_wide",
                                 space_node="bath_c",
                                 camera_view="mirror_close"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[
                _shot_binding(f"S{i}",
                              candidates=["hall_a", "bedroom_b"],
                              state_layers=["sl_normal"],
                              camera="eye_level_wide")
                for i in range(20)
            ],
        )
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        distinct_rollup_ids = {r.rollup_id for r in rollups}
        assert len(distinct_rollup_ids) <= 5, (
            f"too many rollups ({len(distinct_rollup_ids)}): "
            f"{distinct_rollup_ids}"
        )


# ---------------------------------------------------------------------------
# Real W2e fixture smoke test
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# W3b BLOCKING 1: rollup aggregation key 세분화
# ---------------------------------------------------------------------------
class TestRollupAggregationByOptions:
    def test_different_option_sets_do_not_merge(self):
        # Two multi-candidate shots with different candidate sets must
        # produce two distinct rollups, NOT a unioned options bucket.
        artifacts = _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("bedroom_b"),
                _space_node("bath_c"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                    _master_unit("master_bedroom_b_wide",
                                 space_node="bedroom_b",
                                 camera_view="doorway_wide"),
                    _master_unit("master_bath_c_close",
                                 space_node="bath_c",
                                 camera_view="mirror_close"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[
                _shot_binding("Sx", candidates=["hall_a", "bedroom_b"],
                              state_layers=["sl_normal"],
                              camera="eye_level_wide"),
                _shot_binding("Sy", candidates=["hall_a", "bath_c"],
                              state_layers=["sl_normal"],
                              camera="eye_level_wide"),
            ],
        )
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        # 2 distinct rollups (different option sets), not 1 unioned.
        choose_rollups = [
            r for r in rollups if r.decision_type == "choose_space_node"
        ]
        assert len(choose_rollups) == 2, (
            f"different option sets must yield distinct rollups, "
            f"got {len(choose_rollups)}: {[r.options for r in choose_rollups]}"
        )
        options_sets = {tuple(sorted(r.options)) for r in choose_rollups}
        assert options_sets == {
            ("bedroom_b", "hall_a"), ("bath_c", "hall_a"),
        }, options_sets


# ---------------------------------------------------------------------------
# W3b BLOCKING 2: R6/R7 priority — multi-candidate with needs_decision
# ---------------------------------------------------------------------------
class TestMultiCandidateWithNeedsDecisionFirst:
    def test_multi_with_needs_decision_emits_choose_space_node(self):
        artifacts = _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("bedroom_b", active_status="needs_decision"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "Sm", candidates=["hall_a", "bedroom_b"],
                state_layers=["sl_normal"], camera="eye_level_wide",
            )],
        )
        decisions, _, rollups, _ = bsd.build_decision_run(artifacts)
        d = decisions[0]
        assert d.decision_status == "needs_user_decision", d
        # rollup must be choose_space_node with BOTH candidates as options.
        types = {r.decision_type for r in rollups}
        assert "choose_space_node" in types, types
        # options 가 두 후보 모두 포함.
        cs = next(r for r in rollups if r.decision_type == "choose_space_node")
        assert "hall_a" in cs.options
        assert "bedroom_b" in cs.options
        # risk/notes 에 activation 필요 메타.
        assert "activate" in cs.risk_if_wrong.lower() or any(
            "activate" in (n or "").lower() for n in (d.decision_notes or [])
        ), (
            "must note that needs_decision candidate requires activation"
        )


# ---------------------------------------------------------------------------
# W3b BLOCKING 3: derived_camera ambiguous family
# ---------------------------------------------------------------------------
class TestDerivedCameraAmbiguous:
    def test_duplicate_derived_camera_family_no_arbitrary_selection(self):
        artifacts = _synth_artifacts(
            space_nodes=[_space_node("hall_a")],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [
                    _derived_camera("derived_hall_a_camera_table_low",
                                    based_on_master="master_hall_a_wide",
                                    camera_view="eye_level_table_close@low"),
                    _derived_camera("derived_hall_a_camera_table_standing",
                                    based_on_master="master_hall_a_wide",
                                    camera_view="eye_level_table_close@standing"),
                ],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "Sd", candidates=["hall_a"],
                state_layers=["sl_normal"],
                camera="eye_level_table_close",
            )],
        )
        decisions, _, rollups, _ = bsd.build_decision_run(artifacts)
        d = decisions[0]
        # ambiguous → needs_user_decision, not arbitrary first selection.
        assert d.decision_status == "needs_user_decision", (
            f"ambiguous derived_camera family must not arbitrarily bind, got {d}"
        )
        # rollup options 는 두 unit_id 또는 full camera_view 둘 다 포함.
        cam_rollups = [
            r for r in rollups
            if "camera" in r.decision_type or "camera" in r.rollup_id.lower()
        ]
        assert cam_rollups, "must emit camera-variant rollup"
        cr = cam_rollups[0]
        assert any("low" in opt for opt in cr.options)
        assert any("standing" in opt for opt in cr.options)


# ---------------------------------------------------------------------------
# W3b IMPORTANT 1: choose_multi_space_policy branch
# ---------------------------------------------------------------------------
class TestMultiSpaceWidePolicyBranch:
    def test_unresolved_reason_multi_space_wide_policy_uses_distinct_decision_type(self):
        artifacts = _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("bedroom_b"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                    _master_unit("master_bedroom_b_wide",
                                 space_node="bedroom_b",
                                 camera_view="doorway_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "Sw", candidates=["hall_a", "bedroom_b"],
                state_layers=["sl_normal"], camera="eye_level_wide",
                unresolved="multi_space_wide_policy",
            )],
        )
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        types = {r.decision_type for r in rollups}
        assert "choose_multi_space_policy" in types, (
            f"binding.unresolved_reason=multi_space_wide_policy must produce "
            f"choose_multi_space_policy decision_type, got types={types}"
        )


# ---------------------------------------------------------------------------
# W3c BLOCKING 1: option_outcomes simulation
# ---------------------------------------------------------------------------
class TestOptionOutcomes:
    def _multi_active_artifacts(self) -> "bsd.W2EArtifacts":
        return _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("bedroom_b"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                    _master_unit("master_bedroom_b_wide",
                                 space_node="bedroom_b",
                                 camera_view="doorway_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S_mc", candidates=["hall_a", "bedroom_b"],
                state_layers=["sl_normal"], camera="eye_level_wide",
            )],
        )

    def test_rollup_has_option_outcomes_field(self):
        artifacts = self._multi_active_artifacts()
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        assert rollups
        for r in rollups:
            assert hasattr(r, "option_outcomes"), (
                "RollupDecisionRequest must expose option_outcomes"
            )

    def test_every_option_has_outcome_per_affected_shot(self):
        artifacts = self._multi_active_artifacts()
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        for r in rollups:
            if r.decision_type not in {
                "choose_space_node", "choose_multi_space_policy",
                "choose_camera_variant",
            }:
                continue
            outcome_options = {o["option"] for o in r.option_outcomes}
            for opt in r.options:
                assert opt in outcome_options, (
                    f"rollup {r.rollup_id} missing outcome for option {opt}"
                )
            for outcome in r.option_outcomes:
                shot_ids = {p["shot_id"] for p in outcome["per_shot"]}
                for shot_id in r.affected_shots:
                    assert shot_id in shot_ids, (
                        f"rollup {r.rollup_id} option={outcome['option']} "
                        f"missing per_shot for {shot_id}"
                    )

    def test_active_room_option_resolves_to_master_in_outcome(self):
        artifacts = self._multi_active_artifacts()
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        space_rollup = next(
            r for r in rollups if r.decision_type == "choose_space_node"
        )
        hall_outcome = next(
            o for o in space_rollup.option_outcomes
            if o["option"] == "hall_a"
        )
        per_shot = hall_outcome["per_shot"][0]
        assert per_shot["resulting_status"] == "resolved_to_master"
        assert "master_hall_a_wide" in per_shot["bound_unit_ids"]

    def test_needs_decision_option_signals_activate(self):
        # multi-candidate where one option is needs_decision room.
        artifacts = _synth_artifacts(
            space_nodes=[
                _space_node("hall_a"),
                _space_node("bedroom_b", active_status="needs_decision"),
            ],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "S_nd", candidates=["hall_a", "bedroom_b"],
                state_layers=["sl_normal"], camera="eye_level_wide",
            )],
        )
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        space_rollup = next(
            r for r in rollups if r.decision_type == "choose_space_node"
        )
        nd_outcome = next(
            o for o in space_rollup.option_outcomes
            if o["option"] == "bedroom_b"
        )
        per_shot = nd_outcome["per_shot"][0]
        # outcome 이 needs_user_decision + required_rollups 에 activate.
        assert per_shot["resulting_status"] == "needs_user_decision"
        types = per_shot["required_rollups"]
        assert any("activate" in t for t in types), (
            f"needs_decision option must surface activate requirement, got {types}"
        )

    def test_w2e_fixture_no_empty_option_outcome(self):
        # smoke: W2e default run 의 모든 rollup option 에 outcome 존재.
        DEFAULT_RUN = (
            _REPO_ROOT / "scripts_output"
            / "background_place_grouping_experiment"
            / "20260524_1127_b5a5f5"
        )
        if not DEFAULT_RUN.exists():
            pytest.skip("W2e default run dir missing")
        artifacts = bsd.load_w2e_artifacts(DEFAULT_RUN)
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        for r in rollups:
            if r.decision_type not in {
                "choose_space_node", "choose_multi_space_policy",
                "choose_camera_variant",
            }:
                continue
            assert r.option_outcomes, (
                f"rollup {r.rollup_id} has empty option_outcomes"
            )
            for outcome in r.option_outcomes:
                assert outcome["per_shot"], (
                    f"rollup {r.rollup_id} option={outcome['option']} has empty per_shot"
                )


# ---------------------------------------------------------------------------
# W3c IMPORTANT 2: structured camera options
# ---------------------------------------------------------------------------
class TestStructuredCameraOptions:
    def test_choose_camera_variant_options_have_structured_metadata(self):
        artifacts = _synth_artifacts(
            space_nodes=[_space_node("hall_a")],
            state_layers=[_state_layer("normal")],
            generation_unit_plan={
                "master_units": [
                    _master_unit("master_hall_a_wide",
                                 space_node="hall_a",
                                 camera_view="eye_level_wide"),
                ],
                "derived_state_units": [],
                "derived_camera_units": [
                    _derived_camera("derived_hall_a_camera_table_low",
                                    based_on_master="master_hall_a_wide",
                                    camera_view="eye_level_table_close@low"),
                    _derived_camera("derived_hall_a_camera_table_standing",
                                    based_on_master="master_hall_a_wide",
                                    camera_view="eye_level_table_close@standing"),
                ],
                "rejected_cross_product": [],
            },
            shot_bindings=[_shot_binding(
                "Sd", candidates=["hall_a"],
                state_layers=["sl_normal"],
                camera="eye_level_table_close",
            )],
        )
        _, _, rollups, _ = bsd.build_decision_run(artifacts)
        cam_rollup = next(
            r for r in rollups if r.decision_type == "choose_camera_variant"
        )
        # option_outcomes 의 각 entry 가 unit_id + camera_view 둘 다 명시.
        for outcome in cam_rollup.option_outcomes:
            assert "unit_id" in outcome or "metadata" in outcome, (
                f"choose_camera_variant outcome must carry structured "
                f"unit_id/camera_view, got {outcome}"
            )


# ---------------------------------------------------------------------------
# W3d BLOCKING: HTML option_outcomes summary
# ---------------------------------------------------------------------------
class TestHtmlOptionOutcomes:
    def _run_w2e_html(self):
        DEFAULT_RUN = (
            _REPO_ROOT / "scripts_output"
            / "background_place_grouping_experiment"
            / "20260524_1127_b5a5f5"
        )
        if not DEFAULT_RUN.exists():
            pytest.skip("W2e default run dir missing")
        artifacts = bsd.load_w2e_artifacts(DEFAULT_RUN)
        decisions, traces, rollups, combos = bsd.build_decision_run(artifacts)
        return bsd.render_html(
            decisions, traces, rollups, combos,
            run_meta={"run_id": "test"},
        )

    def test_html_contains_option_outcomes_section(self):
        html = self._run_w2e_html()
        assert ("option outcomes" in html.lower()
                or "option_outcomes" in html), (
            "HTML must include option_outcomes section/header in rollup table"
        )

    def test_html_shows_combo_outcome(self):
        html = self._run_w2e_html()
        assert "requires_derived_combo" in html, (
            "HTML must surface requires_derived_combo follow-up in rollup options"
        )

    def test_html_shows_activate_outcome(self):
        html = self._run_w2e_html()
        assert "activate_space_node" in html, (
            "HTML must surface activate_space_node follow-up"
        )

    def test_html_shows_choose_camera_variant_outcome(self):
        html = self._run_w2e_html()
        assert "choose_camera_variant" in html, (
            "HTML must surface choose_camera_variant follow-up"
        )

    def test_html_shows_impossible_topology_update_outcome(self):
        html = self._run_w2e_html()
        assert "impossible_without_topology_update" in html, (
            "HTML must surface impossible_without_topology_update follow-up"
        )


class TestRealW2eFixtureSmoke:
    DEFAULT_RUN = (
        _REPO_ROOT / "scripts_output"
        / "background_place_grouping_experiment"
        / "20260524_1127_b5a5f5"
    )

    def test_load_w2e_artifacts(self):
        if not self.DEFAULT_RUN.exists():
            pytest.skip("W2e default run dir missing")
        artifacts = bsd.load_w2e_artifacts(self.DEFAULT_RUN)
        assert len(artifacts.shot_bindings) >= 1
        assert "master_units" in artifacts.generation_unit_plan

    def test_real_w2e_run_produces_decisions_for_all_shots(self):
        if not self.DEFAULT_RUN.exists():
            pytest.skip("W2e default run dir missing")
        artifacts = bsd.load_w2e_artifacts(self.DEFAULT_RUN)
        decisions, *_ = bsd.build_decision_run(artifacts)
        assert len(decisions) == len(artifacts.shot_bindings)
        statuses = {d.decision_status for d in decisions}
        valid = {
            "resolved_to_master", "resolved_to_derived_state",
            "resolved_to_derived_camera", "requires_derived_combo",
            "needs_user_decision", "impossible_without_topology_update",
        }
        for d in decisions:
            assert d.decision_status in valid, d
