"""Tests for the unified LLMClient."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock

import pytest

from koan.llm.client import LLMClient
from koan.llm.config import LLMConfig, ProviderSettings
from koan.types import LLMResponse, Message

# -- Helpers ----------------------------------------------------------------

def _make_settings(**overrides: str) -> ProviderSettings:
    defaults = {"openai_api_key": "sk-test-openai", "anthropic_api_key": "sk-test-anthropic"}
    defaults.update(overrides)
    return ProviderSettings(**defaults)


def _openai_config(**overrides: object) -> LLMConfig:
    defaults: dict = {"provider": "openai", "model": "gpt-4o-mini"}
    defaults.update(overrides)
    return LLMConfig(**defaults)


def _anthropic_config(**overrides: object) -> LLMConfig:
    defaults: dict = {"provider": "anthropic", "model": "claude-haiku-4-5-20251001"}
    defaults.update(overrides)
    return LLMConfig(**defaults)


def _messages(text: str = "Hello") -> list[Message]:
    return [Message(role="user", content=text)]


def _system_and_user(system: str, user: str) -> list[Message]:
    return [
        Message(role="system", content=system),
        Message(role="user", content=user),
    ]


def _openai_llm_response(text: str = "Hi there") -> LLMResponse:
    return LLMResponse(
        text=text, model="gpt-4o-mini", provider="openai",
        usage={"input_tokens": 10, "output_tokens": 5},
        stop_reason="stop",
    )


def _anthropic_llm_response(text: str = "Hello from Claude") -> LLMResponse:
    return LLMResponse(
        text=text, model="claude-haiku-4-5-20251001", provider="anthropic",
        usage={"input_tokens": 12, "output_tokens": 8},
        stop_reason="end_turn",
    )


def _mock_provider(response: LLMResponse) -> MagicMock:
    """Create a mock provider with a complete() that returns the given response."""
    provider = MagicMock()
    provider.complete = AsyncMock(return_value=response)
    provider.close = AsyncMock()
    return provider


def _client_with_mock_openai(response: LLMResponse | None = None) -> tuple[LLMClient, MagicMock]:
    """Return an LLMClient with a mocked OpenAI provider injected."""
    mock = _mock_provider(response or _openai_llm_response())
    client = LLMClient(settings=_make_settings())
    client._openai = mock  # type: ignore[assignment]
    return client, mock


def _client_with_mock_anthropic(response: LLMResponse | None = None) -> tuple[LLMClient, MagicMock]:
    """Return an LLMClient with a mocked Anthropic provider injected."""
    mock = _mock_provider(response or _anthropic_llm_response())
    client = LLMClient(settings=_make_settings())
    client._anthropic = mock  # type: ignore[assignment]
    return client, mock


# -- Tests: Provider routing ------------------------------------------------

class TestProviderRouting:
    async def test_openai_routing(self) -> None:
        client, mock = _client_with_mock_openai()
        response = await client.complete(_openai_config(), _messages())

        assert response.provider == "openai"
        assert response.text == "Hi there"
        mock.complete.assert_awaited_once()

    async def test_anthropic_routing(self) -> None:
        client, mock = _client_with_mock_anthropic()
        response = await client.complete(_anthropic_config(), _messages())

        assert response.provider == "anthropic"
        assert response.text == "Hello from Claude"
        mock.complete.assert_awaited_once()


# -- Tests: Response normalization ------------------------------------------

class TestResponseNormalization:
    async def test_openai_response_fields(self) -> None:
        client, _ = _client_with_mock_openai(_openai_llm_response("Test output"))
        resp = await client.complete(_openai_config(), _messages())

        assert isinstance(resp, LLMResponse)
        assert resp.text == "Test output"
        assert resp.model == "gpt-4o-mini"
        assert resp.usage.input_tokens == 10
        assert resp.usage.output_tokens == 5
        assert resp.usage.total_tokens == 15
        assert resp.stop_reason == "stop"

    async def test_anthropic_response_fields(self) -> None:
        client, _ = _client_with_mock_anthropic(_anthropic_llm_response("Claude reply"))
        resp = await client.complete(_anthropic_config(), _messages())

        assert isinstance(resp, LLMResponse)
        assert resp.text == "Claude reply"
        assert resp.model == "claude-haiku-4-5-20251001"
        assert resp.usage.input_tokens == 12
        assert resp.usage.output_tokens == 8
        assert resp.stop_reason == "end_turn"


# -- Tests: API key handling ------------------------------------------------

class TestAPIKeyHandling:
    async def test_missing_openai_key_raises(self) -> None:
        settings = _make_settings(openai_api_key="")
        client = LLMClient(settings=settings)
        with pytest.raises(ValueError, match="No API key.*openai.*OPENAI_API_KEY"):
            await client.complete(_openai_config(), _messages())

    async def test_missing_anthropic_key_raises(self) -> None:
        settings = _make_settings(anthropic_api_key="")
        client = LLMClient(settings=settings)
        with pytest.raises(ValueError, match="No API key.*anthropic.*ANTHROPIC_API_KEY"):
            await client.complete(_anthropic_config(), _messages())


# -- Tests: Client lifecycle ------------------------------------------------

class TestClientLifecycle:
    async def test_close_closes_providers(self) -> None:
        client, mock = _client_with_mock_openai()
        await client.complete(_openai_config(), _messages())
        await client.close()
        mock.close.assert_awaited_once()

    async def test_context_manager(self) -> None:
        client, mock = _client_with_mock_openai()
        async with client:
            await client.complete(_openai_config(), _messages())
        mock.close.assert_awaited_once()

    async def test_provider_lazily_initialized(self) -> None:
        """Provider client is not created until first call."""
        client = LLMClient(settings=_make_settings())
        assert client._openai is None
        assert client._anthropic is None

    async def test_close_without_use_is_safe(self) -> None:
        """Closing a client that was never used should not error."""
        client = LLMClient(settings=_make_settings())
        await client.close()  # Should not raise
