"""Tests for TTSClient.

All aiohttp calls are mocked so these tests run without aiohttp installed.
"""

from __future__ import annotations

import sys
from pathlib import Path
from types import ModuleType
from unittest.mock import AsyncMock, 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 fake aiohttp before importing the client ────────────────────────
# aiohttp may not be installed in the dev environment; we stub the entire
# module so TTSClient can be imported and tested without the real dependency.

def _make_fake_aiohttp() -> ModuleType:
    """Build a minimal aiohttp stub sufficient for TTSClient tests."""
    fake = ModuleType("aiohttp")

    class ClientTimeout:
        def __init__(self, **kwargs: object) -> None:
            self.__dict__.update(kwargs)

    class ClientSession:
        """Stub replaced per-test via patch."""

    fake.ClientTimeout = ClientTimeout
    fake.ClientSession = ClientSession
    return fake


if "aiohttp" not in sys.modules:
    sys.modules["aiohttp"] = _make_fake_aiohttp()

from services.tts_client import TTSClient, TTSConfig  # noqa: E402  (after sys.modules patch)


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

def _make_mock_response(status: int, body: str = "") -> MagicMock:
    """Build an async context manager mock that looks like an aiohttp response."""
    resp = MagicMock()
    resp.status = status
    resp.text = AsyncMock(return_value=body)

    # iter_any() must be an async generator
    async def _iter_any():
        yield b"audio_data"

    resp.content = MagicMock()
    resp.content.iter_any = _iter_any

    # Support `async with session.post(...) as resp:`
    resp.__aenter__ = AsyncMock(return_value=resp)
    resp.__aexit__ = AsyncMock(return_value=False)
    return resp


def _make_mock_session(mock_response: MagicMock) -> MagicMock:
    """Build a mock aiohttp.ClientSession whose post() returns mock_response."""
    session = MagicMock()
    session.post = MagicMock(return_value=mock_response)
    session.close = AsyncMock()
    return session


# ── tests ────────────────────────────────────────────────────────────────────


class TestTTSConfig:
    """TTSConfig default values and field behaviour."""

    def test_default_endpoint_uses_env_or_kluster(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("TTS_ENDPOINT", raising=False)
        config = TTSConfig()
        assert config.endpoint == "http://kluster.klass.dev:8200/tts"

    def test_endpoint_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("TTS_ENDPOINT", "http://myhost:9000/tts")
        config = TTSConfig()
        assert config.endpoint == "http://myhost:9000/tts"

    def test_default_timeout(self) -> None:
        config = TTSConfig()
        assert config.timeout_s == 15

    def test_custom_values(self) -> None:
        config = TTSConfig(endpoint="http://custom:1234/tts", timeout_s=30)
        assert config.endpoint == "http://custom:1234/tts"
        assert config.timeout_s == 30


class TestTTSClientSpeak:
    """TTSClient.speak() behaviour."""

    @pytest.mark.asyncio
    async def test_speak_success_returns_wav_bytes(self) -> None:
        """HTTP 200 with streaming body → speak() returns WAV bytes."""
        config = TTSConfig(endpoint="http://fake:8200/tts")
        client = TTSClient(config)

        mock_resp = _make_mock_response(status=200)
        client._session = _make_mock_session(mock_resp)

        result = await client.speak("Hello world")

        assert isinstance(result, bytes)
        assert result == b"audio_data"
        client._session.post.assert_called_once()
        call_args = client._session.post.call_args
        assert call_args.args[0] == "http://fake:8200/tts"
        assert call_args.kwargs["json"] == {"text": "Hello world", "sample_rate": 16000}

    @pytest.mark.asyncio
    async def test_speak_success_custom_sample_rate(self) -> None:
        config = TTSConfig(endpoint="http://fake:8200/tts")
        client = TTSClient(config)
        client._session = _make_mock_session(_make_mock_response(status=200))

        result = await client.speak("Test", sample_rate=22050)

        assert isinstance(result, bytes)
        _, call_kwargs = client._session.post.call_args
        assert call_kwargs["json"]["sample_rate"] == 22050

    @pytest.mark.asyncio
    async def test_speak_server_500_returns_none(self) -> None:
        """Non-200 response → speak() returns None."""
        config = TTSConfig(endpoint="http://fake:8200/tts")
        client = TTSClient(config)
        client._session = _make_mock_session(
            _make_mock_response(status=500, body="Internal Server Error")
        )

        result = await client.speak("Hello world")

        assert result is None

    @pytest.mark.asyncio
    async def test_speak_server_503_returns_none(self) -> None:
        config = TTSConfig(endpoint="http://fake:8200/tts")
        client = TTSClient(config)
        client._session = _make_mock_session(
            _make_mock_response(status=503, body="Service Unavailable")
        )

        result = await client.speak("Test")

        assert result is None

    @pytest.mark.asyncio
    async def test_speak_network_error_returns_none(self) -> None:
        """Connection-level exception → speak() returns None (no raise)."""
        config = TTSConfig(endpoint="http://unreachable:8200/tts")
        client = TTSClient(config)

        session = MagicMock()
        session.post = MagicMock(side_effect=ConnectionRefusedError("refused"))
        client._session = session

        result = await client.speak("Hello")

        assert result is None

    @pytest.mark.asyncio
    async def test_speak_empty_text_returns_none(self) -> None:
        """Empty text is rejected before any HTTP call."""
        client = TTSClient(TTSConfig(endpoint="http://fake:8200/tts"))
        client._session = _make_mock_session(_make_mock_response(status=200))

        result = await client.speak("")

        assert result is None
        client._session.post.assert_not_called()

    @pytest.mark.asyncio
    async def test_speak_creates_session_if_none(self) -> None:
        """If _session is None, speak() creates a new ClientSession."""
        config = TTSConfig(endpoint="http://fake:8200/tts")
        client = TTSClient(config)
        assert client._session is None

        mock_resp = _make_mock_response(status=200)
        mock_session = _make_mock_session(mock_resp)

        # Patch aiohttp.ClientSession constructor
        import aiohttp  # uses our stub
        with patch.object(aiohttp, "ClientSession", return_value=mock_session):
            result = await client.speak("Hello")

        assert isinstance(result, bytes)
        assert client._session is mock_session


class TestTTSClientClose:
    """TTSClient.close() behaviour."""

    @pytest.mark.asyncio
    async def test_close_calls_session_close(self) -> None:
        client = TTSClient(TTSConfig(endpoint="http://fake:8200/tts"))
        mock_session = MagicMock()
        mock_session.close = AsyncMock()
        client._session = mock_session

        await client.close()

        mock_session.close.assert_awaited_once()
        assert client._session is None

    @pytest.mark.asyncio
    async def test_close_no_session_is_noop(self) -> None:
        """close() with no session does not raise."""
        client = TTSClient()
        await client.close()  # should not raise
