"""W21B-w5 STEP5-B — simplified (CV-readable) floor-plan prompt builder.

Builds a GENERIC, deterministic simplified floor-plan ``t2i_prompt`` from a
floor plan's structured ``numbered_elements`` (the ``floor_plan_prompt`` v6/v7
output). The simplified FP is rendered as an **I2I-reference sidecar** for
``background_render`` — it is NOT the projection-card / edge-judge substrate
(the detailed FP stays that), so the W21B-w5 partition SOT is never disturbed
(STEP5-B boundary lock, Codex + Claude consensus).

Why a simpler FP: the production detailed FP draws every base element (furniture,
decor, dense small numbers, colour zones) which the image model (I2I) struggles
to read — rooms bleed into each other (a bathroom's fixtures float in the open
living area). A thick-walled, enclosed, fixture-only partition diagram lets the
renderer read independent rooms. The simplified FP carries SPATIAL ENCLOSURE
(walls / rooms / openings / essential fixtures); the per-shot CONTENT is supplied
by the zone-respecting background prompt (STEP6-C), so room-defining decorative
furniture is intentionally dropped here.

Absolute-rule compliance:
  * GENERIC — no scenario tokens / room names / proper nouns in this module. The
    only per-FP content is the runtime ``label`` / ``position_hint`` strings,
    carried through verbatim as opaque DATA (never matched on / pattern-parsed).
  * Element selection is by the closed ``base_layer_decision`` enum (the v6/v7
    contract), NOT by any text/substring inspection of labels.
"""
from __future__ import annotations

import hashlib
from typing import Any, Dict, List, Optional

# ─────────────────────────────────────────────────────────────────────
# W21B-w5 STEP5-B v0 (pivot 2) — DETERMINISTIC restyle EDIT prompt.
#
# The text-to-image generation path (LLM-written from-scratch layout, rendered
# with no reference) let the image model INVENT geometry — duplicate markers,
# spurious walls, double doors (user visual review + Codex+Claude I2I canary).
# The v0 production approach instead uses the already-correct detailed
# ``floor_plan_render`` PNG as an I2I REFERENCE (images.edit) and only RESTYLES
# it: thick enclosing walls, flat primitive fixtures, no color/labels — while
# PRESERVING every wall, opening, and numbered marker exactly. This is a single
# GENERIC deterministic instruction (no scenario tokens, no per-fp LLM) — the
# detailed FP carries all the per-fp content as the reference image.
# ─────────────────────────────────────────────────────────────────────

#: bump when the wording changes (provenance / cache-invalidation lever).
RESTYLE_PROMPT_VERSION = "1"

RESTYLE_EDIT_PROMPT = (
    "Redraw this detailed floor plan as a SPARSE black-and-white architectural "
    "line-art PARTITION DIAGRAM for machine reading. "
    "PRESERVE EXACTLY, do not reinterpret: every wall position and room shape, "
    "every room location, all door and window openings, and every numbered marker "
    "with its EXACT number and position. Do NOT create, duplicate, move, merge, "
    "split, add, or renumber any wall, door, or numbered marker — exactly one "
    "circle per existing marker number, unchanged. "
    "RESTYLE aggressively: draw ALL walls as VERY THICK solid black lines so each "
    "room reads as a strongly enclosed cell; pure WHITE background; remove ALL "
    "color fills, tints, shading, gradients, and textures; remove EVERY text "
    "label, room name, and zone name. Redraw every furniture or fixture as a "
    "SINGLE minimal flat primitive icon only (a plain rectangle for a desk/bed/"
    "table, a minimal standard symbol for a sink/toilet) — NO interior detail, NO "
    "chairs, monitors, copier parts, pillows, cushions, drawers, or decoration. "
    "Make the numbered circles large and clearly readable. No 3D, perspective, "
    "shadow, or texture."
)


def build_restyle_edit_prompt() -> str:
    """Return the GENERIC deterministic restyle-edit instruction (v0 production).

    Pure and parameter-free — the per-fp content lives in the detailed FP
    reference image, never in this prompt (no scenario tokens, absolute-rule
    compliant). The step pairs this with ``images.edit(image=detailed_fp)``.
    """
    return RESTYLE_EDIT_PROMPT


def restyle_prompt_hash() -> str:
    """Stable short hash of the restyle prompt (provenance + cache lever)."""
    return hashlib.sha256(
        (RESTYLE_PROMPT_VERSION + "\n" + RESTYLE_EDIT_PROMPT).encode("utf-8")
    ).hexdigest()[:16]

# v6/v7 numbered_elements.base_layer_decision enum (the floor_plan_prompt
# contract). We group by this enum only — never by label text.
LAYER_STRUCTURAL_UNIT = "base_structural_unit"
LAYER_OPENING = "base_opening"
LAYER_PERSISTENT_FIXTURE = "base_persistent_fixture"
LAYER_PERSISTENT_FURNITURE = "base_persistent_furniture"
# state_overlay_* (plot_cue / transient_object) are always excluded — transient.

#: default inclusion: spatial enclosure + essential fixtures. Decorative
#: furniture is dropped (content comes from the zone-respecting BG prompt). A
#: canary may flip ``include_furniture`` to compare.
_DEFAULT_INCLUDED_LAYERS = (
    LAYER_STRUCTURAL_UNIT,
    LAYER_OPENING,
    LAYER_PERSISTENT_FIXTURE,
)

# Generic CV-readable preamble / style — NO scenario tokens. Enclosure-first.
_PREAMBLE = (
    "Create a square flat top-down architectural FLOOR-PLAN PARTITION DIAGRAM. "
    "This is a clean CV-readable schematic for machine reading, NOT a decorated "
    "or artistic plan.\n\n"
    "WALLS AND ROOMS (most important): draw THICK solid black outer walls and "
    "THICK solid black interior partition walls. Each distinct room/area is a "
    "fully ENCLOSED cell bounded by walls on all sides, connected to its "
    "neighbours ONLY through clearly drawn door openings. Keep wall lines clean "
    "and straight. Do NOT merge separate rooms into one open area."
)

_STYLE_RULES = (
    "Style: THICK black walls, thin single-weight black outlines for openings and "
    "fixtures, a pure WHITE background, NO colour fill, no pastel shading, no "
    "texture, no gradient, no 3D, no perspective, no isometric, no shadow. Do NOT "
    "write any text labels, room names, or legend inside the image; do NOT draw "
    "camera icons, field-of-view cones, or arrows except one small north arrow in "
    "a corner."
)

_EXCLUSIONS = (
    "Do NOT draw decorative furniture or clutter (sofas, dining tables, chairs, "
    "TVs, lamps, wardrobes, framed pictures, bags, rugs, plants). Do NOT draw any "
    "story-state cues (blood, bodies, footprints, herbs, cups, coloured circles)."
)


def _int(n: Any) -> Optional[int]:
    return n if isinstance(n, int) and not isinstance(n, bool) else None


def _group_elements(
    numbered_elements: Any, included_layers: tuple
) -> Dict[str, List[Dict[str, Any]]]:
    """Group numbered_elements by base_layer_decision (included layers only).

    Reads the closed enum + integer ``number`` + passthrough ``label`` /
    ``position_hint`` only. No text matching."""
    groups: Dict[str, List[Dict[str, Any]]] = {k: [] for k in included_layers}
    if not isinstance(numbered_elements, list):
        return groups
    for el in numbered_elements:
        if not isinstance(el, dict):
            continue
        layer = el.get("base_layer_decision")
        if layer not in included_layers:
            continue
        num = _int(el.get("number"))
        if num is None:
            continue
        groups[layer].append({
            "number": num,
            "label": el.get("label") if isinstance(el.get("label"), str) else "",
            "position_hint": (
                el.get("position_hint")
                if isinstance(el.get("position_hint"), str) else ""
            ),
        })
    for k in groups:
        groups[k].sort(key=lambda e: e["number"])
    return groups


def _render_lines(items: List[Dict[str, Any]]) -> str:
    out = []
    for it in items:
        loc = f" — {it['position_hint']}" if it["position_hint"] else ""
        lbl = it["label"] or "(unlabelled)"
        out.append(f"  {it['number']}: {lbl}{loc}")
    return "\n".join(out)


def build_simple_fp_prompt(
    *,
    numbered_elements: Any,
    include_furniture: bool = False,
) -> str:
    """Assemble the GENERIC simplified-FP ``t2i_prompt`` from numbered_elements.

    ``include_furniture`` (default False) keeps the v0 boundary: spatial
    enclosure + essential fixtures only. Set True only for a canary comparison.
    Deterministic and pure — same input → same prompt (image generation is the
    separate, visually-validated step).
    """
    included = list(_DEFAULT_INCLUDED_LAYERS)
    if include_furniture:
        included.append(LAYER_PERSISTENT_FURNITURE)
    groups = _group_elements(numbered_elements, tuple(included))

    parts: List[str] = [_PREAMBLE]

    rooms = groups.get(LAYER_STRUCTURAL_UNIT) or []
    if rooms:
        parts.append(
            "Rooms / areas — draw each as a separate fully enclosed cell, with a "
            "LARGE clear numbered circle:\n" + _render_lines(rooms)
        )

    openings = groups.get(LAYER_OPENING) or []
    if openings:
        parts.append(
            "Openings — a door is a gap in the wall with a simple thin swing "
            "arc; a window is two short parallel lines in a wall. Draw each on "
            "the wall of the room it belongs to, with its numbered circle:\n"
            + _render_lines(openings)
        )

    fixtures = groups.get(LAYER_PERSISTENT_FIXTURE) or []
    if fixtures:
        parts.append(
            "Essential fixtures — draw each as the simplest possible plan "
            "symbol, strictly INSIDE its own room (never floating in another "
            "room or the open area), with its numbered circle:\n"
            + _render_lines(fixtures)
        )

    furniture = groups.get(LAYER_PERSISTENT_FURNITURE) or []
    if furniture:
        parts.append(
            "Room-defining items — draw each as a single basic rectangle inside "
            "its own room, with its numbered circle:\n" + _render_lines(furniture)
        )

    parts.append(_EXCLUSIONS)
    parts.append(
        "Numbered circles must be LARGE, sparse, and clearly readable — one per "
        "listed element above, placed inside the room/on the element it marks."
    )
    parts.append(_STYLE_RULES)
    return "\n\n".join(parts)


# ─────────────────────────────────────────────────────────────────────
# W21B-w5 STEP5-B (pivot) — LLM-driven simplified-FP prompt generator
#
# The deterministic builder above includes every structural/opening/fixture
# element, which re-creates a cluttered FP (user visual review: the hand-crafted
# canary, which SELECTED ~7 rooms + a few essential fixtures and wrote rich
# per-room enclosed prose, read far better). Deciding *which* fixture is
# essential (a toilet defines a bathroom; a shelf does not) is a SEMANTIC
# judgement no enum rule can make. So a single LLM call does both: it classifies
# each numbered element's render_role AND writes the sparse, room-aware T2I
# prompt — exactly what a human did by hand, but generically per FP.
#
# A deterministic validator then enforces the structural guards (Codex lock):
# every structural/opening element MUST survive; state overlays MUST be dropped;
# selection must stay sparse. The deterministic builder above remains the
# graceful fallback when the LLM provider is unavailable.
# ─────────────────────────────────────────────────────────────────────

RENDER_ROLE_SKELETON = "structural_skeleton"
RENDER_ROLE_ESSENTIAL = "essential_fixture"
RENDER_ROLE_METADATA = "metadata_only"
LIGHT_FP_RENDER_ROLES = (
    RENDER_ROLE_SKELETON,
    RENDER_ROLE_ESSENTIAL,
    RENDER_ROLE_METADATA,
)

#: selection sparsity target (Codex): soft target, hard cap protects against the
#: cluttered failure mode. Skeleton (rooms+openings) is exempt from the cap.
LIGHT_FP_SELECT_TARGET = 12
LIGHT_FP_SELECT_HARD_CAP = 16

#: layers that MUST be kept (the spatial skeleton) and the layer always dropped.
_MUST_KEEP_LAYERS = frozenset({LAYER_STRUCTURAL_UNIT, LAYER_OPENING})
_ALWAYS_DROP_LAYERS = frozenset({
    "state_overlay_plot_cue", "state_overlay_transient_object",
})

LIGHT_FP_SYSTEM = (
    "You turn a detailed apartment floor plan into a CLEAN, CV-READABLE "
    "top-down PARTITION DIAGRAM prompt for an image model. The detailed plan is "
    "too cluttered for the model to read; your job is to SELECT only what defines "
    "the space and write a sparse, room-aware image prompt.\n\n"
    "You are given the floor plan's numbered_elements (number, label, category, "
    "position_hint, base_layer_decision). For EACH element assign a render_role:\n"
    "- structural_skeleton: rooms/areas (base_structural_unit) and openings "
    "(base_opening). ALWAYS keep ALL of these — they are the spatial skeleton.\n"
    "- essential_fixture: a fixture/furniture item that DEFINES a room's identity "
    "or anchors a camera (e.g. a toilet/sink for a bathroom, a bed for a bedroom, "
    "a counter/cooktop for a kitchen, a sofa/TV for a living room). Keep these, "
    "drawn as the simplest plan symbol.\n"
    "- metadata_only: decorative, minor, or duplicate clutter (rugs, plants, "
    "framed pictures, small props) AND every story-state overlay "
    "(state_overlay_*). DROP these — do not draw them.\n\n"
    "Selection must be SPARSE: aim for about 8-12 selected markers total; never "
    "exceed what the rooms genuinely need. Keeping every fixture is the mistake "
    "to avoid.\n\n"
    "Then write room_schematic_prompt — ONE image-generation prompt that:\n"
    "- draws THICK solid black outer + interior partition walls; each room a fully "
    "ENCLOSED cell connected to neighbours ONLY through clearly drawn door "
    "openings; explicitly says do NOT merge separate rooms into one open area;\n"
    "- places each selected essential fixture as the simplest plan symbol STRICTLY "
    "inside its own room (never floating in another room);\n"
    "- marks LARGE clear numbered circles ONLY for the selected markers (use their "
    "original numbers), big and sparse;\n"
    "- pure WHITE background, NO colour/shading/texture/gradient/3D/perspective/"
    "shadow, and NO written text labels/room names/legend inside the image.\n"
    "Describe rooms by their position_hint relationships (which is north/east/"
    "etc., what connects to what). Use the given labels only to understand what "
    "each element is; do not write them into the image.\n\n"
    "Output strict JSON only."
)

LIGHT_FP_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "selected_markers": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "number": {"type": "integer"},
                    "label": {"type": "string"},
                    "render_role": {"type": "string", "enum": [
                        RENDER_ROLE_SKELETON, RENDER_ROLE_ESSENTIAL]},
                    "reason": {"type": "string"},
                },
                "required": ["number", "label", "render_role", "reason"],
            },
        },
        "dropped_markers": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "number": {"type": "integer"},
                    "label": {"type": "string"},
                    "reason": {"type": "string"},
                },
                "required": ["number", "label", "reason"],
            },
        },
        "room_schematic_prompt": {"type": "string", "minLength": 60},
        "safety_contract": {"type": "array", "items": {"type": "string"}},
    },
    "required": [
        "selected_markers", "dropped_markers",
        "room_schematic_prompt", "safety_contract",
    ],
}


def build_light_fp_llm_prompt_bundle(numbered_elements: Any) -> Dict[str, Any]:
    """Assemble the strict {system, user, schema} bundle for the LLM generator.

    The user payload is the numbered_elements carried through verbatim (number /
    label / category / position_hint / base_layer_decision) — no scenario tokens
    in this module, only the runtime data the model classifies."""
    import json as _json

    elements = []
    if isinstance(numbered_elements, list):
        for el in numbered_elements:
            if not isinstance(el, dict):
                continue
            num = _int(el.get("number"))
            if num is None:
                continue
            elements.append({
                "number": num,
                "label": el.get("label"),
                "category": el.get("category"),
                "position_hint": el.get("position_hint"),
                "base_layer_decision": el.get("base_layer_decision"),
            })
    return {
        "system": LIGHT_FP_SYSTEM,
        "user": _json.dumps({"numbered_elements": elements}, ensure_ascii=False),
        "schema": LIGHT_FP_SCHEMA,
    }


def validate_light_fp_output(
    *, raw: Any, numbered_elements: Any,
    select_soft_target: int = LIGHT_FP_SELECT_HARD_CAP,
) -> Dict[str, Any]:
    """Validate the LLM output against the deterministic STRUCTURAL guards.

    ``ok`` reflects STRUCTURAL INTEGRITY ONLY (Codex lock + Claude canary
    correction): every ``base_structural_unit`` / ``base_opening`` element MUST
    be selected (the LLM may not drop the spatial skeleton); no ``state_overlay_*``
    element may be selected; the prompt must be present. The selection COUNT is
    advisory only — it does NOT flip ``ok``, because a too-tight count cap would
    fall back to the cluttered deterministic builder (the very failure mode being
    fixed). Sparsity is driven by the prompt; the count is surfaced as a
    diagnostic so an over-selection is visible without forcing a worse fallback.
    Returns ``{ok, room_schematic_prompt, selected_numbers, dropped_numbers,
    diagnostics}``. ``ok`` false ⇒ the STEP-level caller (floor_plan_light_sidecar)
    skips the light render for that fp and the background consumer keeps the
    DETAILED floor_plan_render PNG (per-fp detailed fallback) — only when the
    skeleton is broken, never just for being verbose. (The deterministic
    ``build_simple_fp_prompt`` remains available as a pure-prompt fallback but the
    wired step uses the detailed-FP fallback path.)"""
    diags: List[str] = []
    if not isinstance(raw, dict):
        return {"ok": False, "room_schematic_prompt": "",
                "selected_numbers": [], "dropped_numbers": [],
                "diagnostics": ["non-dict LLM output"]}

    # index the source elements by number → layer.
    layer_by_num: Dict[int, Any] = {}
    if isinstance(numbered_elements, list):
        for el in numbered_elements:
            if isinstance(el, dict):
                n = _int(el.get("number"))
                if n is not None:
                    layer_by_num[n] = el.get("base_layer_decision")

    selected = [
        _int(m.get("number"))
        for m in (raw.get("selected_markers") or [])
        if isinstance(m, dict)
    ]
    selected_nums = {n for n in selected if n is not None}

    must_keep = {n for n, layer in layer_by_num.items() if layer in _MUST_KEEP_LAYERS}
    dropped_skeleton = sorted(must_keep - selected_nums)
    if dropped_skeleton:
        diags.append(
            f"skeleton elements dropped (forbidden): {dropped_skeleton}")

    overlay_selected = sorted(
        n for n in selected_nums if layer_by_num.get(n) in _ALWAYS_DROP_LAYERS)
    if overlay_selected:
        diags.append(f"state-overlay elements selected (forbidden): {overlay_selected}")

    if len(selected_nums) > select_soft_target:
        # advisory only — does NOT flip ok (avoids the cluttered-fallback trap).
        diags.append(
            f"selected {len(selected_nums)} > soft target {select_soft_target} "
            f"(advisory — verbose but rendered; tighten the prompt if visually busy)")

    prompt = raw.get("room_schematic_prompt")
    prompt = prompt if isinstance(prompt, str) else ""
    if len(prompt) < 60:
        diags.append("room_schematic_prompt too short / missing")

    # ok = STRUCTURAL integrity only (skeleton kept, no overlay, prompt present).
    ok = not dropped_skeleton and not overlay_selected and len(prompt) >= 60
    return {
        "ok": ok,
        "room_schematic_prompt": prompt,
        "selected_numbers": sorted(selected_nums),
        "dropped_numbers": sorted(
            n for n in (
                _int(m.get("number")) for m in (raw.get("dropped_markers") or [])
                if isinstance(m, dict)
            ) if n is not None
        ),
        "diagnostics": diags,
    }


# ─────────────────────────────────────────────────────────────────────
# W21B-w5 STEP5-B (NB2 sparse rework, 2026-05-31) — frequency-aware sparse
# selection for PURE text-to-image (Nano Banana 2 = gemini-3.1-flash-image-preview).
#
# Both prior approaches failed the user visual gate: the v0 ref-edit produced
# duplicate markers ("11" twice), and the first sparse pass — having no frequency
# signal — dropped high-use elements (a TV a camera frames 6×, a curtain framed
# 5×). The winning design (L05 canary, gallery 8815): PURE text-to-image with NB2
# and a SHORT prompt carrying only 8-10 essential numbered markers. Selection is
# guided by camera_use_count (how many shot cameras frame each element) so
# high-frequency elements are never silently dropped, and the room_schematic_prompt
# states explicit marker-placement constraints (every circle inside the building
# outline, door/window markers ON their wall, one circle per number). A long
# prompt renders badly; few markers render cleanly. The geometry skeleton
# (rooms / walls / doors / windows) is drawn WITHOUT numbers — only the essential
# fixtures get the large numbered circles.
#
# Absolute-rule compliance: selection is driven by the closed base_layer_decision
# enum + an INTEGER camera_use_count signal — never a text/substring inspection of
# labels. The room_schematic_prompt is opaque LLM free text, carried verbatim.
# ─────────────────────────────────────────────────────────────────────

#: NB2 sparse selection band (Codex-agreed). SOFT — never flips ``ok`` (a hard
#: count gate would fall back to the cluttered detailed FP, the failure being
#: fixed). The hard cap is a diagnostic ceiling only.
NB2_SELECT_TARGET_MIN = 8
NB2_SELECT_TARGET_MAX = 10
NB2_SELECT_HARD_CAP = 13

#: an element framed by this many cameras (or more) is "high frequency"; dropping
#: it without a stated reason is the TV/curtain omission failure mode. Pure
#: integer signal — generic across scenarios (no label/lexicon inspection).
NB2_HIGH_FREQ_MIN_CAMERAS = 2

NB2_GEOMETRY_KIND_ROOM = "room"
NB2_GEOMETRY_KIND_DOOR = "door"
NB2_GEOMETRY_KIND_WINDOW = "window"

NB2_FREQ_SELECT_SYSTEM = (
    "You convert a detailed apartment floor plan into a SPARSE, CV-readable "
    "top-down PARTITION DIAGRAM prompt for a TEXT-TO-IMAGE model (NO reference "
    "image is given). The detailed plan has far too many numbered markers for the "
    "model to render cleanly; a SHORT prompt with FEW markers renders far better.\n\n"
    "You are given the floor plan's numbered_elements (number, label, category, "
    "position_hint, base_layer_decision) and, for each, a camera_use_count: how "
    "many shot cameras actually FRAME that element. This is a frequency / "
    "importance signal — a HIGH count means cameras show it often (e.g. a TV, or a "
    "window/curtain repeatedly framed) and it MUST NOT be silently dropped.\n\n"
    "Classify EVERY numbered element into exactly ONE bucket:\n"
    "- geometry_skeleton: every room/area (base_structural_unit) and every opening "
    "(base_opening = door/window). These DEFINE the building shape. They are "
    "drawn, but WITHOUT numbered circles — the unnumbered structural shell. Set "
    "kind to room, door, or window.\n"
    "- essential_numbered_markers: the 8-10 fixtures/furniture that DEFINE a room's "
    "identity or are framed by cameras (high camera_use_count) — e.g. a toilet/sink "
    "for a bathroom, a bed for a bedroom, a counter/cooktop for a kitchen, a "
    "sofa/TV for a living room. Keep each element's ORIGINAL number; each appears "
    "EXACTLY ONCE; these get the LARGE numbered circles. Echo each element's "
    "camera_use_count.\n"
    "- high_frequency_candidates: any element with a HIGH camera_use_count that you "
    "nonetheless DROP. You MUST give a drop_reason — dropping something cameras "
    "frame is risky, so justify it.\n"
    "- metadata_only: decorative / minor / duplicate clutter (rugs, plants, framed "
    "pictures, small props) AND every story-state overlay (state_overlay_*). Not "
    "drawn.\n\n"
    "Aim for 8-10 essential_numbered_markers — NEVER the whole inventory; keeping "
    "every fixture is the mistake to avoid; but a high camera_use_count element "
    "should almost always be kept.\n\n"
    "Then write room_schematic_prompt — ONE SHORT image-generation prompt that:\n"
    "- draws THICK solid black outer + interior partition walls; each room a fully "
    "ENCLOSED cell connected to neighbours ONLY through clearly drawn door openings "
    "(a door = a gap in the wall with a thin swing arc; a window = two short "
    "parallel lines in the wall); explicitly says do NOT merge separate rooms into "
    "one open area;\n"
    "- MARKER PLACEMENT (critical): EVERY numbered circle sits INSIDE the building "
    "outline; a door/window marker sits ON its wall, touching from the inside, "
    "NEVER floating outside the building; a fixture marker sits INSIDE its own room, "
    "never floating in another room or the open area; EXACTLY ONE circle per "
    "number — if an element represents SEVERAL identical items (e.g. multiple "
    "chairs, desks, lockers, or cabinets), draw the symbols but place the numbered "
    "circle on only ONE representative item and do NOT repeat that number on the "
    "others;\n"
    "- draws each essential fixture as the simplest flat plan symbol strictly "
    "inside its own room;\n"
    "- uses LARGE clear numbered circles ONLY for the essential markers (their "
    "ORIGINAL numbers), big and sparse;\n"
    "- pure WHITE background, NO colour/shading/texture/gradient/3D/perspective/"
    "shadow; and the image must contain ZERO letters or words — NO room names, NO "
    "text labels, NO legend, NO captions anywhere (e.g. do not write 'kitchen', "
    "'bedroom', 'entrance'); the ONLY characters allowed are the digits inside the "
    "numbered marker circles.\n"
    "Keep it SHORT — describe rooms by their position_hint relationships (which is "
    "north/east/etc., what connects to what). Use the given labels only to "
    "understand what each element is; do NOT write them into the image.\n\n"
    "Output strict JSON only."
)

FREQ_SELECT_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "geometry_skeleton": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "number": {"type": "integer"},
                    "label": {"type": "string"},
                    "kind": {"type": "string", "enum": [
                        NB2_GEOMETRY_KIND_ROOM, NB2_GEOMETRY_KIND_DOOR,
                        NB2_GEOMETRY_KIND_WINDOW]},
                },
                "required": ["number", "label", "kind"],
            },
        },
        "essential_numbered_markers": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "number": {"type": "integer"},
                    "label": {"type": "string"},
                    "reason": {"type": "string"},
                    "camera_use_count": {"type": "integer"},
                },
                "required": ["number", "label", "reason", "camera_use_count"],
            },
        },
        "high_frequency_candidates": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "number": {"type": "integer"},
                    "label": {"type": "string"},
                    "drop_reason": {"type": "string"},
                },
                "required": ["number", "label", "drop_reason"],
            },
        },
        "metadata_only": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "number": {"type": "integer"},
                    "label": {"type": "string"},
                },
                "required": ["number", "label"],
            },
        },
        "room_schematic_prompt": {"type": "string", "minLength": 60},
    },
    "required": [
        "geometry_skeleton", "essential_numbered_markers",
        "high_frequency_candidates", "metadata_only", "room_schematic_prompt",
    ],
}


#: bump when NB2_FREQ_SELECT_SYSTEM / FREQ_SELECT_SCHEMA wording changes.
FREQ_SELECT_PROMPT_VERSION = "1"


def freq_select_prompt_hash() -> str:
    """Stable short hash of the NB2 freq-aware selection system prompt + schema.

    Provenance + cache-invalidation lever for the step config_hash. Pure."""
    import json as _json

    payload = (
        FREQ_SELECT_PROMPT_VERSION + "\n" + NB2_FREQ_SELECT_SYSTEM + "\n"
        + _json.dumps(FREQ_SELECT_SCHEMA, sort_keys=True)
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


def compute_camera_use_counts(
    numbered_elements: Any, camera_recommendations: Any
) -> Dict[int, int]:
    """Aggregate the per-element camera-framing frequency (importance signal).

    ``camera_use_count[n]`` = the number of ``camera_recommendations`` entries
    whose ``use_numbered_elements`` contains ``n``. This is a pure INTEGER signal
    (how many shot cameras frame the element) — never a label/text inspection
    (absolute-rule compliant). Every integer-numbered element is present (0 when
    no camera frames it). A number repeated inside one camera's
    ``use_numbered_elements`` counts once for that camera.
    """
    counts: Dict[int, int] = {}
    if isinstance(numbered_elements, list):
        for el in numbered_elements:
            if isinstance(el, dict):
                n = _int(el.get("number"))
                if n is not None:
                    counts.setdefault(n, 0)
    if isinstance(camera_recommendations, list):
        for cam in camera_recommendations:
            if not isinstance(cam, dict):
                continue
            used = cam.get("use_numbered_elements")
            if not isinstance(used, list):
                continue
            seen_in_cam = set()
            for item in used:
                n = _int(item)
                if n is None or n in seen_in_cam:
                    continue
                seen_in_cam.add(n)
                counts[n] = counts.get(n, 0) + 1
    return counts


def build_freq_aware_selection_bundle(
    numbered_elements: Any, camera_use_counts: Any
) -> Dict[str, Any]:
    """Assemble the strict {system, user, schema} bundle for the NB2 selector.

    The user payload is the numbered_elements carried through verbatim, each
    annotated with its integer ``camera_use_count`` (the frequency signal). No
    scenario tokens live in this module — only the runtime data the model
    classifies."""
    import json as _json

    counts = camera_use_counts if isinstance(camera_use_counts, dict) else {}
    elements = []
    if isinstance(numbered_elements, list):
        for el in numbered_elements:
            if not isinstance(el, dict):
                continue
            num = _int(el.get("number"))
            if num is None:
                continue
            elements.append({
                "number": num,
                "label": el.get("label"),
                "category": el.get("category"),
                "position_hint": el.get("position_hint"),
                "base_layer_decision": el.get("base_layer_decision"),
                "camera_use_count": int(counts.get(num, 0)),
            })
    return {
        "system": NB2_FREQ_SELECT_SYSTEM,
        "user": _json.dumps({"numbered_elements": elements}, ensure_ascii=False),
        "schema": FREQ_SELECT_SCHEMA,
    }


def validate_freq_aware_selection(
    *,
    raw: Any,
    numbered_elements: Any,
    camera_use_counts: Any = None,
    select_hard_cap: int = NB2_SELECT_HARD_CAP,
) -> Dict[str, Any]:
    """Validate the NB2 freq-aware selection against the STRUCTURAL guards.

    ``ok`` reflects STRUCTURAL INTEGRITY ONLY (Codex lock #1 — do not over-gate):
    every room (``base_structural_unit``) MUST be drawn (in the geometry skeleton
    or as an essential marker); no ``state_overlay_*`` element may be an essential
    numbered marker; the prompt must be present. The selection COUNT and the
    high-frequency protection are ADVISORY diagnostics — they never flip ``ok``,
    because a too-tight gate would fall back to the cluttered detailed FP (the very
    failure mode being fixed). Returns ``{ok, room_schematic_prompt,
    essential_numbers, skeleton_numbers, unjustified_high_freq, diagnostics}``.
    """
    diags: List[str] = []
    if not isinstance(raw, dict):
        return {"ok": False, "room_schematic_prompt": "",
                "essential_numbers": [], "skeleton_numbers": [],
                "unjustified_high_freq": [], "diagnostics": ["non-dict LLM output"]}

    layer_by_num: Dict[int, Any] = {}
    if isinstance(numbered_elements, list):
        for el in numbered_elements:
            if isinstance(el, dict):
                n = _int(el.get("number"))
                if n is not None:
                    layer_by_num[n] = el.get("base_layer_decision")
    counts = camera_use_counts if isinstance(camera_use_counts, dict) else {}

    def _nums(key: str) -> List[int]:
        out: List[int] = []
        for m in (raw.get(key) or []):
            if isinstance(m, dict):
                n = _int(m.get("number"))
                if n is not None:
                    out.append(n)
        return out

    essential_raw = _nums("essential_numbered_markers")
    skeleton_set = set(_nums("geometry_skeleton"))
    essential_set = set(essential_raw)
    hf_justified = set(_nums("high_frequency_candidates"))

    unknown_essential = sorted(n for n in essential_set if n not in layer_by_num)
    if unknown_essential:
        diags.append(
            f"essential markers not in source (dropped): {unknown_essential}")
    essential_valid_set = {n for n in essential_set if n in layer_by_num}
    essential_valid = sorted(essential_valid_set)

    if len(essential_raw) != len(essential_set):
        dups = sorted({n for n in essential_raw if essential_raw.count(n) > 1})
        diags.append(f"duplicate essential markers (advisory): {dups}")

    # geometry_skeleton must be REAL structural/opening elements — an overlay or
    # wrong-layer/unknown number mis-bucketed here would otherwise count as "drawn"
    # and mask a room or high-frequency omission (Codex W21B-w5 review). Only valid
    # skeleton numbers count toward room coverage and the high-freq ``drawn`` set.
    skeleton_valid = {
        n for n in skeleton_set
        if layer_by_num.get(n) in _MUST_KEEP_LAYERS
    }
    skeleton_invalid = sorted(skeleton_set - skeleton_valid)
    if skeleton_invalid:
        diags.append(
            "geometry_skeleton has non-structural / unknown numbers (ignored): "
            f"{skeleton_invalid}")

    # rooms MUST be drawn (valid skeleton or essential) — forbidden to drop a room.
    rooms = {n for n, l in layer_by_num.items() if l == LAYER_STRUCTURAL_UNIT}
    rooms_missing = sorted(rooms - skeleton_valid - essential_valid_set)
    if rooms_missing:
        diags.append(f"room(s) not drawn (forbidden): {rooms_missing}")

    # a state-overlay may not be drawn in EITHER bucket (essential or skeleton).
    overlay_drawn = sorted(
        n for n in (essential_set | skeleton_set)
        if layer_by_num.get(n) in _ALWAYS_DROP_LAYERS)
    if overlay_drawn:
        diags.append(
            f"state-overlay selected as drawn (forbidden): {overlay_drawn}")

    # count band — advisory only (never ok-flip).
    if len(essential_valid) > select_hard_cap:
        diags.append(
            f"essential {len(essential_valid)} > hard cap {select_hard_cap} "
            "(advisory — busy, tighten the prompt)")
    elif len(essential_valid) < NB2_SELECT_TARGET_MIN:
        diags.append(
            f"essential {len(essential_valid)} < target min {NB2_SELECT_TARGET_MIN} "
            "(advisory — may be too sparse)")

    # high-frequency protection (advisory): a high-camera-use element neither drawn
    # (VALID skeleton / essential) nor explicitly justified in
    # high_frequency_candidates is a likely TV/curtain-style omission.
    drawn = essential_valid_set | skeleton_valid
    unjustified_high_freq = sorted(
        n for n, c in counts.items()
        if isinstance(c, int) and c >= NB2_HIGH_FREQ_MIN_CAMERAS
        and n not in drawn and n not in hf_justified
    )
    if unjustified_high_freq:
        diags.append(
            "high-frequency elements dropped without justification (advisory): "
            f"{unjustified_high_freq}")

    prompt = raw.get("room_schematic_prompt")
    prompt = prompt if isinstance(prompt, str) else ""
    if len(prompt) < 60:
        diags.append("room_schematic_prompt too short / missing")

    ok = (not rooms_missing and not overlay_drawn and len(prompt) >= 60)
    return {
        "ok": ok,
        "room_schematic_prompt": prompt,
        "essential_numbers": essential_valid,
        "skeleton_numbers": sorted(skeleton_valid),
        "unjustified_high_freq": unjustified_high_freq,
        "diagnostics": diags,
    }
