"""D6 T1 — bg_state_vocab — STATE_CLASS_ENUM strict + ID regex + format helper.

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

LLM 은 ID 를 만들 수 없음. code 가 부여. 두 정규식이 다른 step (raw vs assigned) 에 적용.
"""
from __future__ import annotations

import pytest


# ── STATE_CLASS_ENUM ──

def test_state_class_enum_includes_required_set():
    """spec §4.4 의 11 enum value 모두 포함."""
    from app.core.bg_state_vocab import STATE_CLASS_ENUM
    expected = {
        "normal", "quiet", "busy", "busy_exit", "ransacked",
        "clean_after", "blood_scene", "intrusion", "arrival",
        "evidence_display", "dream_or_vision_state",
    }
    assert expected.issubset(STATE_CLASS_ENUM), (
        f"missing enum values: {expected - set(STATE_CLASS_ENUM)}"
    )


def test_state_class_enum_size_exactly_11():
    """현재 spec §4.4 가 정확히 11 개. 추가 시 본 test 도 같이 갱신해야 함."""
    from app.core.bg_state_vocab import STATE_CLASS_ENUM
    assert len(STATE_CLASS_ENUM) == 11, (
        f"STATE_CLASS_ENUM size drift: {len(STATE_CLASS_ENUM)} != 11. "
        f"spec §4.4 update 의무 — drift 없이 enum 만 추가하면 master_plan prompt sync 깨짐."
    )


def test_validate_state_class_accepts_enum_member():
    from app.core.bg_state_vocab import validate_state_class
    validate_state_class("normal")  # OK — no raise
    validate_state_class("dream_or_vision_state")  # OK


def test_validate_state_class_rejects_unknown():
    from app.core.bg_state_vocab import validate_state_class, StateClassError
    with pytest.raises(StateClassError, match="not in enum"):
        validate_state_class("unknown")


def test_validate_state_class_rejects_freeform_combination():
    """LLM 자유 텍스트 (예: 'dusk_normal') reject — nearest-match 시도 안 함."""
    from app.core.bg_state_vocab import validate_state_class, StateClassError
    with pytest.raises(StateClassError):
        validate_state_class("dusk_normal")
    with pytest.raises(StateClassError):
        validate_state_class("normal_quiet")


# ── BG_ID_RE ──

def test_bg_id_regex_accepts_2_digit():
    from app.core.bg_state_vocab import BG_ID_RE
    assert BG_ID_RE.match("L09B01")
    assert BG_ID_RE.match("L01B01")
    assert BG_ID_RE.match("L99B99")


def test_bg_id_regex_accepts_3_digit_natural_extension():
    from app.core.bg_state_vocab import BG_ID_RE
    assert BG_ID_RE.match("L113B07")
    assert BG_ID_RE.match("L09B100")
    assert BG_ID_RE.match("L100B100")


def test_bg_id_regex_rejects_legacy_freeform():
    from app.core.bg_state_vocab import BG_ID_RE
    assert not BG_ID_RE.match("bg_store_sales_floor_dusk")
    assert not BG_ID_RE.match("bg_001")


def test_bg_id_regex_rejects_1_digit_or_lowercase():
    from app.core.bg_state_vocab import BG_ID_RE
    assert not BG_ID_RE.match("L9B01"), "1-digit loc num must reject"
    assert not BG_ID_RE.match("L09B1"), "1-digit var num must reject"
    assert not BG_ID_RE.match("l09b01"), "lowercase must reject"
    assert not BG_ID_RE.match("L09b01"), "mixed case must reject"


def test_bg_id_regex_rejects_extra_chars():
    from app.core.bg_state_vocab import BG_ID_RE
    assert not BG_ID_RE.match("L09B01_extra")
    assert not BG_ID_RE.match("AL09B01")
    assert not BG_ID_RE.match("L09 B01")


# ── LLM_INTENT_ID_RE ──

def test_llm_intent_id_regex_accepts_lowercase_snake():
    from app.core.bg_state_vocab import LLM_INTENT_ID_RE
    assert LLM_INTENT_ID_RE.match("fp_supermarket_sales_floor")
    assert LLM_INTENT_ID_RE.match("store_sales_floor")
    assert LLM_INTENT_ID_RE.match("a")
    assert LLM_INTENT_ID_RE.match("a1")


def test_llm_intent_id_regex_rejects_uppercase_or_spaces():
    from app.core.bg_state_vocab import LLM_INTENT_ID_RE
    assert not LLM_INTENT_ID_RE.match("L09B01"), "uppercase reject (LLM 이 코드 ID 흉내 차단)"
    assert not LLM_INTENT_ID_RE.match("Store Sales Floor")
    assert not LLM_INTENT_ID_RE.match("_underscore_first"), "first char must be [a-z0-9]"


# ── format_bg_id ──

def test_format_bg_id_zero_pads_2_digits():
    from app.core.bg_state_vocab import format_bg_id
    assert format_bg_id(9, 1) == "L09B01"
    assert format_bg_id(4, 1) == "L04B01"
    assert format_bg_id(1, 1) == "L01B01"


def test_format_bg_id_3_digit_natural_extension():
    from app.core.bg_state_vocab import format_bg_id
    assert format_bg_id(113, 7) == "L113B07"
    assert format_bg_id(9, 100) == "L09B100"
    assert format_bg_id(100, 100) == "L100B100"


def test_format_bg_id_output_passes_bg_id_regex():
    """format_bg_id 의 모든 출력은 BG_ID_RE pass — invariant."""
    from app.core.bg_state_vocab import format_bg_id, BG_ID_RE
    for loc_num in (1, 9, 99, 100, 999):
        for var_num in (1, 9, 99, 100, 999):
            bg_id = format_bg_id(loc_num, var_num)
            assert BG_ID_RE.match(bg_id), (
                f"format_bg_id({loc_num},{var_num})={bg_id!r} fails BG_ID_RE"
            )


def test_format_bg_id_rejects_zero_or_negative():
    """1-based numbering invariant — 0 / 음수 reject (spec §4.5)."""
    from app.core.bg_state_vocab import format_bg_id

    with pytest.raises(ValueError, match="loc_num"):
        format_bg_id(0, 1)
    with pytest.raises(ValueError, match="loc_num"):
        format_bg_id(-1, 1)
    with pytest.raises(ValueError, match="var_num"):
        format_bg_id(1, 0)
    with pytest.raises(ValueError, match="var_num"):
        format_bg_id(1, -1)
    # 둘 다 0
    with pytest.raises(ValueError):
        format_bg_id(0, 0)


# ── parse_loc_num ──

def test_parse_loc_num_accepts_2_3_digit():
    from app.core.bg_state_vocab import parse_loc_num
    assert parse_loc_num("L09") == 9
    assert parse_loc_num("L113") == 113


def test_parse_loc_num_rejects_invalid():
    from app.core.bg_state_vocab import parse_loc_num
    with pytest.raises(ValueError, match="invalid"):
        parse_loc_num("C09")
    with pytest.raises(ValueError, match="invalid"):
        parse_loc_num("L9")
    with pytest.raises(ValueError, match="invalid"):
        parse_loc_num("l09")


def test_parse_loc_num_rejects_l00_zero():
    """1-based — `L00` 은 entity_canon 이 발급한 적 없는 sentinel reject."""
    from app.core.bg_state_vocab import parse_loc_num
    with pytest.raises(ValueError, match="≥1|loc_num"):
        parse_loc_num("L00")
    with pytest.raises(ValueError, match="≥1|loc_num"):
        parse_loc_num("L000")


# ──────────────────────────────────────────────────────────────────────
# validate_location_space_profile (T2-fix + T2-fix2)
# ──────────────────────────────────────────────────────────────────────


def test_validate_space_profile_accepts_single_space():
    from app.core.bg_state_vocab import validate_location_space_profile
    md = {"location": {"space_profile": {"kind": "single_space",
                                          "allowed_space_keys": ["main"]}}}
    profile = validate_location_space_profile(md, short_id="L09")
    assert profile["kind"] == "single_space"


def test_validate_space_profile_accepts_multi_space_with_default():
    from app.core.bg_state_vocab import validate_location_space_profile
    md = {"location": {"space_profile": {
        "kind": "multi_space",
        "allowed_space_keys": ["main", "kitchen", "rooftop"],
        "default_space_key": "main",
    }}}
    profile = validate_location_space_profile(md, short_id="L05")
    assert profile["default_space_key"] == "main"


def test_validate_space_profile_rejects_invalid_kind():
    from app.core.bg_state_vocab import validate_location_space_profile, SpaceProfileError
    with pytest.raises(SpaceProfileError, match="kind"):
        validate_location_space_profile({"location": {"space_profile": {
            "kind": "freeform_unknown",
            "allowed_space_keys": ["main"],
        }}})


def test_validate_space_profile_rejects_outside_vocab():
    from app.core.bg_state_vocab import validate_location_space_profile, SpaceProfileError
    with pytest.raises(SpaceProfileError, match="vocab|controlled"):
        validate_location_space_profile({"location": {"space_profile": {
            "kind": "multi_space",
            "allowed_space_keys": ["main", "garage"],  # garage 은 vocab 밖
            "default_space_key": "main",
        }}})


def test_validate_space_profile_single_space_strict_main_only():
    from app.core.bg_state_vocab import validate_location_space_profile, SpaceProfileError
    with pytest.raises(SpaceProfileError, match="single_space.*main"):
        validate_location_space_profile({"location": {"space_profile": {
            "kind": "single_space",
            "allowed_space_keys": ["main", "kitchen"],
        }}})


def test_validate_space_profile_multi_space_requires_default_in_allowed():
    from app.core.bg_state_vocab import validate_location_space_profile, SpaceProfileError
    with pytest.raises(SpaceProfileError, match="default_space_key"):
        validate_location_space_profile({"location": {"space_profile": {
            "kind": "multi_space",
            "allowed_space_keys": ["main", "kitchen"],
            "default_space_key": "rooftop",  # allowed 밖
        }}})


def test_validate_space_profile_rejects_non_string_in_allowed_keys():
    """T2-fix2 (review iter4 M2): allowed_space_keys 내부 비-str 원소 fail-fast.

    이전: dict / int 등 비-str 원소면 enum 비교가 silent miss 또는 TypeError 발생.
    이제: SpaceProfileError 로 명확 메시지.
    """
    from app.core.bg_state_vocab import validate_location_space_profile, SpaceProfileError
    with pytest.raises(SpaceProfileError, match="strings"):
        validate_location_space_profile({"location": {"space_profile": {
            "kind": "multi_space",
            "allowed_space_keys": ["main", {"nested": "dict"}, 42],
            "default_space_key": "main",
        }}})


def test_validate_space_profile_rejects_missing_location_block():
    from app.core.bg_state_vocab import validate_location_space_profile, SpaceProfileError
    with pytest.raises(SpaceProfileError, match="location"):
        validate_location_space_profile({}, short_id="L01")
    with pytest.raises(SpaceProfileError, match="location"):
        validate_location_space_profile({"location": None}, short_id="L01")


def test_validate_space_profile_rejects_missing_space_profile():
    from app.core.bg_state_vocab import validate_location_space_profile, SpaceProfileError
    with pytest.raises(SpaceProfileError, match="space_profile"):
        validate_location_space_profile({"location": {}}, short_id="L01")
