"""W21B-wave-4 C2: ShotProjectionCardStep gate + path tests.

opt-in (default OFF) producer that, per (bg_id, shot_id) target, builds a
projection card envelope + runs the v0 deterministic gate. Default path
uses the synthetic fixture (NO real VLM call); a test-injected provider
exercises the real-provider accounting + optional semantic gate carry.

LLM / image / VLM API call 0 (synthetic). DB / ImageAsset write 0.
"""
from __future__ import annotations

from unittest.mock import MagicMock, patch

from app.core.steps.shot_projection_card_step import (
    PROMPT_VERSION,
    SCHEMA_VERSION,
    ShotProjectionCardStep,
)
# ★물리 모델 이름은 `shot_projection_card` 가 SOT — 여기 손으로 적으면
#  모델을 올릴 때마다 이 시험이 선다. 여기서 재는 것은 「프로바이더가
#  불리는 것과 **같은 것**을 적었나」지 이름 그 자체가 아니다.
from app.modules.pipeline.shot_projection_card import (
    PROVIDER_MODEL as _PROVIDER_MODEL,
)


def _cp_map(**extra):
    cp = {
        "floor_plan_overlay_payload": {"data": {"overlays": {
            "L05B09": {
                "bg_id": "L05B09", "fp_id": "fp_l05_main",
                "base_markers_to_reference": [
                    {"number": 3, "label": "싱크대"},
                    {"number": 7, "label": "미닫이문"},
                ],
                "transient_markers_to_describe": [{"number": 18, "label": "상자"}],
                "ignored_state_overlay_markers": [],
                "use_numbered_elements": [3, 7, 18],
                "ignore_numbered_elements": [],
            },
        }}},
        "background_master_plan": {"data": {"background_catalog": {
            "L05B09": {"applies_to_shots": ["S5_Shot1", "S5_Shot7"],
                       "surface_role": "interior_room"},
        }}},
        "base_location_dossier": {"data": {"dossiers": {"fp_l05_main": {"fp_id": "fp_l05_main"}}}},
        "floor_plan_render": {"data": {"floor_plans": {
            "fp_l05_main": {"png_path": "/p/c/e/floor_plan_render/fp_l05_main.png"},
        }}},
        "floor_plan_prompt": {"data": {"floor_plans": {
            "fp_l05_main": {"camera_recommendations": [{"bg_id": "L05B09", "label": "cam#2"}]},
        }}},
        "floor_plan_geometry_readback": {"data": {"per_fp": {"fp_l05_main": {"x": 1}}}},
    }
    cp.update(extra)
    return cp


def _new_step(*, cp_map: dict) -> ShotProjectionCardStep:
    step = ShotProjectionCardStep.__new__(ShotProjectionCardStep)
    step.project_id = "p"
    step.episode_id = "e"
    step.project_config = {}
    step.build_opik_metadata = MagicMock(return_value={})
    step._vlm_provider_override = None
    step._load_prev_checkpoint = MagicMock(side_effect=lambda sid: cp_map.get(sid))
    return step


def _patches(tmp_path, *, enabled=True, dossier=True, real_provider=False, mode="on"):
    return [
        patch("app.core.config.settings.projects_dir", str(tmp_path)),
        patch("app.core.config.settings.background_mode", mode),
        patch("app.core.config.settings.base_location_dossier_enabled", dossier),
        patch("app.core.config.settings.shot_projection_card_enabled", enabled),
        patch("app.core.config.settings.shot_projection_card_real_provider_enabled", real_provider),
        patch("app.core.config.settings.shot_projection_card_prompt_version", "1"),
    ]


def _run(step, patches):
    for p in patches:
        p.start()
    try:
        return step._execute()
    finally:
        for p in reversed(patches):
            p.stop()


def _good_provider_output(*, inventory, fp_id, bg_id, shot_id):
    items = []
    for m in inventory:
        items.append({
            "marker_number": m["number"], "marker_layer": m["marker_layer"],
            "expected_label": m["expected_label"], "visibility": "visible",
            "horizontal_band": "left", "depth_band": "foreground",
            "occlusion_note": "", "evidence": "좌측 전경", "source_ref": "fp", "confidence": 0.8,
        })
    return {
        "status": "ok", "fp_id": fp_id, "bg_id": bg_id, "shot_id": shot_id,
        "camera_pose_source": "cam#2", "visible_items": items,
        "scene_visible_description": "전경 왼쪽에 싱크대와 상자.",
        "bg_plate_visible_description": "전경 왼쪽에 싱크대가 있는 좁은 실내.",
        "plate_description_excludes_transient": "yes",
        "not_visible_or_occluded_summary": "",
        "vlm_reported_state": "ok",
        "self_consistency": {"prose_matches_structured": "consistent", "notes": ""},
        "confidence": 0.8, "missing_inputs": [], "diagnostics": [],
    }


# ──────────────────────────── constants / gates ────────────────────────────


def test_module_constants_locked():
    assert SCHEMA_VERSION == 1
    assert PROMPT_VERSION == "1"


def test_default_off_returns_not_applicable(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _patches(tmp_path, enabled=False))
    assert result["applicable_count"] == 0
    assert result["data"] == {}


def test_not_applicable_when_background_mode_off(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _patches(tmp_path, mode="off"))
    assert result["applicable_count"] == 0


def test_not_applicable_when_dossier_disabled(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _patches(tmp_path, dossier=False))
    assert result["applicable_count"] == 0


def test_not_applicable_when_no_targets(tmp_path):
    cp = _cp_map(background_master_plan={"data": {"background_catalog": {}}})
    step = _new_step(cp_map=cp)
    result = _run(step, _patches(tmp_path))
    assert result["applicable_count"] == 0


# ──────────────────────── synthetic path (provider OFF) ────────────────────


def test_synthetic_path_produces_cards_no_real_vlm(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _patches(tmp_path))
    assert result["applicable_count"] == 1
    cards = result["data"]["cards"]
    # one card per (bg, shot) in applies_to_shots
    assert set(cards.keys()) == {"L05B09::S5_Shot1", "L05B09::S5_Shot7"}
    assert result["data"]["real_vlm_call_count"] == 0
    assert result["data"]["image_api_call_count"] == 0
    assert result["data"]["llm_call_count"] == 0


def test_synthetic_cards_never_auto_pass(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _patches(tmp_path))
    for card in result["data"]["cards"].values():
        assert card["card_state"] != "pass"


def test_card_envelope_has_provenance_and_registry(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _patches(tmp_path))
    env = result["data"]["cards"]["L05B09::S5_Shot1"]["card"]
    assert env["bg_id"] == "L05B09"
    assert env["fp_id"] == "fp_l05_main"
    assert env["semantic_gate_state"] == "not_available"  # semantic cp absent
    assert env["substrate_kind"] == "detailed_fp"
    assert env["substrate_status"] == "ok"
    assert env["marker_registry"]["union_numbers"] == [3, 7, 18]
    assert "shot_context_hash" in env["source_hashes"]


def test_semantic_gate_state_carried_when_present(tmp_path):
    cp = _cp_map(floor_plan_semantic_readback={"data": {"per_fp": {
        "fp_l05_main": {"gate_state": "needs_fix"},
    }}})
    step = _new_step(cp_map=cp)
    result = _run(step, _patches(tmp_path))
    env = result["data"]["cards"]["L05B09::S5_Shot1"]["card"]
    assert env["semantic_gate_state"] == "needs_fix"
    # semantic needs_fix → hard block
    assert result["data"]["cards"]["L05B09::S5_Shot1"]["card_state"] == "blocked"


def test_fp_render_missing_blocks_card_no_provider(tmp_path):
    # render is a hard precondition: missing FP substrate → deterministic
    # blocked + no provider call (Required C), not a gate-level block.
    cp = _cp_map_with_shot_ctx(floor_plan_render={"data": {"floor_plans": {}}})
    step = _new_step(cp_map=cp)
    called = {"n": 0}

    def provider(*, inventory, fp_id, bg_id, shot_id, fp_image_path, prompt_context=None):
        called["n"] += 1
        return _good_provider_output(inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id)

    step.set_vlm_provider_for_testing(provider)
    result = _run(step, _patches(tmp_path, real_provider=True))
    assert called["n"] == 0
    for card in result["data"]["cards"].values():
        assert card["card_state"] == "blocked"
        assert card["fallback_reason"] == "fp_render_missing"


# ──────────────────────── injected provider (real accounting) ────────────────


def test_injected_provider_counted_and_validated(tmp_path):
    step = _new_step(cp_map=_cp_map())

    calls = {"n": 0}

    def provider(*, inventory, fp_id, bg_id, shot_id, fp_image_path, prompt_context=None):
        calls["n"] += 1
        return _good_provider_output(
            inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id
        )

    step.set_vlm_provider_for_testing(provider)
    result = _run(step, _patches(tmp_path, real_provider=True))
    # two targets → two real calls
    assert calls["n"] == 2
    assert result["data"]["real_vlm_call_count"] == 2
    # a fully-good provider output passes the gate
    assert result["data"]["cards"]["L05B09::S5_Shot1"]["card_state"] == "pass"


def test_injected_bad_provider_fails_closed_per_card(tmp_path):
    step = _new_step(cp_map=_cp_map())

    def bad_provider(*, inventory, fp_id, bg_id, shot_id, fp_image_path, prompt_context=None):
        out = _good_provider_output(inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id)
        out["bg_id"] = "WRONG"  # id mismatch → ProjectionCardError
        return out

    step.set_vlm_provider_for_testing(bad_provider)
    result = _run(step, _patches(tmp_path, real_provider=True))
    assert result["failed_count"] == 2
    for card in result["data"]["cards"].values():
        assert "error" in card


# ── Required 1: shot context threaded into hash + provider ────────────

_SHOT_STAGING = {"shots": [
    {"scene_index": 5, "shot_index": 1, "camera_direction": "low from door",
     "framing_scale": "wide", "shot_type": "establishing"},
    {"scene_index": 5, "shot_index": 7, "camera_direction": "close on hands",
     "framing_scale": "close", "shot_type": "insert"},
]}
_SCENE_SAVE = {"segments": [
    {"scene_index": 5, "heading": "주방", "text": "수리영이 좁은 주방으로 들어선다."},
]}


def _cp_map_with_shot_ctx(**extra):
    return _cp_map(
        shot_staging={"data": _SHOT_STAGING},
        scene_save={"data": _SCENE_SAVE},
        **extra,
    )


def test_provider_receives_shot_intent_and_scene(tmp_path):
    step = _new_step(cp_map=_cp_map_with_shot_ctx())
    seen = {}

    def provider(*, inventory, fp_id, bg_id, shot_id, fp_image_path, prompt_context=None):
        seen[shot_id] = prompt_context
        return _good_provider_output(inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id)

    step.set_vlm_provider_for_testing(provider)
    _run(step, _patches(tmp_path, real_provider=True))
    ctx1 = seen["S5_Shot1"]
    assert ctx1["shot_context"]["shot_intent"]["camera_direction"] == "low from door"
    assert ctx1["shot_context"]["scene"]["text"] == "수리영이 좁은 주방으로 들어선다."
    # pack/model provenance threaded to provider (Required 3)
    assert ctx1["pack_version"] == "1.202605302212"
    assert ctx1["model"] == _PROVIDER_MODEL


def test_shot_context_in_source_hashes(tmp_path):
    step = _new_step(cp_map=_cp_map_with_shot_ctx())
    result = _run(step, _patches(tmp_path))
    env1 = result["data"]["cards"]["L05B09::S5_Shot1"]["card"]
    env7 = result["data"]["cards"]["L05B09::S5_Shot7"]["card"]
    # different shots → different shot_context_hash → different card_id
    assert env1["source_hashes"]["shot_context_hash"] != env7["source_hashes"]["shot_context_hash"]
    assert env1["card_id"] != env7["card_id"]


# ── Required 3: provenance recorded as resolved pack/model/provider ───

def test_envelope_records_resolved_pack_model_provider(tmp_path):
    step = _new_step(cp_map=_cp_map_with_shot_ctx())
    result = _run(step, _patches(tmp_path))
    env = result["data"]["cards"]["L05B09::S5_Shot1"]["card"]
    sh = env["source_hashes"]
    assert sh["model"] == _PROVIDER_MODEL
    assert sh["provider"] == "openai"
    assert sh["pack_version"] == "1.202605302212"
    assert sh["prompt_version"] == "1"
    assert env["model"] == _PROVIDER_MODEL


# ── Required 2: camera_recommendation missing → deterministic blocked ──

def test_camera_rec_missing_blocks_card_no_provider_call(tmp_path):
    # floor_plan_prompt has camera_recommendations for a DIFFERENT bg only
    cp = _cp_map_with_shot_ctx(floor_plan_prompt={"data": {"floor_plans": {
        "fp_l05_main": {"camera_recommendations": [{"bg_id": "OTHER_BG"}]},
    }}})
    step = _new_step(cp_map=cp)

    called = {"n": 0}

    def provider(*, inventory, fp_id, bg_id, shot_id, fp_image_path, prompt_context=None):
        called["n"] += 1
        return _good_provider_output(inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id)

    step.set_vlm_provider_for_testing(provider)
    result = _run(step, _patches(tmp_path, real_provider=True))
    # camera pose missing → no real VLM call, card blocked with reason
    assert called["n"] == 0
    assert result["data"]["real_vlm_call_count"] == 0
    for card in result["data"]["cards"].values():
        assert card["card_state"] == "blocked"
        assert card["fallback_reason"] == "camera_recommendation_missing"


# ── Required B: dossier/geometry hard dep enforced ────────────────────

def test_dossier_missing_blocks_card_no_provider(tmp_path):
    cp = _cp_map_with_shot_ctx(base_location_dossier={"data": {"dossiers": {}}})
    step = _new_step(cp_map=cp)
    called = {"n": 0}

    def provider(*, inventory, fp_id, bg_id, shot_id, fp_image_path, prompt_context=None):
        called["n"] += 1
        return _good_provider_output(inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id)

    step.set_vlm_provider_for_testing(provider)
    result = _run(step, _patches(tmp_path, real_provider=True))
    assert called["n"] == 0
    for card in result["data"]["cards"].values():
        assert card["card_state"] == "blocked"
        assert card["fallback_reason"] == "dossier_missing"


def test_geometry_missing_blocks_card_no_provider(tmp_path):
    cp = _cp_map_with_shot_ctx(floor_plan_geometry_readback={"data": {"per_fp": {}}})
    step = _new_step(cp_map=cp)
    called = {"n": 0}

    def provider(*, inventory, fp_id, bg_id, shot_id, fp_image_path, prompt_context=None):
        called["n"] += 1
        return _good_provider_output(inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id)

    step.set_vlm_provider_for_testing(provider)
    result = _run(step, _patches(tmp_path, real_provider=True))
    assert called["n"] == 0
    for card in result["data"]["cards"].values():
        assert card["card_state"] == "blocked"
        assert card["fallback_reason"] == "geometry_missing"


def test_dossier_in_source_hashes(tmp_path):
    step = _new_step(cp_map=_cp_map_with_shot_ctx())
    result = _run(step, _patches(tmp_path))
    sh = result["data"]["cards"]["L05B09::S5_Shot1"]["card"]["source_hashes"]
    assert sh["dossier_hash"] != "not_available"
