"""D6 controlled vocab — bg_id regex + state_class enum + format helper +
location space_profile validator.

spec: docs/superpowers/specs/2026-05-09-deterministic-bg-id-and-catalog-lineage.md §4.1, §4.3, §4.4
plan: docs/superpowers/plans/2026-05-09-d6-deterministic-bg-id-and-catalog-lineage-implementation.md T1, T2-fix

LLM 은 bg_id 를 만들 수 없음. master_plan post-processing 에서 코드가 부여.
두 정규식이 다른 step (raw vs assigned) 에 적용:
- BG_ID_RE: code-assigned (`L09B01` uppercase) — strict.
- LLM_INTENT_ID_RE: LLM raw intent (fp_id 등 lowercase snake) — strict.

location.space_profile (entity_canon.metadata_json) 의 controlled vocab + 검증:
- LOCATION_SPACE_KEY_VOCAB: allowed_space_keys 의 enum.
- validate_location_space_profile: shape + enum 검증. fail-fast.
"""
from __future__ import annotations

import re
from typing import Any, Dict, FrozenSet


# ──────────────────────────────────────────────────────────────────────
# state_class controlled vocab (§4.4)
# ──────────────────────────────────────────────────────────────────────

STATE_CLASS_ENUM: FrozenSet[str] = frozenset({
    "normal",
    "quiet",
    "busy",
    "busy_exit",
    "ransacked",
    "clean_after",
    "blood_scene",
    "intrusion",
    "arrival",
    "evidence_display",
    "dream_or_vision_state",
})


class StateClassError(ValueError):
    """state_class 가 enum 밖 — fail-fast (nearest-match 안 함)."""


def validate_state_class(state_class: str) -> None:
    """Raise StateClassError if state_class is not in STATE_CLASS_ENUM.

    LLM prompt 에 enum 강제. 위반 시 master_plan retry. nearest-match 시도하면
    drift 의 silent path 가 열리므로 fail-fast.
    """
    if state_class not in STATE_CLASS_ENUM:
        raise StateClassError(
            f"state_class {state_class!r} not in enum (size={len(STATE_CLASS_ENUM)}). "
            f"LLM prompt 의 enum 강제 violation 또는 nearest-match 시도 — fail-fast."
        )


# ──────────────────────────────────────────────────────────────────────
# ID 정규식 (§4.1)
# ──────────────────────────────────────────────────────────────────────

# code-assigned bg_id — uppercase, 2~3 digit zero-pad ('L09B01' / 'L113B07' 등).
# master_plan post-processing 의 `assign_bg_ids` 가 부여한 ID 만 통과.
BG_ID_RE = re.compile(r"^L\d{2,3}B\d{2,3}$")

# LLM raw intent ID (fp_id, sub_location_label_canon, state_label_raw_canon 등).
# 기존 _SAFE_ID_RE 와 동일. lowercase snake 만 허용 — uppercase reject 로 LLM
# 이 코드 포맷 (BG_ID_RE) 흉내 차단.
LLM_INTENT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_]*$")


# ──────────────────────────────────────────────────────────────────────
# format / parse helper (§4.5)
# ──────────────────────────────────────────────────────────────────────

def format_bg_id(loc_num: int, var_num: int) -> str:
    """deterministic bg_id 생성.

    spec §4.5: variant numbering 은 1-based. ``L00B00`` 같은 0-prefix ID 는
    `_max_b_for_loc` history 에서 발생할 수 없는 sentinel — entity_canon 의
    short_id (L01+) 와 monotonic next_b (1+) 모두 ≥1 이므로 0 입력은
    invariant 위반.

    99 까지 2-digit zero-pad, 100+ 자연 확장 (`L100B100`). BG_ID_RE 의 모든
    출력은 정규식 pass — `test_format_bg_id_output_passes_bg_id_regex` 가 invariant.
    """
    if loc_num < 1:
        raise ValueError(
            f"loc_num {loc_num!r} invalid — must be ≥1 (spec §4.5 1-based numbering, "
            f"entity_canon short_id starts at L01)"
        )
    if var_num < 1:
        raise ValueError(
            f"var_num {var_num!r} invalid — must be ≥1 (spec §4.5 1-based numbering, "
            f"_max_b_for_loc + 1 monotonic)"
        )
    return f"L{loc_num:02d}B{var_num:02d}"


_LOC_SHORT_ID_RE = re.compile(r"^L\d{2,3}$")


def parse_loc_num(short_id: str) -> int:
    """`L09` → 9. invariant: short_id matches `^L\\d{2,3}$` AND value ≥1.

    spec §4.5: location short_id 는 entity_extractor 가 1-based 발급. ``L00`` 은
    아무 entity 도 가질 수 없는 sentinel — 입력 시 invariant 위반.
    """
    if not _LOC_SHORT_ID_RE.match(short_id):
        raise ValueError(
            f"location short_id {short_id!r} invalid — must match ^L\\d{{2,3}}$"
        )
    num = int(short_id[1:])
    if num < 1:
        raise ValueError(
            f"location short_id {short_id!r} invalid — loc_num must be ≥1 "
            f"(spec §4.5 1-based, entity_canon never issues L00)"
        )
    return num


# ──────────────────────────────────────────────────────────────────────
# location.space_profile validator (§4.3)
# ──────────────────────────────────────────────────────────────────────

# allowed_space_keys 의 controlled vocab. T2-fix (review iter3 I3) — entity_extractor
# system prompt 와 일치. 신규 vocab 추가 시 prompt 도 동시 갱신 의무.
LOCATION_SPACE_KEY_VOCAB: FrozenSet[str] = frozenset({
    "main",
    "kitchen",
    "rooftop",
    "stairs",
    "yard",
    "exterior",
    "office",
})

LOCATION_SPACE_KIND_VOCAB: FrozenSet[str] = frozenset({"single_space", "multi_space"})


class SpaceProfileError(ValueError):
    """location.space_profile shape 또는 enum 위반 — fail-fast."""


def validate_location_space_profile(metadata_json: Any, *, short_id: str = "") -> Dict[str, Any]:
    """`metadata_json.location.space_profile` shape 검증 + 정규화 dict 반환.

    spec §4.3: D6 의 SOT. master_plan 후처리 의 normalize_space_key 가 본 shape
    에 의존. 누락/invalid 시 모든 bg_id 부여 fail → silent default '{}' 차단.

    invariants:
        - metadata_json: dict
        - metadata_json["location"]["space_profile"]: dict
        - kind: LOCATION_SPACE_KIND_VOCAB
        - allowed_space_keys: non-empty list, subset of LOCATION_SPACE_KEY_VOCAB
        - single_space → allowed_space_keys == ["main"]
        - multi_space → default_space_key in allowed_space_keys

    Returns: 검증 통과한 space_profile dict (호출자가 store/forward 가능).
    """
    where = f"location {short_id!r}" if short_id else "location"
    if not isinstance(metadata_json, dict):
        raise SpaceProfileError(
            f"{where} metadata_json not a dict: {type(metadata_json).__name__}"
        )
    loc_block = metadata_json.get("location")
    if not isinstance(loc_block, dict):
        raise SpaceProfileError(
            f"{where} metadata_json.location missing or not dict: {loc_block!r}"
        )
    profile = loc_block.get("space_profile")
    if not isinstance(profile, dict):
        raise SpaceProfileError(
            f"{where} metadata_json.location.space_profile missing or not dict: {profile!r}"
        )
    kind = profile.get("kind")
    if kind not in LOCATION_SPACE_KIND_VOCAB:
        raise SpaceProfileError(
            f"{where} space_profile.kind {kind!r} not in {sorted(LOCATION_SPACE_KIND_VOCAB)}"
        )
    allowed = profile.get("allowed_space_keys")
    if not isinstance(allowed, list) or not allowed:
        raise SpaceProfileError(
            f"{where} space_profile.allowed_space_keys must be non-empty list, got {allowed!r}"
        )
    # T2-fix2 (review iter4 M2): 원소 타입 먼저 검사 — dict / int 등 비-str 원소
    # 면 enum 비교가 TypeError 또는 silent miss 가능. fail-fast 운영 메시지 명확.
    non_str_items = [k for k in allowed if not isinstance(k, str)]
    if non_str_items:
        raise SpaceProfileError(
            f"{where} space_profile.allowed_space_keys must contain only strings, "
            f"got non-str items: {non_str_items!r} (types: "
            f"{[type(k).__name__ for k in non_str_items]})"
        )
    invalid_keys = [k for k in allowed if k not in LOCATION_SPACE_KEY_VOCAB]
    if invalid_keys:
        raise SpaceProfileError(
            f"{where} space_profile.allowed_space_keys contains keys outside "
            f"controlled vocab: {invalid_keys}. "
            f"vocab={sorted(LOCATION_SPACE_KEY_VOCAB)}"
        )
    if kind == "single_space" and allowed != ["main"]:
        raise SpaceProfileError(
            f"{where} single_space requires allowed_space_keys == ['main'], got {allowed}"
        )
    if kind == "multi_space":
        default_key = profile.get("default_space_key")
        if default_key is None or default_key not in allowed:
            raise SpaceProfileError(
                f"{where} multi_space requires default_space_key in allowed_space_keys, "
                f"got default_space_key={default_key!r}, allowed={allowed}"
            )
    return profile
