#!/usr/bin/env python3
"""Background spatial decision experiment — W3 dry-run (2026-05-24).

scripts_output/background_spatial_decision_experiment/<run_id>/
read-only: W2e artifact 만 입력. DB/network/image 모듈 0. production code 0.

Goal (plan.md): W2e 산출 (shot_bindings + generation_unit_plan + space_nodes +
state_layers 등) 을 입력으로 받아 각 shot 에 6 enum decision_status 부여하고
unresolved 는 rollup (≤ 5 distinct) 으로 묶는다. 새 entity/unit 자동 생성 금지.

사용자 standing rule (2026-05-24): 특정 시나리오 (location / character / room
label / 장면 문구) 기반 일반 rule/prompt/code 금지. 본 script body 에 sample
literal (e.g. living-room / character names) 직접 등장 0. 모든 rule body 는
generic relation (containment / active_status / state_layer.geometry_preserving
/ camera_view / candidate_count) 기반.
"""
from __future__ import annotations

import argparse
import csv
import html
import json
import re
import sys
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional

# repo root setup --------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parents[2]
_BACKEND_ROOT = _REPO_ROOT / "backend"
if str(_BACKEND_ROOT) not in sys.path:
    sys.path.insert(0, str(_BACKEND_ROOT))

DEFAULT_INPUT_RUN = (
    _REPO_ROOT / "scripts_output" / "background_place_grouping_experiment"
    / "20260524_1127_b5a5f5"
)
DEFAULT_OUTPUT_DIR = (
    _REPO_ROOT / "scripts_output" / "background_spatial_decision_experiment"
)

PLAN_VERSION = "bsd_w3"

# Decision status enum (6)
DECISION_STATUS_ENUM = {
    "resolved_to_master",
    "resolved_to_derived_state",
    "resolved_to_derived_camera",
    "requires_derived_combo",
    "needs_user_decision",
    "impossible_without_topology_update",
}

# Rollup decision_type enum (5)
ROLLUP_DECISION_TYPE_ENUM = {
    "choose_space_node",
    "choose_multi_space_policy",
    "activate_space_node",
    "topology_update",
    "choose_camera_variant",
}

# slug helper (mirrored from W2c — kept local to avoid sample-script import).
_SLUG_SAFE_RE = re.compile(r"[^0-9A-Za-z_가-힣]+")


def _slug_id(value: str) -> str:
    if not value:
        return "_"
    slug = _SLUG_SAFE_RE.sub("_", value).strip("_")
    return slug or "_"


def _make_rollup_id(decision_type: str, options: list[str]) -> str:
    """W3b BLOCKING 1: rollup_id = decision_type + normalized options.

    Two shots that share the same decision_type AND the same option set
    merge. Different option sets must produce different rollup_ids.
    """
    norm_opts = sorted({str(o) for o in (options or [])})
    return _slug_id(
        f"rollup_{decision_type}_" + "__".join(norm_opts)
    )


# -----------------------------------------------------------------------------
# Dataclasses
# -----------------------------------------------------------------------------
@dataclass
class W2EArtifacts:
    space_nodes: list[dict]
    state_layers: list[dict]
    structural_versions: list[dict]
    shot_bindings: list[dict]
    generation_unit_plan: dict
    chain_bg_decomposition: list[dict]
    run_meta: dict


@dataclass
class ShotSpatialDecision:
    shot_id: str
    decision_status: str
    selected_space_node: Optional[str]
    selected_state_layer: Optional[str]
    selected_camera_family: Optional[str]
    bound_unit_ids: list[str]
    unresolved_reasons: list[str]
    evidence_refs: list[str]
    decision_notes: list[str]


@dataclass
class DecisionRuleTrace:
    shot_id: str
    rule_id: str
    input_fields: dict
    result: str
    reason: str


@dataclass
class RollupDecisionRequest:
    rollup_id: str
    decision_type: str
    affected_shots: list[str]
    options: list[str]
    recommended_default: Optional[str]
    risk_if_wrong: str
    # W3c BLOCKING 1: option-level downstream consequence simulation.
    # shape: [{"option": <str>, "unit_id": <str?>, "camera_view": <str?>,
    #          "per_shot": [{"shot_id": <str>, "resulting_status": <str>,
    #                        "bound_unit_ids": [<str>],
    #                        "required_rollups": [<decision_type>...],
    #                        "required_combos": [<combo_id>...],
    #                        "required_topology_updates": [<option_str>...]}]
    #         }]
    option_outcomes: list[dict] = field(default_factory=list)


@dataclass
class DerivedComboPlan:
    combo_id: str
    based_on_master: str
    state_layer: str
    camera_view: str
    applies_to_shots: list[str]
    why_not_existing_unit: str


# -----------------------------------------------------------------------------
# I/O — load W2e artifacts (artifact-only, no DB)
# -----------------------------------------------------------------------------
def load_w2e_artifacts(run_dir: Path) -> W2EArtifacts:
    """Load W2e artifact files. DB read 0."""
    def _read_json(name: str, default):
        p = run_dir / name
        if not p.exists():
            return default
        return json.loads(p.read_text(encoding="utf-8"))

    return W2EArtifacts(
        space_nodes=_read_json("space_nodes.json", []),
        state_layers=_read_json("state_layers.json", []),
        structural_versions=_read_json("structural_versions.json", []),
        shot_bindings=_read_json("shot_bindings.json", []),
        generation_unit_plan=_read_json("generation_unit_plan.json", {
            "master_units": [], "derived_state_units": [],
            "derived_camera_units": [], "rejected_cross_product": [],
        }),
        chain_bg_decomposition=_read_json("chain_bg_decomposition.json", []),
        run_meta=_read_json("run_meta.json", {}),
    )


# -----------------------------------------------------------------------------
# Generic engine helpers — all relation-based, no sample vocabulary
# -----------------------------------------------------------------------------
def _build_space_node_index(artifacts: W2EArtifacts) -> dict[str, dict]:
    return {n["node_id"]: n for n in artifacts.space_nodes}


def _build_master_index(artifacts: W2EArtifacts) -> dict[str, list[dict]]:
    """space_node -> list of master units serving it."""
    idx: dict[str, list[dict]] = {}
    for m in artifacts.generation_unit_plan.get("master_units", []):
        idx.setdefault(m["space_node"], []).append(m)
    return idx


def _build_derived_state_index(artifacts: W2EArtifacts) -> dict[tuple[str, str], list[dict]]:
    """(space_node, state_layer label) -> derived_state units."""
    idx: dict[tuple[str, str], list[dict]] = {}
    master_by_id = {
        m["unit_id"]: m
        for m in artifacts.generation_unit_plan.get("master_units", [])
    }
    for d in artifacts.generation_unit_plan.get("derived_state_units", []):
        master = master_by_id.get(d["based_on_master"])
        if master is None:
            continue
        idx.setdefault((master["space_node"], d["state_layer"]), []).append(d)
    return idx


def _build_derived_camera_index(artifacts: W2EArtifacts) -> dict[tuple[str, str], list[dict]]:
    """(space_node, camera_view family) -> derived_camera units. camera_view
    family is the part before '@' so a binding camera "macro_close" matches
    a unit whose camera_view is "macro_close@floor_close".
    """
    idx: dict[tuple[str, str], list[dict]] = {}
    master_by_id = {
        m["unit_id"]: m
        for m in artifacts.generation_unit_plan.get("master_units", [])
    }
    for d in artifacts.generation_unit_plan.get("derived_camera_units", []):
        master = master_by_id.get(d["based_on_master"])
        if master is None:
            continue
        cam_family = (d.get("camera_view") or "").split("@")[0]
        idx.setdefault((master["space_node"], cam_family), []).append(d)
    return idx


def _collapse_zone_boundary_candidates(
        candidates: list[str],
        node_index: dict[str, dict],
        ) -> tuple[list[str], list[str]]:
    """If every candidate is a zone/boundary contained in a single parent
    room, collapse to parent. Returns (collapsed_candidates, collapse_trace).
    """
    if len(candidates) <= 1:
        return list(candidates), []

    # group candidates by effective parent (zone/boundary → contained_in,
    # room → self).
    parents: set[str] = set()
    has_room = False
    collapse_notes: list[str] = []
    for nid in candidates:
        node = node_index.get(nid)
        if node is None:
            parents.add(nid)
            continue
        if node.get("node_type") in {"zone", "boundary"}:
            parent = node.get("contained_in")
            if parent:
                parents.add(parent)
                collapse_notes.append(
                    f"{nid} ({node.get('node_type')}) collapsed to {parent}"
                )
            else:
                parents.add(nid)
        else:
            parents.add(nid)
            has_room = True
    if len(parents) == 1:
        return list(parents), collapse_notes
    # if collapse removes any zone/boundary but parents > 1, still return
    # parents (multiple rooms remain).
    if collapse_notes:
        return sorted(parents), collapse_notes
    return list(candidates), []


def _candidate_active_status(node_index: dict[str, dict],
                             candidates: list[str]) -> dict[str, str]:
    return {
        c: (node_index.get(c, {}).get("active_status") or "unknown")
        for c in candidates
    }


def _shot_camera_family(camera: Optional[str]) -> Optional[str]:
    if not camera:
        return None
    return camera.split("@")[0]


def _state_label_from_id(state_layer_id: str) -> str:
    """Convert sl_<label> -> <label>."""
    if state_layer_id.startswith("sl_"):
        return state_layer_id[3:]
    return state_layer_id


def _master_default_camera_for(space_node: str,
                                master_index: dict[str, list[dict]]
                                ) -> Optional[str]:
    masters = master_index.get(space_node, [])
    if not masters:
        return None
    # default = first master unit's camera_view.
    return masters[0].get("camera_view")


# -----------------------------------------------------------------------------
# Rule engine — main resolver
# -----------------------------------------------------------------------------
def _resolve_one_shot(
        binding: dict,
        node_index: dict[str, dict],
        master_index: dict[str, list[dict]],
        derived_state_index: dict[tuple[str, str], list[dict]],
        derived_camera_index: dict[tuple[str, str], list[dict]],
        ) -> tuple[ShotSpatialDecision, list[DecisionRuleTrace],
                   list[RollupDecisionRequest], list[DerivedComboPlan]]:
    shot_id = binding["shot_id"]
    raw_candidates = list(binding.get("space_node_candidates") or [])
    state_layers = list(binding.get("state_layers") or [])
    camera = _shot_camera_family(binding.get("camera_view_family_candidate"))
    state_layer_id = state_layers[0] if state_layers else "sl_normal"
    state_label = _state_label_from_id(state_layer_id)

    traces: list[DecisionRuleTrace] = []
    rollups: list[RollupDecisionRequest] = []
    combos: list[DerivedComboPlan] = []
    notes: list[str] = []

    # R9: empty candidates → needs_user_decision.
    if not raw_candidates:
        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id="R9_no_candidates",
            input_fields={"candidates": []},
            result="needs_user_decision",
            reason="no space candidates from binding",
        ))
        node_options = sorted(node_index.keys())
        rollups.append(RollupDecisionRequest(
            rollup_id=_make_rollup_id("choose_space_node", node_options),
            decision_type="choose_space_node",
            affected_shots=[shot_id],
            options=node_options,
            recommended_default=None,
            risk_if_wrong="shot will not be bound to any background unit",
        ))
        decision = ShotSpatialDecision(
            shot_id=shot_id, decision_status="needs_user_decision",
            selected_space_node=None,
            selected_state_layer=state_layer_id,
            selected_camera_family=camera,
            bound_unit_ids=[], unresolved_reasons=["no_candidates"],
            evidence_refs=[f"shot_bindings.json#{shot_id}"],
            decision_notes=notes,
        )
        return decision, traces, rollups, combos

    # R5: zone/boundary collapse to parent (before R6).
    candidates, collapse_notes = _collapse_zone_boundary_candidates(
        raw_candidates, node_index,
    )
    if collapse_notes:
        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id="R5_zone_boundary_collapse",
            input_fields={"raw_candidates": raw_candidates,
                          "collapsed": candidates},
            result="continue",
            reason="; ".join(collapse_notes),
        ))
        notes.extend(collapse_notes)

    active_status_by_cand = _candidate_active_status(node_index, candidates)
    needs_decision_candidates = [
        c for c, status in active_status_by_cand.items()
        if status == "needs_decision"
    ]

    # W3b BLOCKING 2: multi-candidate (>= 2) 가 R6 먼저. needs_decision 후보가
    # 섞여 있어도 사용자가 어느 공간을 선택해야 하는지 먼저 물어야 한다.
    if len(candidates) >= 2:
        # W3b IMPORTANT 1: binding.unresolved_reason 이 multi_space_wide_policy
        # 면 decision_type 을 choose_multi_space_policy 로 분기.
        binding_unresolved = (binding.get("unresolved_reason") or "")
        if binding_unresolved == "multi_space_wide_policy":
            decision_type = "choose_multi_space_policy"
            rule_id = "R6b_multi_space_wide_policy"
        else:
            decision_type = "choose_space_node"
            rule_id = "R6_multi_independent_rooms"

        risk_parts = ["wrong choice binds shot to wrong background master plate"]
        decision_notes_extra: list[str] = []
        if needs_decision_candidates:
            risk_parts.append(
                f"options {needs_decision_candidates} are active_status="
                "needs_decision and require activate_space_node before binding"
            )
            decision_notes_extra.append(
                f"needs_decision candidates: {needs_decision_candidates}"
                " (activate required)"
            )

        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id=rule_id,
            input_fields={
                "candidates": candidates,
                "active_status": active_status_by_cand,
                "binding_unresolved_reason": binding_unresolved,
            },
            result="needs_user_decision",
            reason=(f"{len(candidates)} candidates after zone/boundary collapse;"
                    f" decision_type={decision_type}"),
        ))
        rollups.append(RollupDecisionRequest(
            rollup_id=_make_rollup_id(decision_type, candidates),
            decision_type=decision_type,
            affected_shots=[shot_id],
            options=list(candidates),
            recommended_default=None,
            risk_if_wrong=" | ".join(risk_parts),
        ))
        decision = ShotSpatialDecision(
            shot_id=shot_id, decision_status="needs_user_decision",
            selected_space_node=None,
            selected_state_layer=state_layer_id,
            selected_camera_family=camera,
            bound_unit_ids=[],
            unresolved_reasons=[
                "multi_independent_candidates"
                if decision_type == "choose_space_node"
                else "multi_space_wide_policy"
            ],
            evidence_refs=[f"shot_bindings.json#{shot_id}"],
            decision_notes=notes + decision_notes_extra,
        )
        return decision, traces, rollups, combos

    # R7: single candidate 인데 그 candidate 가 active_status=needs_decision.
    if needs_decision_candidates:
        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id="R7_candidate_active_needs_decision",
            input_fields={"candidates": candidates,
                          "active_status": active_status_by_cand},
            result="needs_user_decision",
            reason=(f"single candidate {needs_decision_candidates} has "
                    "active_status=needs_decision"),
        ))
        rollups.append(RollupDecisionRequest(
            rollup_id=_make_rollup_id(
                "activate_space_node", needs_decision_candidates,
            ),
            decision_type="activate_space_node",
            affected_shots=[shot_id],
            options=needs_decision_candidates,
            recommended_default=None,
            risk_if_wrong=(
                "binding to inactive room may produce wrong background "
                "continuity"
            ),
        ))
        decision = ShotSpatialDecision(
            shot_id=shot_id, decision_status="needs_user_decision",
            selected_space_node=None,
            selected_state_layer=state_layer_id,
            selected_camera_family=camera,
            bound_unit_ids=[],
            unresolved_reasons=["candidate_needs_decision"],
            evidence_refs=[f"shot_bindings.json#{shot_id}"],
            decision_notes=notes,
        )
        return decision, traces, rollups, combos

    # Single candidate path -----------------------------------------------------
    space = candidates[0]
    default_camera = _master_default_camera_for(space, master_index)
    masters = master_index.get(space, [])
    is_normal_state = state_layer_id in {"sl_normal", "normal"}

    if not masters:
        # R8: master 자체 부재.
        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id="R8_no_matching_unit",
            input_fields={"space": space, "masters": []},
            result="impossible_without_topology_update",
            reason=f"no master unit exists for space={space}",
        ))
        options = [f"add_master_for_{space}"]
        rollups.append(RollupDecisionRequest(
            rollup_id=_make_rollup_id("topology_update", options),
            decision_type="topology_update",
            affected_shots=[shot_id],
            options=options,
            recommended_default=None,
            risk_if_wrong="shot cannot be bound until topology updated",
        ))
        decision = ShotSpatialDecision(
            shot_id=shot_id,
            decision_status="impossible_without_topology_update",
            selected_space_node=space,
            selected_state_layer=state_layer_id,
            selected_camera_family=camera,
            bound_unit_ids=[],
            unresolved_reasons=["no_master_unit"],
            evidence_refs=[f"shot_bindings.json#{shot_id}"],
            decision_notes=notes,
        )
        return decision, traces, rollups, combos

    # check camera family vs default.
    default_camera_family = _shot_camera_family(default_camera)
    camera_matches_default = (
        camera is None or camera == default_camera_family
    )

    # R1: single active room + normal + default camera → resolved_to_master.
    if is_normal_state and camera_matches_default:
        master = masters[0]
        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id="R1_single_active_room_normal_default_camera",
            input_fields={"space": space, "camera": camera,
                          "default_camera": default_camera_family},
            result="resolved_to_master",
            reason="single active room + normal state + default camera",
        ))
        decision = ShotSpatialDecision(
            shot_id=shot_id, decision_status="resolved_to_master",
            selected_space_node=space,
            selected_state_layer=state_layer_id,
            selected_camera_family=camera or default_camera_family,
            bound_unit_ids=[master["unit_id"]],
            unresolved_reasons=[],
            evidence_refs=[f"shot_bindings.json#{shot_id}"],
            decision_notes=notes,
        )
        return decision, traces, rollups, combos

    # R2: single active room + normal + alt camera → derived_camera if exists.
    # W3b BLOCKING 3: ambiguous family (≥ 2 derived_camera units) 는 임의 선택
    # 금지 — needs_user_decision + choose_camera_variant rollup.
    if is_normal_state and not camera_matches_default:
        cam_units = derived_camera_index.get((space, camera or ""), [])
        if len(cam_units) == 1:
            unit = cam_units[0]
            traces.append(DecisionRuleTrace(
                shot_id=shot_id,
                rule_id="R2_single_active_room_normal_alt_camera",
                input_fields={"space": space, "camera": camera},
                result="resolved_to_derived_camera",
                reason="single active room + normal + non-default camera with matching derived_camera (1 unit)",
            ))
            decision = ShotSpatialDecision(
                shot_id=shot_id,
                decision_status="resolved_to_derived_camera",
                selected_space_node=space,
                selected_state_layer=state_layer_id,
                selected_camera_family=camera,
                bound_unit_ids=[unit["unit_id"]],
                unresolved_reasons=[],
                evidence_refs=[f"shot_bindings.json#{shot_id}"],
                decision_notes=notes,
            )
            return decision, traces, rollups, combos
        if len(cam_units) >= 2:
            unit_options = sorted(u["unit_id"] for u in cam_units)
            camera_views = sorted({u.get("camera_view") or "" for u in cam_units})
            traces.append(DecisionRuleTrace(
                shot_id=shot_id,
                rule_id="R2b_derived_camera_family_ambiguous",
                input_fields={"space": space, "camera_family": camera,
                              "candidate_units": unit_options},
                result="needs_user_decision",
                reason=("multiple derived_camera units share the same family;"
                        " arbitrary selection forbidden"),
            ))
            rollups.append(RollupDecisionRequest(
                rollup_id=_make_rollup_id(
                    "choose_camera_variant", unit_options,
                ),
                decision_type="choose_camera_variant",
                affected_shots=[shot_id],
                options=unit_options + camera_views,
                recommended_default=None,
                risk_if_wrong=(
                    "wrong choice binds shot to wrong camera variant within"
                    " the same family"
                ),
            ))
            decision = ShotSpatialDecision(
                shot_id=shot_id, decision_status="needs_user_decision",
                selected_space_node=space,
                selected_state_layer=state_layer_id,
                selected_camera_family=camera,
                bound_unit_ids=[],
                unresolved_reasons=["camera_family_ambiguous"],
                evidence_refs=[f"shot_bindings.json#{shot_id}"],
                decision_notes=notes,
            )
            return decision, traces, rollups, combos
        # no derived_camera — impossible without topology update.
        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id="R8_no_matching_unit",
            input_fields={"space": space, "camera": camera,
                          "matched_unit": "derived_camera"},
            result="impossible_without_topology_update",
            reason=f"no derived_camera unit for {space}+{camera}",
        ))
        options = [f"add_derived_camera_for_{space}_{camera}"]
        rollups.append(RollupDecisionRequest(
            rollup_id=_make_rollup_id("topology_update", options),
            decision_type="topology_update",
            affected_shots=[shot_id],
            options=options,
            recommended_default=None,
            risk_if_wrong="shot cannot be bound until derived camera added",
        ))
        decision = ShotSpatialDecision(
            shot_id=shot_id,
            decision_status="impossible_without_topology_update",
            selected_space_node=space,
            selected_state_layer=state_layer_id,
            selected_camera_family=camera,
            bound_unit_ids=[],
            unresolved_reasons=["no_derived_camera_unit"],
            evidence_refs=[f"shot_bindings.json#{shot_id}"],
            decision_notes=notes,
        )
        return decision, traces, rollups, combos

    # Non-normal state path.
    state_units = derived_state_index.get((space, state_label), [])
    has_state_unit = bool(state_units)

    # R4: non-normal AND non-default camera → requires_derived_combo.
    if not camera_matches_default:
        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id="R4_non_normal_and_non_default_camera",
            input_fields={"space": space, "state": state_label,
                          "camera": camera, "default_camera": default_camera_family},
            result="requires_derived_combo",
            reason="state != normal AND camera != default — single derived unit cannot satisfy both axes",
        ))
        combo_id = _slug_id(
            f"combo_{space}_{state_label}_{camera}_{shot_id}"
        )
        combos.append(DerivedComboPlan(
            combo_id=combo_id,
            based_on_master=masters[0]["unit_id"],
            state_layer=state_layer_id,
            camera_view=camera or "",
            applies_to_shots=[shot_id],
            why_not_existing_unit=(
                f"state={state_layer_id} AND camera={camera} 둘 다 default 가 아니어서 "
                "derived_state / derived_camera 어느 쪽도 단독으로 부합하지 않음"
            ),
        ))
        decision = ShotSpatialDecision(
            shot_id=shot_id, decision_status="requires_derived_combo",
            selected_space_node=space,
            selected_state_layer=state_layer_id,
            selected_camera_family=camera,
            bound_unit_ids=[],
            unresolved_reasons=["combo_required"],
            evidence_refs=[f"shot_bindings.json#{shot_id}"],
            decision_notes=notes,
        )
        return decision, traces, rollups, combos

    # R3: non-normal + default camera + matching derived_state → resolved.
    if has_state_unit:
        unit = state_units[0]
        traces.append(DecisionRuleTrace(
            shot_id=shot_id, rule_id="R3_single_active_room_non_normal",
            input_fields={"space": space, "state": state_label},
            result="resolved_to_derived_state",
            reason="single active room + non-normal state with matching derived_state",
        ))
        decision = ShotSpatialDecision(
            shot_id=shot_id, decision_status="resolved_to_derived_state",
            selected_space_node=space,
            selected_state_layer=state_layer_id,
            selected_camera_family=camera or default_camera_family,
            bound_unit_ids=[unit["unit_id"]],
            unresolved_reasons=[],
            evidence_refs=[f"shot_bindings.json#{shot_id}"],
            decision_notes=notes,
        )
        return decision, traces, rollups, combos

    # Non-normal but no derived_state unit → impossible_without_topology_update.
    traces.append(DecisionRuleTrace(
        shot_id=shot_id, rule_id="R8_no_matching_unit",
        input_fields={"space": space, "state": state_label,
                      "matched_unit": "derived_state"},
        result="impossible_without_topology_update",
        reason=f"no derived_state unit for {space}+{state_label}",
    ))
    options = [f"add_derived_state_for_{space}_{state_label}"]
    rollups.append(RollupDecisionRequest(
        rollup_id=_make_rollup_id("topology_update", options),
        decision_type="topology_update",
        affected_shots=[shot_id],
        options=options,
        recommended_default=None,
        risk_if_wrong="shot cannot be bound until derived_state unit added",
    ))
    decision = ShotSpatialDecision(
        shot_id=shot_id,
        decision_status="impossible_without_topology_update",
        selected_space_node=space,
        selected_state_layer=state_layer_id,
        selected_camera_family=camera,
        bound_unit_ids=[],
        unresolved_reasons=["no_derived_state_unit"],
        evidence_refs=[f"shot_bindings.json#{shot_id}"],
        decision_notes=notes,
    )
    return decision, traces, rollups, combos


def _simulate_option_outcome_per_shot(
        binding: dict, option: str,
        node_index: dict[str, dict],
        master_index: dict[str, list[dict]],
        derived_state_index: dict[tuple[str, str], list[dict]],
        derived_camera_index: dict[tuple[str, str], list[dict]],
        ) -> dict:
    """W3c BLOCKING 1: simulate choosing `option` as the sole candidate for
    `binding`. No rollup aggregation; just extracts resulting status + bound
    units + required follow-ups for the user.
    """
    sim_binding = dict(binding)
    sim_binding["space_node_candidates"] = [option]
    # camera_variant simulation 인 경우 — option 이 derived_camera unit_id 일 때.
    # 본 helper 는 candidate-level (space) simulation 만 처리.
    decision, _traces, rollups, combos = _resolve_one_shot(
        sim_binding, node_index, master_index,
        derived_state_index, derived_camera_index,
    )
    required_rollups = sorted({r.decision_type for r in rollups})
    required_combos = sorted({c.combo_id for c in combos})
    required_topology = sorted({
        opt for r in rollups if r.decision_type == "topology_update"
        for opt in r.options
    })
    return {
        "shot_id": binding["shot_id"],
        "resulting_status": decision.decision_status,
        "bound_unit_ids": list(decision.bound_unit_ids),
        "required_rollups": required_rollups,
        "required_combos": required_combos,
        "required_topology_updates": required_topology,
    }


def _simulate_camera_variant_outcome_per_shot(
        binding: dict, unit: dict,
        ) -> dict:
    """Simulate choosing a specific derived_camera variant (unit). The unit is
    a single concrete option; selecting it resolves the shot to that unit.
    """
    return {
        "shot_id": binding["shot_id"],
        "resulting_status": "resolved_to_derived_camera",
        "bound_unit_ids": [unit["unit_id"]],
        "required_rollups": [],
        "required_combos": [],
        "required_topology_updates": [],
    }


def _attach_option_outcomes(
        rollup: RollupDecisionRequest,
        bindings_by_shot: dict[str, dict],
        node_index: dict[str, dict],
        master_index: dict[str, list[dict]],
        derived_state_index: dict[tuple[str, str], list[dict]],
        derived_camera_index: dict[tuple[str, str], list[dict]],
        derived_camera_units_by_id: dict[str, dict],
        ) -> None:
    """For each option in rollup, compute per-shot outcome and attach to
    rollup.option_outcomes. Only multi-choice decision_types need this.
    """
    if rollup.decision_type not in {
        "choose_space_node", "choose_multi_space_policy",
        "choose_camera_variant",
    }:
        return

    if rollup.decision_type == "choose_camera_variant":
        # options carry both unit_ids and full camera_view strings; dedup by
        # unit_id where possible.
        unit_options: dict[str, dict] = {}
        for opt in rollup.options:
            unit = derived_camera_units_by_id.get(opt)
            if unit is None:
                continue
            unit_options[opt] = unit
        outcomes: list[dict] = []
        for unit_id, unit in unit_options.items():
            per_shot = [
                _simulate_camera_variant_outcome_per_shot(
                    bindings_by_shot[s], unit,
                )
                for s in rollup.affected_shots
                if s in bindings_by_shot
            ]
            outcomes.append({
                "option": unit_id,
                "unit_id": unit_id,
                "camera_view": unit.get("camera_view"),
                "per_shot": per_shot,
            })
        rollup.option_outcomes = outcomes
        return

    # space-option rollups (choose_space_node / choose_multi_space_policy).
    outcomes = []
    for opt in rollup.options:
        per_shot = [
            _simulate_option_outcome_per_shot(
                bindings_by_shot[s], opt,
                node_index, master_index,
                derived_state_index, derived_camera_index,
            )
            for s in rollup.affected_shots
            if s in bindings_by_shot
        ]
        outcomes.append({
            "option": opt,
            "per_shot": per_shot,
        })
    rollup.option_outcomes = outcomes


def build_decision_run(artifacts: W2EArtifacts
                       ) -> tuple[list[ShotSpatialDecision],
                                  list[DecisionRuleTrace],
                                  list[RollupDecisionRequest],
                                  list[DerivedComboPlan]]:
    """Run generic resolution on every shot binding. Aggregates rollups by
    rollup_id (≤ 5 distinct in practice; affected_shots accumulates).

    W3c: after aggregation, attach option_outcomes per rollup so the user can
    see downstream consequences of each choice.
    """
    node_index = _build_space_node_index(artifacts)
    master_index = _build_master_index(artifacts)
    derived_state_index = _build_derived_state_index(artifacts)
    derived_camera_index = _build_derived_camera_index(artifacts)
    derived_camera_units_by_id = {
        u["unit_id"]: u
        for u in artifacts.generation_unit_plan.get("derived_camera_units", [])
    }
    bindings_by_shot = {b["shot_id"]: b for b in artifacts.shot_bindings}

    all_decisions: list[ShotSpatialDecision] = []
    all_traces: list[DecisionRuleTrace] = []
    rollup_by_id: dict[str, RollupDecisionRequest] = {}
    all_combos: list[DerivedComboPlan] = []

    for binding in artifacts.shot_bindings:
        decision, traces, rollups, combos = _resolve_one_shot(
            binding, node_index, master_index,
            derived_state_index, derived_camera_index,
        )
        all_decisions.append(decision)
        all_traces.extend(traces)
        all_combos.extend(combos)
        for r in rollups:
            existing = rollup_by_id.get(r.rollup_id)
            if existing is None:
                rollup_by_id[r.rollup_id] = r
            else:
                for s in r.affected_shots:
                    if s not in existing.affected_shots:
                        existing.affected_shots.append(s)
                for opt in r.options:
                    if opt not in existing.options:
                        existing.options.append(opt)

    # W3c: attach option_outcomes.
    for rollup in rollup_by_id.values():
        _attach_option_outcomes(
            rollup, bindings_by_shot, node_index, master_index,
            derived_state_index, derived_camera_index,
            derived_camera_units_by_id,
        )

    return all_decisions, all_traces, list(rollup_by_id.values()), all_combos


# -----------------------------------------------------------------------------
# I/O write
# -----------------------------------------------------------------------------
def _to_jsonable(obj):
    if isinstance(obj, list):
        return [_to_jsonable(x) for x in obj]
    if isinstance(obj, dict):
        return {k: _to_jsonable(v) for k, v in obj.items()}
    if hasattr(obj, "__dataclass_fields__"):
        return _to_jsonable(asdict(obj))
    return obj


def _now_iso() -> str:
    return datetime.now(timezone(timedelta(hours=9))).isoformat(timespec="seconds")


def _run_id() -> str:
    stamp = datetime.now(timezone(timedelta(hours=9))).strftime("%Y%m%d_%H%M")
    return f"{stamp}_{uuid.uuid4().hex[:6]}"


def write_outputs(out_dir: Path,
                  decisions: list[ShotSpatialDecision],
                  traces: list[DecisionRuleTrace],
                  rollups: list[RollupDecisionRequest],
                  combos: list[DerivedComboPlan],
                  run_meta: dict) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)

    (out_dir / "shot_spatial_decisions.json").write_text(
        json.dumps(_to_jsonable(decisions), ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    (out_dir / "rollup_decision_requests.json").write_text(
        json.dumps(_to_jsonable(rollups), ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    (out_dir / "derived_combo_plan.json").write_text(
        json.dumps(_to_jsonable(combos), ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    with (out_dir / "decision_rule_trace.jsonl").open(
            "w", encoding="utf-8") as f:
        for t in traces:
            f.write(json.dumps(asdict(t), ensure_ascii=False) + "\n")

    _write_decisions_tsv(out_dir / "shot_spatial_decisions.tsv", decisions)
    _write_rollups_tsv(out_dir / "rollup_decision_requests.tsv", rollups)

    (out_dir / "run_meta.json").write_text(
        json.dumps(run_meta, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    (out_dir / "index.html").write_text(
        render_html(decisions, traces, rollups, combos, run_meta),
        encoding="utf-8",
    )


def _write_decisions_tsv(path: Path,
                         decisions: list[ShotSpatialDecision]) -> None:
    with path.open("w", encoding="utf-8", newline="") as f:
        w = csv.writer(f, delimiter="\t")
        w.writerow([
            "shot_id", "decision_status", "selected_space_node",
            "selected_state_layer", "selected_camera_family",
            "bound_unit_ids", "unresolved_reasons",
        ])
        for d in decisions:
            w.writerow([
                d.shot_id, d.decision_status,
                d.selected_space_node or "",
                d.selected_state_layer or "",
                d.selected_camera_family or "",
                ",".join(d.bound_unit_ids),
                ",".join(d.unresolved_reasons),
            ])


def _write_rollups_tsv(path: Path,
                       rollups: list[RollupDecisionRequest]) -> None:
    with path.open("w", encoding="utf-8", newline="") as f:
        w = csv.writer(f, delimiter="\t")
        w.writerow([
            "rollup_id", "decision_type", "affected_shots", "options",
            "recommended_default", "risk_if_wrong",
        ])
        for r in rollups:
            w.writerow([
                r.rollup_id, r.decision_type,
                ",".join(r.affected_shots),
                ",".join(r.options),
                r.recommended_default or "",
                r.risk_if_wrong,
            ])


# -----------------------------------------------------------------------------
# HTML report — generic. status-grouped (not shot-order).
# -----------------------------------------------------------------------------
def _render_option_outcomes(rollup: RollupDecisionRequest, esc) -> str:
    """W3d BLOCKING: human-readable summary of option_outcomes per option."""
    if not rollup.option_outcomes:
        return "<i>(no per-option outcomes)</i>"

    blocks: list[str] = []
    for outcome in rollup.option_outcomes:
        opt_label = outcome.get("option", "")
        meta_bits: list[str] = []
        if outcome.get("unit_id"):
            meta_bits.append(f"unit_id={outcome['unit_id']}")
        if outcome.get("camera_view"):
            meta_bits.append(f"camera_view={outcome['camera_view']}")
        meta_html = (" <span style='color:#888;font-size:11px'>"
                     + esc("; ".join(meta_bits)) + "</span>") if meta_bits else ""

        per_shot_rows: list[str] = []
        for ps in outcome.get("per_shot", []):
            shot_id = ps.get("shot_id", "")
            status = ps.get("resulting_status", "")
            bound = ps.get("bound_unit_ids") or []
            req_rollups = ps.get("required_rollups") or []
            req_combos = ps.get("required_combos") or []
            req_top = ps.get("required_topology_updates") or []
            followups = []
            if bound:
                followups.append(f"bind={','.join(bound)}")
            if req_rollups:
                followups.append(f"required_rollups={','.join(req_rollups)}")
            if req_combos:
                followups.append(f"required_combos={','.join(req_combos)}")
            if req_top:
                followups.append(f"required_topology_updates={','.join(req_top)}")
            followup_html = (
                " <span style='color:#666'>" + esc("; ".join(followups))
                + "</span>"
            ) if followups else ""
            per_shot_rows.append(
                f"<li><b>{esc(shot_id)}</b>: "
                f"<code>{esc(status)}</code>{followup_html}</li>"
            )
        blocks.append(
            f"<details><summary><b>{esc(opt_label)}</b>{meta_html}</summary>"
            f"<ul style='margin:4px 0 8px 16px;font-size:12px'>"
            f"{''.join(per_shot_rows)}"
            "</ul></details>"
        )
    return "".join(blocks)


def render_html(decisions: list[ShotSpatialDecision],
                traces: list[DecisionRuleTrace],
                rollups: list[RollupDecisionRequest],
                combos: list[DerivedComboPlan],
                run_meta: dict) -> str:
    def esc(s: object) -> str:
        return html.escape(str(s)) if s is not None else ""

    from collections import Counter
    status_counts = Counter(d.decision_status for d in decisions)
    rule_counts = Counter(t.rule_id for t in traces)

    parts: list[str] = []
    parts.append("<!doctype html><html lang='ko'><head><meta charset='utf-8'>")
    parts.append("<title>Background Spatial Decision — W3 (sample run)</title>")
    parts.append("<style>")
    parts.append("body{font-family:-apple-system,sans-serif;margin:24px;color:#111}")
    parts.append(".banner{background:#e7f3ff;border:2px solid #1e88e5;"
                 "padding:16px;border-radius:8px;margin-bottom:24px;"
                 "display:flex;gap:24px;align-items:center;flex-wrap:wrap}")
    parts.append(".banner .metric{font-size:14px;color:#555}")
    parts.append(".banner .metric strong{display:block;font-size:32px;"
                 "color:#111;margin-top:4px}")
    parts.append(".banner .metric.warn strong{color:#d97706}")
    parts.append(".banner .metric.danger strong{color:#d00}")
    parts.append("h2{border-bottom:1px solid #ccc;padding-bottom:6px;margin-top:36px}")
    parts.append("table{border-collapse:collapse;margin:12px 0;font-size:13px}")
    parts.append("th,td{border:1px solid #ccc;padding:6px 10px;vertical-align:top}")
    parts.append("th{background:#f6f6f6;text-align:left}")
    parts.append(".pill{display:inline-block;padding:2px 8px;border-radius:12px;"
                 "font-size:11px;font-weight:600}")
    parts.append(".pill.ok{background:#d4edda;color:#155724}")
    parts.append(".pill.warn{background:#fff3cd;color:#856404}")
    parts.append(".pill.danger{background:#f8d7da;color:#721c24}")
    parts.append("</style></head><body>")

    parts.append(
        "<p style='font-size:13px;color:#444;background:#fff8e1;"
        "border-left:4px solid #ffb300;padding:10px 14px;margin:0 0 18px 0'>"
        "<b>Scope:</b> Generic background spatial decision experiment. "
        "Input = artifacts only (no DB). 일반화된 방법론 검증, sample fixture "
        "의 값을 production rule 로 승격 금지."
        "</p>"
    )

    parts.append("<div class='banner'>")
    parts.append(f"<div class='metric'>total_shots<strong>{len(decisions)}</strong></div>")
    parts.append(
        f"<div class='metric ok'>resolved<strong>"
        f"{status_counts.get('resolved_to_master', 0) + status_counts.get('resolved_to_derived_state', 0) + status_counts.get('resolved_to_derived_camera', 0)}</strong></div>"
    )
    parts.append(
        f"<div class='metric warn'>needs_user_decision<strong>"
        f"{status_counts.get('needs_user_decision', 0)}</strong></div>"
    )
    parts.append(
        f"<div class='metric warn'>requires_derived_combo<strong>"
        f"{status_counts.get('requires_derived_combo', 0)}</strong></div>"
    )
    parts.append(
        f"<div class='metric danger'>impossible_topology<strong>"
        f"{status_counts.get('impossible_without_topology_update', 0)}</strong></div>"
    )
    parts.append(
        f"<div class='metric'>rollup_requests<strong>"
        f"{len(rollups)}</strong></div>"
    )
    parts.append(f"<div class='metric'>derived_combos<strong>{len(combos)}</strong></div>")
    parts.append(f"<div class='metric'>run_id<strong>{esc(run_meta.get('run_id', ''))}</strong></div>")
    parts.append("</div>")

    # §1. shots grouped by status.
    parts.append("<h2>§1. Shots by decision_status</h2>")
    sorted_statuses = sorted(
        status_counts, key=lambda s: (s != "needs_user_decision", s),
    )
    for status in sorted_statuses:
        parts.append(f"<h3>{esc(status)} ({status_counts[status]})</h3>")
        parts.append("<table><thead><tr><th>shot_id</th>"
                     "<th>selected_space</th><th>state_layer</th>"
                     "<th>camera_family</th><th>bound_unit_ids</th>"
                     "<th>unresolved</th><th>notes</th></tr></thead><tbody>")
        for d in decisions:
            if d.decision_status != status:
                continue
            parts.append(
                f"<tr><td>{esc(d.shot_id)}</td>"
                f"<td>{esc(d.selected_space_node or '')}</td>"
                f"<td>{esc(d.selected_state_layer or '')}</td>"
                f"<td>{esc(d.selected_camera_family or '')}</td>"
                f"<td>{esc(','.join(d.bound_unit_ids))}</td>"
                f"<td>{esc(','.join(d.unresolved_reasons))}</td>"
                f"<td>{esc('; '.join(d.decision_notes))}</td></tr>"
            )
        parts.append("</tbody></table>")

    # §2. rollup decision requests.
    parts.append("<h2>§2. Rollup decision requests (≤ 5 큰 결정)</h2>")
    parts.append("<p style='font-size:12px;color:#666'>"
                 "Each rollup option은 선택 시 발생하는 downstream "
                 "consequence (resulting_status / required follow-ups) 를 "
                 "<b>option outcomes</b> 컬럼에 요약합니다. 사용자는 옵션을 "
                 "고르기 전 follow-up 비용을 예측할 수 있습니다.</p>")
    parts.append("<table><thead><tr><th>rollup_id</th><th>decision_type</th>"
                 "<th>affected_shots</th><th>options</th>"
                 "<th>option outcomes</th>"
                 "<th>risk_if_wrong</th></tr></thead><tbody>")
    for r in rollups:
        outcome_html = _render_option_outcomes(r, esc)
        parts.append(
            f"<tr><td><code>{esc(r.rollup_id)}</code></td>"
            f"<td>{esc(r.decision_type)}</td>"
            f"<td>{esc(','.join(r.affected_shots))}</td>"
            f"<td>{esc(','.join(r.options))}</td>"
            f"<td>{outcome_html}</td>"
            f"<td>{esc(r.risk_if_wrong)}</td></tr>"
        )
    parts.append("</tbody></table>")

    # §3. derived combos.
    parts.append("<h2>§3. Derived combo plan</h2>")
    parts.append("<table><thead><tr><th>combo_id</th><th>based_on_master</th>"
                 "<th>state_layer</th><th>camera_view</th>"
                 "<th>applies_to_shots</th><th>why_not_existing_unit</th>"
                 "</tr></thead><tbody>")
    for c in combos:
        parts.append(
            f"<tr><td><code>{esc(c.combo_id)}</code></td>"
            f"<td>{esc(c.based_on_master)}</td>"
            f"<td>{esc(c.state_layer)}</td>"
            f"<td><code>{esc(c.camera_view)}</code></td>"
            f"<td>{esc(','.join(c.applies_to_shots))}</td>"
            f"<td>{esc(c.why_not_existing_unit)}</td></tr>"
        )
    parts.append("</tbody></table>")

    # §4. rule trace summary.
    parts.append("<h2>§4. Decision rule trace (frequency)</h2>")
    parts.append("<table><thead><tr><th>rule_id</th><th>count</th>"
                 "</tr></thead><tbody>")
    for rid, cnt in sorted(rule_counts.items(), key=lambda kv: -kv[1]):
        parts.append(f"<tr><td><code>{esc(rid)}</code></td><td>{cnt}</td></tr>")
    parts.append("</tbody></table>")

    parts.append("</body></html>")
    return "".join(parts)


# -----------------------------------------------------------------------------
# main + CLI
# -----------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
    ap = argparse.ArgumentParser(
        description="background spatial decision experiment W3 (dry-run)",
    )
    ap.add_argument("--input-run", type=Path,
                    default=DEFAULT_INPUT_RUN,
                    help="W2e run dir (input artifacts)")
    ap.add_argument("--output-root", type=Path,
                    default=DEFAULT_OUTPUT_DIR,
                    help="output root")
    ap.add_argument("--no-serve", action="store_true",
                    help="reserved/no-op (no built-in webserver)")
    return ap.parse_args()


def main() -> None:
    args = parse_args()
    input_run = args.input_run
    if not input_run.is_absolute():
        input_run = _REPO_ROOT / input_run
    output_root = args.output_root
    if not output_root.is_absolute():
        output_root = _REPO_ROOT / output_root

    artifacts = load_w2e_artifacts(input_run)
    decisions, traces, rollups, combos = build_decision_run(artifacts)

    from collections import Counter
    status_counts = Counter(d.decision_status for d in decisions)

    run_id = _run_id()
    out_dir = output_root / run_id
    run_meta = {
        "run_id": run_id,
        "plan_version": PLAN_VERSION,
        "generated_at": _now_iso(),
        "input_run": str(input_run),
        "input_files": [
            "space_nodes.json", "state_layers.json", "structural_versions.json",
            "shot_bindings.json", "generation_unit_plan.json",
            "chain_bg_decomposition.json", "run_meta.json",
        ],
        "shot_count": len(decisions),
        "rollup_count": len(rollups),
        "combo_count": len(combos),
        "rule_trace_count": len(traces),
        "status_counts": dict(status_counts),
    }

    write_outputs(out_dir, decisions, traces, rollups, combos, run_meta)

    print(f"[bsd] run_id={run_id}")
    print(f"[bsd] out_dir={out_dir}")
    print(f"[bsd] shots={len(decisions)} rollups={len(rollups)} combos={len(combos)}")
    print(f"[bsd] status_counts={dict(status_counts)}")


if __name__ == "__main__":
    main()
