"""TDD invariants for experiment_background_place_grouping (dry-run).

plan: scripts_output/background_place_grouping_experiment/plan.md §5

핵심 invariants:
- 같은 loc + 같은 space + 다른 state → same continuity group.
- 다른 room → different SpaceNode, same SetGroup.
- state layer 는 geometry_preserving (cleaned/vandalized/corpse_marks 모두 True).
- StructuralVersion 은 리모델링/폭파/공사 키워드만 트리거.
- L05B variant decomposer 가 (space_node, state_layer, camera_view) 3축 반환.
- L05B05 multi_space → reject.
- generation unit plan 이 cross-product (space × state × camera) 폭증 회피.
- HTML 첫 화면 = 기존 vs 신규 unit count.
- HTML unresolved = shot 별 question 없음, rollup 만.
- script 가 backend.app.* / openai / google.genai / fal_client / PIL / requests / httpx import 0.
- StateLayer.affects enum strict.
- SetGroup.parent_place_group_id ⊂ PlaceGroup IDs.

No API calls, no DB writes, no image generation.
"""
from __future__ import annotations

import sys
from pathlib import Path

import pytest

_REPO_ROOT = Path(__file__).resolve().parents[3]
_SCRIPTS = _REPO_ROOT / "backend" / "scripts"
if str(_SCRIPTS) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS))

import experiment_background_place_grouping as bpg  # noqa: E402


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _bible_minimal() -> dict:
    return {
        "sub_spaces": [
            {"name": "거실", "evidence_quotes": ["거실 햇살"], "layout_notes": "",
             "confidence_band": "trusted"},
            {"name": "수리영의 방", "evidence_quotes": ["침대"], "layout_notes": "",
             "confidence_band": "trusted"},
            {"name": "민숙의 방 (안방)", "evidence_quotes": ["안방"],
             "layout_notes": "", "confidence_band": "trusted"},
            {"name": "주방 영역", "evidence_quotes": ["싱크대"],
             "layout_notes": "거실과 인접", "confidence_band": "trusted"},
            {"name": "욕실", "evidence_quotes": ["거울"], "layout_notes": "",
             "confidence_band": "trusted"},
        ],
        "state_variants": [
            {"state": "참혹한 범죄 현장 상태", "evidence": "시신",
             "confidence_band": "trusted"},
            {"state": "감쪽같이 청소된 상태", "evidence": "깨끗",
             "confidence_band": "trusted"},
            {"state": "침입자에 의해 난장판이 된 상태", "evidence": "어지럽혀",
             "confidence_band": "trusted"},
        ],
        "unknowns": [],
    }


L05B_PROMPTS_SAMPLE = {
    "L05B01": "현대 대한민국 서해안권 도시의 낡은 옥탑방 내부, 거실과 식탁 공간이"
              " 한 방에 붙어 있는 소박한 주거 공간을 실제 카메라로 촬영한 다큐멘터리"
              " 풍 사진. 카메라는 서쪽 벽 큰 미닫이창 아래의 낮은 소파 근처, 앉은"
              " 눈높이와 선 눈높이 사이 약 1.3m에서 35mm 광각 렌즈로 중앙 식탁을 향해"
              " 비스듬히 바라본다. 평범한 한국 TV 뉴스가 켜진 거실 스탠드, 식탁 의자.",
    "L05B02": "실제 미러리스 카메라로 촬영한 다큐멘터리 풍의 근접 증거 사진."
              " 2020년대 한국 서해안권의 낡은 옥탑방 침실 바닥, 해 질 무렵의 차갑고"
              " 희미한 자연광이 커튼 너머로 약하게 들어오고 실내는 축축하고 어둡다."
              " 카메라는 바닥에서 약 0.5m 높이의 낮은 위치에 놓여 있으며, 50mm 렌즈로"
              " 구겨진 낡은 사진 한 장을 가까이 내려다보듯 촬영한다.",
    "L05B03": "현대 대한민국 서해안권 도시의 낡은 옥탑방 거실 겸 현관을 실제 카메라로"
              " 촬영한 다큐멘터리 풍 사진. 카메라는 식탁 가까이에서 사람 눈높이 약"
              " 1.6미터, 35mm급 광각 렌즈로 서서 촬영한 시점이며, 남쪽 현관의 앞문,"
              " 서쪽 커튼 달린 미닫이창, 북쪽 벽의 TV, 스마트폰이 한 프레임 안에서"
              " 자연스럽게 보이도록 현관 쪽을 대각선으로 바라본다.",
    "L05B04": "현대 대한민국 서해안권 도시의 오래된 옥탑방 내부를 실제 카메라로 촬영한"
              " 다큐멘터리 풍 사진. 현관 안쪽 작은 진입 공간 근처에서 약 1.6미터"
              " 사람 눈높이로 서서, 35mm급 광각 렌즈로 중앙의 거실 겸 식사 공간을 지나"
              " 서쪽 거실 미닫이창과 커튼, 동쪽 침실문 방향까지 대각선으로 바라본다."
              " 저녁 무렵 실내 상태로, 열린 거실 미닫이창 틈에서 차갑고 푸른 황혼빛.",
    "L05B05": "현대 대한민국 서해안권 도시의 낡은 옥탑방 내부를 실제 카메라로 촬영한"
              " 다큐멘터리 풍 사진. 2020년대 중반의 평범한 한국식 원룸형 주거 공간으로,"
              " 중앙의 거실 겸 식사 공간과 북서쪽 작은 주방, 동쪽 침실 입구가 한"
              " 프레임에 자연스럽게 이어진다. 카메라는 식탁 가까이에 서 있는 사람"
              " 눈높이 약 1.6미터에서 35mm급 렌즈로 촬영하며, 거실 겸 식사 공간을"
              " 가로질러 침실문 방향을 바라본다.",
    "L05B06": "현대 대한민국 서해안권 도시의 오래된 옥탑 원룸 내부를 실제 카메라로"
              " 촬영한 다큐멘터리 풍 사진. 중앙 식탁 근처에 서서 약 1.6미터 눈높이에서"
              " 40밀리미터 표준 렌즈로 거실 겸 식사 공간의 북서쪽 벽을 바라본다."
              " 프레임 안에는 북쪽 벽의 평범한 텔레비전, 서쪽 큰 미닫이창과 얇은 커튼,"
              " 북서쪽 주방 코너의 싱크대와 냉장고가 자연스럽게 보인다.",
    "L05B07": "현대 대한민국 서해안권 도시의 낡은 옥탑 주거 공간 안쪽 침실을 실제"
              " 카메라로 촬영한 다큐멘터리 풍 사진. 카메라는 침실 문 근처 문턱에 서"
              " 있는 높이 약 1.6m의 사람 눈높이에서, 35mm급 광각 렌즈를 단 실제"
              " 미러리스 또는 DSLR 카메라로 침실 안을 바라본다. 동쪽 방 내부는 작고"
              " 낮은 천장의 한국식 옥탑방 침실이다.",
    "L05B08": "대한민국 서해안권의 작은 도시나 섬 지역에 있을 법한 2020년대 옥탑방"
              " 내부, 깨끗해진 침실과 문 너머로 일부 보이는 거실을 실제 카메라로"
              " 촬영한 다큐멘터리 풍 사진. 카메라는 침실 문 근처에 서서 약 1.6m"
              " 사람 눈높이에서 35mm 광각 렌즈로 침실 안쪽을 바라본다. 침실 문틀을"
              " 가까운 전경으로 두고, 안쪽에는 낮은 침대나 바닥 매트리스와 베개.",
}


# ---------------------------------------------------------------------------
# Continuity grouping rules
# ---------------------------------------------------------------------------
class TestContinuityGrouping:
    def test_same_loc_same_space_different_state_groups_same_continuity(self):
        """같은 loc + 같은 space + 다른 state → same continuity group."""
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        cont_normal = bpg.continuity_group_for(
            space_node_id="거실", state_layer_id="sl_normal",
            structural_version_id="sv_initial",
        )
        cont_corpse = bpg.continuity_group_for(
            space_node_id="거실", state_layer_id="sl_corpse_marks",
            structural_version_id="sv_initial",
        )
        # state 가 달라도 같은 continuity group (geometry 동일).
        assert cont_normal == cont_corpse, (
            "geometry_preserving state layers must share continuity group"
        )

    def test_different_room_same_set_group_different_space_node(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        living = next(n for n in space_nodes if n.node_id == "거실")
        bedroom = next(n for n in space_nodes if n.node_id == "수리영의_방")
        assert living.node_id != bedroom.node_id
        assert living.set_group_id == bedroom.set_group_id, (
            "rooms in same building set must share set_group_id"
        )


# ---------------------------------------------------------------------------
# StateLayer geometry-preserving + affects enum
# ---------------------------------------------------------------------------
class TestStateLayerInvariants:
    @pytest.mark.parametrize("state_id,state_name", [
        ("sl_normal", "normal"),
        ("sl_corpse_marks", "corpse_marks"),
        ("sl_cleaned", "cleaned"),
        ("sl_vandalized", "vandalized"),
        ("sl_dusk_night", "dusk_night"),
    ])
    def test_state_layer_geometry_preserving_true(self, state_id, state_name):
        bible = _bible_minimal()
        layers = bpg.build_state_layers(bible, [])
        by_id = {layer.layer_id: layer for layer in layers}
        if state_id not in by_id:
            pytest.skip(f"{state_id} not built by default fixture")
        assert by_id[state_id].geometry_preserving is True, (
            f"{state_id} must be geometry_preserving (state layer, not structural change)"
        )

    def test_state_layer_affects_enum_strict(self):
        bible = _bible_minimal()
        layers = bpg.build_state_layers(bible, [])
        for layer in layers:
            for aff in layer.affects:
                assert aff in bpg.STATE_LAYER_AFFECTS_ENUM, (
                    f"{layer.layer_id} has illegal affects={aff}; "
                    f"allowed={bpg.STATE_LAYER_AFFECTS_ENUM}"
                )

    def test_state_layer_corpse_does_not_trigger_structural_change(self):
        # 같은 geometry — StructuralVersion 새 entry 생성 금지.
        bible = _bible_minimal()
        versions = bpg.build_structural_versions(bible, [])
        # default = sv_initial only (state 변화는 트리거 X).
        assert len(versions) == 1, versions
        assert versions[0].version_id == "sv_initial"


# ---------------------------------------------------------------------------
# StructuralVersion — geometry changing keywords only
# ---------------------------------------------------------------------------
class TestStructuralVersion:
    def test_only_geometry_keywords_trigger_new_version(self):
        bible = _bible_minimal()
        # bible 에는 geometry 변경 키워드 없음 → 1 version (initial).
        versions = bpg.build_structural_versions(bible, [])
        assert len(versions) == 1

    def test_remodel_keyword_triggers_new_version(self):
        bible = _bible_minimal()
        bible["unknowns"] = ["사건 후 옥탑방 거실에 가벽 추가 공사 진행됨"]
        versions = bpg.build_structural_versions(bible, [])
        assert len(versions) >= 2, (
            "가벽 추가/공사 keyword 가 새 StructuralVersion 트리거 해야 함"
        )

    def test_state_keywords_do_not_trigger_new_version(self):
        bible = _bible_minimal()
        # state_variants 에 corpse/cleaned/vandalized 가 이미 있음. version 1개.
        versions = bpg.build_structural_versions(bible, [])
        assert len(versions) == 1, (
            "state layer keyword 는 StructuralVersion 트리거 금지"
        )


# ---------------------------------------------------------------------------
# L05B variant decomposer — 3 axes
# ---------------------------------------------------------------------------
class TestL05VariantDecomposer:
    @pytest.mark.parametrize("variant_id", list(L05B_PROMPTS_SAMPLE.keys()))
    def test_decomposer_returns_three_axes(self, variant_id):
        prompt = L05B_PROMPTS_SAMPLE[variant_id]
        decomposition = bpg.decompose_l05_variant(variant_id, prompt)
        assert decomposition.variant_id == variant_id
        # 3축 필드 모두 존재.
        assert hasattr(decomposition, "space_nodes")
        assert hasattr(decomposition, "state_layer")
        assert hasattr(decomposition, "camera_view")
        # space_nodes 는 list[str] (multi 가능).
        assert isinstance(decomposition.space_nodes, list)

    def test_l05b01_resolves_living_room_normal_wide(self):
        decomposition = bpg.decompose_l05_variant("L05B01", L05B_PROMPTS_SAMPLE["L05B01"])
        assert "거실" in decomposition.space_nodes
        assert decomposition.state_layer == "normal"
        # camera_view family 가 wide 계열.
        assert "wide" in decomposition.camera_view.lower() or \
               decomposition.camera_view.startswith("eye_level"), decomposition.camera_view

    def test_l05b02_resolves_bedroom_floor_close_with_clue_state(self):
        decomposition = bpg.decompose_l05_variant("L05B02", L05B_PROMPTS_SAMPLE["L05B02"])
        assert "수리영의_방" in decomposition.space_nodes or \
               "민숙의_방_안방" in decomposition.space_nodes or \
               "침실" in decomposition.space_nodes
        # floor close + dusk light.
        assert "close" in decomposition.camera_view.lower() or \
               "0.5m" in decomposition.camera_view or \
               "floor" in decomposition.camera_view.lower(), decomposition.camera_view

    def test_l05b05_multi_space_marked_secondary_not_reject(self):
        # BLOCKING 2 갱신: L05B05 같은 거실 wide+침실문 secondary 는 reject 가 아님.
        # primary=거실 (camera 식탁 가까이), secondary=침실 (문 방향 시야 안).
        decomposition = bpg.decompose_l05_variant("L05B05", L05B_PROMPTS_SAMPLE["L05B05"])
        assert decomposition.multi_space, (
            "L05B05 must be flagged as multi_space"
        )
        # 새 정책: secondary mention 은 reject 가 아니라 visibility constraint.
        assert decomposition.violates_continuity is False, (
            "L05B05 primary 거실 + secondary 침실 은 visibility constraint 로만 처리, reject 아님"
        )
        assert decomposition.primary_space == "거실", decomposition.primary_space
        assert "수리영의_방" in decomposition.visible_secondary_nodes, (
            decomposition.visible_secondary_nodes
        )

    def test_l05b08_resolves_bedroom_cleaned(self):
        decomposition = bpg.decompose_l05_variant("L05B08", L05B_PROMPTS_SAMPLE["L05B08"])
        assert decomposition.state_layer == "cleaned", decomposition.state_layer


# ---------------------------------------------------------------------------
# Generation unit plan — cross-product 폭증 방지
# ---------------------------------------------------------------------------
class TestGenerationUnitPlan:
    def test_unit_plan_avoids_cross_product_explosion(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        l05_decomp = [
            bpg.decompose_l05_variant(v, p)
            for v, p in L05B_PROMPTS_SAMPLE.items()
        ]
        plan = bpg.plan_background_generation_units(
            set_groups, space_nodes, state_layers, l05_decomp,
        )
        # cross-product upper bound.
        active_spaces = [n for n in space_nodes if n.active_status == "active"]
        cross_product_upper = (
            len(active_spaces) * max(1, len(state_layers))
            * 3  # camera families assumption
        )
        # master + sum(derived) <= cross_product_upper / 2 (target 50%+ saving).
        total_units = (
            len(plan.master_units)
            + len(plan.derived_state_units)
            + len(plan.derived_camera_units)
        )
        assert total_units <= cross_product_upper, (
            f"unit total ({total_units}) exceeds cross_product upper "
            f"({cross_product_upper}) — plan failed to decompose."
        )
        # master 가 적어도 1장.
        assert len(plan.master_units) >= 1

    def test_unit_plan_has_three_buckets(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        plan = bpg.plan_background_generation_units(
            set_groups, space_nodes, state_layers, [],
        )
        assert hasattr(plan, "master_units")
        assert hasattr(plan, "derived_state_units")
        assert hasattr(plan, "derived_camera_units")


# ---------------------------------------------------------------------------
# PlaceGroup / SetGroup containment
# ---------------------------------------------------------------------------
class TestPlaceGroupSetGroupContainment:
    def test_set_group_parent_place_group_id_exists(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        place_ids = {pg.place_group_id for pg in place_groups}
        for sg in set_groups:
            assert sg.parent_place_group_id in place_ids, (
                f"SetGroup {sg.set_group_id} references unknown "
                f"PlaceGroup {sg.parent_place_group_id}"
            )

    def test_space_node_set_group_id_exists(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        set_ids = {sg.set_group_id for sg in set_groups}
        for n in space_nodes:
            assert n.set_group_id in set_ids, (
                f"SpaceNode {n.node_id} references unknown "
                f"SetGroup {n.set_group_id}"
            )


# ---------------------------------------------------------------------------
# ShotBindingCandidate — unresolved rollup (no per-shot question)
# ---------------------------------------------------------------------------
class TestShotBindingRollup:
    def test_unresolved_shot_uses_rollup_id_not_free_text(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        # synthetic shots — 2 unresolved bedroom shots.
        shot1 = bpg.ShotMeta(
            still_id="x1", scene_index=12, shot_index=4,
            scene_summary="방 안 흔적", shot_description="방 안 흔적",
            visible_short_ids=["L05"], dependent_scene_id=None,
            raw_visible_json="[]",
        )
        shot2 = bpg.ShotMeta(
            still_id="x2", scene_index=12, shot_index=6,
            scene_summary="방 안 흔적 2", shot_description="방 안 흔적 2",
            visible_short_ids=["L05"], dependent_scene_id=None,
            raw_visible_json="[]",
        )
        bindings = bpg.build_shot_bindings(
            [shot1, shot2], set_groups, space_nodes, state_layers,
        )
        unresolved = [b for b in bindings if b.unresolved_reason]
        # 동일 rollup_id 로 묶여야 한다 (둘 다 bedroom_unresolved).
        rollup_ids = {b.unresolved_reason for b in unresolved}
        assert len(rollup_ids) <= 5, (
            f"unresolved reasons must rollup to ≤ 5 distinct decisions, "
            f"got {len(rollup_ids)}: {rollup_ids}"
        )


# ---------------------------------------------------------------------------
# HTML report — first screen & no per-shot questions
# ---------------------------------------------------------------------------
class TestHtmlReport:
    def _build_artifacts(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        versions = bpg.build_structural_versions(bible, [])
        l05_decomp = [
            bpg.decompose_l05_variant(v, p)
            for v, p in L05B_PROMPTS_SAMPLE.items()
        ]
        plan = bpg.plan_background_generation_units(
            set_groups, space_nodes, state_layers, l05_decomp,
        )
        shots: list = []
        bindings = bpg.build_shot_bindings(shots, set_groups, space_nodes, state_layers)
        return (place_groups, set_groups, space_nodes, state_layers,
                versions, l05_decomp, bindings, plan)

    def test_html_first_screen_shows_old_vs_new_unit_count(self):
        artifacts = self._build_artifacts()
        html = bpg.render_html(*artifacts, run_meta={"run_id": "test"})
        assert ("existing_variant_count" in html
                or "old_unit_count" in html
                or "existing variants" in html), (
            "HTML first screen must show old (existing variant) count"
        )
        assert ("new_unit_count" in html
                or "master+derived" in html
                or "new units" in html.lower()), (
            "HTML first screen must show new (master+derived) count"
        )

    def test_html_unresolved_section_no_per_shot_question(self):
        artifacts = self._build_artifacts()
        html = bpg.render_html(*artifacts, run_meta={"run_id": "test"})
        # rollup decision count ≤ 5 — heuristic: 문장당 question mark "?" 카운트.
        # decision 섹션 위치
        if "Unresolved decisions" in html or "rollup-decisions" in html:
            decision_section_start = max(
                html.find("Unresolved decisions"),
                html.find("rollup-decisions"),
            )
            decision_section = html[decision_section_start: decision_section_start + 8000]
            q_count = decision_section.count("?")
            assert q_count <= 8, (
                f"too many ?-questions in unresolved section ({q_count}); "
                "must rollup to ≤ 5 large decisions"
            )

    def test_html_has_l05b_decomposition_section(self):
        artifacts = self._build_artifacts()
        html = bpg.render_html(*artifacts, run_meta={"run_id": "test"})
        assert ("L05B" in html and "decomposition" in html.lower()) or \
               ("variant decomposition" in html.lower()), (
            "HTML must contain L05B variant decomposition section"
        )


# ---------------------------------------------------------------------------
# Static import guard — no production / no network
# ---------------------------------------------------------------------------
class TestStaticImportGuards:
    def test_script_does_not_import_production_app(self):
        script_path = _SCRIPTS / "experiment_background_place_grouping.py"
        body = script_path.read_text(encoding="utf-8")
        for line in body.splitlines():
            stripped = line.strip()
            if stripped.startswith("from ") or stripped.startswith("import "):
                # allow read-only DAO patterns (matches experiment_rooftop_spatial_bg).
                if "app.core.database" in stripped:
                    continue
                if "app.models.project" in stripped:
                    continue
                assert "from app." not in stripped and "import app." not in stripped, (
                    f"forbidden production app import: {line}"
                )

    def test_script_does_not_import_image_or_network(self):
        """Only inspect actual import lines (not docstrings/comments that mention them
        as 'forbidden')."""
        script_path = _SCRIPTS / "experiment_background_place_grouping.py"
        body = script_path.read_text(encoding="utf-8")
        forbidden_substrings = [
            "google.genai", "from openai", "import openai",
            "fal_client", "import fal ", "from PIL",
            "requests.post", "httpx.post", "httpx.get", "requests.get",
        ]
        for line in body.splitlines():
            stripped = line.strip()
            # Only check actual import lines — docstrings/comments mentioning
            # forbidden names as warnings are OK.
            if not (stripped.startswith("from ") or stripped.startswith("import ")):
                continue
            for forb in forbidden_substrings:
                assert forb not in stripped, (
                    f"forbidden network/image import: {line}"
                )

    def test_script_does_not_import_build_shot_plans(self):
        script_path = _SCRIPTS / "experiment_background_place_grouping.py"
        body = script_path.read_text(encoding="utf-8")
        for line in body.splitlines():
            stripped = line.strip()
            if stripped.startswith("from ") or stripped.startswith("import "):
                assert "build_shot_plans" not in stripped, (
                    f"forbidden import: {line}"
                )


# ---------------------------------------------------------------------------
# W2 patch — BLOCKING 1: state classifier negation guard (golden snippets)
# ---------------------------------------------------------------------------
class TestStateClassifierNegation:
    L05B01_NEGATION = (
        "현대 대한민국 서해안권 도시의 낡은 옥탑방 내부, 거실과 식탁 공간이"
        " 한 방에 붙어 있는 소박한 주거 공간을 실제 카메라로 촬영한 다큐멘터리"
        " 풍 사진. ... 낮 시간의 평범한 자연광이지만 집 안 공기는 무겁고 말 없는"
        " 불안이 감돌며, 서쪽 또는 북서쪽 벽에는 텔레비전이나 주변 물체에서 생긴"
        " 것처럼 보이는 아주 희미한 붉은 원형 반사 자국이 불확실하게 비친다."
        " 사람, 얼굴, 시신, 피, 노골적인 폭력 흔적은 전혀 없다."
    )
    L05B07_NEGATION_BUT_BLOOD = (
        "침실 안. 침대 옆 바닥에는 시신이나 사람 형체 없이, 넓고 무겁게 번진"
        " 어두운 붉은 얼룩 구역만 남아 있다. 그 얼룩에서 침실 문 쪽으로 몇 개의"
        " 붉은 발자국이 이어져 있고, 침대 위쪽 북쪽 벽에는 붉은 원형 표식이 보인다."
    )
    L05B08_CLEANED_BEDROOM = (
        "대한민국 서해안권의 작은 도시나 섬 지역에 있을 법한 2020년대 옥탑방"
        " 내부, 깨끗해진 침실과 문 너머로 일부 보이는 거실. 침실 문틀을 가까운"
        " 전경으로 두고, 안쪽에는 낮은 침대나 바닥 매트리스와 베개."
    )
    REAL_CORPSE = (
        "어깨가 뜯어진 참혹한 모습으로 주저앉아 있는 시신, 흥건한 핏자국."
    )

    def test_l05b01_negation_resolves_normal_not_corpse(self):
        state = bpg.classify_state_layer(self.L05B01_NEGATION)
        assert state == "normal", f"negation must downgrade corpse, got {state}"

    def test_l05b07_blood_marks_without_body_resolves_vandalized_not_corpse(self):
        state = bpg.classify_state_layer(self.L05B07_NEGATION_BUT_BLOOD)
        # 시신은 없으나 핏자국 + 붉은 원 표식 → vandalized vision.
        assert state in {"vandalized", "normal"}, state

    def test_l05b08_cleaned_resolves_cleaned(self):
        state = bpg.classify_state_layer(self.L05B08_CLEANED_BEDROOM)
        assert state == "cleaned", state

    def test_real_corpse_still_resolves(self):
        state = bpg.classify_state_layer(self.REAL_CORPSE)
        assert state == "corpse_marks", state

    def test_cleaned_overrides_corpse_keyword(self):
        text = "지나치게 깨끗하게 정돈된 방안, 시신은 보이지 않는다"
        state = bpg.classify_state_layer(text)
        assert state == "cleaned", state

    def test_decompose_l05_variant_uses_unified_classifier(self):
        # decompose_l05_variant 가 같은 classifier 를 호출해야 한다.
        decomposition = bpg.decompose_l05_variant("L05B01", self.L05B01_NEGATION)
        assert decomposition.state_layer == "normal", (
            f"variant decomposer must respect negation, got "
            f"state_layer={decomposition.state_layer}"
        )


# ---------------------------------------------------------------------------
# W2 patch — BLOCKING 2: primary_space vs visible_secondary
# ---------------------------------------------------------------------------
class TestPrimarySecondarySpaceExtraction:
    L05B03_LIVING_PRIMARY = (
        "현대 대한민국 서해안권 도시의 낡은 옥탑방 거실 겸 현관을 실제 카메라로"
        " 촬영한 다큐멘터리 풍 사진. 카메라는 식탁 가까이에서 사람 눈높이 약"
        " 1.6미터, 35mm급 광각 렌즈로 서서 촬영한 시점이며, 남쪽 현관의 앞문,"
        " 서쪽 커튼 달린 미닫이창, 북쪽 벽의 TV, 스마트폰이 한 프레임 안에서"
        " 자연스럽게 보이도록 현관 쪽을 대각선으로 바라본다. 동쪽 방 문은 닫혀"
        " 있거나 반쯤 보이되 안쪽 사건을 보여주지 않는다."
    )
    L05B06_LIVING_VISIBLE_BATHROOM = (
        "현대 대한민국 서해안권 도시의 오래된 옥탑 원룸 내부를 실제 카메라로"
        " 촬영한 다큐멘터리 풍 사진. 중앙 식탁 근처에 서서 약 1.6미터 눈높이에서"
        " 40밀리미터 표준 렌즈로 거실 겸 식사 공간의 북서쪽 벽을 바라본다."
        " 프레임 안에는 북쪽 벽의 평범한 텔레비전, 서쪽 큰 미닫이창과 얇은 커튼,"
        " 북서쪽 주방 코너의 싱크대와 냉장고가 자연스럽게 보인다. 현관 쪽 욕실"
        " 문이 살짝 열려 있다면 좁은 습식 욕실의 세면대 가장자리가 살짝 보인다."
    )
    L05B08_BEDROOM_PRIMARY = (
        "옥탑방 내부, 깨끗해진 침실과 문 너머로 일부 보이는 거실을 실제 카메라로"
        " 촬영한 다큐멘터리 풍 사진. 카메라는 침실 문 근처에 서서 약 1.6m"
        " 사람 눈높이에서 35mm 광각 렌즈로 침실 안쪽을 바라본다. 침실 문틀을"
        " 가까운 전경으로 두고, 안쪽에는 낮은 침대나 바닥 매트리스."
    )

    def test_living_room_wide_primary_living_not_reject(self):
        decomposition = bpg.decompose_l05_variant("L05B03", self.L05B03_LIVING_PRIMARY)
        # primary_space 가 거실, secondary 에 침실/현관/주방 가능.
        assert decomposition.primary_space == "거실", (
            f"primary_space must be 거실 (camera direction), got "
            f"{decomposition.primary_space}"
        )
        # secondary mention 은 reject 사유가 아님.
        assert decomposition.violates_continuity is False, (
            f"secondary room mention must not violate continuity, got "
            f"reject_reason={decomposition.reject_reason}"
        )

    def test_living_room_wide_visible_bathroom_edge_not_reject(self):
        decomposition = bpg.decompose_l05_variant("L05B06", self.L05B06_LIVING_VISIBLE_BATHROOM)
        assert decomposition.primary_space == "거실"
        assert decomposition.violates_continuity is False

    def test_bedroom_primary_with_visible_living_not_reject(self):
        decomposition = bpg.decompose_l05_variant("L05B08", self.L05B08_BEDROOM_PRIMARY)
        # primary = 침실 (수리영의_방), secondary 에 거실 OK, 거부 아님.
        assert decomposition.primary_space in {"수리영의_방", "민숙의_방_안방"}, (
            decomposition.primary_space
        )
        assert decomposition.violates_continuity is False

    def test_zone_boundary_absorption_not_secondary(self):
        decomposition = bpg.decompose_l05_variant("L05B03", self.L05B03_LIVING_PRIMARY)
        # 주방코너/현관 같은 zone/boundary 는 primary 거실 의 contained 로 흡수.
        # → visible_secondary_nodes 에 포함되되 'room' 아님.
        secondary = decomposition.visible_secondary_nodes
        # 현관/주방코너 는 boundary/zone — secondary 에 보일 수 있음.
        assert isinstance(secondary, list)

    def test_decomposition_has_primary_and_secondary_fields(self):
        decomposition = bpg.decompose_l05_variant("L05B01", "거실 wide")
        assert hasattr(decomposition, "primary_space")
        assert hasattr(decomposition, "visible_secondary_nodes")


# ---------------------------------------------------------------------------
# IMPORTANT 1: metric label downgrade
# ---------------------------------------------------------------------------
class TestMetricLabel:
    def test_run_meta_uses_illustrative_label(self):
        # HTML 또는 run_meta 가 "illustrative_cross_product_upper" label 사용.
        # 직접 헬퍼 검사 — W2b IMPORTANT 2: placeholder state_false_positive_count
        # 대신 golden_state_oracle_passed boolean.
        if hasattr(bpg, "compute_acceptance_metrics"):
            bible = _bible_minimal()
            place_groups = bpg.build_place_groups(bible)
            set_groups = bpg.build_set_groups(bible, place_groups)
            space_nodes = bpg.build_space_nodes(bible, set_groups)
            state_layers = bpg.build_state_layers(bible, [])
            metrics = bpg.compute_acceptance_metrics(
                set_groups=set_groups, space_nodes=space_nodes,
                state_layers=state_layers, l05b_decomp=[],
                bindings=[],
            )
            assert "illustrative_cross_product_upper" in metrics
            # W2b IMPORTANT 2: placeholder 교체 — boolean oracle metric.
            assert "golden_state_oracle_passed" in metrics
            assert "state_false_positive_count" not in metrics, (
                "placeholder metric must be removed (replaced by oracle boolean)"
            )
            assert "valid_decomposition_count" in metrics
            assert "reject_by_reason" in metrics
        else:
            pytest.fail("rp.compute_acceptance_metrics must exist for IMPORTANT 1")


# ---------------------------------------------------------------------------
# IMPORTANT 3: continuity_group geometry_preserving guard
# ---------------------------------------------------------------------------
class TestContinuityGroupGuard:
    def test_geometry_preserving_state_keeps_same_group(self):
        cg1 = bpg.continuity_group_for(
            space_node_id="거실", structural_version_id="sv_initial",
            state_layer_id=None,
        )
        cg2 = bpg.continuity_group_for(
            space_node_id="거실", structural_version_id="sv_initial",
            state_layer_id="sl_corpse_marks",
        )
        assert cg1 == cg2, "geometry_preserving state must share continuity"

    def test_non_geometry_preserving_state_raises_or_warns(self):
        # 가짜 non-preserving state — assertion.
        # bpg 가 StateLayer registry 에서 geometry_preserving 을 lookup 한다면.
        if hasattr(bpg, "_STATE_LAYER_PRESERVING"):
            # inject a non-preserving layer 임시 (test helper 또는 lookup 의존)
            bpg._STATE_LAYER_PRESERVING["sl_non_preserving"] = False
            try:
                with pytest.raises((AssertionError, ValueError)):
                    bpg.continuity_group_for(
                        space_node_id="거실",
                        structural_version_id="sv_initial",
                        state_layer_id="sl_non_preserving",
                    )
            finally:
                bpg._STATE_LAYER_PRESERVING.pop("sl_non_preserving", None)
        else:
            pytest.skip("non-preserving registry helper not implemented")


# ---------------------------------------------------------------------------
# Shot state classifier (unified with variant classifier)
# ---------------------------------------------------------------------------
class TestShotStateClassifierUnified:
    def test_s14_s4_cleaned_not_corpse(self):
        # S14_S4 real shot desc starts: "핏자국이나 시신 없이 지나치게 깔끔하게"
        text = ("핏자국이나 시신 없이 지나치게 깔끔하게 정돈된 수리영의 방안"
                " 침대와 창문 커튼 전경")
        state = bpg.classify_state_layer(text)
        assert state == "cleaned", state

    def test_s18_s5_no_corpse_if_negated(self):
        text = "시신이 없는 깨끗한 바닥을 내려다보는 수리영의 측면"
        state = bpg.classify_state_layer(text)
        assert state != "corpse_marks", state


# ---------------------------------------------------------------------------
# W2b BLOCKING 1: extended negation patterns (절대 포함하지 않는다 등)
# ---------------------------------------------------------------------------
class TestExtendedNegation:
    def test_l05b04_negation_jeoldae_pohamhaji(self):
        # L05B04 real DB prompt 의 negation 구절.
        text = ("저녁 무렵 옥탑방 거실, 황혼빛이 미닫이창을 통해 들어온다."
                " 인물, 얼굴, 시신, 피가 묻은 신체, 노골적인 유혈 장면은"
                " 절대 포함하지 않는다.")
        state = bpg.classify_state_layer(text)
        assert state != "corpse_marks", (
            f"'절대 포함하지 않는다' 부정 처리 실패, got {state}"
        )

    def test_l05b06_negation_jeoldae_nehji(self):
        # L05B06 real DB prompt 의 negation 구절.
        text = ("거실 wide, 북서쪽 벽 시점. 인물, 식별 가능한 얼굴, 시신,"
                " 노골적인 혈흔은 절대 넣지 않는다.")
        state = bpg.classify_state_layer(text)
        assert state != "corpse_marks", (
            f"'절대 넣지 않는다' 부정 처리 실패, got {state}"
        )

    def test_etc_negation_phrases(self):
        # 추가 negation 형태들.
        for text in [
            "시신은 등장하지 않는다.",
            "시신은 보이지 않는다.",
            "노골적인 혈흔은 포함하지 않는다.",
            "피가 묻은 신체는 넣지 않는다.",
        ]:
            state = bpg.classify_state_layer(text)
            assert state != "corpse_marks", (
                f"negation '{text}' 처리 실패, got {state}"
            )


# ---------------------------------------------------------------------------
# W2b BLOCKING 2: derived_camera unit_id uniqueness
# ---------------------------------------------------------------------------
class TestDerivedUnitIdUniqueness:
    def test_all_unit_ids_unique(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        # craft two decompositions same primary+state but different camera height.
        decomp = [
            bpg.L05BDecomposition(
                variant_id="V1",
                space_nodes=["거실"], state_layer="normal",
                camera_view="eye_level_table_close@low_eye_level",
                primary_space="거실", visible_secondary_nodes=[],
            ),
            bpg.L05BDecomposition(
                variant_id="V2",
                space_nodes=["거실"], state_layer="normal",
                camera_view="eye_level_table_close@standing_eye_level",
                primary_space="거실", visible_secondary_nodes=[],
            ),
        ]
        plan = bpg.plan_background_generation_units(
            set_groups, space_nodes, state_layers, decomp,
        )
        all_ids = (
            [m.unit_id for m in plan.master_units]
            + [d.unit_id for d in plan.derived_state_units]
            + [d.unit_id for d in plan.derived_camera_units]
        )
        assert len(all_ids) == len(set(all_ids)), (
            f"duplicate unit_ids: {all_ids}"
        )


# ---------------------------------------------------------------------------
# W2b BLOCKING 3: StateLayer.applied_shots 정합 (unified classifier 사용)
# ---------------------------------------------------------------------------
class TestStateLayerAppliedShotsUnified:
    def test_applied_shots_uses_classify_state_layer(self):
        # cleaned 인 shot 이 corpse 의 applied_shots 에 없어야 한다.
        bible = _bible_minimal()
        shots = [
            bpg.ShotMeta(
                still_id="x", scene_index=14, shot_index=4,
                scene_summary="",
                shot_description=(
                    "핏자국이나 시신 없이 지나치게 깔끔하게 정돈된"
                    " 수리영의 방안"
                ),
                visible_short_ids=["L05"],
                dependent_scene_id=None, raw_visible_json="[]",
            ),
        ]
        layers = bpg.build_state_layers(bible, shots)
        by_label = {layer.label: layer for layer in layers}
        # cleaned shot 이 cleaned layer 의 applied_shots 에만 있어야.
        assert "S14_S4" in by_label["cleaned"].applied_shots, (
            by_label["cleaned"].applied_shots
        )
        assert "S14_S4" not in by_label.get("corpse_marks", layers[0]).applied_shots, (
            f"cleaned shot must NOT appear in corpse_marks.applied_shots"
        )


# ---------------------------------------------------------------------------
# W2b IMPORTANT 1: wrist/body mark vandalized 가드
# ---------------------------------------------------------------------------
class TestWristBodyMarkGuard:
    def test_wrist_mark_not_vandalized(self):
        # 손목 표식 = 캐릭터 mark, 배경 vandalized 아님.
        text = "손목 안쪽의 붉은 원형 표식을 가만히 본다."
        state = bpg.classify_state_layer(text)
        assert state != "vandalized", (
            f"wrist mark must NOT be vandalized, got {state}"
        )

    def test_arm_mark_not_vandalized(self):
        text = "팔에 새겨진 붉은 원형 표식이 선명하다."
        state = bpg.classify_state_layer(text)
        assert state != "vandalized", state

    def test_wall_mark_still_vandalized(self):
        # 벽 표식 = 배경 vandalized 유지.
        text = "벽면에 그려진 거친 붉은 원형 표식이 보인다."
        state = bpg.classify_state_layer(text)
        assert state == "vandalized", state

    def test_floor_blood_still_vandalized(self):
        text = "바닥에 어두운 붉은 얼룩이 무겁게 번져 있다."
        state = bpg.classify_state_layer(text)
        assert state == "vandalized", state


# ---------------------------------------------------------------------------
# W2b IMPORTANT 2: false-positive metric 교체 (golden oracle boolean)
# ---------------------------------------------------------------------------
class TestGoldenOracleMetric:
    def test_metric_uses_golden_oracle_not_placeholder(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        metrics = bpg.compute_acceptance_metrics(
            set_groups=set_groups, space_nodes=space_nodes,
            state_layers=state_layers, l05b_decomp=[], bindings=[],
        )
        # placeholder 가 아닌 boolean 또는 명시적 측정.
        assert "golden_state_oracle_passed" in metrics or \
               "state_false_positive_count" not in metrics, (
            "placeholder state_false_positive_count must be replaced or removed"
        )


# ---------------------------------------------------------------------------
# W2b IMPORTANT 3: multi_space → has_secondary_spaces rename
# ---------------------------------------------------------------------------
class TestMultiSpaceRename:
    def test_has_secondary_spaces_field_present(self):
        dec = bpg.decompose_l05_variant(
            "Vtest", "거실 wide 안에 침실문 방향이 보인다",
        )
        assert hasattr(dec, "has_secondary_spaces") or \
               hasattr(dec, "secondary_visibility"), (
            "L05BDecomposition must have renamed multi_space field"
        )


# ---------------------------------------------------------------------------
# W2b SCOPE: generic methodology — sample fixture leakage guard
# 사용자 standing rule (2026-05-24): 특정 시나리오 (L05/옥탑방) 기반 일반
# rule/prompt/code 금지. fixture data 는 SAMPLE_FIXTURE_* 격리.
# ---------------------------------------------------------------------------
class TestGenericMethodologyGuard:
    SCRIPT_PATH = _SCRIPTS / "experiment_background_place_grouping.py"
    PLAN_PATH = (
        _REPO_ROOT / "scripts_output"
        / "background_place_grouping_experiment" / "plan.md"
    )

    FORBIDDEN_LEAKAGE_PHRASES = [
        "rooftop-only", "옥탑방 전용", "L05 전용 규칙",
        "특정 작품 전용", "rooftop-specific methodology",
    ]

    def test_script_has_no_scope_leakage_phrases(self):
        body = self.SCRIPT_PATH.read_text(encoding="utf-8")
        for phrase in self.FORBIDDEN_LEAKAGE_PHRASES:
            assert phrase not in body, (
                f"forbidden scope-leakage phrase '{phrase}' in script"
            )

    def test_plan_md_declares_generic_scope_and_sample_fixture(self):
        body = self.PLAN_PATH.read_text(encoding="utf-8")
        # English 와 한국어 둘 다 명시되어야 한다.
        assert "generic background place grouping experiment" in body, (
            "plan.md must declare generic scope (English)"
        )
        assert "sample fixture" in body, (
            "plan.md must mark L05 as sample fixture"
        )
        # Korean: '일반화' + 'sample fixture' 명시 또는 generic 방법론 선언.
        assert ("일반화해 분류하는 방법론 검증" in body
                or "일반화된 방법론 검증" in body), (
            "plan.md must declare generic methodology (Korean)"
        )
        # leakage phrases 금지.
        for phrase in self.FORBIDDEN_LEAKAGE_PHRASES:
            assert phrase not in body, (
                f"forbidden scope-leakage phrase '{phrase}' in plan.md"
            )

    def test_sample_fixture_constants_namespaced(self):
        body = self.SCRIPT_PATH.read_text(encoding="utf-8")
        # SAMPLE_FIXTURE_* prefix 가 사용되어야 한다.
        assert "SAMPLE_FIXTURE_PROJECT_ID" in body
        assert "SAMPLE_FIXTURE_L05_SPACE_KEYWORDS" in body
        # L05 fixture 관련 노드 set 도 격리됨.
        assert "SAMPLE_FIXTURE_L05_ZONE_BOUNDARY_NODES" in body or \
               "SAMPLE_FIXTURE_L05_ROOM_NODES" in body

    def test_html_declares_generic_scope(self):
        # render_html 결과에 generic scope 명시 / sample 마킹 포함.
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        versions = bpg.build_structural_versions(bible, [])
        html = bpg.render_html(
            place_groups, set_groups, space_nodes, state_layers, versions,
            [], [],
            bpg.plan_background_generation_units(
                set_groups, space_nodes, state_layers, [],
            ),
            run_meta={"run_id": "test"},
        )
        assert "generic" in html.lower(), "HTML must mark scope as generic"
        assert "sample fixture" in html.lower() or "sample" in html.lower(), (
            "HTML must mark L05 as sample"
        )

    def test_generic_alias_decompose_chain_bg_variant_exists(self):
        # generic 명명의 decomposer 가 노출돼야 한다.
        assert hasattr(bpg, "decompose_chain_bg_variant"), (
            "generic-named decomposer must be available"
        )
        # 결과 동일 — spec 명시 호출 시 (W2d 이후).
        spec = bpg.build_sample_fixture_l05_spec()
        a = bpg.decompose_l05_variant("V", "거실 wide")
        b = bpg.decompose_chain_bg_variant("V", "거실 wide", spec=spec)
        assert a == b, "generic alias (with spec) must yield same decomposition"


# ---------------------------------------------------------------------------
# W2c BLOCKING 1: sample fixture vs generic engine 구조적 분리
# ---------------------------------------------------------------------------
class TestSampleFixtureSpecSeparation:
    def test_sample_fixture_spec_dataclass_exists(self):
        assert hasattr(bpg, "SampleFixtureSpec"), (
            "SampleFixtureSpec dataclass must exist"
        )

    def test_sample_fixture_loader_exists(self):
        # Sample-prefixed loader 가 분리되어야 한다.
        assert hasattr(bpg, "build_sample_fixture_l05_spec"), (
            "build_sample_fixture_l05_spec must exist"
        )

    def test_generic_builders_take_spec(self):
        # generic builder 가 spec 을 input 으로 받아야 한다.
        spec = bpg.build_sample_fixture_l05_spec()
        # PlaceGroup / SetGroup / SpaceNode 가 spec 으로부터 derive.
        place_groups = bpg.build_place_groups_from_spec(spec)
        set_groups = bpg.build_set_groups_from_spec(spec, place_groups)
        space_nodes = bpg.build_space_nodes_from_spec(spec, set_groups)
        assert len(place_groups) >= 1
        assert len(set_groups) >= 1
        assert len(space_nodes) >= 1


# ---------------------------------------------------------------------------
# W2c BLOCKING 2: output artifact 이름 chain_bg_decomposition 통일
# ---------------------------------------------------------------------------
class TestOutputArtifactRename:
    def test_script_writes_chain_bg_decomposition(self):
        # write_outputs 가 chain_bg_decomposition.json+tsv 를 산출해야 한다.
        # source grep 으로 확인 (실 file write 는 dry-run 단계).
        script_path = _SCRIPTS / "experiment_background_place_grouping.py"
        body = script_path.read_text(encoding="utf-8")
        assert "chain_bg_decomposition.json" in body, (
            "write_outputs must produce chain_bg_decomposition.json"
        )
        assert "chain_bg_decomposition.tsv" in body, (
            "write_outputs must produce chain_bg_decomposition.tsv"
        )


# ---------------------------------------------------------------------------
# W2c BLOCKING 3: golden_state_oracle 실측 (hardcoded True 금지)
# ---------------------------------------------------------------------------
class TestGoldenOracleRuntime:
    def test_oracle_cases_constant_exists(self):
        assert hasattr(bpg, "STATE_ORACLE_CASES"), (
            "STATE_ORACLE_CASES list must exist"
        )
        cases = bpg.STATE_ORACLE_CASES
        assert len(cases) >= 4, f"need ≥4 oracle cases, got {len(cases)}"
        # 각 case: (label, text, expected_state).
        for c in cases:
            assert len(c) == 3, c

    def test_compute_acceptance_metrics_runs_oracle(self):
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        metrics = bpg.compute_acceptance_metrics(
            set_groups=set_groups, space_nodes=space_nodes,
            state_layers=state_layers, l05b_decomp=[], bindings=[],
        )
        assert metrics["golden_state_oracle_passed"] in (True, False)
        assert "oracle_pass_count" in metrics
        assert "oracle_total" in metrics
        assert metrics["oracle_total"] == len(bpg.STATE_ORACLE_CASES)
        # 모든 oracle case 가 통과해야 한다 (현 classifier 가 정확하면).
        assert metrics["golden_state_oracle_passed"] is True, (
            f"oracle failures: {metrics.get('oracle_failures', [])}"
        )


# ---------------------------------------------------------------------------
# W2c IMPORTANT 1: prohibition-before-object negation
# ---------------------------------------------------------------------------
class TestProhibitionBeforeObject:
    def test_jeoldae_before_object(self):
        text = "절대 시신을 넣지 않는다. 평범한 옥탑방 거실."
        state = bpg.classify_state_layer(text)
        assert state != "corpse_marks", state

    def test_si_object_boyimyeon_andoenda(self):
        text = "시신이 보이면 안 된다. 평범한 일상 컷."
        state = bpg.classify_state_layer(text)
        assert state != "corpse_marks", state

    def test_pi_myosa_geumji(self):
        text = "피 묘사 금지. 평범한 거실."
        state = bpg.classify_state_layer(text)
        assert state != "corpse_marks", state


# ---------------------------------------------------------------------------
# W2c IMPORTANT 2: unit_id slug sanitizer
# ---------------------------------------------------------------------------
class TestUnitIdSlugSanitizer:
    def test_slug_helper_exists(self):
        assert hasattr(bpg, "_slug_id"), "_slug_id helper must exist"

    def test_slug_handles_special_chars(self):
        slug = bpg._slug_id("a b/c@d#e:f.g")
        # 모든 non-safe 문자가 '_' 로.
        for ch in slug:
            assert ch.isalnum() or ch == "_" or 0xAC00 <= ord(ch) <= 0xD7A3, (
                f"unsafe char in slug: {ch!r}"
            )

    def test_slug_preserves_korean(self):
        slug = bpg._slug_id("거실_wide")
        assert "거실" in slug


# ---------------------------------------------------------------------------
# W2c IMPORTANT 3: HTML/TSV has_secondary_spaces primary 표기
# ---------------------------------------------------------------------------
class TestHasSecondarySpacesPrimary:
    def test_tsv_uses_has_secondary_spaces_column(self):
        # _write_l05b_tsv (or renamed _write_chain_bg_tsv) 의 header 에
        # has_secondary_spaces 컬럼.
        script_path = _SCRIPTS / "experiment_background_place_grouping.py"
        body = script_path.read_text(encoding="utf-8")
        assert "has_secondary_spaces" in body, (
            "tsv writer must use has_secondary_spaces column header"
        )

    def test_html_decomposition_table_uses_secondary_label(self):
        # render_html 의 §3 decomposition table 에 has_secondary 또는
        # secondary 헤더가 있어야 한다.
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        versions = bpg.build_structural_versions(bible, [])
        decomp = [bpg.decompose_l05_variant("V1", "거실 wide 안에 침실문 보임")]
        plan = bpg.plan_background_generation_units(
            set_groups, space_nodes, state_layers, decomp,
        )
        html = bpg.render_html(
            place_groups, set_groups, space_nodes, state_layers, versions,
            decomp, [], plan, run_meta={"run_id": "test"},
        )
        assert "secondary" in html, (
            "HTML decomposition table must use has_secondary/visible_secondary"
        )


# ---------------------------------------------------------------------------
# W2d BLOCKING 1: extract_primary_and_secondary_spaces spec-driven
# ---------------------------------------------------------------------------
class TestSpacePrimarySecondarySpecDriven:
    def test_extract_accepts_spec_parameter(self):
        import inspect
        sig = inspect.signature(bpg.extract_primary_and_secondary_spaces)
        assert "spec" in sig.parameters, (
            "extract_primary_and_secondary_spaces must accept spec parameter"
        )

    def test_extract_uses_spec_keyword_whitelist(self):
        # spec 의 keyword whitelist 만 사용 — fixture 글로벌 미사용.
        spec = bpg.build_sample_fixture_l05_spec()
        primary, secondary = bpg.extract_primary_and_secondary_spaces(
            "거실 wide", spec=spec,
        )
        assert primary == "거실"

    def test_extract_with_alt_spec_yields_different_result(self):
        # 다른 spec (다른 keyword/room set) 이 다른 결과 산출.
        # — 작은 합성 spec 으로 검증.
        alt_spec = bpg.SampleFixtureSpec(
            fixture_id="alt_test",
            project_id="x", episode_id="y", canon_id="z",
            location_short_id="LXX",
            source_run_path=bpg.DEFAULT_SOURCE_RUN,
            place_groups=[], set_groups=[], space_nodes=[],
            space_keyword_whitelist=[("아무방", "study_room")],
            zone_boundary_node_ids=set(),
            room_node_ids={"study_room"},
        )
        primary, secondary = bpg.extract_primary_and_secondary_spaces(
            "아무방 wide", spec=alt_spec,
        )
        assert primary == "study_room", (
            f"alt spec must drive resolution, got {primary}"
        )

    def test_decompose_chain_bg_variant_accepts_spec(self):
        import inspect
        sig = inspect.signature(bpg.decompose_chain_bg_variant)
        assert "spec" in sig.parameters, (
            "decompose_chain_bg_variant must accept spec parameter"
        )

    def test_legacy_decompose_l05_variant_still_works(self):
        # legacy alias 는 build_sample_fixture_l05_spec() 자동 주입.
        result = bpg.decompose_l05_variant("V1", "거실 wide")
        assert result.primary_space == "거실"


# ---------------------------------------------------------------------------
# W2d BLOCKING 2: build_shot_bindings spec-driven
# ---------------------------------------------------------------------------
class TestShotBindingSpecDriven:
    def test_build_shot_bindings_accepts_spec(self):
        import inspect
        sig = inspect.signature(bpg.build_shot_bindings)
        assert "spec" in sig.parameters, (
            "build_shot_bindings must accept spec parameter"
        )

    def test_loc_id_from_spec_not_global(self):
        spec = bpg.build_sample_fixture_l05_spec()
        shot = bpg.ShotMeta(
            still_id="x", scene_index=1, shot_index=1,
            scene_summary="", shot_description="안방 wide",
            visible_short_ids=["L05"], dependent_scene_id=None,
            raw_visible_json="[]",
        )
        # Build minimal place/set/space.
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        bindings = bpg.build_shot_bindings(
            [shot], set_groups, space_nodes, state_layers, spec=spec,
        )
        assert bindings[0].loc_id == spec.location_short_id

    def test_weak_room_hints_from_spec(self):
        # SampleFixtureSpec 에 weak_room_hints 필드가 있고 build_shot_bindings 가 사용.
        spec = bpg.build_sample_fixture_l05_spec()
        assert hasattr(spec, "weak_room_hints"), (
            "SampleFixtureSpec must have weak_room_hints field"
        )


# ---------------------------------------------------------------------------
# W2d BLOCKING 3: plan_background_generation_units spec-driven
# ---------------------------------------------------------------------------
class TestPlanUnitSpecDriven:
    def test_plan_accepts_spec(self):
        import inspect
        sig = inspect.signature(bpg.plan_background_generation_units)
        assert "spec" in sig.parameters, (
            "plan_background_generation_units must accept spec parameter"
        )

    def test_default_camera_from_spec(self):
        spec = bpg.build_sample_fixture_l05_spec()
        assert hasattr(spec, "default_camera_by_space_node"), (
            "SampleFixtureSpec must have default_camera_by_space_node field"
        )
        # spec.default_camera_by_space_node 가 dict {space_node: camera}.
        dcs = spec.default_camera_by_space_node
        assert isinstance(dcs, dict)
        assert "거실" in dcs

    def test_plan_uses_spec_default_camera(self):
        # alt spec 으로 다른 default camera 가 들어가는지 검증.
        spec = bpg.build_sample_fixture_l05_spec()
        bible = _bible_minimal()
        place_groups = bpg.build_place_groups(bible)
        set_groups = bpg.build_set_groups(bible, place_groups)
        space_nodes = bpg.build_space_nodes(bible, set_groups)
        state_layers = bpg.build_state_layers(bible, [])
        plan = bpg.plan_background_generation_units(
            set_groups, space_nodes, state_layers, [], spec=spec,
        )
        # master 거실 camera = spec.default_camera_by_space_node["거실"]
        living_master = next(
            m for m in plan.master_units if m.space_node == "거실"
        )
        assert living_master.camera_view == spec.default_camera_by_space_node["거실"]


# ---------------------------------------------------------------------------
# W2d IMPORTANT 1: oracle case scenario-neutral 분리
# ---------------------------------------------------------------------------
class TestOracleCasesNeutral:
    def test_generic_oracle_cases_no_specific_scenario(self):
        # STATE_ORACLE_CASES 는 scenario-neutral.
        scenario_terms = ["옥탑방", "수리영", "민숙", "엄마", "L05"]
        for label, text, expected in bpg.STATE_ORACLE_CASES:
            for term in scenario_terms:
                assert term not in text, (
                    f"oracle case '{label}' contains scenario-specific term"
                    f" '{term}': {text!r}"
                )

    def test_sample_fixture_oracle_cases_exist(self):
        # SAMPLE_FIXTURE_L05_ORACLE_CASES 별도 노출.
        assert hasattr(bpg, "SAMPLE_FIXTURE_L05_ORACLE_CASES"), (
            "sample-specific oracle cases must be separated to SAMPLE_FIXTURE_*"
        )


# ---------------------------------------------------------------------------
# W2d IMPORTANT 3: PLAN_VERSION bump
# ---------------------------------------------------------------------------
class TestPlanVersion:
    def test_plan_version_is_w2d_or_later(self):
        # bpg_w2d 이상.
        assert bpg.PLAN_VERSION >= "bpg_w2d", (
            f"PLAN_VERSION must be bpg_w2d or later, got {bpg.PLAN_VERSION!r}"
        )

    def test_plan_version_is_w2e_or_later(self):
        # W2e patch 적용 후 bpg_w2e 이상.
        assert bpg.PLAN_VERSION >= "bpg_w2e", (
            f"PLAN_VERSION must be bpg_w2e or later, got {bpg.PLAN_VERSION!r}"
        )


# ---------------------------------------------------------------------------
# W2e BLOCKING 1: primary resolver regex spec-driven
# ---------------------------------------------------------------------------
class TestPrimaryResolverDynamic:
    def test_no_global_primary_space_patterns(self):
        # PRIMARY_SPACE_PATTERNS / _PRIMARY_RAW_TO_NODE 가 모듈 globals 에서
        # 제거되거나 빈 list 여야 한다 (fixture vocabulary 직접 등장 X).
        body = (_SCRIPTS / "experiment_background_place_grouping.py").read_text(
            encoding="utf-8",
        )
        # 'PRIMARY_SPACE_PATTERNS = [' 같이 list literal 정의가 module-level 에서
        # 거실/침실/안방/욕실 vocabulary 를 직접 박지 않아야 한다.
        forbidden_in_module_globals = [
            r"\"카메라는.*거실.*침실.*수리영의\s*방.*안방.*욕실",
        ]
        # 단순 검사: PRIMARY_SPACE_PATTERNS list literal 안에 (거실|침실|...) 같은
        # sample vocabulary 가 직접 박혀 있으면 안 된다.
        import re as _re
        pattern = _re.compile(
            r"PRIMARY_SPACE_PATTERNS[^=]*=\s*\[[^\]]*"
            r"(?:거실|침실|수리영의|안방|욕실|현관|주방)"
        )
        assert not pattern.search(body), (
            "PRIMARY_SPACE_PATTERNS must not embed sample vocabulary as "
            "module-level constant (must build dynamically from spec)"
        )

    def test_alt_spec_synthetic_vocabulary_resolves(self):
        # synthetic spec — L05 vocabulary 0. generic engine 이 spec 만으로
        # primary 를 잡아야 한다.
        alt_spec = bpg.SampleFixtureSpec(
            fixture_id="alt_synth",
            project_id="x", episode_id="y", canon_id="z",
            location_short_id="LXX",
            source_run_path=bpg.DEFAULT_SOURCE_RUN,
            place_groups=[], set_groups=[],
            space_nodes=[
                bpg.SpaceNodeSeed(
                    node_id="hall_a", label="hall_a", node_type="room",
                    set_group_id="sg_x", contained_in=None,
                    connected_to=[], visibility_to=[],
                    active_status="active",
                ),
                bpg.SpaceNodeSeed(
                    node_id="bedroom_b", label="bedroom_b", node_type="room",
                    set_group_id="sg_x", contained_in=None,
                    connected_to=[], visibility_to=[],
                    active_status="active",
                ),
            ],
            space_keyword_whitelist=[
                ("hall_a", "hall_a"),
                ("bedroom_b", "bedroom_b"),
            ],
            zone_boundary_node_ids=set(),
            room_node_ids={"hall_a", "bedroom_b"},
        )
        primary, secondary = bpg.extract_primary_and_secondary_spaces(
            "카메라는 hall_a 안쪽을 바라본다. bedroom_b 도 보인다.",
            spec=alt_spec,
        )
        assert primary == "hall_a", (
            f"alt synthetic spec must resolve primary, got {primary}"
        )
        assert "bedroom_b" in secondary, secondary

    def test_alt_spec_does_not_accept_l05_vocabulary(self):
        # synthetic spec 으로 L05 keyword (거실) 를 넣어도 primary 가 안 잡혀야 한다.
        alt_spec = bpg.SampleFixtureSpec(
            fixture_id="alt_synth",
            project_id="x", episode_id="y", canon_id="z",
            location_short_id="LXX",
            source_run_path=bpg.DEFAULT_SOURCE_RUN,
            place_groups=[], set_groups=[],
            space_nodes=[
                bpg.SpaceNodeSeed(
                    node_id="hall_a", label="hall_a", node_type="room",
                    set_group_id="sg_x", contained_in=None,
                    connected_to=[], visibility_to=[],
                    active_status="active",
                ),
            ],
            space_keyword_whitelist=[("hall_a", "hall_a")],
            zone_boundary_node_ids=set(),
            room_node_ids={"hall_a"},
        )
        primary, secondary = bpg.extract_primary_and_secondary_spaces(
            "카메라는 거실 안쪽을 바라본다.",  # L05 vocabulary
            spec=alt_spec,
        )
        # 거실 은 alt_spec 의 whitelist 에 없음 → primary 결정 안 됨.
        assert primary != "거실", (
            f"alt spec must not resolve L05 vocabulary, got primary={primary}"
        )


# ---------------------------------------------------------------------------
# W2e IMPORTANT 2: ChainBgVariantDecomposition rename
# ---------------------------------------------------------------------------
class TestChainBgVariantDecompositionRename:
    def test_generic_name_class_exists(self):
        assert hasattr(bpg, "ChainBgVariantDecomposition"), (
            "ChainBgVariantDecomposition must exist as generic-named class"
        )

    def test_legacy_alias_kept(self):
        # legacy alias = ChainBgVariantDecomposition 와 동일 클래스.
        assert bpg.L05BDecomposition is bpg.ChainBgVariantDecomposition, (
            "L05BDecomposition must alias to ChainBgVariantDecomposition"
        )


# ---------------------------------------------------------------------------
# W2e IMPORTANT 3: source_bible_filename in spec
# ---------------------------------------------------------------------------
class TestSourceBibleFilenameInSpec:
    def test_spec_has_source_bible_filename(self):
        spec = bpg.build_sample_fixture_l05_spec()
        assert hasattr(spec, "source_bible_filename"), (
            "SampleFixtureSpec must have source_bible_filename field"
        )
        assert spec.source_bible_filename, (
            "source_bible_filename must be non-empty in sample fixture"
        )
