"""Stage D BLOCKING-1 잠금 — lane-only 플래그 조합에서 spec/canon producer
가 실제 _execute 게이트를 통과하고, canon 은 map_marker 그룹만 타깃한다.

생성은 monkeypatch 로 차단. fixture 전부 시나리오 중립 SAMPLE.
"""

from unittest.mock import MagicMock, patch

from app.core.steps.outdoor_place_canon_step import OutdoorPlaceCanonStep
from app.core.steps.outdoor_place_spec_step import OutdoorPlaceSpecStep


def _lane_only_patches(pipe=True, plan=True):
    return (
        patch("app.core.config.settings.background_mode", "on"),
        patch("app.core.config.settings.outdoor_direct_compose_enabled",
              False, create=True),
        patch("app.core.config.settings.outdoor_map_conti_enabled",
              False, create=True),
        patch("app.core.config.settings.outdoor_lane_pipe_enabled",
              pipe, create=True),
        patch("app.core.config.settings.outdoor_lane_plan_enabled",
              plan, create=True),
    )


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


def test_spec_execute_gate_opens_on_lane_only():
    """direct/map OFF + lane ON = spec _execute 가 CP 소비 단계 도달
    (Codex BLOCKING-1 재현의 역방향 잠금)."""
    step = _make(OutdoorPlaceSpecStep)
    loaded = []

    def _load(sid):
        loaded.append(sid)
        return None

    step._load_prev_checkpoint = _load
    ps = _lane_only_patches()
    with ps[0], ps[1], ps[2], ps[3], ps[4]:
        step._execute()
    assert "background_classify" in loaded  # 게이트 통과 증거
    # all-off = 게이트 차단 (CP 소비 0)
    loaded.clear()
    ps = _lane_only_patches(pipe=False, plan=False)
    with ps[0], ps[1], ps[2], ps[3], ps[4]:
        step._execute()
    assert loaded == []


def _lane_plan_group(gid, lane):
    return {
        "status": "ok",
        "plan": {
            "segments": [
                {"segment_id": "seg1", "label_en": "SAMPLE seg",
                 "dominant_mode": "structure",
                 "evidence": [{"scene_index": 3, "quote_ko": "구조물"}],
                 "confidence": "high"},
            ],
            "shot_bindings": [
                {"scene_index": 3, "shot_index": 1, "segment_id": "seg1",
                 "lane": lane, "confidence": "high",
                 "rationale_ko": "SAMPLE",
                 "evidence": {"scene_index": 3, "quote_ko": "구조물"}},
            ],
        },
        "scene_indices": [3],
    }


def _canon_cps(with_lane_cp=True):
    spec_entry = {
        "spec": {"zone_labels_en": ["Z"], "items": []},
        "outdoor_loc_ids": ["L01"],
        "scene_indices": [3],
    }
    cps = {
        "outdoor_place_spec": {"data": {"groups": {
            "site_marker": dict(spec_entry),
            "site_structure": dict(spec_entry),
        }}},
        "scene_save": {"data": {"segments": [
            {"scene_index": 3, "text": "구조물 앞."}]}},
        "shot_validator": {"data": {"scenes": [
            {"scene_index": 3, "shots": [
                {"shot_index": 1, "location_id": "L01",
                 "description": "SAMPLE"}]}]}},
        "shot_staging": {"data": {"shots": [
            {"scene_index": 3, "shot_index": 1}]}},
        "floor_plan_prompt": None,
        "floor_plan_render": None,
    }
    if with_lane_cp:
        cps["outdoor_lane_plan"] = {"data": {"groups": {
            "site_marker": _lane_plan_group("site_marker", "map_marker"),
            "site_structure": _lane_plan_group(
                "site_structure", "structure_plate"),
        }}}
    return cps


def _run_canon(cps):
    step = _make(OutdoorPlaceCanonStep)
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    step._upsert_canon_asset = MagicMock(return_value="asset-x")
    called = []

    def fake_group(**kwargs):
        called.append(kwargs["group_id"])
        return {
            "master_png_path": "/tmp/SAMPLE_master.png",
            "map_png_path": "/tmp/SAMPLE_map.png",
            "photo_prompt": "p", "map_prompt": "m",
            "selected_candidate": "A", "judge_totals": {},
            "issues_count": 0, "fix_applied": False,
            "style_fp_attached": False,
        }

    ps = _lane_only_patches()
    with ps[0], ps[1], ps[2], ps[3], ps[4], patch(
        "app.modules.pipeline.outdoor_place_canon."
        "run_outdoor_place_canon_group",
        side_effect=fake_group,
    ), patch.object(
        OutdoorPlaceCanonStep, "_make_nb2_fn", lambda self: MagicMock(),
    ), patch.object(
        OutdoorPlaceCanonStep, "_make_gpt_edit_fn",
        lambda self: MagicMock(),
    ):
        result = step._execute()
    return result, called


def test_canon_lane_filter_targets_map_marker_only():
    """lane ON: map_marker 그룹만 canon 생성, structure_plate 전용=skip."""
    result, called = _run_canon(_canon_cps())
    assert called == ["site_marker"]
    skipped = result["data"]["groups"]["site_structure"]
    assert "structure_plate" in skipped["skipped"]
    assert result["data"]["groups"]["site_marker"]["status"] == "ok"


def test_canon_lane_cp_missing_falls_back_all_groups():
    result, called = _run_canon(_canon_cps(with_lane_cp=False))
    assert sorted(called) == ["site_marker", "site_structure"]


def test_producer_hashes_stamp_lane_on_only():
    """spec/canon _config_hash — lane ON 시만 스탬프 (OFF byte-identical)."""
    for cls in (OutdoorPlaceSpecStep, OutdoorPlaceCanonStep):
        step = _make(cls)
        ps = _lane_only_patches(pipe=False, plan=False)
        with ps[0], ps[1], ps[2], ps[3], ps[4]:
            h_off = step._config_hash()
        ps = _lane_only_patches()
        with ps[0], ps[1], ps[2], ps[3], ps[4]:
            h_on = step._config_hash()
        assert h_off != h_on, cls.__name__


def test_canon_parity_missing_group_fail_closed():
    """lane CP 가 존재하면 개별 그룹 누락은 fallback 이 아니라 AppError
    (Codex 재리뷰 BLOCKING-1: 3번 — legacy fallback 은 CP 전체 부재만)."""
    import pytest

    from app.core.errors import AppError

    cps = _canon_cps()
    del cps["outdoor_lane_plan"]["data"]["groups"]["site_structure"]
    with pytest.raises(AppError) as ei:
        _run_canon(cps)
    assert ei.value.code == "step.missing_input.outdoor_place_canon"
    assert "parity" in ei.value.message
