"""Tests for background_prompt — Phase 7 Step 5 / Phase 8 v2 / D6 v6.

Phase 8 v2: t2i_prompt 본문은 source language(scene_segments 언어)이므로
ASCII-only 검증은 식별자(bg_id, sub_location, state_label, shot_id) 에만 적용되고
t2i_prompt 본문에는 적용되지 않는다. user_template에 numbered_elements +
camera_recommendation 두 신규 블록이 inject된다.

D6 v6: bg_id pattern → BG_ID_RE (`^L\\d{2,3}B\\d{2,3}$`). user_template 의
state_label / sub_location 필드를 D6 raw intent (state_label_raw /
sub_location_label) 우선 으로 inject. ``run_background_prompt`` 가 schema
deep-copy + ``bg_id.enum=[expected_bg_id]`` 주입 (LLM 출력 강제).
"""
import json
from pathlib import Path
from unittest.mock import MagicMock

import jsonschema
import pytest

from app.modules.pipeline.background_prompt import (
    BackgroundPromptError,
    _format_camera_recommendation_block,
    _format_numbered_elements_block,
    build_bg_user_prompt,
    run_background_prompt,
    validate_bg_prompt_output,
)

# D6 v6 prompt pack — bg_id pattern aligned with BG_ID_RE.
_V6_PROMPT_DIR = (
    Path(__file__).resolve().parent.parent.parent.parent
    / "prompts" / "_base" / "background_prompt" / "6.202605091200"
)


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


def _valid_v6_output(bg_id: str = "L10B01") -> dict:
    """v6 schema 통과를 위한 minimal valid output."""
    return {
        "bg_id": bg_id,
        "t2i_prompt": "x" * 60,
        "ref_guide": "x",
        "shot_guides": [{"shot_id": "S01_Shot1", "guide_text": "g"}],
        "objects_owned_by_background": ["door"],
    }


def test_build_user_prompt_includes_full_scene_text():
    huge = "B" * 30000
    p = build_bg_user_prompt(
        bg_spec={
            "bg_id": "cb_x",
            "loc_id": "L01",
            "sub_location": "living_room",
            "state_label": "day_normal",
            "applies_to_shots": ["S01_Shot1"],
        },
        floor_plan_path="/tmp/fp.png",
        prior_bg_paths=[],
        scene_segments=[{"scene_index": 1, "heading": "h", "text": huge}],
        visual_world_rules="rules",
    )
    assert huge in p


def test_validate_rejects_missing_shot_guide():
    out = {
        "bg_id": "cb_x",
        "t2i_prompt": "x" * 60,
        "ref_guide": "x",
        "shot_guides": [],
    }
    with pytest.raises(ValueError, match="shot_guide"):
        validate_bg_prompt_output(out, "cb_x", {"S01_Shot1"})


def test_validate_passes_minimal():
    out = {
        "bg_id": "cb_x",
        "t2i_prompt": "x" * 60,
        "ref_guide": "x",
        "shot_guides": [{"shot_id": "S01_Shot1", "guide_text": "h"}],
        # G3.2: validate 가 owned 의 normalize + 1+ entries 강제.
        "objects_owned_by_background": ["door"],
    }
    validate_bg_prompt_output(out, "cb_x", {"S01_Shot1"})


def test_run_retries_then_returns():
    bad = {
        "bg_id": "cb_x",
        "t2i_prompt": "x" * 60,
        "ref_guide": "x",
        "shot_guides": [],
        # G3.2: bad output 도 owned 부재로 fail 발생 (shot_guides 부재가 먼저 catch).
        "objects_owned_by_background": ["door"],
    }
    good = {
        "bg_id": "cb_x",
        "t2i_prompt": "x" * 60,
        "ref_guide": "x",
        "shot_guides": [{"shot_id": "S01_Shot1", "guide_text": "g"}],
        "objects_owned_by_background": ["door"],
    }
    fn = MagicMock(side_effect=[bad, good])
    r = run_background_prompt(
        user_prompt="x",
        expected_bg_id="cb_x",
        applies_to_shots=["S01_Shot1"],
        call_structured_fn=fn,
        sleep_fn=lambda _: None,
    )
    assert r == good


# ----- Phase 8 v2: numbered/camera inject + source-language t2i -----


def test_background_prompt_v2_inject_numbered_block():
    """v2: build_bg_user_prompt가 numbered_elements를 직렬화해 user prompt에 포함."""
    user_prompt = build_bg_user_prompt(
        bg_spec={
            "bg_id": "bg_x",
            "loc_id": "L01",
            "sub_location": "living_room",
            "state_label": "day_normal",
            "applies_to_shots": ["S1_Shot1"],
        },
        floor_plan_path="/tmp/fp.png",
        prior_bg_paths=[],
        scene_segments=[{"scene_index": 1, "heading": "EXT.", "text": "..."}],
        visual_world_rules="rules text",
        numbered_elements=[
            {
                "number": 1,
                "label": "wardrobe",
                "category": "furniture",
                "position_hint": "north wall",
            },
            {
                "number": 2,
                "label": "window",
                "category": "opening",
                "position_hint": "east wall",
            },
        ],
        camera_recommendation={
            "bg_id": "bg_x",
            "sub_location": "living_room",
            "camera_position": "near number 1, facing 2",
            "camera_height": "1.6m",
            "lens_hint": "35mm",
        },
    )
    assert "1. wardrobe [furniture] — north wall" in user_prompt
    assert "2. window [opening] — east wall" in user_prompt
    assert "near number 1, facing 2" in user_prompt
    assert "camera_height: 1.6m" in user_prompt
    assert "lens_hint: 35mm" in user_prompt


def test_background_prompt_v2_blocks_blank_when_no_camera():
    """v2: numbered_elements + camera_recommendation None일 때 fallback 가이드 문자열."""
    up = build_bg_user_prompt(
        bg_spec={
            "bg_id": "bg_x",
            "loc_id": "L01",
            "sub_location": "x",
            "state_label": "x",
            "applies_to_shots": [],
        },
        floor_plan_path=None,
        prior_bg_paths=[],
        scene_segments=[],
        visual_world_rules="",
        numbered_elements=None,
        camera_recommendation=None,
    )
    # fallback 가이드 문자열이 양쪽 블록 자리에 들어가야 함
    assert up.count("(none — derive from scene segments and shot descriptions)") >= 2


def test_background_prompt_v2_allows_korean_t2i_prompt():
    """v2: t2i_prompt 본문에 한국어가 들어가도 검증을 통과해야 함 (ASCII guard 제거).

    spec 결정사항 #10: background_prompt t2i_prompt 출력 언어 = scene_segments
    원문 언어. 한국 시나리오면 한국어 본문이 정상 출력. 따라서 v1 의 ASCII-only
    검증은 v2 에서 제거되어야 한다.
    """
    out = {
        "bg_id": "cb_x",
        # 길이 invariant(>=50) 는 유지되므로 충분히 긴 한국어 본문
        "t2i_prompt": (
            "거실 내부의 시네마틱 인테리어 사진 — 35mm 광각, 시선 높이, "
            "16:9 시네마틱 화면비, 자연스러운 측면광"
        ),
        "ref_guide": "도면을 layout 참고용으로만 사용",
        "shot_guides": [
            {"shot_id": "S01_Shot1", "guide_text": "인물 없이 배경만, 측면광 강조"}
        ],
        # G3.2: owned 는 영어 canonical (한국어 t2i 본문과 별개로 ASCII).
        "objects_owned_by_background": ["window"],
    }
    # 한국어가 들어 있어도 예외 없이 통과해야 함
    validate_bg_prompt_output(out, "cb_x", {"S01_Shot1"})


def test_background_prompt_v2_validates_bg_id_ascii_identifier():
    """v2: 식별자 ASCII validation은 그대로 유지되어야 함."""
    out = {
        "bg_id": "cb_x",
        "t2i_prompt": "x" * 60,
        "ref_guide": "x",
        "shot_guides": [{"shot_id": "S01_Shot1", "guide_text": "h"}],
        # G3.2: validate 가 owned 1+ entries 강제.
        "objects_owned_by_background": ["door"],
    }
    # 정상 ASCII bg_id는 통과
    validate_bg_prompt_output(out, "cb_x", {"S01_Shot1"})
    # bg_id 불일치는 여전히 실패
    with pytest.raises(ValueError, match="bg_id mismatch"):
        validate_bg_prompt_output(out, "cb_y", {"S01_Shot1"})


def test_background_prompt_v2_format_numbered_elements_helper():
    """v2: _format_numbered_elements_block helper 직접 검증."""
    items = [
        {
            "number": 3,
            "label": "low table",
            "category": "furniture",
            "position_hint": "center",
        }
    ]
    out = _format_numbered_elements_block(items)
    assert "3. low table [furniture] — center" in out

    # None / empty 둘 다 fallback
    fb = _format_numbered_elements_block(None)
    assert "(none — derive from scene segments and shot descriptions)" == fb
    assert _format_numbered_elements_block([]) == fb


def test_background_prompt_v2_format_camera_recommendation_helper():
    """v2: _format_camera_recommendation_block helper 직접 검증."""
    cam = {
        "bg_id": "bg_x",
        "sub_location": "loc_x",
        "camera_position": "near number 1",
        "camera_height": "1.6m",
        "lens_hint": "35mm",
        "framing_notes": "wide",
    }
    out = _format_camera_recommendation_block(cam)
    assert "camera_position: near number 1" in out
    assert "camera_height: 1.6m" in out
    assert "lens_hint: 35mm" in out
    assert "framing_notes: wide" in out

    # framing_notes 없으면 그 줄 생략
    cam_no_frame = {
        "bg_id": "bg_x",
        "sub_location": "loc_x",
        "camera_position": "x" * 10,
        "camera_height": "y" * 5,
        "lens_hint": "35mm",
    }
    out2 = _format_camera_recommendation_block(cam_no_frame)
    assert "framing_notes" not in out2

    # None 시 fallback
    assert (
        _format_camera_recommendation_block(None)
        == "(none — derive from scene segments and shot descriptions)"
    )


# ──────────────────────────────────────────────────────────────────────
# D6 v6 schema regression (BG_ID_RE alignment + state_label_raw)
#
# 결함 (review 지적):
# 1. schema 5.x 의 ``bg_id`` pattern (`^[a-z0-9][a-z0-9_]*$`) 가 D6 BG_ID_RE
#    (`^L\d{2,3}B\d{2,3}$`) 를 거부 → LLM 이 lowercase 출력 → validator 의
#    ``bg_id != expected_bg_id`` exact match 와 충돌 → exhaustion BLOCK.
# 2. user_template 가 ``{state_label}`` / ``{sub_location}`` 사용. D6 raw intent
#    는 ``state_label_raw`` / ``sub_location_label`` → 빈 문자열 inject.
# v6: schema bg_id pattern → BG_ID_RE + builder D6 fields 우선 (legacy fallback).
# ──────────────────────────────────────────────────────────────────────


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

    def test_v6_schema_accepts_d6_bg_id(self):
        schema = _load_v6_schema()
        jsonschema.validate(_valid_v6_output("L10B01"), schema)

    def test_v6_schema_accepts_three_digit_loc_or_b(self):
        schema = _load_v6_schema()
        jsonschema.validate(_valid_v6_output("L100B01"), schema)
        jsonschema.validate(_valid_v6_output("L10B100"), schema)

    def test_v6_schema_rejects_pre_d6_lowercase(self):
        schema = _load_v6_schema()
        for bad in ("display", "bg_a", "cb_living_day", "cb_x"):
            with pytest.raises(jsonschema.ValidationError):
                jsonschema.validate(_valid_v6_output(bad), schema)

    def test_v6_schema_rejects_lowercase_l_b(self):
        schema = _load_v6_schema()
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(_valid_v6_output("l10b01"), schema)


class TestUserPromptD6Fields:
    """builder 가 D6 raw intent 필드 (state_label_raw / sub_location_label) 우선 사용."""

    def test_user_prompt_uses_state_label_raw(self):
        p = build_bg_user_prompt(
            bg_spec={
                "bg_id": "L10B01",
                "loc_id": "L10",
                "sub_location_label": "kitchen",
                "state_label_raw": "evening_lit_normal",
                "applies_to_shots": ["S01_Shot1"],
            },
            floor_plan_path="/tmp/fp.png",
            prior_bg_paths=[],
            scene_segments=[],
            visual_world_rules="",
        )
        assert "evening_lit_normal" in p
        # placeholder 미치환 / 빈 inject 차단.
        assert "{state_label}" not in p
        assert "state_label: \n" not in p
        assert "state_label_raw:" in p or "state:" in p

    def test_user_prompt_uses_sub_location_label(self):
        p = build_bg_user_prompt(
            bg_spec={
                "bg_id": "L10B01",
                "loc_id": "L10",
                "sub_location_label": "rooftop",
                "state_label_raw": "day_norm",
            },
            floor_plan_path="/tmp/fp.png",
            prior_bg_paths=[],
            scene_segments=[],
            visual_world_rules="",
        )
        assert "rooftop" in p
        assert "{sub_location}" not in p

    def test_user_prompt_legacy_state_label_fallback(self):
        """legacy cp (state_label / sub_location) 도 안전 — D6 필드 부재 시 fallback."""
        p = build_bg_user_prompt(
            bg_spec={
                "bg_id": "L99B01",
                "loc_id": "L99",
                "sub_location": "old_room",
                "state_label": "old_state",
            },
            floor_plan_path="/tmp/fp.png",
            prior_bg_paths=[],
            scene_segments=[],
            visual_world_rules="",
        )
        assert "old_room" in p
        assert "old_state" in p


class TestRuntimeEnumInjection:
    """run_background_prompt 가 schema deep-copy + bg_id.enum=[expected] 주입."""

    def test_run_injects_bg_id_enum(self):
        out = _valid_v6_output("L10B01")
        fn = MagicMock(return_value=out)
        run_background_prompt(
            user_prompt="x",
            expected_bg_id="L10B01",
            applies_to_shots=["S01_Shot1"],
            call_structured_fn=fn,
            sleep_fn=lambda _: None,
        )
        kwargs = fn.call_args.kwargs
        bg_field = kwargs["response_schema"]["properties"]["bg_id"]
        assert bg_field.get("enum") == ["L10B01"]

    def test_run_does_not_mutate_loaded_schema_across_calls(self):
        """deep-copy 미사용 시 첫 호출의 enum 이 두 번째 호출에 누수."""
        out1 = _valid_v6_output("L10B01")
        out2 = _valid_v6_output("L20B05")
        fn1 = MagicMock(return_value=out1)
        fn2 = MagicMock(return_value=out2)
        run_background_prompt(
            user_prompt="x",
            expected_bg_id="L10B01",
            applies_to_shots=["S01_Shot1"],
            call_structured_fn=fn1,
            sleep_fn=lambda _: None,
        )
        run_background_prompt(
            user_prompt="x",
            expected_bg_id="L20B05",
            applies_to_shots=["S01_Shot1"],
            call_structured_fn=fn2,
            sleep_fn=lambda _: None,
        )
        s1 = fn1.call_args.kwargs["response_schema"]
        s2 = fn2.call_args.kwargs["response_schema"]
        assert s1["properties"]["bg_id"]["enum"] == ["L10B01"]
        assert s2["properties"]["bg_id"]["enum"] == ["L20B05"]
        assert s1 is not s2
