"""W21B-w5 STEP5-B: build_simple_fp_prompt — generic simplified-FP prompt.

The builder assembles a CV-readable partition-diagram prompt from a floor plan's
numbered_elements, grouping by the closed base_layer_decision enum (never by
label text) and carrying label/position_hint through verbatim. v0 keeps spatial
enclosure + essential fixtures and drops decorative furniture + transient state
overlays. Image quality itself is validated by canary, not here — these tests
pin the deterministic structure + absolute-rule compliance.
"""
from __future__ import annotations

from app.modules.pipeline.floor_plan_light_prompt import (
    NB2_HIGH_FREQ_MIN_CAMERAS,
    NB2_SELECT_TARGET_MIN,
    RENDER_ROLE_ESSENTIAL,
    RENDER_ROLE_SKELETON,
    build_freq_aware_selection_bundle,
    build_light_fp_llm_prompt_bundle,
    build_simple_fp_prompt,
    compute_camera_use_counts,
    validate_freq_aware_selection,
    validate_light_fp_output,
)


def _elements():
    return [
        {"number": 1, "label": "거실", "position_hint": "중앙",
         "base_layer_decision": "base_structural_unit"},
        {"number": 5, "label": "욕실", "position_hint": "남서쪽",
         "base_layer_decision": "base_structural_unit"},
        {"number": 7, "label": "현관문", "position_hint": "남쪽 벽",
         "base_layer_decision": "base_opening"},
        {"number": 3, "label": "싱크대", "position_hint": "서쪽 벽",
         "base_layer_decision": "base_persistent_fixture"},
        {"number": 9, "label": "소파", "position_hint": "거실 동쪽",
         "base_layer_decision": "base_persistent_furniture"},
        {"number": 18, "label": "핏자국", "position_hint": "바닥",
         "base_layer_decision": "state_overlay_plot_cue"},
    ]


def test_groups_by_layer_includes_structure_opening_fixture():
    p = build_simple_fp_prompt(numbered_elements=_elements())
    # structural unit + opening + fixture labels present (passthrough data)
    assert "거실" in p and "욕실" in p  # structural units
    assert "현관문" in p               # opening
    assert "싱크대" in p               # fixture


def test_decorative_furniture_and_overlay_excluded_by_default():
    p = build_simple_fp_prompt(numbered_elements=_elements())
    assert "소파" not in p     # persistent_furniture dropped (v0 boundary)
    assert "핏자국" not in p   # state_overlay transient always dropped


def test_include_furniture_flag_adds_room_defining_items():
    p = build_simple_fp_prompt(numbered_elements=_elements(), include_furniture=True)
    assert "소파" in p          # furniture now included
    assert "핏자국" not in p     # overlay still excluded


def test_generic_no_scenario_tokens_in_static_text():
    # the static scaffolding must be scenario-agnostic — strip the runtime
    # element lines and assert no room-name leakage in the boilerplate.
    p = build_simple_fp_prompt(numbered_elements=[])
    for token in ("거실", "욕실", "bathroom", "bedroom", "living", "kitchen",
                  "rooftop", "Korean"):
        assert token not in p
    # generic CV-readable scaffolding still present
    assert "PARTITION DIAGRAM" in p
    assert "THICK solid black" in p
    assert "WHITE background" in p


def test_numbers_carried_and_sorted():
    p = build_simple_fp_prompt(numbered_elements=_elements())
    # structural units sorted by number within their block: 1 before 5
    assert p.index(" 1: 거실") < p.index(" 5: 욕실")


def test_malformed_elements_are_skipped():
    bad = [
        {"label": "no number", "base_layer_decision": "base_structural_unit"},
        {"number": True, "label": "bool", "base_layer_decision": "base_opening"},
        "not a dict",
        {"number": 2, "label": "현관", "base_layer_decision": "base_structural_unit"},
    ]
    p = build_simple_fp_prompt(numbered_elements=bad)
    assert "현관" in p
    assert "no number" not in p and "bool" not in p


# ──────────── LLM-driven generator: bundle + validator guards ────────────


def _src_elements():
    return [
        {"number": 1, "label": "거실", "base_layer_decision": "base_structural_unit"},
        {"number": 5, "label": "욕실", "base_layer_decision": "base_structural_unit"},
        {"number": 7, "label": "현관문", "base_layer_decision": "base_opening"},
        {"number": 3, "label": "싱크대", "base_layer_decision": "base_persistent_fixture"},
        {"number": 9, "label": "소파", "base_layer_decision": "base_persistent_furniture"},
        {"number": 18, "label": "핏자국", "base_layer_decision": "state_overlay_plot_cue"},
    ]


def _good_raw():
    return {
        "selected_markers": [
            {"number": 1, "label": "거실", "render_role": RENDER_ROLE_SKELETON, "reason": "room"},
            {"number": 5, "label": "욕실", "render_role": RENDER_ROLE_SKELETON, "reason": "room"},
            {"number": 7, "label": "현관문", "render_role": RENDER_ROLE_SKELETON, "reason": "door"},
            {"number": 3, "label": "싱크대", "render_role": RENDER_ROLE_ESSENTIAL, "reason": "kitchen anchor"},
        ],
        "dropped_markers": [
            {"number": 9, "label": "소파", "reason": "decorative"},
            {"number": 18, "label": "핏자국", "reason": "state overlay"},
        ],
        "room_schematic_prompt": "x" * 80,
        "safety_contract": ["thick closed walls", "sparse markers"],
    }


def test_bundle_carries_elements_and_schema():
    b = build_light_fp_llm_prompt_bundle(_src_elements())
    assert "system" in b and "user" in b and "schema" in b
    assert "거실" in b["user"] and "핏자국" in b["user"]  # passthrough data
    # static system text must not bake in this FP's runtime labels (scenario
    # data). Generic English room-type examples (bathroom/kitchen) are fine —
    # they apply to any scenario and only guide the essential-fixture judgement.
    assert "거실" not in b["system"] and "핏자국" not in b["system"]


def test_validator_accepts_good_output():
    res = validate_light_fp_output(raw=_good_raw(), numbered_elements=_src_elements())
    assert res["ok"] is True
    assert res["selected_numbers"] == [1, 3, 5, 7]
    assert res["diagnostics"] == []


def test_validator_fails_when_skeleton_dropped():
    raw = _good_raw()
    # drop the bathroom room (5) — a structural skeleton element
    raw["selected_markers"] = [m for m in raw["selected_markers"] if m["number"] != 5]
    res = validate_light_fp_output(raw=raw, numbered_elements=_src_elements())
    assert res["ok"] is False
    assert any("skeleton elements dropped" in d for d in res["diagnostics"])


def test_validator_fails_when_overlay_selected():
    raw = _good_raw()
    raw["selected_markers"].append(
        {"number": 18, "label": "핏자국", "render_role": RENDER_ROLE_ESSENTIAL, "reason": "x"})
    res = validate_light_fp_output(raw=raw, numbered_elements=_src_elements())
    assert res["ok"] is False
    assert any("state-overlay" in d for d in res["diagnostics"])


def test_over_count_is_advisory_not_a_failure():
    # count over the soft target must NOT flip ok (a too-tight count cap would
    # fall back to the cluttered deterministic builder — the failure being fixed).
    raw = _good_raw()
    res = validate_light_fp_output(
        raw=raw, numbered_elements=_src_elements(), select_soft_target=2)
    assert res["ok"] is True  # structural integrity intact → still usable
    assert any("soft target" in d for d in res["diagnostics"])


def test_validator_non_dict_fails_closed():
    res = validate_light_fp_output(raw=None, numbered_elements=_src_elements())
    assert res["ok"] is False


# ──────────── NB2 freq-aware sparse selection (rework) ────────────


def _freq_elements():
    # 2 rooms + 1 opening + 4 fixtures/furniture + 1 overlay.
    return [
        {"number": 1, "label": "거실", "base_layer_decision": "base_structural_unit"},
        {"number": 2, "label": "침실", "base_layer_decision": "base_structural_unit"},
        {"number": 7, "label": "현관문", "base_layer_decision": "base_opening"},
        {"number": 16, "label": "TV", "base_layer_decision": "base_persistent_fixture"},
        {"number": 23, "label": "커튼", "base_layer_decision": "base_persistent_fixture"},
        {"number": 30, "label": "침대", "base_layer_decision": "base_persistent_furniture"},
        {"number": 41, "label": "화분", "base_layer_decision": "base_persistent_furniture"},
        {"number": 88, "label": "핏자국", "base_layer_decision": "state_overlay_plot_cue"},
    ]


def _cameras():
    return [
        {"bg_id": "B01", "use_numbered_elements": [1, 16, 23], "ignore_numbered_elements": []},
        {"bg_id": "B02", "use_numbered_elements": [16, 23, 16], "ignore_numbered_elements": []},
        {"bg_id": "B03", "use_numbered_elements": [2, 30], "ignore_numbered_elements": []},
    ]


def test_compute_camera_use_counts_aggregates_frequency():
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    assert counts[16] == 2  # framed by B01 + B02 (dup inside B02 counts once)
    assert counts[23] == 2
    assert counts[1] == 1
    assert counts[30] == 1
    assert counts[41] == 0  # no camera frames it
    assert counts[88] == 0


def _good_freq_raw():
    return {
        "geometry_skeleton": [
            {"number": 1, "label": "거실", "kind": "room"},
            {"number": 2, "label": "침실", "kind": "room"},
            {"number": 7, "label": "현관문", "kind": "door"},
        ],
        "essential_numbered_markers": [
            {"number": 16, "label": "TV", "reason": "framed often", "camera_use_count": 2},
            {"number": 23, "label": "커튼", "reason": "framed often", "camera_use_count": 2},
            {"number": 30, "label": "침대", "reason": "bedroom anchor", "camera_use_count": 1},
        ],
        "high_frequency_candidates": [],
        "metadata_only": [
            {"number": 41, "label": "화분"},
            {"number": 88, "label": "핏자국"},
        ],
        "room_schematic_prompt": "x" * 80,
    }


def test_freq_bundle_carries_camera_counts_and_schema():
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    b = build_freq_aware_selection_bundle(_freq_elements(), counts)
    assert "system" in b and "user" in b and "schema" in b
    assert '"camera_use_count": 2' in b["user"]  # the TV/curtain signal is carried
    assert "TV" in b["user"] and "핏자국" in b["user"]
    # no runtime scenario tokens baked into the static system prompt. Generic
    # English room-type examples (TV/sofa/bathroom) are fine — they apply to any
    # scenario; the runtime data is the Korean labels, which must NOT appear.
    assert "핏자국" not in b["system"] and "커튼" not in b["system"]
    assert "거실" not in b["system"] and "침대" not in b["system"]


def test_freq_validator_accepts_good_output():
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    res = validate_freq_aware_selection(
        raw=_good_freq_raw(), numbered_elements=_freq_elements(),
        camera_use_counts=counts)
    assert res["ok"] is True
    assert res["essential_numbers"] == [16, 23, 30]
    assert res["unjustified_high_freq"] == []


def test_freq_validator_fails_when_room_not_drawn():
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    raw = _good_freq_raw()
    # drop the bedroom (2) from both skeleton and essential → room not drawn.
    raw["geometry_skeleton"] = [s for s in raw["geometry_skeleton"] if s["number"] != 2]
    res = validate_freq_aware_selection(
        raw=raw, numbered_elements=_freq_elements(), camera_use_counts=counts)
    assert res["ok"] is False
    assert any("not drawn" in d for d in res["diagnostics"])


def test_freq_validator_fails_when_overlay_essential():
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    raw = _good_freq_raw()
    raw["essential_numbered_markers"].append(
        {"number": 88, "label": "핏자국", "reason": "x", "camera_use_count": 0})
    res = validate_freq_aware_selection(
        raw=raw, numbered_elements=_freq_elements(), camera_use_counts=counts)
    assert res["ok"] is False
    assert any("state-overlay" in d for d in res["diagnostics"])


def test_freq_validator_fails_when_overlay_in_skeleton():
    # an overlay mis-bucketed into geometry_skeleton must ALSO hard-fail — else a
    # story-state cue would be "drawn" as skeleton (Codex W21B-w5 review).
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    raw = _good_freq_raw()
    raw["geometry_skeleton"].append({"number": 88, "label": "핏자국", "kind": "room"})
    res = validate_freq_aware_selection(
        raw=raw, numbered_elements=_freq_elements(), camera_use_counts=counts)
    assert res["ok"] is False
    assert any("state-overlay" in d for d in res["diagnostics"])


def test_freq_validator_wrong_layer_skeleton_is_ignored_not_room_coverage():
    # a fixture (TV, base_persistent_fixture) mis-bucketed into geometry_skeleton
    # is ignored (diagnostic) and does NOT count toward the drawn set, so its
    # high-frequency omission is still surfaced rather than masked.
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    raw = _good_freq_raw()
    # drop TV(16) from essential and instead mis-bucket it into the skeleton.
    raw["essential_numbered_markers"] = [
        m for m in raw["essential_numbered_markers"] if m["number"] != 16]
    raw["geometry_skeleton"].append({"number": 16, "label": "TV", "kind": "room"})
    res = validate_freq_aware_selection(
        raw=raw, numbered_elements=_freq_elements(), camera_use_counts=counts)
    assert res["ok"] is True  # structural integrity intact (rooms still drawn)
    assert 16 not in res["skeleton_numbers"]  # wrong-layer skeleton excluded
    assert any("non-structural" in d for d in res["diagnostics"])
    assert 16 in res["unjustified_high_freq"]  # omission NOT masked by mis-bucket


def test_freq_validator_flags_unjustified_high_freq_drop_but_stays_ok():
    # drop the TV (16, camera_use_count=2) entirely without justification → the
    # protection diagnostic fires, but ok stays True (advisory, not a hard gate).
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    raw = _good_freq_raw()
    raw["essential_numbered_markers"] = [
        m for m in raw["essential_numbered_markers"] if m["number"] != 16]
    raw["metadata_only"].append({"number": 16, "label": "TV"})
    res = validate_freq_aware_selection(
        raw=raw, numbered_elements=_freq_elements(), camera_use_counts=counts)
    assert res["ok"] is True
    assert 16 in res["unjustified_high_freq"]
    assert any("high-frequency" in d for d in res["diagnostics"])


def test_freq_validator_high_freq_justified_is_not_flagged():
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    raw = _good_freq_raw()
    raw["essential_numbered_markers"] = [
        m for m in raw["essential_numbered_markers"] if m["number"] != 16]
    raw["high_frequency_candidates"].append(
        {"number": 16, "label": "TV", "drop_reason": "wall-mounted, implied by room"})
    res = validate_freq_aware_selection(
        raw=raw, numbered_elements=_freq_elements(), camera_use_counts=counts)
    assert res["ok"] is True
    assert res["unjustified_high_freq"] == []


def test_freq_validator_too_sparse_is_advisory_only():
    counts = compute_camera_use_counts(_freq_elements(), _cameras())
    raw = _good_freq_raw()
    raw["essential_numbered_markers"] = [
        {"number": 16, "label": "TV", "reason": "x", "camera_use_count": 2}]
    raw["high_frequency_candidates"] = [
        {"number": 23, "label": "커튼", "drop_reason": "behind window"}]
    res = validate_freq_aware_selection(
        raw=raw, numbered_elements=_freq_elements(), camera_use_counts=counts)
    assert res["ok"] is True  # below target min is advisory, not a failure
    assert any("target min" in d for d in res["diagnostics"])
    assert NB2_SELECT_TARGET_MIN == 8


def test_freq_validator_non_dict_fails_closed():
    res = validate_freq_aware_selection(raw=None, numbered_elements=_freq_elements())
    assert res["ok"] is False
    assert NB2_HIGH_FREQ_MIN_CAMERAS == 2
