"""D6 T4 — bg_catalog: semantic_key + assign_bg_ids + hash 분리 + normalize_space_key.

spec: docs/superpowers/specs/2026-05-09-deterministic-bg-id-and-catalog-lineage.md §4.2, §4.5, §4.6
plan: docs/superpowers/plans/2026-05-09-d6-deterministic-bg-id-and-catalog-lineage-implementation.md T4

코드 SOT — LLM 은 raw intent 만 출력, 코드가 deterministic ID 부여 (`L09B01`).
- semantic_key dedup: `loc_id|space_key|time_phase|state_class`
- monotonic next_b: prev catalog history 기반 (B7 — deletion 후에도 reset 안 함)
- intent sort: I8 — `(loc_id, time_phase, state_class)` 정렬 후 처리 (LLM 순서 무관 결정론)
- hash 두 축 분리:
  * bg_catalog_hash: catalog 의 render-relevant field (metadata 제외)
  * shot_binding_hash: shot_background_map 전용
"""
from __future__ import annotations

import json
import pytest


# helper for compact intent dict construction
_SENTINEL = object()


def _intent(loc_id="L09", space_key_hint="main", time_phase="dusk",
            state_class="busy_exit", applies_to_shots=_SENTINEL,
            surface_role="interior_room",
            sub_location_label="store sales floor",
            state_label_raw="dusk busy", depends_on_fp=_SENTINEL,
            depends_on_bg=_SENTINEL):
    return {
        "loc_id": loc_id,
        "space_key_hint": space_key_hint,
        "time_phase": time_phase,
        "state_class": state_class,
        "surface_role": surface_role,
        "applies_to_shots": ["S8_Shot4"] if applies_to_shots is _SENTINEL else applies_to_shots,
        "sub_location_label": sub_location_label,
        "state_label_raw": state_label_raw,
        "depends_on_fp": [] if depends_on_fp is _SENTINEL else depends_on_fp,
        "depends_on_bg": [] if depends_on_bg is _SENTINEL else depends_on_bg,
    }


# ──────────────────────────────────────────────────────────────────────
# normalize_space_key
# ──────────────────────────────────────────────────────────────────────


def test_single_space_location_forces_main_regardless_of_hint():
    from app.core.bg_catalog import normalize_space_key

    profile = {"kind": "single_space", "allowed_space_keys": ["main"]}
    assert normalize_space_key("L09", "store_sales_floor", profile) == "main"
    assert normalize_space_key("L09", "main", profile) == "main"
    assert normalize_space_key("L09", "kitchen", profile) == "main"  # ignored


def test_multi_space_location_enum_strict_accepts():
    from app.core.bg_catalog import normalize_space_key

    profile = {
        "kind": "multi_space",
        "allowed_space_keys": ["main", "kitchen", "rooftop"],
        "default_space_key": "main",
    }
    assert normalize_space_key("L05", "main", profile) == "main"
    assert normalize_space_key("L05", "kitchen", profile) == "kitchen"


def test_multi_space_location_enum_strict_rejects_outside_vocab():
    from app.core.bg_catalog import normalize_space_key, SemanticKeyError

    profile = {
        "kind": "multi_space",
        "allowed_space_keys": ["main", "kitchen", "rooftop"],
        "default_space_key": "main",
    }
    with pytest.raises(SemanticKeyError, match="garage|allowed_space_keys"):
        normalize_space_key("L05", "garage", profile)


def test_normalize_space_key_rejects_unknown_kind():
    from app.core.bg_catalog import normalize_space_key, SemanticKeyError

    with pytest.raises(SemanticKeyError, match="kind"):
        normalize_space_key("L01", "main", {"kind": "weird", "allowed_space_keys": ["main"]})


def test_normalize_space_key_rejects_non_dict_profile():
    from app.core.bg_catalog import normalize_space_key, SemanticKeyError

    with pytest.raises(SemanticKeyError, match="profile"):
        normalize_space_key("L01", "main", None)
    with pytest.raises(SemanticKeyError, match="profile"):
        normalize_space_key("L01", "main", "not-a-dict")


# ──────────────────────────────────────────────────────────────────────
# compute_semantic_key
# ──────────────────────────────────────────────────────────────────────


def test_semantic_key_format():
    from app.core.bg_catalog import compute_semantic_key

    sk = compute_semantic_key(loc_id="L09", space_key="main",
                              time_phase="dusk", state_class="busy_exit")
    assert sk == "L09|main|dusk|busy_exit"


def test_semantic_key_state_class_changes_key():
    from app.core.bg_catalog import compute_semantic_key

    sk1 = compute_semantic_key("L09", "main", "dusk", "busy_exit")
    sk2 = compute_semantic_key("L09", "main", "dusk", "ransacked")
    assert sk1 != sk2


def test_semantic_key_validates_state_class_enum():
    from app.core.bg_catalog import compute_semantic_key
    from app.core.bg_state_vocab import StateClassError

    with pytest.raises(StateClassError):
        compute_semantic_key("L09", "main", "dusk", "freeform_unknown")


# ──────────────────────────────────────────────────────────────────────
# assign_bg_ids
# ──────────────────────────────────────────────────────────────────────


_L09_SINGLE = {"L09": {"kind": "single_space", "allowed_space_keys": ["main"]}}


def test_assign_bg_ids_first_run_basic():
    from app.core.bg_catalog import assign_bg_ids

    catalog = assign_bg_ids({}, [_intent()], _L09_SINGLE)
    assert "L09B01" in catalog
    entry = catalog["L09B01"]
    assert entry["loc_id"] == "L09"
    assert entry["space_key"] == "main"
    assert entry["semantic_key"] == "L09|main|dusk|busy_exit"
    assert entry["applies_to_shots"] == ["S8_Shot4"]


def test_assign_bg_ids_reuses_for_same_semantic_key():
    """metadata-only field 변경 (applies_to_shots / sub_location_label) → ID 재사용."""
    from app.core.bg_catalog import assign_bg_ids

    prev = {
        "L09B01": {
            "bg_id": "L09B01",
            "loc_id": "L09",
            "space_key": "main",
            "time_phase": "dusk",
            "state_class": "busy_exit",
            "semantic_key": "L09|main|dusk|busy_exit",
            "applies_to_shots": ["S8_Shot4"],
            "sub_location_label": "store sales floor",
            "state_label_raw": "dusk busy",
            "depends_on_fp": [],
            "depends_on_bg": [],
        },
    }
    new_intents = [_intent(applies_to_shots=["S8_Shot4", "S8_Shot5"],
                            sub_location_label="store interior")]
    catalog = assign_bg_ids(prev, new_intents, _L09_SINGLE)
    assert "L09B01" in catalog
    assert catalog["L09B01"]["applies_to_shots"] == ["S8_Shot4", "S8_Shot5"]
    assert catalog["L09B01"]["sub_location_label"] == "store interior"


def test_assign_bg_ids_reuses_legacy_prev_catalog_without_surface_role():
    """W21B-wave-2: prev catalog lacks surface_role but new intent carries it.

    prev_catalog is only an ID reuse history. The returned catalog entry is
    rebuilt from the new intent, so legacy entries without surface_role must not
    trigger same-semantic-key mismatch.
    """
    from app.core.bg_catalog import assign_bg_ids

    prev = {
        "L09B01": {
            "bg_id": "L09B01",
            "loc_id": "L09",
            "space_key": "main",
            "time_phase": "dusk",
            "state_class": "busy_exit",
            "semantic_key": "L09|main|dusk|busy_exit",
            "applies_to_shots": ["S8_Shot4"],
            "sub_location_label": "store sales floor",
            "state_label_raw": "dusk busy",
            "depends_on_fp": [],
            "depends_on_bg": [],
            # no surface_role: legacy W21B-wave-1 catalog shape
        },
    }

    catalog = assign_bg_ids(
        prev,
        [_intent(surface_role="interior_room")],
        _L09_SINGLE,
    )
    assert list(catalog.keys()) == ["L09B01"]
    assert catalog["L09B01"]["surface_role"] == "interior_room"


def test_assign_bg_ids_increments_for_new_semantic_key():
    from app.core.bg_catalog import assign_bg_ids

    prev = {
        "L09B01": {
            "bg_id": "L09B01",
            "loc_id": "L09",
            "space_key": "main",
            "time_phase": "dusk",
            "state_class": "busy_exit",
            "semantic_key": "L09|main|dusk|busy_exit",
        },
    }
    new_intents = [
        _intent(state_class="busy_exit"),  # 기존 재사용 → L09B01
        _intent(state_class="ransacked"),  # 새 → L09B02
    ]
    catalog = assign_bg_ids(prev, new_intents, _L09_SINGLE)
    assert "L09B01" in catalog
    assert "L09B02" in catalog


def test_assign_bg_ids_5_reruns_stable_id():
    """spec AC-1 — 5 reruns same input → bg_id 변동 0 (deterministic)."""
    from app.core.bg_catalog import assign_bg_ids

    intents = [_intent(state_class="busy_exit")]

    catalog1 = assign_bg_ids({}, intents, _L09_SINGLE)
    bg_id_1 = next(iter(catalog1.keys()))

    for i in range(4):
        catalog_n = assign_bg_ids(catalog1, intents, _L09_SINGLE)
        bg_id_n = next(iter(catalog_n.keys()))
        assert bg_id_n == bg_id_1, f"run {i+2}: ID drift {bg_id_1} → {bg_id_n}"


def test_assign_bg_ids_validates_assigned_format():
    from app.core.bg_catalog import assign_bg_ids
    from app.core.bg_state_vocab import BG_ID_RE

    catalog = assign_bg_ids({}, [_intent()], _L09_SINGLE)
    bg_id = next(iter(catalog.keys()))
    assert BG_ID_RE.match(bg_id)


def test_assign_bg_ids_b7_monotonic_next_b_after_deletion():
    """B7 — `_max_b_for_loc` history-based: deletion 후에도 next_b reset 안 함.

    prev catalog 가 L09B05 까지 있다가 (다른 input 으로) 발급, 다음에 다른 input 에서
    L09B01..L09B03 만 등장 → catalog 에 L09B01~L09B03 + 새 entry 가 L09B06+.
    """
    from app.core.bg_catalog import assign_bg_ids

    prev = {
        f"L09B0{i}": {
            "bg_id": f"L09B0{i}",
            "loc_id": "L09",
            "space_key": "main",
            "time_phase": "morning",
            "state_class": "normal",
            "semantic_key": f"L09|main|morning|normal_v{i}",
        }
        for i in range(1, 6)  # L09B01 .. L09B05
    }
    # 새 intent — 모두 새 semantic_key
    new_intents = [_intent(time_phase="night", state_class="quiet")]
    catalog = assign_bg_ids(prev, new_intents, _L09_SINGLE)
    new_entries = [bid for bid in catalog if bid not in prev]
    assert new_entries == ["L09B06"], (
        f"B7 monotonic 위반 — prev max=L09B05, expected L09B06, got {new_entries}"
    )


def test_assign_bg_ids_i8_intent_sort_first_run_deterministic():
    """I8 — assign_bg_ids 가 intents 를 (loc_id, time_phase, state_class) sort 후 처리.

    LLM 출력 순서 무관: 첫 run 에서도 같은 set 의 intents 면 같은 ID 분포.
    """
    from app.core.bg_catalog import assign_bg_ids

    profile = {
        "L09": {"kind": "single_space", "allowed_space_keys": ["main"]},
        "L05": {"kind": "single_space", "allowed_space_keys": ["main"]},
    }
    intents_a = [
        _intent(loc_id="L09", time_phase="dusk", state_class="busy_exit"),
        _intent(loc_id="L05", time_phase="morning", state_class="normal"),
    ]
    # 순서만 다른 같은 set
    intents_b = [
        _intent(loc_id="L05", time_phase="morning", state_class="normal"),
        _intent(loc_id="L09", time_phase="dusk", state_class="busy_exit"),
    ]
    catalog_a = assign_bg_ids({}, intents_a, profile)
    catalog_b = assign_bg_ids({}, intents_b, profile)
    assert set(catalog_a.keys()) == set(catalog_b.keys()), (
        f"I8 sort 위반 — order-dependent IDs: {set(catalog_a) ^ set(catalog_b)}"
    )
    # semantic_key → bg_id 매핑도 동일
    a_map = {v["semantic_key"]: bid for bid, v in catalog_a.items()}
    b_map = {v["semantic_key"]: bid for bid, v in catalog_b.items()}
    assert a_map == b_map


def test_assign_bg_ids_normalizes_space_key_via_profile():
    """multi_space location 에서 hint 가 controlled vocab 안이면 그대로, 밖이면 raise."""
    from app.core.bg_catalog import assign_bg_ids, SemanticKeyError

    profile = {
        "L05": {
            "kind": "multi_space",
            "allowed_space_keys": ["main", "kitchen"],
            "default_space_key": "main",
        }
    }
    catalog = assign_bg_ids({}, [_intent(loc_id="L05", space_key_hint="kitchen")], profile)
    only_entry = next(iter(catalog.values()))
    assert only_entry["space_key"] == "kitchen"

    # 밖이면 raise (semantic_key 구성 실패)
    with pytest.raises(SemanticKeyError):
        assign_bg_ids({}, [_intent(loc_id="L05", space_key_hint="garage")], profile)


def test_assign_bg_ids_missing_location_profile_raises():
    """location_profiles 에 loc_id 부재 → fail-fast."""
    from app.core.bg_catalog import assign_bg_ids, SemanticKeyError

    with pytest.raises(SemanticKeyError, match="L99|profile"):
        assign_bg_ids({}, [_intent(loc_id="L99")], {})


# ──────────────────────────────────────────────────────────────────────
# compute_bg_catalog_hash + compute_shot_binding_hash
# ──────────────────────────────────────────────────────────────────────


def test_bg_catalog_hash_excludes_metadata_fields():
    """metadata-only field (applies_to_shots / sub_location_label / state_label_raw)
    변동해도 catalog_hash 안정 — render-relevant fields 만 hash 입력."""
    from app.core.bg_catalog import compute_bg_catalog_hash

    catalog_a = {
        "L09B01": {
            "bg_id": "L09B01", "loc_id": "L09", "space_key": "main",
            "time_phase": "dusk", "state_class": "busy_exit",
            "semantic_key": "L09|main|dusk|busy_exit",
            "depends_on_bg": [], "depends_on_fp": ["fp_supermarket"],
            # metadata-only (excluded from hash)
            "applies_to_shots": ["S8_Shot4"],
            "sub_location_label": "sales floor", "state_label_raw": "raw a",
        }
    }
    catalog_b = {
        "L09B01": {**catalog_a["L09B01"],
                   "applies_to_shots": ["S8_Shot4", "S8_Shot5"],
                   "sub_location_label": "store interior",
                   "state_label_raw": "raw b"},
    }
    assert compute_bg_catalog_hash(catalog_a) == compute_bg_catalog_hash(catalog_b)


def test_bg_catalog_hash_changes_on_render_relevant_field():
    from app.core.bg_catalog import compute_bg_catalog_hash

    catalog_a = {"L09B01": {
        "bg_id": "L09B01", "loc_id": "L09", "space_key": "main",
        "time_phase": "dusk", "state_class": "busy_exit",
        "semantic_key": "L09|main|dusk|busy_exit",
        "depends_on_bg": [], "depends_on_fp": [],
    }}
    catalog_b = {"L09B01": {**catalog_a["L09B01"],
                            "state_class": "ransacked",
                            "semantic_key": "L09|main|dusk|ransacked"}}
    assert compute_bg_catalog_hash(catalog_a) != compute_bg_catalog_hash(catalog_b)


def test_bg_catalog_hash_deterministic_across_runs():
    """같은 catalog 5회 hash → 같은 값."""
    from app.core.bg_catalog import compute_bg_catalog_hash

    catalog = {
        "L09B01": {
            "bg_id": "L09B01", "loc_id": "L09", "space_key": "main",
            "time_phase": "dusk", "state_class": "busy_exit",
            "semantic_key": "L09|main|dusk|busy_exit",
            "depends_on_bg": [], "depends_on_fp": ["fp_a"],
        },
        "L05B02": {
            "bg_id": "L05B02", "loc_id": "L05", "space_key": "kitchen",
            "time_phase": "morning", "state_class": "normal",
            "semantic_key": "L05|kitchen|morning|normal",
            "depends_on_bg": [], "depends_on_fp": ["fp_b"],
        },
    }
    h0 = compute_bg_catalog_hash(catalog)
    for _ in range(4):
        assert compute_bg_catalog_hash(catalog) == h0


def test_shot_binding_hash_isolated_from_catalog_changes():
    """shot_background_map 변경만으로 shot_binding_hash 변동, catalog_hash 안정."""
    from app.core.bg_catalog import compute_bg_catalog_hash, compute_shot_binding_hash

    catalog = {
        "L09B01": {
            "bg_id": "L09B01", "loc_id": "L09", "space_key": "main",
            "time_phase": "dusk", "state_class": "busy_exit",
            "semantic_key": "L09|main|dusk|busy_exit",
            "depends_on_bg": [], "depends_on_fp": [],
            "applies_to_shots": ["S8_Shot4"],
        }
    }
    map_a = {"S8_Shot4": "L09B01"}
    map_b = {"S8_Shot4": "L09B01", "S8_Shot5": "L09B01"}

    assert compute_bg_catalog_hash(catalog) == compute_bg_catalog_hash(catalog)
    assert compute_shot_binding_hash(map_a) != compute_shot_binding_hash(map_b)


def test_shot_binding_hash_deterministic_key_order_invariant():
    from app.core.bg_catalog import compute_shot_binding_hash

    map_1 = {"S8_Shot4": "L09B01", "S8_Shot5": "L09B02"}
    map_2 = {"S8_Shot5": "L09B02", "S8_Shot4": "L09B01"}
    assert compute_shot_binding_hash(map_1) == compute_shot_binding_hash(map_2)


# ──────────────────────────────────────────────────────────────────────
# build_shot_background_map
# ──────────────────────────────────────────────────────────────────────


def test_build_shot_background_map_basic():
    from app.core.bg_catalog import build_shot_background_map

    catalog = {
        "L09B01": {"bg_id": "L09B01", "applies_to_shots": ["S8_Shot4", "S8_Shot5"]},
        "L09B02": {"bg_id": "L09B02", "applies_to_shots": ["S9_Shot1"]},
    }
    m = build_shot_background_map(catalog)
    assert m == {"S8_Shot4": "L09B01", "S8_Shot5": "L09B01", "S9_Shot1": "L09B02"}


def test_build_shot_background_map_dup_shot_raises():
    """T4-fix (review iter5 I1): 같은 shot 이 여러 bg 에 매핑되면 raise (D6 N:1 강제).

    이전 first-wins 는 SOT 결손 silent absorb. spec §4.5 의 invariant: 각 shot 은
    정확히 1 bg 에 매핑. assign_bg_ids 가 same-run dedup 을 제대로 하면 발생 안 해야 함.
    """
    from app.core.bg_catalog import build_shot_background_map, ShotBindingError

    catalog = {
        "L09B02": {"bg_id": "L09B02", "applies_to_shots": ["S8_Shot4"]},
        "L09B01": {"bg_id": "L09B01", "applies_to_shots": ["S8_Shot4"]},
    }
    with pytest.raises(ShotBindingError, match="S8_Shot4|conflict"):
        build_shot_background_map(catalog)


# ──────────────────────────────────────────────────────────────────────
# T4-fix (review iter5)
# ──────────────────────────────────────────────────────────────────────


def test_assign_bg_ids_same_run_dedup_merges_applies_to_shots():
    """T4-fix B3: 같은 run 안 동일 semantic_key 두 번 등장 → 새 ID 안 발급, applies_to_shots merge.

    이전 결함: prev_semkey_to_bgid 만 인덱싱 → 같은 sem_key 두 번이면 L09B02 발급.
    spec §4.5 dedup 위반.
    """
    from app.core.bg_catalog import assign_bg_ids

    intents = [
        _intent(applies_to_shots=["S8_Shot4"]),
        _intent(applies_to_shots=["S8_Shot5", "S8_Shot4"]),  # 같은 sem_key
    ]
    catalog = assign_bg_ids({}, intents, _L09_SINGLE)
    assert list(catalog.keys()) == ["L09B01"], (
        f"same-run dedup 위반 — got {list(catalog.keys())}"
    )
    # applies_to_shots merge — order 보존 + dedup
    shots = catalog["L09B01"]["applies_to_shots"]
    assert set(shots) == {"S8_Shot4", "S8_Shot5"}


def test_assign_bg_ids_same_run_three_intents_dedup():
    """3 개 intent 가 같은 sem_key → 단 1 entry, applies_to_shots 모두 merge."""
    from app.core.bg_catalog import assign_bg_ids

    intents = [
        _intent(applies_to_shots=["S1_Shot1"]),
        _intent(applies_to_shots=["S2_Shot1"]),
        _intent(applies_to_shots=["S3_Shot1"]),
    ]
    catalog = assign_bg_ids({}, intents, _L09_SINGLE)
    assert list(catalog.keys()) == ["L09B01"]
    assert set(catalog["L09B01"]["applies_to_shots"]) == {"S1_Shot1", "S2_Shot1", "S3_Shot1"}


def test_assign_bg_ids_no_silent_first_wins_on_metadata_drift():
    """같은 sem_key + 다른 metadata (sub_location_label) → 첫 entry 의 metadata 보존 (sort 결정).

    metadata-only field drift 는 silent absorb 안 함 — first sorted intent 의
    metadata 가 catalog 에 들어가는 것은 deterministic. 두 번째 intent 의
    sub_location_label 은 무시 (단, applies_to_shots 는 merge — render 정합).
    """
    from app.core.bg_catalog import assign_bg_ids

    intents = [
        _intent(applies_to_shots=["S1_Shot1"], sub_location_label="floor_a"),
        _intent(applies_to_shots=["S2_Shot1"], sub_location_label="floor_b"),
    ]
    catalog = assign_bg_ids({}, intents, _L09_SINGLE)
    # sub_location_label 은 첫 intent 값 유지 (merge 안 함, drift detection 가능)
    assert catalog["L09B01"]["sub_location_label"] == "floor_a"
    # applies_to_shots 는 merge
    assert set(catalog["L09B01"]["applies_to_shots"]) == {"S1_Shot1", "S2_Shot1"}


def test_assign_bg_ids_rejects_same_sem_key_with_different_depends_on_fp():
    """T4-fix2 (review iter6 B2): 같은 sem_key 두 번이 다른 depends_on_fp → raise.

    depends_on_fp 는 render-relevant hash 입력 (compute_bg_catalog_hash). drift 시
    silent first-wins 면 하나의 intent 의 fp 참조가 catalog 에 들어가고 다른 intent
    의 fp 참조가 사라짐 → SOT 결손. fail-fast.
    """
    from app.core.bg_catalog import assign_bg_ids, SemanticKeyError

    intents = [
        _intent(applies_to_shots=["S1_Shot1"], depends_on_fp=["fp_a"]),
        _intent(applies_to_shots=["S2_Shot1"], depends_on_fp=["fp_b"]),
    ]
    with pytest.raises(SemanticKeyError, match="depends_on_fp|render-relevant"):
        assign_bg_ids({}, intents, _L09_SINGLE)


def test_assign_bg_ids_rejects_same_sem_key_with_different_depends_on_bg():
    """T4-fix2 (review iter6 B2): depends_on_bg 도 동일 — 다르면 raise."""
    from app.core.bg_catalog import assign_bg_ids, SemanticKeyError

    intents = [
        _intent(applies_to_shots=["S1_Shot1"], depends_on_bg=[]),
        _intent(applies_to_shots=["S2_Shot1"], depends_on_bg=["L09B99"]),
    ]
    with pytest.raises(SemanticKeyError, match="depends_on_bg|render-relevant"):
        assign_bg_ids({}, intents, _L09_SINGLE)


def test_assign_bg_ids_accepts_same_sem_key_with_identical_render_deps():
    """T4-fix2: render-relevant deps 동일하면 dedup 통과 (applies_to_shots merge)."""
    from app.core.bg_catalog import assign_bg_ids

    intents = [
        _intent(applies_to_shots=["S1_Shot1"],
                depends_on_fp=["fp_a"], depends_on_bg=[]),
        _intent(applies_to_shots=["S2_Shot1"],
                depends_on_fp=["fp_a"], depends_on_bg=[]),
    ]
    catalog = assign_bg_ids({}, intents, _L09_SINGLE)
    assert list(catalog.keys()) == ["L09B01"]
    assert set(catalog["L09B01"]["applies_to_shots"]) == {"S1_Shot1", "S2_Shot1"}
    assert catalog["L09B01"]["depends_on_fp"] == ["fp_a"]


def test_assign_bg_ids_carries_surface_role():
    from app.core.bg_catalog import assign_bg_ids

    catalog = assign_bg_ids(
        {},
        [_intent(surface_role="exterior_plate", depends_on_fp=[])],
        _L09_SINGLE,
    )
    assert catalog["L09B01"]["surface_role"] == "exterior_plate"


def test_bg_catalog_hash_changes_on_surface_role():
    from app.core.bg_catalog import compute_bg_catalog_hash

    base = {
        "L09B01": {
            "bg_id": "L09B01",
            "loc_id": "L09",
            "space_key": "main",
            "time_phase": "dusk",
            "state_class": "quiet",
            "surface_role": "interior_room",
            "semantic_key": "L09|main|dusk|quiet",
            "depends_on_bg": [],
            "depends_on_fp": ["fp_l09_main"],
        }
    }
    changed = {
        "L09B01": {
            **base["L09B01"],
            "surface_role": "exterior_plate",
            "depends_on_fp": [],
        }
    }
    assert compute_bg_catalog_hash(base) != compute_bg_catalog_hash(changed)


def test_assign_bg_ids_rejects_same_sem_key_with_different_surface_role():
    from app.core.bg_catalog import assign_bg_ids, SemanticKeyError

    intents = [
        _intent(applies_to_shots=["S1_Shot1"], surface_role="interior_room"),
        _intent(applies_to_shots=["S2_Shot1"], surface_role="exterior_plate"),
    ]
    with pytest.raises(SemanticKeyError, match="surface_role|render-relevant"):
        assign_bg_ids({}, intents, _L09_SINGLE)
