"""W21B-w4 · #4(C) render-input substrate policy — resolve_render_substrate (TDD).

brief docs/w21b-wave4-bg-render-substrate-policy-20260531 (APPROVED v0.2) +
Codex design lock (2026-05-31). Single deterministic resolver core (Q4). Pure —
no image/LLM/IO beyond caller-supplied PNG snapshot dicts. Decides, per plan
node, the render-input substrate:
    - reuse alias  → render input 0 (copy-less; refs empty; proceed=False)
    - fresh plate  → FP PNG first, then ref_tree_parents reordered
                     space_continuity → style (canonical anchors). missing parent
                     PNG dropped + recorded (graceful degrade, brief §5 v0).
    - fp-less      → no FP png → '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).

resolver decides PNG substrate ONLY — plate prose / projection_card_state stays
with C4 resolve_projection_plate_injection (no overlap, Codex caution).
"""
from app.modules.pipeline.background_render_substrate import (
    SUBSTRATE_FIELDS,
    resolve_render_substrate,
)


def _node(bg, *, action="render_new_plate", needs_new=True, parents=None,
          roles=None, withhold=None):
    n = {
        "bg_id": bg,
        "render_action": action,
        "needs_new_plate": needs_new,
        "ref_tree_parents": parents or [],
        "ref_role_per_parent": roles or {},
    }
    if withhold:
        n["card_withhold_reason"] = withhold
    return n


# ─── reuse alias → render input 0, proceed=False ─────────────────────────
def test_reuse_alias_has_no_render_input():
    node = _node("B", action="reuse_existing_plate", needs_new=False)
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={"A": "/p/A.png"},
    )
    assert d["node_class"] == "reuse_alias"
    assert d["proceed"] is False
    assert d["ordered_parent_bg_ids"] == []
    assert d["ordered_prior_bg_paths"] == []
    assert d["fp_path"] is None
    assert d["ref_used"] == "reuse_alias"
    assert d["attached_ref_labels"] == []


# ─── fresh: FP only (no parents) ─────────────────────────────────────────
def test_fresh_no_parents_is_fp_only():
    node = _node("A", parents=[])
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={},
    )
    assert d["node_class"] == "fresh"
    assert d["fp_path"] == "/p/fp1.png"
    assert d["ordered_parent_bg_ids"] == []
    assert d["ref_used"] == "fp_only"
    assert d["attached_ref_labels"] == [
        {"order": 0, "bg_id": "fp1", "role": "floor_plan", "path": "/p/fp1.png"},
    ]


# ─── fresh: FP + one space_continuity parent ─────────────────────────────
def test_fresh_fp_plus_space_continuity_parent():
    node = _node("C", parents=["A"], roles={"A": "space_continuity"})
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={"A": "/p/A.png"},
    )
    assert d["ordered_parent_bg_ids"] == ["A"]
    assert d["ordered_prior_bg_paths"] == ["/p/A.png"]
    assert d["ref_used"] == "fp_plus_refs"
    assert d["fallback_reason"] == ""
    assert d["attached_ref_labels"] == [
        {"order": 0, "bg_id": "fp1", "role": "floor_plan", "path": "/p/fp1.png"},
        {"order": 1, "bg_id": "A", "role": "space_continuity", "path": "/p/A.png"},
    ]


# ─── fresh: ordering FP → space_continuity → style ───────────────────────
def test_fresh_orders_space_continuity_before_style():
    # ref_tree_parents lists style first; resolver must emit continuity first,
    # preserving ref_tree_parents order WITHIN each role.
    node = _node("D", parents=["S", "K"],
                 roles={"S": "style", "K": "space_continuity"})
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={"S": "/p/S.png", "K": "/p/K.png"},
    )
    assert d["ordered_parent_bg_ids"] == ["K", "S"]
    assert d["ordered_prior_bg_paths"] == ["/p/K.png", "/p/S.png"]
    assert d["ref_used"] == "fp_plus_refs"
    assert [l["role"] for l in d["attached_ref_labels"]] == [
        "floor_plan", "space_continuity", "style"]


# ─── fresh: one missing parent → drop only it + record ───────────────────
def test_fresh_missing_one_parent_drops_only_missing():
    node = _node("D", parents=["K", "S"],
                 roles={"K": "space_continuity", "S": "style"})
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={"K": "/p/K.png"},  # S missing
    )
    assert d["ordered_parent_bg_ids"] == ["K"]
    assert d["ordered_prior_bg_paths"] == ["/p/K.png"]
    assert d["missing_parent_bg_ids"] == ["S"]
    assert "missing_parent_plate:S" in d["fallback_reason"]
    assert d["ref_used"] == "fp_plus_refs"  # still has K
    assert d["proceed"] is True


# ─── fresh: ALL parents missing → fallback_fp_only ───────────────────────
def test_fresh_all_parents_missing_is_fallback_fp_only():
    node = _node("C", parents=["A"], roles={"A": "space_continuity"})
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={},
    )
    assert d["ordered_parent_bg_ids"] == []
    assert d["missing_parent_bg_ids"] == ["A"]
    assert d["ref_used"] == "fallback_fp_only"
    assert d["fp_path"] == "/p/fp1.png"
    assert d["proceed"] is True


# ─── fp-less fresh: no FP png → 'direct' ─────────────────────────────────
def test_fp_less_no_fp_path_is_direct():
    node = _node("C", parents=["A"], roles={"A": "space_continuity"})
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={},  # no FP png
        rendered_plate_png_by_bg={"A": "/p/A.png"},
    )
    assert d["node_class"] == "fp_less_direct"
    assert d["fp_path"] is None
    assert d["ordered_prior_bg_paths"] == ["/p/A.png"]
    assert d["ref_used"] == "direct"
    assert d["proceed"] is True


# ─── max-refs defensive cap (DAG already caps, resolver re-guards) ────────
def test_resolver_defensive_max_refs_cap():
    node = _node("E", parents=["K", "L", "M"],
                 roles={"K": "space_continuity", "L": "space_continuity",
                        "M": "style"})
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={"K": "/p/K.png", "L": "/p/L.png",
                                  "M": "/p/M.png"},
    )
    assert len(d["ordered_parent_bg_ids"]) == 2  # capped


# ─── card-withheld fresh folds into fresh ────────────────────────────────
def test_card_withheld_fresh_is_fresh_class():
    node = _node("B", parents=["A"], roles={"A": "space_continuity"},
                 withhold="horizontal_band_conflict")
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={"A": "/p/A.png"},
    )
    assert d["node_class"] == "fresh"
    assert d["ordered_parent_bg_ids"] == ["A"]  # target not implicitly added


# ─── JSON-safety: Path inputs normalize to str output (Codex Required) ───
def test_path_inputs_normalize_to_str_output():
    """resolver output is JSON-safe — even if the caller passes Path objects,
    fp_path / ordered_prior_bg_paths / attached_ref_labels[].path are str (so a
    decision can be persisted to a manifest without JSON serialization breaking)."""
    from pathlib import Path
    node = _node("C", parents=["A"], roles={"A": "space_continuity"})
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": Path("/p/fp1.png")},
        rendered_plate_png_by_bg={"A": Path("/p/A.png")},
    )
    assert isinstance(d["fp_path"], str)
    assert all(isinstance(p, str) for p in d["ordered_prior_bg_paths"])
    assert all(isinstance(lbl["path"], str) for lbl in d["attached_ref_labels"])
    assert d["fp_path"] == "/p/fp1.png"
    assert d["ordered_prior_bg_paths"] == ["/p/A.png"]


def test_unspecified_role_is_not_invented_as_style():
    """a parent with no DAG role surfaces as 'unspecified' in lineage labels —
    we do not invent 'style' (Codex non-blocking: honest lineage metadata)."""
    node = _node("C", parents=["A"], roles={})  # A has no role
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={"A": "/p/A.png"},
    )
    parent_label = [l for l in d["attached_ref_labels"] if l["bg_id"] == "A"][0]
    assert parent_label["role"] == "unspecified"


# ─── contract: every decision carries all SUBSTRATE_FIELDS ───────────────
def test_decision_always_carries_all_fields():
    node = _node("C", parents=["A"], roles={"A": "space_continuity"})
    d = resolve_render_substrate(
        plan_node=node, fp_id="fp1",
        fp_png_by_fp={"fp1": "/p/fp1.png"},
        rendered_plate_png_by_bg={"A": "/p/A.png"},
    )
    for f in SUBSTRATE_FIELDS:
        assert f in d, f"missing substrate field {f}"
