"""Tests for VLMClient.

All requests.post calls are mocked so these tests run without a real network
connection or the requests library installed (we inject a stub module).
"""

from __future__ import annotations

import base64
import json
import sys
from pathlib import Path
from types import ModuleType
from unittest.mock import MagicMock, patch

import pytest

# ── path setup ───────────────────────────────────────────────────────────────
ROOT = Path(__file__).resolve().parents[4]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
    sys.path.insert(0, str(SRC))

# ── inject a minimal requests stub before importing the client ────────────────
# requests may not be installed in the dev environment; stub it so VLMClient
# can be imported and the requests branch exercised in tests.

def _make_fake_requests() -> ModuleType:
    """Build a minimal requests stub sufficient for VLMClient tests."""
    fake = ModuleType("requests")

    class HTTPError(Exception):
        def __init__(self, response: object = None) -> None:
            super().__init__(str(response))
            self.response = response

    fake.HTTPError = HTTPError
    # post() is overridden per-test via patch
    fake.post = MagicMock()
    return fake


if "requests" not in sys.modules:
    sys.modules["requests"] = _make_fake_requests()

from services.vlm_client import VLMClient, VLMConfig  # noqa: E402  (after stub injection)


# ── helpers ──────────────────────────────────────────────────────────────────

_SAMPLE_JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 12  # minimal JPEG-like header bytes


def _make_mock_response(
    status_code: int = 200,
    content: str = "Scene analysis result.",
) -> MagicMock:
    """Build a mock requests.Response for a successful VLM reply."""
    resp = MagicMock()
    resp.status_code = status_code

    body = {
        "choices": [
            {"message": {"content": content}}
        ]
    }
    resp.json = MagicMock(return_value=body)
    resp.raise_for_status = MagicMock()  # no-op for 200
    return resp


def _make_error_response(status_code: int = 500) -> MagicMock:
    """Build a mock requests.Response that raises on raise_for_status()."""
    resp = MagicMock()
    resp.status_code = status_code
    resp.raise_for_status = MagicMock(
        side_effect=Exception(f"HTTP {status_code}")
    )
    return resp


# ── VLMConfig tests ───────────────────────────────────────────────────────────


class TestVLMConfig:
    """VLMConfig defaults and environment variable overrides."""

    def test_default_endpoint(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("VLM_ENDPOINT", raising=False)
        config = VLMConfig()
        assert config.endpoint == "https://modelapi.klass.dev/v1/chat/completions"

    def test_endpoint_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("VLM_ENDPOINT", "http://myhost:9999/v1/chat/completions")
        config = VLMConfig()
        assert config.endpoint == "http://myhost:9999/v1/chat/completions"

    def test_default_model_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("VLM_MODEL", raising=False)
        config = VLMConfig()
        assert config.model == ""

    def test_model_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("VLM_MODEL", "Qwen-VL-72B")
        config = VLMConfig()
        assert config.model == "Qwen-VL-72B"

    def test_api_key_from_modelapi_key_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("MODEL_API_KEY", raising=False)
        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
        monkeypatch.setenv("MODELAPI_KEY", "sk-test-key")
        config = VLMConfig()
        assert config.api_key == "sk-test-key"

    def test_api_key_fallback_to_model_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("MODELAPI_KEY", raising=False)
        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
        monkeypatch.setenv("MODEL_API_KEY", "sk-fallback")
        config = VLMConfig()
        assert config.api_key == "sk-fallback"

    def test_explicit_api_key_not_overridden_by_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("MODELAPI_KEY", "env-key")
        config = VLMConfig(api_key="explicit-key")
        # __post_init__ only fills api_key when it is None
        assert config.api_key == "explicit-key"

    def test_default_timeout(self) -> None:
        config = VLMConfig()
        assert config.timeout_s == 30

    def test_custom_values(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("VLM_ENDPOINT", raising=False)
        monkeypatch.delenv("VLM_MODEL", raising=False)
        config = VLMConfig(
            endpoint="http://custom:1234/v1/chat/completions",
            model="custom-vlm",
            api_key="my-key",
            timeout_s=60,
        )
        assert config.endpoint == "http://custom:1234/v1/chat/completions"
        assert config.model == "custom-vlm"
        assert config.api_key == "my-key"
        assert config.timeout_s == 60


# ── VLMClient.analyze_scene tests ────────────────────────────────────────────


class TestVLMClientAnalyzeScene:
    """VLMClient.analyze_scene() behaviour."""

    def _client(self) -> VLMClient:
        config = VLMConfig(
            endpoint="https://fake-modelapi/v1/chat/completions",
            model="test-vlm",
            api_key="sk-test",
        )
        return VLMClient(config)

    def test_success_returns_model_text(self) -> None:
        """HTTP 200 with well-formed response → returns content string."""
        client = self._client()
        mock_resp = _make_mock_response(content="Two workers unconscious near barrels.")

        import requests
        with patch.object(requests, "post", return_value=mock_resp):
            result = client.analyze_scene(_SAMPLE_JPEG, "Describe the scene.")

        assert result == "Two workers unconscious near barrels."

    def test_success_sends_correct_endpoint(self) -> None:
        """analyze_scene posts to the configured endpoint."""
        client = self._client()
        mock_resp = _make_mock_response()

        import requests
        with patch.object(requests, "post", return_value=mock_resp) as mock_post:
            client.analyze_scene(_SAMPLE_JPEG, "Describe the scene.")

        call_args = mock_post.call_args
        assert call_args.args[0] == "https://fake-modelapi/v1/chat/completions"

    def test_success_sends_auth_header(self) -> None:
        """Authorization header is set from config.api_key."""
        client = self._client()
        mock_resp = _make_mock_response()

        import requests
        with patch.object(requests, "post", return_value=mock_resp) as mock_post:
            client.analyze_scene(_SAMPLE_JPEG, "Describe the scene.")

        headers = mock_post.call_args.kwargs["headers"]
        assert headers["Authorization"] == "Bearer sk-test"

    def test_base64_encoding_is_correct(self) -> None:
        """The JPEG bytes appear in the payload as a valid base64 data URL."""
        client = self._client()
        captured_payload: dict = {}

        def fake_post(url: str, json: dict, headers: dict, timeout: int) -> MagicMock:  # type: ignore[override]
            captured_payload.update(json)
            return _make_mock_response()

        import requests
        with patch.object(requests, "post", side_effect=fake_post):
            client.analyze_scene(_SAMPLE_JPEG, "Check for hazards.")

        content_parts = captured_payload["messages"][0]["content"]
        image_part = next(p for p in content_parts if p["type"] == "image_url")
        data_url: str = image_part["image_url"]["url"]

        assert data_url.startswith("data:image/jpeg;base64,")
        b64_part = data_url.split(",", 1)[1]
        decoded = base64.b64decode(b64_part)
        assert decoded == _SAMPLE_JPEG

    def test_payload_structure(self) -> None:
        """Payload matches the OpenAI vision chat completion format."""
        client = self._client()
        captured: dict = {}

        def fake_post(url: str, json: dict, headers: dict, timeout: int) -> MagicMock:  # type: ignore[override]
            captured.update(json)
            return _make_mock_response()

        import requests
        with patch.object(requests, "post", side_effect=fake_post):
            client.analyze_scene(_SAMPLE_JPEG, "Any casualties?")

        assert captured["model"] == "test-vlm"
        assert captured["temperature"] == 0.3
        assert captured["max_tokens"] == 512

        messages = captured["messages"]
        assert len(messages) == 1
        assert messages[0]["role"] == "user"

        content = messages[0]["content"]
        types = {p["type"] for p in content}
        assert types == {"image_url", "text"}

        text_part = next(p for p in content if p["type"] == "text")
        assert text_part["text"] == "Any casualties?"

    def test_http_error_returns_empty_string(self) -> None:
        """Non-200 response → analyze_scene returns empty string (no raise)."""
        client = self._client()
        mock_resp = _make_error_response(status_code=500)

        import requests
        with patch.object(requests, "post", return_value=mock_resp):
            result = client.analyze_scene(_SAMPLE_JPEG, "Describe the scene.")

        assert result == ""

    def test_network_exception_returns_empty_string(self) -> None:
        """Connection error → analyze_scene returns empty string (no raise)."""
        client = self._client()

        import requests
        with patch.object(requests, "post", side_effect=ConnectionRefusedError("refused")):
            result = client.analyze_scene(_SAMPLE_JPEG, "Describe the scene.")

        assert result == ""

    def test_empty_jpeg_bytes_returns_empty_string(self) -> None:
        """Empty bytes → returns empty string without making an HTTP call."""
        client = self._client()

        import requests
        with patch.object(requests, "post") as mock_post:
            result = client.analyze_scene(b"", "Describe the scene.")

        assert result == ""
        mock_post.assert_not_called()

    def test_empty_prompt_returns_empty_string(self) -> None:
        """Empty prompt → returns empty string without making an HTTP call."""
        client = self._client()

        import requests
        with patch.object(requests, "post") as mock_post:
            result = client.analyze_scene(_SAMPLE_JPEG, "")

        assert result == ""
        mock_post.assert_not_called()

    def test_malformed_response_returns_empty_string(self) -> None:
        """Response missing 'choices' key → returns empty string."""
        client = self._client()
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json = MagicMock(return_value={"unexpected": "format"})

        import requests
        with patch.object(requests, "post", return_value=mock_resp):
            result = client.analyze_scene(_SAMPLE_JPEG, "Describe the scene.")

        assert result == ""
