"""Tests for kokoro_chat.engine.llm — LLM client factory and channel adapters."""
from __future__ import annotations

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from kokoro_chat.engine.llm import (
    STREAM_SENTINEL,
    NullChannel,
    StreamingChannel,
    UsageTrackingAiClient,
    build_ai_client,
)


# ---------------------------------------------------------------------------
# STREAM_SENTINEL
# ---------------------------------------------------------------------------

class TestStreamSentinel:

    def test_is_unique_object(self) -> None:
        """STREAM_SENTINEL must be a singleton-like unique object."""
        assert STREAM_SENTINEL is STREAM_SENTINEL

    def test_not_equal_to_common_values(self) -> None:
        assert STREAM_SENTINEL is not None
        assert STREAM_SENTINEL is not True
        assert STREAM_SENTINEL is not False
        assert STREAM_SENTINEL != ""
        assert STREAM_SENTINEL != 0

    def test_distinct_from_new_object(self) -> None:
        other = object()
        assert STREAM_SENTINEL is not other


# ---------------------------------------------------------------------------
# NullChannel
# ---------------------------------------------------------------------------

class TestNullChannel:

    def test_name(self) -> None:
        ch = NullChannel()
        assert ch.name == "api"

    @pytest.mark.asyncio
    async def test_stream_chunk_is_noop(self) -> None:
        ch = NullChannel()
        # Should complete without error and return None
        result = await ch.stream_chunk("user1", "hello")
        assert result is None

    @pytest.mark.asyncio
    async def test_stream_done_is_noop(self) -> None:
        ch = NullChannel()
        result = await ch.stream_done("user1")
        assert result is None

    @pytest.mark.asyncio
    async def test_send_is_noop(self) -> None:
        ch = NullChannel()
        result = await ch.send("user1", "text")
        assert result is None


# ---------------------------------------------------------------------------
# StreamingChannel
# ---------------------------------------------------------------------------

class TestStreamingChannel:

    def _make_channel(self) -> tuple[StreamingChannel, asyncio.Queue]:
        q: asyncio.Queue = asyncio.Queue()
        return StreamingChannel(q), q

    def test_name(self) -> None:
        ch, _ = self._make_channel()
        assert ch.name == "api"

    @pytest.mark.asyncio
    async def test_stream_chunk_pushes_to_queue(self) -> None:
        ch, q = self._make_channel()
        await ch.stream_chunk("user1", "hello world")
        assert q.qsize() == 1
        assert await q.get() == "hello world"

    @pytest.mark.asyncio
    async def test_stream_chunk_filters_tool_annotations(self) -> None:
        """Chunks starting with the wrench emoji should be dropped."""
        ch, q = self._make_channel()
        await ch.stream_chunk("user1", "\U0001f527 some tool annotation")
        assert q.qsize() == 0

    @pytest.mark.asyncio
    async def test_stream_chunk_allows_normal_emoji(self) -> None:
        """Non-wrench emoji chunks must pass through."""
        ch, q = self._make_channel()
        await ch.stream_chunk("user1", "\U0001f600 smile")
        assert q.qsize() == 1

    @pytest.mark.asyncio
    async def test_multiple_chunks_ordered(self) -> None:
        ch, q = self._make_channel()
        for text in ["a", "b", "c"]:
            await ch.stream_chunk("user1", text)
        assert q.qsize() == 3
        assert await q.get() == "a"
        assert await q.get() == "b"
        assert await q.get() == "c"

    @pytest.mark.asyncio
    async def test_stream_done_is_noop(self) -> None:
        ch, q = self._make_channel()
        await ch.stream_done("user1")
        assert q.qsize() == 0

    @pytest.mark.asyncio
    async def test_send_is_noop(self) -> None:
        ch, q = self._make_channel()
        await ch.send("user1", "text")
        assert q.qsize() == 0


# ---------------------------------------------------------------------------
# UsageTrackingAiClient
# ---------------------------------------------------------------------------

class TestUsageTrackingAiClient:

    def _make_mock_client(self) -> MagicMock:
        client = MagicMock()
        usage = MagicMock()
        usage.input_tokens = 10
        usage.output_tokens = 5
        response = MagicMock()
        response.usage = usage
        client.chat = AsyncMock(return_value=response)
        return client

    @pytest.mark.asyncio
    async def test_initial_token_counts_are_zero(self) -> None:
        client = self._make_mock_client()
        tracker = UsageTrackingAiClient(client, model="test-model", max_tokens=100)
        assert tracker.total_input_tokens == 0
        assert tracker.total_output_tokens == 0

    @pytest.mark.asyncio
    async def test_chat_accumulates_tokens(self) -> None:
        client = self._make_mock_client()
        tracker = UsageTrackingAiClient(client, model="test-model", max_tokens=100)

        await tracker.chat([{"role": "user", "content": "hi"}])

        assert tracker.total_input_tokens == 10
        assert tracker.total_output_tokens == 5

    @pytest.mark.asyncio
    async def test_chat_accumulates_across_multiple_calls(self) -> None:
        client = self._make_mock_client()
        tracker = UsageTrackingAiClient(client, model="test-model", max_tokens=100)

        await tracker.chat([])
        await tracker.chat([])

        assert tracker.total_input_tokens == 20
        assert tracker.total_output_tokens == 10

    @pytest.mark.asyncio
    async def test_chat_with_no_usage_does_not_crash(self) -> None:
        client = MagicMock()
        response = MagicMock()
        response.usage = None
        client.chat = AsyncMock(return_value=response)

        tracker = UsageTrackingAiClient(client, model="test-model", max_tokens=100)
        await tracker.chat([])

        assert tracker.total_input_tokens == 0
        assert tracker.total_output_tokens == 0

    @pytest.mark.asyncio
    async def test_chat_injects_model_into_provider_options(self) -> None:
        client = self._make_mock_client()
        tracker = UsageTrackingAiClient(client, model="claude-3-sonnet", max_tokens=200)

        await tracker.chat([])

        call_kwargs = client.chat.call_args.kwargs
        assert call_kwargs["provider_options"]["model"] == "claude-3-sonnet"
        assert call_kwargs["max_tokens"] == 200


# ---------------------------------------------------------------------------
# build_ai_client
# ---------------------------------------------------------------------------

class TestBuildAiClient:

    def _make_config(self, **kwargs):
        cfg = MagicMock()
        cfg.anthropic_api_key = kwargs.get("anthropic_api_key", "")
        cfg.anthropic_oauth_token = kwargs.get("anthropic_oauth_token", "")
        cfg.openai_api_key = kwargs.get("openai_api_key", "")
        cfg.minimax_api_key = kwargs.get("minimax_api_key", "")
        return cfg

    def test_unknown_provider_returns_none(self) -> None:
        cfg = self._make_config()
        result = build_ai_client("unknown_provider", "some-model", cfg)
        assert result is None

    def test_anthropic_missing_credentials_returns_none(self) -> None:
        cfg = self._make_config(anthropic_api_key="", anthropic_oauth_token="")
        result = build_ai_client("anthropic", "claude-3", cfg)
        assert result is None

    def test_openai_missing_credentials_returns_none(self) -> None:
        cfg = self._make_config(openai_api_key="")
        result = build_ai_client("openai", "gpt-4", cfg)
        assert result is None

    def test_minimax_missing_credentials_returns_none(self) -> None:
        cfg = self._make_config(minimax_api_key="")
        result = build_ai_client("minimax", "minimax-model", cfg)
        assert result is None

    def test_anthropic_with_api_key_calls_factory(self) -> None:
        cfg = self._make_config(anthropic_api_key="sk-test-key")
        with patch("kokoro_chat.engine.llm.AiClient") as MockAiClient:
            fake_client = MagicMock()
            MockAiClient.anthropic.return_value = fake_client
            result = build_ai_client("anthropic", "claude-3", cfg)
            MockAiClient.anthropic.assert_called_once_with(
                api_key="sk-test-key", model="claude-3",
            )
            assert result is fake_client

    def test_anthropic_with_oauth_token_calls_factory(self) -> None:
        cfg = self._make_config(anthropic_api_key="", anthropic_oauth_token="oauth-tok")
        with patch("kokoro_chat.engine.llm.AiClient") as MockAiClient:
            MockAiClient.anthropic.return_value = MagicMock()
            build_ai_client("anthropic", "claude-3", cfg)
            MockAiClient.anthropic.assert_called_once_with(
                api_key="oauth-tok", model="claude-3",
            )

    def test_provider_comparison_is_case_insensitive(self) -> None:
        cfg = self._make_config(anthropic_api_key="key123")
        with patch("kokoro_chat.engine.llm.AiClient") as MockAiClient:
            MockAiClient.anthropic.return_value = MagicMock()
            result = build_ai_client("ANTHROPIC", "claude-3", cfg)
            assert result is not None
