"""W21B-w4 · #4(C) render-input substrate policy — deterministic resolver core.

brief ``docs/w21b-wave4-bg-render-substrate-policy-20260531`` (APPROVED v0.2) +
Codex design lock (2026-05-31).

Single pure helper :func:`resolve_render_substrate` that decides, per
shot-aware-plan node, what render-input substrate the bg gets — WITHOUT calling
any image/LLM API. It reads the 3a/3b stamped fields (``render_action`` /
``needs_new_plate`` / ``ref_tree_parents`` / ``ref_role_per_parent``) and the
caller-supplied PNG snapshots, and returns an ordered reference plan:

  - **reuse alias** (``reuse_existing_plate`` / ``needs_new_plate=False``)
    → render input 0 (copy-less alias is materialised elsewhere; ``proceed=False``).
  - **fresh plate** → FP PNG first, then ``ref_tree_parents`` reordered
    ``space_continuity`` → ``style`` (already canonical anchors from 3b), each
    resolved to its rendered-plate PNG. A parent whose PNG is not yet rendered is
    dropped and recorded in ``missing_parent_bg_ids`` + ``fallback_reason`` (brief
    §5 v0 = graceful degrade, never a silent drop). All parents missing →
    ``fallback_fp_only``.
  - **fp-less / direct** → no FP png for the fp → ``direct`` (parents/text only).

card-withheld fresh folds into ``fresh`` (route_v2 already dropped its
reuse_target, so it is not in ``ref_tree_parents`` — never re-added here).

The resolver decides the **PNG substrate only**. Plate prose / projection-card
``pass`` gating stays with C4 ``resolve_projection_plate_injection`` /
background_prompt (Codex caution — no overlap). This module is a pure helper and
is **not wired into any step yet** (default-inert): the ``background_render_step``
consumer (replacing the legacy ``depends_on_bg`` prior selection behind a
selector/flag) is a separate, separately-reviewed change.
"""
from __future__ import annotations

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

# brief §4 — max distinct rendered-plate parents (3b already caps; re-guard here).
MAX_PARENT_REFS: int = 2

# render order rank: FP is always first (handled separately), then continuity,
# then style. Unknown / absent role sorts last.
_ROLE_RANK: Dict[str, int] = {"space_continuity": 0, "style": 1}
_DEFAULT_ROLE_RANK: int = 2

SUBSTRATE_FIELDS: Tuple[str, ...] = (
    "bg_id",
    "node_class",
    "proceed",
    "fp_path",
    "ordered_parent_bg_ids",
    "ordered_prior_bg_paths",
    "attached_ref_labels",
    "ref_used",
    "fallback_reason",
    "missing_parent_bg_ids",
)


def _reuse_alias_decision(bg_id: Optional[str]) -> Dict[str, Any]:
    return {
        "bg_id": bg_id,
        "node_class": "reuse_alias",
        "proceed": False,
        "fp_path": None,
        "ordered_parent_bg_ids": [],
        "ordered_prior_bg_paths": [],
        "attached_ref_labels": [],
        "ref_used": "reuse_alias",
        "fallback_reason": "",
        "missing_parent_bg_ids": [],
    }


def resolve_render_substrate(
    *,
    plan_node: Dict[str, Any],
    fp_id: str,
    fp_png_by_fp: Dict[str, Any],
    rendered_plate_png_by_bg: Dict[str, Any],
) -> Dict[str, Any]:
    """Resolve the render-input substrate for one shot-aware-plan node.

    ``fp_png_by_fp`` maps ``fp_id -> floor-plan PNG path``; ``rendered_plate_png_by_bg``
    maps an already-rendered plate ``bg_id -> PNG path`` (the render snapshot).
    Both are caller-supplied — this function performs no IO. See module docstring
    for the node-class policy. The returned dict always carries every
    :data:`SUBSTRATE_FIELDS` key.
    """
    bg_id = plan_node.get("bg_id")

    # reuse alias — render input 0, materialised by the W21B-w3 copy-less path.
    if (
        plan_node.get("render_action") == "reuse_existing_plate"
        or plan_node.get("needs_new_plate") is False
    ):
        return _reuse_alias_decision(bg_id)

    # JSON-safe string path contract (Codex Required): callers may pass str OR
    # Path; the decision may be persisted to a manifest/diagnostic, so every path
    # field is normalized to ``str``. The render consumer re-wraps with ``Path``.
    raw_fp = fp_png_by_fp.get(fp_id)
    fp_path: Optional[str] = str(raw_fp) if raw_fp else None

    # fresh — reorder parents by role (continuity → style), preserving the
    # ref_tree_parents order WITHIN each role, then defensively cap.
    parents: List[str] = [
        p for p in (plan_node.get("ref_tree_parents") or [])
        if isinstance(p, str) and p
    ]
    roles: Dict[str, Any] = plan_node.get("ref_role_per_parent") or {}
    ordered_parents = sorted(
        parents, key=lambda p: _ROLE_RANK.get(roles.get(p), _DEFAULT_ROLE_RANK)
    )[:MAX_PARENT_REFS]

    resolved_ids: List[str] = []
    resolved_paths: List[str] = []
    missing: List[str] = []
    for p in ordered_parents:
        png = rendered_plate_png_by_bg.get(p)
        if png:
            resolved_ids.append(p)
            resolved_paths.append(str(png))  # JSON-safe string contract
        else:
            missing.append(p)

    # attached lineage labels (FP first, then resolved parents in order).
    labels: List[Dict[str, Any]] = []
    order_i = 0
    if fp_path:
        labels.append(
            {"order": 0, "bg_id": fp_id, "role": "floor_plan", "path": fp_path}
        )
        order_i = 1
    for p, png in zip(resolved_ids, resolved_paths):
        labels.append({
            "order": order_i,
            "bg_id": p,
            # honest lineage — do not invent 'style' for a role-less parent
            # (Codex non-blocking). DAG guarantees a role in practice.
            "role": roles.get(p) or "unspecified",
            "path": png,  # already str-normalized
        })
        order_i += 1

    fallback_reason = (
        ";".join(f"missing_parent_plate:{m}" for m in missing) if missing else ""
    )

    if fp_path is None:
        node_class = "fp_less_direct"
        ref_used = "direct"
    else:
        node_class = "fresh"
        if resolved_ids:
            ref_used = "fp_plus_refs"
        elif ordered_parents:  # parents were requested but every one is missing
            ref_used = "fallback_fp_only"
        else:  # no parents requested
            ref_used = "fp_only"

    return {
        "bg_id": bg_id,
        "node_class": node_class,
        "proceed": True,
        "fp_path": fp_path,
        "ordered_parent_bg_ids": resolved_ids,
        "ordered_prior_bg_paths": resolved_paths,
        "attached_ref_labels": labels,
        "ref_used": ref_used,
        "fallback_reason": fallback_reason,
        "missing_parent_bg_ids": missing,
    }
