"""W21B-wave-4 C4: background_prompt projection-card consumer guard.

Pure-unit tests for ``resolve_projection_plate_injection`` — the
deterministic decision the BG prompt consumer makes per bg: inject the
anchor card's ``bg_plate_visible_description`` ONLY when the plan node
says pass AND the shot_projection_card checkpoint re-join confirms the
same card_id and a still-pass state AND the plate prose is leak-free.
Anything else → no injection (v10 fallback) with an explicit
fallback_reason. NO LLM / VLM / image.

Contract locked with Codex (2026-05-31 C4 scope + Required):
  - pass-only injection; needs_review/blocked/not_available/not_applicable
    → v10 fallback (needs_review is diagnostic only, never injected in v0).
  - the plan node's projection_card_id/hash/state are NOT trusted blindly:
    the consumer re-joins the shot_projection_card checkpoint and verifies
    card_id (content-addressed == hash) + the checkpoint's own card_state.
    missing entry → projection_card_missing; id mismatch →
    projection_card_stale; checkpoint state not pass →
    projection_card_state_changed.
  - leakage guard reuses the C2 self-defined internal-token check (no
    duplicate matcher; Korean meaning untouched).
  - only bg_plate_visible_description reaches the prompt surface.
"""
from __future__ import annotations

from app.modules.pipeline.background_prompt_projection import (
    resolve_projection_plate_injection,
)


def _plan_node(bg_id, *, state="pass", card_id="cardA", anchor="S1_Shot1",
               source_bg_id=None, inherited=False):
    return {
        "bg_id": bg_id,
        "projection_card_state": state,
        "projection_card_id": card_id,
        "projection_card_hash": card_id,
        "anchor_shot_id": anchor,
        "projection_card_source_bg_id": source_bg_id if source_bg_id is not None else bg_id,
        "projection_card_inherited": inherited,
    }


def _card_entry(bg_id, shot_id, card_id, *, state="pass", plate="거실 전경, 큰 창과 낮은 소파",
                marker_numbers=()):
    return {
        "bg_id": bg_id,
        "shot_id": shot_id,
        "card_state": state,
        "card": {
            "card_id": card_id,
            "vlm_output": {
                "bg_plate_visible_description": plate,
                "visible_items": [{"marker_number": n} for n in marker_numbers],
            },
        },
    }


def _lookup(*entries):
    return {(e["bg_id"], e["shot_id"]): e for e in entries}


def test_pass_with_matching_card_injects_plate_prose():
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    lookup = _lookup(_card_entry("A", "S1_Shot1", "cardA", plate="거실 전경, 큰 창과 소파"))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is True
    assert res["plate_prose"] == "거실 전경, 큰 창과 소파"
    assert res["fallback_reason"] == ""
    assert res["card_id"] == "cardA"


def test_needs_review_state_does_not_inject():
    node = _plan_node("A", state="needs_review")
    lookup = _lookup(_card_entry("A", "S1_Shot1", "cardA"))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is False
    assert res["fallback_reason"] == "needs_review"
    assert res["plate_prose"] == ""


def test_blocked_state_does_not_inject():
    node = _plan_node("A", state="blocked")
    res = resolve_projection_plate_injection(plan_node=node, card_lookup={})
    assert res["inject"] is False
    assert res["fallback_reason"] == "blocked"


def test_not_applicable_state_does_not_inject():
    node = _plan_node("A", state="not_applicable")
    res = resolve_projection_plate_injection(plan_node=node, card_lookup={})
    assert res["inject"] is False
    assert res["fallback_reason"] == "not_applicable"


def test_pass_but_card_missing_from_checkpoint_falls_back():
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    res = resolve_projection_plate_injection(plan_node=node, card_lookup={})
    assert res["inject"] is False
    assert res["fallback_reason"] == "projection_card_missing"


def test_pass_but_card_id_mismatch_is_stale():
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    # checkpoint regenerated the card → different content-addressed id.
    lookup = _lookup(_card_entry("A", "S1_Shot1", "cardA_v2"))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is False
    assert res["fallback_reason"] == "projection_card_stale"


def test_pass_but_checkpoint_state_not_pass_is_state_changed():
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    # plan node says pass but the checkpoint card now resolves needs_review.
    lookup = _lookup(_card_entry("A", "S1_Shot1", "cardA", state="needs_review"))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is False
    assert res["fallback_reason"] == "projection_card_state_changed"


def test_pass_but_empty_plate_prose_falls_back():
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    lookup = _lookup(_card_entry("A", "S1_Shot1", "cardA", plate="   "))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is False
    assert res["fallback_reason"] == "projection_card_empty_plate"


def test_pass_but_marker_number_leak_in_prose_falls_back():
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    lookup = _lookup(
        _card_entry("A", "S1_Shot1", "cardA", plate="소파는 #3 위치에 있음", marker_numbers=[3])
    )
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is False
    assert res["fallback_reason"] == "projection_card_leak"


def test_pass_but_enum_literal_leak_in_prose_falls_back():
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    # 'left' is a fixed English enum literal the card defines — leaking it into
    # the plate prose is a self-defined-token leak (NOT Korean meaning).
    lookup = _lookup(_card_entry("A", "S1_Shot1", "cardA", plate="sofa on the left side"))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is False
    assert res["fallback_reason"] == "projection_card_leak"


def test_pass_but_node_id_hash_self_mismatch_is_stale():
    """Plan-node self-consistency (Codex C4 review Required): C3 stamps
    id == hash == card_id. A node whose projection_card_hash disagrees with
    its projection_card_id is a corrupt pointer → fail-closed, even if the
    card_id alone would match a checkpoint entry."""
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    node["projection_card_hash"] = "cardB"  # disagrees with id
    lookup = _lookup(_card_entry("A", "S1_Shot1", "cardA"))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is False
    assert res["fallback_reason"] == "projection_card_stale"


def test_pass_but_empty_hash_field_is_stale():
    node = _plan_node("A", card_id="cardA", anchor="S1_Shot1")
    node["projection_card_hash"] = ""  # missing hash field
    lookup = _lookup(_card_entry("A", "S1_Shot1", "cardA"))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is False
    assert res["fallback_reason"] == "projection_card_stale"


def test_reuse_inherited_pass_injects_target_anchor_plate():
    """A reuse plate inherited from a target (projection_card_source_bg_id=B,
    inherited=True) re-joins the TARGET's anchor card and injects its plate
    prose (§8 decision-lock — pixels' origin is the SOT)."""
    node = _plan_node(
        "A", card_id="cardB", anchor="S2_Shot1",
        source_bg_id="B", inherited=True,
    )
    lookup = _lookup(_card_entry("B", "S2_Shot1", "cardB", plate="침실 전경"))
    res = resolve_projection_plate_injection(plan_node=node, card_lookup=lookup)
    assert res["inject"] is True
    assert res["plate_prose"] == "침실 전경"
    assert res["source_bg_id"] == "B"
    assert res["anchor_shot_id"] == "S2_Shot1"
