"""W19B-1: floor_plan_overlay_payload deterministic builder tests.

production-side, minimal. LLM / image / VLM API call 0, DB / ImageAsset write 0.
모든 join 은 marker number / fp_id / bg_id exact equality 만 — regex /
substring / lexical inference 0.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline.floor_plan_overlay_payload import (
    FloorPlanOverlayPayloadError,
    build_overlay_payload,
)


def _build_fp_prompt_cp(*, numbered, cameras, fp_id="fp_a"):
    return {
        "data": {
            "floor_plans": {
                fp_id: {
                    "status": "ok",
                    "fp_id": fp_id,
                    "t2i_prompt": "x" * 50,
                    "key_elements": [],
                    "numbered_elements": numbered,
                    "camera_recommendations": cameras,
                }
            }
        }
    }


def _build_master_plan_cp(*, backgrounds, group_id="g_a"):
    return {
        "data": {
            "plans": {
                group_id: {
                    "status": "ok",
                    "plan": {
                        "group_id": group_id,
                        "rationale_summary": "",
                        "floor_plans": [],
                        "backgrounds": backgrounds,
                        "gen_order": [bg["bg_id"] for bg in backgrounds],
                    },
                }
            }
        }
    }


def _two_bg_scenario():
    """clean L01B01 (overlay #2 ignored) + dirty L01B02 (overlay #2 used)."""
    numbered = [
        {
            "number": 1,
            "label": "primary unit",
            "category": "area",
            "position_hint": "center",
            "base_layer_decision": "base_structural_unit",
        },
        {
            "number": 2,
            "label": "transient cue",
            "category": "prop",
            "position_hint": "north wall",
            "base_layer_decision": "state_overlay_plot_cue",
        },
        {
            "number": 3,
            "label": "anchor furniture",
            "category": "furniture",
            "position_hint": "south wall",
            "base_layer_decision": "base_persistent_furniture",
        },
    ]
    cameras = [
        {
            "bg_id": "L01B01",
            "sub_location": "primary_unit",
            "camera_position": "near number 1, facing east",
            "camera_height": "eye level",
            "lens_hint": "35mm",
            "framing_notes": "",
            "use_numbered_elements": [1, 3],
            "ignore_numbered_elements": [2],
        },
        {
            "bg_id": "L01B02",
            "sub_location": "primary_unit",
            "camera_position": "near number 1, facing west",
            "camera_height": "eye level",
            "lens_hint": "35mm",
            "framing_notes": "",
            "use_numbered_elements": [1, 2, 3],
            "ignore_numbered_elements": [],
        },
    ]
    backgrounds = [
        {
            "bg_id": "L01B01",
            "loc_id": "L01",
            "sub_location": "primary_unit",
            "state_label_raw": "clean",
            "applies_to_shots": ["S1_Shot1"],
            "depends_on_fp": ["fp_a"],
            "depends_on_bg": [],
        },
        {
            "bg_id": "L01B02",
            "loc_id": "L01",
            "sub_location": "primary_unit",
            "state_label_raw": "dirty",
            "applies_to_shots": ["S1_Shot2"],
            "depends_on_fp": ["fp_a"],
            "depends_on_bg": ["L01B01"],
        },
    ]
    return numbered, cameras, backgrounds


# ----- happy path -----


def test_happy_path_clean_and_dirty_bg_partition_use():
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)

    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"],
        master_plan_data=mp_cp["data"],
    )
    assert set(out) == {"L01B01", "L01B02"}

    clean = out["L01B01"]
    assert clean["fp_id"] == "fp_a"
    assert clean["use_numbered_elements"] == [1, 3]
    assert clean["ignore_numbered_elements"] == [2]
    assert [m["number"] for m in clean["base_markers_to_reference"]] == [1, 3]
    assert clean["transient_markers_to_describe"] == []
    assert [m["number"] for m in clean["ignored_state_overlay_markers"]] == [2]
    assert clean["clean_background_expected"] is True

    dirty = out["L01B02"]
    assert dirty["use_numbered_elements"] == [1, 2, 3]
    assert dirty["ignore_numbered_elements"] == []
    assert [m["number"] for m in dirty["base_markers_to_reference"]] == [1, 3]
    assert [m["number"] for m in dirty["transient_markers_to_describe"]] == [2]
    assert dirty["ignored_state_overlay_markers"] == []
    assert dirty["clean_background_expected"] is False


def test_dominant_target_unit_marker_set_when_single_structural_unit_in_use():
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
    )
    # both bgs have exactly one base_structural_unit (#1) in use.
    for bg in ("L01B01", "L01B02"):
        assert out[bg]["target_unit_marker_numbers"] == [1]
        assert out[bg]["dominant_target_unit_marker_number"] == 1
        # diagnostics 가 unit ambiguity 관련 메시지 없음.
        assert not any("dominant_target_unit" in d for d in out[bg]["diagnostics"])


def test_dominant_target_unit_marker_none_when_multiple_structural_units_in_use():
    numbered = [
        {
            "number": 1,
            "label": "unit A",
            "category": "area",
            "position_hint": "west",
            "base_layer_decision": "base_structural_unit",
        },
        {
            "number": 2,
            "label": "unit B",
            "category": "area",
            "position_hint": "east",
            "base_layer_decision": "base_structural_unit",
        },
    ]
    cameras = [
        {
            "bg_id": "L01B01",
            "sub_location": "split",
            "camera_position": "between number 1 and number 2",
            "camera_height": "eye level",
            "lens_hint": "35mm",
            "framing_notes": "",
            "use_numbered_elements": [1, 2],
            "ignore_numbered_elements": [],
        }
    ]
    backgrounds = [
        {
            "bg_id": "L01B01",
            "loc_id": "L01",
            "sub_location": "split",
            "applies_to_shots": ["S1_Shot1"],
            "depends_on_fp": ["fp_a"],
            "depends_on_bg": [],
        }
    ]
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
    )
    assert out["L01B01"]["target_unit_marker_numbers"] == [1, 2]
    assert out["L01B01"]["dominant_target_unit_marker_number"] is None
    assert any(
        "multiple base_structural_unit" in d for d in out["L01B01"]["diagnostics"]
    )


def test_dominant_target_unit_marker_none_when_no_structural_unit_in_use():
    numbered = [
        {
            "number": 1,
            "label": "anchor furniture",
            "category": "furniture",
            "position_hint": "south",
            "base_layer_decision": "base_persistent_furniture",
        }
    ]
    cameras = [
        {
            "bg_id": "L01B01",
            "sub_location": "anywhere",
            "camera_position": "near number 1",
            "camera_height": "eye level",
            "lens_hint": "35mm",
            "framing_notes": "",
            "use_numbered_elements": [1],
            "ignore_numbered_elements": [],
        }
    ]
    backgrounds = [
        {
            "bg_id": "L01B01",
            "loc_id": "L01",
            "sub_location": "anywhere",
            "applies_to_shots": ["S1_Shot1"],
            "depends_on_fp": ["fp_a"],
            "depends_on_bg": [],
        }
    ]
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
    )
    assert out["L01B01"]["target_unit_marker_numbers"] == []
    assert out["L01B01"]["dominant_target_unit_marker_number"] is None
    assert any(
        "no base_structural_unit" in d for d in out["L01B01"]["diagnostics"]
    )


# ----- fail-closed paths -----


def test_unknown_use_marker_number_fails_closed():
    numbered, cameras, backgrounds = _two_bg_scenario()
    cameras[0]["use_numbered_elements"].append(99)
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    with pytest.raises(FloorPlanOverlayPayloadError, match="unknown marker numbers"):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
        )


def test_use_ignore_overlap_fails_closed():
    numbered, cameras, backgrounds = _two_bg_scenario()
    cameras[0]["ignore_numbered_elements"] = [1]  # 1 is in use already.
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    with pytest.raises(FloorPlanOverlayPayloadError, match="non-empty"):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
        )


def test_missing_camera_for_bg_fails_closed():
    numbered, cameras, backgrounds = _two_bg_scenario()
    cameras.pop()  # drop L01B02 camera entry.
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    with pytest.raises(
        FloorPlanOverlayPayloadError,
        match="missing camera_recommendations entry",
    ):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
        )


def test_missing_fp_entry_fails_closed():
    numbered, cameras, backgrounds = _two_bg_scenario()
    backgrounds[0]["depends_on_fp"] = ["fp_unknown"]
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    with pytest.raises(FloorPlanOverlayPayloadError, match="not found"):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
        )


def test_empty_depends_on_fp_fails_closed():
    numbered, cameras, backgrounds = _two_bg_scenario()
    backgrounds[0]["depends_on_fp"] = []
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    with pytest.raises(FloorPlanOverlayPayloadError, match="empty depends_on_fp"):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
        )


@pytest.mark.parametrize(
    "surface_role", ["exterior_plate", "transition_zone", "site_surface"],
)
def test_fp_less_non_interior_surface_role_skips_overlay(surface_role):
    numbered, cameras, backgrounds = _two_bg_scenario()
    backgrounds[0]["depends_on_fp"] = []
    backgrounds[0]["surface_role"] = surface_role
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)

    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
    )

    assert set(out) == {"L01B02"}


def test_non_integer_use_element_fails_closed():
    numbered, cameras, backgrounds = _two_bg_scenario()
    cameras[0]["use_numbered_elements"] = ["1"]
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    with pytest.raises(FloorPlanOverlayPayloadError, match="not an integer"):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
        )


def test_failed_fp_entry_treated_as_missing():
    """status != 'ok' fp entry 는 join 대상 아님."""
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    fp_cp["data"]["floor_plans"]["fp_a"]["status"] = "failed"
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    with pytest.raises(FloorPlanOverlayPayloadError, match="not found"):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
        )


def test_prev_shot_only_group_excluded():
    """status != 'ok' group 의 plan.backgrounds 는 무시."""
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = {
        "data": {
            "plans": {
                "g_main": {
                    "status": "ok",
                    "plan": {
                        "group_id": "g_main",
                        "floor_plans": [],
                        "backgrounds": [backgrounds[0]],
                        "gen_order": ["L01B01"],
                    },
                },
                "g_prev": {
                    "status": "prev_shot_only",
                    "plan": None,
                },
            }
        }
    }
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
    )
    assert set(out) == {"L01B01"}


# ----- diagnostics -----


def test_base_marker_in_ignore_records_diagnostic():
    """base_* marker 가 ignore 에 있으면 fail 아닌 diagnostic — production 에선 드문 case."""
    numbered = [
        {
            "number": 1,
            "label": "unit A",
            "category": "area",
            "position_hint": "center",
            "base_layer_decision": "base_structural_unit",
        },
        {
            "number": 2,
            "label": "fixed sink",
            "category": "prop",
            "position_hint": "north wall",
            "base_layer_decision": "base_persistent_fixture",
        },
        {
            "number": 3,
            "label": "transient cue",
            "category": "prop",
            "position_hint": "east wall",
            "base_layer_decision": "state_overlay_plot_cue",
        },
    ]
    cameras = [
        {
            "bg_id": "L01B01",
            "sub_location": "unit_a",
            "camera_position": "near number 1, facing east",
            "camera_height": "eye level",
            "lens_hint": "35mm",
            "framing_notes": "",
            "use_numbered_elements": [1, 3],
            "ignore_numbered_elements": [2],  # base_persistent_fixture in ignore.
        }
    ]
    backgrounds = [
        {
            "bg_id": "L01B01",
            "loc_id": "L01",
            "sub_location": "unit_a",
            "applies_to_shots": ["S1_Shot1"],
            "depends_on_fp": ["fp_a"],
            "depends_on_bg": [],
        }
    ]
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
    )
    # ignored_state_overlay_markers 는 state_overlay 만 — base_persistent_fixture 제외.
    assert out["L01B01"]["ignored_state_overlay_markers"] == []
    assert any(
        "base layer marker" in d and "#2" in d
        for d in out["L01B01"]["diagnostics"]
    )


# ----- W19B-1 Codex BLOCKING fix: exact enum membership (no prefix matching) -----


@pytest.mark.parametrize(
    "invalid_decision",
    [
        "base_fake",  # prefix match 로 통과하면 안 됨.
        "state_overlay_fake",  # prefix match 로 통과하면 안 됨.
        None,  # missing.
        123,  # non-string.
    ],
)
def test_invalid_base_layer_decision_fails_closed(invalid_decision):
    """unknown / missing / non-string base_layer_decision 은 모두 fail-closed.

    Codex BLOCKING (W19B-1): payload module 이 ``startswith("base_")`` 같은
    prefix matching 으로 분류하면 안 된다. exact enum membership 만.
    """
    numbered, cameras, backgrounds = _two_bg_scenario()
    numbered[0]["base_layer_decision"] = invalid_decision
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    with pytest.raises(FloorPlanOverlayPayloadError, match="base_layer_decision"):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
        )


# ----- W21B-wave-4 A2: marker meta carry (render_role / top_down_legibility) -----
#
# A2 lock (Claude+Codex 2026-05-30): overlay payload marker entry 에
# render_role / top_down_legibility 를 OPTIONAL carry. marker_meta_by_fp 미주입
# 이면 모든 marker 가 'not_applicable' 로 채워져 옛 동작이 그대로 유지된다
# (backward-compat). 값은 enum exact-membership 만 — substring / lexical 0.


def test_marker_meta_absent_defaults_not_applicable():
    """marker_meta_by_fp 미주입 → 모든 marker entry 의 render_role /
    top_down_legibility 가 'not_applicable'. 기존 5 키는 그대로 보존."""
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"], master_plan_data=mp_cp["data"]
    )
    for bg in ("L01B01", "L01B02"):
        for key in (
            "base_markers_to_reference",
            "transient_markers_to_describe",
            "ignored_state_overlay_markers",
        ):
            for m in out[bg][key]:
                assert m["render_role"] == "not_applicable"
                assert m["top_down_legibility"] == "not_applicable"
                assert {
                    "number",
                    "label",
                    "category",
                    "position_hint",
                    "base_layer_decision",
                } <= set(m)


def test_marker_meta_carried_when_present():
    """marker_meta_by_fp 주입 → 지정 marker 는 enum 값 그대로 carry."""
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    meta = {
        "fp_a": {
            1: {
                "render_role": "structural_skeleton",
                "top_down_legibility": "clear",
            },
            3: {
                "render_role": "persistent_context",
                "top_down_legibility": "low",
            },
        }
    }
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"],
        master_plan_data=mp_cp["data"],
        marker_meta_by_fp=meta,
    )
    base = {m["number"]: m for m in out["L01B01"]["base_markers_to_reference"]}
    assert base[1]["render_role"] == "structural_skeleton"
    assert base[1]["top_down_legibility"] == "clear"
    assert base[3]["render_role"] == "persistent_context"
    assert base[3]["top_down_legibility"] == "low"


def test_marker_meta_partial_unlisted_marker_na():
    """meta 에 없는 marker 는 na (partial 주입 안전)."""
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    meta = {
        "fp_a": {
            1: {
                "render_role": "structural_skeleton",
                "top_down_legibility": "clear",
            }
        }
    }
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"],
        master_plan_data=mp_cp["data"],
        marker_meta_by_fp=meta,
    )
    base = {m["number"]: m for m in out["L01B01"]["base_markers_to_reference"]}
    assert base[3]["render_role"] == "not_applicable"
    assert base[3]["top_down_legibility"] == "not_applicable"


def test_marker_meta_string_number_key_carried():
    """Codex Required (A2): marker number key 가 문자열(JSON/checkpoint round-trip)
    이어도 carry 된다 — int-key 조회만으로 조용히 na 로 떨어지면 안 된다."""
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    meta = {
        "fp_a": {
            "1": {
                "render_role": "structural_skeleton",
                "top_down_legibility": "clear",
            }
        }
    }
    out = build_overlay_payload(
        fp_prompt_data=fp_cp["data"],
        master_plan_data=mp_cp["data"],
        marker_meta_by_fp=meta,
    )
    base = {m["number"]: m for m in out["L01B01"]["base_markers_to_reference"]}
    assert base[1]["render_role"] == "structural_skeleton"
    assert base[1]["top_down_legibility"] == "clear"


@pytest.mark.parametrize(
    "bad",
    [
        {"render_role": "bogus", "top_down_legibility": "clear"},
        {"render_role": "structural_skeleton", "top_down_legibility": "sharp"},
    ],
)
def test_marker_meta_invalid_enum_fails_closed(bad):
    """잘못된 render_role / top_down_legibility enum 값은 fail-closed."""
    numbered, cameras, backgrounds = _two_bg_scenario()
    fp_cp = _build_fp_prompt_cp(numbered=numbered, cameras=cameras)
    mp_cp = _build_master_plan_cp(backgrounds=backgrounds)
    meta = {"fp_a": {1: bad}}
    with pytest.raises(FloorPlanOverlayPayloadError):
        build_overlay_payload(
            fp_prompt_data=fp_cp["data"],
            master_plan_data=mp_cp["data"],
            marker_meta_by_fp=meta,
        )
