"""OutdoorLanePlanStep 결정론 테스트 (3레인 Stage A) — 게이트/스킵/격리/hash.

LLM 은 monkeypatch 로 차단 — 스텝의 게이트·그룹 집계 분기만 검증한다.
fixture 는 전부 시나리오 중립 SAMPLE 데이터.
"""

from unittest.mock import MagicMock, patch

from app.core.steps.outdoor_lane_plan_step import (
    SCHEMA_VERSION,
    OutdoorLanePlanStep,
)


def _make_step():
    step = OutdoorLanePlanStep.__new__(OutdoorLanePlanStep)
    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 체크포인트 세트 — spec 있는 그룹 1 + spec 결측 그룹 1."""
    return {
        "outdoor_place_spec": {"data": {"groups": {
            "sample_site": {
                "spec": {
                    "zone_labels_en": ["Open Field"],
                    "items": [{"code": "P1", "kind": "gate",
                               "name_en": "front entry gate",
                               "placement_en": "at the south edge"}],
                },
                "outdoor_loc_ids": ["L01"],
                "scene_indices": [3],
            },
            "no_spec_site": {"skipped": "no scenes mapped to outdoor locs"},
        }}},
        "scene_save": {"data": {"segments": [
            {"scene_index": 3, "text": "그가 대문을 밀고 들어선다. " * 500},
        ]}},
        "scene_director": {"data": {"scenes": [
            {"scene_index": 3, "primary_location": "L01"},
        ]}},
        "shot_selection": {"data": {"scenes": [
            {"scene_index": 3, "selected_shot_indices": [1]},
        ]}},
        "shot_validator": {"data": {"scenes": [
            {"scene_index": 3, "shots": [
                {"shot_index": 1, "location_id": "L01",
                 "description": "대문으로 들어서는 인물",
                 "characters": ["SAMPLE 인물 A"]},
            ]},
        ]}},
        # 실측 형상: staging 샷에는 description 없음 (camera_direction 등만)
        "shot_staging": {"data": {"shots": [
            {"scene_index": 3, "shot_index": 1,
             "camera_direction": "정면 와이드"},
        ]}},
    }


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


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_lane_plan.run_outdoor_lane_plan_group",
        side_effect=fake_run,
    ):
        return step._execute()


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


def test_spec_missing_group_skipped():
    captured = {}

    def fake_run(**kwargs):
        captured.update(kwargs)
        return {"plan": {"segments": [], "shot_bindings": []}, "attempts": 1}

    result = _run_with_fake(_cps(), fake_run)
    assert result["failed_count"] == 0
    # 카운트 불변식 completed<=applicable — skip 을 완료로 세면 applicable
    # 에도 포함 (image_steps 관례, Codex NARROW)
    assert result["applicable_count"] == 2  # 저작 1 + skip 1
    assert result["completed_count"] == 2
    assert "skipped" in result["data"]["groups"]["no_spec_site"]
    # spec 있는 그룹은 씬 원문 전문으로 저작 호출
    g = result["data"]["groups"]["sample_site"]
    assert g["attempts"] == 1
    full_text = "그가 대문을 밀고 들어선다. " * 500
    assert captured["scene_texts"] == {3: full_text}
    assert [(s["scene_index"], s["shot_index"])
            for s in captured["group_shots"]] == [(3, 1)]
    # 샷 서술 SOT=shot_validator — staging 에 없는 description/characters 병합
    # (5회차 실측 결함 회귀 방지: 설명 공백 → 전 샷 low confidence)
    assert captured["group_shots"][0]["description"] == "대문으로 들어서는 인물"
    assert captured["group_shots"][0]["characters"] == ["SAMPLE 인물 A"]
    assert captured["group_shots"][0]["camera_direction"] == "정면 와이드"


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

    result = _run_with_fake(_cps(), fake_run)
    assert result["applicable_count"] == 2  # 실패 1 + skip 1 (관례: skip 포함)
    assert result["failed_count"] == 1
    assert "SAMPLE LLM 실패" in result["data"]["groups"]["sample_site"]["error"]
    # spec 결측 그룹은 실패 아님
    assert "skipped" in result["data"]["groups"]["no_spec_site"]


def test_config_hash_includes_prompt_version():
    """selector(settings) 변경=hash 드리프트 — 재설계 C 에서 상수→settings
    opt-in 으로 전환(기존 프로젝트 byte-identical, "2"=lane none 팩)."""
    from app.core.config import settings

    step = _make_step()
    h1 = step._config_hash()
    with patch.object(
        settings, "outdoor_lane_plan_prompt_version", "2"
    ):
        h2 = step._config_hash()
    assert h1 != h2


def test_config_hash_includes_evidence_validation_version():
    """validator 의미 버전 변화 = 새 계약 hash — 구 CP 가 cp-clean SKIP 으로
    잔존하지 않도록 잠금 (Codex 재리뷰 NARROW_1)."""
    step = _make_step()
    h1 = step._config_hash()
    with patch(
        "app.core.steps.outdoor_lane_plan_step.EVIDENCE_VALIDATION_VERSION",
        999,
    ):
        h2 = step._config_hash()
    assert h1 != h2
