"""CosyVoiceService 单元测试."""

from __future__ import annotations

import time
from unittest.mock import MagicMock, patch

import httpx
import pytest

from packages.application.cosyvoice_service import (
    CloneResult,
    CosyVoiceAuthError,
    CosyVoiceError,
    CosyVoiceService,
    CosyVoiceTimeoutError,
    SynthesizeResult,
)


@pytest.fixture
def mock_client() -> MagicMock:
    """Mock HTTP client."""
    return MagicMock(spec=httpx.Client)


@pytest.fixture
def service(mock_client: MagicMock) -> CosyVoiceService:
    """Create CosyVoiceService with mocked HTTP client and config."""
    with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
        settings = MagicMock()
        settings.cosyvoice_api_key = "sk-test-12345678"
        settings.cosyvoice_base_url = "https://dashscope.aliyuncs.com/api/v1"
        settings.cosyvoice_model = "cosyvoice-v3-flash"
        settings.cosyvoice_sample_rate = 22050
        settings.cosyvoice_format = "mp3"
        settings.cosyvoice_voice = "longxiaochun_v3"
        settings.cosyvoice_clone_model = "voice-enrollment"
        mock_settings.return_value = settings
        svc = CosyVoiceService(http_client=mock_client)
        svc.CLONE_POLL_INTERVAL = 0.001  # 加速测试
        svc.RETRY_BACKOFF = 0.001
        yield svc


class TestInitConfig:
    """初始化配置测试."""

    def test_base_url_with_old_text2audio_path_gets_normalized(self, mock_client: MagicMock) -> None:
        """旧版 base_url 带 text2audio 路径应自动修正."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()
            settings.cosyvoice_api_key = "sk-test"
            settings.cosyvoice_base_url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio"
            settings.cosyvoice_model = "cosyvoice-v3-flash"
            settings.cosyvoice_sample_rate = 22050
            settings.cosyvoice_format = "mp3"
            settings.cosyvoice_voice = "test"
            settings.cosyvoice_clone_model = "voice-enrollment"
            mock_settings.return_value = settings
            svc = CosyVoiceService(http_client=mock_client)
            assert svc._base_url == "https://dashscope.aliyuncs.com/api/v1"

    def test_custom_params_override_config(self, mock_client: MagicMock) -> None:
        """显式传入参数覆盖配置."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()
            settings.cosyvoice_api_key = "sk-config"
            settings.cosyvoice_base_url = "https://config.example.com"
            settings.cosyvoice_model = "config-model"
            settings.cosyvoice_sample_rate = 22050
            settings.cosyvoice_format = "mp3"
            settings.cosyvoice_voice = "test"
            settings.cosyvoice_clone_model = "voice-enrollment"
            mock_settings.return_value = settings
            svc = CosyVoiceService(
                api_key="sk-custom",
                base_url="https://custom.example.com/api/v1",
                model="custom-model",
                clone_model="custom-clone",
                http_client=mock_client,
            )
            assert svc._api_key == "sk-custom"
            assert svc._base_url == "https://custom.example.com/api/v1"
            assert svc._model == "custom-model"
            assert svc._clone_model == "custom-clone"

    def test_context_manager(self, mock_client: MagicMock) -> None:
        """上下文管理器正常工作."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()

            settings.cosyvoice_api_key = "sk-test"

            settings.cosyvoice_base_url = "https://example.com/api/v1"

            settings.cosyvoice_model = "test"

            settings.cosyvoice_sample_rate = 22050

            settings.cosyvoice_format = "mp3"

            settings.cosyvoice_voice = "test"

            settings.cosyvoice_clone_model = "voice-enrollment"

            mock_settings.return_value = settings
            svc = CosyVoiceService(http_client=mock_client)
            with svc as s:
                assert s is svc
            # 注入的client不会被关闭
            mock_client.close.assert_not_called()

    def test_owns_client_gets_closed(self) -> None:
        """自有client在close时被关闭."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()

            settings.cosyvoice_api_key = "sk-test"

            settings.cosyvoice_base_url = "https://example.com/api/v1"

            settings.cosyvoice_model = "test"

            settings.cosyvoice_sample_rate = 22050

            settings.cosyvoice_format = "mp3"

            settings.cosyvoice_voice = "test"

            settings.cosyvoice_clone_model = "voice-enrollment"

            mock_settings.return_value = settings
            with patch("packages.application.cosyvoice_service.httpx.Client") as mock_cls:
                mock_instance = MagicMock()
                mock_cls.return_value = mock_instance
                svc = CosyVoiceService()
                svc.close()
                mock_instance.close.assert_called_once()


class TestSanitizePrefix:
    """_sanitize_prefix 清洗音色名称测试."""

    def test_keeps_alphanumeric(self, service: CosyVoiceService) -> None:
        """保留字母数字."""
        assert service._sanitize_prefix("hello123") == "hello123"

    def test_strips_special_chars(self, service: CosyVoiceService) -> None:
        """去除特殊字符（中文isalnum返回True会保留）."""
        assert service._sanitize_prefix("hello-world!@#") == "helloworld"

    def test_truncates_to_10_chars(self, service: CosyVoiceService) -> None:
        """超长截断到10字符."""
        assert service._sanitize_prefix("abcdefghijklmnop") == "abcdefghij"

    def test_empty_after_cleaning_uses_default(self, service: CosyVoiceService) -> None:
        """清洗后为空用默认值clone."""
        assert service._sanitize_prefix("!!!@@@###") == "clone"

    def test_empty_string(self, service: CosyVoiceService) -> None:
        """空字符串用默认值."""
        assert service._sanitize_prefix("") == "clone"


class TestSubmitCloneTask:
    """submit_clone_task 提交音色克隆测试."""

    def test_empty_audio_url_raises(self, service: CosyVoiceService) -> None:
        """空audio_url抛ValueError."""
        with pytest.raises(ValueError, match="audio_url 不能为空"):
            service.submit_clone_task(audio_url="")

    def test_no_api_key_raises_auth_error(self, mock_client: MagicMock) -> None:
        """无API Key抛认证错误."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()

            settings.cosyvoice_api_key = ""

            settings.cosyvoice_base_url = "https://example.com/api/v1"

            settings.cosyvoice_model = "test"

            settings.cosyvoice_sample_rate = 22050

            settings.cosyvoice_format = "mp3"

            settings.cosyvoice_voice = "test"

            settings.cosyvoice_clone_model = "voice-enrollment"

            mock_settings.return_value = settings
            svc = CosyVoiceService(http_client=mock_client)
            with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"):
                svc.submit_clone_task(audio_url="https://example.com/audio.mp3")

    def test_success_returns_voice_id(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """成功返回voice_id和状态."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"voice_id": "voice_123", "status": "DEPLOYING"},
            "request_id": "req_456",
        }
        mock_response.text = '{"output": {...}}'
        mock_client.request.return_value = mock_response

        result = service.submit_clone_task(
            audio_url="https://example.com/audio.mp3",
            voice_name="myvoice",
        )

        assert result["voice_id"] == "voice_123"
        assert result["status"] == "DEPLOYING"
        assert result["request_id"] == "req_456"

    def test_voice_name_gets_sanitized(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """音色名称被清洗后作为prefix（中文isalnum为True会保留）."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"voice_id": "v1", "status": "DEPLOYING"},
            "request_id": "r1",
        }
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        service.submit_clone_task(
            audio_url="https://example.com/a.mp3",
            voice_name="my-voice!",
        )

        call_args = mock_client.request.call_args
        payload = call_args.kwargs["json"]
        # 去掉横杠和感叹号，只剩myvoice
        assert payload["input"]["prefix"] == "myvoice"

    def test_language_zh_cn_converts_to_zh(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """zh-CN转换为zh."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"voice_id": "v1", "status": "DEPLOYING"},
            "request_id": "r1",
        }
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        service.submit_clone_task(
            audio_url="https://example.com/a.mp3",
            language="zh-CN",
        )

        call_args = mock_client.request.call_args
        payload = call_args.kwargs["json"]
        assert payload["input"]["language_hints"] == ["zh"]

    def test_audio_url_signer_is_called(self, mock_client: MagicMock) -> None:
        """配置了audio_url_signer时会被调用预签名."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()

            settings.cosyvoice_api_key = "sk-test"

            settings.cosyvoice_base_url = "https://example.com/api/v1"

            settings.cosyvoice_model = "test"

            settings.cosyvoice_sample_rate = 22050

            settings.cosyvoice_format = "mp3"

            settings.cosyvoice_voice = "test"

            settings.cosyvoice_clone_model = "voice-enrollment"

            mock_settings.return_value = settings
            signer = MagicMock(return_value="https://signed.example.com/audio.mp3?token=xxx")
            svc = CosyVoiceService(http_client=mock_client, audio_url_signer=signer)

            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.json.return_value = {
                "output": {"voice_id": "v1", "status": "DEPLOYING"},
                "request_id": "r1",
            }
            mock_response.text = ""
            mock_client.request.return_value = mock_response

            svc.submit_clone_task(audio_url="https://original.example.com/a.mp3")

            signer.assert_called_once_with("https://original.example.com/a.mp3")
            call_args = mock_client.request.call_args
            payload = call_args.kwargs["json"]
            assert payload["input"]["url"] == "https://signed.example.com/audio.mp3?token=xxx"

    def test_signer_failure_falls_back_to_original_url(self, mock_client: MagicMock) -> None:
        """预签名失败时回退到原始URL."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()

            settings.cosyvoice_api_key = "sk-test"

            settings.cosyvoice_base_url = "https://example.com/api/v1"

            settings.cosyvoice_model = "test"

            settings.cosyvoice_sample_rate = 22050

            settings.cosyvoice_format = "mp3"

            settings.cosyvoice_voice = "test"

            settings.cosyvoice_clone_model = "voice-enrollment"

            mock_settings.return_value = settings
            signer = MagicMock(side_effect=RuntimeError("sign failed"))
            svc = CosyVoiceService(http_client=mock_client, audio_url_signer=signer)

            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.json.return_value = {
                "output": {"voice_id": "v1", "status": "DEPLOYING"},
                "request_id": "r1",
            }
            mock_response.text = ""
            mock_client.request.return_value = mock_response

            svc.submit_clone_task(audio_url="https://original.example.com/a.mp3")

            call_args = mock_client.request.call_args
            payload = call_args.kwargs["json"]
            assert payload["input"]["url"] == "https://original.example.com/a.mp3"

    def test_no_voice_id_in_response_raises(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """API返回无voice_id时抛错."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"output": {}, "request_id": "r1"}
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        with pytest.raises(CosyVoiceError, match="未返回 voice_id"):
            service.submit_clone_task(audio_url="https://example.com/a.mp3")

    def test_custom_target_model(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """自定义目标模型被传递."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"voice_id": "v1", "status": "DEPLOYING"},
            "request_id": "r1",
        }
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        service.submit_clone_task(
            audio_url="https://example.com/a.mp3",
            target_model="cosyvoice-v3.5",
        )

        call_args = mock_client.request.call_args
        payload = call_args.kwargs["json"]
        assert payload["input"]["target_model"] == "cosyvoice-v3.5"


class TestQueryVoiceStatus:
    """query_voice_status 查询音色状态测试."""

    def test_empty_voice_id_raises(self, service: CosyVoiceService) -> None:
        """空voice_id抛ValueError."""
        with pytest.raises(ValueError, match="voice_id 不能为空"):
            service.query_voice_status("")

    def test_no_api_key_raises(self, mock_client: MagicMock) -> None:
        """无API Key抛认证错误."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()

            settings.cosyvoice_api_key = ""

            settings.cosyvoice_base_url = "https://example.com/api/v1"

            settings.cosyvoice_model = "test"

            settings.cosyvoice_sample_rate = 22050

            settings.cosyvoice_format = "mp3"

            settings.cosyvoice_voice = "test"

            settings.cosyvoice_clone_model = "voice-enrollment"

            mock_settings.return_value = settings
            svc = CosyVoiceService(http_client=mock_client)
            with pytest.raises(CosyVoiceAuthError):
                svc.query_voice_status("v1")

    def test_success_returns_status(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """成功返回状态信息."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {
                "status": "OK",
                "target_model": "cosyvoice-v3-flash",
                "gmt_create": "2024-01-01 00:00:00",
                "gmt_modified": "2024-01-01 00:01:00",
                "resource_link": "https://example.com/voice",
            },
            "request_id": "r1",
        }
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        result = service.query_voice_status("voice_123")

        assert result["status"] == "OK"
        assert result["target_model"] == "cosyvoice-v3-flash"
        assert result["gmt_create"] == "2024-01-01 00:00:00"
        assert result["resource_link"] == "https://example.com/voice"


class TestCheckTaskStatus:
    """check_task_status 兼容接口测试."""

    def test_delegates_to_query_voice_status(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """委托给query_voice_status并返回兼容格式."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"status": "DEPLOYING", "target_model": "m1"},
            "request_id": "r1",
        }
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        result = service.check_task_status("voice_456")

        assert result["status"] == "DEPLOYING"
        assert result["voice_id"] == "voice_456"
        assert result["message"] == ""


class TestPollCloneTask:
    """poll_clone_task 轮询克隆状态测试."""

    def test_success_after_several_attempts(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """几次DEPLOYING后变OK."""
        responses = []
        for _ in range(3):
            r = MagicMock()
            r.status_code = 200
            r.json.return_value = {"output": {"status": "DEPLOYING"}, "request_id": "r"}
            r.text = ""
            responses.append(r)
        ok_r = MagicMock()
        ok_r.status_code = 200
        ok_r.json.return_value = {"output": {"status": "OK"}, "request_id": "r"}
        ok_r.text = ""
        responses.append(ok_r)

        mock_client.request.side_effect = responses

        result = service.poll_clone_task("v1", timeout=30.0)
        assert result["voice_id"] == "v1"
        assert mock_client.request.call_count == 4

    def test_undeployed_raises_error(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """状态为UNDEPLOYED时抛错."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"output": {"status": "UNDEPLOYED"}, "request_id": "r"}
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        with pytest.raises(CosyVoiceError, match="审核未通过"):
            service.poll_clone_task("v1", timeout=30.0)

    def test_timeout_raises(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """超时抛CosyVoiceTimeoutError."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"output": {"status": "DEPLOYING"}, "request_id": "r"}
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        with pytest.raises(CosyVoiceTimeoutError):
            service.poll_clone_task("v1", timeout=0.001)

    def test_max_attempts_exceeded_raises_timeout(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """超过最大轮询次数抛超时."""
        service.CLONE_MAX_POLL_ATTEMPTS = 3
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"output": {"status": "DEPLOYING"}, "request_id": "r"}
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        with pytest.raises(CosyVoiceTimeoutError, match="轮询次数超限"):
            service.poll_clone_task("v1", timeout=300.0)
        assert mock_client.request.call_count == 3

    def test_unknown_status_continues_polling(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """未知状态继续轮询."""
        unknown_r = MagicMock()
        unknown_r.status_code = 200
        unknown_r.json.return_value = {"output": {"status": "WEIRD_STATE"}, "request_id": "r"}
        unknown_r.text = ""
        ok_r = MagicMock()
        ok_r.status_code = 200
        ok_r.json.return_value = {"output": {"status": "OK"}, "request_id": "r"}
        ok_r.text = ""
        mock_client.request.side_effect = [unknown_r, ok_r]

        result = service.poll_clone_task("v1", timeout=30.0)
        assert result["voice_id"] == "v1"
        assert mock_client.request.call_count == 2


class TestCloneVoice:
    """clone_voice 完整克隆流程测试."""

    def test_full_flow_submit_and_poll(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """提交+轮询完整流程."""
        # 第一次：提交克隆
        submit_r = MagicMock()
        submit_r.status_code = 200
        submit_r.json.return_value = {
            "output": {"voice_id": "v_new", "status": "DEPLOYING"},
            "request_id": "req_submit",
        }
        submit_r.text = ""
        # 第二次：轮询DEPLOYING
        poll_r1 = MagicMock()
        poll_r1.status_code = 200
        poll_r1.json.return_value = {"output": {"status": "DEPLOYING"}, "request_id": "req_p1"}
        poll_r1.text = ""
        # 第三次：轮询OK
        poll_r2 = MagicMock()
        poll_r2.status_code = 200
        poll_r2.json.return_value = {"output": {"status": "OK"}, "request_id": "req_p2"}
        poll_r2.text = ""

        mock_client.request.side_effect = [submit_r, poll_r1, poll_r2]

        result = service.clone_voice(
            audio_url="https://example.com/a.mp3",
            voice_name="test",
            timeout=30.0,
        )

        assert isinstance(result, CloneResult)
        assert result.voice_id == "v_new"
        assert result.request_id == "req_submit"
        assert mock_client.request.call_count == 3

    def test_already_ok_on_submit_returns_immediately(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """提交时已OK直接返回，不轮询."""
        submit_r = MagicMock()
        submit_r.status_code = 200
        submit_r.json.return_value = {
            "output": {"voice_id": "v_ok", "status": "OK"},
            "request_id": "req_ok",
        }
        submit_r.text = ""
        mock_client.request.return_value = submit_r

        result = service.clone_voice(audio_url="https://example.com/a.mp3", timeout=30.0)

        assert result.voice_id == "v_ok"
        # 只调用了一次（提交），没有轮询
        assert mock_client.request.call_count == 1


class TestSubmitSynthesizeTask:
    """submit_synthesize_task 提交语音合成测试."""

    def test_empty_text_raises(self, service: CosyVoiceService) -> None:
        """空text抛ValueError."""
        with pytest.raises(ValueError, match="text 不能为空"):
            service.submit_synthesize_task(text="", voice_id="v1")

    def test_empty_voice_id_raises(self, service: CosyVoiceService) -> None:
        """空voice_id抛ValueError."""
        with pytest.raises(ValueError, match="voice_id 不能为空"):
            service.submit_synthesize_task(text="你好", voice_id="")

    def test_no_api_key_raises(self, mock_client: MagicMock) -> None:
        """无API Key抛认证错误."""
        with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings:
            settings = MagicMock()

            settings.cosyvoice_api_key = ""

            settings.cosyvoice_base_url = "https://example.com/api/v1"

            settings.cosyvoice_model = "test"

            settings.cosyvoice_sample_rate = 22050

            settings.cosyvoice_format = "mp3"

            settings.cosyvoice_voice = "test"

            settings.cosyvoice_clone_model = "voice-enrollment"

            mock_settings.return_value = settings
            svc = CosyVoiceService(http_client=mock_client)
            with pytest.raises(CosyVoiceAuthError):
                svc.submit_synthesize_task(text="你好", voice_id="v1")

    def test_success_returns_audio_url(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """成功返回audio_url."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"audio": {"url": "https://example.com/out.mp3"}},
            "request_id": "req_tts",
        }
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        result = service.submit_synthesize_task(text="你好世界", voice_id="voice_001")

        assert result["audio_url"] == "https://example.com/out.mp3"
        assert result["request_id"] == "req_tts"
        assert result["task_id"] == ""  # 同步接口无task_id

    def test_no_audio_url_raises(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """返回无audio_url时抛错."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"output": {"audio": {}}, "request_id": "r1"}
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        with pytest.raises(CosyVoiceError, match="未返回 audio_url"):
            service.submit_synthesize_task(text="你好", voice_id="v1")

    def test_custom_params_passed_through(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """自定义参数被传递."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"audio": {"url": "https://example.com/out.wav"}},
            "request_id": "r1",
        }
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        service.submit_synthesize_task(
            text="你好",
            voice_id="v1",
            sample_rate=44100,
            format="wav",
            speed=1.5,
            volume=80,
        )

        call_args = mock_client.request.call_args
        payload = call_args.kwargs["json"]
        assert payload["input"]["voice"] == "v1"
        assert payload["input"]["sample_rate"] == 44100
        assert payload["input"]["format"] == "wav"
        assert payload["input"]["rate"] == 1.5
        assert payload["input"]["volume"] == 80


class TestPollSynthesizeTask:
    """poll_synthesize_task 测试."""

    def test_raises_error_synchronous_no_poll(self, service: CosyVoiceService) -> None:
        """同步接口不支持轮询，抛错."""
        with pytest.raises(CosyVoiceError, match="无需轮询"):
            service.poll_synthesize_task("task_123")


class TestSynthesizeSpeech:
    """synthesize_speech 语音合成测试."""

    def test_returns_synthesize_result(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """返回SynthesizeResult对象."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"audio": {"url": "https://example.com/out.mp3"}},
            "request_id": "req_tts",
        }
        mock_response.text = ""
        mock_client.request.return_value = mock_response

        result = service.synthesize_speech(text="你好", voice_id="v1")

        assert isinstance(result, SynthesizeResult)
        assert result.audio_url == "https://example.com/out.mp3"
        assert result.request_id == "req_tts"


class TestCallApiRetry:
    """_call_api 重试逻辑测试."""

    def test_500_retries_and_succeeds(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """500错误重试后成功."""
        err_r = MagicMock()
        err_r.status_code = 500
        err_r.text = "Internal Server Error"
        ok_r = MagicMock()
        ok_r.status_code = 200
        ok_r.json.return_value = {"output": {}, "request_id": "r"}
        ok_r.text = "{}"
        mock_client.request.side_effect = [err_r, ok_r]

        result = service._call_api("POST", "/test", json={"k": "v"})
        assert result["request_id"] == "r"
        assert mock_client.request.call_count == 2

    def test_500_all_retries_exhausted(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """500重试耗尽抛错."""
        err_r = MagicMock()
        err_r.status_code = 500
        err_r.text = "Internal Server Error"
        mock_client.request.return_value = err_r

        with pytest.raises(CosyVoiceError, match="服务端错误"):
            service._call_api("POST", "/test")
        assert mock_client.request.call_count == service.MAX_RETRIES

    def test_401_raises_auth_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """401直接抛认证错误，不重试."""
        err_r = MagicMock()
        err_r.status_code = 401
        err_r.text = "Unauthorized"
        mock_client.request.return_value = err_r

        with pytest.raises(CosyVoiceAuthError):
            service._call_api("POST", "/test")
        assert mock_client.request.call_count == 1

    def test_403_raises_auth_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """403直接抛认证错误，不重试."""
        err_r = MagicMock()
        err_r.status_code = 403
        err_r.text = "Forbidden"
        mock_client.request.return_value = err_r

        with pytest.raises(CosyVoiceAuthError):
            service._call_api("POST", "/test")
        assert mock_client.request.call_count == 1

    def test_400_with_json_body_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """400带JSON错误体不重试."""
        err_r = MagicMock()
        err_r.status_code = 400
        err_r.json.return_value = {"code": "InvalidParameter", "message": "text too long"}
        err_r.text = '{"code": "InvalidParameter", "message": "text too long"}'
        mock_client.request.return_value = err_r

        with pytest.raises(CosyVoiceError, match="参数错误") as exc_info:
            service._call_api("POST", "/test")
        assert "InvalidParameter" in str(exc_info.value)
        assert mock_client.request.call_count == 1

    def test_400_with_plain_text_body_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """400纯文本错误体不重试."""
        err_r = MagicMock()
        err_r.status_code = 400
        err_r.json.side_effect = ValueError("not json")
        err_r.text = "Bad Request: malformed input"
        mock_client.request.return_value = err_r

        with pytest.raises(CosyVoiceError, match="调用失败"):
            service._call_api("POST", "/test")
        assert mock_client.request.call_count == 1

    def test_timeout_retries(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """超时错误重试."""
        ok_r = MagicMock()
        ok_r.status_code = 200
        ok_r.json.return_value = {"output": {}, "request_id": "r"}
        ok_r.text = "{}"
        mock_client.request.side_effect = [httpx.TimeoutException("timeout"), ok_r]

        result = service._call_api("POST", "/test")
        assert result["request_id"] == "r"
        assert mock_client.request.call_count == 2

    def test_request_error_retries(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """请求错误重试."""
        ok_r = MagicMock()
        ok_r.status_code = 200
        ok_r.json.return_value = {"output": {}, "request_id": "r"}
        ok_r.text = "{}"
        mock_client.request.side_effect = [httpx.ConnectError("conn err"), ok_r]

        result = service._call_api("POST", "/test")
        assert result["request_id"] == "r"
        assert mock_client.request.call_count == 2

    def test_other_status_code_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """其他状态码不重试."""
        err_r = MagicMock()
        err_r.status_code = 418
        err_r.text = "I'm a teapot"
        mock_client.request.return_value = err_r

        with pytest.raises(CosyVoiceError, match="HTTP 418"):
            service._call_api("POST", "/test")
        assert mock_client.request.call_count == 1

    def test_bearer_token_in_headers(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """Authorization header正确设置."""
        ok_r = MagicMock()
        ok_r.status_code = 200
        ok_r.json.return_value = {"output": {}, "request_id": "r"}
        ok_r.text = "{}"
        mock_client.request.return_value = ok_r

        service._call_api("POST", "/test")

        call_args = mock_client.request.call_args
        headers = call_args.kwargs["headers"]
        assert headers["Authorization"] == "Bearer sk-test-12345678"
        assert headers["Content-Type"] == "application/json"

    def test_url_built_correctly(self, service: CosyVoiceService, mock_client: MagicMock) -> None:
        """URL正确拼接."""
        ok_r = MagicMock()
        ok_r.status_code = 200
        ok_r.json.return_value = {"output": {}, "request_id": "r"}
        ok_r.text = "{}"
        mock_client.request.return_value = ok_r

        service._call_api("POST", "/services/test")

        call_args = mock_client.request.call_args
        assert call_args.kwargs["url"] == "https://dashscope.aliyuncs.com/api/v1/services/test"


class TestListPresetVoices:
    """list_preset_voices 预置音色测试."""

    def test_returns_preset_voices(self, service: CosyVoiceService) -> None:
        """返回预置音色列表."""
        with patch("packages.application.cosyvoice_service.get_preset_voices") as mock_get:
            mock_get.return_value = [MagicMock(), MagicMock()]
            result = service.list_preset_voices()
            assert len(result) == 2
            mock_get.assert_called_once()


class TestErrorClasses:
    """错误类继承关系测试."""

    def test_auth_error_inherits_from_cosyvoice_error(self) -> None:
        assert issubclass(CosyVoiceAuthError, CosyVoiceError)

    def test_timeout_error_inherits_from_cosyvoice_error(self) -> None:
        assert issubclass(CosyVoiceTimeoutError, CosyVoiceError)
