"""D6 — bg_id catalog: semantic_key dedup + assign_bg_ids + hash 분리.

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 부여.
- 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 제외).
  applies_to_shots / sub_location_label / state_label_raw 변동에도 안정.
- shot_binding_hash: shot_background_map 전용. shot 추가/제거 만으로 변동.
"""
from __future__ import annotations

import hashlib
import json
from typing import Any, Dict, List

from app.core.bg_state_vocab import (
    BG_ID_RE,
    format_bg_id,
    parse_loc_num,
    validate_state_class,
)


class SemanticKeyError(ValueError):
    """semantic_key 구성 실패 — space_key normalize / state_class enum / profile 위반."""


class FpLinkMismatchError(SemanticKeyError):
    """T4-fix3: background.depends_on_fp[*] 가 가리키는 fp 의 declared
    (loc_id, space_key_hint) 가 background 의 (loc_id, space_key_hint) 와
    불일치 — fp link mismatch (raw validator fail-fast → LLM retry)."""


class ShotBindingError(ValueError):
    """shot_background_map 구성 실패 — 같은 shot 이 여러 bg 에 매핑 (D6 N:1 위반)."""


# ──────────────────────────────────────────────────────────────────────
# normalize_space_key (§4.3)
# ──────────────────────────────────────────────────────────────────────


def normalize_space_key(loc_id: str, hint: str, profile: Dict[str, Any]) -> str:
    """entity_canon.metadata_json.location.space_profile 을 SOT 로 hint normalize.

    single_space → 항상 `main` (hint 무시).
    multi_space → allowed_space_keys 안이면 그대로, 밖이면 raise.
    """
    if not isinstance(profile, dict):
        raise SemanticKeyError(
            f"location {loc_id!r} profile not dict: type={type(profile).__name__}"
        )
    kind = profile.get("kind")
    if kind == "single_space":
        return "main"
    if kind == "multi_space":
        allowed = profile.get("allowed_space_keys") or []
        if hint in allowed:
            return hint
        raise SemanticKeyError(
            f"space_key hint {hint!r} not in allowed_space_keys {sorted(allowed)} "
            f"for {loc_id} (multi_space). controlled vocab strict — fail-fast."
        )
    raise SemanticKeyError(
        f"location {loc_id!r} unknown space_profile.kind={kind!r}"
    )


# ──────────────────────────────────────────────────────────────────────
# compute_semantic_key (§4.2)
# ──────────────────────────────────────────────────────────────────────


def compute_semantic_key(
    loc_id: str, space_key: str, time_phase: str, state_class: str,
) -> str:
    """semantic_key = `loc_id|space_key|time_phase|state_class`.

    metadata-only fields (applies_to_shots / sub_location_label /
    state_label_raw) 는 절대 들어가지 않음 — drift 회피 (P2/P3/P4).

    state_class 는 STATE_CLASS_ENUM 강제 (validate_state_class).
    """
    validate_state_class(state_class)
    return f"{loc_id}|{space_key}|{time_phase}|{state_class}"


# ──────────────────────────────────────────────────────────────────────
# _max_b_for_loc (B7 monotonic)
# ──────────────────────────────────────────────────────────────────────


def _max_b_for_loc(prev_catalog: Dict[str, Dict[str, Any]], loc_id: str) -> int:
    """주어진 loc_id 안에서 prev_catalog 의 최대 var_num. 없으면 0.

    B7 monotonic next_b: history 기반. 삭제된 entry 도 max 계산에 포함되어
    deletion 후에도 새 ID 가 reset 안 함. 결과: AC-1 의 5 reruns same input
    stable + 다른 input 들어간 후 다시 같은 input 으로 돌아가도 ID 보존.
    """
    max_b = 0
    for entry in prev_catalog.values():
        if entry.get("loc_id") != loc_id:
            continue
        bg_id = entry.get("bg_id", "")
        if not BG_ID_RE.match(bg_id):
            continue
        # parse: L{NN}B{NN} 또는 L{NNN}B{NNN}
        b_part = bg_id.split("B", 1)[1]
        try:
            n = int(b_part)
        except ValueError:
            continue
        if n > max_b:
            max_b = n
    return max_b


# ──────────────────────────────────────────────────────────────────────
# assign_bg_ids (§4.5)
# ──────────────────────────────────────────────────────────────────────


# Intent dict required fields. metadata-only fields are optional.
_INTENT_REQUIRED_FIELDS = (
    "loc_id", "space_key_hint", "time_phase", "state_class",
    "surface_role",
)


def assign_bg_ids(
    prev_catalog: Dict[str, Dict[str, Any]],
    new_intents: List[Dict[str, Any]],
    location_profiles: Dict[str, Dict[str, Any]],
) -> Dict[str, Dict[str, Any]]:
    """raw intent → deterministic bg_id catalog.

    flow:
        1. I8: intents 를 (loc_id, time_phase, state_class) 정렬 후 처리.
           LLM 출력 순서 무관 결정론.
        2. 각 intent 의 space_key 를 location_profile 로 normalize.
        3. semantic_key 계산 (loc_id|space_key|time_phase|state_class).
        4. prev_catalog 안에 같은 semantic_key 있으면 그 bg_id 재사용.
        5. 새 semantic_key 면 `_max_b_for_loc(prev_catalog 또는 catalog) + 1`
           으로 monotonic next_b 부여 (B7).

    Returns:
        catalog: {bg_id: entry_dict}. entry 는 render-relevant + metadata 모두 포함.
    """
    # Step 1: I8 — deterministic sort
    sorted_intents = sorted(
        new_intents,
        key=lambda i: (
            i.get("loc_id", ""),
            i.get("time_phase", ""),
            i.get("state_class", ""),
        ),
    )

    # T4-fix B3: prev catalog + same-run 누적 sem_key → bg_id 역인덱스. 새 intent
    # 처리 중 같은 sem_key 두 번이면 catalog dedup + applies_to_shots merge.
    sem_key_to_bgid: Dict[str, str] = {
        entry.get("semantic_key", ""): bg_id
        for bg_id, entry in prev_catalog.items()
        if entry.get("semantic_key")
    }

    # 결과 catalog. 새 entry 는 monotonic next_b 부여 — base = prev_catalog 의 max.
    # B7: prev_catalog 가 source — 같은 run 안에서 새로 부여된 ID 도 prev 처럼
    # 다음 next_b 계산에 포함시켜 진행 누적 monotonic 보장.
    catalog: Dict[str, Dict[str, Any]] = {}
    rolling_history: Dict[str, Dict[str, Any]] = dict(prev_catalog)

    for intent in sorted_intents:
        # 필수 field 검증
        missing = [f for f in _INTENT_REQUIRED_FIELDS if intent.get(f) in (None, "")]
        if missing:
            raise SemanticKeyError(
                f"intent missing required fields {missing}: {intent}"
            )

        loc_id = intent["loc_id"]
        if loc_id not in location_profiles:
            raise SemanticKeyError(
                f"intent loc_id {loc_id!r} has no location_profile entry "
                f"(available: {sorted(location_profiles.keys())})"
            )
        profile = location_profiles[loc_id]
        space_key = normalize_space_key(loc_id, intent["space_key_hint"], profile)
        time_phase = intent["time_phase"]
        state_class = intent["state_class"]
        sem_key = compute_semantic_key(loc_id, space_key, time_phase, state_class)

        intent_shots = list(intent.get("applies_to_shots") or [])
        intent_dep_fp = list(intent.get("depends_on_fp") or [])
        intent_dep_bg = list(intent.get("depends_on_bg") or [])
        intent_surface_role = intent.get("surface_role", "")

        # T4-fix B3 + T4-fix2 B2: same-run dedup + merge. sem_key_to_bgid 가 prev +
        # 같은 run 누적 모두 인덱싱 — 같은 sem_key 두 번이면 catalog 재사용 +
        # applies_to_shots merge.
        # render-relevant fields (depends_on_fp / depends_on_bg) 는 hash 입력
        # (compute_bg_catalog_hash). 같은 sem_key 의 다른 deps 는 SOT 결손 →
        # SemanticKeyError fail-fast (silent first-wins 차단).
        # metadata-only (sub_location_label / state_label_raw) 는 first 보존
        # (drift detection 가능, hash 영향 0).
        if sem_key in sem_key_to_bgid:
            bg_id = sem_key_to_bgid[sem_key]
            existing = catalog.get(bg_id)
            if existing is not None:
                # render-relevant deps 정합 의무
                if existing.get("depends_on_fp", []) != intent_dep_fp:
                    raise SemanticKeyError(
                        f"same sem_key {sem_key!r} has different render-relevant "
                        f"depends_on_fp: {existing.get('depends_on_fp')!r} vs "
                        f"{intent_dep_fp!r}. silent first-wins 차단 — fail-fast."
                    )
                if existing.get("depends_on_bg", []) != intent_dep_bg:
                    raise SemanticKeyError(
                        f"same sem_key {sem_key!r} has different render-relevant "
                        f"depends_on_bg: {existing.get('depends_on_bg')!r} vs "
                        f"{intent_dep_bg!r}. silent first-wins 차단 — fail-fast."
                    )
                if existing.get("surface_role", "") != intent_surface_role:
                    raise SemanticKeyError(
                        f"same sem_key {sem_key!r} has different render-relevant "
                        f"surface_role: {existing.get('surface_role')!r} vs "
                        f"{intent_surface_role!r}. silent first-wins 차단 — fail-fast."
                    )
                # 같은 run 안 중복 — applies_to_shots merge (order 보존 + dedup).
                merged_shots = list(existing.get("applies_to_shots") or [])
                for shot in intent_shots:
                    if shot not in merged_shots:
                        merged_shots.append(shot)
                existing["applies_to_shots"] = merged_shots
                continue
            # prev_catalog 에서 재사용 — 새 entry 시작 (catalog 에 아직 없음).
        else:
            loc_num = parse_loc_num(loc_id)
            next_b = _max_b_for_loc(rolling_history, loc_id) + 1
            bg_id = format_bg_id(loc_num, next_b)
            sem_key_to_bgid[sem_key] = bg_id  # 같은 run 누적 등록

        entry = {
            "bg_id": bg_id,
            "loc_id": loc_id,
            "space_key": space_key,
            "time_phase": time_phase,
            "state_class": state_class,
            "surface_role": intent_surface_role,
            "semantic_key": sem_key,
            "depends_on_fp": intent_dep_fp,
            "depends_on_bg": intent_dep_bg,
            # metadata-only — semantic_key 에 안 들어감, hash 에 안 들어감.
            "applies_to_shots": intent_shots,
            "sub_location_label": intent.get("sub_location_label", ""),
            "state_label_raw": intent.get("state_label_raw", ""),
        }
        catalog[bg_id] = entry
        rolling_history[bg_id] = entry  # B7 누적 monotonic

    return catalog


# ──────────────────────────────────────────────────────────────────────
# compute_bg_catalog_hash (§4.6)
# ──────────────────────────────────────────────────────────────────────


# render-relevant fields — hash 입력. 변동 시 background_render 재실행 의미 있음.
# applies_to_shots / sub_location_label / state_label_raw 는 metadata-only — 제외.
_RENDER_RELEVANT_FIELDS = (
    "bg_id", "loc_id", "space_key", "time_phase", "state_class",
    "surface_role", "semantic_key", "depends_on_bg", "depends_on_fp",
)


def compute_bg_catalog_hash(catalog: Dict[str, Dict[str, Any]]) -> str:
    """catalog 의 render-relevant field 만 deterministic hash.

    metadata-only field (applies_to_shots / sub_location_label /
    state_label_raw) 변동에도 hash 안정 — chain_bg / floor_plan_render 가
    재실행될 필요 없는 변경 (binding-only 변동) 을 isolate.
    """
    normalized = {}
    for bg_id in sorted(catalog.keys()):
        entry = catalog[bg_id]
        normalized[bg_id] = {
            k: entry.get(k) for k in _RENDER_RELEVANT_FIELDS
        }
    blob = json.dumps(normalized, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]


def compute_shot_binding_hash(shot_background_map: Dict[str, str]) -> str:
    """shot_background_map (shot_id → bg_id) deterministic hash.

    shot 추가/제거 / bg_id 매핑 변경 만으로 변동. catalog 변경 (state_class /
    space_key 등) 과 분리된 축이라 consumer (scene_detail) 의 stamp 가
    binding 변경 vs catalog 변경을 별도 추적 가능.
    """
    blob = json.dumps(shot_background_map, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]


# ──────────────────────────────────────────────────────────────────────
# build_shot_background_map (§4.5)
# ──────────────────────────────────────────────────────────────────────


def build_shot_background_map(
    catalog: Dict[str, Dict[str, Any]]
) -> Dict[str, str]:
    """catalog 의 applies_to_shots 를 평탄화 — `shot_id → bg_id`.

    T4-fix I1: 같은 shot 이 여러 bg 에 매핑되면 ShotBindingError raise (D6 N:1 강제).
    이전 first-wins silent 는 SOT 결손 absorb. assign_bg_ids 의 same-run dedup
    이 정상 동작하면 conflict 발생 안 해야 함.

    shot 이 어떤 bg 에도 매핑 안 된 경우는 결과 dict 에서 누락 (consumer 가 catch).
    """
    result: Dict[str, str] = {}
    for bg_id in sorted(catalog.keys()):
        entry = catalog[bg_id]
        for shot_id in entry.get("applies_to_shots") or []:
            if shot_id in result and result[shot_id] != bg_id:
                raise ShotBindingError(
                    f"shot {shot_id!r} mapped to multiple bg_ids: "
                    f"{result[shot_id]!r} and {bg_id!r}. "
                    f"D6 N:1 invariant 위반 — assign_bg_ids same-run dedup 미동작 또는 "
                    f"input intents 가 같은 shot 을 다른 semantic_key 에 등록."
                )
            result[shot_id] = bg_id
    return result
