"""TTSStreamingService 纯逻辑单测 — 分段策略 + 分块推送 + 错误处理.

mock 掉 WebSocket 和 CosyVoiceService，验证核心逻辑：
- 文本长度路由（短文本/长文本）
- 空文本/超长文本校验
- 音频分块推送算法
- 错误处理路径
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from packages.application.cosyvoice_service import CosyVoiceError
from packages.application.tts_job.streaming_service import (
    _AUDIO_CHUNK_SIZE,
    TTSStreamingError,
    TTSStreamingService,
)

# ── Fixtures ──────────────────────────────────────────────────────────────


@pytest.fixture
def mock_cosyvoice():
    """mock CosyVoiceService."""
    svc = MagicMock()
    svc.submit_synthesize_task.return_value = {
        "audio_url": "https://example.com/audio.mp3",
        "duration": 3.5,
    }
    return svc


@pytest.fixture
def streaming_service(mock_cosyvoice):
    """TTSStreamingService 实例."""
    return TTSStreamingService(mock_cosyvoice)


@pytest.fixture
def mock_ws():
    """mock WebSocket."""
    ws = AsyncMock()
    ws.send_bytes = AsyncMock()
    ws.send_json = AsyncMock()
    return ws


class FakeAudioBytes:
    """生成指定大小的假音频数据."""

    @staticmethod
    def make(size: int) -> bytes:
        return b"\x00" * size


# ── 合成路由测试 ──────────────────────────────────────────────────────────


class TestSynthesizeRouting:
    """synthesize_and_stream 路由逻辑测试."""

    @pytest.mark.asyncio
    async def test_empty_text_returns_error(self, streaming_service, mock_ws):
        """空文本返回错误，不调用合成."""
        await streaming_service.synthesize_and_stream(mock_ws, {"text": ""})

        # 应发送 error 消息
        mock_ws.send_json.assert_called()
        last_call = mock_ws.send_json.call_args
        assert last_call[0][0]["type"] == "error"
        assert "不能为空" in last_call[0][0]["message"]
        # 不应调用合成
        streaming_service._cosyvoice.submit_synthesize_task.assert_not_called()

    @pytest.mark.asyncio
    async def test_missing_text_key_returns_error(self, streaming_service, mock_ws):
        """缺少 text 字段返回错误."""
        await streaming_service.synthesize_and_stream(mock_ws, {})

        mock_ws.send_json.assert_called()
        last_call = mock_ws.send_json.call_args
        assert last_call[0][0]["type"] == "error"
        streaming_service._cosyvoice.submit_synthesize_task.assert_not_called()

    @pytest.mark.asyncio
    async def test_too_long_text_returns_error(self, streaming_service, mock_ws):
        """超长文本返回错误."""
        long_text = "你" * 10001
        await streaming_service.synthesize_and_stream(mock_ws, {"text": long_text})

        mock_ws.send_json.assert_called()
        last_call = mock_ws.send_json.call_args
        assert last_call[0][0]["type"] == "error"
        assert "最大" in last_call[0][0]["message"]
        streaming_service._cosyvoice.submit_synthesize_task.assert_not_called()

    @pytest.mark.asyncio
    async def test_short_text_uses_short_path(self, streaming_service, mock_ws):
        """短文本走 _stream_short_text 路径."""
        with patch.object(streaming_service, "_stream_short_text", new_callable=AsyncMock) as mock_short:
            await streaming_service.synthesize_and_stream(mock_ws, {"text": "hello"})
            mock_short.assert_called_once()

    @pytest.mark.asyncio
    async def test_long_text_uses_long_path(self, streaming_service, mock_ws):
        """长文本走 _stream_long_text 路径."""
        long_text = "你" * 501
        with patch.object(streaming_service, "_stream_long_text", new_callable=AsyncMock) as mock_long:
            await streaming_service.synthesize_and_stream(mock_ws, {"text": long_text})
            mock_long.assert_called_once()

    @pytest.mark.asyncio
    async def test_exactly_threshold_uses_short_path(self, streaming_service, mock_ws):
        """恰好等于阈值走短文本路径."""
        text = "你" * 500
        with (
            patch.object(streaming_service, "_stream_short_text", new_callable=AsyncMock) as mock_short,
            patch.object(streaming_service, "_stream_long_text", new_callable=AsyncMock) as mock_long,
        ):
            await streaming_service.synthesize_and_stream(mock_ws, {"text": text})
            mock_short.assert_called_once()
            mock_long.assert_not_called()


# ── 短文本流测试 ──────────────────────────────────────────────────────────


class TestStreamShortText:
    """短文本流式合成测试."""

    @pytest.mark.asyncio
    async def test_happy_path_sends_started_then_done(self, streaming_service, mock_ws):
        """短文本正常流程：started → 音频块 → done."""
        audio_data = FakeAudioBytes.make(5000)
        with patch.object(streaming_service, "_download_audio", return_value=audio_data):
            await streaming_service._stream_short_text(
                mock_ws,
                {"text": "hello", "voice_id": "v1", "format": "mp3", "speed": 1.0},
            )

        # 检查 started 消息
        calls = mock_ws.send_json.call_args_list
        assert calls[0][0][0]["type"] == "started"
        assert calls[0][0][0]["segment_count"] == 1

        # 检查 done 消息
        last_msg = calls[-1][0][0]
        assert last_msg["type"] == "done"
        assert last_msg["file_size"] == 5000
        assert last_msg["format"] == "mp3"
        assert last_msg["duration"] == 3.5

    @pytest.mark.asyncio
    async def test_calls_cosyvoice_with_correct_params(self, streaming_service, mock_ws):
        """正确传递参数给 CosyVoice."""
        audio_data = FakeAudioBytes.make(1000)
        with patch.object(streaming_service, "_download_audio", return_value=audio_data):
            await streaming_service._stream_short_text(
                mock_ws,
                {
                    "text": "test text",
                    "voice_id": "voice-123",
                    "sample_rate": 22050,
                    "format": "wav",
                    "speed": 1.5,
                },
            )

        streaming_service._cosyvoice.submit_synthesize_task.assert_called_once_with(
            text="test text",
            voice_id="voice-123",
            sample_rate=22050,
            format="wav",
            speed=1.5,
        )

    @pytest.mark.asyncio
    async def test_cosyvoice_error_returns_error(self, streaming_service, mock_ws):
        """CosyVoice 错误返回 error 消息."""
        streaming_service._cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("API quota exceeded")

        await streaming_service._stream_short_text(mock_ws, {"text": "hello"})

        # 最后一条应该是 error
        last_msg = mock_ws.send_json.call_args_list[-1][0][0]
        assert last_msg["type"] == "error"
        assert "API quota exceeded" in last_msg["message"]

    @pytest.mark.asyncio
    async def test_generic_exception_returns_error(self, streaming_service, mock_ws):
        """普通异常返回 error 消息."""
        streaming_service._cosyvoice.submit_synthesize_task.side_effect = RuntimeError("boom")

        await streaming_service._stream_short_text(mock_ws, {"text": "hello"})

        last_msg = mock_ws.send_json.call_args_list[-1][0][0]
        assert last_msg["type"] == "error"
        assert "合成失败" in last_msg["message"]

    @pytest.mark.asyncio
    async def test_no_audio_url_returns_error(self, streaming_service, mock_ws):
        """合成结果无 audio_url 返回错误."""
        streaming_service._cosyvoice.submit_synthesize_task.return_value = {"duration": 3.0}

        await streaming_service._stream_short_text(mock_ws, {"text": "hello"})

        last_msg = mock_ws.send_json.call_args_list[-1][0][0]
        assert last_msg["type"] == "error"
        assert "音频 URL" in last_msg["message"]

    @pytest.mark.asyncio
    async def test_download_failure_returns_error(self, streaming_service, mock_ws):
        """音频下载失败返回 error."""
        with patch.object(
            streaming_service,
            "_download_audio",
            side_effect=Exception("download failed"),
        ):
            await streaming_service._stream_short_text(mock_ws, {"text": "hello"})

        last_msg = mock_ws.send_json.call_args_list[-1][0][0]
        assert last_msg["type"] == "error"
        assert "音频推送失败" in last_msg["message"]


# ── 音频分块测试 ──────────────────────────────────────────────────────────


class TestStreamAudioChunks:
    """_stream_audio_chunks 分块推送测试."""

    @pytest.mark.asyncio
    async def test_exact_one_chunk(self, streaming_service, mock_ws):
        """恰好一个 chunk 大小的数据."""
        data = FakeAudioBytes.make(_AUDIO_CHUNK_SIZE)
        total = await streaming_service._stream_audio_chunks(mock_ws, data)

        assert total == _AUDIO_CHUNK_SIZE
        assert mock_ws.send_bytes.call_count == 1
        assert len(mock_ws.send_bytes.call_args[0][0]) == _AUDIO_CHUNK_SIZE

    @pytest.mark.asyncio
    async def test_smaller_than_one_chunk(self, streaming_service, mock_ws):
        """小于一个 chunk 的数据."""
        data = FakeAudioBytes.make(1000)
        total = await streaming_service._stream_audio_chunks(mock_ws, data)

        assert total == 1000
        assert mock_ws.send_bytes.call_count == 1

    @pytest.mark.asyncio
    async def test_multiple_full_chunks(self, streaming_service, mock_ws):
        """多个完整 chunk."""
        num_chunks = 5
        data = FakeAudioBytes.make(_AUDIO_CHUNK_SIZE * num_chunks)
        total = await streaming_service._stream_audio_chunks(mock_ws, data)

        assert total == _AUDIO_CHUNK_SIZE * num_chunks
        assert mock_ws.send_bytes.call_count == num_chunks
        for c in mock_ws.send_bytes.call_args_list:
            assert len(c[0][0]) == _AUDIO_CHUNK_SIZE

    @pytest.mark.asyncio
    async def test_partial_last_chunk(self, streaming_service, mock_ws):
        """最后一个 chunk 不完整."""
        data = FakeAudioBytes.make(_AUDIO_CHUNK_SIZE * 2 + 1234)
        total = await streaming_service._stream_audio_chunks(mock_ws, data)

        assert total == _AUDIO_CHUNK_SIZE * 2 + 1234
        assert mock_ws.send_bytes.call_count == 3
        # 最后一块是 1234 字节
        last_chunk = mock_ws.send_bytes.call_args_list[-1][0][0]
        assert len(last_chunk) == 1234

    @pytest.mark.asyncio
    async def test_empty_audio_sends_zero_chunks(self, streaming_service, mock_ws):
        """空音频不发送任何 chunk."""
        total = await streaming_service._stream_audio_chunks(mock_ws, b"")
        assert total == 0
        mock_ws.send_bytes.assert_not_called()

    @pytest.mark.asyncio
    async def test_chunks_are_consecutive(self, streaming_service, mock_ws):
        """所有 chunk 拼接起来等于原始数据."""
        data = bytes(range(256)) * 50  # 12800 bytes
        total = await streaming_service._stream_audio_chunks(mock_ws, data)

        assert total == len(data)
        # 收集所有 chunk
        all_bytes = b"".join(c[0][0] for c in mock_ws.send_bytes.call_args_list)
        assert all_bytes == data


# ── 长文本分段流测试 ──────────────────────────────────────────────────────


class TestStreamLongText:
    """长文本分段流式合成测试."""

    @pytest.mark.asyncio
    async def test_happy_path_all_segments_ok(self, streaming_service, mock_ws):
        """长文本正常流程：多个分段全部成功."""
        audio_data = FakeAudioBytes.make(2000)
        with patch.object(streaming_service, "_download_audio", return_value=audio_data):
            text = "你" * 1200  # 应该分成3段
            await streaming_service._stream_long_text(
                mock_ws,
                {"text": text, "voice_id": "v1", "format": "mp3", "speed": 1.0},
            )

        # 检查 started 消息
        calls = mock_ws.send_json.call_args_list
        assert calls[0][0][0]["type"] == "started"
        segment_count = calls[0][0][0]["segment_count"]
        assert segment_count >= 2  # 1200 字至少分 2 段

        # 检查有 segment_done 消息
        segment_dones = [c for c in calls if c[0][0].get("type") == "segment_done"]
        assert len(segment_dones) == segment_count

        # 检查最后是 done 消息
        last_msg = calls[-1][0][0]
        assert last_msg["type"] == "done"
        assert last_msg["file_size"] == 2000 * segment_count

    @pytest.mark.asyncio
    async def test_first_segment_fails_returns_error(self, streaming_service, mock_ws):
        """第一个分段失败，立即返回错误."""
        streaming_service._cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("segment 0 failed")

        text = "你" * 1200
        await streaming_service._stream_long_text(mock_ws, {"text": text, "voice_id": "v1"})

        calls = mock_ws.send_json.call_args_list
        last_msg = calls[-1][0][0]
        assert last_msg["type"] == "error"
        assert "分段" in last_msg["message"]
        assert "1" in last_msg["message"]  # 第1段失败

    @pytest.mark.asyncio
    async def test_segment_without_audio_url_fails(self, streaming_service, mock_ws):
        """分段结果无 audio_url 视为失败."""
        # 第一段正常，第二段返回空 audio_url
        call_results = [
            {"audio_url": "https://a.com/1.mp3", "duration": 2.0},
            {"audio_url": "", "duration": 0},
            {"audio_url": "https://a.com/3.mp3", "duration": 3.0},
        ]
        streaming_service._cosyvoice.submit_synthesize_task.side_effect = call_results

        with patch.object(
            streaming_service,
            "_download_audio",
            return_value=FakeAudioBytes.make(1000),
        ):
            text = "你" * 1500
            await streaming_service._stream_long_text(mock_ws, {"text": text, "voice_id": "v1"})

        calls = mock_ws.send_json.call_args_list
        # 应该有错误
        error_msgs = [c for c in calls if c[0][0].get("type") == "error"]
        assert len(error_msgs) >= 1

    @pytest.mark.asyncio
    async def test_each_segment_gets_correct_text(self, streaming_service, mock_ws):
        """每个分段都调用了合成，且 text 参数不同."""
        with patch.object(
            streaming_service,
            "_download_audio",
            return_value=FakeAudioBytes.make(500),
        ):
            text = "你" * 1200
            await streaming_service._stream_long_text(mock_ws, {"text": text, "voice_id": "v1"})

        # 分段数应大于1
        assert streaming_service._cosyvoice.submit_synthesize_task.call_count >= 2

        # 收集所有传进去的 text
        texts_called = [
            c.kwargs.get("text") or c.args[0]
            for c in streaming_service._cosyvoice.submit_synthesize_task.call_args_list
        ]
        # 每段文本都应该是原文的一部分（不全部相同）
        assert len(set(texts_called)) >= 2
        # 所有文本拼接起来应该约等于原文长度
        total_len = sum(len(t) for t in texts_called)
        assert total_len >= len(text) * 0.95  # 允许标点切分的小误差

    @pytest.mark.asyncio
    async def test_each_segment_has_unique_index(self, streaming_service, mock_ws):
        """segment_done 消息的序号不重复且正确."""
        with patch.object(
            streaming_service,
            "_download_audio",
            return_value=FakeAudioBytes.make(500),
        ):
            text = "你" * 1200
            await streaming_service._stream_long_text(mock_ws, {"text": text, "voice_id": "v1"})

        calls = mock_ws.send_json.call_args_list
        segment_dones = [c[0][0] for c in calls if c[0][0].get("type") == "segment_done"]
        indices = [s["segment"] for s in segment_dones]
        total = segment_dones[0]["total"]
        # 序号从 1 到 total，不重复
        assert sorted(indices) == list(range(1, total + 1))


# ── WebSocket 发送失败容错 ────────────────────────────────────────────────


class TestSendJsonErrorHandling:
    """_send_json 容错测试."""

    @pytest.mark.asyncio
    async def test_send_json_failure_logs_warning(self, streaming_service, mock_ws):
        """WebSocket send_json 失败不抛异常."""
        mock_ws.send_json.side_effect = Exception("connection closed")

        # 不应抛出异常
        await streaming_service._send_json(mock_ws, {"type": "done"})
        mock_ws.send_json.assert_called_once()


# ── TTSStreamingError 异常类 ──────────────────────────────────────────────


class TestTTSStreamingError:
    """TTSStreamingError 异常类测试."""

    def test_is_exception(self):
        """是 Exception 子类."""
        assert issubclass(TTSStreamingError, Exception)

    def test_carry_message(self):
        """携带错误消息."""
        err = TTSStreamingError("stream failed")
        assert str(err) == "stream failed"
