"""
tests/test_agents/test_llm_init.py
=====================================
Tests for LLM client initialization (inference/llm_client.py).

No real Ollama or vLLM service is required — HTTP calls are mocked.

Run standalone:
    python -m unittest tests/test_agents/test_llm_init.py -v
Run via runner:
    python tests/run_tests.py

Coverage:
- get_llm_client()   — returns OllamaClient or VLLMTextClient based on config
- OllamaClient       — constructor, interface, mocked generate(), mocked health_check()
- VLLMTextClient     — constructor, interface, mocked generate()
- get_llm()          — singleton: returns same object on repeated calls
- LLMConfig defaults — endpoint, model name, token limits
"""

from __future__ import annotations

import sys
import logging
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
from typing import Any

# ── bootstrap ────────────────────────────────────────────────────────────────
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))  # → tests/
import shared  # noqa: E402

logger = logging.getLogger("vss.tests.llm_init")


# ─────────────────────────────────────────────────────────────────────────────
# 1. LLMConfig defaults
# ─────────────────────────────────────────────────────────────────────────────

class TestLLMConfigDefaults(unittest.TestCase):
    """Verify that LLMConfig provides sensible defaults."""

    def test_default_model_name_is_set(self):
        from config.settings import LLMConfig
        cfg = LLMConfig()
        self.assertIsInstance(cfg.model_name, str)
        self.assertGreater(len(cfg.model_name), 0)
        logger.debug("default model_name=%r", cfg.model_name)

    def test_default_endpoint_is_url(self):
        from config.settings import LLMConfig
        cfg = LLMConfig()
        self.assertTrue(cfg.endpoint.startswith("http"))

    def test_default_max_tokens_positive(self):
        from config.settings import LLMConfig
        cfg = LLMConfig()
        self.assertGreater(cfg.max_tokens, 0)

    def test_default_temperature_valid(self):
        from config.settings import LLMConfig
        cfg = LLMConfig()
        self.assertGreaterEqual(cfg.temperature, 0.0)
        self.assertLessEqual(cfg.temperature, 2.0)

    def test_use_vllm_is_bool(self):
        from config.settings import LLMConfig
        cfg = LLMConfig()
        self.assertIsInstance(cfg.use_vllm, bool)

    def test_vllm_endpoint_is_url(self):
        from config.settings import LLMConfig
        cfg = LLMConfig()
        self.assertTrue(cfg.vllm_endpoint.startswith("http"))


# ─────────────────────────────────────────────────────────────────────────────
# 2. OllamaClient — construction and interface
# ─────────────────────────────────────────────────────────────────────────────

class TestOllamaClientConstruction(unittest.TestCase):
    """OllamaClient should be constructable with default or custom config."""

    def test_instantiates_with_defaults(self):
        from inference.llm_client import OllamaClient
        client = OllamaClient()
        self.assertIsNotNone(client)

    def test_instantiates_with_custom_config(self):
        from inference.llm_client import OllamaClient
        from config.settings import LLMConfig
        cfg = LLMConfig(model_name="llama3.1:8b", endpoint="http://localhost:11434")
        client = OllamaClient(config=cfg)
        self.assertEqual(client.model, "llama3.1:8b")
        self.assertEqual(client.base_url, "http://localhost:11434")

    def test_has_generate_method(self):
        from inference.llm_client import OllamaClient
        self.assertTrue(callable(getattr(OllamaClient, "generate", None)))

    def test_has_health_check_method(self):
        from inference.llm_client import OllamaClient
        self.assertTrue(callable(getattr(OllamaClient, "health_check", None)))

    def test_has_close_method(self):
        from inference.llm_client import OllamaClient
        self.assertTrue(callable(getattr(OllamaClient, "close", None)))


class TestOllamaClientGenerate(unittest.IsolatedAsyncioTestCase):
    """OllamaClient.generate() with mocked HTTP — no real Ollama needed."""

    async def test_generate_returns_string(self):
        from inference.llm_client import OllamaClient
        import httpx

        mock_response = MagicMock(spec=httpx.Response)
        mock_response.status_code = 200
        mock_response.raise_for_status = MagicMock()
        mock_response.json.return_value = {"response": "42 fire events."}

        client = OllamaClient()
        client._client.post = AsyncMock(return_value=mock_response)

        result = await client.generate("How many fire events?")
        self.assertEqual(result, "42 fire events.")
        logger.info("OllamaClient.generate mocked → %r", result)

    async def test_generate_strips_whitespace(self):
        from inference.llm_client import OllamaClient
        import httpx

        mock_response = MagicMock(spec=httpx.Response)
        mock_response.raise_for_status = MagicMock()
        mock_response.json.return_value = {"response": "  SQL  \n"}

        client = OllamaClient()
        client._client.post = AsyncMock(return_value=mock_response)

        result = await client.generate("route this")
        self.assertEqual(result, "SQL")

    async def test_generate_raises_on_http_error(self):
        from inference.llm_client import OllamaClient
        import httpx

        client = OllamaClient()
        client._client.post = AsyncMock(
            side_effect=httpx.ConnectError("Connection refused")
        )

        with self.assertRaises(Exception):
            await client.generate("some prompt")
        logger.warning("OllamaClient.generate correctly raises on HTTP error")

    async def test_health_check_returns_true_on_200(self):
        from inference.llm_client import OllamaClient
        import httpx

        mock_response = MagicMock(spec=httpx.Response)
        mock_response.status_code = 200

        client = OllamaClient()
        client._client.get = AsyncMock(return_value=mock_response)

        result = await client.health_check()
        self.assertTrue(result)
        logger.info("OllamaClient health_check 200 → True")

    async def test_health_check_returns_false_on_connection_error(self):
        from inference.llm_client import OllamaClient
        import httpx

        client = OllamaClient()
        client._client.get = AsyncMock(
            side_effect=httpx.ConnectError("Connection refused")
        )

        result = await client.health_check()
        self.assertFalse(result)
        logger.info("OllamaClient health_check connection error → False")


# ─────────────────────────────────────────────────────────────────────────────
# 3. VLLMTextClient — construction and interface
# ─────────────────────────────────────────────────────────────────────────────

class TestVLLMTextClientConstruction(unittest.TestCase):
    """VLLMTextClient should be constructable and expose the same interface."""

    def test_instantiates_with_defaults(self):
        from inference.llm_client import VLLMTextClient
        client = VLLMTextClient()
        self.assertIsNotNone(client)

    def test_has_generate_and_health_check(self):
        from inference.llm_client import VLLMTextClient
        self.assertTrue(callable(getattr(VLLMTextClient, "generate", None)))
        self.assertTrue(callable(getattr(VLLMTextClient, "health_check", None)))

    def test_base_url_from_config(self):
        from inference.llm_client import VLLMTextClient
        from config.settings import LLMConfig
        cfg = LLMConfig(vllm_endpoint="http://localhost:8001/v1")
        client = VLLMTextClient(config=cfg)
        self.assertEqual(client.base_url, "http://localhost:8001/v1")


# ─────────────────────────────────────────────────────────────────────────────
# 4. get_llm_client() factory
# ─────────────────────────────────────────────────────────────────────────────

class TestGetLLMClientFactory(unittest.TestCase):
    """get_llm_client() picks the right backend based on config.use_vllm."""

    def test_ollama_when_use_vllm_false(self):
        from inference.llm_client import get_llm_client, OllamaClient
        from config.settings import LLMConfig
        cfg = LLMConfig(use_vllm=False)
        client = get_llm_client(config=cfg)
        self.assertIsInstance(client, OllamaClient)
        logger.info("use_vllm=False → OllamaClient ✓")

    def test_vllm_when_use_vllm_true(self):
        from inference.llm_client import get_llm_client, VLLMTextClient
        from config.settings import LLMConfig
        cfg = LLMConfig(use_vllm=True)
        client = get_llm_client(config=cfg)
        self.assertIsInstance(client, VLLMTextClient)
        logger.info("use_vllm=True → VLLMTextClient ✓")

    def test_returns_base_llm_client_subclass(self):
        from inference.llm_client import get_llm_client, BaseLLMClient
        from config.settings import LLMConfig
        client = get_llm_client(config=LLMConfig())
        self.assertIsInstance(client, BaseLLMClient)


# ─────────────────────────────────────────────────────────────────────────────
# 5. get_llm() singleton
# ─────────────────────────────────────────────────────────────────────────────

class TestGetLLMSingleton(unittest.TestCase):
    """get_llm() should return the same object every time (singleton)."""

    def setUp(self):
        # Reset singleton before each test so tests are independent
        import inference.llm_client as mod
        mod._llm_client = None

    def tearDown(self):
        import inference.llm_client as mod
        mod._llm_client = None

    def test_returns_non_none_client(self):
        from inference.llm_client import get_llm
        client = get_llm()
        self.assertIsNotNone(client)
        logger.info("get_llm() returned %s", type(client).__name__)

    def test_second_call_returns_same_object(self):
        from inference.llm_client import get_llm
        client1 = get_llm()
        client2 = get_llm()
        self.assertIs(client1, client2, "get_llm() must return the same singleton")

    def test_client_has_generate_attribute(self):
        from inference.llm_client import get_llm
        client = get_llm()
        self.assertTrue(hasattr(client, "generate"),
            "LLM client must expose a generate() method")

    def test_client_has_health_check_attribute(self):
        from inference.llm_client import get_llm
        client = get_llm()
        self.assertTrue(hasattr(client, "health_check"),
            "LLM client must expose a health_check() method")

    def test_singleton_reset_creates_new_object(self):
        """After clearing the singleton, get_llm() creates a fresh client."""
        import inference.llm_client as mod
        from inference.llm_client import get_llm
        first = get_llm()
        mod._llm_client = None          # simulate reset
        second = get_llm()
        # They are different objects, both valid
        self.assertIsNotNone(second)
        self.assertIsNot(first, second)


if __name__ == "__main__":
    unittest.main(verbosity=2)
