"""scene_detail prompt ID 정책 alignment 회귀 가드.

Group 1 #1 (visual_pipeline_contracts_plan.md): scene_detail v12 까지
``detail_schema.json`` 의 t2i_prompt description 이 "복합 ID(C01O02) 금지" 라고
명시했지만 ``system.md`` 다수 위치 + ``scene_reference_service`` 코드는 C##O##
사용을 디폴트로 가정 — LLM 정책이 흔들리는 모순. v13 에서 schema description 을
system policy 에 정렬했다.

본 테스트는 두 가지 회귀 가드:
1. 최신 active scene_detail prompt 가 t2i_prompt description 에 "금지" 같은 stale
   policy 표현을 포함하지 않는다.
2. 최신 schema 와 system.md 가 같은 정책 (C##O## 사용) 을 가리킨다.

시나리오 의존성 0 — placeholder C01O02 만 검사.
"""
from __future__ import annotations

from pathlib import Path

from app.modules.prompt_loader import _list_module_versions, load_schema, load_prompt

# Repo root — backend/tests/prompts/test_*.py → 3 levels up.
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent


# scene_detail 의 t2i_prompt description 에서 stale 정책으로 분류되는 패턴.
# "복합ID(...) 금지" 또는 "bare ID만" 류는 v9~v12 잔재.
_STALE_POLICY_PHRASES = (
    "복합ID(C01O02) 금지",
    "복합 ID(C01O02) 금지",
    "복합ID 금지",
    "bare ID만",
)


def test_latest_scene_detail_schema_no_stale_id_policy():
    """최신 scene_detail detail_schema.json description 에 stale ID 정책 없음."""
    schema = load_schema("scene_detail", "detail_schema")
    t2i_desc = (
        schema["properties"]["t2i_variations"]["items"]
        ["properties"]["t2i_prompt"]["description"]
    )
    for phrase in _STALE_POLICY_PHRASES:
        assert phrase not in t2i_desc, (
            f"stale policy phrase {phrase!r} present in latest scene_detail "
            f"detail_schema.json — system.md 와 정렬 안 됨. v13 (Group 1 #1) 의 "
            f"alignment 가 후속 버전에서 깨졌을 가능성"
        )


def test_latest_scene_detail_schema_uses_composite_id_hint():
    """최신 schema description 이 C##O## composite ID 사용 정책을 명시."""
    schema = load_schema("scene_detail", "detail_schema")
    t2i_desc = (
        schema["properties"]["t2i_variations"]["items"]
        ["properties"]["t2i_prompt"]["description"]
    )
    assert "C01O02" in t2i_desc, (
        "schema description 이 composite ID 형식 placeholder 를 명시해야 한다 "
        "(예: 'C01O02') — system.md 의 C##O## 사용 정책과 정렬"
    )


def test_latest_scene_detail_system_uses_composite_id():
    """system.md 가 C##O## composite ID 사용 정책을 명시 (alignment source)."""
    system_md = load_prompt("scene_detail", "system")
    assert "C##O##" in system_md or "C01O02" in system_md, (
        "scene_detail system.md 에서 composite ID 정책 표현이 사라졌다 — "
        "schema description 과 단방향 alignment 가 의미 없어진다"
    )


def test_scene_detail_versions_monotonic():
    """scene_detail prompt versions 가 numeric sort 로 정상 증가."""
    versions = _list_module_versions("scene_detail")
    assert versions, "scene_detail prompt directory 비어 있음 — 운영 incident"
    # 최신이 v13 이상 — Group 1 #1 alignment 적용된 버전.
    latest_major = int(versions[0].split(".")[0])
    assert latest_major >= 13, (
        f"latest scene_detail major={latest_major} < 13 — Group 1 #1 v13 "
        f"alignment 가 누락된 상태"
    )


def test_scene_detail_system_and_schema_resolve_same_version():
    """Claude review I1: system.md / detail_schema.json 이 같은 version 디렉토리에서 resolve.

    prompt_loader 는 stem 별로 독립 latest version 을 picking 한다. 미래에 누군가
    system.md 만 새 버전 (예: v14) 추가하고 detail_schema.json 을 누락하면, 두 stem 이
    다른 version 에서 resolve 되어 정렬 가드가 무력화된다 (각각의 alignment 검사는
    개별 통과). 따라서 두 stem 이 같은 version pack 에서 picking 되는지 직접 검사.
    """
    from app.modules.prompt_loader import _resolve_stem_in_pack, _list_module_versions

    versions = _list_module_versions("scene_detail")
    assert versions, "scene_detail versions empty"
    latest = versions[0]

    # 두 stem 이 모두 latest pack 안에 존재해야 한다.
    # _resolve_stem_in_pack returns (path, version, versions_list).
    _, system_version, _ = _resolve_stem_in_pack("scene_detail", "system", ".md")
    _, schema_version, _ = _resolve_stem_in_pack("scene_detail", "detail_schema", ".json")
    assert system_version == latest, (
        f"system.md resolved to v{system_version}, latest pack is v{latest} — "
        f"누군가 schema 만 새 version 에 추가하고 system 을 누락한 drift"
    )
    assert schema_version == latest, (
        f"detail_schema.json resolved to v{schema_version}, latest pack is v{latest} — "
        f"누군가 system.md 만 새 version 에 추가하고 schema 를 누락한 drift"
    )


def test_scene_detail_step_check_prompts_accepts_composite_id():
    """Group 1 #1: detail_steps._check_prompts 가 valid composite ID(C##O##)를 통과시킴.

    옛 코드 (line 858-860 stale): `if 'O' in sid: to_remove.add(sid)` — composite
    ID 를 무조건 violation 으로 분류했다. v13 alignment 후 base C## 가 allowed
    이면 composite 도 통과해야 한다.

    시나리오 의존성 0 — placeholder C01/C05/O02/O07 만 사용.
    """
    import re as _re

    # detail_steps.py 의 _check_prompts 와 동일 구조 (회귀 가드 — 실제 함수가 nested
    # closure 라 직접 import 어려우므로 동일 로직을 본 테스트에서 재현해 검증).
    allowed_set = {"C01", "C05", "L02", "P03"}
    _ve_pattern = _re.compile(r'[CLP]\d{2,3}(?:O\d{2,3})?')
    _base_pattern = _re.compile(r'[CLP]\d{2,3}')

    def _extract_base(sid):
        m = _base_pattern.match(sid)
        return m.group(0) if m else sid

    def _check_prompts_v13(res):
        to_remove = set()
        for var in res.get("t2i_variations", []):
            found = set(_ve_pattern.findall(var.get("t2i_prompt", "")))
            for sid in found:
                if _extract_base(sid) not in allowed_set:
                    to_remove.add(sid)
        return to_remove

    # composite ID (C01O02) — base C01 가 allowed → 통과해야 한다.
    res_composite_only = {
        "t2i_variations": [
            {"t2i_prompt": "C01O02 leans against the wall, [L02: dim corridor]"},
        ]
    }
    assert _check_prompts_v13(res_composite_only) == set(), (
        "valid composite ID 가 to_remove 에 들어갔다 — v13 alignment 가 step 코드에서 깨졌다"
    )

    # invalid base — C99 not in allowed.
    res_invalid_base = {
        "t2i_variations": [{"t2i_prompt": "C99O02 stands by the door"}]
    }
    assert _check_prompts_v13(res_invalid_base) == {"C99O02"}, (
        "invalid base ID 가 detect 안 됐다 — allowed_set 검사가 깨졌다"
    )

    # bare + composite 혼합 — 둘 다 allowed 면 통과.
    res_mixed = {
        "t2i_variations": [
            {"t2i_prompt": "C01 in foreground, C05O07 in background, P03 between them"},
        ]
    }
    assert _check_prompts_v13(res_mixed) == set(), (
        "valid bare + composite 혼합이 to_remove 에 들어갔다"
    )

    # 3-digit ID future-proof — Claude review MINOR. P123 (3-digit) 의 base 가 P12
    # 로 잘못 truncate 되면 allowed_set 매칭 실패. _extract_base 가 정확히 P123 추출.
    allowed_3d = {"P123"}
    def _check_prompts_3d(res):
        to_remove = set()
        for var in res.get("t2i_variations", []):
            found = set(_ve_pattern.findall(var.get("t2i_prompt", "")))
            for sid in found:
                m = _base_pattern.match(sid)
                base = m.group(0) if m else sid
                if base not in allowed_3d:
                    to_remove.add(sid)
        return to_remove
    res_3d = {"t2i_variations": [{"t2i_prompt": "P123 sits on the table"}]}
    assert _check_prompts_3d(res_3d) == set(), (
        "3-digit prop ID(P123) 가 잘못 to_remove 됐다 — _extract_base 정확성 깨짐"
    )


def test_check_prompts_rejects_invalid_outlook():
    """Codex review IMPORTANT (Group 1 #1): _check_prompts 가 invalid outlook ID 를 catch.

    composite ID 의 outlook 부분 (O##) 이 scene 의 allowed_outlook_pairs 에 없으면
    to_remove. fail-fast 원칙 — invalid outlook 이 silent 로 downstream 에 흘러가
    wrong/fallback ref 에 연결되는 회귀 차단.

    O00 은 fixed_outfits/scene_outlooks 에 명시된 cid 에 한해 valid (인간형 cid 에
    explicit non-null outlook 있는 경우 C##O00 reject). P##O## / L##O## 는 valid v13
    형식 아님 — base 가 visible 이어도 reject.

    시나리오 의존성 0 — placeholder C01/C05/O02/O07/O99/P03/L02 만 사용.
    """
    import re as _re

    allowed_set = {"C01", "C05", "L02", "P03"}
    _ve_pattern = _re.compile(r'[CLP]\d{2,3}(?:O\d{2,3})?')
    _base_pattern = _re.compile(r'[CLP]\d{2,3}')

    # scene 의 valid pair:
    # - C01: O02 / O07 (human + explicit non-null outlooks)
    # - C05: O00 (Null Outlook — 비인간형 fixed_outfits)
    # 인간형 C01 에 O00 무조건 추가 안 함 — Codex/Claude review IMPORTANT.
    allowed_outlook_pairs = {("C01", "O02"), ("C01", "O07"), ("C05", "O00")}

    def _extract_base(sid):
        m = _base_pattern.match(sid)
        return m.group(0) if m else sid

    def _check_prompts_v13(res):
        to_remove = set()
        for var in res.get("t2i_variations", []):
            found = set(_ve_pattern.findall(var.get("t2i_prompt", "")))
            for sid in found:
                base_sid = _extract_base(sid)
                if base_sid not in allowed_set:
                    to_remove.add(sid)
                    continue
                if sid != base_sid:
                    if not base_sid.startswith("C"):
                        to_remove.add(sid)
                        continue
                    outlook_sid = sid[len(base_sid):]
                    if (base_sid, outlook_sid) not in allowed_outlook_pairs:
                        to_remove.add(sid)
        return to_remove

    # valid composite — 통과.
    res_valid = {"t2i_variations": [{"t2i_prompt": "C01O02 leans against the wall"}]}
    assert _check_prompts_v13(res_valid) == set(), "valid (cid, oid) pair 가 to_remove 됐다"

    # invalid outlook (O99) for valid base C01.
    res_invalid_outlook = {"t2i_variations": [{"t2i_prompt": "C01O99 stands"}]}
    assert _check_prompts_v13(res_invalid_outlook) == {"C01O99"}, (
        "invalid outlook ID 가 to_remove 안 됐다 — silent fallthrough 회귀 위험"
    )

    # cross-character outlook (O02 belongs to C01, but applied to C05).
    res_cross = {"t2i_variations": [{"t2i_prompt": "C05O02 walks"}]}
    assert _check_prompts_v13(res_cross) == {"C05O02"}, (
        "다른 캐릭터의 outlook ID 가 cid 에 잘못 매핑된 case 가 통과됨"
    )

    # 인간형 C01 + O00 — invalid (C01 has explicit O02/O07, no O00 in pairs).
    res_human_o00 = {"t2i_variations": [{"t2i_prompt": "C01O00 silhouette"}]}
    assert _check_prompts_v13(res_human_o00) == {"C01O00"}, (
        "인간형 cid 가 explicit outlook 갖는데 C##O00 통과 — over-broad 회귀"
    )

    # 비인간형 C05 + O00 — valid (C05 has fixed O00).
    res_nonhuman_o00 = {"t2i_variations": [{"t2i_prompt": "C05O00 standing"}]}
    assert _check_prompts_v13(res_nonhuman_o00) == set(), (
        "비인간형 fixed_outfit O00 이 to_remove — over-narrow 회귀"
    )

    # bare C## — outlook 검사 skip (base 만 valid 이면 통과).
    res_bare = {"t2i_variations": [{"t2i_prompt": "C01 stands"}]}
    assert _check_prompts_v13(res_bare) == set(), "bare ID 에 outlook 검사가 잘못 적용됨"

    # P##O## / L##O## composite — valid v13 형식 아님, reject.
    res_prop_composite = {"t2i_variations": [{"t2i_prompt": "P03O01 on the table"}]}
    assert _check_prompts_v13(res_prop_composite) == {"P03O01"}, (
        "prop composite (P##O##) 가 통과 — non-character composite reject 깨짐"
    )
    res_loc_composite = {"t2i_variations": [{"t2i_prompt": "L02O01 background"}]}
    assert _check_prompts_v13(res_loc_composite) == {"L02O01"}, (
        "location composite (L##O##) 가 통과 — non-character composite reject 깨짐"
    )

    # bare P##/L## — 정상 통과.
    res_bare_pl = {"t2i_variations": [{"t2i_prompt": "P03 on table inside L02"}]}
    assert _check_prompts_v13(res_bare_pl) == set(), "bare P##/L## 가 잘못 to_remove"


def test_variant_replace_handles_composite_id():
    """Codex/Claude review BLOCKING (Group 1 #1): variant replace 가 composite ID 도 처리.

    옛 코드 ``\\bC02\\b`` 는 ``C02O01`` 안의 C02 와 매치 안 함 (O 가 \\w → \\b 미충족).
    새 코드는 lookahead ``(?=O\\d{2,3}\\b|\\b)`` 로 composite/bare 모두 매치, outlook 보존.
    """
    import re as _re

    replace_map = {"C02": "C01"}

    def _apply_replace_v13(prompt):
        for old_id, new_id in replace_map.items():
            prompt = _re.sub(
                r'\b' + _re.escape(old_id) + r'(?=O\d{2,3}\b|\b)',
                new_id,
                prompt,
            )
        return prompt

    # composite — outlook 보존되며 base 만 치환.
    assert _apply_replace_v13("C02O01 stands here") == "C01O01 stands here", (
        "composite ID 안의 base 가 치환 안 됐다 — variant replace 회귀"
    )
    # bare — 단순 치환.
    assert _apply_replace_v13("C02 walks") == "C01 walks", "bare ID 치환 깨짐"
    # 4-digit (invalid) C0299 — base C02 다음 9 → \b 미충족, lookahead O 도 아님 → 매치 안 함.
    assert _apply_replace_v13("C0299 noise") == "C0299 noise", (
        "4-digit invalid ID 가 잘못 매치됐다"
    )
    # 혼합.
    assert _apply_replace_v13("C02 met C02O03 inside") == "C01 met C01O03 inside", (
        "bare + composite 혼합 치환 부정확"
    )


def test_prompt_chars_extraction_includes_composite_base():
    """Codex review IMPORTANT (Group 1 #1): prompt_chars 가 composite ID 의 base 도 추출.

    옛 코드 ``(?<![CO\\d])C\\d{2,3}(?!O\\d)`` 는 composite 형식의 base 를 negative
    lookahead 로 제외 → outfit_assignments drop 회귀. v13 후 LLM 이 composite default
    출력하므로 lookahead 제거.
    """
    import re as _re

    pattern_v13 = _re.compile(r'(?<![CO\d])C\d{2,3}')

    # composite-only prompt — base C01 추출.
    assert pattern_v13.findall("C01O02 stands at the wall") == ["C01"], (
        "composite ID 의 base 가 prompt_chars 에서 누락 — outfit_assignments drop 회귀"
    )
    # bare-only — 그대로 추출.
    assert pattern_v13.findall("C01 walks") == ["C01"]
    # 혼합.
    assert pattern_v13.findall("C01 met C05O07") == ["C01", "C05"]
    # 다른 토큰 일부 아닌지 — XC01 형식 (X 가 [CO\d] 아님) 는 매치, OC01 (O 가 lookbehind 차단) 매치 안 함.
    assert pattern_v13.findall("XC01 here") == ["C01"]
    assert pattern_v13.findall("OC01 there") == [], "lookbehind 가 OC## prefix 차단 못 함"


def test_scene_detail_prompt_version_constant_matches_latest():
    """Codex review IMPORTANT: detail_steps.SCENE_DETAIL_PROMPT_VERSION 상수가 latest 와 동기화.

    이 상수는 checkpoint config_hash 계산에 사용된다. prompt 디렉토리만 추가하고
    상수를 안 바꾸면 옛 v12 cp 가 v13 와 hash 동등하게 처리되어 stale 결과가 살아남는다.
    """
    from app.core.steps.detail_steps import SCENE_DETAIL_PROMPT_VERSION

    versions = _list_module_versions("scene_detail")
    latest = versions[0]
    assert SCENE_DETAIL_PROMPT_VERSION == latest, (
        f"SCENE_DETAIL_PROMPT_VERSION={SCENE_DETAIL_PROMPT_VERSION!r} != latest "
        f"prompt version {latest!r} — checkpoint invalidation 깨짐. "
        f"새 prompt 추가 시 detail_steps.py:94 SCENE_DETAIL_PROMPT_VERSION 도 갱신."
    )


def test_scene_detail_schema_version_synced_with_manifest():
    """Codex review IMPORTANT (iter#4): SCENE_DETAIL_SCHEMA_VERSION (detail_steps.py)
    가 STEP_MANIFEST['scene_detail']['schema_version'] 와 동기화.

    step_runner 가 checkpoint 의 schema_version vs manifest 의 schema_version 을
    비교해 stale cp 검출. 둘이 어긋나면 fresh cp 가 stale 로 잘못 분류돼 무한
    재실행 또는 valid 결과 폐기.
    """
    from app.core.steps.detail_steps import SCENE_DETAIL_SCHEMA_VERSION
    from app.core.step_manifest import STEP_MANIFEST

    manifest_schema = STEP_MANIFEST.get("scene_detail", {}).get("schema_version")
    assert manifest_schema == SCENE_DETAIL_SCHEMA_VERSION, (
        f"STEP_MANIFEST['scene_detail']['schema_version']={manifest_schema!r} != "
        f"SCENE_DETAIL_SCHEMA_VERSION={SCENE_DETAIL_SCHEMA_VERSION!r} — "
        f"step_runner cp 검증이 깨진다 (cp/manifest 동기화 누락)"
    )


def test_scene_detail_version_registry_aligned():
    """Codex review IMPORTANT: version_registry _MODULE_INFO scene_detail_composer prompt_dependency 가 latest 와 정렬.

    G4.3 (2026-05-04) carry — Wave 2 outputs:
      - MODULE_VERSIONS["scene_detail_composer"] == "1.18.0" (R2-I7)
      - _MODULE_INFO["scene_detail_composer"]["prompt_dependency"] == "scene_detail/v18"
      - latest scene_detail directory ends with 18.202605041549
    `latest_major` derivation makes this test self-updating; 본 테스트는
    registry 4-way sync 깨짐 (build script 가 _MODULE_INFO 갱신 누락) 만
    catch 하는 방어선.
    `_MODULE_INFO[...]` dict access 형식 유지 (R3R4-B1 carry).
    """
    from app.core.version_registry import _MODULE_INFO, MODULE_VERSIONS

    versions = _list_module_versions("scene_detail")
    latest_major = versions[0].split(".")[0]
    # R3R4-B1 carry — dict access (not attribute access).
    # R5-I2 (G4.3 iter1): silent fallback `_MODULE_INFO.get(..., {})` 와
    # `binding.get(..., "")` 제거 — 키 누락 시 fail-fast.
    assert "scene_detail_composer" in _MODULE_INFO, (
        "_MODULE_INFO['scene_detail_composer'] missing — version_registry stale"
    )
    binding = _MODULE_INFO["scene_detail_composer"]
    assert isinstance(binding, dict), (
        f"_MODULE_INFO['scene_detail_composer'] is {type(binding).__name__} "
        f"(expected dict) — R3R4-B1 contract violation"
    )
    assert "prompt_dependency" in binding, (
        f"_MODULE_INFO['scene_detail_composer'] missing 'prompt_dependency' — "
        f"got {sorted(binding.keys())!r}"
    )
    pdep = binding["prompt_dependency"]
    expected_suffix = f"v{latest_major}"
    assert pdep.endswith(expected_suffix), (
        f"version_registry._MODULE_INFO['scene_detail_composer']['prompt_dependency']="
        f"{pdep!r} != latest v{latest_major} — registry 가 stale"
    )
    # MODULE_VERSIONS 의 scene_detail_composer 도 latest_major 와 정렬 (1.X.0 형태).
    # R6 iter2 M1: silent `.get(..., "")` 제거 — 키 누락 시 fail-fast.
    assert "scene_detail_composer" in MODULE_VERSIONS, (
        "MODULE_VERSIONS['scene_detail_composer'] missing — version_registry stale"
    )
    mv = MODULE_VERSIONS["scene_detail_composer"]
    assert f".{latest_major}." in mv, (
        f"MODULE_VERSIONS['scene_detail_composer']={mv!r} 의 minor 가 prompt "
        f"v{latest_major} 와 정렬 안 됨 — version 동기화 누락"
    )


def test_card_payload_has_subject_reference_policy_field():
    """Area #1 W5 — id_policy.subject_reference_policy 신규 sub-field 가 card
    payload 안 항상 존재 + 폐기 2 sub-field (body_part_focus_rule /
    close_framing_face_phrasing) 부재 검증.

    시나리오 의존성 0 — placeholder C01/O01 만 사용.
    """
    from app.core.steps.render_prompt_card import build_id_policy

    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    assert "subject_reference_policy" in ip, (
        "id_policy 안에 subject_reference_policy 신규 sub-field 가 없다 — "
        "Area #1 W3 render_prompt_card rewrite 가 깨졌거나 build_id_policy "
        "downstream injection 누락"
    )
    assert isinstance(ip["subject_reference_policy"], list), (
        f"subject_reference_policy 는 list 여야 한다 — got "
        f"{type(ip['subject_reference_policy']).__name__}"
    )
    # 폐기 확인 (Area #1 W3 — body_part_focus_rule / close_framing_face_phrasing
    # 두 sub-field 완전 제거).
    assert "body_part_focus_rule" not in ip, (
        "id_policy 안에 body_part_focus_rule 가 잔존 — Area #1 W3 폐기 누락"
    )
    assert "close_framing_face_phrasing" not in ip, (
        "id_policy 안에 close_framing_face_phrasing 가 잔존 — Area #1 W3 폐기 누락"
    )


# ─────────────────────────────────────────────
# reference-necessity Phase3 W3 — scene_detail v34 prompt alignment
# (generic_descriptor_allowed no-ID rule promoted above Rule X-2;
#  Rule X-2 made policy-conditional per subject_reference_policy[])
# ─────────────────────────────────────────────


_V34_SYSTEM_MD = (
    REPO_ROOT
    / "prompts"
    / "_base"
    / "scene_detail"
    / "37.202607081535"
    / "system.md"
)


class TestSceneDetailV34IdPolicyAlignment:
    """Phase3 W3 — v34 prompt 가 generic_descriptor_allowed = no-ID 절대 규칙 +
    policy-conditional Rule X-2 wording 을 포함하는지 검증.

    카드 W2 layer (render_prompt_card.py constraints[]) 와 prompt 본문이 같은
    instructional framing 을 공유하도록 lock 한다 — drift 발생 시 fail-fast.
    """

    def test_v34_directory_exists_and_system_md_nonempty(self):
        """v34 디렉토리 + system.md 존재 + non-empty."""
        assert _V34_SYSTEM_MD.exists(), (
            f"v34 system.md 가 존재하지 않음 ({_V34_SYSTEM_MD}) — "
            "Phase3 W3 prompt 디렉토리 생성 누락"
        )
        content = _V34_SYSTEM_MD.read_text()
        assert content.strip(), "v34 system.md 가 비어 있음"
        # 최소 길이 sanity — v33 본문 size 대비 ~80% 이상
        assert len(content) > 10_000, (
            f"v34 system.md 길이 {len(content)} 가 v33 baseline 보다 너무 짧음 — "
            "복사 누락 의심"
        )

    def test_v34_generic_descriptor_allowed_must_not_use_id_explicit(self):
        """generic_descriptor_allowed subject 는 C##/C##O## 절대 사용 금지 wording 존재.

        (b) 검증 — generic_descriptor_allowed 와 동일 단락 안에 "must not use"
        (대소문자 무관) 가 존재해야 한다.
        """
        content = _V34_SYSTEM_MD.read_text()
        assert "generic_descriptor_allowed" in content, (
            "generic_descriptor_allowed enum 자체가 prompt 본문에 등장 안 함"
        )
        # 단락 단위 (연속된 빈 줄 사이) 검사 — generic_descriptor_allowed 와
        # must not use 가 같은 단락 안에 함께 등장해야 한다.
        paragraphs = content.split("\n\n")
        norm_paras = [p.lower() for p in paragraphs]
        found = any(
            "generic_descriptor_allowed" in p
            and ("must not use" in p or "do not use" in p or "forbidden" in p)
            for p in norm_paras
        )
        assert found, (
            "generic_descriptor_allowed 단락 안에 'must not use' / 'do not use' / "
            "'forbidden' 표현이 없음 — Phase3 W3 절대 규칙 wording 누락"
        )

    def test_v34_rule_x2_mentions_all_three_policies(self):
        """Rule X-2 섹션이 3가지 policy enum 값을 모두 mention.

        (c) 검증 — 본문에 id_and_outlook_required / base_id_required /
        generic_descriptor_allowed 가 모두 등장해야 한다. 단독 등장만 검사 —
        Rule X-2 섹션 한정 sub-window 검사는 후속 강화.
        """
        content = _V34_SYSTEM_MD.read_text()
        for policy in (
            "id_and_outlook_required",
            "base_id_required",
            "generic_descriptor_allowed",
        ):
            assert policy in content, (
                f"policy enum '{policy}' 가 v34 system.md 에 등장 안 함 — "
                "Rule X-2 policy-conditional rewrite 누락"
            )

        # Rule X-2 섹션 안에 3개 enum 모두 등장해야 함 (헤더 부터 다음 ## 헤더 전까지).
        m = content.find("## Rule X-2")
        assert m >= 0, "Rule X-2 섹션 헤더가 v34 system.md 에 없음"
        # 다음 ## 헤더까지 substring
        next_header = content.find("\n## ", m + 1)
        section = content[m: next_header if next_header > 0 else len(content)]
        for policy in (
            "id_and_outlook_required",
            "base_id_required",
            "generic_descriptor_allowed",
        ):
            assert policy in section, (
                f"Rule X-2 섹션 안에 policy enum '{policy}' 누락 — "
                "policy-conditional rewrite 가 Rule X-2 안에서 보이지 않음"
            )

    def test_v34_rule_x2_overrides_clause(self):
        """ID Policy 섹션 안 generic_descriptor_allowed 규칙이 Rule X-2 보다
        우선한다는 wording 존재 (overrides clause).
        """
        content = _V34_SYSTEM_MD.read_text()
        # 한국어 "보다 우선" / "overrides" 둘 중 하나 + Rule X-2 단어 같은 단락 안.
        paragraphs = content.split("\n\n")
        found = any(
            "generic_descriptor_allowed" in p
            and ("Rule X-2" in p or "rule x-2" in p.lower())
            and ("보다 우선" in p or "overrides" in p.lower() or "우선 적용" in p)
            for p in paragraphs
        )
        assert found, (
            "generic_descriptor_allowed 가 Rule X-2 보다 우선한다는 wording 누락 "
            "(예상: '보다 우선' / 'overrides' / '우선 적용')"
        )

    def test_v34_generic_descriptor_example_present(self):
        """generic_descriptor_allowed pass case 예시 (✓) 존재 — Rule X-2 안
        common noun + descriptor only 예시.
        """
        content = _V34_SYSTEM_MD.read_text()
        # ✓ + (Korean / black cat / "a ... in a ..." common noun) 패턴.
        # 본문 어딘가에 generic_descriptor_allowed 예시 line 가 있어야 한다.
        # 간단한 ✓ 마커 + descriptor only 패턴 substring 검사.
        assert "✓" in content and (
            "a Korean middle-aged man" in content
            or "black cat" in content
        ), (
            "Phase3 W3 generic_descriptor_allowed pass case 예시 (common noun + "
            "demographic descriptor only) 가 v34 system.md 안에 없음"
        )

    def test_v34_subject_reference_policy_sot_clause(self):
        """v34 prompt 가 subject_reference_policy[] 를 per-subject id 형식 SOT 로
        designate. global rule (예: Rule X-2) 보다 우선임을 명시.
        """
        content = _V34_SYSTEM_MD.read_text()
        # ID Policy 섹션 안에 SOT designation + per-subject keyword 존재.
        id_policy_idx = content.find("## ID Policy")
        assert id_policy_idx >= 0, "ID Policy 섹션 헤더 부재"
        next_h = content.find("\n## ", id_policy_idx + 1)
        section = content[id_policy_idx: next_h if next_h > 0 else len(content)]
        assert "subject_reference_policy" in section
        assert (
            "SOT" in section
            or "winner" in section
            or "단독으로 결정" in section
            or "단독" in section
        ), (
            "ID Policy 섹션 안 subject_reference_policy SOT designation wording "
            "누락 (예상: 'SOT' / 'winner' / '단독으로 결정')"
        )

    def test_scene_detail_prompt_version_constant_matches_latest_pack(self):
        """(d) — SCENE_DETAIL_PROMPT_VERSION 상수가 **최신 판 디렉토리**와 일치.

        ★값을 박아 두면 판을 올릴 때마다 깨진다(실제로 v38·v39 에서 깨졌다).
        지켜야 할 계약은 "상수가 로더가 고르는 판을 가리키는가"이다 — 어긋나면
        cp config_hash 가 실제 나간 프롬프트와 다른 판을 가리킨다.
        """
        from app.core.steps.detail_steps import SCENE_DETAIL_PROMPT_VERSION

        latest = _list_module_versions("scene_detail")[0]
        assert SCENE_DETAIL_PROMPT_VERSION == latest, (
            f"SCENE_DETAIL_PROMPT_VERSION={SCENE_DETAIL_PROMPT_VERSION!r} != "
            f"최신 판 {latest!r} — 상수 미bump (cp invalidation 어긋남)"
        )
