"""W21B-wave-4: FloorPlanSemanticReadbackStep gate + path tests.

opt-in (default OFF) producer that, per fp_id in the base-location
dossier checkpoint, computes a marker-SEMANTIC readback + fail-closed
gate. Default path uses the synthetic fixture (NO real VLM call); a
test-injected provider exercises the real-provider accounting.

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

import json
from pathlib import Path
from unittest.mock import MagicMock, patch

from app.core.steps.floor_plan_semantic_readback_step import (
    PROMPT_VERSION,
    SCHEMA_VERSION,
    FloorPlanSemanticReadbackStep,
)


_DOSSIER = {
    "fp_id": "fp_a",
    "fp_image_path": "/p/c/e/floor_plan_render/fp_a.png",
    "base_marker_inventory": [
        {"number": 1, "label": "primary unit", "category": "area",
         "position_hint": "", "base_layer_decision": "base_structural_unit"},
        {"number": 2, "label": "interior opening", "category": "opening",
         "position_hint": "", "base_layer_decision": "base_opening"},
        {"number": 3, "label": "anchor seating", "category": "furniture",
         "position_hint": "", "base_layer_decision": "base_persistent_furniture"},
    ],
    "diagnostics": [],
}


def _cp_map():
    return {"base_location_dossier": {"data": {"dossiers": {"fp_a": _DOSSIER}}}}


def _new_step(*, cp_map: dict) -> FloorPlanSemanticReadbackStep:
    step = FloorPlanSemanticReadbackStep.__new__(FloorPlanSemanticReadbackStep)
    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 _enabled_patches(tmp_path, *, version="7", enabled=True, dossier=True):
    return [
        patch("app.core.config.settings.projects_dir", str(tmp_path)),
        patch("app.core.config.settings.background_mode", "on"),
        patch("app.core.config.settings.floor_plan_prompt_version", version),
        patch("app.core.config.settings.base_location_dossier_enabled", dossier),
        patch(
            "app.core.config.settings.floor_plan_semantic_readback_enabled",
            enabled,
        ),
        patch(
            "app.core.config.settings."
            "floor_plan_semantic_readback_real_provider_enabled",
            False,
        ),
    ]


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


# ──────────────────────────── constants ────────────────────────────


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


# ──────────────────────────── gates ────────────────────────────


def test_default_off_returns_not_applicable(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _enabled_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())
    patches = _enabled_patches(tmp_path)
    patches[1] = patch("app.core.config.settings.background_mode", "off")
    result = _run(step, patches)
    assert result["applicable_count"] == 0


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


def test_not_applicable_on_v5(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _enabled_patches(tmp_path, version="5"))
    assert result["applicable_count"] == 0


def test_applicable_on_v6_and_v7(tmp_path):
    for v in ("6", "7"):
        step = _new_step(cp_map=_cp_map())
        result = _run(step, _enabled_patches(tmp_path, version=v))
        assert result["applicable_count"] == 1, v


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


def test_real_load_prev_checkpoint_reads_manifest(tmp_path):
    """The step must define its own ``_load_prev_checkpoint`` — StepRunner
    does not provide one. Exercise the REAL helper (no mock) by writing a
    dossier manifest to the checkpoint path and confirming the step picks
    it up."""
    assert hasattr(FloorPlanSemanticReadbackStep, "_load_prev_checkpoint")
    cp_dir = (
        tmp_path / "p" / "checkpoints" / "episodes" / "e"
        / "base_location_dossier"
    )
    cp_dir.mkdir(parents=True)
    (cp_dir / "manifest.json").write_text(
        json.dumps({"data": {"dossiers": {"fp_a": _DOSSIER}}}),
        encoding="utf-8",
    )
    # New step WITHOUT a mocked _load_prev_checkpoint.
    step = FloorPlanSemanticReadbackStep.__new__(FloorPlanSemanticReadbackStep)
    step.project_id = "p"
    step.episode_id = "e"
    step.project_config = {}
    step.build_opik_metadata = MagicMock(return_value={})
    step._vlm_provider_override = None
    result = _run(step, _enabled_patches(tmp_path))
    assert result["applicable_count"] == 1
    assert result["data"]["per_fp"]["fp_a"]["gate_state"] == "synthetic_unverified"


def test_synthetic_path_records_gate_and_zero_vlm_calls(tmp_path):
    step = _new_step(cp_map=_cp_map())
    result = _run(step, _enabled_patches(tmp_path))
    assert result["completed_count"] == 1
    assert result["failed_count"] == 0
    assert result["data"]["real_vlm_call_count"] == 0
    fp = result["data"]["per_fp"]["fp_a"]
    assert fp["readback_status"] == "synthetic_fixture"
    assert fp["gate_state"] == "synthetic_unverified"
    assert fp["real_vlm_call_count"] == 0


# ──────────────────────── injected provider path ────────────────────


def _provider_all_match(**kw):
    from app.modules.pipeline.floor_plan_semantic_readback import (
        validate_semantic_output,
    )
    dossier = kw["dossier"]
    inv = {e["number"]: e for e in dossier["base_marker_inventory"]}
    entries = [
        {
            "number": n,
            "expected_label": inv[n]["label"],
            "expected_layer": inv[n]["base_layer_decision"],
            "observed_object_summary": "generic",
            "semantic_match": "match",
            "mismatch_reason": "",
            "source_ref": "center",
            "confidence": 0.9,
            "reasoning_basis": "ok",
        }
        for n in sorted(inv)
    ]
    out = {"status": "ok", "fp_id": dossier["fp_id"],
           "observed_marker_semantics": entries, "diagnostics": []}
    return validate_semantic_output(
        output=out, dossier=dossier, fp_id=dossier["fp_id"]
    )["readback"]


def _provider_furniture_mismatch(**kw):
    rb = _provider_all_match(**kw)
    for e in rb["observed_marker_semantics"]:
        if e["expected_layer"] == "base_persistent_furniture":
            e["semantic_match"] = "mismatch"
    return rb


def test_injected_provider_records_pass_and_one_vlm_call(tmp_path):
    step = _new_step(cp_map=_cp_map())
    step._vlm_provider_override = _provider_all_match
    result = _run(step, _enabled_patches(tmp_path))
    assert result["data"]["real_vlm_call_count"] == 1
    fp = result["data"]["per_fp"]["fp_a"]
    assert fp["readback_status"] == "ok"
    assert fp["gate_state"] == "pass"
    assert fp["real_vlm_call_count"] == 1


def test_injected_provider_mismatch_records_needs_fix(tmp_path):
    step = _new_step(cp_map=_cp_map())
    step._vlm_provider_override = _provider_furniture_mismatch
    result = _run(step, _enabled_patches(tmp_path))
    fp = result["data"]["per_fp"]["fp_a"]
    assert fp["readback_status"] == "ok"
    assert fp["gate_state"] == "needs_fix"
