"""검색 대상 = 씨드 대상 exact parity 회귀 가드 (v3, 2026-07-30).

## 무엇을 막는가

검색 그라운딩이 25그룹 중 **10그룹에만** 들어갔던 실측 결함의 회귀 가드다.
원인은 대상을 lane plan 의 ``structure_plate`` 바인딩으로 잡은 것이었다 —
그건 구조물의 성질이 아니라 **샷의 필요성**(그 그룹의 선택 샷 중 인물 뒤로
구조가 보존돼야 하는 샷이 있는가)이라, 바인딩이 없는 그룹이 조용히 순수
T2I 로 남았다.

v3 계약: 검색 대상 = 소비자(``outdoor_structure_seed``)가 쓰는
``collect_lane2_groups(lane, all_groups=<같은 플래그>)`` 와 **같은 집합**
− place spec 결손. 그래서 "조용히 빠지는 경로"가 존재하지 않는다.

## 픽스처 규약

그룹 id·서술은 전부 ``SAMPLE_FIXTURE_*`` 로 격리한 가상 데이터다. 특정
작품의 장소 이름을 테스트에 넣지 않는다(프로젝트 standing rule).
"""
from __future__ import annotations

from typing import Any, Dict
from unittest.mock import patch

import pytest

from app.core.steps.outdoor_structure_form_reference_step import (
    OutdoorStructureFormReferenceStep,
)
from app.core.steps.outdoor_structure_seed_step import collect_lane2_groups

# ── 샘플 픽스처 ─────────────────────────────────────────────────────
# G_PLATE = structure_plate 바인딩 있음 (구 v1 이 유일하게 잡던 부류)
# G_NOPLATE_* = 바인딩 없음 — 구 v1 이 조용히 빠뜨리던 부류
# G_NOSPEC = place spec 결손 (유일하게 정당한 제외, 단 명시 기록)
# G_NOT_OK = lane plan status != ok (양쪽 모두에서 제외)
SAMPLE_FIXTURE_G_PLATE = "sample_fixture_group_plate_bound"
SAMPLE_FIXTURE_G_NOPLATE_A = "sample_fixture_group_unbound_a"
SAMPLE_FIXTURE_G_NOPLATE_B = "sample_fixture_group_unbound_b"
SAMPLE_FIXTURE_G_NOSPEC = "sample_fixture_group_without_spec"
SAMPLE_FIXTURE_G_NOT_OK = "sample_fixture_group_lane_failed"


def _lane_cp() -> Dict[str, Any]:
    return {"data": {"groups": {
        SAMPLE_FIXTURE_G_PLATE: {
            "status": "ok",
            "plan": {"shot_bindings": [{"lane": "structure_plate"}]},
        },
        SAMPLE_FIXTURE_G_NOPLATE_A: {
            "status": "ok",
            "plan": {"shot_bindings": [{"lane": "wide_establishing"}]},
        },
        SAMPLE_FIXTURE_G_NOPLATE_B: {"status": "ok", "plan": {}},
        SAMPLE_FIXTURE_G_NOSPEC: {"status": "ok", "plan": {}},
        SAMPLE_FIXTURE_G_NOT_OK: {
            "status": "failed",
            "plan": {"shot_bindings": [{"lane": "structure_plate"}]},
        },
    }}}


def _spec_cp() -> Dict[str, Any]:
    """place spec — NOSPEC 그룹만 items 가 비어 있다."""
    filled = {"spec": {"items": [{"name": "sample fixture structure"}]}}
    return {"data": {"groups": {
        SAMPLE_FIXTURE_G_PLATE: filled,
        SAMPLE_FIXTURE_G_NOPLATE_A: filled,
        SAMPLE_FIXTURE_G_NOPLATE_B: filled,
        SAMPLE_FIXTURE_G_NOSPEC: {"spec": {"items": []}},
        SAMPLE_FIXTURE_G_NOT_OK: filled,
    }}}


_PREV_CP = {
    "outdoor_lane_plan": _lane_cp(),
    "outdoor_place_spec": _spec_cp(),
    "background_classify": {"data": {"building_groups": []}},
    "entity_merge": {"data": {"locations": []}},
    "scene_save": {"data": {"segments": [{"text": "sample fixture scene"}]}},
    "visual_world_rules": None,
}


def _make_step(all_groups: bool):
    """DB·네트워크 없이 ``_execute`` 만 태우는 스텝 인스턴스.

    기존 스텝 테스트 관례와 동일하게 ``__new__`` 로 __init__ 을 우회하고,
    검색·판정(LLM/VLM)은 ``_run_group`` 스텁으로 잘라낸다 — 여기서 검증하는
    것은 **어느 그룹이 대상이 되는가** 하나뿐이다.
    """
    step = OutdoorStructureFormReferenceStep.__new__(
        OutdoorStructureFormReferenceStep)
    step.project_id = "sample-fixture-project"
    step.episode_id = "sample-fixture-episode"
    step.project_config = {}
    step.db = _FakeDb()
    step._load_prev_checkpoint = lambda step_id: _PREV_CP.get(step_id)
    step.load_checkpoint = lambda: None
    step.build_opik_metadata = lambda *a, **k: {}
    step._run_group = lambda **kw: {
        "status": "ok", "group_id": kw["group_id"],
        "form_ref_path": "/dev/null/sample.png",
        "form_ref_sha256": "0" * 64, "form_ref_asset_id": "asset",
        "audit": {},
    }
    return step


class _FakeDb:
    def commit(self):  # noqa: D102
        pass

    def rollback(self):  # noqa: D102
        pass


def _execute(all_groups: bool) -> Dict[str, Any]:
    step = _make_step(all_groups)
    with patch("app.core.config.settings.outdoor_lane_pipe_enabled", True), \
         patch("app.core.config.settings.outdoor_lane_plan_enabled", True), \
         patch("app.core.config.settings.outdoor_seed_all_groups_enabled",
               all_groups), \
         patch("app.core.steps.shot_conti_light_step._resolve_openai_client",
               lambda: object()), \
         patch("app.modules.pipeline.outdoor_structure_seed."
               "derive_seed_inputs",
               lambda **kw: {"structure_desc": "sample fixture structure"}):
        return step._execute(mode="resume")


# ─────────────────────────────────────────────────────────────────────
# 1. 두 모집단 플래그 모두에서 씨드 대상과 정확히 같은가
# ─────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("all_groups", [False, True])
def test_universe_equals_seed_target_set(all_groups: bool):
    """검색 모집단 == 씨드가 쓰는 ``collect_lane2_groups`` 산출.

    한쪽만 바뀌어도 다시 "조용히 빠지는 그룹" 이 생긴다.
    """
    out = _execute(all_groups)
    seed_target = collect_lane2_groups(_lane_cp()["data"],
                                       all_groups=all_groups)
    assert out["data"]["target"]["universe_group_ids"] == sorted(seed_target)


def test_all_groups_on_covers_groups_without_plate_binding():
    """★핵심 회귀: 바인딩 없는 그룹도 검색을 탄다.

    구 v1 은 이 부류(주유소·편의점 등 실측 15그룹)를 통째로 빠뜨렸다.
    """
    out = _execute(all_groups=True)
    targets = out["data"]["mandatory_group_ids"]
    assert SAMPLE_FIXTURE_G_NOPLATE_A in targets
    assert SAMPLE_FIXTURE_G_NOPLATE_B in targets
    assert SAMPLE_FIXTURE_G_PLATE in targets
    assert out["applicable_count"] == 3


def test_plate_bound_group_still_covered_when_flag_off():
    """모집단 플래그가 꺼져 있어도 바인딩 그룹은 빠지지 않는다(기존 계약)."""
    out = _execute(all_groups=False)
    assert out["data"]["mandatory_group_ids"] == [SAMPLE_FIXTURE_G_PLATE]
    assert out["data"]["target"]["plate_bound_group_ids"] == [
        SAMPLE_FIXTURE_G_PLATE]


# ─────────────────────────────────────────────────────────────────────
# 2. 유일한 제외 경로는 명시 기록된다
# ─────────────────────────────────────────────────────────────────────
def test_spec_missing_group_is_excluded_but_recorded():
    """검색어를 저작할 근거(place spec)가 없는 그룹만 빠지고, CP 에 남는다."""
    out = _execute(all_groups=True)
    target = out["data"]["target"]
    assert SAMPLE_FIXTURE_G_NOSPEC in target["no_spec_group_ids"]
    assert SAMPLE_FIXTURE_G_NOSPEC not in out["data"]["mandatory_group_ids"]
    # 모집단에는 들어 있어야 "왜 빠졌는지"가 추적 가능하다.
    assert SAMPLE_FIXTURE_G_NOSPEC in target["universe_group_ids"]


def test_lane_failed_group_excluded_from_both_sides():
    """lane plan 이 실패한 그룹은 씨드도 검색도 대상이 아니다."""
    out = _execute(all_groups=True)
    target = out["data"]["target"]
    assert SAMPLE_FIXTURE_G_NOT_OK not in target["universe_group_ids"]
    assert SAMPLE_FIXTURE_G_NOT_OK not in out["data"]["mandatory_group_ids"]
    assert SAMPLE_FIXTURE_G_NOT_OK not in collect_lane2_groups(
        _lane_cp()["data"], all_groups=True)


def test_no_target_selection_judgement_is_recorded():
    """대상 집합에 LLM 판정이 개입하지 않음을 CP 정책 버전으로 고정."""
    out = _execute(all_groups=True)
    assert out["data"]["target"]["policy_version"] == "3-seed-parity"
    # 판정 산출이 CP 에 남아 있으면 v2 잔재다.
    assert "scope" not in out["data"]


def test_target_and_mandatory_are_sorted_deterministic():
    out = _execute(all_groups=True)
    gids = out["data"]["mandatory_group_ids"]
    assert gids == sorted(gids)
