from unittest.mock import MagicMock
import pytest
from app.modules.pipeline.background_classify import (
    build_classify_user_prompt,
    validate_classify_output,
    run_background_classify,
    ClassifyError,
)


def test_build_user_prompt_includes_locations():
    locations = [
        {"loc_id": "L01", "label": "옥탑방", "shot_count": 12, "is_indoor": True,
         "summary": "main rooftop unit interior"},
        {"loc_id": "L02", "label": "옥상", "shot_count": 4, "is_indoor": False,
         "summary": "rooftop terrace"},
    ]
    prompt = build_classify_user_prompt(locations, "rules text")
    assert "L01" in prompt and "L02" in prompt
    assert "rules text" in prompt
    # truncation 검사 — 모든 location 포함
    assert "옥탑방" in prompt and "옥상" in prompt
    # indoor/outdoor 마커 포함
    assert "indoor" in prompt and "outdoor" in prompt
    # summary도 포함 (truncation 없음)
    assert "main rooftop unit interior" in prompt


def test_build_user_prompt_handles_brace_safely():
    """visual_world_rules에 { } 가 있어도 .format() 충돌 없이 통과."""
    locations = [{"loc_id": "L01", "label": "x", "shot_count": 1, "is_indoor": True}]
    rules = "rule with {brace} markers"
    prompt = build_classify_user_prompt(locations, rules)
    assert "{brace}" in prompt


def test_validate_passes_partition_cover():
    out = {"building_groups": [
        {
            "group_id": "bg_apt", "anchor_loc": "L01", "kind": "chain_bg",
            "members": [
                {"loc_id": "L01", "label": "living", "shot_count": 5, "is_indoor": True},
                {"loc_id": "L02", "label": "balcony", "shot_count": 2, "is_indoor": False},
            ],
            "rationale": "ok",
        },
    ]}
    validate_classify_output(out, all_loc_ids={"L01", "L02"})  # no raise


def test_validate_clusters_indoor_pair_chain_bg():
    """LLM이 두 indoor location을 한 chain_bg group으로 묶는 시나리오 — 통과."""
    out = {"building_groups": [
        {
            "group_id": "bg_apt", "anchor_loc": "L05", "kind": "chain_bg",
            "members": [
                {"loc_id": "L05", "label": "living", "shot_count": 4, "is_indoor": True},
                {"loc_id": "L10", "label": "kitchen", "shot_count": 3, "is_indoor": True},
            ],
            "rationale": "같은 아파트 거실+주방, 7 shots, indoor anchor 있음",
        },
    ]}
    validate_classify_output(
        out,
        all_loc_ids={"L05", "L10"},
        indoor_loc_ids={"L05", "L10"},
        shot_counts={"L05": 4, "L10": 3},
    )  # no raise


def test_validate_solo_outdoor_prev_shot_ref():
    """단독 outdoor location → prev_shot_ref."""
    out = {"building_groups": [
        {
            "group_id": "bg_street", "anchor_loc": "L20", "kind": "prev_shot_ref",
            "members": [
                {"loc_id": "L20", "label": "alley", "shot_count": 5, "is_indoor": False},
            ],
            "rationale": "단독 야외, indoor 없음",
        },
    ]}
    validate_classify_output(
        out,
        all_loc_ids={"L20"},
        indoor_loc_ids=set(),
        shot_counts={"L20": 5},
    )  # no raise


def test_validate_rejects_missing_loc_in_cover():
    """L02가 어느 그룹에도 없으면 partition cover 위반."""
    out = {"building_groups": [
        {
            "group_id": "bg_x", "anchor_loc": "L01", "kind": "chain_bg",
            "members": [
                {"loc_id": "L01", "label": "x", "shot_count": 5, "is_indoor": True},
            ],
            "rationale": "ok",
        },
    ]}
    with pytest.raises(ValueError, match="missing loc_ids"):
        validate_classify_output(out, all_loc_ids={"L01", "L02"})


def test_validate_rejects_duplicate_cover():
    """L01이 두 그룹에 등장하면 partition cover 위반."""
    out = {"building_groups": [
        {
            "group_id": "bg_a", "anchor_loc": "L01", "kind": "chain_bg",
            "members": [{"loc_id": "L01", "label": "a", "shot_count": 5, "is_indoor": True}],
            "rationale": "ok",
        },
        {
            "group_id": "bg_b", "anchor_loc": "L01", "kind": "chain_bg",
            "members": [{"loc_id": "L01", "label": "b", "shot_count": 4, "is_indoor": True}],
            "rationale": "ok",
        },
    ]}
    with pytest.raises(ValueError, match="partition cover"):
        validate_classify_output(out, all_loc_ids={"L01"})


def test_validate_rejects_anchor_not_in_members():
    """anchor_loc이 그룹의 멤버가 아니면 거부."""
    out = {"building_groups": [
        {
            "group_id": "bg_x", "anchor_loc": "L99", "kind": "chain_bg",
            "members": [{"loc_id": "L01", "label": "x", "shot_count": 5, "is_indoor": True}],
            "rationale": "ok",
        },
    ]}
    with pytest.raises(ValueError, match="anchor_loc"):
        validate_classify_output(out, all_loc_ids={"L01"})


def test_validate_rejects_unknown_member_loc():
    """member loc_id가 입력에 없으면 거부."""
    out = {"building_groups": [
        {
            "group_id": "bg_x", "anchor_loc": "L99", "kind": "chain_bg",
            "members": [{"loc_id": "L99", "label": "x", "shot_count": 5, "is_indoor": True}],
            "rationale": "ok",
        },
    ]}
    with pytest.raises(ValueError, match="not in input locations"):
        validate_classify_output(out, all_loc_ids={"L01"})


def test_validate_rejects_korean_group_id():
    out = {"building_groups": [
        {
            "group_id": "옥탑방", "anchor_loc": "L01", "kind": "chain_bg",
            "members": [{"loc_id": "L01", "label": "x", "shot_count": 5, "is_indoor": True}],
            "rationale": "ok",
        },
    ]}
    with pytest.raises(ValueError, match="non-ASCII"):
        validate_classify_output(out, all_loc_ids={"L01"})


def test_validate_rejects_chain_bg_without_indoor():
    """chain_bg인데 indoor 멤버 없으면 거부."""
    out = {"building_groups": [
        {
            "group_id": "bg_x", "anchor_loc": "L20", "kind": "chain_bg",
            "members": [
                {"loc_id": "L20", "label": "park", "shot_count": 5, "is_indoor": False},
            ],
            "rationale": "ok",
        },
    ]}
    with pytest.raises(ValueError, match="no indoor member"):
        validate_classify_output(
            out,
            all_loc_ids={"L20"},
            indoor_loc_ids=set(),
            shot_counts={"L20": 5},
        )


def test_validate_rejects_chain_bg_without_enough_shots():
    """chain_bg인데 sum(shot_count) < 3이면 거부."""
    out = {"building_groups": [
        {
            "group_id": "bg_x", "anchor_loc": "L01", "kind": "chain_bg",
            "members": [
                {"loc_id": "L01", "label": "x", "shot_count": 2, "is_indoor": True},
            ],
            "rationale": "ok",
        },
    ]}
    with pytest.raises(ValueError, match="< 3"):
        validate_classify_output(
            out,
            all_loc_ids={"L01"},
            indoor_loc_ids={"L01"},
            shot_counts={"L01": 2},
        )


def test_run_retries_on_invariant_failure():
    bad = {"building_groups": [
        {"group_id": "bg_x", "anchor_loc": "L99", "kind": "chain_bg",
         "members": [{"loc_id": "L99", "label": "x", "shot_count": 5, "is_indoor": True}],
         "rationale": "x"},
    ]}
    good = {"building_groups": [
        {"group_id": "bg_x", "anchor_loc": "L01", "kind": "chain_bg",
         "members": [{"loc_id": "L01", "label": "x", "shot_count": 5, "is_indoor": True}],
         "rationale": "x"},
    ]}
    fn = MagicMock(side_effect=[bad, good])
    result = run_background_classify(
        user_prompt="x",
        all_loc_ids=["L01"],
        indoor_loc_ids=["L01"],
        shot_counts={"L01": 5},
        call_structured_fn=fn,
        sleep_fn=lambda _: None,
    )
    assert result == good
    assert fn.call_count == 2


def test_run_raises_on_exhaustion():
    fn = MagicMock(return_value={"building_groups": [
        {"group_id": "bg_x", "anchor_loc": "L99", "kind": "chain_bg",
         "members": [{"loc_id": "L99", "label": "x", "shot_count": 5, "is_indoor": True}],
         "rationale": "x"},
    ]})
    with pytest.raises(ClassifyError):
        run_background_classify(
            user_prompt="x",
            all_loc_ids=["L01"],
            call_structured_fn=fn,
            max_retries=2,
            sleep_fn=lambda _: None,
        )
    assert fn.call_count == 2
