"""OutdoorPlaceSpecStep 결정론 테스트 (W22 ①) — 게이트/그룹 조립/실패 격리.

LLM 은 monkeypatch 로 차단 — 스텝의 데이터 조립과 분기만 검증한다.
fixture 는 전부 시나리오 중립 SAMPLE 데이터.
"""

from unittest.mock import MagicMock, patch

from app.core.steps.outdoor_place_spec_step import (
    SCHEMA_VERSION,
    OutdoorPlaceSpecStep,
)
from app.modules.pipeline.outdoor_direct_common import scene_indices_for_locs


def _make_step():
    step = OutdoorPlaceSpecStep.__new__(OutdoorPlaceSpecStep)
    step.project_id = "SAMPLE_PROJECT"
    step.episode_id = "SAMPLE_EPISODE"
    step.project_config = {}
    step.build_opik_metadata = MagicMock(return_value={})
    return step


def _cps():
    """SAMPLE 체크포인트 세트 — 실외 그룹 1 + 실내 전용 그룹 1."""
    return {
        "background_classify": {"data": {"building_groups": [
            {
                "group_id": "sample_site",
                "anchor_loc": "L01",
                "members": [
                    {"loc_id": "L01", "label": "SAMPLE 마당", "is_indoor": False},
                    {"loc_id": "L02", "label": "SAMPLE 거실", "is_indoor": True},
                ],
            },
            {
                "group_id": "indoor_only",
                "anchor_loc": "L03",
                "members": [
                    {"loc_id": "L03", "label": "SAMPLE 사무실", "is_indoor": True},
                ],
            },
        ]}},
        "scene_save": {"data": {"segments": [
            {"scene_index": 3, "text": "그가 대문을 밀고 들어선다. " * 500},
            {"scene_index": 4, "text": "사무실 안. 서류를 넘긴다."},
        ]}},
        "scene_director": {"data": {"scenes": [
            {"scene_index": 3, "primary_location": "L01"},
            {"scene_index": 4, "primary_location": "L03"},
        ]}},
        "shot_selection": {"data": {"scenes": [
            {"scene_index": 3, "selected_shot_indices": [1, 2]},
            {"scene_index": 4, "selected_shot_indices": [1]},
        ]}},
        "shot_validator": {"data": {"scenes": [
            {"scene_index": 3, "shots": [
                {"shot_index": 1, "location_id": "L01"},
                {"shot_index": 2, "location_id": "L02"},   # 같은 씬 실내 샷
            ]},
        ]}},
        "shot_staging": {"data": {"shots": [
            {"scene_index": 3, "shot_index": 1,
             "description": "대문으로 들어서는 인물", "camera_direction": "정면"},
            {"scene_index": 3, "shot_index": 2,
             "description": "거실에서 내다보는 시선"},     # L02 — 그룹 스펙에 새면 안 됨
            {"scene_index": 4, "shot_index": 1,
             "description": "서류를 넘기는 손"},
        ]}},
        "entity_merge": {"data": {"locations": [
            {"short_id": "L01", "name": "SAMPLE 마당",
             "description": "담으로 둘러싸인 앞마당", "visual_traits": []},
        ]}},
        "visual_world_rules": {"data": {"rules_text": "SAMPLE 물리 규칙"}},
    }


def _patch_settings(mode="on", flag=True):
    return (
        patch("app.core.config.settings.background_mode", mode),
        patch("app.core.config.settings.outdoor_direct_compose_enabled",
              flag, create=True),
    )


# ── 게이트 ───────────────────────────────────────────────────────────


def test_gate_background_mode_off_returns_empty():
    p1, p2 = _patch_settings(mode="off", flag=True)
    with p1, p2:
        result = _make_step()._execute()
    assert result["applicable_count"] == 0
    assert result["data"] == {"groups": {}}
    assert result["schema_version"] == SCHEMA_VERSION


def test_gate_flag_off_returns_empty():
    p1, p2 = _patch_settings(mode="on", flag=False)
    with p1, p2:
        result = _make_step()._execute()
    assert result["applicable_count"] == 0
    assert result["data"] == {"groups": {}}


def test_no_outdoor_groups_returns_empty():
    step = _make_step()
    cps = _cps()
    cps["background_classify"] = {"data": {"building_groups": [
        {"group_id": "indoor_only", "anchor_loc": "L03",
         "members": [{"loc_id": "L03", "label": "SAMPLE 사무실", "is_indoor": True}]},
    ]}}
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    p1, p2 = _patch_settings()
    with p1, p2:
        result = step._execute()
    assert result["applicable_count"] == 0


# ── 씬 매핑 헬퍼 ─────────────────────────────────────────────────────


def test_scene_indices_union_of_primary_and_selected_shot_locs():
    out = scene_indices_for_locs(
        {"L01"},
        scene_primary={3: "L01", 4: "L03"},
        shot_loc_by_key={(7, 1): "L01", (4, 1): "L03", (9, 1): "L01"},
        selected_keys={(7, 1), (4, 1)},  # (9,1) 미선택 → 씬 9 매핑 안 됨
    )
    assert out == [3, 7]


# ── 그룹 조립/격리 ───────────────────────────────────────────────────


def _run_with_fake(cps, fake_run):
    step = _make_step()
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    p1, p2 = _patch_settings()
    with p1, p2, patch(
        "app.modules.pipeline.outdoor_place_spec.run_outdoor_place_spec",
        side_effect=fake_run,
    ), patch(
        "app.core.creator_corrections.project_corrections_block",
        return_value="",
    ):
        return step._execute()


def test_outdoor_group_assembled_with_full_scene_text():
    captured = {}

    def fake_run(**kwargs):
        captured.update(kwargs)
        return {"spec": {"layout_narration_en": "x", "zone_labels_en": ["Y"],
                         "items": []}, "attempts": 1}

    cps = _cps()
    result = _run_with_fake(cps, fake_run)

    # 실외 멤버 있는 그룹만 applicable — indoor_only 제외
    assert result["applicable_count"] == 1
    assert result["completed_count"] == 1
    assert result["failed_count"] == 0
    assert set(result["data"]["groups"]) == {"sample_site"}

    g = result["data"]["groups"]["sample_site"]
    assert g["outdoor_loc_ids"] == ["L01"]
    assert g["scene_indices"] == [3]

    # 실외 멤버만 전달 (실내 L02 제외)
    assert [m["loc_id"] for m in captured["outdoor_members"]] == ["L01"]
    # 씬 원문 전문 전달 — 자르지 않음
    full_text = cps["scene_save"]["data"]["segments"][0]["text"]
    assert captured["scene_texts"] == [(3, full_text)]
    # 그룹 loc 샷만 전달 — 같은 씬(3)의 L02 샷(shot 2)과 씬 4 샷 제외 (NARROW_1)
    assert [(s["scene_index"], s["shot_index"]) for s in captured["shots"]] == [(3, 1)]
    assert captured["rules_text"] == "SAMPLE 물리 규칙"


def test_unselected_shot_location_does_not_map_scene():
    """미선택 샷의 location_id 는 씬 매핑을 만들지 않는다 (NARROW_1)."""
    captured = {}

    def fake_run(**kwargs):
        captured.update(kwargs)
        return {"spec": {}, "attempts": 1}

    cps = _cps()
    # 씬 5: L01 샷이 있지만 미선택 — primary 는 타 loc
    cps["scene_director"]["data"]["scenes"].append(
        {"scene_index": 5, "primary_location": "L03"})
    cps["scene_save"]["data"]["segments"].append(
        {"scene_index": 5, "text": "다른 장소 씬"})
    cps["shot_validator"]["data"]["scenes"].append(
        {"scene_index": 5, "shots": [{"shot_index": 1, "location_id": "L01"}]})
    # shot_selection 에 씬 5 선택 없음 (기존 fixture 그대로)

    result = _run_with_fake(cps, fake_run)
    assert result["data"]["groups"]["sample_site"]["scene_indices"] == [3]


def test_unselected_staging_shot_excluded_from_group_shots():
    """이미 매핑된 씬 안의 같은 loc 미선택 샷 staging 누출 차단 (NARROW_1 재리뷰).

    staging cp 는 생성 시점 선택 기준이라 토글 해제 샷이 남을 수 있다.
    """
    captured = {}

    def fake_run(**kwargs):
        captured.update(kwargs)
        return {"spec": {}, "attempts": 1}

    cps = _cps()
    # 씬 3: 선택은 [1, 2] 유지, validator 에 L01 shot 4 존재하나 미선택,
    # staging 에는 shot 4 가 잔존 (토글 해제 시나리오)
    cps["shot_validator"]["data"]["scenes"][0]["shots"].append(
        {"shot_index": 4, "location_id": "L01"})
    cps["shot_staging"]["data"]["shots"].append(
        {"scene_index": 3, "shot_index": 4, "description": "잔존 미선택 샷"})

    _run_with_fake(cps, fake_run)
    keys = [(s["scene_index"], s["shot_index"]) for s in captured["shots"]]
    assert keys == [(3, 1)]


def test_shot_without_location_id_included_via_scene_primary():
    """location_id 없는 샷은 scene primary 가 그룹 loc 일 때 포함 (NARROW_1 join)."""
    captured = {}

    def fake_run(**kwargs):
        captured.update(kwargs)
        return {"spec": {}, "attempts": 1}

    cps = _cps()
    # 씬 3(primary=L01)에 location_id 없는 선택 샷 3 추가
    cps["shot_selection"]["data"]["scenes"][0]["selected_shot_indices"] = [1, 2, 3]
    cps["shot_validator"]["data"]["scenes"][0]["shots"].append(
        {"shot_index": 3})  # location_id 없음
    cps["shot_staging"]["data"]["shots"].append(
        {"scene_index": 3, "shot_index": 3, "description": "마당 전경"})

    _run_with_fake(cps, fake_run)
    keys = [(s["scene_index"], s["shot_index"]) for s in captured["shots"]]
    assert keys == [(3, 1), (3, 3)]


def test_group_failure_is_isolated():
    def fake_run(**kwargs):
        raise RuntimeError("SAMPLE LLM 실패")

    result = _run_with_fake(_cps(), fake_run)
    assert result["applicable_count"] == 1
    assert result["completed_count"] == 0
    assert result["failed_count"] == 1
    assert "SAMPLE LLM 실패" in result["data"]["groups"]["sample_site"]["error"]


def test_group_without_mapped_scenes_is_skipped_not_failed():
    cps = _cps()
    # 매핑 근거 제거 — primary/shot loc 모두 실외 loc 미참조
    cps["scene_director"] = {"data": {"scenes": [
        {"scene_index": 4, "primary_location": "L03"},
    ]}}
    cps["shot_validator"] = {"data": {"scenes": []}}

    def fake_run(**kwargs):
        raise AssertionError("씬 매핑 0 그룹에서 LLM 호출되면 안 됨")

    result = _run_with_fake(cps, fake_run)
    assert result["failed_count"] == 0
    assert result["completed_count"] == 1
    assert "skipped" in result["data"]["groups"]["sample_site"]
