"""D6 T-pre-2 (B6) — master_plan validator split.

기존 monolithic `validate_master_plan_output` 의 bg_id field hard-require 가 D6
LLM (raw intent only, no bg_id) 출력을 reject. split:
- `validate_master_plan_raw_intent`: bg_id 부재. raw intent fields 검증.
- `validate_master_plan_assigned`: code 부여 후 BG_ID_RE 강제.

spec: docs/superpowers/specs/2026-05-09-deterministic-bg-id-and-catalog-lineage.md §4.5
plan: T-pre-2 (R2 B6)
"""
from __future__ import annotations

import pytest


_SENTINEL = object()

# T4-fix3: validator 가 location_profiles 받음 — single_space main fixture.
_PROFILES = {"L09": {"kind": "single_space", "allowed_space_keys": ["main"]}}


def _raw_intent(loc_id="L09", space_key_hint="main", time_phase="dusk",
                state_class="busy_exit", applies_to_shots=_SENTINEL,
                surface_role="interior_room",
                sub_location_label="store sales floor",
                state_label_raw="dusk busy", depends_on_fp=_SENTINEL,
                depends_on_bg=_SENTINEL):
    return {
        "loc_id": loc_id,
        "space_key_hint": space_key_hint,
        "time_phase": time_phase,
        "state_class": state_class,
        "surface_role": surface_role,
        "applies_to_shots": ["S8_Shot4"] if applies_to_shots is _SENTINEL else applies_to_shots,
        "sub_location_label": sub_location_label,
        "state_label_raw": state_label_raw,
        "depends_on_fp": ["fp_supermarket"] if depends_on_fp is _SENTINEL else depends_on_fp,
        "depends_on_bg": [] if depends_on_bg is _SENTINEL else depends_on_bg,
    }


def _raw_plan(group_id="g1", floor_plans=None, backgrounds=None):
    return {
        "group_id": group_id,
        "rationale_summary": "test",
        "floor_plans": floor_plans or [
            {
                "fp_id": "fp_supermarket",
                "loc_id": "L09",
                "space_key_hint": "main",
                "sub_location": "main",
                "scope": "store",
                "depends_on_fp": [],
            }
        ],
        "backgrounds": backgrounds or [_raw_intent()],
    }


# ──────────────────────────────────────────────────────────────────────
# validate_master_plan_raw_intent
# ──────────────────────────────────────────────────────────────────────


def test_raw_intent_validator_accepts_bg_id_absent():
    """raw intent 는 bg_id field 없어도 통과 — 코드 부여 전 단계."""
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan()
    # No raise
    validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_rejects_state_class_outside_enum():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan(backgrounds=[_raw_intent(state_class="freeform_unknown")])
    with pytest.raises(ValueError, match="state_class|enum"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_rejects_loc_id_outside_group():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan(backgrounds=[_raw_intent(loc_id="L99")])
    with pytest.raises(ValueError, match="loc_id"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_rejects_applies_to_shots_outside_group():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan(backgrounds=[_raw_intent(applies_to_shots=["S99_Shot1"])])
    with pytest.raises(ValueError, match="applies_to_shots"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_rejects_empty_depends_on_fp_for_interior_room():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan(backgrounds=[_raw_intent(depends_on_fp=[])])
    with pytest.raises(ValueError, match="depends_on_fp"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_accepts_fp_less_exterior_plate():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan(
        backgrounds=[
            _raw_intent(
                surface_role="exterior_plate",
                depends_on_fp=[],
            )
        ],
    )
    validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_rejects_unknown_surface_role():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan(backgrounds=[_raw_intent(surface_role="outdoor_room")])
    with pytest.raises(ValueError, match="surface_role"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_rejects_unreferenced_fp():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan(backgrounds=[_raw_intent(depends_on_fp=["fp_nonexistent"])])
    with pytest.raises(ValueError, match="depends_on_fp.*fp_nonexistent"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_rejects_missing_required_fields():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    bg = _raw_intent()
    bg.pop("space_key_hint")
    plan = _raw_plan(backgrounds=[bg])
    with pytest.raises(ValueError, match="space_key_hint"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_rejects_group_id_mismatch():
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    plan = _raw_plan(group_id="other_group")
    with pytest.raises(ValueError, match="group_id"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


# ──────────────────────────────────────────────────────────────────────
# invariant F3 (E2E v1) — single_space location 은 floor_plan 정확히 1개.
# regression: L05 '옥탑방 내부'(single_space) 에 fp 2개(kitchen + main_living_dining)
# → normalize_space_key 가 둘 다 main 으로 collapse → assign_bg_ids SemanticKeyError
# 크래시. raw 단계에서 retryable ValueError 로 차단.
# ──────────────────────────────────────────────────────────────────────


def test_raw_intent_validator_rejects_single_space_multi_fp():
    """single_space location 에 floor_plan 2개 → ValueError (L05-style 회귀 가드)."""
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    floor_plans = [
        {"fp_id": "fp_l09_kitchen", "loc_id": "L09", "space_key_hint": "kitchen",
         "sub_location": "kitchen", "scope": "kitchen", "depends_on_fp": []},
        {"fp_id": "fp_l09_living", "loc_id": "L09", "space_key_hint": "main",
         "sub_location": "living", "scope": "living", "depends_on_fp": []},
    ]
    plan = _raw_plan(floor_plans=floor_plans,
                     backgrounds=[_raw_intent(depends_on_fp=["fp_l09_kitchen"])])
    with pytest.raises(ValueError, match="single_space.*floor_plan"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_accepts_single_space_one_fp():
    """single_space location 에 floor_plan 1개 → 통과."""
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    validate_master_plan_raw_intent(_raw_plan(), "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


def test_raw_intent_validator_accepts_multi_space_multi_fp():
    """multi_space location 은 allowed_space_keys 안에서 floor_plan 복수 허용."""
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    profiles = {"L20": {"kind": "multi_space", "allowed_space_keys": ["main", "kitchen"]}}
    floor_plans = [
        {"fp_id": "fp_l20_main", "loc_id": "L20", "space_key_hint": "main",
         "sub_location": "main", "scope": "main room", "depends_on_fp": []},
        {"fp_id": "fp_l20_kitchen", "loc_id": "L20", "space_key_hint": "kitchen",
         "sub_location": "kitchen", "scope": "kitchen", "depends_on_fp": []},
    ]
    backgrounds = [
        _raw_intent(loc_id="L20", space_key_hint="main", depends_on_fp=["fp_l20_main"]),
        _raw_intent(loc_id="L20", space_key_hint="kitchen", depends_on_fp=["fp_l20_kitchen"]),
    ]
    plan = _raw_plan(floor_plans=floor_plans, backgrounds=backgrounds)
    validate_master_plan_raw_intent(plan, "g1", {"L20"}, {"S8_Shot4"}, profiles)


# ──────────────────────────────────────────────────────────────────────
# validate_master_plan_assigned
# ──────────────────────────────────────────────────────────────────────


def test_assigned_validator_accepts_d6_format_unique():
    from app.modules.pipeline.background_master_plan import validate_master_plan_assigned

    catalog = {
        "L09B01": {"bg_id": "L09B01", "loc_id": "L09"},
        "L09B02": {"bg_id": "L09B02", "loc_id": "L09"},
        "L113B07": {"bg_id": "L113B07", "loc_id": "L113"},
    }
    validate_master_plan_assigned(catalog)  # no raise


def test_assigned_validator_rejects_legacy_freeform():
    from app.modules.pipeline.background_master_plan import validate_master_plan_assigned

    catalog = {"bg_freeform": {"bg_id": "bg_freeform", "loc_id": "L09"}}
    with pytest.raises(ValueError, match="BG_ID_RE|fails"):
        validate_master_plan_assigned(catalog)


def test_assigned_validator_rejects_lowercase_or_short():
    from app.modules.pipeline.background_master_plan import validate_master_plan_assigned

    with pytest.raises(ValueError, match="BG_ID_RE|fails"):
        validate_master_plan_assigned(
            {"l09b01": {"bg_id": "l09b01", "loc_id": "L09"}}
        )
    with pytest.raises(ValueError, match="BG_ID_RE|fails"):
        validate_master_plan_assigned(
            {"L9B01": {"bg_id": "L9B01", "loc_id": "L09"}}
        )


def test_assigned_validator_rejects_key_entry_bg_id_mismatch():
    """catalog dict key 와 entry.bg_id 가 일치해야 — invariant."""
    from app.modules.pipeline.background_master_plan import validate_master_plan_assigned

    with pytest.raises(ValueError, match="mismatch|entry"):
        validate_master_plan_assigned(
            {"L09B01": {"bg_id": "L09B02", "loc_id": "L09"}}
        )


# ──────────────────────────────────────────────────────────────────────
# Legacy validate_master_plan_output 보존 (compat)
# ──────────────────────────────────────────────────────────────────────


def test_legacy_validator_function_still_exported():
    """legacy `validate_master_plan_output` 가 여전히 export — pre-D6 path 호환."""
    from app.modules.pipeline import background_master_plan as mod
    assert hasattr(mod, "validate_master_plan_output")
    assert callable(mod.validate_master_plan_output)


# ──────────────────────────────────────────────────────────────────────
# T4-fix B1 — manifest schema_version 일치
# ──────────────────────────────────────────────────────────────────────


def test_background_master_plan_manifest_schema_version_matches_step():
    """STEP_MANIFEST 의 background_master_plan entry schema_version 이 step 본체와 일치.

    이전 결함: manifest 에 schema_version 누락 → step 이 cp 에 v2 stamp 후 다음 resume
    시 _check_cp_mismatch 가 manifest default 1 vs cp 2 비교 → BLOCK.

    History:
        T4-fix B1: 2 (raw intent + bg_catalog post-processing)
        T4-fix3 (commit 682c875): 3 (floor_plans[].loc_id + space_key_hint required +
            fp ↔ bg link cross-check). step_manifest 와 step.py 의 SCHEMA_VERSION 모두
            3 으로 동기. 본 assertion 도 함께 동기.

    본 assertion 은 manifest 와 step 본체의 SOT 동기를 강제 — drift 시 force re-run
    cascade 의 silent halt 로 이어진다 (`background_master_plan contract drift detected
    — schema_version mismatch` cp_mismatch BLOCK).
    """
    from app.core.step_manifest import STEP_MANIFEST
    from app.core.steps.background_master_plan_step import (
        SCHEMA_VERSION as STEP_SCHEMA_VERSION,
    )
    entry = STEP_MANIFEST.get("background_master_plan", {})
    assert entry.get("schema_version") == STEP_SCHEMA_VERSION, (
        f"manifest.background_master_plan.schema_version 가 step 본체 "
        f"(={STEP_SCHEMA_VERSION}) 와 불일치 — got {entry.get('schema_version')!r}. "
        f"resume 시 cp_mismatch BLOCK 위험."
    )


# ──────────────────────────────────────────────────────────────────────
# T4-fix I2 — raw validator 가 bg_id 명시 reject
# ──────────────────────────────────────────────────────────────────────


def test_raw_intent_validator_rejects_bg_id_field_present():
    """T4-fix I2: raw intent 에 bg_id field 존재하면 reject.

    schema 가 막지만 (additionalProperties: false), validator 도 명시 reject —
    LLM 이 schema 우회 시 (예: schema disabled provider) 안전망.
    """
    from app.modules.pipeline.background_master_plan import validate_master_plan_raw_intent

    bg = _raw_intent()
    bg["bg_id"] = "L09B01"  # LLM 이 부여하면 안 됨
    plan = _raw_plan(backgrounds=[bg])
    with pytest.raises(ValueError, match="bg_id"):
        validate_master_plan_raw_intent(plan, "g1", {"L09"}, {"S8_Shot4"}, _PROFILES)


# ──────────────────────────────────────────────────────────────────────
# T4-fix I3 — _build_gen_order missing dep / cycle guard
# ──────────────────────────────────────────────────────────────────────


def test_build_gen_order_raises_on_missing_dependency():
    """T4-fix I3: depends_on_bg/depends_on_fp 가 plan 안 fp/bg 에 없으면 raise."""
    from app.core.steps.background_master_plan_step import BackgroundMasterPlanStep

    plan = {
        "floor_plans": [{"fp_id": "fp_a", "depends_on_fp": []}],
        "backgrounds": [{
            "bg_id": "L09B01",
            "depends_on_fp": ["fp_a"],
            "depends_on_bg": ["L99B99"],  # 부재
        }],
    }
    with pytest.raises(ValueError, match="L99B99|missing"):
        BackgroundMasterPlanStep._build_gen_order(plan)


def test_build_gen_order_raises_on_cycle():
    """T4-fix I3: cycle 검출 시 raise."""
    from app.core.steps.background_master_plan_step import BackgroundMasterPlanStep

    plan = {
        "floor_plans": [],
        "backgrounds": [
            {"bg_id": "L09B01", "depends_on_fp": [], "depends_on_bg": ["L09B02"]},
            {"bg_id": "L09B02", "depends_on_fp": [], "depends_on_bg": ["L09B01"]},
        ],
    }
    with pytest.raises(ValueError, match="cycle"):
        BackgroundMasterPlanStep._build_gen_order(plan)


def test_load_prev_background_catalog_reads_archive_under_force_cleared(tmp_path, monkeypatch):
    """T4-fix2 (review iter6 B1): explicit force (`.force_cleared` marker) 환경에서도
    archive 의 background_catalog 를 prev 로 read.

    이전: `_load_prev_background_catalog` 가 `load_checkpoint()` 사용 → marker 있으면
    archive 복원 차단 → prev={}. B7 monotonic 보장 깨짐 (force re-run 시 ID reset).
    이제: archive 직접 read (manifest restore 안 함, force 의도 보존).
    """
    import json as _json
    from datetime import datetime
    from app.core.steps.background_master_plan_step import BackgroundMasterPlanStep

    monkeypatch.setattr(
        "app.core.config.settings.projects_dir", str(tmp_path / "p"),
    )

    step = BackgroundMasterPlanStep.__new__(BackgroundMasterPlanStep)
    step.project_id = "proj-d6-archive"
    step.episode_id = "ep1"
    step.step_id = "background_master_plan"
    step.project_config = {}

    cp_dir = (
        tmp_path / "p" / step.project_id / "checkpoints" / "episodes"
        / step.episode_id / step.step_id
    )
    cp_dir.mkdir(parents=True, exist_ok=True)
    # archive 작성 — completed status, background_catalog 보유.
    archive_payload = {
        "status": "completed",
        "data": {
            "background_catalog": {
                "L09B01": {"bg_id": "L09B01", "loc_id": "L09",
                           "semantic_key": "L09|main|dusk|busy_exit"},
                "L09B05": {"bg_id": "L09B05", "loc_id": "L09",
                           "semantic_key": "L09|main|night|quiet"},
            },
        },
    }
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    (cp_dir / f"manifest_{ts}.json").write_text(
        _json.dumps(archive_payload), encoding="utf-8"
    )
    # explicit force 시뮬레이션: .force_cleared marker 존재 + manifest.json 부재.
    (cp_dir / ".force_cleared").write_text("", encoding="utf-8")
    # _cp_dir 는 step_runner property — 명시 set (StepRunner.__new__ 우회)
    step._cp_dir = cp_dir

    prev = step._load_prev_background_catalog()
    assert "L09B01" in prev, (
        f"explicit force (.force_cleared) 에서도 archive 의 catalog 가 prev 로 read 안 됨 — "
        f"got keys={list(prev.keys())}"
    )
    assert "L09B05" in prev


def test_build_gen_order_topological_order_passes():
    """기본 topological — fp 먼저, bg 그 후."""
    from app.core.steps.background_master_plan_step import BackgroundMasterPlanStep

    plan = {
        "floor_plans": [{"fp_id": "fp_a", "depends_on_fp": []}],
        "backgrounds": [
            {"bg_id": "L09B01", "depends_on_fp": ["fp_a"], "depends_on_bg": []},
            {"bg_id": "L09B02", "depends_on_fp": ["fp_a"], "depends_on_bg": ["L09B01"]},
        ],
    }
    order = BackgroundMasterPlanStep._build_gen_order(plan)
    assert order.index("fp_a") < order.index("L09B01")
    assert order.index("L09B01") < order.index("L09B02")
