"""W19B-1: deterministic per-bg overlay payload builder.

``floor_plan_prompt`` (v6) 의 ``numbered_elements`` + ``camera_recommendations``
와 ``background_master_plan`` 의 backgrounds DAG 만으로 per-bg overlay payload
를 산출한다. LLM / image / VLM / DB / ImageAsset write 0.

Locked from Codex (W19B-1 review):
- production v6 는 ``unit_id`` 문자열을 emit 하지 않는다. 따라서 본 모듈은
  unit identity 를 ``base_structural_unit`` marker **number** 로만 본다.
- ``dominant_target_unit_marker_number`` 는 use 안에 ``base_structural_unit``
  marker 가 **정확히 하나** 일 때만 set. 그 외는 None + diagnostic.
- label / position_hint / sub_location / prose 기반 추론 금지 — 모든 join 은
  marker number / fp_id / bg_id exact integer 또는 exact string equality.
- unknown number, use/ignore overlap, missing camera, missing fp 모두 fail-closed.

Step (`FloorPlanOverlayPayloadStep`) 가 본 모듈을 호출 + checkpoint 직렬화.
본 모듈은 pure-function — 부수효과 없음, deterministic.
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional


# W19B-1 (Codex BLOCKING fix): partition / membership 검증을 `startswith("base_")` /
# `startswith("state_overlay_")` 같은 prefix matching 으로 하지 않는다. 사용자가
# 강조한 "substring / prefix 기반 의미 분류 금지" 원칙 + W19A/W19A2 의 exact enum
# 계약을 지키기 위해 frozenset exact membership 만 사용한다.
BASE_LAYER_DECISIONS = frozenset({
    "base_structural_unit",
    "base_opening",
    "base_persistent_fixture",
    "base_persistent_furniture",
})
STATE_OVERLAY_DECISIONS = frozenset({
    "state_overlay_plot_cue",
    "state_overlay_transient_object",
})
ALL_LAYER_DECISIONS = BASE_LAYER_DECISIONS | STATE_OVERLAY_DECISIONS

# W21B-wave-4 A2: optional per-marker render meta. enum exact-membership 만
# (substring / lexical 0). 둘 다 'not_applicable' sentinel 을 가지므로 메타
# 미주입 / 부분 주입 시 옛 동작이 그대로 유지된다 (backward-compat).
#   - render_role: light_fp_composition 의 deterministic 분류 (A3 에서 production
#     주입; A2 는 carry slot 만).
#   - top_down_legibility: floor_plan_semantic_readback 의 VLM semantic_match
#     enum 매핑 (match→clear / uncertain→low). label 텍스트 분류 아님.
RENDER_ROLE_VALUES = frozenset({
    "structural_skeleton",
    "scene_essential_fixtures",
    "persistent_context",
    "not_applicable",
})
TOP_DOWN_LEGIBILITY_VALUES = frozenset({"clear", "low", "not_applicable"})


class FloorPlanOverlayPayloadError(Exception):
    """fail-closed: silent inference 회피용."""


def _index_floor_plans(fp_prompt_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
    fps = (fp_prompt_data or {}).get("floor_plans") or {}
    return {
        fid: entry
        for fid, entry in fps.items()
        if isinstance(entry, dict) and entry.get("status") == "ok"
    }


def _collect_bg_specs(master_plan_data: Dict[str, Any]) -> List[Dict[str, Any]]:
    out: List[Dict[str, Any]] = []
    plans = (master_plan_data or {}).get("plans") or {}
    for _gid, entry in plans.items():
        if not isinstance(entry, dict) or entry.get("status") != "ok":
            continue
        plan = entry.get("plan") or {}
        for bg in plan.get("backgrounds") or []:
            if isinstance(bg, dict) and bg.get("bg_id"):
                out.append(bg)
    return out


def _coerce_int_array(
    raw: Any, *, bg_id: str, field: str
) -> List[int]:
    if not isinstance(raw, list):
        raise FloorPlanOverlayPayloadError(
            f"bg_id={bg_id!r} camera.{field} missing or not a list"
        )
    coerced: List[int] = []
    for idx, item in enumerate(raw):
        # bool 도 isinstance(int) 라 별도 차단 — JSON-side 정수만.
        if isinstance(item, bool) or not isinstance(item, int):
            raise FloorPlanOverlayPayloadError(
                f"bg_id={bg_id!r} camera.{field}[{idx}] is not an integer "
                f"(got {item!r})"
            )
        coerced.append(item)
    return coerced


def _marker_entry(
    marker_by_num: Dict[int, Dict[str, Any]],
    num: int,
    meta_for_marker: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    ne = marker_by_num[num]
    render_role = "not_applicable"
    top_down_legibility = "not_applicable"
    if meta_for_marker:
        render_role = meta_for_marker.get("render_role", "not_applicable")
        top_down_legibility = meta_for_marker.get(
            "top_down_legibility", "not_applicable"
        )
        if render_role not in RENDER_ROLE_VALUES:
            raise FloorPlanOverlayPayloadError(
                f"marker #{num} render_role={render_role!r} not in "
                f"{sorted(RENDER_ROLE_VALUES)}"
            )
        if top_down_legibility not in TOP_DOWN_LEGIBILITY_VALUES:
            raise FloorPlanOverlayPayloadError(
                f"marker #{num} top_down_legibility={top_down_legibility!r} "
                f"not in {sorted(TOP_DOWN_LEGIBILITY_VALUES)}"
            )
    return {
        "number": num,
        "label": ne.get("label", ""),
        "category": ne.get("category", ""),
        "position_hint": ne.get("position_hint", ""),
        "base_layer_decision": ne.get("base_layer_decision", ""),
        "render_role": render_role,
        "top_down_legibility": top_down_legibility,
    }


def build_overlay_payload(
    *,
    fp_prompt_data: Dict[str, Any],
    master_plan_data: Dict[str, Any],
    marker_meta_by_fp: Optional[Dict[str, Dict[int, Dict[str, Any]]]] = None,
) -> Dict[str, Dict[str, Any]]:
    """deterministic per-bg overlay payload.

    Returns: ``{bg_id: payload_dict}``. master_plan plans 순회 순서 보존.

    Per-bg payload shape (W19B-1 lock):
        bg_id, fp_id,
        use_numbered_elements (sorted), ignore_numbered_elements (sorted),
        base_markers_to_reference, transient_markers_to_describe,
        ignored_state_overlay_markers,
        target_unit_marker_numbers, dominant_target_unit_marker_number,
        clean_background_expected (bool), diagnostics (list[str]).

    Exact integer / string join only. label / position_hint / sub_location /
    prose 기반 추론 금지.
    """
    fp_index = _index_floor_plans(fp_prompt_data)
    bg_specs = _collect_bg_specs(master_plan_data)

    output: Dict[str, Dict[str, Any]] = {}
    for bg in bg_specs:
        bg_id = bg["bg_id"]

        depends = bg.get("depends_on_fp") or []
        if not depends:
            surface_role = bg.get("surface_role", "interior_room") or "interior_room"
            if surface_role in {"exterior_plate", "transition_zone", "site_surface"}:
                # W21B-wave-2: fp-less exterior / transition / site plates are
                # not backed by floor_plan_prompt numbered markers. They bypass
                # overlay payload and are handled by background_prompt v9's
                # fp-less surface-plate path.
                continue
            raise FloorPlanOverlayPayloadError(
                f"bg_id={bg_id!r} has empty depends_on_fp — cannot resolve fp_id"
            )
        fp_id = depends[0]
        fp_entry = fp_index.get(fp_id)
        if fp_entry is None:
            raise FloorPlanOverlayPayloadError(
                f"bg_id={bg_id!r} depends_on_fp={fp_id!r} not found in "
                f"floor_plan_prompt.data.floor_plans (or status != ok)"
            )

        marker_by_num: Dict[int, Dict[str, Any]] = {}
        for ne in fp_entry.get("numbered_elements") or []:
            if not isinstance(ne, dict) or "number" not in ne:
                continue
            try:
                num = int(ne["number"])
            except (TypeError, ValueError):
                continue
            marker_by_num[num] = ne

        # W19B-1 (Codex BLOCKING fix): payload build 진입 단계에서 모든 marker
        # 의 base_layer_decision 이 exact enum membership 인지 검증. unknown /
        # missing / non-string 모두 fail-closed (diagnostic-only 아님).
        for num, ne in marker_by_num.items():
            decision = ne.get("base_layer_decision")
            if not isinstance(decision, str):
                raise FloorPlanOverlayPayloadError(
                    f"fp_id={fp_id!r} marker #{num} base_layer_decision missing "
                    f"or non-string (got {decision!r})"
                )
            if decision not in ALL_LAYER_DECISIONS:
                raise FloorPlanOverlayPayloadError(
                    f"fp_id={fp_id!r} marker #{num} base_layer_decision="
                    f"{decision!r} not in allowed enum "
                    f"{sorted(ALL_LAYER_DECISIONS)}"
                )

        cam: Optional[Dict[str, Any]] = None
        for c in fp_entry.get("camera_recommendations") or []:
            if isinstance(c, dict) and c.get("bg_id") == bg_id:
                cam = c
                break
        if cam is None:
            raise FloorPlanOverlayPayloadError(
                f"bg_id={bg_id!r} missing camera_recommendations entry under "
                f"fp_id={fp_id!r}"
            )

        use_list = _coerce_int_array(
            cam.get("use_numbered_elements"),
            bg_id=bg_id,
            field="use_numbered_elements",
        )
        ignore_list = _coerce_int_array(
            cam.get("ignore_numbered_elements"),
            bg_id=bg_id,
            field="ignore_numbered_elements",
        )

        use_set = set(use_list)
        ignore_set = set(ignore_list)
        overlap = use_set & ignore_set
        if overlap:
            raise FloorPlanOverlayPayloadError(
                f"bg_id={bg_id!r} use_numbered_elements ∩ "
                f"ignore_numbered_elements non-empty: {sorted(overlap)}"
            )
        unknown = (use_set | ignore_set) - set(marker_by_num)
        if unknown:
            raise FloorPlanOverlayPayloadError(
                f"bg_id={bg_id!r} use/ignore references unknown marker numbers: "
                f"{sorted(unknown)}"
            )

        diagnostics: List[str] = []

        # W19B-1 (Codex BLOCKING fix): partition 은 frozenset exact membership 만
        # 사용. 위 enum 검증을 통과한 marker 만 여기 들어오므로 unknown decision
        # branch 는 발생 불가 (방어용 assert 도 두지 않음 — 위 raise 가 SOT).
        fp_meta = (marker_meta_by_fp or {}).get(fp_id) or {}

        def _meta_for(n: int) -> Optional[Dict[str, Any]]:
            # Codex Required (A2): marker number key 가 JSON / checkpoint 를
            # 거치면 문자열이 될 수 있다 (예: ``{"7": {...}}``). int / str 둘 다
            # 조회해 carry slot 이 조용히 na 로 떨어지는 것을 막는다.
            return fp_meta.get(n) or fp_meta.get(str(n))

        base_markers_to_reference: List[Dict[str, Any]] = []
        transient_markers_to_describe: List[Dict[str, Any]] = []
        for num in sorted(use_set):
            entry = _marker_entry(marker_by_num, num, _meta_for(num))
            decision = entry["base_layer_decision"]
            if decision in BASE_LAYER_DECISIONS:
                base_markers_to_reference.append(entry)
            elif decision in STATE_OVERLAY_DECISIONS:
                transient_markers_to_describe.append(entry)

        ignored_state_overlay_markers: List[Dict[str, Any]] = []
        for num in sorted(ignore_set):
            entry = _marker_entry(marker_by_num, num, _meta_for(num))
            decision = entry["base_layer_decision"]
            if decision in STATE_OVERLAY_DECISIONS:
                ignored_state_overlay_markers.append(entry)
            elif decision in BASE_LAYER_DECISIONS:
                diagnostics.append(
                    f"marker #{num} in ignore_numbered_elements is a base layer "
                    f"marker ({decision!r}); structural elements normally remain "
                    "in use"
                )

        target_unit_marker_numbers = sorted(
            num
            for num in use_set
            if marker_by_num[num].get("base_layer_decision") == "base_structural_unit"
        )
        dominant_target_unit_marker_number: Optional[int]
        if len(target_unit_marker_numbers) == 1:
            dominant_target_unit_marker_number = target_unit_marker_numbers[0]
        else:
            dominant_target_unit_marker_number = None
            if not target_unit_marker_numbers:
                diagnostics.append(
                    "no base_structural_unit marker in use_numbered_elements; "
                    "dominant_target_unit_marker_number left empty"
                )
            else:
                diagnostics.append(
                    f"multiple base_structural_unit markers in use_numbered_elements "
                    f"({target_unit_marker_numbers}); "
                    "dominant_target_unit_marker_number left empty "
                    "(W19B-1 lock — no lexical disambiguation)"
                )

        output[bg_id] = {
            "bg_id": bg_id,
            "fp_id": fp_id,
            "use_numbered_elements": sorted(use_set),
            "ignore_numbered_elements": sorted(ignore_set),
            "base_markers_to_reference": base_markers_to_reference,
            "transient_markers_to_describe": transient_markers_to_describe,
            "ignored_state_overlay_markers": ignored_state_overlay_markers,
            "target_unit_marker_numbers": target_unit_marker_numbers,
            "dominant_target_unit_marker_number": dominant_target_unit_marker_number,
            "clean_background_expected": len(transient_markers_to_describe) == 0,
            "diagnostics": diagnostics,
        }
    return output
