"""C8 — Area #7 잔여 sub-area marathon (Residual Hygiene) regression tests.

fix-critical-1 Tier γ #9. 닫힌 enumeration·시나리오 leak → generic semantic
principle 추상화. 신규 schema / producer / mechanism 0.

파일 read + 문자열 검사 + sha256 only — no live LLM, NO VLM.
residue needle 은 split-string 상수 (test 파일 / 문서 self-hit 방지).

Gate map (spec §7 / plan §G): wave 별 append.
"""
from __future__ import annotations

import hashlib
from pathlib import Path

from app.core.version_registry import MODULE_VERSIONS, _MODULE_INFO
from app.modules.prompt_loader import (
    _list_module_versions,
    _resolve_stem_in_pack,
    get_effective_source,
    load_prompt,
)

_PROMPTS = Path(__file__).resolve().parents[3] / "prompts" / "_base"


def _latest_dir(module: str) -> Path:
    """module 의 numeric-latest prompt-pack 디렉토리."""
    return _PROMPTS / module / _list_module_versions(module)[0]


def _sha256(p: Path) -> str:
    return hashlib.sha256(p.read_bytes()).hexdigest()


# ──────────────── W1a-1 — entity_all + entity_filter (Site 1, 3) ────────────────

# 닫힌 목록 needle (split-string — test self-hit 방지)
_WEAR_LIST = "수트, 우주" + "복, 갑옷, 제복"
_EQUIP_LIST = "벽면 모니터, " + "TV, CCTV"
_PHENOM_LIST = "피, 물, " + "불, 연기"


def test_w1a1_g_residue_entity_all_prop():
    """G-residue: entity_all 신규 prop.md 에 닫힌 의복/장비/현상 목록 부재."""
    prop = (_latest_dir("entity_all") / "prop.md").read_text(encoding="utf-8")
    assert _WEAR_LIST not in prop, "Site1 의복 닫힌 목록 잔존"
    assert _EQUIP_LIST not in prop, "Site1 배경장비 닫힌 목록 잔존"
    assert _PHENOM_LIST not in prop, "Site1 자연현상 닫힌 목록 잔존"


def test_w1a1_g_residue_entity_filter_system():
    """G-residue: entity_filter 신규 system.md 에 닫힌 의복/장비 목록 부재."""
    sysmd = (_latest_dir("entity_filter") / "system.md").read_text(encoding="utf-8")
    assert _WEAR_LIST not in sysmd, "Site3 의복 닫힌 목록 잔존"
    assert _EQUIP_LIST not in sysmd, "Site3 배경장비 닫힌 목록 잔존"


def test_w1a1_g_intent_preserved():
    """G-intent: 제외 기준 의도 (입는 것 제외 / 장비 제외 / 효과 구분) 보존."""
    prop = (_latest_dir("entity_all") / "prop.md").read_text(encoding="utf-8")
    assert "제외 기준" in prop, "prop.md 제외 기준 섹션 손실"
    assert "시각 효과" in prop, "자연현상→시각효과 구분 의도 손실"
    sysmd = (_latest_dir("entity_filter") / "system.md").read_text(encoding="utf-8")
    assert "제거 (물체)" in sysmd, "entity_filter 제거 기준 의도 손실"


def test_w1a1_g_sibling_entity_all():
    """G-sibling: entity_all 신규 dir 의 미수정 6 stem 이 직전 dir 와 byte-identical."""
    vers = _list_module_versions("entity_all")
    new_d, old_d = _PROMPTS / "entity_all" / vers[0], _PROMPTS / "entity_all" / vers[1]
    for stem in ("character.md", "character_schema.json", "location.md",
                 "location_schema.json", "prop_schema.json", "system.md"):
        assert _sha256(new_d / stem) == _sha256(old_d / stem), f"sibling drift: {stem}"


def test_w1a1_g_sibling_entity_filter():
    """G-sibling: W1A1 이 만든 판(v3)의 미수정 stem 이 직전(v2)과 byte-identical.

    ★★2026-09-04 — 「최신 대 직전」으로 적혀 있었다. 그러면 **다음 판을 막는다**:
     새 판이 `filter_schema.json` 을 **의도적으로** 바꾸면(판 4 가 `short_id`
     칸을 더했다) 이 래칫이 걸린다. 래칫에는 값이 아니라 **관계**를 적어야
     하고, 이 시험이 말하는 관계는 「W1A1 이 system.md 만 고쳤다」다.
     그래서 그 두 판을 **이름으로** 고정한다.
    """
    new_d = _PROMPTS / "entity_filter" / "3.202605201316"
    old_d = _PROMPTS / "entity_filter" / "2.202603241900"
    assert _sha256(new_d / "filter_schema.json") == _sha256(old_d / "filter_schema.json")


def test_w1a1_g_activation():
    """G-activation: loader 가 entity_all v6 / entity_filter v3 를 active 로 resolve."""
    src = get_effective_source("entity_all", "prop")
    assert src["module_pack"]["latest"].startswith("6."), "entity_all v6 미활성"
    assert "/6." in src["candidates"]["file"]["path"], "entity_all prop.md v6 경로 아님"
    src2 = get_effective_source("entity_filter", "system")
    # ★2026-09-04 — 판 4 가 `short_id` 계약으로 올라갔다. 「v3 가 최신」이 아니라
    #  「W1A1 의 의도가 살아 있는 판이 활성」인지를 본다.
    assert int(src2["module_pack"]["latest"].split(".")[0]) >= 3, (
        "entity_filter 가 W1A1 이전 판으로 내려갔다")


# ──────────────── W1a-2 — entity_extractor_v2 (Site 4) ────────────────

# 닫힌 목록 needle (split-string)
_AGE_EX = "20대" + "→60대"
_OUTFIT_LIST = "군복/" + "정장/일상복"
_STATE_LIST = "부상/" + "결박/사망"


def test_w1a2_g_residue_turn2():
    """G-residue: entity_extractor_v2 신규 turn2.md 에 나이/의상/상태 닫힌 목록 부재."""
    turn2 = (_latest_dir("entity_extractor_v2") / "turn2.md").read_text(encoding="utf-8")
    assert _AGE_EX not in turn2, "Site4 나이밴드 example 잔존"
    assert _OUTFIT_LIST not in turn2, "Site4 의상 닫힌 목록 잔존"
    assert _STATE_LIST not in turn2, "Site4 상태 닫힌 목록 잔존"


def test_w1a2_g_intent_preserved():
    """G-intent: 변형 허용/금지 판단 의도 보존."""
    turn2 = (_latest_dir("entity_extractor_v2") / "turn2.md").read_text(encoding="utf-8")
    assert "허용:" in turn2 and "금지:" in turn2, "변형 허용/금지 구조 손실"
    assert "변형이 없으면 빈 배열" in turn2, "변형 없음 지시 손실"


def test_w1a2_g_sibling():
    """G-sibling: entity_extractor_v2 latest 의 미수정 9 stem 이 직전 dir 와 byte-identical.

    current-state pin — Wave3 (e2e-review-fix-v1, 2026-05-21) 가 latest 를 v12 로
    올리며 system.md/turn0_style.md 를 추상화 → 그 2 stem 은 sibling allowlist 에서
    제외. turn2.md 는 C8 v11 에서 이미 추상화됐고 v12 는 미수정 → sibling 에 포함.
    """
    vers = _list_module_versions("entity_extractor_v2")
    new_d = _PROMPTS / "entity_extractor_v2" / vers[0]
    old_d = _PROMPTS / "entity_extractor_v2" / vers[1]
    for stem in ("turn_entity_detail.md", "turn0_style_schema.json",
                 "turn1_7_detail_batch_schema.json", "turn1_7_detail_batch.md",
                 "turn1_review_schema.json", "turn1.md", "turn2.md",
                 "turn3.md", "turn4.md"):
        assert _sha256(new_d / stem) == _sha256(old_d / stem), f"sibling drift: {stem}"


def test_w1a2_g_activation():
    """G-activation: loader 가 entity_extractor_v2 latest(v12, Wave3) 를 active 로 resolve."""
    src = get_effective_source("entity_extractor_v2", "turn2")
    assert src["module_pack"]["latest"].startswith("12."), "v12 미활성"


def test_w1a2_g_registry():
    """G-registry: version_registry provenance current-state pin — Wave3 (2026-05-21) v12 / 1.5.0 sync."""
    assert _MODULE_INFO["entity_extractor"]["prompt_dependency"] == "entity_extractor_v2/v12"
    assert _MODULE_INFO["entity_extractor"]["updated_at"] == "2026-05-21"
    assert MODULE_VERSIONS["entity_extractor"] == "1.5.0"


# ──────────────── W1a-3 — entity_extract_v4 (Site 2) ────────────────


def test_w1a3_g_residue_prop():
    """G-residue: entity_extract_v4 신규 prop.md 에 닫힌 의복/장비/현상 목록 부재."""
    prop = (_latest_dir("entity_extract_v4") / "prop.md").read_text(encoding="utf-8")
    assert _WEAR_LIST not in prop, "Site2 의복 닫힌 목록 잔존"
    assert _EQUIP_LIST not in prop, "Site2 배경장비 닫힌 목록 잔존"
    assert _PHENOM_LIST not in prop, "Site2 자연현상 닫힌 목록 잔존"


def test_w1a3_g_intent_preserved():
    """G-intent: 제외 기준 의도 보존."""
    prop = (_latest_dir("entity_extract_v4") / "prop.md").read_text(encoding="utf-8")
    assert "제외 기준" in prop, "제외 기준 섹션 손실"


def test_w1a3_g_sibling():
    """G-sibling: 미수정 6 stem byte-identical."""
    vers = _list_module_versions("entity_extract_v4")
    new_d = _PROMPTS / "entity_extract_v4" / vers[0]
    old_d = _PROMPTS / "entity_extract_v4" / vers[1]
    for stem in ("character.md", "character_schema.json", "location.md",
                 "location_schema.json", "prop_schema.json", "system.md"):
        assert _sha256(new_d / stem) == _sha256(old_d / stem), f"sibling drift: {stem}"


def test_w1a3_g_activation():
    """G-activation: loader 가 entity_extract_v4 v9 를 active 로 resolve."""
    src = get_effective_source("entity_extract_v4", "prop")
    assert src["module_pack"]["latest"].startswith("9."), "v9 미활성"


# ──────────────── W1b — outlook_extractor + location_consistency (Site 5,6,7) ────────────────

# 닫힌 목록 needle (split-string)
_OUTFIT_CHANGE_EX = "평상복→전투복, " + "정장→피의갑옷"
_SEONBI_EX = "고급선비" + "복1"
_WEATHER_TOKENS = "rain, storm, " + "clear sky, snow, fog"
_TIME_TOKENS = "dawn, noon, " + "dusk, night, golden hour"


def test_w1b_g_residue_outlook_phase2():
    """G-residue: outlook_extractor 신규 phase2.md 에 의상변화 시나리오 example 부재."""
    p2 = (_latest_dir("outlook_extractor") / "phase2.md").read_text(encoding="utf-8")
    assert _OUTFIT_CHANGE_EX not in p2, "Site5 의상변화 시나리오 example 잔존"


def test_w1b_g_residue_outlook_phase3():
    """G-residue: outlook_extractor 신규 phase3.md 에 작품 고유명사 example 부재."""
    p3 = (_latest_dir("outlook_extractor") / "phase3.md").read_text(encoding="utf-8")
    assert _SEONBI_EX not in p3, "Site6 고급선비복 고유명사 example 잔존"


def test_w1b_g_residue_location_consistency():
    """G-residue: location_consistency 신규 system.md 에 환경상태 닫힌 영문 토큰 목록 부재."""
    sysmd = (_latest_dir("location_consistency") / "system.md").read_text(encoding="utf-8")
    assert _WEATHER_TOKENS not in sysmd, "Site7 날씨 닫힌 토큰 목록 잔존"
    assert _TIME_TOKENS not in sysmd, "Site7 시간 닫힌 토큰 목록 잔존"


def test_w1b_g_intent_preserved():
    """G-intent: 의상 변화 배정 의도 / 병합 판별 의도 / 환경상태 제외 의도 보존."""
    p2 = (_latest_dir("outlook_extractor") / "phase2.md").read_text(encoding="utf-8")
    assert "최대 2개 아웃룩 배정" in p2, "의상변화 2-아웃룩 배정 의도 손실"
    p3 = (_latest_dir("outlook_extractor") / "phase3.md").read_text(encoding="utf-8")
    assert "병합" in p3, "병합 판별 의도 손실"
    sysmd = (_latest_dir("location_consistency") / "system.md").read_text(encoding="utf-8")
    assert "절대 포함 금지" in sysmd, "환경상태 제외 의도 손실"


def test_w1b_g_sibling():
    """G-sibling: outlook_extractor 미수정 4 stem + location_consistency 미수정 1 stem byte-identical."""
    ov = _list_module_versions("outlook_extractor")
    on_d, oo_d = _PROMPTS / "outlook_extractor" / ov[0], _PROMPTS / "outlook_extractor" / ov[1]
    for stem in ("phase1_schema.json", "phase1.md", "phase2_schema.json", "phase3_schema.json"):
        assert _sha256(on_d / stem) == _sha256(oo_d / stem), f"outlook sibling drift: {stem}"
    lv = _list_module_versions("location_consistency")
    ln_d, lo_d = _PROMPTS / "location_consistency" / lv[0], _PROMPTS / "location_consistency" / lv[1]
    assert _sha256(ln_d / "location_schema.json") == _sha256(lo_d / "location_schema.json")


def test_w1b_g_activation():
    """G-activation: loader 가 outlook_extractor v13 / location_consistency v3 active resolve."""
    assert get_effective_source("outlook_extractor", "phase2")["module_pack"]["latest"].startswith("13.")
    assert get_effective_source("location_consistency", "system")["module_pack"]["latest"].startswith("3.")


def test_w1b_g_registry():
    """G-registry: location_consistency provenance v3 sync."""
    assert _MODULE_INFO["location_consistency"]["prompt_dependency"] == "location_consistency/v3"
    assert _MODULE_INFO["location_consistency"]["updated_at"] == "2026-05-20"
    assert MODULE_VERSIONS["location_consistency"] == "1.2.0"


# ──────────────── W2a — shot_extract v14 (Site 8,9,10 — A069) ────────────────

# 닫힌 목록 needle (split-string)
_RACE_EX = '"한국인 경찰"' + ', "동양인 노파"'
_HUMANOID_LIST = "외계인/" + "귀신/돌연변이"
_A069_STATE_LIST = "부상, 얼룩, " + "창백해짐, 멍, 화상"
# OQ-PLAN-2 — user.md 변형 block 닫힌 목록 needle (split-string)
_USER_FULLTRANSFORM = "인간→동물, " + "인간→비인간형"
_USER_STATE_LIST = "부상, 출혈, " + "창백해짐, 피멍, 화상"
_USER_BODYPART_LIST = "눈 색, 비늘, " + "손톱/송곳니, 꼬리"
_USER_METAPHOR_EX = '"해골처럼", ' + '"짐승 같은"'


def test_w2a_g_residue_user_race_example():
    """G-residue: shot_extract v14 user.md 에 인종 시나리오 example 부재."""
    user = (_latest_dir("shot_extract") / "user.md").read_text(encoding="utf-8")
    assert _RACE_EX not in user, "Site8 인종 example 잔존"


def test_w2a_g_residue_user_transformation_block():
    """G-residue (OQ-PLAN-2): shot_extract v14 user.md 변형 block 닫힌 목록 부재."""
    user = (_latest_dir("shot_extract") / "user.md").read_text(encoding="utf-8")
    assert _USER_FULLTRANSFORM not in user, "user.md 완전변형 닫힌 목록 잔존"
    assert _USER_STATE_LIST not in user, "user.md 일시적 상태 닫힌 목록 잔존"
    assert _USER_BODYPART_LIST not in user, "user.md 부분변화 닫힌 목록 잔존"
    assert _USER_METAPHOR_EX not in user, "user.md 비유적 표현 example 잔존"


def test_w2a_g_residue_system_humanoid_a069():
    """G-residue: shot_extract v14 system.md 에 인간형 sub-type + A069 닫힌 목록 부재."""
    sysmd = (_latest_dir("shot_extract") / "system.md").read_text(encoding="utf-8")
    assert _HUMANOID_LIST not in sysmd, "Site9 인간형 sub-type 닫힌 목록 잔존"
    assert _A069_STATE_LIST not in sysmd, "Site10 A069 일시적 상태 닫힌 목록 잔존"


def test_w2a_g_boundary_c5_time_connective_preserved():
    """G-boundary: C5 가 추상화한 시간연결어 wording 이 v14 에 byte-보존 (재-claim 금지)."""
    user = (_latest_dir("shot_extract") / "user.md").read_text(encoding="utf-8")
    sysmd = (_latest_dir("shot_extract") / "system.md").read_text(encoding="utf-8")
    needle = "시간 흐름을 나타내는 표현(언어·형태 불문)"
    assert needle in user, "C5 시간연결어 추상화 wording 손실(user)"
    assert needle in sysmd, "C5 시간연결어 추상화 wording 손실(system)"


def test_w2a_g_intent_preserved():
    """G-intent: 인종/국적 포함 의도 + 변형 판정 의도 보존."""
    sysmd = (_latest_dir("shot_extract") / "system.md").read_text(encoding="utf-8")
    assert "인종/국적을 반드시 포함" in sysmd, "인종/국적 포함 의도 손실"
    assert "변형 기준" in sysmd, "변형 기준 섹션 손실"
    assert "완전변형" in sysmd, "완전변형 판정 의도 손실"


def test_w2a_g_activation():
    """G-activation: shot_extract v15 active + shot_schema.json 은 v10 per-stem fallback."""
    src = get_effective_source("shot_extract", "system")
    assert src["module_pack"]["latest"].startswith("17."), "shot_extract v17 미활성"  # E2E13 fix①
    # shot_schema.json (C5 OPTION_1) — v14 에 부재, _resolve 가 v10 으로 fallback
    _path, found_ver, _vers = _resolve_stem_in_pack("shot_extract", "shot_schema", ext=".json")
    assert found_ver.startswith("10."), f"shot_schema.json per-stem fallback v10 아님: {found_ver}"


def test_w2a_g_registry():
    """G-registry: shot_extractor provenance v15 sync (Wave1 fix-up; 이전 C8 W2a v14/2.3.0)."""
    assert _MODULE_INFO["shot_extractor"]["prompt_dependency"] == "shot_extract/v17"  # E2E13 fix①
    assert MODULE_VERSIONS["shot_extractor"] == "2.6.0"  # E2E13 fix①


# ──────────────── W2b — shot_essence + shot_selection + scene_extractor_v2 (Site 11-14) ────────────────

# 닫힌 목록 / 시나리오 needle (split-string)
_TRACE_LIST = "혈흔, 깨진 " + "유리, 부상 흔적"
_DOLL_BAG = "인형 " + "백팩"
_LINK_CAT_VEHICLE = "교통수단 이용 " + "장면"
_MONTAGE_MARKER = "<몽" + "타주>"


def test_w2b_g_residue_shot_essence():
    """G-residue: shot_essence v4 system.md 에 사건흔적 닫힌 목록 + 인형 백팩 부재."""
    sysmd = (_latest_dir("shot_essence_extraction") / "system.md").read_text(encoding="utf-8")
    assert _TRACE_LIST not in sysmd, "Site11 사건흔적 닫힌 목록 잔존"
    assert _DOLL_BAG not in sysmd, "Site12 인형 백팩 시나리오 noun 잔존"


def test_w2b_g_residue_shot_selection():
    """G-residue: shot_selection v5 system.md 에 연결순간 카테고리 닫힌 목록 부재."""
    sysmd = (_latest_dir("shot_selection") / "system.md").read_text(encoding="utf-8")
    assert _LINK_CAT_VEHICLE not in sysmd, "Site13 교통수단 카테고리 example 잔존"


def test_w2b_g_residue_scene_extractor_montage():
    """G-residue: scene_extractor_v2 v21 system.md 에 <몽타주> literal marker 부재."""
    sysmd = (_latest_dir("scene_extractor_v2") / "system.md").read_text(encoding="utf-8")
    assert _MONTAGE_MARKER not in sysmd, "Site14 <몽타주> literal marker 잔존"


def test_w2b_g_intent_preserved():
    """G-intent: essence 분류 / ROI 선택 / montage 분류 의도 보존."""
    se = (_latest_dir("shot_essence_extraction") / "system.md").read_text(encoding="utf-8")
    assert "영구 흔적" in se, "사건 영구 흔적 → essence 의도 손실"
    assert "잘못된 예시" in se or "올바른 분류" in se, "worked-example 구조 손실"
    ss = (_latest_dir("shot_selection") / "system.md").read_text(encoding="utf-8")
    assert "선택하지 말 것" in ss, "shot_selection 비선택 원칙 손실"
    sx = (_latest_dir("scene_extractor_v2") / "system.md").read_text(encoding="utf-8")
    assert "montage:" in sx, "montage 씬 유형 분류 손실"


def test_w2b_g_sibling():
    """G-sibling: 미수정 stem byte-identical (shot_essence schema.json / shot_selection user.md+schema / scene_extractor 4 stem)."""
    sev = _list_module_versions("shot_essence_extraction")
    assert _sha256(_PROMPTS / "shot_essence_extraction" / sev[0] / "schema.json") == \
           _sha256(_PROMPTS / "shot_essence_extraction" / sev[1] / "schema.json")
    ssv = _list_module_versions("shot_selection")
    for stem in ("user.md", "selection_schema.json"):
        assert _sha256(_PROMPTS / "shot_selection" / ssv[0] / stem) == \
               _sha256(_PROMPTS / "shot_selection" / ssv[1] / stem), f"shot_selection sibling drift: {stem}"
    sxv = _list_module_versions("scene_extractor_v2")
    for stem in ("scene_detail_schema.json", "turn_scene_detail.md", "turn0_context.md", "turn1_split_long.md"):
        assert _sha256(_PROMPTS / "scene_extractor_v2" / sxv[0] / stem) == \
               _sha256(_PROMPTS / "scene_extractor_v2" / sxv[1] / stem), f"scene_extractor sibling drift: {stem}"


def test_w2b_g_activation():
    """G-activation: 3 module 신규 version active resolve (shot_selection=v7, E2E13 fix①)."""
    assert get_effective_source("shot_essence_extraction", "system")["module_pack"]["latest"].startswith("4.")
    assert get_effective_source("shot_selection", "system")["module_pack"]["latest"].startswith("7.")  # E2E13 fix①
    assert get_effective_source("scene_extractor_v2", "system")["module_pack"]["latest"].startswith("21.")


def test_w2b_g_registry():
    """G-registry: shot_selector provenance v7 sync (E2E13 fix①)."""
    assert _MODULE_INFO["shot_selector"]["prompt_dependency"] == "shot_selection/v7"  # E2E13 fix①
    assert MODULE_VERSIONS["shot_selector"] == "4.2.0"  # E2E13 fix①


# ──────────────── W3a — t2i / background / floor_plan (Site 15,17,18) ────────────────

_BACKEND = Path(__file__).resolve().parents[2]

# 시나리오 leak needle (split-string)
_SCIFI_EX = "지하 극저온 저장 " + "시설의 투명 원통형 캡슐"
_MART_GROUP = "bg_large" + "_mart"
_ONDOL_EX = "ondol-friendly " + "bed"


def test_w3a_g_residue_t2i():
    """G-residue: t2i_visual_converter v5 system.md 에 sci-fi 시나리오 example 부재."""
    sysmd = (_PROMPTS / "t2i_visual_converter" / "v5" / "system.md").read_text(encoding="utf-8")
    assert _SCIFI_EX not in sysmd, "Site15 sci-fi 시나리오 example 잔존"


def test_w3a_g_residue_background_master_plan():
    """G-residue: background_master_plan v4 system.md 에 구체 location ID example 부재."""
    sysmd = (_latest_dir("background_master_plan") / "system.md").read_text(encoding="utf-8")
    assert _MART_GROUP not in sysmd, "Site17 bg_large_mart 구체 group example 잔존"


def test_w3a_g_residue_floor_plan():
    """G-residue: floor_plan_prompt v5 system.md 에 region-specific 가구 example 부재."""
    sysmd = (_latest_dir("floor_plan_prompt") / "system.md").read_text(encoding="utf-8")
    assert _ONDOL_EX not in sysmd, "Site18 ondol-friendly bed region example 잔존"


def test_w3a_g_boundary_floor_plan_a031():
    """G-boundary: floor_plan_prompt A031 (색/glyph rule 2·5, lines 10-13) byte-보존."""
    sysmd = (_latest_dir("floor_plan_prompt") / "system.md").read_text(encoding="utf-8")
    assert "single flat pastel color" in sysmd, "A031 색 매핑 rule 2 손실"
    assert "symbolic glyphs" in sysmd, "A031 glyph rule 5 손실"


def test_w3a_g_intent_preserved():
    """G-intent: 콘티 단순성 / fp↔bg link / cultural-cues-derive 의도 보존."""
    t2i = (_PROMPTS / "t2i_visual_converter" / "v5" / "system.md").read_text(encoding="utf-8")
    assert "나쁜 예" in t2i and "좋은 예" in t2i, "콘티 나쁜/좋은 예 구조 손실"
    bmp = (_latest_dir("background_master_plan") / "system.md").read_text(encoding="utf-8")
    assert "depends_on_fp" in bmp, "fp↔bg link example 의도 손실"
    fp = (_latest_dir("floor_plan_prompt") / "system.md").read_text(encoding="utf-8")
    assert "do NOT use specific work nouns" in fp, "cultural-cues no-hardcoding 의도 손실"


def test_w3a_g_constant_t2i():
    """G-activation: t2i_visual_converter.py PROMPT_DIR 가 v5 로 bump."""
    code = (_BACKEND / "app" / "modules" / "t2i_visual_converter.py").read_text(encoding="utf-8")
    assert '"t2i_visual_converter" / "v5"' in code, "PROMPT_DIR v5 미bump"
    assert (_PROMPTS / "t2i_visual_converter" / "v5" / "system.md").exists(), "v5 dir 미생성"


def test_w3a_g_constant_background_floor():
    """G-activation: background_master_plan / floor_plan_prompt 의 활성화 검증.

    W19A (2026-05-26) 갱신: floor_plan_prompt 가 v5 default + v6 opt-in selector
    패턴으로 진화. ``PROMPT_VERSION`` 상수는 selector mapping 의 default 값 (v5
    pack 이름) 을 backward-compat marker 로 보존하고, latest pack (v6) 는
    selector ``"6"`` 가 resolve 한다. 그래서 v6 가 디스크 latest 가 된 후로는
    상수가 latest 와 lock-step 인지가 아니라 default selector 의 resolution 이
    안정한지를 본다.
    """
    bmp_code = (_BACKEND / "app" / "core" / "steps" / "background_master_plan_step.py").read_text(encoding="utf-8")
    bmp_latest = _list_module_versions("background_master_plan")[0]
    assert f'PROMPT_VERSION = "{bmp_latest}"' in bmp_code, "background_master_plan PROMPT_VERSION 불일치"
    assert bmp_latest.startswith("5."), "background_master_plan v5 미활성 (E2E v1 — space_profile 주입)"
    fp_code = (_BACKEND / "app" / "core" / "steps" / "floor_plan_prompt_step.py").read_text(encoding="utf-8")
    fp_versions = _list_module_versions("floor_plan_prompt")
    assert any(v.startswith("5.") for v in fp_versions), \
        "floor_plan_prompt v5 pack 가 디스크에 없음 (default selector 가 가리킬 pack 누락)"
    assert "PROMPT_VERSION_MAP" in fp_code, "floor_plan_prompt selector mapping 누락 (W19A)"
    from app.modules.pipeline.floor_plan_prompt import PROMPT_VERSION_MAP
    assert PROMPT_VERSION_MAP["5"].startswith("5."), \
        "PROMPT_VERSION_MAP['5'] 가 v5 pack 을 가리키지 않음"
    # Default selector resolution: 상수는 v5 pack 이름과 byte-identical
    # (W19A 갱신 후에도 v5 default 보존 contract 의 핵심).
    assert f'PROMPT_VERSION = PROMPT_VERSION_MAP["5"]' in fp_code, \
        "floor_plan_prompt PROMPT_VERSION 의 v5-default mapping 누락 (W19A backward-compat)"
    assert "SCHEMA_VERSION = 2" in fp_code, "floor_plan SCHEMA_VERSION 불변 위반"


def test_w3a_g_sibling():
    """G-sibling: 미수정 stem 의 byte-identical 검증.

    W19A (2026-05-26) 갱신: floor_plan_prompt v5 → v6 가 schema.json 의 의도적
    delta (``base_layer_decision`` required, ``plot_device`` 제거) 를 도입.
    user_template.md 는 v5 와 byte-identical 유지 (placeholder 동일). 따라서
    schema.json drift 는 W19A 의 정상 산출이며 sibling check 에서 제외.
    background_master_plan 는 schema/user_template 모두 sibling byte-identical
    (W3a content-only bump invariant) 유지.
    """
    bv = _list_module_versions("background_master_plan")
    for stem in ("schema.json", "user_template.md"):
        assert _sha256(_PROMPTS / "background_master_plan" / bv[0] / stem) == \
               _sha256(_PROMPTS / "background_master_plan" / bv[1] / stem), f"bmp sibling drift: {stem}"
    fv = _list_module_versions("floor_plan_prompt")
    # user_template.md 만 byte-identical 검증. schema.json 은 W19A delta.
    for stem in ("user_template.md",):
        assert _sha256(_PROMPTS / "floor_plan_prompt" / fv[0] / stem) == \
               _sha256(_PROMPTS / "floor_plan_prompt" / fv[1] / stem), f"floor_plan sibling drift: {stem}"


# ──────────────── W4a — code carry prose (Site 22,23,24,25) ────────────────

# 시나리오 leak / heuristic needle (split-string)
_CHAR_HEURISTIC = "다른 타입과 중복될 " + "가능성이 거의 없"
_BLACKSUIT_EX = "검은" + "정장1"
_BLACKSUIT_EX2 = "검은" + "정장2"
_PROPER_NAME = "동" + "녘(강의원)"
_RESTRAINT_KO = "포박 " + "상태"
_RESTRAINT_EN = "restraint " + "state"
_KOREA_HARDCODE_KO = "현대 " + "한국/근미래 한국"
_KOREA_HARDCODE_EN = "present-day / " + "near-future Korean"


def test_w4a_g_residue_entity_steps():
    """G-residue: entity_steps.py 에 character 중복 heuristic 부재."""
    code = (_BACKEND / "app" / "core" / "steps" / "entity_steps.py").read_text(encoding="utf-8")
    assert _CHAR_HEURISTIC not in code, "Site22 character heuristic 잔존"


def test_w4a_g_residue_outlook_dedup():
    """G-residue: outlook_dedup.py 에 검은정장 시나리오 example 부재."""
    code = (_BACKEND / "app" / "modules" / "pipeline" / "outlook_dedup.py").read_text(encoding="utf-8")
    assert _BLACKSUIT_EX not in code, "Site23 검은정장1 example 잔존"
    assert _BLACKSUIT_EX2 not in code, "Site23 검은정장2 example 잔존"


def test_w4a_g_residue_scene_validator():
    """G-residue: scene_validator.py 에 작품 고유명사 character 이름 부재."""
    code = (_BACKEND / "app" / "modules" / "pipeline" / "scene_validator.py").read_text(encoding="utf-8")
    assert _PROPER_NAME not in code, "Site24 동녘(강의원) 고유명사 잔존"
    assert "<visible_body_name>" in code, "Site24 placeholder form 미도입"


def test_w4a_g_residue_reference_image_generator():
    """G-residue: reference_image_generator.py 에 한국 하드코딩·포박 상태 needle 부재."""
    code = (_BACKEND / "app" / "modules" / "reference_image_generator.py").read_text(encoding="utf-8")
    assert _KOREA_HARDCODE_KO not in code, "Site25 현대 한국 하드코딩 잔존(ko)"
    assert _KOREA_HARDCODE_EN not in code, "Site25 Korean 하드코딩 잔존(en)"
    assert _RESTRAINT_KO not in code, "Site25 포박 상태 잔존(ko)"
    assert _RESTRAINT_EN not in code, "Site25 restraint state 잔존(en)"
    assert "visual_world_rules" in code, "Site25 visual_world_rules derive 지시 미도입"


def test_w4a_g_carrier_shape():
    """G-carrier: f-string / dict 구조 보존 (함수·key 불변)."""
    rig = (_BACKEND / "app" / "modules" / "reference_image_generator.py").read_text(encoding="utf-8")
    assert "def _entity_type_reference_rules(" in rig, "_entity_type_reference_rules signature 손실"
    es = (_BACKEND / "app" / "core" / "steps" / "entity_steps.py").read_text(encoding="utf-8")
    assert "user_prompt = (" in es, "entity_steps user_prompt f-string 구조 손실"


# ──────────────── W4b — render_prompt_card camera prose (Site 20) ────────────────

# 닫힌 camera-height token 목록 needle — applies_when literal 이 source 에서
# string-literal 2조각으로 split 돼 있어 head/tail 두 조각 모두 검사 (split-aware).
_CAMERA_TOKEN_HEAD = "(low / hip-height / " + "overhead / ground level / quay level"
_CAMERA_TOKEN_TAIL = "floor level / " + "from above)"


def test_w4b_g_residue_camera_applies_when():
    """G-residue: applies_when 닫힌 camera-height token 목록 부재(head+tail) + generic wording 도입."""
    code = (_BACKEND / "app" / "core" / "steps" / "render_prompt_card.py").read_text(encoding="utf-8")
    assert _CAMERA_TOKEN_HEAD not in code, "Site20 applies_when 닫힌 camera token 목록(head) 잔존"
    assert _CAMERA_TOKEN_TAIL not in code, "Site20 applies_when 닫힌 camera token 목록(tail) 잔존"
    assert "vertical camera height or" in code, "Site20 generic vertical-height wording 미도입"


def test_w4b_g_keep_canary_tuples():
    """G-canary KEEP: _SPATIAL_CAMERA_* + Site21 _ID_* canary tuple byte-보존 (폐기 금지)."""
    code = (_BACKEND / "app" / "core" / "steps" / "render_prompt_card.py").read_text(encoding="utf-8")
    assert "_SPATIAL_CAMERA_LOW_TOKENS" in code, "canary LOW tuple 손실"
    assert "_SPATIAL_CAMERA_HIGH_TOKENS" in code, "canary HIGH tuple 손실"
    assert "_SPATIAL_CAMERA_HIP_TOKENS" in code, "canary HIP tuple 손실"
    assert "_SPATIAL_FRAME_EDGE_POSITION_TOKENS" in code, "canary frame-edge tuple 손실"
    assert "_ID_ETHNICITY_COMPONENTS" in code, "Site21 KEEP — ethnicity tuple 손실"
    assert "_ID_AGE_BANDS" in code, "Site21 KEEP — age band tuple 손실"


def test_w4b_g_intent_camera_frame_rule():
    """G-intent: camera_frame_rule frame-edge 판정 의도 + 구조 키 보존."""
    code = (_BACKEND / "app" / "core" / "steps" / "render_prompt_card.py").read_text(encoding="utf-8")
    assert "frame-edge surfaces" in code, "frame-edge 판정 의도 손실"
    assert '"applies_when":' in code, "camera_frame_rule applies_when key 손실"
    assert '"core_principles":' in code, "camera_frame_rule core_principles key 손실"
    assert '"consistency_check_summary":' in code, "camera_frame_rule consistency_check_summary key 손실"


# ──────────────── W5 — G-boundary (KEEP / DEFER site 불변 검증) ────────────────


def test_w5_g_boundary_keep_defer_sites():
    """G-boundary: KEEP (Site 16, 21) + DEFER (Site 19) + OQ-PLAN-1/3 KEEP site 불변."""
    # Site 16 — background_chain_render KEEP (C8 신규 dir 미생성)
    bcr_vers = _list_module_versions("background_chain_render")
    assert bcr_vers[0].startswith("2."), "Site16 background_chain_render KEEP 위반 — 신규 dir 생성됨"
    # Site 19 — scene_consistency_step P020 prefix shim DEFER (edit 0)
    scs = (_BACKEND / "app" / "core" / "steps" / "scene_consistency_step.py").read_text(encoding="utf-8")
    assert "분석 실패" in scs, "Site19 DEFER — P020 prefix shim 이 의도치 않게 제거됨"
    # Site 21 — render_prompt_card _ID_ETHNICITY/_ID_AGE_BANDS KEEP (W4b test 와 중복 확인)
    rpc = (_BACKEND / "app" / "core" / "steps" / "render_prompt_card.py").read_text(encoding="utf-8")
    assert "_ID_ETHNICITY_COMPONENTS" in rpc and "_ID_AGE_BANDS" in rpc, "Site21 KEEP 위반"
    # OQ-PLAN-3 KEEP — render_prompt_card role_hint_from_outfit (G4.3 SOT) 보존
    assert "role_hint_from_outfit" in rpc, "OQ-PLAN-3 KEEP 위반 — role_hint_from_outfit 손실"
    # OQ-PLAN-1 KEEP — location_consistency v3 example block (lines 41-57) 보존
    lc = (_latest_dir("location_consistency") / "system.md").read_text(encoding="utf-8")
    assert "compact one-room apartment" in lc, "OQ-PLAN-1 KEEP 위반 — 실내 output example 손실"
    assert "narrow back alley" in lc, "OQ-PLAN-1 KEEP 위반 — 야외 output example 손실"
