"""W21B-wave-4: floor_plan_semantic_vlm_provider wire-up tests.

Mirrors the W20A2.6 geometry provider tests: monkeypatch
``litellm.completion`` / ``supports_response_schema`` /
``get_supported_openai_params`` + ``OPENAI_API_KEY`` — no real network
call ever happens. Preflight fail-closed paths must raise BEFORE the
completion call so the call counter stays 0 on bad input.

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 pytest

from app.modules.pipeline.floor_plan_semantic_readback import (
    SemanticReadbackError,
)
from app.modules.pipeline.floor_plan_semantic_vlm_provider import (
    MAX_COMPLETION_TOKENS_DEFAULT,
    PROVIDER_MODEL_DEFAULT,
    WIRED_PROVIDER_NAME,
    litellm_semantic_vlm_provider,
)


# ─────────────────────────────── 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"},
        ],
    }


def _good_content_for(dossier: dict, *, overrides: dict | None = None) -> str:
    overrides = overrides or {}
    inv = {e["number"]: e for e in dossier["base_marker_inventory"]}
    entries = []
    for num in sorted(inv):
        entries.append({
            "number": num,
            "expected_label": inv[num]["label"],
            "expected_layer": inv[num]["base_layer_decision"],
            "observed_object_summary": "generic visual description",
            "semantic_match": overrides.get(num, "match"),
            "mismatch_reason": "",
            "source_ref": "near grid center",
            "confidence": 0.9,
            "reasoning_basis": "drawn glyph reads as expected class",
        })
    return _json.dumps({
        "status": "ok",
        "fp_id": dossier["fp_id"],
        "observed_marker_semantics": entries,
        "diagnostics": [],
    })


def _make_fake_response(
    *,
    content: str | None = None,
    finish_reason: str = "stop",
    refusal: str | None = None,
):
    msg = SimpleNamespace(content=content, refusal=refusal)
    ch = SimpleNamespace(message=msg, finish_reason=finish_reason)
    return SimpleNamespace(choices=[ch])


def _write_fake_png(tmp_path: Path, name: str = "fp_a.png") -> Path:
    p = tmp_path / name
    p.write_bytes(b"\x89PNG\r\n\x1a\nfake-bytes")
    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",
) -> None:
    import litellm
    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 _no_gemini_key(monkeypatch) -> None:
    """gemini 키 풀을 **비운다** — 빈 풀의 실제 동작(RuntimeError)을 흉내낸다.

    ★#92 (2026-08-27): 이 자리의 모델이 Sol → gemini 로 바뀌었다.
     막으려는 실패는 그대로다 — 「키가 없으면 부르지 않는다」.
     **어느 키냐만** 옮겼다.
    """
    from app.modules.llm import gemini_key_pool

    def _raise():
        raise RuntimeError("No Gemini API keys configured")

    monkeypatch.setattr(gemini_key_pool, "get_next_key", _raise)


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


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


def test_provider_rejects_missing_fp_image_path():
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(dossier=_dossier(), fp_image_path=None)


def test_provider_rejects_dossier_without_fp_id():
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(dossier={}, fp_image_path="/x.png")


def test_provider_rejects_missing_api_key_without_calling(tmp_path, monkeypatch):
    _stub_preflight(monkeypatch, openai_key=None)
    _no_gemini_key(monkeypatch)
    mock = _stub_completion(monkeypatch, response=_make_fake_response(content="{}"))
    png = _write_fake_png(tmp_path)
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(dossier=_dossier(), fp_image_path=str(png))
    assert mock.call_count == 0


def test_provider_rejects_empty_base_inventory_without_calling(
    tmp_path, monkeypatch
):
    """A dossier with zero base_* markers gives the VLM nothing to
    attest to — the provider must fail closed BEFORE the (costly)
    completion call, matching the core synthetic fixture's guard."""
    _stub_preflight(monkeypatch)
    mock = _stub_completion(
        monkeypatch, response=_make_fake_response(content="{}")
    )
    png = _write_fake_png(tmp_path)
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(
            dossier={"fp_id": "fp_a", "base_marker_inventory": []},
            fp_image_path=str(png),
        )
    assert mock.call_count == 0


def test_provider_rejects_nonexistent_image_without_calling(monkeypatch):
    _stub_preflight(monkeypatch)
    mock = _stub_completion(monkeypatch, response=_make_fake_response(content="{}"))
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(
            dossier=_dossier(), fp_image_path="/does/not/exist.png"
        )
    assert mock.call_count == 0


def test_provider_rejects_when_schema_unsupported(tmp_path, monkeypatch):
    _stub_preflight(monkeypatch, supports_schema=False)
    mock = _stub_completion(monkeypatch, response=_make_fake_response(content="{}"))
    png = _write_fake_png(tmp_path)
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(dossier=_dossier(), fp_image_path=str(png))
    assert mock.call_count == 0


# ─────────────── happy wiring (monkeypatched litellm) ───────────────


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

    result = litellm_semantic_vlm_provider(
        dossier=_dossier(), fp_image_path=str(png)
    )
    assert mock.call_count == 1
    assert result["status"] == "ok"
    assert result["fp_id"] == "fp_a"
    assert len(result["observed_marker_semantics"]) == 3


def test_provider_message_carries_image_url_detail_original(tmp_path, monkeypatch):
    _stub_preflight(monkeypatch)
    mock = _stub_completion(
        monkeypatch, response=_make_fake_response(content=_good_content_for(_dossier()))
    )
    png = _write_fake_png(tmp_path)
    litellm_semantic_vlm_provider(dossier=_dossier(), fp_image_path=str(png))

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


def test_provider_uses_strict_json_schema_response_format(tmp_path, monkeypatch):
    _stub_preflight(monkeypatch)
    mock = _stub_completion(
        monkeypatch, response=_make_fake_response(content=_good_content_for(_dossier()))
    )
    png = _write_fake_png(tmp_path)
    litellm_semantic_vlm_provider(dossier=_dossier(), fp_image_path=str(png))

    rf = mock.call_args.kwargs["response_format"]
    assert rf["type"] == "json_schema"
    assert rf["json_schema"]["strict"] is True


# ─────────────── response guards ───────────────


def test_provider_fails_on_non_stop_finish_reason(tmp_path, monkeypatch):
    _stub_preflight(monkeypatch)
    _stub_completion(
        monkeypatch,
        response=_make_fake_response(
            content=_good_content_for(_dossier()), finish_reason="length"
        ),
    )
    png = _write_fake_png(tmp_path)
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(dossier=_dossier(), fp_image_path=str(png))


def test_provider_fails_on_empty_content(tmp_path, monkeypatch):
    _stub_preflight(monkeypatch)
    _stub_completion(monkeypatch, response=_make_fake_response(content=""))
    png = _write_fake_png(tmp_path)
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(dossier=_dossier(), fp_image_path=str(png))


# ─────────────── validator integration ───────────────


def test_provider_fails_when_output_has_unknown_marker(tmp_path, monkeypatch):
    """A model that returns a marker number not in the dossier inventory
    must fail closed through the core validator."""
    _stub_preflight(monkeypatch)
    bad = _json.loads(_good_content_for(_dossier()))
    bad["observed_marker_semantics"][0]["number"] = 99
    _stub_completion(
        monkeypatch, response=_make_fake_response(content=_json.dumps(bad))
    )
    png = _write_fake_png(tmp_path)
    with pytest.raises(SemanticReadbackError):
        litellm_semantic_vlm_provider(dossier=_dossier(), fp_image_path=str(png))


def test_provider_constants_locked():
    # ★#92 (2026-08-27): VLM 은 gemini 3.1 pro + grok 최신 둘만.
    assert PROVIDER_MODEL_DEFAULT == "gemini/gemini-3.1-pro-preview"
    assert MAX_COMPLETION_TOKENS_DEFAULT == 16000
    assert WIRED_PROVIDER_NAME == "litellm_gpt55_semantic_vision_v1"
