"""W21B-wave-4 Phase A (dry-run): floor_plan_semantic_readback contract tests.

Deterministic shape / exact-ID / fail-closed gate tests for the new
marker-SEMANTIC readback sidecar. This sidecar answers a question the
existing W20A2 geometry readback does NOT: "does the image content drawn
at marker N actually match the expected object class / label from the
dossier inventory?" — turning marker-label consistency into an explicit
evidence-backed gate instead of trusting that metadata and PNG agree.

Boundaries asserted here (W21B-wave-4 brief §3.1):
  - Code performs ONLY structural / exact-ID checks + reads the LLM-emit
    ``semantic_match`` enum. It never lexically inspects the free-text
    evidence fields (observed_object_summary / mismatch_reason /
    reasoning_basis / source_ref). No substring / lexicon matching.
  - The gate fails closed: synthetic / unattested / uncertain base
    markers never auto-pass; only an all-match real readback passes.

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

import jsonschema
import pytest

from app.modules.pipeline.floor_plan_semantic_readback import (
    BASE_STRUCTURAL_LAYERS,
    SEMANTIC_MATCH_VALUES,
    SEMANTIC_READBACK_SCHEMA,
    _OPENAI_UNSUPPORTED_SCHEMA_KEYS,
    SemanticReadbackError,
    compute_semantic_gate,
    compute_semantic_readback,
    compute_synthetic_semantic_fixture,
    validate_semantic_output,
)


# ─────────────────────────────── fixtures ───────────────────────────────


def _dossier() -> dict:
    return {
        "fp_id": "fp_a",
        "base_marker_inventory": [
            {"number": 1, "label": "main living unit", "category": "area",
             "position_hint": "center", "base_layer_decision": "base_structural_unit"},
            {"number": 2, "label": "entry door", "category": "opening",
             "position_hint": "south wall", "base_layer_decision": "base_opening"},
            {"number": 3, "label": "low storage chest", "category": "furniture",
             "position_hint": "north corner",
             "base_layer_decision": "base_persistent_furniture"},
            {"number": 4, "label": "wall sink", "category": "prop",
             "position_hint": "east wall",
             "base_layer_decision": "base_persistent_fixture"},
        ],
    }


def _entry(
    number: int,
    *,
    expected_label: str,
    expected_layer: str,
    match: str = "match",
) -> dict:
    return {
        "number": number,
        "expected_label": expected_label,
        "expected_layer": expected_layer,
        "observed_object_summary": "a generic visual description",
        "semantic_match": match,
        "mismatch_reason": "",
        "source_ref": "marker glyph near grid center",
        "confidence": 0.9,
        "reasoning_basis": "the drawn glyph reads as the expected class",
    }


def _good_output(match_overrides: dict | None = None) -> dict:
    overrides = match_overrides or {}
    return {
        "status": "ok",
        "fp_id": "fp_a",
        "observed_marker_semantics": [
            _entry(1, expected_label="main living unit",
                   expected_layer="base_structural_unit",
                   match=overrides.get(1, "match")),
            _entry(2, expected_label="entry door",
                   expected_layer="base_opening",
                   match=overrides.get(2, "match")),
            _entry(3, expected_label="low storage chest",
                   expected_layer="base_persistent_furniture",
                   match=overrides.get(3, "match")),
            _entry(4, expected_label="wall sink",
                   expected_layer="base_persistent_fixture",
                   match=overrides.get(4, "match")),
        ],
        "diagnostics": [],
    }


# ─────────────────────────── schema contract ────────────────────────────


def test_schema_has_no_openai_unsupported_keys():
    """Strict structured-output mode rejects anyOf/minItems/pattern/etc."""
    def _walk(node):
        if isinstance(node, dict):
            for key, value in node.items():
                assert key not in _OPENAI_UNSUPPORTED_SCHEMA_KEYS, (
                    f"unsupported strict-mode key {key!r} in schema"
                )
                _walk(value)
        elif isinstance(node, list):
            for item in node:
                _walk(item)

    _walk(SEMANTIC_READBACK_SCHEMA)


def test_good_output_validates_against_json_schema():
    jsonschema.validate(instance=_good_output(), schema=SEMANTIC_READBACK_SCHEMA)


# ─────────────────────────── validator: happy ───────────────────────────


def test_validator_passes_on_well_formed_output():
    res = validate_semantic_output(
        output=_good_output(), dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is True
    assert res["blockers"] == []
    rb = res["readback"]
    assert rb["status"] == "ok"
    assert rb["fp_id"] == "fp_a"
    assert len(rb["observed_marker_semantics"]) == 4


# ─────────────────────── validator: exact-ID joins ──────────────────────


def test_validator_rejects_number_not_in_dossier():
    out = _good_output()
    out["observed_marker_semantics"][0]["number"] = 99
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is False
    assert any("99" in b for b in res["blockers"])


def test_validator_rejects_expected_label_disagreeing_with_dossier():
    out = _good_output()
    out["observed_marker_semantics"][2]["expected_label"] = "queen bed"
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is False
    assert any("expected_label" in b for b in res["blockers"])


def test_validator_rejects_expected_layer_disagreeing_with_dossier():
    out = _good_output()
    out["observed_marker_semantics"][2]["expected_layer"] = "base_opening"
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is False
    assert any("expected_layer" in b for b in res["blockers"])


def test_validator_rejects_duplicate_number():
    out = _good_output()
    out["observed_marker_semantics"][1]["number"] = 1
    out["observed_marker_semantics"][1]["expected_label"] = "main living unit"
    out["observed_marker_semantics"][1]["expected_layer"] = "base_structural_unit"
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is False
    assert any("duplicat" in b.lower() for b in res["blockers"])


def test_validator_rejects_bad_semantic_match_enum():
    out = _good_output()
    out["observed_marker_semantics"][0]["semantic_match"] = "sort_of"
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is False
    assert any("semantic_match" in b for b in res["blockers"])


def test_validator_rejects_unhashable_semantic_match():
    """A list/dict semantic_match must fail closed as a blocker, not
    crash the validator with a TypeError (frozenset membership on an
    unhashable value)."""
    out = _good_output()
    out["observed_marker_semantics"][0]["semantic_match"] = ["match"]
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is False
    assert any("semantic_match" in b for b in res["blockers"])


def test_validator_rejects_missing_required_field():
    out = _good_output()
    del out["observed_marker_semantics"][0]["reasoning_basis"]
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is False


def test_validator_rejects_fp_id_mismatch():
    res = validate_semantic_output(
        output=_good_output(), dossier=_dossier(), fp_id="fp_other"
    )
    assert res["ok"] is False
    assert any("fp_id" in b for b in res["blockers"])


def test_validator_rejects_status_not_ok():
    out = _good_output()
    out["status"] = "synthetic_fixture"
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is False


def test_validator_accepts_null_confidence():
    out = _good_output()
    out["observed_marker_semantics"][0]["confidence"] = None
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    assert res["ok"] is True


def test_validator_does_not_lexically_inspect_evidence_text():
    """Code must never parse meaning out of free-text evidence fields.

    A scenario noun appearing in the evidence prose must not flip the
    validator's verdict — only the structured ``semantic_match`` enum
    carries the decision signal.
    """
    out = _good_output()
    out["observed_marker_semantics"][2]["observed_object_summary"] = (
        "alpha object, impossible noun, contradictory furniture text"
    )
    out["observed_marker_semantics"][2]["mismatch_reason"] = (
        "alpha impossible contradictory synthetic-token-only"
    )
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    # semantic_match stayed "match", so the validator passes regardless
    # of the scenario nouns in the prose.
    assert res["ok"] is True


# ───────────────────────────── gate decision ────────────────────────────


def test_gate_passes_when_all_base_markers_match():
    res = validate_semantic_output(
        output=_good_output(), dossier=_dossier(), fp_id="fp_a"
    )
    gate = compute_semantic_gate(readback=res["readback"], dossier=_dossier())
    assert gate["gate_state"] == "pass"
    assert gate["mismatch_markers"] == []
    assert gate["uncertain_markers"] == []
    assert gate["unattested_base_markers"] == []


def test_gate_needs_fix_on_base_furniture_mismatch():
    out = _good_output(match_overrides={3: "mismatch"})
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    gate = compute_semantic_gate(readback=res["readback"], dossier=_dossier())
    assert gate["gate_state"] == "needs_fix"
    assert 3 in gate["mismatch_markers"]


def test_gate_needs_review_on_uncertain_base_marker():
    out = _good_output(match_overrides={4: "uncertain"})
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    gate = compute_semantic_gate(readback=res["readback"], dossier=_dossier())
    assert gate["gate_state"] == "needs_review"
    assert 4 in gate["uncertain_markers"]


def test_gate_needs_review_when_base_marker_unattested():
    """A base structural/persistent marker the VLM never reported cannot
    be confirmed — fail-closed to manual review, not pass."""
    out = _good_output()
    # Drop marker #3 (low storage chest) from the readback entirely.
    out["observed_marker_semantics"] = [
        e for e in out["observed_marker_semantics"] if e["number"] != 3
    ]
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    gate = compute_semantic_gate(readback=res["readback"], dossier=_dossier())
    assert gate["gate_state"] == "needs_review"
    assert 3 in gate["unattested_base_markers"]


def test_gate_mismatch_dominates_uncertain():
    out = _good_output(match_overrides={3: "uncertain", 4: "mismatch"})
    res = validate_semantic_output(
        output=out, dossier=_dossier(), fp_id="fp_a"
    )
    gate = compute_semantic_gate(readback=res["readback"], dossier=_dossier())
    assert gate["gate_state"] == "needs_fix"


def test_gate_synthetic_fixture_never_authoritative():
    rb = compute_synthetic_semantic_fixture(dossier=_dossier())
    gate = compute_semantic_gate(readback=rb, dossier=_dossier())
    assert gate["gate_state"] == "synthetic_unverified"


# ──────────────────────── synthetic fixture shape ───────────────────────


def test_synthetic_fixture_shape():
    rb = compute_synthetic_semantic_fixture(dossier=_dossier())
    assert rb["status"] == "synthetic_fixture"
    assert rb["fp_id"] == "fp_a"
    # one entry per base marker, all uncertain (placeholder, unverified)
    assert len(rb["observed_marker_semantics"]) == 4
    assert all(
        e["semantic_match"] == "uncertain"
        for e in rb["observed_marker_semantics"]
    )


def test_synthetic_fixture_raises_on_empty_inventory():
    with pytest.raises(SemanticReadbackError):
        compute_synthetic_semantic_fixture(
            dossier={"fp_id": "fp_a", "base_marker_inventory": []}
        )


# ─────────────────── compute_semantic_readback dispatch ─────────────────


def test_compute_readback_defaults_to_synthetic_without_provider():
    rb = compute_semantic_readback(dossier=_dossier())
    assert rb["status"] == "synthetic_fixture"


def test_compute_readback_uses_provider_when_supplied():
    def provider(**kw):
        out = _good_output()
        res = validate_semantic_output(
            output=out, dossier=kw["dossier"], fp_id=kw["dossier"]["fp_id"]
        )
        return res["readback"]

    rb = compute_semantic_readback(
        dossier=_dossier(), fp_image_path="/tmp/x.png", vlm_provider=provider
    )
    assert rb["status"] == "ok"


def test_compute_readback_rejects_provider_status_not_ok():
    def provider(**kw):
        return {"status": "failed", "fp_id": "fp_a",
                "observed_marker_semantics": [], "diagnostics": []}

    with pytest.raises(SemanticReadbackError):
        compute_semantic_readback(
            dossier=_dossier(), fp_image_path="/tmp/x.png",
            vlm_provider=provider,
        )


def test_compute_readback_validates_provider_unknown_marker():
    """The dispatcher must run provider output through the full
    validator — an unknown marker number on the live path must raise,
    not slip through on a top-level status/fp_id check alone."""
    def provider(**kw):
        out = _good_output()
        out["observed_marker_semantics"][0]["number"] = 99
        return out

    with pytest.raises(SemanticReadbackError):
        compute_semantic_readback(
            dossier=_dossier(), fp_image_path="/tmp/x.png",
            vlm_provider=provider,
        )


def test_compute_readback_validates_provider_wrong_expected_label():
    def provider(**kw):
        out = _good_output()
        out["observed_marker_semantics"][2]["expected_label"] = "queen bed"
        return out

    with pytest.raises(SemanticReadbackError):
        compute_semantic_readback(
            dossier=_dossier(), fp_image_path="/tmp/x.png",
            vlm_provider=provider,
        )


def test_compute_readback_validates_provider_missing_field():
    def provider(**kw):
        out = _good_output()
        del out["observed_marker_semantics"][0]["reasoning_basis"]
        return out

    with pytest.raises(SemanticReadbackError):
        compute_semantic_readback(
            dossier=_dossier(), fp_image_path="/tmp/x.png",
            vlm_provider=provider,
        )


# ───────────────────────────── enum exports ─────────────────────────────


def test_enum_exports():
    assert SEMANTIC_MATCH_VALUES == {"match", "mismatch", "uncertain"}
    assert BASE_STRUCTURAL_LAYERS == {
        "base_structural_unit",
        "base_opening",
        "base_persistent_fixture",
        "base_persistent_furniture",
    }
