"""Tests for floor_plan_prompt — Phase 7 Step 3 / Phase 8 v2 / D6 v4 schema."""
import json
from pathlib import Path
from unittest.mock import MagicMock

import jsonschema
import pytest

from app.modules.pipeline.floor_plan_prompt import (
    FloorPlanPromptError,
    _validate_fp_prompt_extras,
    build_fp_user_prompt,
    run_floor_plan_prompt,
    validate_fp_prompt_output,
)

# D6 v4 prompt pack — bg_id pattern aligned with BG_ID_RE (^L\d{2,3}B\d{2,3}$)
# 이 디렉토리는 fix 적용 후 존재. 미적용 상태에서는 본 모듈 import 가 가능해야
# 하므로 PROMPT_DIR 은 lazy 로 평가 (fixture 안에서 read).
_V4_PROMPT_DIR = (
    Path(__file__).resolve().parent.parent.parent.parent
    / "prompts" / "_base" / "floor_plan_prompt" / "4.202605091200"
)


def _load_v4_schema() -> dict:
    return json.loads((_V4_PROMPT_DIR / "schema.json").read_text(encoding="utf-8"))


def _valid_v4_output(bg_id: str = "L10B01") -> dict:
    """v4 schema 통과를 위한 minimal valid output."""
    return {
        "fp_id": "fp_x",
        "t2i_prompt": "x" * 60,
        "key_elements": [],
        "numbered_elements": [
            {"number": 1, "label": "aa", "category": "area", "position_hint": "xxxxx"},
        ],
        "camera_recommendations": [
            {
                "bg_id": bg_id,
                "sub_location": "loc_a",
                "camera_position": "x" * 10,
                "camera_height": "y" * 5,
                "lens_hint": "35mm",
                "framing_notes": "",
            }
        ],
    }


def test_build_user_prompt_includes_full_scene_text():
    huge = "Z" * 30000
    p = build_fp_user_prompt(
        fp_spec={"fp_id": "fp_living", "sub_location": "living_room", "scope": "x"},
        applied_backgrounds=[{"bg_id": "cb_living_day"}],
        applied_shots=["S01_Shot1"],
        scene_segments=[{"scene_index": 1, "heading": "h", "text": huge}],
        visual_world_rules="rules",
    )
    assert huge in p


def test_validate_rejects_korean_in_t2i():
    out = {"fp_id": "fp_x", "t2i_prompt": "옥탑방 floor plan", "key_elements": []}
    with pytest.raises(ValueError, match="non-ASCII"):
        validate_fp_prompt_output(out, expected_fp_id="fp_x")


def test_validate_rejects_fp_id_mismatch():
    out = {"fp_id": "fp_y", "t2i_prompt": "x" * 50, "key_elements": []}
    with pytest.raises(ValueError, match="fp_id mismatch"):
        validate_fp_prompt_output(out, expected_fp_id="fp_x")


def test_run_returns_on_success():
    out = {"fp_id": "fp_x", "t2i_prompt": "x" * 50, "key_elements": ["wall"]}
    fn = MagicMock(return_value=out)
    r = run_floor_plan_prompt(
        user_prompt="x",
        expected_fp_id="fp_x",
        call_structured_fn=fn,
        sleep_fn=lambda _: None,
    )
    assert r == out


def test_run_raises_on_exhaustion():
    out = {"fp_id": "fp_y", "t2i_prompt": "x" * 50, "key_elements": []}
    fn = MagicMock(return_value=out)
    with pytest.raises(FloorPlanPromptError):
        run_floor_plan_prompt(
            user_prompt="x",
            expected_fp_id="fp_x",
            call_structured_fn=fn,
            max_retries=2,
            sleep_fn=lambda _: None,
        )


# ----- Phase 8 v2 schema validation -----


def test_floor_plan_prompt_v2_validates_numbered_elements_unique():
    """v2: numbered_elements[].number must be unique."""
    bad = {
        "fp_id": "fp_x",
        "t2i_prompt": "x" * 60,
        "key_elements": [],
        "numbered_elements": [
            {"number": 1, "label": "aa", "category": "area", "position_hint": "xxxxx"},
            {"number": 1, "label": "bb", "category": "area", "position_hint": "yyyyy"},
        ],
        "camera_recommendations": [],
    }
    with pytest.raises(FloorPlanPromptError, match="duplicate"):
        _validate_fp_prompt_extras(fp_result=bad, expected_bg_ids=set())


def test_floor_plan_prompt_v2_validates_camera_bg_ids_subset():
    """v2: camera_recommendations[].bg_id must be subset of expected_bg_ids."""
    bad = {
        "fp_id": "fp_x",
        "t2i_prompt": "x" * 60,
        "key_elements": [],
        "numbered_elements": [],
        "camera_recommendations": [
            {
                "bg_id": "bg_unknown",
                "sub_location": "x",
                "camera_position": "x" * 10,
                "camera_height": "y" * 5,
                "lens_hint": "35mm",
            }
        ],
    }
    with pytest.raises(FloorPlanPromptError, match="unknown bg_ids"):
        _validate_fp_prompt_extras(
            fp_result=bad, expected_bg_ids={"bg_known"}
        )


def test_floor_plan_prompt_v2_passes_with_valid_extras():
    """v2/v4: well-formed numbered + camera passes (exact-set match)."""
    good = {
        "fp_id": "fp_x",
        "t2i_prompt": "x" * 60,
        "key_elements": [],
        "numbered_elements": [
            {"number": 1, "label": "aa", "category": "area", "position_hint": "xxxxx"},
            {"number": 2, "label": "bb", "category": "furniture", "position_hint": "yyyyy"},
        ],
        "camera_recommendations": [
            {
                "bg_id": "bg_a",
                "sub_location": "loc_a",
                "camera_position": "x" * 10,
                "camera_height": "y" * 5,
                "lens_hint": "35mm",
            },
            {
                "bg_id": "bg_b",
                "sub_location": "loc_b",
                "camera_position": "x" * 10,
                "camera_height": "y" * 5,
                "lens_hint": "35mm",
            },
        ],
    }
    # exact-set: cam == expected → no raise
    _validate_fp_prompt_extras(
        fp_result=good, expected_bg_ids={"bg_a", "bg_b"}
    )


def test_run_passes_extras_through_when_expected_bg_ids_provided():
    """v2: run_floor_plan_prompt preserves numbered/camera fields and validates."""
    out = {
        "fp_id": "fp_x",
        "t2i_prompt": "x" * 60,
        "key_elements": [],
        "numbered_elements": [
            {
                "number": 1,
                "label": "aa",
                "category": "area",
                "position_hint": "xxxxx",
            }
        ],
        "camera_recommendations": [
            {
                "bg_id": "bg_a",
                "sub_location": "loc_a",
                "camera_position": "x" * 10,
                "camera_height": "y" * 5,
                "lens_hint": "35mm",
            }
        ],
    }
    fn = MagicMock(return_value=out)
    r = run_floor_plan_prompt(
        user_prompt="x",
        expected_fp_id="fp_x",
        call_structured_fn=fn,
        expected_bg_ids={"bg_a"},
        sleep_fn=lambda _: None,
    )
    assert r["numbered_elements"][0]["number"] == 1
    assert r["camera_recommendations"][0]["bg_id"] == "bg_a"


def test_run_rejects_unknown_bg_in_camera_recommendations():
    """v2: run_floor_plan_prompt fails when camera bg_id ∉ expected_bg_ids."""
    out = {
        "fp_id": "fp_x",
        "t2i_prompt": "x" * 60,
        "key_elements": [],
        "numbered_elements": [],
        "camera_recommendations": [
            {
                "bg_id": "bg_unknown",
                "sub_location": "loc_a",
                "camera_position": "x" * 10,
                "camera_height": "y" * 5,
                "lens_hint": "35mm",
            }
        ],
    }
    fn = MagicMock(return_value=out)
    with pytest.raises(FloorPlanPromptError):
        run_floor_plan_prompt(
            user_prompt="x",
            expected_fp_id="fp_x",
            call_structured_fn=fn,
            expected_bg_ids={"bg_a"},
            max_retries=2,
            sleep_fn=lambda _: None,
        )


# ──────────────────────────────────────────────────────────────────────
# D6 v4 schema regression (BG_ID_RE alignment)
#
# 결함 (prior session): schema 3.x 의 ``camera_recommendations[].bg_id``
# pattern (`^[a-z0-9][a-z0-9_]*$`) 가 D6 BG_ID_RE (`^L\d{2,3}B\d{2,3}$`) 를
# 구조적으로 거부 → LLM 이 ``display`` / ``go`` / ``020`` / hash 류 lowercase
# garbage hallucinate. v4 pack 으로 schema 정합 + runtime enum injection +
# validator exact-set 으로 3-layer 방어.
# ──────────────────────────────────────────────────────────────────────


class TestD6Schema:
    """v4 schema (`^L\\d{2,3}B\\d{2,3}$` bg_id pattern) 정합 검증."""

    def test_v4_schema_accepts_d6_bg_id(self):
        schema = _load_v4_schema()
        jsonschema.validate(_valid_v4_output("L10B01"), schema)

    def test_v4_schema_accepts_three_digit_loc_or_b(self):
        schema = _load_v4_schema()
        jsonschema.validate(_valid_v4_output("L100B01"), schema)
        jsonschema.validate(_valid_v4_output("L10B100"), schema)

    def test_v4_schema_rejects_pre_d6_lowercase(self):
        schema = _load_v4_schema()
        for bad in ("display", "go", "020", "bg_a", "cb_living_day"):
            with pytest.raises(jsonschema.ValidationError):
                jsonschema.validate(_valid_v4_output(bad), schema)

    def test_v4_schema_rejects_lowercase_l_b(self):
        # uppercase 강제 — `l10b01` 은 거부.
        schema = _load_v4_schema()
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(_valid_v4_output("l10b01"), schema)

    def test_v4_schema_rejects_hash_garbage(self):
        # 도면 결함 사례 — 32-char hex hash 류.
        schema = _load_v4_schema()
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(_valid_v4_output("fae4ee86" + "0" * 24), schema)


# ──────────────────────────────────────────────────────────────────────
# Validator exact-set (extras + missing + duplicate)
# ──────────────────────────────────────────────────────────────────────


class TestExactSetValidator:
    def test_raises_on_missing_expected_bg(self):
        bad = {
            "fp_id": "fp_x",
            "t2i_prompt": "x" * 60,
            "key_elements": [],
            "numbered_elements": [],
            "camera_recommendations": [
                {
                    "bg_id": "L10B01",
                    "sub_location": "loc_a",
                    "camera_position": "x" * 10,
                    "camera_height": "y" * 5,
                    "lens_hint": "35mm",
                }
            ],
        }
        # expected={L10B01, L10B02}, cam={L10B01} → missing L10B02
        with pytest.raises(FloorPlanPromptError, match="missing"):
            _validate_fp_prompt_extras(
                fp_result=bad, expected_bg_ids={"L10B01", "L10B02"}
            )

    def test_raises_on_duplicate_cam_bg_id(self):
        bad = {
            "fp_id": "fp_x",
            "t2i_prompt": "x" * 60,
            "key_elements": [],
            "numbered_elements": [],
            "camera_recommendations": [
                {
                    "bg_id": "L10B01",
                    "sub_location": "loc_a",
                    "camera_position": "x" * 10,
                    "camera_height": "y" * 5,
                    "lens_hint": "35mm",
                },
                {
                    "bg_id": "L10B01",
                    "sub_location": "loc_a",
                    "camera_position": "y" * 10,
                    "camera_height": "y" * 5,
                    "lens_hint": "50mm",
                },
            ],
        }
        with pytest.raises(FloorPlanPromptError, match="duplicate"):
            _validate_fp_prompt_extras(
                fp_result=bad, expected_bg_ids={"L10B01"}
            )

    def test_passes_on_exact_match(self):
        good = _valid_v4_output("L10B01")
        # exact: cam={L10B01}, expected={L10B01}
        _validate_fp_prompt_extras(
            fp_result=good, expected_bg_ids={"L10B01"}
        )


# ──────────────────────────────────────────────────────────────────────
# build_fp_user_prompt — D6 state_label_raw + explicit valid bg_id list
# ──────────────────────────────────────────────────────────────────────


class TestUserPromptD6:
    def test_user_prompt_uses_state_label_raw(self):
        """D6 raw intent shape — state_label_raw 필드 (state_label 아님)."""
        p = build_fp_user_prompt(
            fp_spec={"fp_id": "fp_living", "sub_location": "living", "scope": "x"},
            applied_backgrounds=[
                {
                    "bg_id": "L10B01",
                    "loc_id": "L10",
                    "state_label_raw": "kitchen_evening_normal",
                    "applies_to_shots": ["S01_Shot1", "S01_Shot2"],
                }
            ],
            applied_shots=["S01_Shot1"],
            scene_segments=[],
            visual_world_rules="rules",
        )
        assert "kitchen_evening_normal" in p
        # legacy state_label 미존재 시 빈 문자열 inject 차단.
        assert "state=)" not in p

    def test_user_prompt_lists_valid_bg_ids_explicitly(self):
        """LLM 이 schema pattern 으로 추측 안 하도록 명시적 valid list inject."""
        p = build_fp_user_prompt(
            fp_spec={"fp_id": "fp_living", "sub_location": "living", "scope": "x"},
            applied_backgrounds=[
                {"bg_id": "L10B01", "state_label_raw": "day"},
                {"bg_id": "L10B02", "state_label_raw": "night"},
            ],
            applied_shots=[],
            scene_segments=[],
            visual_world_rules="",
        )
        # 정확한 list 가 user prompt 안에 명시.
        assert "L10B01" in p
        assert "L10B02" in p
        # 헤더로 "Valid bg_ids" (case-insensitive) 명시.
        assert "valid bg_ids" in p.lower()

    def test_user_prompt_legacy_state_label_fallback(self):
        """legacy 데이터 (state_label 만) 도 안전 — 미정의 시 빈 문자열."""
        p = build_fp_user_prompt(
            fp_spec={"fp_id": "fp_x", "sub_location": "x", "scope": ""},
            applied_backgrounds=[
                {"bg_id": "L99B01", "state_label": "old_label"}
            ],
            applied_shots=[],
            scene_segments=[],
            visual_world_rules="",
        )
        # legacy state_label 도 fallback 으로 inject (D6 가 아닌 옛 cp 호환).
        assert "old_label" in p


# ──────────────────────────────────────────────────────────────────────
# Runtime enum injection — schema deep-copy + bg_id.enum=expected
# ──────────────────────────────────────────────────────────────────────


class TestRuntimeEnumInjection:
    def test_run_injects_bg_id_enum_when_expected_non_empty(self):
        """expected_bg_ids 비어있지 않으면 schema deep-copy + enum 주입."""
        out = _valid_v4_output("L10B01")
        fn = MagicMock(return_value=out)
        run_floor_plan_prompt(
            user_prompt="x",
            expected_fp_id="fp_x",
            call_structured_fn=fn,
            expected_bg_ids={"L10B01"},
            sleep_fn=lambda _: None,
        )
        # call_structured 가 받은 response_schema 추출.
        kwargs = fn.call_args.kwargs
        passed_schema = kwargs["response_schema"]
        cam_bg = (
            passed_schema["properties"]["camera_recommendations"]["items"]
            ["properties"]["bg_id"]
        )
        assert cam_bg.get("enum") == ["L10B01"]

    def test_run_skips_enum_injection_when_expected_empty(self):
        """expected_bg_ids 빈 set 이면 enum 주입 안 함 (empty enum = invalid schema)."""
        # cam = [] 인 fp 도면 (orphan) — validator 통과해야 함.
        out = {
            "fp_id": "fp_x",
            "t2i_prompt": "x" * 60,
            "key_elements": [],
            "numbered_elements": [],
            "camera_recommendations": [],
        }
        fn = MagicMock(return_value=out)
        run_floor_plan_prompt(
            user_prompt="x",
            expected_fp_id="fp_x",
            call_structured_fn=fn,
            expected_bg_ids=set(),
            sleep_fn=lambda _: None,
        )
        kwargs = fn.call_args.kwargs
        passed_schema = kwargs["response_schema"]
        cam_bg = (
            passed_schema["properties"]["camera_recommendations"]["items"]
            ["properties"]["bg_id"]
        )
        # enum 없음 — pattern 만 (그대로 v4 pattern 유지).
        assert "enum" not in cam_bg
        assert cam_bg.get("pattern") == r"^L\d{2,3}B\d{2,3}$"

    def test_run_does_not_mutate_loaded_schema_across_calls(self):
        """deep-copy 미사용 시 첫 호출의 enum 이 두 번째 호출에 누수.

        같은 process 내에서 다른 fp (다른 expected_bg_ids) 호출 시 schema 가
        오염되지 않는지 확인. 첫 호출은 {L10B01}, 두 번째 호출은 {L20B01}.
        """
        out1 = _valid_v4_output("L10B01")
        out2 = _valid_v4_output("L20B01")
        fn1 = MagicMock(return_value=out1)
        fn2 = MagicMock(return_value=out2)
        run_floor_plan_prompt(
            user_prompt="x",
            expected_fp_id="fp_x",
            call_structured_fn=fn1,
            expected_bg_ids={"L10B01"},
            sleep_fn=lambda _: None,
        )
        run_floor_plan_prompt(
            user_prompt="x",
            expected_fp_id="fp_x",
            call_structured_fn=fn2,
            expected_bg_ids={"L20B01"},
            sleep_fn=lambda _: None,
        )
        s1 = fn1.call_args.kwargs["response_schema"]
        s2 = fn2.call_args.kwargs["response_schema"]
        e1 = s1["properties"]["camera_recommendations"]["items"]["properties"]["bg_id"]["enum"]
        e2 = s2["properties"]["camera_recommendations"]["items"]["properties"]["bg_id"]["enum"]
        assert e1 == ["L10B01"]
        assert e2 == ["L20B01"]
        # 두 schema 객체가 별개 (deep-copy).
        assert s1 is not s2
