"""W19A — floor_plan_prompt v6 pack + version-aware validator + versioned load.

W19A 가 production 코드/프롬프트에 처음으로 touch 하는 wave (이전 W18 wave 는
모두 실험 script). 본 test 는 다음 4 가지 contract 만 확인:

1. v6 pack (system.md / user_template.md / schema.json) 이 세 stem 모두 같이
   디스크에 존재 (review §4.5 의 partial-pack drift 방지).
2. v6 schema delta — ``base_layer_decision`` required + enum 6값 exact-set,
   ``category`` enum 에서 ``plot_device`` 제거 (review §3.1.2 + §6.3 lock).
3. 시나리오 specific token leak 없음 (memory feedback-no-scenario-specific-coding).
4. ``_validate_fp_prompt_extras`` version-aware — v5 selector 는 기존 동작
   verbatim, v6 selector 만 추가 enum membership 검사.
5. ``prompt_loader.load_prompt`` / ``load_schema`` 가 ``version=`` 인자로
   특정 pack 디렉토리에 pin 됨 (review §3.6 latest-pack auto-resolve risk
   mitigation).

production 회귀 605/605 회복은 별도 backend/tests/scripts 회귀에서 본다.
본 test 는 image API / LLM API / VLM API / DB / ImageAsset 호출 0.
"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from app.modules.pipeline.floor_plan_prompt import (
    PROMPT_VERSION_MAP,
    FloorPlanPromptError,
    _V6_BASE_LAYER_DECISION_ENUM,
    _validate_fp_prompt_extras,
    resolve_prompt_version,
)
from app.modules.prompt_loader import PROMPTS_BASE, load_prompt, load_schema

V5_PACK = PROMPT_VERSION_MAP["5"]  # "5.202605201406"
V6_PACK = PROMPT_VERSION_MAP["6"]  # "6.202605262300"
V6_PACK_DIR = PROMPTS_BASE / "floor_plan_prompt" / V6_PACK


def _expected_enum() -> set[str]:
    return {
        "base_structural_unit",
        "base_opening",
        "base_persistent_fixture",
        "base_persistent_furniture",
        "state_overlay_plot_cue",
        "state_overlay_transient_object",
    }


# ----- 1. pack integrity -----


def test_v6_pack_has_all_three_stems():
    """system.md / user_template.md / schema.json must land together."""
    for stem in ("system.md", "user_template.md", "schema.json"):
        path = V6_PACK_DIR / stem
        assert path.exists(), f"v6 pack missing stem: {path}"


# ----- 2. schema delta -----


def test_v6_schema_requires_base_layer_decision():
    schema = json.loads((V6_PACK_DIR / "schema.json").read_text(encoding="utf-8"))
    items = schema["properties"]["numbered_elements"]["items"]
    assert "base_layer_decision" in set(items["required"]), (
        "v6 schema must require base_layer_decision on each numbered_elements entry"
    )


def test_v6_schema_base_layer_decision_enum_exact():
    schema = json.loads((V6_PACK_DIR / "schema.json").read_text(encoding="utf-8"))
    enum = set(
        schema["properties"]["numbered_elements"]["items"]["properties"][
            "base_layer_decision"
        ]["enum"]
    )
    assert enum == _expected_enum(), (
        f"v6 base_layer_decision enum drift: got {sorted(enum)}, "
        f"expected {sorted(_expected_enum())}"
    )


def test_v6_schema_category_excludes_plot_device():
    schema = json.loads((V6_PACK_DIR / "schema.json").read_text(encoding="utf-8"))
    cat_enum = set(
        schema["properties"]["numbered_elements"]["items"]["properties"]["category"][
            "enum"
        ]
    )
    assert "plot_device" not in cat_enum, (
        "v6 category enum must drop the legacy plot_device value "
        "(layer decision moves to base_layer_decision)"
    )
    # 나머지 generic shape-kind 값들은 그대로 유지.
    assert {"furniture", "opening", "prop", "area"}.issubset(cat_enum)


def test_v6_module_enum_constant_matches_schema():
    """on-disk schema 와 module 안 ``_V6_BASE_LAYER_DECISION_ENUM`` 가 lock-step."""
    schema = json.loads((V6_PACK_DIR / "schema.json").read_text(encoding="utf-8"))
    enum_in_schema = set(
        schema["properties"]["numbered_elements"]["items"]["properties"][
            "base_layer_decision"
        ]["enum"]
    )
    assert set(_V6_BASE_LAYER_DECISION_ENUM) == enum_in_schema


# ----- 3. methodology grep — no scenario tokens -----


def test_v6_pack_has_no_scenario_specific_tokens():
    """v6 production pack 이 작품 고유명사 / sample-fixture ID / W18 실험 scene
    description / brand / body-related token / 사용자 예시 인접 room-kind 를
    정적 텍스트에 포함하지 않는지 확인.

    v6 의 static examples 는 generic zone taxonomy (shared / private / service /
    threshold / utility zone) 로 유지된다. 실제 runtime room label 은 LLM input
    payload 가 carry 한다 (memory feedback-no-scenario-specific-coding +
    render-safety: body/corpse/cadaver 류 0).
    """
    banned = {
        # 작품 고유명사 (Korean / transliteration)
        "옥탑방",
        "민숙",
        "수리영",
        "suri-young",
        "suri young",
        # sample-fixture location ID prefix (W19 brief §5.2 scenario hygiene)
        "l05b",
        "l05_",
        "l07b",
        "l07_",
        "l11b",
        "l11_",
        # W18 experiment-derived scene description fragments (정적 텍스트에
        # 다시 등장하면 scenario-specific leak)
        "rooftop deck wheelhouse",
        "curtain-side footprint",
        "toppled chair",
        "red floor stain",
        "wall red ring",
        # render-safety: body/corpse/cadaver 계열 0 (v6 는 render-consumed
        # surface 의 출발점이므로 정적 prompt 에서 허용하거나 예시로 들면 안 됨).
        "body",
        "corpse",
        "cadaver",
        "dead person",
        # brand / proper noun
        "ikea",
        # 사용자 예시 인접 room-kind — generic zone taxonomy 로 대체됐어야.
        "rooftop",
        "deck",
    }
    for stem in ("system.md", "user_template.md", "schema.json"):
        text = (V6_PACK_DIR / stem).read_text(encoding="utf-8").lower()
        leaks = sorted(t for t in banned if t in text)
        assert not leaks, f"v6 {stem} contains scenario tokens: {leaks}"


# ----- 4. version-aware validator -----


def _v5_shape_payload() -> dict:
    """v5 production shape — base_layer_decision 없음, category 그대로."""
    return {
        "fp_id": "fp_a",
        "t2i_prompt": "x" * 50,
        "key_elements": [],
        "numbered_elements": [
            {
                "number": 1,
                "label": "primary unit",
                "category": "area",
                "position_hint": "center area",
            },
            {
                "number": 2,
                "label": "sofa anchor",
                "category": "furniture",
                "position_hint": "south wall of primary unit",
            },
        ],
        "camera_recommendations": [
            {
                "bg_id": "L01B01",
                "sub_location": "primary_unit",
                "camera_position": "near number 1, facing toward number 2",
                "camera_height": "eye level",
                "lens_hint": "35mm",
                "framing_notes": "",
            }
        ],
    }


def _v6_shape_payload(*, valid: bool = True, missing: bool = False) -> dict:
    base = _v5_shape_payload()
    base["camera_recommendations"][0]["bg_id"] = "L01B01"
    if missing:
        # base_layer_decision 누락 — v6 에서 raise 해야. base_layer_decision
        # branch 가 cam-level use/ignore 검증보다 먼저 도착하므로 cam payload 는
        # 갱신할 필요 없음.
        return base
    decision = "base_structural_unit" if valid else "not_in_enum_value"
    for entry in base["numbered_elements"]:
        entry["base_layer_decision"] = decision
    # W19A2: v6 happy / invalid-enum path 둘 다 cam 에 use/ignore 가 있어야
    # cam-level validator 가 통과/실패 의도대로 동작. state_overlay marker 가
    # 없는 payload 이므로 use=모든 base, ignore=[] 로 coverage 자동 충족.
    base["camera_recommendations"][0]["use_numbered_elements"] = [
        int(e["number"]) for e in base["numbered_elements"]
    ]
    base["camera_recommendations"][0]["ignore_numbered_elements"] = []
    return base


def test_validator_v5_selector_accepts_legacy_v5_shape():
    """default v5 selector path 는 base_layer_decision 부재여도 통과."""
    _validate_fp_prompt_extras(
        fp_result=_v5_shape_payload(),
        expected_bg_ids={"L01B01"},
        prompt_version="5",
    )


def test_validator_v6_selector_rejects_missing_base_layer_decision():
    with pytest.raises(FloorPlanPromptError, match="base_layer_decision"):
        _validate_fp_prompt_extras(
            fp_result=_v6_shape_payload(missing=True),
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_selector_rejects_invalid_enum_value():
    with pytest.raises(FloorPlanPromptError, match="invalid base_layer_decision"):
        _validate_fp_prompt_extras(
            fp_result=_v6_shape_payload(valid=False),
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_selector_accepts_all_enum_values():
    """v6 enum 6값 모두 valid + state_overlay 가 cam.use 에 cover."""
    payload = _v5_shape_payload()
    payload["numbered_elements"] = [
        {
            "number": idx + 1,
            "label": f"marker {idx + 1}",
            "category": "area",
            "position_hint": "anywhere",
            "base_layer_decision": value,
        }
        for idx, value in enumerate(sorted(_expected_enum()))
    ]
    # W19A2: 모든 marker (base + state_overlay) 를 cam.use 에 포함 → coverage 충족.
    payload["camera_recommendations"][0]["use_numbered_elements"] = [
        e["number"] for e in payload["numbered_elements"]
    ]
    payload["camera_recommendations"][0]["ignore_numbered_elements"] = []
    _validate_fp_prompt_extras(
        fp_result=payload,
        expected_bg_ids={"L01B01"},
        prompt_version="6",
    )


# ----- W19A2 — per-bg marker applicability (use_numbered_elements / ignore_numbered_elements) -----


def test_v6_schema_camera_recommendations_require_use_ignore():
    schema = json.loads((V6_PACK_DIR / "schema.json").read_text(encoding="utf-8"))
    cam_items = schema["properties"]["camera_recommendations"]["items"]
    required = set(cam_items["required"])
    assert {"use_numbered_elements", "ignore_numbered_elements"} <= required, (
        "v6 camera_recommendations.required must include both use_numbered_elements "
        "and ignore_numbered_elements (W19A2 contract)"
    )
    use_prop = cam_items["properties"]["use_numbered_elements"]
    ignore_prop = cam_items["properties"]["ignore_numbered_elements"]
    assert use_prop["type"] == "array"
    assert ignore_prop["type"] == "array"
    assert use_prop["items"]["type"] == "integer"
    assert ignore_prop["items"]["type"] == "integer"
    assert use_prop["items"].get("minimum", 0) >= 1
    assert ignore_prop["items"].get("minimum", 0) >= 1


def test_validator_v6_raises_on_missing_use_numbered_elements():
    payload = _v6_shape_payload(valid=True)
    del payload["camera_recommendations"][0]["use_numbered_elements"]
    with pytest.raises(FloorPlanPromptError, match="use_numbered_elements"):
        _validate_fp_prompt_extras(
            fp_result=payload,
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_raises_on_missing_ignore_numbered_elements():
    payload = _v6_shape_payload(valid=True)
    del payload["camera_recommendations"][0]["ignore_numbered_elements"]
    with pytest.raises(FloorPlanPromptError, match="ignore_numbered_elements"):
        _validate_fp_prompt_extras(
            fp_result=payload,
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_raises_on_unknown_use_number():
    payload = _v6_shape_payload(valid=True)
    payload["camera_recommendations"][0]["use_numbered_elements"].append(99)
    with pytest.raises(FloorPlanPromptError, match="unknown numbers"):
        _validate_fp_prompt_extras(
            fp_result=payload,
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_raises_on_unknown_ignore_number():
    payload = _v6_shape_payload(valid=True)
    payload["camera_recommendations"][0]["ignore_numbered_elements"] = [42]
    with pytest.raises(FloorPlanPromptError, match="unknown numbers"):
        _validate_fp_prompt_extras(
            fp_result=payload,
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_raises_on_use_ignore_overlap():
    payload = _v6_shape_payload(valid=True)
    shared = payload["numbered_elements"][0]["number"]
    payload["camera_recommendations"][0]["use_numbered_elements"] = [shared]
    payload["camera_recommendations"][0]["ignore_numbered_elements"] = [shared]
    with pytest.raises(FloorPlanPromptError, match="non-empty"):
        _validate_fp_prompt_extras(
            fp_result=payload,
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_raises_on_non_integer_use_element():
    payload = _v6_shape_payload(valid=True)
    payload["camera_recommendations"][0]["use_numbered_elements"] = ["1"]
    with pytest.raises(FloorPlanPromptError, match="not an integer"):
        _validate_fp_prompt_extras(
            fp_result=payload,
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_raises_on_uncovered_state_overlay():
    """state_overlay marker 가 어느 cam.use 에도 안 들어가면 overlay leak 후보 — raise."""
    payload = _v5_shape_payload()
    payload["numbered_elements"] = [
        {
            "number": 1,
            "label": "primary unit",
            "category": "area",
            "position_hint": "center",
            "base_layer_decision": "base_structural_unit",
        },
        {
            "number": 2,
            "label": "transient cue",
            "category": "prop",
            "position_hint": "north wall",
            "base_layer_decision": "state_overlay_plot_cue",
        },
    ]
    # cam 은 base 만 use, state_overlay (#2) 는 use 에도 ignore 에도 안 들어감.
    payload["camera_recommendations"][0]["use_numbered_elements"] = [1]
    payload["camera_recommendations"][0]["ignore_numbered_elements"] = []
    with pytest.raises(
        FloorPlanPromptError,
        match="state_overlay numbered_elements not covered",
    ):
        _validate_fp_prompt_extras(
            fp_result=payload,
            expected_bg_ids={"L01B01"},
            prompt_version="6",
        )


def test_validator_v6_state_overlay_covered_in_one_bg_with_clean_sister_passes():
    """clean bg 가 overlay 를 ignore 에 두고, dirty bg 가 use 에 두면 coverage 충족."""
    payload = _v5_shape_payload()
    payload["numbered_elements"] = [
        {
            "number": 1,
            "label": "primary unit",
            "category": "area",
            "position_hint": "center",
            "base_layer_decision": "base_structural_unit",
        },
        {
            "number": 2,
            "label": "transient cue",
            "category": "prop",
            "position_hint": "north wall",
            "base_layer_decision": "state_overlay_plot_cue",
        },
    ]
    payload["camera_recommendations"] = [
        {
            # clean bg — overlay 를 ignore (이 bg 는 해당 시점 이전/이후).
            "bg_id": "L01B01",
            "sub_location": "primary_unit",
            "camera_position": "near number 1, facing east",
            "camera_height": "eye level",
            "lens_hint": "35mm",
            "framing_notes": "",
            "use_numbered_elements": [1],
            "ignore_numbered_elements": [2],
        },
        {
            # dirty bg — overlay 를 use.
            "bg_id": "L01B02",
            "sub_location": "primary_unit",
            "camera_position": "near number 1, facing west",
            "camera_height": "eye level",
            "lens_hint": "35mm",
            "framing_notes": "",
            "use_numbered_elements": [1, 2],
            "ignore_numbered_elements": [],
        },
    ]
    _validate_fp_prompt_extras(
        fp_result=payload,
        expected_bg_ids={"L01B01", "L01B02"},
        prompt_version="6",
    )


def test_validator_v5_path_unchanged_when_use_ignore_absent():
    """v5 selector + v5-shape payload (use/ignore 필드 없음) 그대로 통과."""
    _validate_fp_prompt_extras(
        fp_result=_v5_shape_payload(),
        expected_bg_ids={"L01B01"},
        prompt_version="5",
    )


def test_validator_v5_path_skips_v6_only_check_even_if_use_ignore_present():
    """v5 selector 는 use/ignore 가 우연히 있어도 v6-only 검증 skip — verbatim 보존."""
    payload = _v5_shape_payload()
    # v5 path 에선 unknown number 99 가 use 에 있어도 raise 안 함.
    payload["camera_recommendations"][0]["use_numbered_elements"] = [99]
    payload["camera_recommendations"][0]["ignore_numbered_elements"] = []
    _validate_fp_prompt_extras(
        fp_result=payload,
        expected_bg_ids={"L01B01"},
        prompt_version="5",
    )


# ----- 5. versioned-load surface -----


def test_versioned_load_prompt_pins_v5_pack():
    text = load_prompt("floor_plan_prompt", "system", version=V5_PACK)
    assert "v5" in text.lower() or "5 / D6" in text or "plot-critical visual device" in text


def test_versioned_load_prompt_pins_v6_pack():
    text = load_prompt("floor_plan_prompt", "system", version=V6_PACK)
    assert "v6" in text.lower() or "W19A" in text
    assert "base_layer_decision" in text


def test_versioned_load_schema_pins_v5_pack():
    schema = load_schema("floor_plan_prompt", "schema", version=V5_PACK)
    cat_enum = schema["properties"]["numbered_elements"]["items"]["properties"][
        "category"
    ]["enum"]
    # v5 schema 는 plot_device 유지.
    assert "plot_device" in set(cat_enum)


def test_versioned_load_schema_pins_v6_pack():
    schema = load_schema("floor_plan_prompt", "schema", version=V6_PACK)
    cat_enum = schema["properties"]["numbered_elements"]["items"]["properties"][
        "category"
    ]["enum"]
    # v6 schema 는 plot_device 제거.
    assert "plot_device" not in set(cat_enum)
    required = set(
        schema["properties"]["numbered_elements"]["items"]["required"]
    )
    assert "base_layer_decision" in required


def test_versioned_load_unknown_version_raises_file_not_found():
    with pytest.raises(FileNotFoundError):
        load_prompt(
            "floor_plan_prompt", "system", version="99.notapackdirexists"
        )


def test_versioned_load_does_not_silently_shift_to_latest():
    """v6 pack 이 디스크에 있더라도 ``version=V5_PACK`` 호출은 v5 내용을 돌려준다.

    review §4.7 의 latest-pack auto-resolve risk mitigation 의 핵심 invariant.
    """
    v5_text = load_prompt("floor_plan_prompt", "system", version=V5_PACK)
    v6_text = load_prompt("floor_plan_prompt", "system", version=V6_PACK)
    assert v5_text != v6_text, (
        "versioned load surface failed to differentiate v5 vs v6 pack content"
    )


def test_resolve_prompt_version_unknown_selector_raises():
    # "10" 은 W21B scale-guideline pack 으로 승격 — 미지 selector 예시는 "99".
    with pytest.raises(ValueError, match="unknown floor_plan_prompt selector"):
        resolve_prompt_version("99")


def test_resolve_prompt_version_7_resolves():
    # W21B-wave-4: v7 = v6-compatible fidelity pack (Rule 11 wording only).
    assert resolve_prompt_version("7") == "7.202605291624"
    assert PROMPT_VERSION_MAP["7"] == "7.202605291624"


def test_v7_pack_load_differs_from_v6_only_by_system_wording():
    v6_sys = load_prompt("floor_plan_prompt", "system", version=V6_PACK)
    v7_sys = load_prompt(
        "floor_plan_prompt", "system", version="7.202605291624"
    )
    assert v6_sys != v7_sys  # Rule 11 self-fidelity wording added
    assert "self-fidelity" in v7_sys.lower()


def test_validator_v7_applies_v6_partition_checks():
    """v7 is schema-compatible with v6 — the partition / use-ignore
    validation must fire for v7 exactly as for v6 (not silently skip)."""
    payload = _v6_shape_payload(valid=True)
    payload["camera_recommendations"][0]["use_numbered_elements"].append(99)
    with pytest.raises(FloorPlanPromptError, match="unknown numbers"):
        _validate_fp_prompt_extras(
            fp_result=payload,
            expected_bg_ids={"L01B01"},
            prompt_version="7",
        )
