"""
声音克隆 API 集成测试。

覆盖端点：
- POST   /voice-clones — 创建声音克隆
- GET    /voice-clones — 列出声音克隆
- GET    /voice-clones/{clone_id} — 获取克隆详情
- GET    /voice-clones/{clone_id}/status — 获取克隆状态
- POST   /voice-clones/{clone_id}/retry — 重试克隆
- DELETE /voice-clones/{clone_id} — 删除克隆

使用 FastAPI TestClient + dependency_overrides 模式，
mock repository 和 CosyVoice 服务。
"""

from __future__ import annotations

import os
import sys
from datetime import datetime, timezone

# ── 环境变量 & sys.path（必须在导入 app.* 之前设置） ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))

from unittest.mock import MagicMock

from app.api.routes.voice_clones import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
    get_asset_repository,
    get_audio_url_signer,
    get_cosyvoice_service,
    get_project_repository,
    get_voice_clone_profile_repository,
)

from packages.domain.entities import Asset, AssetStatus, Project, User
from packages.domain.voice_clone_profile import (
    VoiceCloneProfile,
    VoiceCloneStatus,
)

# ---------------------------------------------------------------------------
# 1. 内存 Repository
# ---------------------------------------------------------------------------


class InMemoryVoiceCloneProfileRepository:
    """内存中的音色克隆档案 Repository。"""

    def __init__(self):
        self._items: dict[str, VoiceCloneProfile] = {}

    def create(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
        self._items[profile.id] = profile
        return profile

    def get(self, profile_id: str) -> VoiceCloneProfile | None:
        return self._items.get(profile_id)

    def update(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
        self._items[profile.id] = profile
        return profile

    def delete(self, profile_id: str) -> bool:
        if profile_id in self._items:
            del self._items[profile_id]
            return True
        return False

    def list_by_user(
        self,
        user_id: str,
        *,
        status=None,
        limit: int = 50,
        offset: int = 0,
    ) -> list[VoiceCloneProfile]:
        items = [p for p in self._items.values() if p.user_id == user_id]
        if status:
            status_str = status.value if hasattr(status, "value") else str(status)
            items = [p for p in items if p.status.value == status_str]
        # 按 created_at 倒序
        items.sort(key=lambda p: p.created_at, reverse=True)
        return items[offset : offset + limit]

    def count_by_user(
        self,
        user_id: str,
        *,
        status=None,
    ) -> int:
        items = [p for p in self._items.values() if p.user_id == user_id]
        if status:
            status_str = status.value if hasattr(status, "value") else str(status)
            items = [p for p in items if p.status.value == status_str]
        return len(items)

    def find_by_voice_id(self, voice_id: str) -> VoiceCloneProfile | None:
        for p in self._items.values():
            if p.voice_id == voice_id:
                return p
        return None

    def find_profile_ids_by_voice_ids(self, voice_ids: list[str]) -> dict[str, str]:
        result = {}
        for p in self._items.values():
            if p.voice_id in voice_ids:
                result[p.voice_id] = p.id
        return result


# ---------------------------------------------------------------------------
# 2. Mock CosyVoice 服务
# ---------------------------------------------------------------------------


class MockCosyVoiceService:
    """Mock CosyVoice 服务，模拟克隆任务提交和状态查询。"""

    def __init__(self, *, fail_submit: bool = False, async_mode: bool = True):
        self.fail_submit = fail_submit
        self.async_mode = async_mode
        self.submit_called = False
        self.submit_args = None

    def submit_clone_task(self, *, audio_url: str, voice_name: str, language: str = "zh-CN") -> dict:
        self.submit_called = True
        self.submit_args = {"audio_url": audio_url, "voice_name": voice_name, "language": language}

        if self.fail_submit:
            from packages.application.cosyvoice_service import CosyVoiceError

            raise CosyVoiceError("模拟 CosyVoice 提交失败")

        if self.async_mode:
            # 异步模式：返回 task_id，需要轮询
            return {"task_id": "mock-task-123", "request_id": "req-456", "status": "processing"}
        else:
            # 同步模式：直接返回 voice_id
            return {"voice_id": "mock-voice-789", "status": "success"}

    def check_task_status(self, task_id: str) -> dict:
        return {"status": "completed", "voice_id": "mock-voice-789"}

    def list_preset_voices(self) -> list:
        return []

    def submit_synthesize_task(self, **kwargs) -> dict:
        return {"task_id": "synth-1", "status": "processing"}

    def synthesize_speech(self, **kwargs) -> dict:
        return {"audio_url": "https://example.com/audio.mp3", "duration": 5.0}

    def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
        return {
            "status": "completed",
            "audio_url": "https://example.com/audio.mp3",
            "duration": 5.0,
        }


# ---------------------------------------------------------------------------
# 3. 辅助函数
# ---------------------------------------------------------------------------


def _make_user(**overrides) -> User:
    defaults = dict(
        id="user-test-001",
        email="test@example.com",
        display_name="Test User",
        username="testuser",
        subscription_plan="free",
        subscription_status="active",
        max_projects=3,
        max_storage_gb=10,
        created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
    )
    defaults.update(overrides)
    return User(**defaults)


def _make_clone_profile(
    name: str = "我的音色",
    user_id: str = "user-test-001",
    status: VoiceCloneStatus = VoiceCloneStatus.PENDING,
    source_audio_url: str = "https://example.com/source.wav",
    **kwargs,
) -> VoiceCloneProfile:
    profile = VoiceCloneProfile.create(
        user_id=user_id,
        name=name,
        source_audio_url=source_audio_url,
        voice_model=kwargs.get("voice_model", "cosyvoice-v2"),
        language=kwargs.get("language", "zh-CN"),
        gender=kwargs.get("gender", "female"),
        max_retries=kwargs.get("max_retries", 3),
        metadata=kwargs.get("metadata", None),
        description=kwargs.get("description", ""),
    )
    # 设置状态
    if status == VoiceCloneStatus.PROCESSING:
        profile.mark_processing()
        profile.metadata = {"cosyvoice_task_id": "task-123"}
    elif status == VoiceCloneStatus.READY:
        profile.mark_processing()
        profile.mark_ready("voice-ready-001")
    elif status == VoiceCloneStatus.FAILED:
        profile.mark_processing()
        profile.mark_failed("模拟失败")
    elif status == VoiceCloneStatus.DISABLED:
        profile.mark_disabled()
    return profile


# ---------------------------------------------------------------------------
# 4. Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def clone_repo():
    return InMemoryVoiceCloneProfileRepository()


@pytest.fixture
def cosyvoice_service():
    return MockCosyVoiceService(async_mode=True)  # 异步模式，匹配真实 CosyVoice API 行为


def _make_voice_asset(
    *,
    asset_id: str = "asset-voice-1",
    project_id: str = "proj-1",
    user_id: str = "user-test-001",
    file_type: str = "audio",
    storage_key: str = "uploads/voice/sample.m4a",
) -> Asset:
    """构造一个音频素材（可改 file_type 模拟非音频）。"""
    mime = {"audio": "audio/mp4", "video": "video/mp4", "image": "image/jpeg"}[file_type]
    return Asset(
        id=asset_id,
        project_id=project_id,
        library_id="lib-1",
        name="配音素材.m4a",
        storage_key=storage_key,
        mime_type=mime,
        file_size=12345,
        duration=10.0,
        status=AssetStatus.READY,
        uploaded_by_user_id=user_id,
    )


class InMemoryAssetRepository:
    """最小内存 Asset 仓储（find_by_id 即可）。"""

    def __init__(self, assets: list[Asset] | None = None):
        self._items = {a.id: a for a in (assets or [])}

    def find_by_id(self, asset_id: str):
        return self._items.get(asset_id)


class InMemoryProjectRepository:
    """最小内存 Project 仓储（find_by_id 即可）。"""

    def __init__(self, projects: list[Project] | None = None):
        self._items = {p.id: p for p in (projects or [])}

    def find_by_id(self, project_id: str):
        return self._items.get(project_id)


class MockStorageService:
    """Mock 存储服务：get_download_url 返回固定签名 URL。"""

    def __init__(self):
        self.signed_keys: list[str] = []

    def get_download_url(self, storage_key: str, expires_seconds: int = 3600) -> str:
        self.signed_keys.append(storage_key)
        return f"https://oss.example.com/{storage_key}?signed=1&exp={expires_seconds}"


@pytest.fixture
def asset_repo():
    return InMemoryAssetRepository()


@pytest.fixture
def project_repo():
    return InMemoryProjectRepository()


@pytest.fixture
def storage_service():
    return MockStorageService()


@pytest.fixture
def client(clone_repo, cosyvoice_service, asset_repo, project_repo, storage_service):
    """创建带有依赖覆盖的 TestClient。"""
    test_app = FastAPI()
    test_app.include_router(router, prefix="/voice-clones")

    def _override_current_user():
        return AuthenticatedUser(user=_make_user())

    test_app.dependency_overrides[get_current_user] = _override_current_user
    test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: clone_repo
    test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
    test_app.dependency_overrides[get_audio_url_signer] = lambda: (lambda url: url)
    test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
    test_app.dependency_overrides[get_project_repository] = lambda: project_repo
    test_app.dependency_overrides[get_storage_service] = lambda: storage_service

    yield TestClient(test_app)

    test_app.dependency_overrides.clear()


# ---------------------------------------------------------------------------
# 5. POST / — 创建声音克隆
# ---------------------------------------------------------------------------


class TestCreateVoiceClone:
    """创建声音克隆端点测试。"""

    def test_create_with_source_audio(self, client, cosyvoice_service):
        """提供源音频时创建克隆，异步提交后状态为 processing。"""
        resp = client.post(
            "/voice-clones",
            json={
                "name": "我的专属音色",
                "source_audio_url": "https://example.com/voice.wav",
                "voice_model": "cosyvoice-v2",
                "language": "zh-CN",
                "gender": "female",
            },
        )
        assert resp.status_code == 201
        data = resp.json()
        assert data["name"] == "我的专属音色"
        assert data["source_audio_url"] == "https://example.com/voice.wav"
        assert data["voice_model"] == "cosyvoice-v2"
        assert data["language"] == "zh-CN"
        assert data["gender"] == "female"
        assert "id" in data
        assert len(data["id"]) > 0

        # 异步模式下提交后状态为 processing，voice_id 为空
        assert data["status"] == "processing"
        assert data["voice_id"] == ""
        assert data["error_message"] == ""

    def test_create_without_source_audio(self, client):
        """不提供源音频时创建，状态为 pending。"""
        resp = client.post(
            "/voice-clones",
            json={
                "name": "待上传音色",
                "description": "等待上传音频",
            },
        )
        assert resp.status_code == 201
        data = resp.json()
        assert data["name"] == "待上传音色"
        assert data["status"] == "pending"
        assert data["source_audio_url"] == ""
        assert data["voice_id"] == ""

    def test_create_persists_to_repository(self, client, clone_repo):
        """创建后档案保存到 repository。"""
        resp = client.post("/voice-clones", json={"name": "持久化测试"})
        profile_id = resp.json()["id"]

        saved = clone_repo.get(profile_id)
        assert saved is not None
        assert saved.name == "持久化测试"
        assert saved.user_id == "user-test-001"

    def test_create_missing_name_returns_422(self, client):
        """缺少 name 返回 422。"""
        resp = client.post("/voice-clones", json={})
        assert resp.status_code == 422

    def test_create_empty_name_returns_422(self, client):
        """空 name 返回 422。"""
        resp = client.post("/voice-clones", json={"name": ""})
        assert resp.status_code == 422

    def test_create_name_too_long_returns_422(self, client):
        """名称超长返回 422。"""
        long_name = "a" * 101
        resp = client.post("/voice-clones", json={"name": long_name})
        assert resp.status_code == 422

    def test_create_with_metadata(self, client, cosyvoice_service):
        """支持自定义 metadata。"""
        resp = client.post(
            "/voice-clones",
            json={
                "name": "带元数据的克隆",
                "source_audio_url": "https://example.com/v.wav",
                "metadata": {"source": "mobile_app", "version": "1.0"},
            },
        )
        assert resp.status_code == 201
        data = resp.json()
        assert data["metadata"]["source"] == "mobile_app"
        assert data["metadata"]["version"] == "1.0"

    def test_create_cosyvoice_failure_returns_failed(self, client, clone_repo, cosyvoice_service):
        """CosyVoice 提交失败时返回 201 + failed 状态（不抛 500）。"""
        cosyvoice_service.fail_submit = True
        cosyvoice_service.async_mode = True  # 异步模式才会调用 submit_clone_task

        resp = client.post(
            "/voice-clones",
            json={
                "name": "会失败的克隆",
                "source_audio_url": "https://example.com/bad.wav",
            },
        )
        # 不抛 500，返回 201 + failed 状态
        assert resp.status_code == 201
        data = resp.json()
        assert data["status"] == "failed"
        assert data["error_message"] != ""


# ---------------------------------------------------------------------------
# 6. GET / — 列出声音克隆
# ---------------------------------------------------------------------------


class TestListVoiceClones:
    """列出声音克隆端点测试。"""

    def test_empty_list(self, client):
        """无克隆时返回空列表。"""
        resp = client.get("/voice-clones")
        assert resp.status_code == 200
        data = resp.json()
        assert data["items"] == []
        assert data["total"] == 0

    def test_list_user_clones(self, client, clone_repo):
        """只返回当前用户的克隆。"""
        p1 = _make_clone_profile("音色1", "user-test-001")
        p2 = _make_clone_profile("音色2", "user-test-001")
        p3 = _make_clone_profile("他人音色", "other-user")
        clone_repo.create(p1)
        clone_repo.create(p2)
        clone_repo.create(p3)

        resp = client.get("/voice-clones")
        assert resp.status_code == 200
        data = resp.json()
        assert data["total"] == 2
        assert len(data["items"]) == 2
        names = {item["name"] for item in data["items"]}
        assert names == {"音色1", "音色2"}

    def test_filter_by_status(self, client, clone_repo):
        """按状态筛选。"""
        ready = _make_clone_profile("已就绪", status=VoiceCloneStatus.READY)
        failed = _make_clone_profile("已失败", status=VoiceCloneStatus.FAILED)
        clone_repo.create(ready)
        clone_repo.create(failed)

        resp = client.get("/voice-clones?status=ready")
        assert resp.status_code == 200
        data = resp.json()
        assert data["total"] == 1
        assert data["items"][0]["name"] == "已就绪"

    def test_filter_by_failed_status(self, client, clone_repo):
        """筛选失败状态。"""
        failed = _make_clone_profile("失败的", status=VoiceCloneStatus.FAILED)
        ready = _make_clone_profile("成功的", status=VoiceCloneStatus.READY)
        clone_repo.create(failed)
        clone_repo.create(ready)

        resp = client.get("/voice-clones?status=failed")
        assert resp.status_code == 200
        assert resp.json()["total"] == 1
        assert resp.json()["items"][0]["name"] == "失败的"

    def test_list_response_fields(self, client, clone_repo):
        """列表响应包含所有必需字段。"""
        p = _make_clone_profile("字段测试")
        clone_repo.create(p)

        resp = client.get("/voice-clones")
        item = resp.json()["items"][0]
        for field in [
            "id",
            "user_id",
            "name",
            "description",
            "source_audio_url",
            "voice_id",
            "voice_model",
            "language",
            "gender",
            "status",
            "error_message",
            "retry_count",
            "max_retries",
            "created_at",
            "updated_at",
        ]:
            assert field in item, f"缺少字段: {field}"


# ---------------------------------------------------------------------------
# 7. GET /{clone_id} — 获取克隆详情
# ---------------------------------------------------------------------------


class TestGetVoiceClone:
    """获取克隆详情端点测试。"""

    def test_get_existing_clone(self, client, clone_repo):
        """获取存在的克隆返回详情。"""
        p = _make_clone_profile("详情测试", description="这是一段描述")
        clone_repo.create(p)

        resp = client.get(f"/voice-clones/{p.id}")
        assert resp.status_code == 200
        data = resp.json()
        assert data["id"] == p.id
        assert data["name"] == "详情测试"
        assert data["description"] == "这是一段描述"

    def test_get_nonexistent_returns_404(self, client):
        """获取不存在的克隆返回 404。"""
        resp = client.get("/voice-clones/nonexistent-clone-id")
        assert resp.status_code == 404
        assert "not found" in resp.json()["detail"].lower()

    def test_get_other_user_clone_returns_404(self, client, clone_repo):
        """获取其他用户的克隆返回 404（安全隔离）。"""
        p = _make_clone_profile("他人音色", user_id="other-user")
        clone_repo.create(p)

        resp = client.get(f"/voice-clones/{p.id}")
        assert resp.status_code == 404

    def test_get_ready_clone_has_voice_id(self, client, clone_repo):
        """就绪状态的克隆有 voice_id。"""
        p = _make_clone_profile("就绪音色", status=VoiceCloneStatus.READY)
        clone_repo.create(p)

        resp = client.get(f"/voice-clones/{p.id}")
        data = resp.json()
        assert data["status"] == "ready"
        assert data["voice_id"] == "voice-ready-001"

    def test_get_failed_clone_has_error_message(self, client, clone_repo):
        """失败状态的克隆有错误信息。"""
        p = _make_clone_profile("失败音色", status=VoiceCloneStatus.FAILED)
        clone_repo.create(p)

        resp = client.get(f"/voice-clones/{p.id}")
        data = resp.json()
        assert data["status"] == "failed"
        assert "模拟失败" in data["error_message"]


# ---------------------------------------------------------------------------
# 8. GET /{clone_id}/status — 获取克隆状态
# ---------------------------------------------------------------------------


class TestGetVoiceCloneStatus:
    """获取克隆状态端点测试。"""

    def test_status_pending(self, client, clone_repo):
        """pending 状态。"""
        p = _make_clone_profile("pending", status=VoiceCloneStatus.PENDING)
        clone_repo.create(p)

        resp = client.get(f"/voice-clones/{p.id}/status")
        assert resp.status_code == 200
        data = resp.json()
        assert data["id"] == p.id
        assert data["status"] == "pending"
        assert data["retry_count"] == 0

    def test_status_ready(self, client, clone_repo):
        """ready 状态包含 voice_id。"""
        p = _make_clone_profile("ready", status=VoiceCloneStatus.READY)
        clone_repo.create(p)

        resp = client.get(f"/voice-clones/{p.id}/status")
        data = resp.json()
        assert data["status"] == "ready"
        assert data["voice_id"] == "voice-ready-001"

    def test_status_failed(self, client, clone_repo):
        """failed 状态包含错误信息。"""
        p = _make_clone_profile("failed", status=VoiceCloneStatus.FAILED)
        clone_repo.create(p)

        resp = client.get(f"/voice-clones/{p.id}/status")
        data = resp.json()
        assert data["status"] == "failed"
        assert data["error_message"] != ""
        assert data["retry_count"] == 0  # mark_failed 不增加 retry_count，只有重试时才增加

    def test_status_nonexistent_returns_404(self, client):
        """获取不存在克隆的状态返回 404。"""
        resp = client.get("/voice-clones/nonexistent/status")
        assert resp.status_code == 404


# ---------------------------------------------------------------------------
# 9. POST /{clone_id}/retry — 重试克隆
# ---------------------------------------------------------------------------


class TestRetryVoiceClone:
    """重试克隆端点测试。"""

    def test_retry_failed_clone(self, client, clone_repo, cosyvoice_service):
        """重试失败的克隆，重新提交后期望 processing。"""
        p = _make_clone_profile("重试测试", status=VoiceCloneStatus.FAILED)
        clone_repo.create(p)

        resp = client.post(f"/voice-clones/{p.id}/retry")
        assert resp.status_code == 200
        data = resp.json()
        # 异步模式下重试后状态为 processing，等待 CosyVoice 完成
        assert data["status"] == "processing"
        assert data["retry_count"] >= 1

    def test_retry_nonexistent_returns_404(self, client):
        """重试不存在的克隆返回 404。"""
        resp = client.post("/voice-clones/nonexistent/retry")
        assert resp.status_code == 404

    def test_retry_ready_clone_returns_400(self, client, clone_repo):
        """重试已就绪的克隆返回 400（不可重试）。"""
        p = _make_clone_profile("已就绪", status=VoiceCloneStatus.READY)
        clone_repo.create(p)

        resp = client.post(f"/voice-clones/{p.id}/retry")
        assert resp.status_code == 400
        assert "retryable" in resp.json()["detail"].lower() or "not" in resp.json()["detail"].lower()

    def test_retry_processing_clone_returns_400(self, client, clone_repo):
        """重试处理中的克隆返回 400。"""
        p = _make_clone_profile("处理中", status=VoiceCloneStatus.PROCESSING)
        clone_repo.create(p)

        resp = client.post(f"/voice-clones/{p.id}/retry")
        assert resp.status_code == 400

    def test_retry_increments_retry_count(self, client, clone_repo, cosyvoice_service):
        """重试后重试次数增加。"""
        p = _make_clone_profile("重试计数", status=VoiceCloneStatus.FAILED)
        clone_repo.create(p)

        before_count = p.retry_count
        resp = client.post(f"/voice-clones/{p.id}/retry")
        after_count = resp.json()["retry_count"]

        assert after_count > before_count


# ---------------------------------------------------------------------------
# 10. DELETE /{clone_id} — 删除克隆
# ---------------------------------------------------------------------------


class TestDeleteVoiceClone:
    """删除克隆端点测试。"""

    def test_delete_existing_clone(self, client, clone_repo):
        """删除存在的克隆返回 204。"""
        p = _make_clone_profile("待删除")
        clone_repo.create(p)

        resp = client.delete(f"/voice-clones/{p.id}")
        assert resp.status_code == 204

        # 验证已删除
        assert clone_repo.get(p.id) is None

    def test_delete_nonexistent_returns_404(self, client):
        """删除不存在的克隆返回 404。"""
        resp = client.delete("/voice-clones/nonexistent-clone-id")
        assert resp.status_code == 404

    def test_delete_other_user_clone_returns_404(self, client, clone_repo):
        """删除其他用户的克隆返回 404（安全隔离）。"""
        p = _make_clone_profile("他人音色", user_id="other-user")
        clone_repo.create(p)

        resp = client.delete(f"/voice-clones/{p.id}")
        assert resp.status_code == 404
        # 验证未被删除
        assert clone_repo.get(p.id) is not None

    def test_delete_idempotent(self, client, clone_repo):
        """删除后再次删除返回 404。"""
        p = _make_clone_profile("幂等测试")
        clone_repo.create(p)

        resp1 = client.delete(f"/voice-clones/{p.id}")
        assert resp1.status_code == 204

        resp2 = client.delete(f"/voice-clones/{p.id}")
        assert resp2.status_code == 404


# ---------------------------------------------------------------------------
# 11. 跨端点场景
# ---------------------------------------------------------------------------


class TestVoiceCloneLifecycle:
    """音色克隆完整生命周期测试。"""

    def test_full_lifecycle_create_list_get_delete(self, client, clone_repo, cosyvoice_service):
        """创建 → 列表 → 详情 → 删除 完整流程。"""
        # 1. 创建
        create_resp = client.post(
            "/voice-clones",
            json={
                "name": "生命周期测试",
                "source_audio_url": "https://example.com/voice.wav",
            },
        )
        assert create_resp.status_code == 201
        clone_id = create_resp.json()["id"]

        # 2. 列表
        list_resp = client.get("/voice-clones")
        assert list_resp.json()["total"] == 1

        # 3. 详情
        detail_resp = client.get(f"/voice-clones/{clone_id}")
        assert detail_resp.status_code == 200
        assert detail_resp.json()["name"] == "生命周期测试"

        # 4. 状态
        status_resp = client.get(f"/voice-clones/{clone_id}/status")
        assert status_resp.status_code == 200
        assert status_resp.json()["status"] == "processing"

        # 5. 删除
        del_resp = client.delete(f"/voice-clones/{clone_id}")
        assert del_resp.status_code == 204

        # 6. 删除后列表为空
        list_resp2 = client.get("/voice-clones")
        assert list_resp2.json()["total"] == 0

    def test_failed_retry_flow(self, client, clone_repo, cosyvoice_service):
        """失败 → 重试 → processing（等待异步完成） 流程。"""
        # 创建一个失败的克隆
        p = _make_clone_profile("失败重试", status=VoiceCloneStatus.FAILED)
        clone_repo.create(p)

        # 确认状态
        status_resp = client.get(f"/voice-clones/{p.id}/status")
        assert status_resp.json()["status"] == "failed"

        # 重试
        retry_resp = client.post(f"/voice-clones/{p.id}/retry")
        assert retry_resp.status_code == 200
        assert retry_resp.json()["status"] == "processing"

        # 再次确认状态
        status_resp2 = client.get(f"/voice-clones/{p.id}/status")
        assert status_resp2.json()["status"] == "processing"


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


# ---------------------------------------------------------------------------
# 6. POST /?asset_id=xxx — 从配音素材创建克隆
# ---------------------------------------------------------------------------


class TestCreateVoiceCloneFromAsset:
    """asset_id 参数测试：用配音素材库音频发起克隆。"""

    def test_create_from_asset_success(self, client, clone_repo, asset_repo, project_repo, storage_service):
        """正常路径：asset 归属本人且为音频 → 201，source_audio_url 为签名 URL。"""
        project = Project.create(owner_user_id="user-test-001", name="默认项目")
        project_repo._items[project.id] = project
        asset = _make_voice_asset(asset_id="asset-ok", project_id=project.id)
        asset_repo._items[asset.id] = asset

        resp = client.post(
            "/voice-clones",
            json={"name": "素材克隆", "asset_id": asset.id},
        )
        assert resp.status_code == 201, resp.text
        data = resp.json()
        assert data["name"] == "素材克隆"
        # 用 OSS storage_key 生成了服务端签名 URL
        assert any("uploads/voice/sample.m4a" in k for k in storage_service.signed_keys)
        assert data["source_audio_url"].startswith("https://oss.example.com/uploads/voice/sample.m4a")

    def test_create_from_asset_not_found_returns_404(self, client, asset_repo, project_repo):
        """asset 不存在 → 404。"""
        resp = client.post("/voice-clones", json={"name": "克隆", "asset_id": "no-such-asset"})
        assert resp.status_code == 404, resp.text

    def test_create_from_asset_other_user_returns_403(self, client, asset_repo, project_repo):
        """asset 属于他人项目 → 403。"""
        project = Project.create(owner_user_id="other-user", name="他人项目")
        project_repo._items[project.id] = project
        asset = _make_voice_asset(asset_id="asset-other", project_id=project.id, user_id="other-user")
        asset_repo._items[asset.id] = asset

        resp = client.post("/voice-clones", json={"name": "克隆", "asset_id": asset.id})
        assert resp.status_code == 403, resp.text

    def test_create_from_non_audio_asset_returns_400(self, client, asset_repo, project_repo):
        """asset 是视频 → 400。"""
        project = Project.create(owner_user_id="user-test-001", name="默认项目")
        project_repo._items[project.id] = project
        asset = _make_voice_asset(
            asset_id="asset-video",
            project_id=project.id,
            file_type="video",
            storage_key="uploads/v/test.mp4",
        )
        asset_repo._items[asset.id] = asset

        resp = client.post("/voice-clones", json={"name": "克隆", "asset_id": asset.id})
        assert resp.status_code == 400, resp.text

    def test_create_with_both_asset_id_and_url_returns_400(self, client, asset_repo, project_repo):
        """asset_id 与 source_audio_url 同时传 → 400。"""
        project = Project.create(owner_user_id="user-test-001", name="默认项目")
        project_repo._items[project.id] = project
        asset = _make_voice_asset(asset_id="asset-both", project_id=project.id)
        asset_repo._items[asset.id] = asset

        resp = client.post(
            "/voice-clones",
            json={
                "name": "克隆",
                "asset_id": asset.id,
                "source_audio_url": "https://example.com/a.wav",
            },
        )
        assert resp.status_code == 400, resp.text
