import pytest
from unittest.mock import MagicMock
from app.modules.pipeline.background_master_plan import (
    build_master_plan_user_prompt,
    validate_master_plan_output,
    run_background_master_plan,
    MasterPlanError,
)


def _good_plan():
    return {
        "group_id": "bg_x",
        "rationale_summary": "...",
        "floor_plans": [
            {"fp_id": "fp_living", "sub_location": "living_room", "scope": "...", "depends_on_fp": []},
        ],
        "backgrounds": [
            {"bg_id": "cb_living_day", "loc_id": "L01", "sub_location": "living_room",
             "state_label": "day_normal", "depends_on_fp": ["fp_living"],
             "depends_on_bg": [], "applies_to_shots": ["S01_Shot1"]},
        ],
        "gen_order": ["fp_living", "cb_living_day"],
    }


def test_validate_passes_minimal():
    validate_master_plan_output(_good_plan(), expected_group_id="bg_x",
                                 group_loc_ids={"L01"}, group_shot_ids={"S01_Shot1"})


def test_invariant_1_korean_id_rejected():
    plan = _good_plan()
    plan["floor_plans"][0]["fp_id"] = "fp_거실"
    with pytest.raises(ValueError, match="non-ASCII"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_2_bg_without_fp_ref_rejected():
    plan = _good_plan()
    plan["backgrounds"][0]["depends_on_fp"] = []
    with pytest.raises(ValueError, match="depends_on_fp"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_3_cross_group_shot_rejected():
    plan = _good_plan()
    plan["backgrounds"][0]["applies_to_shots"] = ["S99_Shot1"]
    with pytest.raises(ValueError, match="applies_to_shots"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_4_same_sublocation_must_share_fp():
    plan = _good_plan()
    plan["floor_plans"].append(
        {"fp_id": "fp_living2", "sub_location": "living_room", "scope": "...", "depends_on_fp": []}
    )
    plan["backgrounds"].append({
        "bg_id": "cb_living_dusk", "loc_id": "L01", "sub_location": "living_room",
        "state_label": "dusk", "depends_on_fp": ["fp_living2"],
        "depends_on_bg": [], "applies_to_shots": []
    })
    plan["gen_order"] = ["fp_living", "fp_living2", "cb_living_day", "cb_living_dusk"]
    with pytest.raises(ValueError, match="sub_location"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_4b_bg_sub_location_must_match_fp_sub_location():
    """bg가 가리키는 fp의 sub_location이 bg.sub_location과 다르면 거절 (sub-room mismatch fix)."""
    plan = _good_plan()
    # plan은 fp_living(sub=living_room), cb_living_day(sub=living_room) — OK.
    # bedroom 도면 + bedroom bg를 추가 (정상). 그러나 bedroom bg가 living_room fp를 ref → 위반.
    plan["floor_plans"].append(
        {"fp_id": "fp_bedroom", "sub_location": "bedroom", "scope": "...", "depends_on_fp": []}
    )
    plan["backgrounds"].append({
        "bg_id": "cb_bedroom_night", "loc_id": "L01", "sub_location": "bedroom",
        "state_label": "night", "depends_on_fp": ["fp_living"],  # 잘못된 ref!
        "depends_on_bg": [], "applies_to_shots": []
    })
    plan["gen_order"] = ["fp_living", "fp_bedroom", "cb_living_day", "cb_bedroom_night"]
    with pytest.raises(ValueError, match="mismatches floor_plan"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_5_chain_within_sublocation():
    plan = _good_plan()
    plan["backgrounds"].append({
        "bg_id": "cb_living_dusk", "loc_id": "L01", "sub_location": "living_room",
        "state_label": "dusk", "depends_on_fp": ["fp_living"],
        "depends_on_bg": [],  # 같은 sub_location 두 번째인데 chain 미지정 → 위반
        "applies_to_shots": []
    })
    plan["gen_order"] = ["fp_living", "cb_living_day", "cb_living_dusk"]
    with pytest.raises(ValueError, match="chain"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_6_topological_violation_rejected():
    plan = _good_plan()
    plan["gen_order"] = ["cb_living_day", "fp_living"]  # fp가 bg 뒤에 옴 — 위반
    with pytest.raises(ValueError, match="topolog"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_8_loc_id_not_in_group_rejected():
    plan = _good_plan()
    plan["backgrounds"][0]["loc_id"] = "L99"
    with pytest.raises(ValueError, match="loc_id"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def _good_plan_d6():
    """D6 raw intent shape — `run_background_master_plan` 가 호출하는 새 validator 통과."""
    return {
        "group_id": "bg_x",
        "rationale_summary": "...",
        "floor_plans": [
            {"fp_id": "fp_living", "loc_id": "L01", "space_key_hint": "main",
             "sub_location": "living_room", "scope": "...", "depends_on_fp": []},
        ],
        "backgrounds": [
            {
                "loc_id": "L01",
                "space_key_hint": "main",
                "time_phase": "dusk",
                "state_class": "normal",
                "surface_role": "interior_room",
                "applies_to_shots": ["S01_Shot1"],
                "sub_location_label": "living room",
                "state_label_raw": "dusk normal",
                "depends_on_fp": ["fp_living"],
                "depends_on_bg": [],
            },
        ],
    }


_PROFILES_D6 = {"L01": {"kind": "single_space", "allowed_space_keys": ["main"]}}


def test_run_retries_on_invariant_failure():
    """D6 raw intent validator 가 첫 attempt 의 loc_id 위반 reject 후 두 번째 통과."""
    bad = _good_plan_d6()
    bad["backgrounds"][0]["loc_id"] = "L99"  # group 밖 loc_id
    good = _good_plan_d6()
    fn = MagicMock(side_effect=[bad, good])
    result = run_background_master_plan(
        user_prompt="x",
        expected_group_id="bg_x",
        group_loc_ids=["L01"],
        group_shot_ids=["S01_Shot1"],
        location_profiles=_PROFILES_D6,
        call_structured_fn=fn,
        sleep_fn=lambda _: None,
    )
    assert result["group_id"] == "bg_x"
    assert fn.call_count == 2


def test_run_raises_after_exhaustion():
    bad = _good_plan_d6()
    bad["backgrounds"][0]["loc_id"] = "L99"
    fn = MagicMock(return_value=bad)
    with pytest.raises(MasterPlanError):
        run_background_master_plan(
            user_prompt="x",
            expected_group_id="bg_x",
            group_loc_ids=["L01"],
            group_shot_ids=["S01_Shot1"],
            location_profiles=_PROFILES_D6,
            call_structured_fn=fn,
            max_retries=2,
            sleep_fn=lambda _: None,
        )


def test_validate_raises_semantic_key_error_when_profile_missing():
    """회귀 가드: location_profile 없는 loc_id 는 SemanticKeyError 로 raise (NameError 아님).

    background_master_plan.py 가 bg_catalog.SemanticKeyError 를 import 하지 않아
    NameError 로 가려졌던 버그 (W21B fresh E2E 에서 bg_rooftop_villa group 이 3-retry
    소진) 재발 방지. import 가 빠지면 이 테스트는 NameError 로 실패한다.
    """
    from app.core.bg_catalog import SemanticKeyError
    from app.modules.pipeline.background_master_plan import (
        validate_master_plan_raw_intent,
    )

    plan = _good_plan_d6()
    with pytest.raises(SemanticKeyError):
        validate_master_plan_raw_intent(
            plan,
            "bg_x",
            {"L01"},
            {"S01_Shot1"},
            location_profiles={},  # L01 profile 누락 → SemanticKeyError 경로
        )


def test_build_user_prompt_includes_full_scene_text():
    """truncation 금지 — 전체 scene 본문 포함."""
    huge_text = "A" * 50000
    prompt = build_master_plan_user_prompt(
        group={"group_id": "bg_x", "members": [{"loc_id": "L01", "label": "x"}]},
        scenes=[{"scene_index": 1, "heading": "h", "text": huge_text, "shots": []}],
        visual_world_rules="rules",
        location_profiles={"L01": {"kind": "single_space", "allowed_space_keys": ["main"]}},
    )
    assert huge_text in prompt


def test_build_user_prompt_injects_space_profile_marker():
    """E2E v1: members block 에 space_profile kind + allowed_space_keys marker 주입.

    single_space location 을 LLM 이 sub-room 으로 분할하지 않도록 하는 SOT 전달.
    """
    prompt = build_master_plan_user_prompt(
        group={
            "group_id": "bg_x",
            "members": [
                {"loc_id": "L05", "label": "옥탑방 내부", "is_indoor": True, "shot_count": 12},
                {"loc_id": "L04", "label": "빌라 마당", "is_indoor": False, "shot_count": 5},
            ],
        },
        scenes=[{"scene_index": 1, "heading": "h", "text": "t", "shots": []}],
        visual_world_rules="rules",
        location_profiles={
            "L05": {"kind": "single_space", "allowed_space_keys": ["main"]},
            "L04": {"kind": "multi_space", "allowed_space_keys": ["main", "yard", "stairs"]},
        },
    )
    assert "[space_profile: single_space — allowed_space_keys: main]" in prompt
    assert "[space_profile: multi_space — allowed_space_keys: main, yard, stairs]" in prompt


def test_invariant_1_uppercase_id_rejected():
    """`Fp_Living` (mixed-case) violates SAFE_ID 패턴 — non-Korean ASCII fail."""
    plan = _good_plan()
    plan["floor_plans"][0]["fp_id"] = "Fp_Living"
    with pytest.raises(ValueError, match="ASCII snake_case|non-ASCII"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_2_unknown_fp_ref_rejected():
    """background.depends_on_fp 참조가 floor_plans에 없으면 reject."""
    plan = _good_plan()
    plan["backgrounds"][0]["depends_on_fp"] = ["fp_unknown"]
    plan["gen_order"] = ["fp_living", "cb_living_day"]
    with pytest.raises(ValueError, match="not in plan"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_7_fp_id_duplicate_rejected():
    """floor_plans.fp_id 중복 reject."""
    plan = _good_plan()
    plan["floor_plans"].append(
        {"fp_id": "fp_living", "sub_location": "living_room",
         "scope": "duplicate", "depends_on_fp": []}
    )
    plan["gen_order"] = ["fp_living", "fp_living", "cb_living_day"]
    with pytest.raises(ValueError, match="duplicates"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})


def test_invariant_7_gen_order_set_mismatch_rejected():
    """gen_order에 missing entry → set 불일치."""
    plan = _good_plan()
    plan["gen_order"] = ["fp_living"]  # cb_living_day 누락
    with pytest.raises(ValueError, match="gen_order set mismatch"):
        validate_master_plan_output(plan, "bg_x", {"L01"}, {"S01_Shot1"})
