"""W21B-wave-4 C2: shot_projection_card deterministic core.

Pure, deterministic core for the per-shot projection card — the common
shot-observation SOT that both ``background_prompt`` (BG plate) and
``scene_detail`` (full scene) consume so the BG and the final shot t2i
follow the same contract (design brief §3; wiring brief §6/§7).

This module performs NO LLM / VLM / image / DB / ImageAsset I/O. A real
VLM provider is injected via the ``vlm_provider`` slot of
``compute_projection_card`` (mirrors the W21B-wave-4 semantic readback
and W20A2 geometry patterns); the provider itself lives in a separate
module behind an explicit opt-in selector. With ``vlm_provider=None``
(default) the synthetic-fixture path is used and the FP image is ignored.

Design boundaries (design brief §5.1 / wiring brief §11 — scenario
leakage guard):
  - Code performs ONLY structural / exact-ID checks plus reads of the
    LLM-emitted enums. The leakage check is an exact-membership check of
    SELF-DEFINED INTERNAL TOKENS — the integer marker numbers the card
    itself emitted, the fixed English enum/layer literals this module
    declares, and the card_id. It is NOT semantic parsing: it never
    inspects Korean natural-language meaning, spatial words, or labels by
    substring / lexicon / particle / word-boundary matching. Korean
    spatial language (왼쪽/중앙/배경) is deliberately untouched.
  - ``expected_label`` / ``marker_layer`` are carried from the overlay /
    dossier inventory verbatim; the VLM echoes, it does not re-label.
  - The gate fails closed (design brief §5.2 / §9).
"""
from __future__ import annotations

import hashlib
import json
import re
from typing import Any, Callable, Dict, List, Optional, Set, Tuple


# ── pack / model provenance (Required 3) ─────────────────────────────
#
# The prompt-version SELECTOR (settings.shot_projection_card_prompt_version,
# e.g. "1") resolves to a concrete pack DIRECTORY. Both the selector and
# the resolved pack are recorded in source_hashes so a selector change OR
# a pack bump invalidates the card, and the real provider is invoked with
# the SAME resolved pack/model the provenance records (no drift).
PROMPT_VERSION_TO_PACK: Dict[str, str] = {"1": "1.202605302212"}
DEFAULT_PACK_VERSION: str = "1.202605302212"
PROVIDER_MODEL: str = "openai/gpt-5.6-sol"
PROVIDER_NAME: str = "openai"


def resolve_pack_version(prompt_version: str) -> str:
    """Map a prompt-version selector to its concrete pack dir.

    An unknown selector is returned verbatim (NOT silently mapped to a
    default pack) so a mismatch surfaces in provenance instead of hiding.
    """
    return PROMPT_VERSION_TO_PACK.get(str(prompt_version), str(prompt_version))


# ── input-assembly: overlay-payload union registry (pure) ────────────


def build_union_registry(
    ov_entry: Dict[str, Any],
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    """Assemble the marker inventory + union registry from one overlay entry.

    The inventory is the COMPLETE marker registry the VLM is shown:
    base markers ∪ transient markers ∪ ignored state-overlay markers, each
    carrying its ``marker_layer`` verbatim. The registry records the
    deterministic union so the validator can tell a transient marker
    (valid, in union) apart from a true input-contract gap
    (``out_of_union_referenced`` — a camera-referenced number absent from
    the union).

    Returns ``(inventory, registry)``. Camera-referenced numbers =
    ``use_numbered_elements`` − ``ignore_numbered_elements``.
    """
    layers = (
        ("base_markers_to_reference", "base"),
        ("transient_markers_to_describe", "transient"),
        ("ignored_state_overlay_markers", "ignored_state_overlay"),
    )
    inventory: List[Dict[str, Any]] = []
    base_n: List[int] = []
    trans_n: List[int] = []
    ign_n: List[int] = []
    bucket = {"base": base_n, "transient": trans_n, "ignored_state_overlay": ign_n}
    for key, layer in layers:
        for marker in ov_entry.get(key) or []:
            if not isinstance(marker, dict):
                continue
            num = marker.get("number")
            inventory.append(
                {
                    "number": num,
                    "expected_label": marker.get("label"),
                    "marker_layer": layer,
                    "category": marker.get("category"),
                    "position_hint": marker.get("position_hint"),
                    "expected_layer": (
                        marker.get("base_layer_decision")
                        or marker.get("state_layer_decision")
                    ),
                }
            )
            if isinstance(num, int) and not isinstance(num, bool):
                bucket[layer].append(num)

    union = sorted(set(base_n) | set(trans_n) | set(ign_n))
    cam_ref = sorted(
        set(ov_entry.get("use_numbered_elements") or [])
        - set(ov_entry.get("ignore_numbered_elements") or [])
    )
    out_of_union = sorted(set(cam_ref) - set(union))
    registry = {
        "base_numbers": sorted(base_n),
        "transient_numbers": sorted(trans_n),
        "ignored_numbers": sorted(ign_n),
        "union_numbers": union,
        "camera_referenced_numbers": cam_ref,
        "out_of_union_referenced": out_of_union,
    }
    return inventory, registry


# ── source hashes / cache key (pure) ─────────────────────────────────


def _normalized_hash(obj: Any) -> str:
    """sha256 of a key-order-normalized JSON encoding of ``obj``.

    Normalization = ``sort_keys=True`` so dict key order does not change
    the hash, while any change to the actual values does (brief §4 —
    ``shot_context_hash`` is normalized, not raw text). 16-hex prefix.
    """
    return hashlib.sha256(
        json.dumps(obj, sort_keys=True, ensure_ascii=False).encode("utf-8")
    ).hexdigest()[:16]


def compute_source_hashes(
    *,
    fp_render: Any,
    substrate: Any,
    overlay: Any,
    geometry: Any,
    semantic: Any,
    camera_rec: Any,
    shot_context: Any,
    prompt_version: str,
    schema_version: int,
    model: str,
    provider: str,
    pack_version: str = DEFAULT_PACK_VERSION,
    dossier: Any = None,
) -> Dict[str, Any]:
    """Build the per-card ``source_hashes`` provenance dict (brief §4).

    Each input is normalized-hashed so the card invalidates when any input
    changes. ``semantic`` may be ``None`` when the optional semantic gate
    is disabled — recorded as the ``"not_available"`` sentinel rather than
    a hash, so a disabled gate never looks like a content change.
    ``pack_version`` is the resolved concrete pack dir (Required 3) so a
    selector change OR a pack bump invalidates the card.
    """
    return {
        "fp_render_hash": _normalized_hash(fp_render),
        "substrate_hash": _normalized_hash(substrate),
        "overlay_hash": _normalized_hash(overlay),
        "geometry_hash": _normalized_hash(geometry),
        "dossier_hash": (
            "not_available" if dossier is None else _normalized_hash(dossier)
        ),
        "semantic_hash": (
            "not_available" if semantic is None else _normalized_hash(semantic)
        ),
        "camera_rec_hash": _normalized_hash(camera_rec),
        "shot_context_hash": _normalized_hash(shot_context),
        "prompt_version": prompt_version,
        "pack_version": pack_version,
        "schema_version": schema_version,
        "model": model,
        "provider": provider,
    }


def compute_card_cache_key(
    *,
    source_hashes: Dict[str, Any],
    bg_id: str,
    shot_id: str,
) -> str:
    """``card_cache_key = hash(source_hashes + bg_id + shot_id)`` (brief §4)."""
    return _normalized_hash(
        {"source_hashes": source_hashes, "bg_id": bg_id, "shot_id": shot_id}
    )


# ── self-defined internal-token leak check (NOT semantic parsing) ─────
#
# Per design brief §5.1 / wiring brief §11 + the user's standing rule
# (feedback-no-literal-substring-meaning): this checks ONLY that the
# card's OWN self-defined internal tokens did not leak into the prose the
# VLM wrote for the text-to-image model. It is NOT meaning extraction —
# it never inspects Korean natural-language spatial words, labels, or
# topology by substring / lexicon / particle / word-boundary matching.
# The only "meaning" signal anywhere in the gate is the structured enums
# the VLM emits. Korean spatial language (왼쪽/중앙/배경/전경) is left
# entirely untouched.
#
# Two token classes, two exact-membership strategies (Codex review):
#   - English enum/layer literals → whitespace/punctuation TOKENIZATION,
#     then exact set membership (so 'grounding' never matches 'ground',
#     and Korean text — which carries none of these ascii tokens — never
#     matches). No regex word-boundary on the enum literals.
#   - Marker-number annotation (#18 / marker 18 / number 18) → a narrow
#     regex on the ascii annotation FORM only, so a bare digit inside a
#     Korean sentence is not treated as a leak.

# Fixed English enum + layer literals this module's schema defines. These
# are OUR tokens, not a scenario lexicon.
_ENUM_LITERALS: Set[str] = frozenset(
    {
        "left",
        "center",
        "right",
        "foreground",
        "midground",
        "background",
        "visible",
        "partial",
        "occluded",
        "out_of_frame",
        "base",
        "transient",
        "ignored_state_overlay",
    }
)

_PROSE_FIELDS = (
    "scene_visible_description",
    "bg_plate_visible_description",
    "not_visible_or_occluded_summary",
)

# ASCII tokenizer: split on anything that is not an ascii letter, digit,
# or underscore. Korean characters are separators, so a Korean run never
# produces an english enum token. (This is tokenization for exact-token
# membership, NOT meaning inspection.)
_ASCII_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+")


def _emitted_marker_numbers(vlm_output: Dict[str, Any]) -> Set[int]:
    out: Set[int] = set()
    for item in vlm_output.get("visible_items") or []:
        n = item.get("marker_number")
        if isinstance(n, int) and not isinstance(n, bool):
            out.add(n)
    return out


def _find_internal_token_leaks(
    *, prose: str, marker_numbers: Set[int], card_id: str
) -> List[str]:
    """Return labels for any self-defined internal token leaked in ``prose``.

    NOT semantic parsing — exact membership of our own tokens only.
    """
    leaks: List[str] = []

    # English enum/layer literals — tokenize then exact set membership.
    tokens = {t.lower() for t in _ASCII_TOKEN_RE.findall(prose)}
    for lit in sorted(_ENUM_LITERALS & tokens):
        leaks.append(f"enum_literal:{lit}")

    # Marker-number annotation forms only (ascii). A bare digit in Korean
    # prose is not a leak; only '#N' / 'marker N' / 'number N' is.
    low = prose.lower()
    for n in sorted(marker_numbers):
        if (
            re.search(rf"#\s*{n}\b", prose)
            or re.search(rf"\bmarker\s*{n}\b", low)
            or re.search(rf"\bnumber\s*{n}\b", low)
        ):
            leaks.append(f"marker_number:{n}")

    # card_id — exact substring of our own identifier.
    if card_id and card_id in prose:
        leaks.append("card_id")

    return leaks


def find_plate_prose_leaks(
    *, plate_prose: str, vlm_output: Dict[str, Any], card_id: str
) -> List[str]:
    """Public C4 consumer guard: reuse the C2 self-defined internal-token leak
    check on a card's plate prose before BG injection.

    Single-sourced with the C2 gate (no duplicate matcher — the user's
    no-duplicate-lexicon rule): same exact-membership semantics on our OWN
    tokens (integer marker numbers the card emitted, fixed English enum /
    layer literals, card_id). It is NOT semantic parsing — Korean spatial
    language is never inspected.
    """
    return _find_internal_token_leaks(
        prose=plate_prose or "",
        marker_numbers=_emitted_marker_numbers(vlm_output or {}),
        card_id=str(card_id or ""),
    )


# ── v0 deterministic gate (validator — pure) ─────────────────────────

_REQUIRED_ENVELOPE_KEYS = (
    "card_id",
    "schema_version",
    "prompt_version",
    "bg_id",
    "shot_id",
    "fp_id",
    "semantic_gate_state",
    "substrate_kind",
    "substrate_status",
    "source_hashes",
    "vlm_output",
)

_LOW_CONFIDENCE_FLOOR = 0.5


def validate_shot_projection_card(
    *,
    card: Dict[str, Any],
    current_source_hashes: Dict[str, Any],
) -> Dict[str, Any]:
    """v0 deterministic gate (design brief §9 / wiring brief §9).

    Returns ``{validator_state, card_state, diagnostics, leaks}``.

    ``validator_state``: ``ok | leak_detected | contradiction |
    marker_contract_gap | plate_transient_leak | malformed``.
    ``card_state``: ``pass | needs_review | blocked``.

    HARD block (→ ``blocked`` → v10 fallback): internal-token leak, VLM
    ``self_consistency=contradictory``, camera-referenced marker absent
    from the base∪transient∪ignored union, ``plate_description_excludes_
    transient='no'``, stale ``source_hashes``, ``semantic_gate_state=
    needs_fix``, or ``substrate_status='missing'``.

    SOFT hold (→ ``needs_review`` → diagnostic only, still injectable):
    non-empty ``missing_inputs``, low confidence, ``semantic_gate_state=
    needs_review``, or ``plate_description_excludes_transient='uncertain'``.

    ``vlm_reported_state`` is reference-only — the validator re-derives the
    authoritative state and never blindly trusts the VLM self-report.
    """
    missing_keys = [k for k in _REQUIRED_ENVELOPE_KEYS if k not in card]
    if missing_keys:
        return {
            "validator_state": "malformed",
            "card_state": "blocked",
            "diagnostics": [f"missing envelope keys: {missing_keys}"],
            "leaks": [],
        }

    diags: List[str] = []
    vlm = card.get("vlm_output") or {}

    # 1. source_hash match (stale detection).
    stale = card.get("source_hashes") != current_source_hashes
    if stale:
        diags.append("source_hashes mismatch (stale) — card invalid, regenerate")

    # 2. semantic gate (optional; not_available when disabled).
    sem = card.get("semantic_gate_state")
    if sem == "needs_fix":
        diags.append("semantic_gate_state=needs_fix — FP self-fidelity failed")

    # 3. substrate.
    substrate_missing = card.get("substrate_status") == "missing"
    if substrate_missing:
        diags.append("substrate_status=missing — no projection substrate")

    # 4. marker registry — deterministic union membership (no text parse).
    reg = card.get("marker_registry") or {}
    out_of_union = list(reg.get("out_of_union_referenced") or [])
    if out_of_union:
        diags.append(
            "camera_rec referenced marker(s) absent from "
            f"base∪transient∪ignored union: {out_of_union} — input gap, BLOCK"
        )

    # 5. plate transient self-report (VLM self-report, no Korean substring).
    plate_excl = vlm.get("plate_description_excludes_transient")
    plate_transient_block = plate_excl == "no"
    plate_transient_review = plate_excl == "uncertain"
    if plate_transient_block:
        diags.append(
            "plate_description_excludes_transient=no — transient in plate prose, BLOCK"
        )
    elif plate_transient_review:
        diags.append("plate_description_excludes_transient=uncertain → needs_review")

    # 6. self_consistency contradiction (VLM self-report = blocked, §9).
    contradiction = (
        (vlm.get("self_consistency") or {}).get("prose_matches_structured")
        == "contradictory"
    )
    if contradiction:
        diags.append("vlm self_consistency=contradictory — prose↔structured BLOCK")

    # 7. missing_inputs — needs_review signal only (not a hard block).
    missing_inputs = vlm.get("missing_inputs") or []
    if missing_inputs:
        diags.append(f"vlm missing_inputs non-empty (→needs_review): {missing_inputs}")

    # 8. internal-token leakage (self-defined tokens, NOT semantic parse).
    marker_numbers = _emitted_marker_numbers(vlm)
    card_id = str(card.get("card_id") or "")
    leaks: List[str] = []
    for field in _PROSE_FIELDS:
        text = vlm.get(field) or ""
        if not isinstance(text, str):
            continue
        for lk in _find_internal_token_leaks(
            prose=text, marker_numbers=marker_numbers, card_id=card_id
        ):
            leaks.append(f"{field}:{lk}")
    if leaks:
        diags.append(f"internal token leakage in prose: {leaks}")

    # validator_state (structural precedence).
    if leaks:
        validator_state = "leak_detected"
    elif contradiction:
        validator_state = "contradiction"
    elif out_of_union:
        validator_state = "marker_contract_gap"
    elif plate_transient_block:
        validator_state = "plate_transient_leak"
    else:
        validator_state = "ok"

    # card_state (fail-closed combination).
    hard_block = (
        bool(leaks)
        or contradiction
        or bool(out_of_union)
        or plate_transient_block
        or stale
        or sem == "needs_fix"
        or substrate_missing
    )
    conf = vlm.get("confidence")
    low_conf = isinstance(conf, (int, float)) and not isinstance(conf, bool) and conf < _LOW_CONFIDENCE_FLOOR
    soft_hold = (
        bool(missing_inputs)
        or low_conf
        or sem == "needs_review"
        or plate_transient_review
    )
    if hard_block:
        card_state = "blocked"
    elif soft_hold:
        card_state = "needs_review"
        diags.append("needs_review (missing_inputs/low-conf/semantic) → BG inject hold")
    else:
        card_state = "pass"

    diags.append(f"(ref) vlm_reported_state={vlm.get('vlm_reported_state')!r}")

    return {
        "validator_state": validator_state,
        "card_state": card_state,
        "diagnostics": diags,
        "leaks": leaks,
    }


# ── vlm-output shape validation (pure) ───────────────────────────────

class ProjectionCardError(Exception):
    """Fail-closed signal for projection-card assembly / dispatch."""


_VISIBILITY_VALUES = frozenset({"visible", "partial", "occluded", "out_of_frame"})
_HBAND_VALUES = frozenset({"left", "center", "right"})
_DBAND_VALUES = frozenset({"foreground", "midground", "background"})
_LAYER_VALUES = frozenset({"base", "transient", "ignored_state_overlay"})
_PLATE_EXCL_VALUES = frozenset({"yes", "uncertain", "no"})
_REPORTED_STATE_VALUES = frozenset({"ok", "low_confidence", "insufficient_evidence"})
_SELF_CONS_VALUES = frozenset({"consistent", "uncertain", "contradictory"})

_VISIBLE_ITEM_REQUIRED = (
    "marker_number",
    "marker_layer",
    "expected_label",
    "visibility",
    "horizontal_band",
    "depth_band",
    "occlusion_note",
    "evidence",
    "source_ref",
    "confidence",
)

# Strict-schema top-level required keys (mirror pack schema.json). Presence
# is enforced so a nullable field like ``confidence`` cannot be silently
# omitted (missing ≠ explicit null).
_VLM_OUTPUT_REQUIRED = (
    "status",
    "fp_id",
    "bg_id",
    "shot_id",
    "camera_pose_source",
    "visible_items",
    "scene_visible_description",
    "bg_plate_visible_description",
    "plate_description_excludes_transient",
    "not_visible_or_occluded_summary",
    "vlm_reported_state",
    "self_consistency",
    "confidence",
    "missing_inputs",
    "diagnostics",
)


def _inventory_map(inventory: List[Dict[str, Any]]) -> Dict[int, Dict[str, str]]:
    """``{number: {expected_label, marker_layer}}`` for exact-ID echo.

    Both fields are the verbatim SOT the VLM must echo — it observes, it
    does NOT re-label OR re-classify. Carrying marker_layer here lets the
    validator reject a transient marker echoed as base (which would defeat
    the base/transient separation + plate-purity gate).
    """
    out: Dict[int, Dict[str, str]] = {}
    for entry in inventory or []:
        if not isinstance(entry, dict):
            continue
        num = entry.get("number")
        if isinstance(num, int) and not isinstance(num, bool):
            out[num] = {
                "expected_label": str(entry.get("expected_label", "")),
                "marker_layer": str(entry.get("marker_layer", "")),
            }
    return out


def validate_vlm_output(
    *,
    output: Any,
    inventory: List[Dict[str, Any]],
    fp_id: str,
    bg_id: str,
    shot_id: str,
) -> Dict[str, Any]:
    """Pure JSON-shape + exact-ID validator for a projection-card VLM output.

    Returns ``{"ok": bool, "blockers": [str]}``. Structural / enum / exact-ID
    checks only — never inspects the free-text prose for meaning. A marker
    the VLM reports must exist in the supplied inventory and echo the
    inventory's verbatim label (the VLM observes; it does not re-label).
    """
    blockers: List[str] = []
    if not isinstance(output, dict):
        return {"ok": False, "blockers": [f"output not a dict ({type(output).__name__})"]}

    # Required-key PRESENCE (strict schema mirror). A nullable field
    # (``confidence``) must be PRESENT even when null — ``output.get`` alone
    # cannot tell a missing key from an explicit null, so presence is
    # checked here before the type/enum checks below.
    missing_top = [k for k in _VLM_OUTPUT_REQUIRED if k not in output]
    if missing_top:
        blockers.append(f"missing required top-level fields: {missing_top}")

    if output.get("status") != "ok":
        blockers.append(f"status must be 'ok' (got {output.get('status')!r})")
    for key, want in (("fp_id", fp_id), ("bg_id", bg_id), ("shot_id", shot_id)):
        if output.get(key) != want:
            blockers.append(f"{key} mismatch: provider={output.get(key)!r} vs {want!r}")

    if not isinstance(output.get("camera_pose_source"), str) or not output.get(
        "camera_pose_source"
    ):
        blockers.append("camera_pose_source must be a non-empty string (selected, not invented)")

    inv_map = _inventory_map(inventory)
    items = output.get("visible_items")
    if not isinstance(items, list):
        blockers.append("visible_items must be a list")
        items = []

    for idx, item in enumerate(items):
        prefix = f"visible_items[{idx}]"
        if not isinstance(item, dict):
            blockers.append(f"{prefix} not a dict")
            continue
        missing = [f for f in _VISIBLE_ITEM_REQUIRED if f not in item]
        if missing:
            blockers.append(f"{prefix} missing required fields: {missing}")
            continue
        n = item["marker_number"]
        if not (isinstance(n, int) and not isinstance(n, bool)):
            blockers.append(f"{prefix}.marker_number must be int (got {n!r})")
            continue
        for field, allowed in (
            ("marker_layer", _LAYER_VALUES),
            ("visibility", _VISIBILITY_VALUES),
            ("horizontal_band", _HBAND_VALUES),
            ("depth_band", _DBAND_VALUES),
        ):
            val = item[field]
            if not isinstance(val, str) or val not in allowed:
                blockers.append(f"{prefix}.{field}={val!r} not in {sorted(allowed)}")
        # Free-text visible-item fields are validated for TYPE only —
        # never inspected for meaning (design §5.1).
        for text_field in ("expected_label", "occlusion_note", "evidence", "source_ref"):
            if not isinstance(item[text_field], str):
                blockers.append(f"{prefix}.{text_field} must be a string (got {item[text_field]!r})")
        conf = item["confidence"]
        if conf is not None and (not isinstance(conf, (int, float)) or isinstance(conf, bool)):
            blockers.append(f"{prefix}.confidence must be float|int|None (got {conf!r})")
        # exact-ID join — marker must exist + echo BOTH the verbatim label
        # AND the verbatim layer. The VLM observes; it neither re-labels
        # nor re-classifies (a transient marker echoed as base is rejected).
        if n not in inv_map:
            blockers.append(f"{prefix}.marker_number={n} not in supplied inventory")
        else:
            exp = inv_map[n]
            if isinstance(item["expected_label"], str) and item["expected_label"] != exp["expected_label"]:
                blockers.append(
                    f"{prefix}.expected_label={item['expected_label']!r} disagrees with "
                    f"inventory label={exp['expected_label']!r} for marker #{n} (re-label forbidden)"
                )
            if item.get("marker_layer") != exp["marker_layer"]:
                blockers.append(
                    f"{prefix}.marker_layer={item.get('marker_layer')!r} disagrees with "
                    f"inventory layer={exp['marker_layer']!r} for marker #{n} (re-classify forbidden)"
                )

    plate = output.get("plate_description_excludes_transient")
    if plate not in _PLATE_EXCL_VALUES:
        blockers.append(f"plate_description_excludes_transient={plate!r} not in {sorted(_PLATE_EXCL_VALUES)}")
    rep = output.get("vlm_reported_state")
    if rep not in _REPORTED_STATE_VALUES:
        blockers.append(f"vlm_reported_state={rep!r} not in {sorted(_REPORTED_STATE_VALUES)}")
    sc = output.get("self_consistency")
    if not isinstance(sc, dict) or sc.get("prose_matches_structured") not in _SELF_CONS_VALUES:
        blockers.append("self_consistency.prose_matches_structured invalid")
    elif not isinstance(sc.get("notes"), str):
        blockers.append("self_consistency.notes must be a string")
    for prose_field in _PROSE_FIELDS:
        if not isinstance(output.get(prose_field), str):
            blockers.append(f"{prose_field} must be a string")
    # top-level confidence number|null + list[str] fields.
    top_conf = output.get("confidence")
    if top_conf is not None and (not isinstance(top_conf, (int, float)) or isinstance(top_conf, bool)):
        blockers.append(f"confidence must be number|null (got {top_conf!r})")
    for list_field in ("missing_inputs", "diagnostics"):
        val = output.get(list_field)
        if not isinstance(val, list) or any(not isinstance(x, str) for x in val):
            blockers.append(f"{list_field} must be a list[str] (got {val!r})")

    return {"ok": not blockers, "blockers": blockers}


# ── synthetic fixture + envelope + dispatcher ────────────────────────


def compute_synthetic_fixture(
    *,
    inventory: List[Dict[str, Any]],
    fp_id: str,
    bg_id: str,
    shot_id: str,
) -> Dict[str, Any]:
    """Return a non-authoritative synthetic projection-card VLM output.

    Mirrors the semantic-readback synthetic fixture: it asserts NOTHING
    about real image content, so the gate must never promote it to a
    production pass. Every marker is echoed verbatim from the inventory;
    prose is empty, the plate self-report is ``uncertain``, and
    ``vlm_reported_state`` is ``insufficient_evidence`` — so a synthetic
    card routes to ``needs_review`` (fail-closed), not ``pass``.
    """
    items: List[Dict[str, Any]] = []
    for entry in inventory or []:
        if not isinstance(entry, dict):
            continue
        num = entry.get("number")
        items.append(
            {
                "marker_number": num,
                "marker_layer": str(entry.get("marker_layer", "base")),
                "expected_label": str(entry.get("expected_label", "")),
                "visibility": "out_of_frame",
                "horizontal_band": "center",
                "depth_band": "midground",
                "occlusion_note": "",
                "evidence": "synthetic_fixture placeholder — no real VLM observation",
                "source_ref": "synthetic_fixture",
                "confidence": None,
            }
        )
    return {
        "status": "ok",
        "fp_id": fp_id,
        "bg_id": bg_id,
        "shot_id": shot_id,
        "camera_pose_source": "synthetic_fixture",
        "visible_items": items,
        "scene_visible_description": "",
        "bg_plate_visible_description": "",
        "plate_description_excludes_transient": "uncertain",
        "not_visible_or_occluded_summary": "",
        "vlm_reported_state": "insufficient_evidence",
        "self_consistency": {
            "prose_matches_structured": "uncertain",
            "notes": "synthetic_fixture — no real observation",
        },
        "confidence": None,
        "missing_inputs": [],
        "diagnostics": [
            "synthetic_fixture: placeholder output, not a real VLM observation; "
            "the gate must not promote this to a production pass."
        ],
    }


def build_card_envelope(
    *,
    card_id: str,
    schema_version: int,
    prompt_version: str,
    model: str,
    provider: str,
    bg_id: str,
    shot_id: str,
    fp_id: str,
    semantic_gate_state: str,
    substrate_kind: str,
    substrate_status: str,
    source_hashes: Dict[str, Any],
    vlm_output: Dict[str, Any],
    marker_registry: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """Assemble the card envelope (provenance + vlm_output) for the gate."""
    return {
        "card_id": card_id,
        "schema_version": schema_version,
        "prompt_version": prompt_version,
        "model": model,
        "provider": provider,
        "bg_id": bg_id,
        "shot_id": shot_id,
        "fp_id": fp_id,
        "semantic_gate_state": semantic_gate_state,
        "substrate_kind": substrate_kind,
        "substrate_status": substrate_status,
        "source_hashes": source_hashes,
        "vlm_output": vlm_output,
        "marker_registry": marker_registry or {},
    }


def compute_projection_card(
    *,
    inventory: List[Dict[str, Any]],
    fp_id: str,
    bg_id: str,
    shot_id: str,
    vlm_provider: Optional[Callable[..., Dict[str, Any]]] = None,
    fp_image_path: Optional[str] = None,
    prompt_context: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """Return a projection-card VLM output for ``(bg_id, shot_id)``.

    With ``vlm_provider=None`` (default) the synthetic-fixture path is used
    and ``fp_image_path`` / ``prompt_context`` are ignored. When a provider
    callable is passed it is invoked as ``vlm_provider(inventory=...,
    fp_id=..., bg_id=..., shot_id=..., fp_image_path=...,
    prompt_context=...)`` — ``prompt_context`` carries the richer real-VLM
    inputs (camera recommendation, registry, shot/scene context) — and its
    return value runs through the FULL ``validate_vlm_output`` contract;
    any non-conforming return raises ``ProjectionCardError``. The
    dispatcher never returns an unvalidated provider payload.
    """
    if vlm_provider is None:
        return compute_synthetic_fixture(
            inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id
        )
    out = vlm_provider(
        inventory=inventory,
        fp_id=fp_id,
        bg_id=bg_id,
        shot_id=shot_id,
        fp_image_path=fp_image_path,
        prompt_context=prompt_context,
    )
    if not isinstance(out, dict):
        raise ProjectionCardError(f"vlm_provider returned non-dict ({type(out).__name__})")
    res = validate_vlm_output(
        output=out, inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id
    )
    if not res["ok"]:
        joined = "; ".join(res["blockers"])
        raise ProjectionCardError(f"vlm_provider output failed validation: {joined[:400]}")
    return out


# ── per-(bg, shot) target iteration (pure) ───────────────────────────


def _overlay_entries(overlay_payload: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
    ov = (overlay_payload or {}).get("overlays", overlay_payload) or {}
    out: Dict[str, Dict[str, Any]] = {}
    if isinstance(ov, dict):
        for key, entry in ov.items():
            if isinstance(entry, dict):
                out[str(entry.get("bg_id", key))] = entry
    elif isinstance(ov, list):
        for entry in ov:
            if isinstance(entry, dict) and entry.get("bg_id"):
                out[str(entry["bg_id"])] = entry
    return out


# Shot-intent fields carried into the projection-card shot_context. Subset
# of the shot_staging entry (upstream SOT) that conditions framing — the
# same fields the proven dry runner threaded. NOT derived/downstream
# (background_prompt.shot_guides is excluded by the dry-run input policy).
_SHOT_INTENT_FIELDS = (
    "camera_direction",
    "key_bg_elements",
    "frame_spatial_contract",
    "framing_scale",
    "lighting_mood",
    "subject_action",
    "shot_type",
)

# Our own shot_id structural format ``S<scene>_Shot<shot>`` — this parses
# OUR identifier structure to join the upstream shot_staging / scene_save
# rows. It is NOT scenario-meaning extraction (no Korean text / particle /
# lexicon inspection); the scene text it returns is carried verbatim.
_SHOT_ID_RE = re.compile(r"S(\d+)_Shot(\d+)$")


def build_shot_context(
    *,
    shot_id: str,
    shot_staging: Dict[str, Any],
    scene_save: Dict[str, Any],
) -> Dict[str, Any]:
    """Assemble the normalized shot_context for one ``shot_id`` (Required 1).

    Returns ``{shot_id, shot_intent, scene}`` where ``shot_intent`` is the
    upstream shot_staging subset that conditions framing and ``scene`` is
    the verbatim scene segment text/heading. Used for BOTH the
    ``shot_context`` source-hash input (so a shot/scene content change
    invalidates the card) AND the provider prompt_context (so the real VLM
    produces shot-conditioned prose). An unmatched shot_id yields a stable
    empty context (no crash).
    """
    shot_intent: Dict[str, Any] = {}
    scene = {"scene_ref": "", "heading": "", "text": ""}

    match = _SHOT_ID_RE.match(shot_id or "")
    if match:
        scene_no = int(match.group(1))
        shot_no = int(match.group(2))
        for entry in (shot_staging or {}).get("shots") or []:
            if (
                isinstance(entry, dict)
                and entry.get("scene_index") == scene_no
                and entry.get("shot_index") == shot_no
            ):
                shot_intent = {
                    f: entry[f] for f in _SHOT_INTENT_FIELDS if f in entry
                }
                break

        segments = (
            (scene_save or {}).get("segments")
            or (scene_save or {}).get("scenes")
            or []
        )
        if isinstance(segments, dict):
            segments = list(segments.values())
        for seg in segments:
            if not isinstance(seg, dict):
                continue
            if seg.get("scene_index") == scene_no:
                scene = {
                    "scene_ref": str(seg.get("scene_index", scene_no)),
                    "heading": str(seg.get("heading", "")),
                    "text": str(
                        seg.get("text")
                        or seg.get("scene_text")
                        or seg.get("body")
                        or ""
                    ),
                }
                break

    return {"shot_id": shot_id, "shot_intent": shot_intent, "scene": scene}


def iter_card_targets(
    *,
    overlay_payload: Dict[str, Any],
    master_plan: Dict[str, Any],
) -> List[Dict[str, Any]]:
    """Enumerate the ``(bg_id, shot_id, fp_id, ov_entry)`` cards to build.

    One card per ``(bg_id, shot_id)`` — every shot in the background's
    ``applies_to_shots`` (design brief §4.1, per-shot SOT). Anchor / plate
    grouping is deferred to the plan vNext (C3); C2 produces the per-shot
    cards the planner later selects from. A bg with no overlay entry or no
    ``applies_to_shots`` yields no targets (it cannot be projected).
    """
    entries = _overlay_entries(overlay_payload)
    catalog = (master_plan or {}).get("background_catalog") or {}
    targets: List[Dict[str, Any]] = []
    for bg_id, ov_entry in entries.items():
        bg_meta = catalog.get(bg_id) or {}
        for shot_id in bg_meta.get("applies_to_shots") or []:
            targets.append(
                {
                    "bg_id": bg_id,
                    "shot_id": str(shot_id),
                    "fp_id": str(ov_entry.get("fp_id", "")),
                    "ov_entry": ov_entry,
                }
            )
    return targets
