"""W21B (2026-06-08) — dwelling_zone_map pure core.

The cross-space consistency problem: the edge-judge (``bg_space_partition``)
cannot group bgs of the SAME dwelling that are framed from different camera
angles (on an observed multi-room dwelling it scored only one bg pair as
``same_space`` while the cross-angle pairs fell to ``adjacent_related``
confidence below the strong gate) — so the same dwelling renders as many
independent plates and looks different in every shot.

This module owns the DETERMINISTIC core of the alternative grouping path:
group bgs into dwelling candidates, validate the VLM zone-mapping output,
assemble the per-dwelling **zone map** contract (zones + bg/shot zone
assignments) by an exact-string join, compare it diagnostically against the
edge-judge grouping, and provide a synthetic fixture. It performs NO LLM /
image / VLM / DB I/O — the clean black-and-white floor-plan image generation
and the gpt-5.5 vision call live in ``dwelling_zone_map_provider`` and the
step, which inject their output through ``validate_vlm_zone_output`` /
``assemble_zone_map``.

Boundaries (scenario-leakage guard):
  - Code joins on exact bg_id / fp_id / shot_id strings and on closed
    structural vocabularies (``surface_role``). It NEVER lexically inspects
    labels or prose to infer a zone — zone identity comes only from the VLM
    assignment + the structured space analysis.
  - The CLEAN floor-plan reference (the one a downstream renderer may use as
    an i2i anchor) is kept strictly separate from the ANNOTATED reference
    (shot-number overlay) which is ``must_not_be_used_for_render`` — the
    contract carries both refs and the flag, never a single ambiguous ref.
"""
from __future__ import annotations

from collections import Counter, defaultdict
from typing import Any, Dict, List, Optional


# Only true dwelling interiors are zone-map targets. exterior / site /
# transition surfaces are single-purpose and never multi-zone dwellings.
INTERIOR_SURFACE_ROLES: frozenset = frozenset({"interior_room"})

# Normalized FP grid is 0..GRID_SIZE on both axes (matches the PoC, where the
# VLM reads x/y in 0..100 off the black-and-white plan image).
GRID_SIZE: int = 100

# Below this VLM confidence the bg's zone assignment is treated as ambiguous
# (still recorded, never silently dropped). Conservative: well under the
# edge-judge strong gate (0.75) so only genuinely weak placements are flagged.
CONFIDENCE_FLOOR: float = 0.5

# Padding (grid units) added around a zone's member points to give the derived
# bbox area — a single-member zone still has a non-degenerate box.
_ZONE_BBOX_PAD: int = 6

ASSIGNMENT_STATES: frozenset = frozenset({"ok", "ambiguous", "fallback"})


class DwellingZoneMapError(Exception):
    """Fail-closed signal for the dwelling-zone-map core."""


def _is_int(v: Any) -> bool:
    return isinstance(v, int) and not isinstance(v, bool)


def select_dwelling_targets(
    *,
    background_catalog: Dict[str, Any],
) -> Dict[str, Dict[str, Any]]:
    """Group interior bgs into per-dwelling candidates keyed by fp_id.

    Returns ``{fp_id: {"fp_id", "bg_ids" (sorted), "shot_ids" (sorted
    union), "applicable", "not_applicable_reason"}}``. Only bgs whose
    ``surface_role`` is a dwelling interior and that carry at least one
    ``depends_on_fp`` are grouped; exterior / site / transition / fp-less
    bgs are dropped. A dwelling with fewer than two bgs has nothing to
    cross-reference and is marked ``applicable=False`` (reason
    ``single_bg``) — the structural pre-gate; the 2+ DISTINCT zones check
    happens after the VLM map is assembled.
    """
    groups: Dict[str, Dict[str, Any]] = {}
    for bg_id in sorted(background_catalog or {}):
        entry = background_catalog.get(bg_id) or {}
        if not isinstance(entry, dict):
            continue
        if entry.get("surface_role") not in INTERIOR_SURFACE_ROLES:
            continue
        fps = entry.get("depends_on_fp") or []
        for fp in fps:
            if not isinstance(fp, str) or not fp:
                continue
            g = groups.setdefault(
                fp, {"fp_id": fp, "bg_ids": [], "_shots": set()}
            )
            g["bg_ids"].append(bg_id)
            for s in entry.get("applies_to_shots") or []:
                if isinstance(s, str) and s:
                    g["_shots"].add(s)

    out: Dict[str, Dict[str, Any]] = {}
    for fp, g in groups.items():
        bg_ids = sorted(set(g["bg_ids"]))
        applicable = len(bg_ids) >= 2
        out[fp] = {
            "fp_id": fp,
            "bg_ids": bg_ids,
            "shot_ids": sorted(g["_shots"]),
            "applicable": applicable,
            "not_applicable_reason": None if applicable else "single_bg",
        }
    return out


def build_analysis_from_space_model(
    space_model: Any,
) -> Dict[str, Any]:
    """Adapt a ``floor_plan_prompt.space_model`` into the analysis shape the FP
    generation + ``assemble_zone_map`` consume.

    ``space_model`` is the upstream canonical dwelling structure (every zone of
    the dwelling). This is the STRUCTURE SOT — the FP must show the WHOLE
    dwelling, not just the spaces that happen to have a bg plate. The adapter
    carries EVERY zone through (the bug it fixes: a sparse bg-only input dropped
    zones that no bg plate happened to frame), keeping ``zone_id`` as the unique
    room name and ``zone_type`` as the label so two rooms of the same type stay
    distinct. Returns
    ``{"space_type", "rooms": [{name, label, fixtures, same_space_as}], ...}``;
    empty / malformed input yields no rooms (the caller then falls back).
    """
    out: Dict[str, Any] = {
        "space_type": None, "summary": None, "rooms": [], "openings": [],
        "source": "floor_plan_prompt.space_model",
    }
    if not isinstance(space_model, dict):
        return out
    out["space_type"] = space_model.get("space_type")
    zones = space_model.get("zones")
    if not isinstance(zones, list):
        return out
    for z in zones:
        if not isinstance(z, dict):
            continue
        zid = z.get("zone_id")
        if not isinstance(zid, str) or not zid:
            continue
        out["rooms"].append({
            "name": zid,
            "label": z.get("zone_type") if isinstance(z.get("zone_type"), str) else zid,
            "fixtures": [
                e for e in (z.get("essential_elements") or []) if isinstance(e, str)
            ],
            "openings": [
                o for o in (z.get("openings") or []) if isinstance(o, str)
            ],
            "adjacency": [
                a for a in (z.get("adjacency") or []) if isinstance(a, str)
            ],
            "same_space_as": None,
        })
    return out


# numbered_elements closed structural enums. floor_plan_prompt v6+ packs emit
# NO space_model — numbered_elements is the structural SOT. The adapter reads
# ONLY these enums (never the free label/position_hint prose) so zone identity
# stays scenario-agnostic.
_NE_AREA_CATEGORY = "area"
_NE_AREA_DECISION = "base_structural_unit"
_NE_OPENING_CATEGORY = "opening"
_NE_OPENING_DECISION = "base_opening"
_NE_PERSISTENT_DECISIONS: frozenset = frozenset(
    {"base_persistent_fixture", "base_persistent_furniture"})


def build_analysis_from_numbered_elements(
    numbered_elements: Any,
) -> Dict[str, Any]:
    """Adapt floor_plan_prompt ``numbered_elements`` into the analysis shape
    ``build_analysis_from_space_model`` produces (the FALLBACK-tier structure
    SOT when no ``space_model`` is present — which is every current fp prompt
    pack, v6..v9).

    ``numbered_elements`` is a flat numbered list; each entry carries a
    ``category`` enum (area/opening/furniture/prop) and a ``base_layer_decision``
    enum. Only the CLOSED enums are read as structural authority — the free
    ``label``/``position_hint`` prose is never parsed for meaning (scenario-leak
    guard, mirrors ``surface_role`` joins elsewhere):

      - room/zone = ``category=='area'`` AND ``base_layer_decision=='base_structural_unit'``
      - opening   = ``category=='opening'`` AND ``base_layer_decision=='base_opening'``
      - fixture   = ``base_layer_decision`` in the persistent set — carried
        GLOBALLY (``global_fixtures``); code never infers which room a fixture
        belongs to (that needs position_hint prose). The FP prompt may show them
        all as dwelling-wide structural facts; code performs no per-room assignment.
      - ``state_overlay_*`` (transient props / plot cues) are EXCLUDED — they are
        events/state, not clean dwelling structure.

    Room ``name`` is the stable ``E<number>`` id, NOT the label: two rooms can
    share a label (e.g. two bedrooms) and a label key would merge them. The raw
    label is carried verbatim for the FP prompt / VLM but never used as a key or
    parsed. Empty / malformed input or zero structural areas yields no rooms, so
    the caller falls back to the bg-derived analysis.
    """
    out: Dict[str, Any] = {
        "space_type": None, "summary": None, "rooms": [], "openings": [],
        "global_fixtures": [], "source": "floor_plan_prompt.numbered_elements",
    }
    if not isinstance(numbered_elements, list):
        return out
    for e in numbered_elements:
        if not isinstance(e, dict):
            continue
        cat = e.get("category")
        dec = e.get("base_layer_decision")
        label = e.get("label") if isinstance(e.get("label"), str) else None
        if cat == _NE_AREA_CATEGORY and dec == _NE_AREA_DECISION:
            num = e.get("number")
            if not _is_int(num):
                continue
            out["rooms"].append({
                "name": f"E{num}",
                "label": label if label else f"E{num}",
                "fixtures": [],
                "openings": [],
                "adjacency": [],
                "same_space_as": None,
            })
        elif cat == _NE_OPENING_CATEGORY and dec == _NE_OPENING_DECISION:
            if label:
                out["openings"].append(label)
        elif dec in _NE_PERSISTENT_DECISIONS:
            if label:
                out["global_fixtures"].append(label)
        # state_overlay_* and anything else: excluded from clean structure.
    return out


def validate_vlm_zone_output(
    *,
    raw: Any,
    expected_bg_ids: List[str],
    grid_size: int = GRID_SIZE,
) -> Dict[str, Any]:
    """Strictly validate the per-bg VLM zone-mapping output.

    The VLM returns a list of per-bg records ``{bg_id, room, zone_group,
    grid{x,y}, confidence, evidence}`` (the raw grouping uses opaque
    ``zone_group`` strings; the deterministic Zxx assignment happens in
    ``assemble_zone_map``). This checks: list shape; every record a dict
    with a known bg_id; exact coverage (no missing / unexpected bg); a
    non-empty ``zone_group`` string; ``grid`` an ``{x,y}`` pair inside
    ``[0, grid_size]``; ``confidence`` a number in ``[0, 1]``. Returns
    ``{"ok", "blockers": [str], "normalized": {bg_id: record}}`` —
    ``normalized`` is ``{}`` whenever ``ok`` is False.
    """
    blockers: List[str] = []
    expected = set(expected_bg_ids or [])
    if not isinstance(raw, list):
        return {
            "ok": False,
            "blockers": [f"vlm output is not a list ({type(raw).__name__})"],
            "normalized": {},
        }

    normalized: Dict[str, Any] = {}
    seen: set = set()
    for idx, rec in enumerate(raw):
        pfx = f"vlm[{idx}]"
        if not isinstance(rec, dict):
            blockers.append(f"{pfx} not a dict")
            continue
        bg_id = rec.get("bg_id")
        if not isinstance(bg_id, str) or not bg_id:
            blockers.append(f"{pfx}.bg_id missing or non-string")
            continue
        if bg_id not in expected:
            blockers.append(f"{pfx}.bg_id={bg_id!r} not an expected bg")
            continue
        if bg_id in seen:
            blockers.append(f"{pfx}.bg_id={bg_id!r} duplicated")
            continue
        seen.add(bg_id)

        group = rec.get("zone_group")
        if not isinstance(group, str) or not group.strip():
            blockers.append(f"{pfx} #{bg_id} zone_group missing or empty")

        grid = rec.get("grid")
        gx = gy = None
        if not isinstance(grid, dict):
            blockers.append(f"{pfx} #{bg_id} grid not an {{x,y}} object")
        else:
            gx, gy = grid.get("x"), grid.get("y")
            if not _is_num(gx) or not _is_num(gy):
                blockers.append(f"{pfx} #{bg_id} grid x/y not numeric")
                gx = gy = None
            elif not (0 <= gx <= grid_size and 0 <= gy <= grid_size):
                blockers.append(
                    f"{pfx} #{bg_id} grid out-of-bound ({gx},{gy})")

        conf = rec.get("confidence")
        if not _is_num(conf) or not (0.0 <= float(conf) <= 1.0):
            blockers.append(f"{pfx} #{bg_id} confidence not in [0,1]")

        normalized[bg_id] = {
            "bg_id": bg_id,
            "room": rec.get("room") if isinstance(rec.get("room"), str) else "",
            "zone_group": group if isinstance(group, str) else "",
            "grid": {"x": gx, "y": gy} if gx is not None else None,
            "confidence": float(conf) if _is_num(conf) else None,
            "evidence": rec.get("evidence") if isinstance(rec.get("evidence"), str) else "",
        }

    missing = sorted(expected - seen)
    for bg_id in missing:
        blockers.append(f"missing zone assignment for bg {bg_id}")

    if blockers:
        return {"ok": False, "blockers": blockers, "normalized": {}}
    return {"ok": True, "blockers": [], "normalized": normalized}


def _is_num(v: Any) -> bool:
    return isinstance(v, (int, float)) and not isinstance(v, bool)


def _most_common_room(
    member_ids: List[str], vlm_normalized: Dict[str, Any]
) -> Optional[str]:
    rooms = [
        vlm_normalized[b].get("room")
        for b in member_ids
        if isinstance(vlm_normalized[b].get("room"), str)
        and vlm_normalized[b].get("room")
    ]
    if not rooms:
        return None
    return Counter(rooms).most_common(1)[0][0]


def _point_in_bbox(bbox: Dict[str, int], x: float, y: float) -> bool:
    return (
        bbox["x"] <= x <= bbox["x"] + bbox["w"]
        and bbox["y"] <= y <= bbox["y"] + bbox["h"]
    )


def assemble_zone_map(
    *,
    fp_id: str,
    vlm_normalized: Dict[str, Any],
    bg_shot_map: Dict[str, List[str]],
    clean_fp_ref: Any,
    annotated_fp_ref: Any,
    space_analysis: Optional[Dict[str, Any]] = None,
    grid_size: int = GRID_SIZE,
    confidence_floor: float = CONFIDENCE_FLOOR,
) -> Dict[str, Any]:
    """Deterministically join the validated VLM zone output into the
    per-dwelling zone-map contract.

    Steps (exact-string / numeric joins only — no lexical inference):
      1. Assign ``Zxx`` ids in first-seen order over bgs sorted by bg_id, so
         the first bg's zone is always ``Z01`` (stable, intuitive).
      2. Derive each zone's ``grid_bbox`` from its members' grid points
         (padded), ``label`` from the members' most-common ``room``,
         ``structure_cues`` from the space analysis by an EXACT room-name
         match (empty when no exact match), ``confidence`` = member min.
      3. ``bg_zone_assignments``: zone, member grid_focus, bound shots,
         ``assignment_state`` (``ok`` / ``ambiguous`` — low confidence or a
         grid point landing inside another zone's bbox / ``fallback`` — no
         grid). Low-confidence bgs are also listed in diagnostics.
      4. ``shot_zone_assignments``: invert ``bg_shot_map``; a shot bound to
         two bgs of this dwelling is ``ambiguous``.

    The CLEAN render ref and the ANNOTATED (number-overlay, render-forbidden)
    ref are carried as separate fields so a downstream i2i anchor can never
    grab the numbered image. Returns ``{"ok", "blockers", "zone_map"}``.
    """
    # 1) Zxx ids in first-seen order over sorted bgs.
    zone_id_by_group: Dict[str, str] = {}
    bg_zone: Dict[str, str] = {}
    for bg_id in sorted(vlm_normalized):
        group = vlm_normalized[bg_id].get("zone_group") or ""
        if group not in zone_id_by_group:
            zone_id_by_group[group] = f"Z{len(zone_id_by_group) + 1:02d}"
        bg_zone[bg_id] = zone_id_by_group[group]

    members_by_zone: Dict[str, List[str]] = defaultdict(list)
    for bg_id in sorted(vlm_normalized):
        members_by_zone[bg_zone[bg_id]].append(bg_id)

    # space analysis: EXACT room-name → fixtures (deterministic join only).
    room_fixtures: Dict[str, List[str]] = {}
    if isinstance(space_analysis, dict):
        for r in space_analysis.get("rooms") or []:
            if isinstance(r, dict) and isinstance(r.get("name"), str):
                room_fixtures[r["name"]] = [
                    f for f in (r.get("fixtures") or []) if isinstance(f, str)
                ]

    # 2) zones.
    zones: Dict[str, Any] = {}
    for group, zid in zone_id_by_group.items():
        member_ids = members_by_zone[zid]
        pts = [
            vlm_normalized[b]["grid"]
            for b in member_ids
            if isinstance(vlm_normalized[b].get("grid"), dict)
        ]
        xs = [p["x"] for p in pts]
        ys = [p["y"] for p in pts]
        if xs:
            x0 = max(0, min(xs) - _ZONE_BBOX_PAD)
            y0 = max(0, min(ys) - _ZONE_BBOX_PAD)
            x1 = min(grid_size, max(xs) + _ZONE_BBOX_PAD)
            y1 = min(grid_size, max(ys) + _ZONE_BBOX_PAD)
        else:
            x0, y0, x1, y1 = 0, 0, grid_size, grid_size
        label = _most_common_room(member_ids, vlm_normalized) or group
        confs = [
            vlm_normalized[b]["confidence"]
            for b in member_ids
            if _is_num(vlm_normalized[b].get("confidence"))
        ]
        zones[zid] = {
            "label": label,
            "grid_bbox": {"x": x0, "y": y0, "w": x1 - x0, "h": y1 - y0},
            "structure_cues": room_fixtures.get(label, []),
            "confidence": min(confs) if confs else None,
            "member_bg_ids": member_ids,
        }

    # 3) bg_zone_assignments.
    low_confidence: List[str] = []
    bg_assign: Dict[str, Any] = {}
    for bg_id in sorted(vlm_normalized):
        rec = vlm_normalized[bg_id]
        zid = bg_zone[bg_id]
        grid = rec.get("grid") if isinstance(rec.get("grid"), dict) else None
        conf = rec.get("confidence")
        if grid is None:
            state = "fallback"
        else:
            low = not _is_num(conf) or float(conf) < confidence_floor
            ambiguous = any(
                z2 != zid
                and _point_in_bbox(zones[z2]["grid_bbox"], grid["x"], grid["y"])
                for z2 in zones
            )
            if low:
                low_confidence.append(bg_id)
            state = "ambiguous" if (low or ambiguous) else "ok"
        bg_assign[bg_id] = {
            "zone_id": zid,
            "shot_ids": sorted(bg_shot_map.get(bg_id) or []),
            "grid_focus": grid,
            "confidence": conf if _is_num(conf) else None,
            "assignment_state": state,
        }

    # 4) shot_zone_assignments (invert bg_shot_map; intra-dwelling only).
    shot_to_bgs: Dict[str, List[str]] = defaultdict(list)
    for bg_id, shots in (bg_shot_map or {}).items():
        if bg_id not in vlm_normalized:
            continue
        for s in shots or []:
            if isinstance(s, str) and s:
                shot_to_bgs[s].append(bg_id)
    shot_assign: Dict[str, Any] = {}
    for shot in sorted(shot_to_bgs):
        bgs = sorted(set(shot_to_bgs[shot]))
        if not bgs:
            continue
        bg_id = bgs[0]
        state = bg_assign[bg_id]["assignment_state"]
        if len(bgs) > 1:
            state = "ambiguous"
        shot_assign[shot] = {
            "bg_id": bg_id,
            "zone_id": bg_assign[bg_id]["zone_id"],
            "grid_focus": bg_assign[bg_id]["grid_focus"],
            "assignment_state": state,
        }

    zone_count = len(zones)
    applicable = zone_count >= 2
    not_applicable_reason = (
        None if applicable else ("single_zone" if zone_count == 1 else "no_zones")
    )
    zone_map = {
        "fp_id": fp_id,
        "synthetic": False,
        "clean_fp_ref": clean_fp_ref,
        "annotated_fp_ref": {
            "ref": annotated_fp_ref,
            "must_not_be_used_for_render": True,
            "asset_role": "vlm_mapping_only",
        },
        "grid": {"size": grid_size},
        "zones": zones,
        "bg_zone_assignments": bg_assign,
        "shot_zone_assignments": shot_assign,
        "diagnostics": {
            "zone_count": zone_count,
            "single_zone": zone_count < 2,
            "low_confidence": low_confidence,
            "edge_judge_comparison": None,
        },
        "applicable": applicable,
        "not_applicable_reason": not_applicable_reason,
    }
    return {"ok": True, "blockers": [], "zone_map": zone_map}


def compare_to_edge_judge(
    *,
    zone_map: Dict[str, Any],
    partition_plan: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
    """Diagnostic-only comparison of the VLM zone grouping against the
    edge-judge plate grouping (``bg_space_partition.node_assignments``).

    The edge-judge cannot unify same-dwelling bgs framed from different
    angles; this surfaces exactly the pairs the zone map merged
    (``merged_by_zone_map``) and any it split that the edge-judge had
    unified (``split_by_zone_map``). It NEVER changes an assignment — the
    zone map stays authoritative. Returns empty lists when no plan exists.
    """
    bg_zone = {
        bg: a.get("zone_id")
        for bg, a in (zone_map.get("bg_zone_assignments") or {}).items()
    }
    node: Dict[str, Any] = {}
    if isinstance(partition_plan, dict):
        node = partition_plan.get("node_assignments") or {}
    available = bool(node)
    common = sorted(set(bg_zone) & set(node)) if available else []

    merged: List[List[str]] = []
    split: List[List[str]] = []
    agreement = 0
    for i in range(len(common)):
        for j in range(i + 1, len(common)):
            a, b = common[i], common[j]
            z_same = bg_zone[a] == bg_zone[b]
            e_same = node[a] == node[b]
            if z_same and not e_same:
                merged.append([a, b])
            elif e_same and not z_same:
                split.append([a, b])
            else:
                agreement += 1
    return {
        "edge_judge_available": available,
        "edge_judge_group_count": len(set(node.values())) if available else 0,
        "zone_map_group_count": len(set(bg_zone.values())),
        "merged_by_zone_map": merged,
        "split_by_zone_map": split,
        "agreement_pair_count": agreement,
        "compared_pair_count": len(common) * (len(common) - 1) // 2,
    }


def compute_synthetic_zone_map(
    *,
    fp_id: str,
    bg_ids: List[str],
    bg_shot_map: Optional[Dict[str, List[str]]] = None,
) -> Dict[str, Any]:
    """Non-authoritative placeholder zone map (default-off / no-real-provider
    path). Asserts NOTHING — every bg is ``fallback`` with no zone — so a
    synthetic run can never be mistaken for a real VLM observation, while the
    contract shape stays uniform for downstream readers.
    """
    bg_shot_map = bg_shot_map or {}
    bg_assign = {
        bg: {
            "zone_id": None,
            "shot_ids": sorted(bg_shot_map.get(bg) or []),
            "grid_focus": None,
            "confidence": None,
            "assignment_state": "fallback",
        }
        for bg in sorted(bg_ids)
    }
    return {
        "fp_id": fp_id,
        "synthetic": True,
        "clean_fp_ref": None,
        "annotated_fp_ref": {
            "ref": None,
            "must_not_be_used_for_render": True,
            "asset_role": "vlm_mapping_only",
        },
        "grid": {"size": GRID_SIZE},
        "zones": {},
        "bg_zone_assignments": bg_assign,
        "shot_zone_assignments": {},
        "diagnostics": {
            "zone_count": 0,
            "single_zone": True,
            "low_confidence": [],
            "edge_judge_comparison": None,
            "note": (
                "synthetic_fixture: non-authoritative placeholder; no real "
                "VLM zone observation"
            ),
        },
        "applicable": False,
        "not_applicable_reason": "synthetic_fixture",
    }
