"""validate_attached_refs — D5 identity-level + Area #5 sidecar edition.

spec: docs/superpowers/specs/2026-05-09-attached-reference-identity-contract-design.md §4.4
plan: docs/superpowers/plans/2026-05-09-attached-reference-identity-contract-implementation.md Task 3

가드 (T3 사용자 binding):
- G1: D2 strict extractor 그대로 보존 (rpc/asset_requirements/required_refs `or {}` 금지)
- G2: character_outlook 은 close_framing 무관 항상 strict (close 면제는 background 만)
- G3: chain_bg_lookup(bg_id)=None 이면 prev_shot 으로 background 통과 X (lineage 확인 불가 = missing)

이전 D1~D4 (substring 매칭) tests 는 모두 meta 기반 (kind, id) 으로 마이그레이션.
Area #5 W2 removed the legacy prompt-prose classifier. This file preserves
identity-level required_refs regressions and sidecar bridge tests; dedicated
sidecar behavior lives in test_ref_contract_validator_sidecar.py.
"""
from __future__ import annotations

import pytest



# ──────────────────────────────────────────────────────────────────────
# legacy classify_from_the_reference 직접 test 20개 폐기 (Area #5 W2 atomic delete).
# phantom guard sidecar exact compare 로 대체 — see test_ref_contract_validator_sidecar.py.
# ──────────────────────────────────────────────────────────────────────

# ──────────────────────────────────────────────────────────────────────
# Integration regression — S12 / S24 production fail 패턴
# ──────────────────────────────────────────────────────────────────────


def test_S12_pattern_character_far_object_near_passes_with_prop_meta():
    """S12_Shot10 회귀: character 묘사 텍스트 + object phrase + prop attached.

    phrase 직전 30자에 object 토큰만 있어 object 로 분류 → prop meta 첨부 →
    PASS. 이전 (60자만 보던) 로직은 character 로 오분류해 fail 했었음.
    """
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": []}}
    labeled_refs = [
        ("Image 1: previous shot at L05", b"x"),
        ("Image 2 (object reference): P10 prop", b"y"),
    ]
    attached_meta = [
        ("background_prev_shot", "L05"),
        ("prop", "P10"),
    ]
    prompt = (
        "an around-40 Korean woman in a dark uniform sleeve, curled "
        "and half-clamping the object from the reference, a crumpled "
        "photo near the floor."
    )
    # raise 없이 통과해야
    validate_attached_refs(
        rpc, labeled_refs, attached_meta, prompt=prompt,
        is_close_framing=False,
        reference_phrase_kinds=[],
    )


def test_S24_pattern_generic_instruction_only_does_not_phantom_fail():
    """S24_Shot9 회귀: 합성 prompt 에 generic instruction 'compositions
    from reference images' 만 있고 specific ref phrase 는 없는 상황.

    plural generic instruction 은 검사에서 제외되므로 phantom fail 발생 X.
    이전 로직은 plural 도 단수와 동일 처리 → object token 윈도우 매칭 →
    fail 했었음.
    """
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": []}}
    labeled_refs = [("Image 1: previous shot at L05", b"x")]
    attached_meta = [("background_prev_shot", "L05")]
    prompt = (
        "A subjective POV close-up. Focus is tight on a old photograph "
        "held in the immediate foreground against a pitch-dark void.\n"
        "Generate one image:\n"
        "- do not copy poses or compositions from reference images\n"
        "- do not alter character identities where their face is visible "
        "in the scene"
    )
    # raise 없이 통과해야
    validate_attached_refs(
        rpc, labeled_refs, attached_meta, prompt=prompt,
        is_close_framing=False,
        reference_phrase_kinds=[],
    )


# ──────────────────────────────────────────────────────────────────────
# validate_attached_refs — D5 identity-level required_refs 검사
# ──────────────────────────────────────────────────────────────────────


def test_required_character_outlook_missing_raises():
    """required_refs.character_outlook 명시 + attached_meta 부재 → fail (D5 §4.4 검사 3)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": {"character_outlook": ["C01O02"]}}}
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(rpc, [], [], prompt="A young woman walks.", is_close_framing=False, reference_phrase_kinds=[])


def test_required_character_outlook_satisfied_no_raise():
    """character_outlook composite meta 첨부 → no raise."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": {"character_outlook": ["C01O02"]}}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(rpc, labeled_refs, attached_meta, prompt="scene.", is_close_framing=False, reference_phrase_kinds=[])


def test_required_background_close_framing_exempt():
    """required_refs.background 명시 + close framing → 면제 (D5 §4.4 검사 4 분기)."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": {"background": ["bg_kitchen"]}}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(rpc, labeled_refs, attached_meta, prompt="Eye close-up.", is_close_framing=True, reference_phrase_kinds=[])


def test_required_background_not_close_missing_raises():
    """required_refs.background + NOT close + bg meta 부재 → fail."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": {"background": ["bg_kitchen"]}}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    with pytest.raises(RefContractError, match="background"):
        validate_attached_refs(rpc, labeled_refs, attached_meta, prompt="aisle.", is_close_framing=False, reference_phrase_kinds=[])


def test_sidecar_character_declared_missing_meta_raises():
    """phantom guard: sidecar declares character + character meta 부재 → fail (Area #5 v1)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": {}}}
    with pytest.raises(RefContractError, match="reference_phrase_kinds declares kind='character'"):
        validate_attached_refs(
            rpc, [], [],
            prompt="the character shown in image 1 walks",
            is_close_framing=False,
            reference_phrase_kinds=["character"],
        )


def test_sidecar_background_empty_close_framing_passes():
    """Area #5 v1 C2 — close framing 이어도 sidecar 가 background declare 안 하면 step 6 통과.

    legacy 의도 = "close framing 면제" — sidecar 는 producer 가 emit 안 하면 `[]` 로 step 6 skip.
    """
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": {}}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(
        rpc, labeled_refs, attached_meta,
        prompt="close-up of subject's hand",
        is_close_framing=True,
        reference_phrase_kinds=[],
    )


def test_sidecar_background_declared_missing_meta_raises():
    """phantom guard: sidecar declares background + bg meta 부재 → fail (Area #5 v1)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": {}}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    with pytest.raises(RefContractError, match="reference_phrase_kinds declares kind='background'"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="the sliding door shown in image 2",
            is_close_framing=False,
            reference_phrase_kinds=["background"],
        )


def test_sidecar_prop_declared_missing_meta_raises():
    """phantom guard: sidecar declares prop + prop meta 부재 → fail (Area #5 v1 — legacy 'object' enum 폐기, prop 채택)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": {}}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    with pytest.raises(RefContractError, match="reference_phrase_kinds declares kind='prop'"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="the photograph shown in image 4 on the desk",
            is_close_framing=False,
            reference_phrase_kinds=["prop"],
        )


def test_prompt_prose_reference_phrase_ignored_when_sidecar_empty():
    """Area #5: step 6 does not parse prompt prose. Empty reference_phrase_kinds means no
    phantom guard check even if prompt text contains legacy reference wording."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": {}}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(
        rpc, labeled_refs, attached_meta,
        prompt="from the reference image as inspiration",
        is_close_framing=False,
        reference_phrase_kinds=[],
    )


def test_no_required_no_phrase_no_raise():
    """required_refs 없음 + 'from the reference' phrase 없음 → no raise."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": {}}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(
        rpc, labeled_refs, attached_meta,
        prompt="A young Korean woman walks down the street.",
        is_close_framing=False,
        reference_phrase_kinds=[],
    )


# ──────────────────────────────────────────────────────────────────────
# manifest canonical shape (list of dicts) 지원
# ──────────────────────────────────────────────────────────────────────


def test_required_refs_list_shape_character_outlook_missing_raises():
    """list shape: kind=character_outlook + meta 부재 → fail.

    G2: close_framing=True 여도 character_outlook 은 strict.
    """
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
        {"kind": "character_outlook", "id": "C02O03", "policy": "required"},
    ]}}
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(rpc, [], [], prompt="scene.", is_close_framing=True, reference_phrase_kinds=[])


def test_required_refs_list_shape_character_outlook_satisfied_no_raise():
    """list shape + character_outlook meta 첨부 → 통과."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
    ]}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(rpc, labeled_refs, attached_meta, prompt="scene.", is_close_framing=False, reference_phrase_kinds=[])


def test_required_refs_list_shape_background_not_close_missing_raises():
    """list shape: kind=background + NOT close + bg meta 부재 → fail."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "background", "id": "bg_aisle", "policy": "required"},
    ]}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    with pytest.raises(RefContractError, match="background"):
        validate_attached_refs(rpc, labeled_refs, attached_meta, prompt="aisle.", is_close_framing=False, reference_phrase_kinds=[])


def test_required_refs_list_shape_background_close_exempt():
    """list shape: kind=background + close framing → 면제."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "background", "id": "bg_aisle", "policy": "required"},
    ]}}
    labeled_refs = [("character C01O02 in outfit", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(rpc, labeled_refs, attached_meta, prompt="Eye close-up.", is_close_framing=True, reference_phrase_kinds=[])


def test_required_refs_list_shape_empty_list_no_raise():
    """list shape: 빈 list → no required, no raise."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": []}}
    validate_attached_refs(rpc, [], [], prompt="scene.", is_close_framing=False, reference_phrase_kinds=[])


# ──────────────────────────────────────────────────────────────────────
# G1 — D2 strict extractor 보존 (silent fallback 회로 차단)
# ──────────────────────────────────────────────────────────────────────


def test_normalize_explicit_empty_list_no_raise():
    """explicit [] = no required_refs 의미, validator 통과."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": []}}
    validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_explicit_empty_dict_no_raise():
    """explicit {} = no required_refs 의미, validator 통과."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": {}}}
    validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_required_refs_missing_in_asset_requirements_raises():
    """asset_requirements 안 required_refs key 부재 → fail-fast (G1)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {}}
    with pytest.raises(RefContractError, match="required_refs"):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_required_refs_explicit_none_raises():
    """required_refs explicit None → fail-fast (corrupt 신호, G1)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": None}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_wrong_type_string_raises():
    """required_refs 가 string → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": "malformed"}}
    with pytest.raises(RefContractError, match="required_refs"):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_wrong_type_int_raises():
    """required_refs 가 int → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": 42}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_list_item_non_dict_raises():
    """list 안의 item 이 dict 아니면 fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [None]}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])
    rpc2 = {"asset_requirements": {"required_refs": ["C01O02"]}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc2, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_list_item_missing_kind_raises():
    """list item 에 kind 누락 → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"id": "C01O02", "policy": "required"},
    ]}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_list_item_missing_id_raises():
    """list item 에 id 누락 → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "policy": "required"},
    ]}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_list_item_kind_or_id_non_string_raises():
    """list item 의 kind/id 가 string 아니면 fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": 1, "id": "C01O02"},
    ]}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])
    rpc2 = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "id": 42},
    ]}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc2, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_dict_value_non_list_raises():
    """legacy dict shape 의 value 가 list 아니면 fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": {"character_outlook": "C01O02"}}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_normalize_dict_value_list_with_non_string_raises():
    """legacy dict shape 의 value list 안에 string 아닌 것 → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": {"character_outlook": [42]}}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_rpc_non_dict_raises():
    """rpc 자체가 dict 아니면 fail-fast (G1)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    with pytest.raises(RefContractError):
        validate_attached_refs("not-a-dict", [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])
    with pytest.raises(RefContractError):
        validate_attached_refs(42, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_rpc_missing_asset_requirements_raises():
    """rpc dict + asset_requirements key 부재 → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"other_field": "x"}
    with pytest.raises(RefContractError, match="asset_requirements"):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_asset_requirements_non_dict_raises():
    """asset_requirements 가 dict 아니면 fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": "not-a-dict"}
    with pytest.raises(RefContractError, match="asset_requirements"):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_asset_requirements_explicit_none_raises():
    """asset_requirements 가 explicit None → fail-fast (corrupt 신호)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": None}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_rpc_none_legacy_classifier_only_mode_no_raise():
    """rpc=None 은 legacy classifier-only mode (호환 보존, G1)."""
    from app.core.ref_contract_validator import validate_attached_refs
    validate_attached_refs(None, [], [], prompt="just text", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_explicit_empty_list_still_no_raise():
    """explicit [] regression."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": []}}
    validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_explicit_empty_dict_still_no_raise():
    """explicit {} regression."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = {"asset_requirements": {"required_refs": {}}}
    validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_list_policy_required_passes():
    """policy='required' canonical → policy 검증 통과 후 normal flow."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
    ]}}
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_list_policy_optional_raises():
    """policy='optional' producer drift → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "id": "C01O02", "policy": "optional"},
    ]}}
    with pytest.raises(RefContractError, match="policy"):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_list_policy_missing_raises():
    """policy 필드 자체 누락 → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "id": "C01O02"},
    ]}}
    with pytest.raises(RefContractError, match="policy"):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_list_policy_unknown_value_raises():
    """알려지지 않은 policy 값 → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "id": "C01O02", "policy": "mandatory"},
    ]}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_iter2_list_policy_non_string_raises():
    """policy 가 string 아닌 타입 → fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "character_outlook", "id": "C01O02", "policy": True},
    ]}}
    with pytest.raises(RefContractError):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


# ──────────────────────────────────────────────────────────────────────
# D5 Acceptance Criteria — AC-1 ~ AC-8 + AC-7b
# ──────────────────────────────────────────────────────────────────────


def _make_rpc(reqs, readiness="block_if_missing"):
    """list-shape required_refs RPC fixture helper."""
    return {
        "asset_requirements": {
            "required_refs": [
                {"kind": k, "id": i, "policy": "required"} for k, i in reqs
            ],
            "readiness_policy": readiness,
        }
    }


def test_AC1_S8_canary_character_t2i_inline_white_background_does_not_satisfy_bg_required():
    """AC-1 (사용자 binding) — D5 raison d'être.

    S8_Shot4 production 사고 재현: C01 base ref 가 첨부되고 그 label 안에
    'plain white background' inline (passport-style ID photo description) 이
    있어도 character_outlook=C01O02 + background=bg_store_… 요구는 차단되어야
    함. P2 — base/state_variant 가 outlook 요구를 만족시키지 않음.
    """
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = _make_rpc([
        ("character_outlook", "C01O02"),
        ("background", "bg_store_sales_floor_dusk_busy_exit_visible"),
    ])
    labeled_refs = [(
        "Image 1 (character reference): 수리영 — Set in modern mid-2020s, "
        "대한민국. Passport-style ID photo, head and upper chest visible, "
        "plain white background. Korean early 20s female, ...",
        b"c01_bytes",
    )]
    attached_meta = [("character", "C01")]  # composite 부재 → base fallback
    chain_bg_lookup = lambda _bg_id: None  # background_render checkpoint 부재
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="dummy",
            is_close_framing=False,
            chain_bg_lookup=chain_bg_lookup,
            reference_phrase_kinds=[],
        )


def test_AC2_composite_satisfies_outlook_required():
    """AC-2: composite (character_outlook) 정상 첨부 시 통과."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = _make_rpc([("character_outlook", "C01O02")])
    labeled_refs = [("Image 1 (character reference): ...", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(
        rpc, labeled_refs, attached_meta,
        prompt="scene.",
        is_close_framing=False,
        chain_bg_lookup=lambda _: None,
        reference_phrase_kinds=[],
    )


def test_AC3_prev_shot_lineage_match_satisfies_background():
    """AC-3: prev_shot lineage 일치 시 background 요구 통과."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = _make_rpc([("background", "bg_kitchen_morning")])
    labeled_refs = [
        ("Image 1 (character reference): ...", b"x"),
        ("Image 2: previous shot at same location — ...", b"y"),
    ]
    attached_meta = [
        ("character_outlook", "C01O02"),
        ("background_prev_shot", "L05"),
    ]
    chain_bg_lookup = lambda bg_id: "L05" if bg_id == "bg_kitchen_morning" else None
    validate_attached_refs(
        rpc, labeled_refs, attached_meta,
        prompt="scene.",
        is_close_framing=False,
        chain_bg_lookup=chain_bg_lookup,
        reference_phrase_kinds=[],
    )


def test_AC4_prev_shot_lineage_mismatch_blocks_background():
    """AC-4: prev_shot 의 loc 가 background 요구의 loc 와 다르면 차단."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = _make_rpc([("background", "bg_kitchen_morning")])
    labeled_refs = [("Image 1: previous shot at L08 — ...", b"x")]
    attached_meta = [("background_prev_shot", "L08")]
    chain_bg_lookup = lambda bg_id: "L05"  # 요구 loc 는 L05
    with pytest.raises(RefContractError, match="bg_kitchen_morning.*missing"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="scene.",
            is_close_framing=False,
            chain_bg_lookup=chain_bg_lookup,
            reference_phrase_kinds=[],
        )


def test_AC5_meta_length_mismatch_fails_fast():
    """AC-5: labeled_refs 와 attached_meta 길이 불일치 → 즉시 fail-fast (다른 검사 진입 X)."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    labeled_refs = [("Image 1: ...", b"x"), ("Image 2: ...", b"y")]
    attached_meta = [("character", "C01")]  # 길이 불일치
    with pytest.raises(RefContractError, match="meta length mismatch"):
        validate_attached_refs(None, labeled_refs, attached_meta, prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_AC6_readiness_block_if_missing_with_empty_required_drift():
    """AC-6: readiness_policy=block_if_missing + required_refs empty → drift fail-fast."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = {
        "asset_requirements": {
            "required_refs": [],
            "readiness_policy": "block_if_missing",
        }
    }
    with pytest.raises(RefContractError, match="readiness_policy=block_if_missing.*empty"):
        validate_attached_refs(rpc, [], [], prompt="x", is_close_framing=False, reference_phrase_kinds=[])


def test_AC7_close_framing_background_exempt():
    """AC-7: close framing 시 background 요구 면제."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = _make_rpc([
        ("character_outlook", "C01O02"),
        ("background", "bg_kitchen_morning"),
    ])
    labeled_refs = [("Image 1 (character reference): ...", b"x")]
    attached_meta = [("character_outlook", "C01O02")]
    # background 미첨부 + close framing → no raise
    validate_attached_refs(
        rpc, labeled_refs, attached_meta,
        prompt="Eye close-up.",
        is_close_framing=True,
        chain_bg_lookup=lambda _: None,
        reference_phrase_kinds=[],
    )


def test_AC7b_close_framing_does_NOT_exempt_character_outlook():
    """AC-7b (G2): close framing 시에도 character_outlook 은 strict.

    close 면제는 background 만 적용. character_outlook 은 항상 검사.
    """
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = _make_rpc([("character_outlook", "C01O02")])
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(
            rpc, [], [],
            prompt="Eye close-up.",
            is_close_framing=True,
            chain_bg_lookup=lambda _: None,
            reference_phrase_kinds=[],
        )


def test_AC8_same_char_state_variant_satisfies_outlook():
    """AC-8 정정 (2026-07-02): 같은 base 캐릭터의 character_state 는
    character_outlook 요구를 충족 — immobilized resolver 의 composite→state 대체와
    기존 strict 계약이 데드락(S12 sh10 production hard-block 실측)이라 계약 변경.
    다른 캐릭터 state 는 여전히 충족 X (아래 테스트)."""
    from app.core.ref_contract_validator import validate_attached_refs
    rpc = _make_rpc([("character_outlook", "C01O02")])
    labeled_refs = [("Image 1: ... state reference", b"x")]
    attached_meta = [("character_state", "C01:unconscious")]
    validate_attached_refs(
        rpc, labeled_refs, attached_meta,
        prompt="scene.",
        is_close_framing=False,
        chain_bg_lookup=lambda _: None,
        reference_phrase_kinds=[],
    )


def test_AC8b_other_char_state_variant_does_NOT_satisfy_outlook():
    """다른 캐릭터의 character_state 는 required character_outlook 충족 X."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = _make_rpc([("character_outlook", "C01O02")])
    labeled_refs = [("Image 1: ... state reference", b"x")]
    attached_meta = [("character_state", "C09:unconscious")]
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="scene.",
            is_close_framing=False,
            chain_bg_lookup=lambda _: None,
            reference_phrase_kinds=[],
        )


# ──────────────────────────────────────────────────────────────────────
# G3 보조 — chain_bg_lookup=None / 결과 None 시 prev_shot 으로 통과 X
# ──────────────────────────────────────────────────────────────────────


def test_G3_chain_bg_lookup_None_blocks_prev_shot_substitute():
    """G3: chain_bg_lookup=None 이면 lineage 확인 불가 → prev_shot 으로 통과 X."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = _make_rpc([("background", "bg_kitchen")])
    labeled_refs = [("Image 1: previous shot ...", b"x")]
    attached_meta = [("background_prev_shot", "L05")]
    # chain_bg_lookup 자체가 None → lineage 분기 진입 X → missing
    with pytest.raises(RefContractError, match="bg_kitchen.*missing"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="scene.",
            is_close_framing=False,
            chain_bg_lookup=None,
            reference_phrase_kinds=[],
        )


def test_G3_chain_bg_lookup_returns_none_blocks_prev_shot_substitute():
    """G3: chain_bg_lookup(bg_id) 결과 None 이면 prev_shot 으로 통과 X."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    rpc = _make_rpc([("background", "bg_unknown")])
    labeled_refs = [("Image 1: previous shot ...", b"x")]
    attached_meta = [("background_prev_shot", "L05")]
    chain_bg_lookup = lambda _bg_id: None  # 알 수 없는 bg_id
    with pytest.raises(RefContractError, match="bg_unknown.*missing"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="scene.",
            is_close_framing=False,
            chain_bg_lookup=chain_bg_lookup,
            reference_phrase_kinds=[],
        )


# ──────────────────────────────────────────────────────────────────────
# build_chain_bg_lookup — D5 §4.5 helper (T1 bg_map entry 활용)
# ──────────────────────────────────────────────────────────────────────


def test_build_chain_bg_lookup_returns_loc_for_known_bg_id():
    """T1 entry (bg_id + location_id) 가 보존된 bg_map → bg_id 로 loc 역참조."""
    from app.services.scene_generation_coordinator import build_chain_bg_lookup
    bg_map = {
        "5_3": {"bg_id": "bg_kitchen_morning", "location_id": "L05",
                "image_bytes": b"", "label": ""},
        "8_4": {"bg_id": "bg_store_dusk", "location_id": "L09",
                "image_bytes": b"", "label": ""},
    }
    lookup = build_chain_bg_lookup(bg_map)
    assert lookup("bg_kitchen_morning") == "L05"
    assert lookup("bg_store_dusk") == "L09"
    assert lookup("unknown_bg") is None


def test_build_chain_bg_lookup_skips_entries_without_bg_id_or_loc_id():
    """T1 entry 확장이 없는 (legacy) entry 는 lookup 에서 자연스럽게 제외 (G3 정합)."""
    from app.services.scene_generation_coordinator import build_chain_bg_lookup
    bg_map = {
        "5_3": {"image_bytes": b"", "label": ""},  # bg_id/loc_id 부재
        "8_4": {"bg_id": "bg_store_dusk", "image_bytes": b""},  # loc_id 부재
        "9_5": {"location_id": "L09", "image_bytes": b""},  # bg_id 부재
    }
    lookup = build_chain_bg_lookup(bg_map)
    assert lookup("anything") is None
    assert lookup("bg_store_dusk") is None  # loc_id 부재로 entry 제외


def test_build_chain_bg_lookup_empty_map_returns_lambda_that_always_none():
    """빈 bg_map → 항상 None 반환."""
    from app.services.scene_generation_coordinator import build_chain_bg_lookup
    lookup = build_chain_bg_lookup({})
    assert lookup("bg_anything") is None


# =========================================================================
# Patch A Task 4: validate_attached_refs Tier 3 strict subset for prop kind
# synthetic short_ids only — P91~P99 / C91~C99 / L91~L99
# =========================================================================
class TestTier3RequiredPropContract:
    def test_required_prop_attached_passes(self):
        """Tier 3: required_refs (prop, P91) 가 attached_meta 에 매칭되면 통과."""
        from app.core.ref_contract_validator import validate_attached_refs
        rpc = {
            "asset_requirements": {
                "required_refs": [
                    {"kind": "prop", "id": "P91", "policy": "required"},
                ],
                "forbidden_refs": [],
                "readiness_policy": "block_if_missing",
            }
        }
        labeled_refs = [("object P91", b"\x89PNG\r\n")]
        attached_meta = [("prop", "P91")]
        validate_attached_refs(
            rpc=rpc, labeled_refs=labeled_refs, attached_meta=attached_meta,
            prompt="...P91 lies flat on the console...",
            is_close_framing=True,
            reference_phrase_kinds=[],
        )

    def test_required_prop_missing_raises_ref_contract_error(self):
        """Tier 3: required_refs (prop, P91) 가 attached_meta 에 없으면 RefContractError."""
        from app.core.ref_contract_validator import (
            validate_attached_refs, RefContractError,
        )
        rpc = {
            "asset_requirements": {
                "required_refs": [
                    {"kind": "prop", "id": "P91", "policy": "required"},
                ],
                "forbidden_refs": [],
                "readiness_policy": "block_if_missing",
            }
        }
        with pytest.raises(RefContractError) as exc:
            validate_attached_refs(
                rpc=rpc, labeled_refs=[], attached_meta=[],
                prompt="...the photograph lies flat...",
                is_close_framing=True,
                reference_phrase_kinds=[],
            )
        # error 메시지에 prop kind + 누락 P## 명시
        assert "prop" in str(exc.value).lower() and "P91" in str(exc.value)

    def test_normalize_accepts_prop_kind_unchanged(self):
        """_normalize_required_refs 는 v1.2 에서 변경 없음 — 이미 arbitrary kind 허용.
        prop kind 정상 통과 확인 (regression check)."""
        from app.core.ref_contract_validator import _normalize_required_refs
        normalized = _normalize_required_refs(
            [{"kind": "prop", "id": "P91", "policy": "required"}]
        )
        assert normalized == {"prop": ["P91"]}

    def test_prop_mixed_with_other_kinds_all_pass(self):
        """prop + character_outlook + background 혼합 정상 (3-way strict subset)."""
        from app.core.ref_contract_validator import validate_attached_refs
        rpc = {
            "asset_requirements": {
                "required_refs": [
                    {"kind": "background", "id": "L92B01", "policy": "required"},
                    {"kind": "character_outlook", "id": "C93O92", "policy": "required"},
                    {"kind": "prop", "id": "P91", "policy": "required"},
                ],
                "forbidden_refs": [],
                "readiness_policy": "block_if_missing",
            }
        }
        labeled_refs = [
            ("pre-rendered BACKGROUND chain reference", b"bg"),
            ("character C93O92 in outfit", b"co"),
            ("object P91", b"p"),
        ]
        attached_meta = [
            ("background", "L92B01"),
            ("character_outlook", "C93O92"),
            ("prop", "P91"),
        ]
        validate_attached_refs(
            rpc=rpc, labeled_refs=labeled_refs, attached_meta=attached_meta,
            prompt="...C93O92...P91...",
            is_close_framing=False,
            reference_phrase_kinds=[],
        )

    def test_extra_prop_attached_without_required_does_not_fail(self):
        """attached_meta 가 required 보다 많아도 (예: visible 매칭으로 attach 된
        non-required prop) 통과. strict subset 의미 — required ⊆ attached."""
        from app.core.ref_contract_validator import validate_attached_refs
        rpc = {
            "asset_requirements": {
                "required_refs": [],
                "forbidden_refs": [],
                "readiness_policy": "n/a",
            }
        }
        labeled_refs = [("object P93", b"p")]
        attached_meta = [("prop", "P93")]  # required 0, attached 1
        validate_attached_refs(
            rpc=rpc, labeled_refs=labeled_refs, attached_meta=attached_meta,
            prompt="...the map sits on the table...",
            is_close_framing=True,
            reference_phrase_kinds=[],
        )
