"""W20A2.5/2.6: floor_plan_vlm_provider validator + wire-up tests.

Pure-function tests for ``validate_provider_output`` plus W20A2.6 real
wire-up tests for ``litellm_vlm_provider``. The wire-up tests
monkeypatch ``litellm.completion``, ``litellm.supports_response_schema``,
``litellm.get_supported_openai_params`` and the ``OPENAI_API_KEY`` env
var — no real network call ever happens.

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

import json as _json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

import jsonschema
import pytest

from app.modules.pipeline.floor_plan_vlm_provider import (
    BASE_KINDS,
    MAX_COMPLETION_TOKENS_DEFAULT,
    PROVIDER_MODEL_DEFAULT,
    READBACK_SCHEMA,
    STATE_OVERLAY_KINDS,
    WIRED_PROVIDER_NAME,
    _OPENAI_UNSUPPORTED_SCHEMA_KEYS,
    VlmProviderError,
    litellm_vlm_provider,
    validate_provider_output,
)


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


def _dossier() -> dict:
    return {
        "fp_id": "fp_a",
        "base_marker_inventory": [
            {"number": 1, "label": "u1", "category": "area",
             "base_layer_decision": "base_structural_unit"},
            {"number": 2, "label": "u2", "category": "area",
             "base_layer_decision": "base_structural_unit"},
            {"number": 3, "label": "o1", "category": "opening",
             "base_layer_decision": "base_opening"},
            {"number": 4, "label": "fx", "category": "furniture",
             "base_layer_decision": "base_persistent_fixture"},
        ],
    }


def _good_output() -> dict:
    return {
        "status": "ok",
        "fp_id": "fp_a",
        "grid_size": [10, 10],
        "observed_markers": [
            {"number": 1, "row": 1, "col": 1, "kind": "base_structural_unit"},
            {"number": 2, "row": 7, "col": 4, "kind": "base_structural_unit"},
            {"number": 3, "row": 4, "col": 2, "kind": "base_opening"},
            {"number": 4, "row": 0, "col": 0,
             "kind": "base_persistent_fixture"},
        ],
        "missing_markers": [],
        "extra_markers": [],
        "confidence": 0.87,
        "diagnostics": [],
    }


# ─────────────────────────── happy path ────────────────────────────


def test_validator_passes_on_well_formed_output():
    res = validate_provider_output(
        output=_good_output(),
        dossier=_dossier(),
        grid_size=(10, 10),
        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 rb["provider_name"] == WIRED_PROVIDER_NAME
    assert rb["confidence"] == pytest.approx(0.87)
    assert len(rb["observed_markers"]) == 4


def test_validator_normalizes_missing_and_extra_lists():
    # Drop marker #4 from observation, add an unknown #99.
    out = _good_output()
    out["observed_markers"] = [
        m for m in out["observed_markers"] if m["number"] != 4
    ]
    out["observed_markers"].append(
        {"number": 99, "row": 5, "col": 5, "kind": "base_structural_unit"}
    )
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    # Unknown marker #99 fails the inventory exact-ID check.
    assert res["ok"] is False
    assert any("not in dossier" in b for b in res["blockers"])


# ─────────────────────────── fail-closed ────────────────────────────


def test_validator_fails_when_status_not_ok():
    out = _good_output()
    out["status"] = "synthetic_fixture"
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("status must be 'ok'" in b for b in res["blockers"])


def test_validator_fails_on_fp_id_mismatch():
    out = _good_output()
    out["fp_id"] = "fp_other"
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("fp_id mismatch" in b for b in res["blockers"])


def test_validator_fails_on_duplicate_marker_number():
    out = _good_output()
    out["observed_markers"][1]["number"] = 1  # same as [0]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("duplicated" in b for b in res["blockers"])


def test_validator_fails_on_unknown_marker_number():
    out = _good_output()
    out["observed_markers"][0]["number"] = 999
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("not in dossier" in b for b in res["blockers"])


def test_validator_fails_on_unknown_kind():
    out = _good_output()
    out["observed_markers"][0]["kind"] = "not_a_kind"
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("not a known base kind" in b for b in res["blockers"])


def test_validator_fails_on_out_of_grid_cell():
    out = _good_output()
    out["observed_markers"][0]["row"] = 99
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("out of grid" in b for b in res["blockers"])


def test_validator_fails_on_state_overlay_kind_contamination():
    out = _good_output()
    out["observed_markers"].append(
        {"number": 5, "row": 9, "col": 9,
         "kind": "state_overlay_plot_cue"}
    )
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("state-overlay kind" in b for b in res["blockers"])


def test_validator_fails_on_kind_mismatch_with_dossier():
    """VLM cannot re-classify a marker. Number 1 is a structural unit
    in the dossier; the provider claims it is a fixture → fail-closed.
    """
    out = _good_output()
    out["observed_markers"][0]["kind"] = "base_persistent_fixture"
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("disagrees with dossier" in b for b in res["blockers"])


def test_validator_passes_with_cell_collision_as_diagnostic_not_blocker():
    """W20E6-A: two distinct marker numbers sharing a 10x10 cell is a
    soft diagnostic, not a hard blocker. The validator must still
    return ok=True and surface a per-pair collision message under
    ``cell_collision_diagnostics`` (and additively under the merged
    ``diagnostics`` list).
    """
    out = _good_output()
    out["observed_markers"][1]["row"] = out["observed_markers"][0]["row"]
    out["observed_markers"][1]["col"] = out["observed_markers"][0]["col"]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is True
    assert res["blockers"] == []
    rb = res["readback"]
    collisions = rb.get("cell_collision_diagnostics") or []
    assert collisions, "collision must surface in cell_collision_diagnostics"
    assert any(
        "shares a 10x10 cell" in c for c in collisions
    ), collisions
    assert all(c in (rb.get("diagnostics") or []) for c in collisions), (
        "collision diagnostics must also be merged into the readback's "
        "general diagnostics list"
    )


def test_validator_still_fails_on_duplicate_marker_number_even_with_same_cell():
    """W20E6-A boundary: duplicate marker numbers remain a hard blocker
    even if the duplicate happens to land on the same cell.
    """
    out = _good_output()
    # Both markers carry number=1 and share cell (1,1).
    out["observed_markers"][1]["number"] = 1
    out["observed_markers"][1]["row"] = out["observed_markers"][0]["row"]
    out["observed_markers"][1]["col"] = out["observed_markers"][0]["col"]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any("duplicated" in b for b in res["blockers"])


def test_validator_fails_on_malformed_dict():
    for bad in (None, "string", 42, [1, 2, 3]):
        res = validate_provider_output(
            output=bad, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
        )
        assert res["ok"] is False


def test_validator_fails_on_bad_grid_size_in_output():
    out = _good_output()
    out["grid_size"] = [10, "10"]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False


def test_validator_fails_on_non_int_confidence():
    out = _good_output()
    out["confidence"] = "high"
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False


def test_kind_sets_locked():
    assert BASE_KINDS == frozenset({
        "base_structural_unit",
        "base_opening",
        "base_persistent_fixture",
        "base_persistent_furniture",
    })
    assert STATE_OVERLAY_KINDS == frozenset({
        "state_overlay_plot_cue",
        "state_overlay_transient_object",
    })


# ─────── narrow patch: missing_markers / extra_markers consistency ───────


def test_validator_fails_when_missing_markers_underreported():
    """dossier {1,2,3,4}, observed only {1,3} → expected missing=[2,4],
    extra=[]. provider falsely reports missing=[] → fail-closed."""
    out = _good_output()
    out["observed_markers"] = [
        m for m in out["observed_markers"]
        if m["number"] in {1, 3}
    ]
    # provider claims nothing is missing — false.
    out["missing_markers"] = []
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any(
        "missing_markers mismatch" in b for b in res["blockers"]
    )


def test_validator_fails_when_missing_markers_partial():
    """provider drops one of the truly missing markers."""
    out = _good_output()
    out["observed_markers"] = [
        m for m in out["observed_markers"]
        if m["number"] in {1, 3}
    ]
    # truly missing = {2,4}; provider only reports [2].
    out["missing_markers"] = [2]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any(
        "missing_markers mismatch" in b for b in res["blockers"]
    )


def test_validator_fails_when_missing_markers_fabricated():
    """provider claims marker is missing when it was observed."""
    out = _good_output()
    # all 4 markers observed → expected missing=[]
    out["missing_markers"] = [99]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any(
        "missing_markers mismatch" in b for b in res["blockers"]
    )


def test_validator_fails_when_extra_markers_fabricated():
    """provider reports extra=[99] but every observed number is in
    the dossier inventory → fail-closed."""
    out = _good_output()
    out["extra_markers"] = [99]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any(
        "extra_markers mismatch" in b for b in res["blockers"]
    )


def test_validator_passes_when_observed_subset_with_correct_missing():
    """Policy: observed-subset is allowed as long as missing/extra are
    consistent with the dossier-vs-observed set difference. A
    legitimate non-empty missing list must NOT be auto-failed — only
    inconsistency is."""
    out = _good_output()
    # Observe only #1 and #3, declare #2 and #4 as missing.
    out["observed_markers"] = [
        m for m in out["observed_markers"]
        if m["number"] in {1, 3}
    ]
    out["missing_markers"] = [2, 4]
    out["extra_markers"] = []
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is True, res["blockers"]
    assert res["readback"]["missing_markers"] == [2, 4]
    assert res["readback"]["extra_markers"] == []


def test_validator_fails_on_missing_markers_duplicate():
    out = _good_output()
    out["observed_markers"] = [
        m for m in out["observed_markers"]
        if m["number"] in {1, 3}
    ]
    # Duplicate "2" in the missing list.
    out["missing_markers"] = [2, 2, 4]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any(
        "missing_markers contains duplicate" in b for b in res["blockers"]
    )


def test_validator_fails_on_extra_markers_duplicate():
    out = _good_output()
    out["extra_markers"] = [99, 99]
    res = validate_provider_output(
        output=out, dossier=_dossier(), grid_size=(10, 10), fp_id="fp_a",
    )
    assert res["ok"] is False
    assert any(
        "extra_markers contains duplicate" in b for b in res["blockers"]
    )


# ─────────── litellm_vlm_provider — arg-shape fail-closed (no env/network) ───────────


def test_litellm_provider_rejects_missing_fp_image_path():
    with pytest.raises(VlmProviderError):
        litellm_vlm_provider(
            dossier=_dossier(),
            fp_image_path=None,
        )


def test_litellm_provider_rejects_dossier_without_fp_id():
    with pytest.raises(VlmProviderError):
        litellm_vlm_provider(
            dossier={},
            fp_image_path="/x.png",
        )


# ─────────── W20A2.6 real wire-up: monkeypatched litellm, no network ───────────


def _good_response_content_for(dossier: dict) -> str:
    """JSON content the validator accepts as a healthy ``ok`` readback
    for the standard ``_dossier`` fixture."""
    payload = {
        "status": "ok",
        "fp_id": dossier["fp_id"],
        "grid_size": [10, 10],
        "observed_markers": [
            {"number": 1, "row": 1, "col": 1, "kind": "base_structural_unit"},
            {"number": 2, "row": 7, "col": 4, "kind": "base_structural_unit"},
            {"number": 3, "row": 4, "col": 2, "kind": "base_opening"},
            {"number": 4, "row": 0, "col": 0,
             "kind": "base_persistent_fixture"},
        ],
        "missing_markers": [],
        "extra_markers": [],
        "confidence": 0.87,
        "diagnostics": [],
    }
    return _json.dumps(payload)


def _make_fake_response(
    *,
    content: str | None = None,
    finish_reason: str = "stop",
    refusal: str | None = None,
    choices: list | None = None,
):
    """Build a litellm-shaped response stub.

    Real litellm responses use a pydantic ``ModelResponse`` with
    ``.choices[0].message.content`` and ``.choices[0].finish_reason``.
    ``SimpleNamespace`` mirrors that shape without dragging in the
    pydantic dependency.
    """
    if choices is None:
        msg = SimpleNamespace(content=content, refusal=refusal)
        ch = SimpleNamespace(message=msg, finish_reason=finish_reason)
        choices = [ch]
    return SimpleNamespace(choices=choices)


def _write_fake_png(tmp_path: Path, name: str = "fp_a.png") -> Path:
    """Write a small bytes blob with a ``.png`` extension. The provider
    only base64-encodes the bytes; it does not parse the PNG header."""
    p = tmp_path / name
    p.write_bytes(b"\x89PNG\r\n\x1a\nfake-bytes-for-unit-test")
    return p


def _stub_preflight(
    monkeypatch,
    *,
    supports_schema: bool = True,
    supported_params: list[str] | None = None,
    openai_key: str | None = "sk-test-do-not-call-real-api",
) -> None:
    """Apply the standard env + litellm preflight stubs. Network 0."""
    import litellm  # real install — only attributes are monkeypatched
    if openai_key is None:
        # [2026-08-01] 키 유무의 권위가 환경변수에서 슬롯 브로커로 옮겼다 —
        # 보조 슬롯만 있어도 '있음'이므로 env 만 지우면 fail-closed 가 아니다.
        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
        monkeypatch.setattr(
            "app.core.config.settings.openai_api_key", "", raising=False)
        monkeypatch.setattr(
            "app.core.config.settings.openai_api_key_secondary", "",
            raising=False)
    else:
        monkeypatch.setenv("OPENAI_API_KEY", openai_key)
    monkeypatch.setattr(
        litellm,
        "supports_response_schema",
        lambda **kw: supports_schema,
        raising=False,
    )
    monkeypatch.setattr(
        litellm,
        "get_supported_openai_params",
        lambda **kw: list(
            supported_params
            if supported_params is not None
            else ["response_format", "max_completion_tokens", "timeout"]
        ),
        raising=False,
    )


def _stub_completion(monkeypatch, *, response) -> MagicMock:
    """Replace ``litellm.completion`` with a counter-returning mock."""
    import litellm
    mock = MagicMock(return_value=response)
    monkeypatch.setattr(litellm, "completion", mock, raising=False)
    return mock


def test_litellm_provider_invokes_completion_exactly_once(tmp_path, monkeypatch):
    _stub_preflight(monkeypatch)
    fake = _make_fake_response(content=_good_response_content_for(_dossier()))
    mock = _stub_completion(monkeypatch, response=fake)
    png = _write_fake_png(tmp_path)

    result = litellm_vlm_provider(
        dossier=_dossier(),
        fp_image_path=str(png),
    )

    assert mock.call_count == 1
    assert result["status"] == "ok"


def test_litellm_provider_messages_contain_image_url_with_detail_original(
    tmp_path, monkeypatch
):
    _stub_preflight(monkeypatch)
    fake = _make_fake_response(content=_good_response_content_for(_dossier()))
    mock = _stub_completion(monkeypatch, response=fake)
    png = _write_fake_png(tmp_path)

    litellm_vlm_provider(
        dossier=_dossier(),
        fp_image_path=str(png),
    )

    kwargs = mock.call_args.kwargs
    messages = kwargs["messages"]
    image_url_entries: list[dict] = []
    for m in messages:
        content = m.get("content")
        if isinstance(content, list):
            for c in content:
                if isinstance(c, dict) and c.get("type") == "image_url":
                    image_url_entries.append(c)
    assert image_url_entries, (
        "expected an image_url entry in the user message content list"
    )
    iu = image_url_entries[0]["image_url"]
    assert isinstance(iu, dict)
    assert iu["url"].startswith("data:image/png;base64,")
    assert iu["detail"] == "original"


def test_litellm_provider_request_uses_strict_json_schema_response_format(
    tmp_path, monkeypatch
):
    _stub_preflight(monkeypatch)
    fake = _make_fake_response(content=_good_response_content_for(_dossier()))
    mock = _stub_completion(monkeypatch, response=fake)
    png = _write_fake_png(tmp_path)

    litellm_vlm_provider(
        dossier=_dossier(),
        fp_image_path=str(png),
    )

    kwargs = mock.call_args.kwargs
    rf = kwargs["response_format"]
    assert rf["type"] == "json_schema"
    inner = rf["json_schema"]
    assert inner["name"] == "floor_plan_vlm_readback"
    assert inner["strict"] is True
    assert inner["schema"] is READBACK_SCHEMA
    # Temperature must be omitted (gpt-5 family ignores tunable temp).
    assert "temperature" not in kwargs
    # Caller passes max_completion_tokens, timeout, num_retries=0.
    assert kwargs["max_completion_tokens"] == MAX_COMPLETION_TOKENS_DEFAULT
    assert kwargs["timeout"] == 120
    assert kwargs["num_retries"] == 0
    # Model lock — provider default points to the litellm-routed gpt-5.5.
    assert kwargs["model"] == PROVIDER_MODEL_DEFAULT


@pytest.mark.parametrize(
    "case",
    [
        "malformed_json",
        "refusal",
        "empty_content",
        "validator_fail",
        "finish_reason_length",
        "finish_reason_content_filter",
        "empty_choices",
    ],
)
def test_litellm_provider_raises_on_bad_response(tmp_path, monkeypatch, case):
    _stub_preflight(monkeypatch)
    good = _good_response_content_for(_dossier())
    if case == "malformed_json":
        resp = _make_fake_response(content="this is not json")
    elif case == "refusal":
        resp = _make_fake_response(content=good, refusal="I cannot help with that")
    elif case == "empty_content":
        resp = _make_fake_response(content="")
    elif case == "validator_fail":
        parsed = _json.loads(good)
        parsed["fp_id"] = "fp_some_other_id"  # validator fp_id mismatch
        resp = _make_fake_response(content=_json.dumps(parsed))
    elif case == "finish_reason_length":
        resp = _make_fake_response(content=good, finish_reason="length")
    elif case == "finish_reason_content_filter":
        resp = _make_fake_response(content=good, finish_reason="content_filter")
    elif case == "empty_choices":
        resp = _make_fake_response(choices=[])
    else:
        raise AssertionError(f"unknown parametrized case: {case}")

    _stub_completion(monkeypatch, response=resp)
    png = _write_fake_png(tmp_path)

    with pytest.raises(VlmProviderError):
        litellm_vlm_provider(dossier=_dossier(), fp_image_path=str(png))


def test_litellm_provider_returns_normalized_readback_on_valid_fake_response(
    tmp_path, monkeypatch
):
    _stub_preflight(monkeypatch)
    fake = _make_fake_response(content=_good_response_content_for(_dossier()))
    _stub_completion(monkeypatch, response=fake)
    png = _write_fake_png(tmp_path)

    result = litellm_vlm_provider(
        dossier=_dossier(),
        fp_image_path=str(png),
    )

    assert result["status"] == "ok"
    assert result["fp_id"] == "fp_a"
    assert result["grid_size"] == [10, 10]
    assert len(result["observed_markers"]) == 4
    assert result["missing_markers"] == []
    assert result["extra_markers"] == []
    assert result["confidence"] == pytest.approx(0.87)
    assert result["provider_name"] == WIRED_PROVIDER_NAME


def test_litellm_provider_messages_carry_grid_coordinate_contract(
    tmp_path, monkeypatch
):
    """The static prompt MUST lock the coordinate system that downstream
    cells are interpreted in. Without these tokens, the VLM may emit
    1-indexed or bottom-left-origin cells that still happen to fall
    within ``0..grid-1`` — silently invisible to the validator.
    """
    _stub_preflight(monkeypatch)
    fake = _make_fake_response(content=_good_response_content_for(_dossier()))
    mock = _stub_completion(monkeypatch, response=fake)
    png = _write_fake_png(tmp_path)

    litellm_vlm_provider(
        dossier=_dossier(),
        fp_image_path=str(png),
    )

    kwargs = mock.call_args.kwargs
    messages = kwargs["messages"]
    flat_text_parts: list[str] = []
    for m in messages:
        content = m.get("content")
        if isinstance(content, str):
            flat_text_parts.append(content)
        elif isinstance(content, list):
            for c in content:
                if isinstance(c, dict) and c.get("type") == "text":
                    flat_text_parts.append(c.get("text", ""))
    flat_text = "\n".join(flat_text_parts)
    flat_lower = flat_text.lower()
    # v3.1 lock — every token must be explicit.
    for keyword in (
        "top-left",
        "zero-indexed",
        "row",
        "col",
        "grid_size",
        "0 <= row < rows",
        "0 <= col < cols",
    ):
        assert keyword.lower() in flat_lower, (
            f"messages missing coordinate-contract keyword {keyword!r}; "
            f"VLM may silently emit cells under a different convention"
        )


@pytest.mark.parametrize(
    "bad_grid",
    [
        pytest.param(None, id="none"),
        pytest.param([10], id="too_short"),
        pytest.param([10, "10"], id="non_int_element"),
        pytest.param([True, 10], id="bool_element"),
        pytest.param([0, 10], id="zero_element"),
    ],
)
def test_litellm_provider_fails_closed_on_malformed_grid_size(
    tmp_path, monkeypatch, bad_grid
):
    """grid_size shape failures must raise VlmProviderError BEFORE any
    env / litellm / filesystem / completion step. The mock's call
    counter therefore stays at 0 for every malformed input."""
    _stub_preflight(monkeypatch)
    mock = _stub_completion(
        monkeypatch,
        response=_make_fake_response(content=_good_response_content_for(_dossier())),
    )
    png = _write_fake_png(tmp_path)

    with pytest.raises(VlmProviderError):
        litellm_vlm_provider(
            dossier=_dossier(),
            fp_image_path=str(png),
            grid_size=bad_grid,
        )
    assert mock.call_count == 0


def test_litellm_provider_fails_closed_when_openai_api_key_missing(
    tmp_path, monkeypatch
):
    # Stub litellm helpers + completion mock, then drop the API key.
    _stub_preflight(monkeypatch, openai_key=None)
    mock = _stub_completion(
        monkeypatch,
        response=_make_fake_response(content=_good_response_content_for(_dossier())),
    )
    png = _write_fake_png(tmp_path)

    with pytest.raises(VlmProviderError):
        litellm_vlm_provider(dossier=_dossier(), fp_image_path=str(png))
    assert mock.call_count == 0


def test_litellm_provider_fails_closed_when_fp_image_path_missing_on_disk(
    tmp_path, monkeypatch
):
    _stub_preflight(monkeypatch)
    mock = _stub_completion(
        monkeypatch,
        response=_make_fake_response(content=_good_response_content_for(_dossier())),
    )
    missing = str(tmp_path / "does_not_exist_on_disk.png")

    with pytest.raises(VlmProviderError):
        litellm_vlm_provider(dossier=_dossier(), fp_image_path=missing)
    assert mock.call_count == 0


# ─────────── schema lock tests (no network, no litellm call) ───────────


def _walk_schema_for_keys(node, banned: frozenset) -> list[str]:
    """Walk a JSON-Schema-like dict recursively and return any banned
    keys found."""
    leaks: list[str] = []
    if isinstance(node, dict):
        for k, v in node.items():
            if k in banned:
                leaks.append(k)
            leaks.extend(_walk_schema_for_keys(v, banned))
    elif isinstance(node, list):
        for item in node:
            leaks.extend(_walk_schema_for_keys(item, banned))
    return leaks


def test_readback_schema_has_no_openai_strict_unsupported_keys():
    leaks = _walk_schema_for_keys(READBACK_SCHEMA, _OPENAI_UNSUPPORTED_SCHEMA_KEYS)
    assert not leaks, (
        f"READBACK_SCHEMA contains OpenAI-strict-mode unsupported keys: "
        f"{sorted(set(leaks))}"
    )


def test_readback_schema_validates_payload_with_confidence_null():
    payload = {
        "status": "ok",
        "fp_id": "fp_a",
        "grid_size": [10, 10],
        "observed_markers": [
            {"number": 1, "row": 1, "col": 1, "kind": "base_structural_unit"},
        ],
        "missing_markers": [2, 3, 4],
        "extra_markers": [],
        "confidence": None,
        "diagnostics": [],
    }
    jsonschema.Draft202012Validator(READBACK_SCHEMA).validate(payload)


def test_readback_schema_validates_payload_with_confidence_float():
    payload = {
        "status": "ok",
        "fp_id": "fp_a",
        "grid_size": [10, 10],
        "observed_markers": [
            {"number": 1, "row": 1, "col": 1, "kind": "base_structural_unit"},
            {"number": 3, "row": 4, "col": 2, "kind": "base_opening"},
        ],
        "missing_markers": [2, 4],
        "extra_markers": [],
        "confidence": 0.93,
        "diagnostics": ["partial-readback"],
    }
    jsonschema.Draft202012Validator(READBACK_SCHEMA).validate(payload)
