"""W21B-w4 · 3a route_render_actions_v2 — card-aware horizontal withhold (TDD).

brief docs/w21b-wave4-bg-plate-partition-refdag-brief-20260531 (APPROVED v0.1):
  ③ self-card base-band prep + ④ card-aware route_render_actions_v2.
  D5 FINAL lock (dry §10-2 실측): v0 hard-withhold = horizontal left↔right only
  (both visibility∈{visible,partial}, both confidence>=0.6, same base marker).
  depth foreground↔background = diagnostic-only (canonical render_action 불변).
"""
from app.modules.pipeline import shot_aware_bg_render_plan as P


def _card(bg_id, shot_id, *, items, card_id="c1", state="pass"):
    """C2-shape card entry: envelope with vlm_output.visible_items."""
    return {
        "bg_id": bg_id,
        "shot_id": shot_id,
        "card_id": card_id,
        "card_state": state,
        "card": {
            "card_id": card_id,
            "vlm_output": {"visible_items": items},
        },
    }


def _item(mn, *, layer="base", h="center", d="midground", vis="visible", conf=0.8):
    return {
        "marker_number": mn,
        "marker_layer": layer,
        "horizontal_band": h,
        "depth_band": d,
        "visibility": vis,
        "confidence": conf,
    }


# ─── extract_base_bands ──────────────────────────────────────────────────
def test_extract_base_bands_keeps_only_base_markers():
    card = _card("L05B09", "S1", items=[
        _item(2, layer="base", h="left", d="foreground", vis="visible", conf=0.83),
        _item(7, layer="transient", h="right", d="background"),
        _item(9, layer="ignored_state_overlay", h="center", d="midground"),
    ])
    bands = P.extract_base_bands(card)
    assert bands == {2: ("left", "foreground", "visible", 0.83)}


# ─── base_band_contradictions (D5 FINAL lock) ────────────────────────────
def test_horizontal_left_right_is_hard_withhold():
    cur = {5: ("left", "midground", "visible", 0.8)}
    tgt = {5: ("right", "midground", "visible", 0.8)}
    hard, depth_diag, guarded = P.base_band_contradictions(cur, tgt)
    assert len(hard) == 1 and hard[0][0] == 5
    assert depth_diag == [] and guarded == []


def test_depth_fg_bg_is_diagnostic_only_not_hard():
    cur = {5: ("left", "foreground", "visible", 0.85)}
    tgt = {5: ("left", "background", "visible", 0.83)}
    hard, depth_diag, guarded = P.base_band_contradictions(cur, tgt)
    assert hard == []
    assert len(depth_diag) == 1 and depth_diag[0][0] == 5


def test_occluded_marker_is_guarded_not_withheld():
    cur = {5: ("left", "midground", "visible", 0.8)}
    tgt = {5: ("right", "midground", "occluded", 0.8)}
    hard, depth_diag, guarded = P.base_band_contradictions(cur, tgt)
    assert hard == [] and depth_diag == []
    assert len(guarded) == 1


def test_low_confidence_marker_is_guarded():
    cur = {5: ("left", "midground", "visible", 0.8)}
    tgt = {5: ("right", "midground", "visible", 0.4)}
    hard, depth_diag, guarded = P.base_band_contradictions(cur, tgt)
    assert hard == [] and len(guarded) == 1


def test_agreeing_bands_produce_no_contradiction():
    cur = {5: ("left", "midground", "visible", 0.8), 6: ("center", "background", "visible", 0.9)}
    tgt = {5: ("left", "midground", "visible", 0.7), 6: ("center", "background", "visible", 0.8)}
    hard, depth_diag, guarded = P.base_band_contradictions(cur, tgt)
    assert hard == [] and depth_diag == [] and guarded == []


def test_center_adjacent_is_not_opposite():
    cur = {5: ("left", "midground", "visible", 0.8)}
    tgt = {5: ("center", "midground", "visible", 0.8)}
    hard, depth_diag, guarded = P.base_band_contradictions(cur, tgt)
    assert hard == [] and depth_diag == [] and guarded == []


def test_only_shared_markers_compared():
    cur = {5: ("left", "midground", "visible", 0.8)}
    tgt = {6: ("right", "midground", "visible", 0.8)}
    hard, depth_diag, guarded = P.base_band_contradictions(cur, tgt)
    assert hard == [] and depth_diag == [] and guarded == []


# ─── route_render_actions_v2 (④ card-aware withhold) ─────────────────────
def _rnode(bg_id, node_index, mode, *, anchor=False, candidate=False, target="",
           camera_unit=1, look_at_unit=1):
    selected_refs = ([{"ref_bg_id": "ref", "physical_space_id": "S"}]
                     if mode in ("same_physical_space_view", "reference_derived") else [])
    return {
        "bg_id": bg_id, "node_index": node_index, "mode": mode,
        "is_dwelling_identity_anchor": anchor, "rationale": "r",
        "reference_decision": {
            "selected_refs": selected_refs, "rejected_refs": [],
            "same_physical_space_dedup_decision": "single_ref",
            "why_single_ref_or_two_refs": "", "physical_space_id_per_ref": [],
            "same_physical_space_low_delta_candidate": candidate,
            "low_delta_reuse_target_bg_id": target, "low_delta_rationale":
            "same view low delta" if candidate else "",
        },
        "camera_decision": {"camera_unit": camera_unit, "look_at_unit": look_at_unit},
    }


def _facts(**bg_to_markers):
    pbf = {bg: {"bg_id": bg, "target_unit_marker_numbers": list(m),
                "dominant_target_unit_marker_number": (m[0] if len(m) == 1 else None)}
           for bg, m in bg_to_markers.items()}
    return {"per_bg_render_facts_by_bg_id": pbf}


def _reuse_pair():
    """A(anchor) + B(reuse target=A, geometry-corroborated) → B reuse."""
    return [
        _rnode("A", 0, "fp_seeded_anchor", anchor=True, camera_unit=1),
        _rnode("B", 1, "reference_derived", candidate=True, target="A",
               camera_unit=1, look_at_unit=1),
    ], _facts(A=[1], B=[1])


def _content_index(a_items, b_items, *, a_state="pass", b_state="pass"):
    idx = {
        "A": {"sA": _card("A", "sA", items=a_items, card_id="ca", state=a_state)},
        "B": {"sB": _card("B", "sB", items=b_items, card_id="cb", state=b_state)},
    }
    readiness = {"A": {"applies_to_shots": ["sA"]}, "B": {"applies_to_shots": ["sB"]}}
    return idx, readiness


def test_route_v2_no_card_is_noop():
    nodes, dossier = _reuse_pair()
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=None, per_bg_readiness={})
    assert routed[1]["render_action"] == "reuse_existing_plate"
    assert routed[1]["reuse_target_bg_id"] == "A"
    assert summary["card_withhold_count"] == 0
    # baseline reuse counts preserved (no withhold).
    assert summary["reuse_plate_count"] == 1 and summary["reuse_count"] == 1


def test_route_v2_agreeing_cards_keep_reuse():
    nodes, dossier = _reuse_pair()
    items = [_item(1, h="center", d="midground", vis="visible", conf=0.85)]
    idx, readiness = _content_index(items, list(items))
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    assert routed[1]["render_action"] == "reuse_existing_plate"
    assert summary["card_withhold_count"] == 0
    assert summary["reuse_plate_count"] == 1 and summary["reuse_count"] == 1
    # horizontal agreement on pass cards → card-corroborated reuse (for ⑥ mirror).
    assert routed[1].get("plate_corroboration") == "card_corroborated"


def test_route_v2_no_card_reuse_has_no_corroboration_stamp():
    nodes, dossier = _reuse_pair()
    routed, _ = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=None, per_bg_readiness={})
    assert routed[1].get("plate_corroboration") is None  # geometry-only reuse


def test_route_v2_disjoint_base_markers_is_not_corroborated():
    """Finding 1: two pass cards with NO shared base marker → reuse kept but
    NOT card-corroborated (no positive band evidence). mirror → geometry_only."""
    nodes, dossier = _reuse_pair()
    a_items = [_item(1, h="center", d="midground", vis="visible", conf=0.85)]
    b_items = [_item(99, h="center", d="midground", vis="visible", conf=0.85)]
    idx, readiness = _content_index(a_items, b_items)
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    assert routed[1]["render_action"] == "reuse_existing_plate"  # no conflict → kept
    assert routed[1].get("plate_corroboration") is None  # but no positive evidence
    mirrored, _ = P.mirror_plate_partition(routed)
    assert mirrored[1]["plate_shareability"] == "shareable_geometry_only"


def test_route_v2_all_guarded_shared_marker_is_not_corroborated():
    """Finding 1: shared marker exists but is low-confidence → guarded → no
    positive evidence → not corroborated."""
    nodes, dossier = _reuse_pair()
    a_items = [_item(1, h="center", d="midground", vis="visible", conf=0.40)]
    b_items = [_item(1, h="center", d="midground", vis="visible", conf=0.40)]
    idx, readiness = _content_index(a_items, b_items)
    routed, _ = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    assert routed[1].get("plate_corroboration") is None


def test_route_v2_horizontal_conflict_withholds_reuse():
    nodes, dossier = _reuse_pair()
    a_items = [_item(1, h="left", d="midground", vis="visible", conf=0.85)]
    b_items = [_item(1, h="right", d="midground", vis="visible", conf=0.85)]
    idx, readiness = _content_index(a_items, b_items)
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    assert routed[1]["render_action"] == "render_new_plate"
    assert routed[1]["reuse_target_bg_id"] == ""
    assert summary["card_withhold_count"] == 1
    # canonical render_action changed → reuse counts must be recomputed (1→0).
    assert summary["reuse_plate_count"] == 0 and summary["reuse_count"] == 0


def test_route_v2_depth_conflict_keeps_reuse_with_diagnostic():
    nodes, dossier = _reuse_pair()
    a_items = [_item(1, h="left", d="foreground", vis="visible", conf=0.85)]
    b_items = [_item(1, h="left", d="background", vis="visible", conf=0.85)]
    idx, readiness = _content_index(a_items, b_items)
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    assert routed[1]["render_action"] == "reuse_existing_plate"  # canonical 불변
    assert summary["card_withhold_count"] == 0
    assert routed[1].get("depth_band_conflict_diagnostic")  # 진단만 기록
    assert summary["reuse_plate_count"] == 1 and summary["reuse_count"] == 1


def test_route_v2_target_blocked_card_does_not_withhold():
    """Required 2: target card non-pass → horizontal conflict ignored, reuse kept."""
    nodes, dossier = _reuse_pair()
    a_items = [_item(1, h="left", d="midground", vis="visible", conf=0.85)]
    b_items = [_item(1, h="right", d="midground", vis="visible", conf=0.85)]
    idx, readiness = _content_index(a_items, b_items, a_state="blocked")
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    assert routed[1]["render_action"] == "reuse_existing_plate"
    assert summary["card_withhold_count"] == 0


def test_route_v2_needs_review_card_does_not_withhold():
    """Required 2: reuse-side card needs_review → not trusted, reuse kept."""
    nodes, dossier = _reuse_pair()
    a_items = [_item(1, h="left", d="midground", vis="visible", conf=0.85)]
    b_items = [_item(1, h="right", d="midground", vis="visible", conf=0.85)]
    idx, readiness = _content_index(a_items, b_items, b_state="needs_review")
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    assert routed[1]["render_action"] == "reuse_existing_plate"
    assert summary["card_withhold_count"] == 0


def test_route_v2_card_id_mismatch_does_not_withhold():
    """Required 2: envelope.card_id != index entry card_id → re-join fails, reuse kept."""
    nodes, dossier = _reuse_pair()
    a_items = [_item(1, h="left", d="midground", vis="visible", conf=0.85)]
    b_items = [_item(1, h="right", d="midground", vis="visible", conf=0.85)]
    idx, readiness = _content_index(a_items, b_items)
    # corrupt the re-join: envelope card_id no longer matches the entry pointer.
    idx["B"]["sB"]["card"]["card_id"] = "tampered"
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    assert routed[1]["render_action"] == "reuse_existing_plate"
    assert summary["card_withhold_count"] == 0


# ─── ⑥ mirror_plate_partition (PLATE_PARTITION_FIELDS) ───────────────────
def _mnode(bg, *, action="render_new_plate", target="", withhold=False,
           corrob=None, anchor_shot=""):
    n = {"bg_id": bg, "render_action": action, "reuse_target_bg_id": target,
         "anchor_shot_id": anchor_shot}
    if withhold:
        n["card_withhold_reason"] = "horizontal_band_conflict"
    if corrob:
        n["plate_corroboration"] = corrob
    return n


def test_mirror_exclusive_solo_new_plate():
    nodes, _ = P.mirror_plate_partition([_mnode("A")])
    n = nodes[0]
    assert n["plate_group_id"] == "A" and n["plate_anchor_bg_id"] == "A"
    assert n["needs_new_plate"] is True
    assert n["plate_shareability"] == "exclusive"


def test_mirror_reuse_member_mirrors_target_group():
    nodes, _ = P.mirror_plate_partition([
        _mnode("A"), _mnode("B", action="reuse_existing_plate", target="A")])
    a, b = nodes
    assert b["plate_group_id"] == "A" and b["plate_anchor_bg_id"] == "A"
    assert b["needs_new_plate"] is False
    assert b["plate_shareability"] == "shareable_geometry_only"
    # anchor with a member is shareable (not exclusive).
    assert a["plate_shareability"] == "shareable_geometry_only"
    assert a["needs_new_plate"] is True


def test_mirror_withheld_node_is_not_shareable_card_withheld():
    nodes, _ = P.mirror_plate_partition([_mnode("A"), _mnode("B", withhold=True)])
    assert nodes[1]["plate_shareability"] == "not_shareable_card_withheld"
    assert nodes[1]["needs_new_plate"] is True


def test_mirror_card_corroborated_reuse_and_anchor():
    nodes, _ = P.mirror_plate_partition([
        _mnode("A"),
        _mnode("B", action="reuse_existing_plate", target="A", corrob="card_corroborated")])
    assert nodes[1]["plate_shareability"] == "shareable_card_corroborated"
    # anchor inherits the strongest member corroboration.
    assert nodes[0]["plate_shareability"] == "shareable_card_corroborated"


def test_mirror_plate_anchor_shot_id_inherited_from_group_anchor():
    nodes, _ = P.mirror_plate_partition([
        _mnode("A", anchor_shot="sA"),
        _mnode("B", action="reuse_existing_plate", target="A")])
    assert nodes[0]["plate_anchor_shot_id"] == "sA"
    assert nodes[1]["plate_anchor_shot_id"] == "sA"  # member inherits anchor's shot


# ─── ⑦ build_reference_dag (REFERENCE_DAG_FIELDS) ────────────────────────
def _dnode(bg, node_index, mode, *, needs_new=True, refs=None):
    return {"bg_id": bg, "node_index": node_index, "mode": mode,
            "needs_new_plate": needs_new,
            "reference_decision": {"selected_refs": refs or []}}


def test_dag_anchor_has_no_parents():
    nodes, _ = P.build_reference_dag([_dnode("A", 0, "fp_seeded_anchor")])
    n = nodes[0]
    assert n["ref_tree_parents"] == [] and n["render_order_index"] == 0
    assert n["max_refs"] == 2 and n["ref_role_per_parent"] == {}


def test_dag_same_space_view_parent_role_continuity():
    nodes, _ = P.build_reference_dag([
        _dnode("A", 0, "fp_seeded_anchor"),
        _dnode("B", 1, "same_physical_space_view",
               refs=[{"ref_bg_id": "A", "physical_space_id": "S"}])])
    b = nodes[1]
    assert b["ref_tree_parents"] == ["A"]
    assert b["ref_role_per_parent"] == {"A": "space_continuity"}


def test_dag_related_style_role_style():
    nodes, _ = P.build_reference_dag([
        _dnode("A", 0, "fp_seeded_anchor"),
        _dnode("C", 1, "related_style_new_space",
               refs=[{"ref_bg_id": "A", "physical_space_id": "S2"}])])
    assert nodes[1]["ref_role_per_parent"] == {"A": "style"}


def test_dag_reuse_member_not_a_dag_node():
    nodes, _ = P.build_reference_dag([
        _dnode("A", 0, "fp_seeded_anchor"),
        _dnode("B", 1, "same_physical_space_view", needs_new=False,
               refs=[{"ref_bg_id": "A", "physical_space_id": "S"}])])
    assert "ref_tree_parents" not in nodes[1]  # reuse alias is not a DAG node


def test_dag_render_order_is_topological_by_node_index():
    nodes, _ = P.build_reference_dag([
        _dnode("A", 0, "fp_seeded_anchor"),
        _dnode("B", 2, "reference_derived",
               refs=[{"ref_bg_id": "A", "physical_space_id": "S"}]),
        _dnode("C", 1, "reference_derived",
               refs=[{"ref_bg_id": "A", "physical_space_id": "S"}])])
    order = {n["bg_id"]: n["render_order_index"] for n in nodes}
    assert order["A"] == 0 and order["C"] == 1 and order["B"] == 2


# ─── ⑦ parent canonicalization (Codex wiring-review Required) ─────────────
def _dnode_anchored(bg, node_index, mode, *, needs_new=True, refs=None, anchor=None):
    """A ⑥-mirror-stamped node — carries plate_anchor_bg_id (the bg whose pixels
    the plate uses: self for fresh, reuse target for an alias)."""
    n = _dnode(bg, node_index, mode, needs_new=needs_new, refs=refs)
    n["plate_anchor_bg_id"] = anchor if anchor is not None else bg
    return n


def test_dag_parent_canonicalizes_reuse_alias_to_fresh_anchor():
    """selected_refs is pre-route LLM output, so a fresh node can reference a bg
    that later became a reuse alias. ⑦ must canonicalize the parent through
    plate_anchor_bg_id (⑥ mirror) to the underlying RENDERED plate — never record
    an alias as a DAG parent (the alias has no DAG fields / produces no pixels)."""
    nodes, summary = P.build_reference_dag([
        _dnode_anchored("A", 0, "fp_seeded_anchor"),
        _dnode_anchored("B", 1, "same_physical_space_view", needs_new=False,
                        anchor="A", refs=[{"ref_bg_id": "A"}]),
        _dnode_anchored("C", 2, "reference_derived", refs=[{"ref_bg_id": "B"}]),
    ])
    c = next(n for n in nodes if n["bg_id"] == "C")
    assert c["ref_tree_parents"] == ["A"]  # B alias → A, its rendered plate
    assert c["ref_role_per_parent"] == {"A": "space_continuity"}
    b = next(n for n in nodes if n["bg_id"] == "B")
    assert "ref_tree_parents" not in b  # reuse alias is not a DAG node
    assert summary["dag_node_count"] == 2  # A + C only


def test_dag_parents_dedupe_when_canonicalizing_to_same_anchor():
    """Two distinct refs that canonicalize to the same rendered plate collapse to
    one parent."""
    nodes, _ = P.build_reference_dag([
        _dnode_anchored("A", 0, "fp_seeded_anchor"),
        _dnode_anchored("B1", 1, "same_physical_space_view", needs_new=False,
                        anchor="A", refs=[{"ref_bg_id": "A"}]),
        _dnode_anchored("B2", 2, "same_physical_space_view", needs_new=False,
                        anchor="A", refs=[{"ref_bg_id": "A"}]),
        _dnode_anchored("C", 3, "two_refs_distinct_spaces",
                        refs=[{"ref_bg_id": "B1"}, {"ref_bg_id": "B2"}]),
    ])
    c = next(n for n in nodes if n["bg_id"] == "C")
    assert c["ref_tree_parents"] == ["A"]


def test_dag_parents_cap_applies_to_distinct_canonical_parents():
    """MAX_REFS_PER_BG caps DISTINCT rendered-plate parents (after canonicalize +
    dedupe), not the raw selected_refs count."""
    nodes, _ = P.build_reference_dag([
        _dnode_anchored("A", 0, "fp_seeded_anchor"),
        _dnode_anchored("B", 1, "fp_seeded_anchor"),
        _dnode_anchored("C", 2, "fp_seeded_anchor"),
        _dnode_anchored("D", 3, "two_refs_distinct_spaces",
                        refs=[{"ref_bg_id": "A"}, {"ref_bg_id": "B"},
                              {"ref_bg_id": "C"}]),
    ])
    d = next(n for n in nodes if n["bg_id"] == "D")
    assert d["ref_tree_parents"] == ["A", "B"]  # capped at MAX_REFS_PER_BG=2


# ─── Finding 2 — reuse-chain edge is fail-closed (2a lock) ───────────────
def test_route_v2_reuse_chain_edge_stays_closed_without_killing_the_fp():
    """A new, B reuse A (card horizontal withhold), C low-delta target B.

    route_v2 is withhold-only: B is withheld to render_new_plate, and the
    downstream reuse C→B that the baseline router already **downgraded**
    (reuse-of-reuse) is NOT re-opened — C stays render_new_plate.

    ★2026-09-19 — 바뀐 것은 **총계뿐**이다. 앞서는 baseline 이
    `routing_ok=False` 를 놓아 fp 가 통째로 죽었는데, 이제 내림 + 진단으로
    남는다. 「C 를 다시 열지 않는다」는 계약은 그대로다.
    """
    nodes = [
        _rnode("A", 0, "fp_seeded_anchor", anchor=True),
        _rnode("B", 1, "reference_derived", candidate=True, target="A"),
        _rnode("C", 2, "reference_derived", candidate=True, target="B"),
    ]
    dossier = _facts(A=[1], B=[1], C=[1])
    # A↔B horizontal conflict → B withheld.
    idx = {
        "A": {"sA": _card("A", "sA", card_id="ca",
              items=[_item(1, h="left", d="midground", vis="visible", conf=0.85)])},
        "B": {"sB": _card("B", "sB", card_id="cb",
              items=[_item(1, h="right", d="midground", vis="visible", conf=0.85)])},
    }
    readiness = {"A": {"applies_to_shots": ["sA"]}, "B": {"applies_to_shots": ["sB"]}}
    routed, summary = P.route_render_actions_v2(
        nodes=nodes, dossier=dossier, card_content_index=idx, per_bg_readiness=readiness)
    # B withheld → new.
    assert routed[1]["render_action"] == "render_new_plate"
    assert routed[1].get("card_withhold_reason") == "horizontal_band_conflict"
    # C is NOT re-opened — stays render_new_plate (baseline reuse-of-reuse reject).
    assert routed[2]["render_action"] == "render_new_plate"
    # ★C 를 다시 열지 않는 것이 핵심 — 참조는 그대로 두고 새로 그린다.
    assert routed[2].get("reuse_target_bg_id", "") == ""
    assert routed[2]["reference_decision"]["selected_refs"], routed[2]
    # baseline 진단은 그대로 남는다. 총계는 이제 fp 를 죽이지 않는다.
    assert summary["render_action_routing_ok"] is True
    assert any("reuse-of-reuse" in d and "downgraded" in d
               for d in summary["diagnostics"]), summary["diagnostics"]
    # reuse count recomputed to 0 (B withheld; C never was reuse).
    assert summary["reuse_plate_count"] == 0 and summary["reuse_count"] == 0
