"""D6 T7 — _d6_post_process 결정론 + handshake boundary.

T4 의 atom-level 결정론 (`tests/core/test_bg_catalog.py:194/246/350/...`) 위에
step composition (`BackgroundMasterPlanStep._d6_post_process` 의 4-tuple 결합 +
plan.backgrounds[].bg_id handshake) 회귀 차단.

unit-style pivot (T6 와 같은 결정) — d6_episode_factory fixture 의존 X.
DB / cp loader 두 instance method (`_load_location_profiles` /
`_load_prev_background_catalog`) 만 직접 attribute 할당으로 우회 (T5 의
`step._load_prev_checkpoint = _load_cp` 패턴 동일).

spec: docs/superpowers/specs/2026-05-09-deterministic-bg-id-and-catalog-lineage.md AC-1
plan: T7 (unit-style pivot)
"""
from __future__ import annotations

from typing import Any, Dict, List


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


def _make_step(prev_catalog: Dict[str, Dict[str, Any]] = None):
    """BackgroundMasterPlanStep instance + DB/cp 의존 우회.

    `_load_location_profiles` / `_load_prev_background_catalog` 두 method 를
    instance attribute 로 shadow (class method 호출 차단).
    """
    from app.core.steps.background_master_plan_step import BackgroundMasterPlanStep
    step = BackgroundMasterPlanStep.__new__(BackgroundMasterPlanStep)
    step.project_id = "p-d6-t7"
    step.episode_id = "ep1"
    step.step_id = "background_master_plan"
    step.project_config = {}
    step._load_location_profiles = lambda: dict(_PROFILES)
    step._load_prev_background_catalog = lambda: dict(prev_catalog or {})
    return step


def _make_ordered_plans(intents: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
    """단일 group 'g1' 에 raw intents 를 backgrounds 로 묶음."""
    return {"g1": {"status": "ok", "plan": {
        "floor_plans": [], "backgrounds": [dict(i) for i in intents],
    }}}


def _intent(**kw):
    base = {
        "loc_id": "L09", "space_key_hint": "main", "time_phase": "dusk",
        "state_class": "busy_exit", "applies_to_shots": ["S8_Shot4"],
        "sub_location_label": "store sales floor", "state_label_raw": "raw",
        "depends_on_fp": ["fp_supermarket"], "depends_on_bg": [],
        # W21B-w2 에서 surface_role 이 semantic key 의 필수 필드가 됐다.
        # 빠지면 bg_catalog 가 SemanticKeyError 로 세운다(fail-fast 의도).
        "surface_role": "interior_main",
    }
    base.update(kw)
    return base


def test_d6_post_process_5_reruns_stable_hashes():
    """AC-1 — 같은 ordered_plans 5 회 → catalog/shot_bg_map/2 hash 모두 안정."""
    intents = [
        _intent(state_class="busy_exit"),
        _intent(state_class="ransacked", applies_to_shots=["S15_Shot2"]),
    ]

    step = _make_step()
    cat_1, map_1, ch_1, bh_1 = step._d6_post_process(_make_ordered_plans(intents))

    # prev_catalog 를 첫 run 의 catalog 로 고정. shot grouping/순서 그대로.
    for i in range(4):
        step_n = _make_step(prev_catalog=cat_1)
        cat_n, map_n, ch_n, bh_n = step_n._d6_post_process(
            _make_ordered_plans(intents),
        )
        assert ch_n == ch_1, f"run {i+2}: catalog hash drift {ch_1!r} → {ch_n!r}"
        assert bh_n == bh_1, f"run {i+2}: binding hash drift {bh_1!r} → {bh_n!r}"
        assert set(cat_n.keys()) == set(cat_1.keys())
        assert map_n == map_1


def test_d6_post_process_intent_order_shuffle_stable_hashes():
    """I8 — per-group backgrounds[] 순서가 달라도 같은 set 이면 catalog/binding 안정."""
    intent_a = _intent(loc_id="L09", time_phase="dusk", state_class="busy_exit",
                       applies_to_shots=["S8_Shot4"])
    intent_b = _intent(loc_id="L09", time_phase="dusk", state_class="ransacked",
                       applies_to_shots=["S15_Shot2"])

    step1 = _make_step()
    cat_ab, map_ab, ch_ab, bh_ab = step1._d6_post_process(
        _make_ordered_plans([intent_a, intent_b]),
    )
    step2 = _make_step()
    cat_ba, map_ba, ch_ba, bh_ba = step2._d6_post_process(
        _make_ordered_plans([intent_b, intent_a]),
    )

    assert ch_ab == ch_ba, f"I8 sort drift — catalog hash {ch_ab!r} vs {ch_ba!r}"
    assert bh_ab == bh_ba, f"I8 sort drift — binding hash {bh_ab!r} vs {bh_ba!r}"
    assert set(cat_ab.keys()) == set(cat_ba.keys())
    sem_ab = {v["semantic_key"]: bid for bid, v in cat_ab.items()}
    sem_ba = {v["semantic_key"]: bid for bid, v in cat_ba.items()}
    assert sem_ab == sem_ba, "semantic_key → bg_id 매핑 순서 의존"
    assert map_ab == map_ba, f"shot_bg_map drift: {map_ab!r} vs {map_ba!r}"


def test_d6_post_process_handshake_updates_plan_backgrounds_bg_id():
    """boundary — _d6_post_process 가 plan.backgrounds[].bg_id slot 을 L##B## 로 갱신.

    catalog helper 는 맞는데 step handshake 가 깨지면 downstream reader
    (background_prompt / background_render) 가 옛 bg_* 또는 빈 bg_id 받아 cascade.
    """
    import re

    intents = [_intent(state_class="busy_exit"),
               _intent(state_class="ransacked", applies_to_shots=["S15_Shot2"])]
    plans = _make_ordered_plans(intents)

    step = _make_step()
    catalog, _map, _ch, _bh = step._d6_post_process(plans)

    bg_id_re = re.compile(r"^L\d{2,3}B\d{2,3}$")
    bg_ids_in_plan = [
        b.get("bg_id") for b in plans["g1"]["plan"]["backgrounds"]
    ]
    assert all(bg_id_re.match(bid or "") for bid in bg_ids_in_plan), (
        f"handshake 깨짐 — plan.backgrounds[].bg_id 가 L##B## 형식 아님: "
        f"{bg_ids_in_plan!r}"
    )
    catalog_ids = set(catalog.keys())
    assert set(bg_ids_in_plan) == catalog_ids, (
        f"handshake drift — plan ids {sorted(bg_ids_in_plan)} vs "
        f"catalog {sorted(catalog_ids)}"
    )
