"""save_tts_job_to_library 改写为 assets 素材体系的单元测试（路由级）。

覆盖：TTS 音频转存素材 OSS → 创建 audio/ready asset → 返回结构；
voice 素材库查找/自动创建/并发竞态兜底；未完成/无音频/下载失败等分支。
"""

from __future__ import annotations

from datetime import datetime, timezone
from unittest.mock import MagicMock

from packages.domain import AssetLibrary, AssetLibraryKind
from packages.domain.tts_job import TTSJob, TTSJobStatus


def _completed_job(**kwargs) -> TTSJob:
    defaults = {
        "id": "job-save-001",
        "user_id": "user-1",
        "input_text": "测试",
        "voice_id": "longxiaochun_v2",
        "voice_model": "",
        "project_id": "",
        "voice_clone_profile_id": "",
        "status": TTSJobStatus.COMPLETED,
        "output_audio_url": "https://oss.example.com/tts-outputs/u/job.mp3",
        "output_audio_key": "tts-outputs/user-1/job-save-001.mp3",
        "duration": 6.0,
        "file_size": 12345,
        "sample_rate": 22050,
        "format": "mp3",
        "error_message": "",
        "retry_count": 0,
        "max_retries": 3,
        "metadata": {"speed": 1.0},
        "started_at": None,
        "completed_at": None,
        "created_at": datetime.now(timezone.utc),
        "updated_at": datetime.now(timezone.utc),
    }
    defaults.update(kwargs)
    return TTSJob(**defaults)


class FakeTTSRepo:
    def __init__(self, job):
        self._job = job

    def get(self, job_id, user_id=None):
        if self._job and self._job.id == job_id and (user_id is None or self._job.user_id == user_id):
            return self._job
        return None


class FakeAssetRepo:
    def __init__(self):
        self.created = []

    def create(self, asset):
        self.created.append(asset)
        return asset


class FakeProject:
    def __init__(self, pid="proj-1", owner="user-1"):
        self.id = pid
        self.owner_user_id = owner

    def can_access(self, user_id):
        return user_id == self.owner_user_id


class FakeProjectRepo:
    def __init__(self, projects):
        self._projects = projects

    def find_accessible_projects(self, user_id):
        return [p for p in self._projects if p.can_access(user_id)]

    def find_by_id(self, pid):
        for p in self._projects:
            if p.id == pid:
                return p
        return None


class FakeAssetLibraryRepo:
    def __init__(self, libs=None, fail_integrity=False, rollback_raises=False):
        self._libs = list(libs or [])
        self.fail_integrity = fail_integrity
        self.session = MagicMock()
        if rollback_raises:
            self.session.rollback.side_effect = RuntimeError("session already closed")

    def find_by_project(self, project_id):
        return [lib for lib in self._libs if lib.project_id == project_id]

    def create(self, library):
        if self.fail_integrity and not any(
            lib.project_id == library.project_id and lib.kind == library.kind for lib in self._libs
        ):
            from sqlalchemy.exc import IntegrityError

            # 模拟并发：另一个请求抢先创建了同名库
            existing = AssetLibrary.create(
                project_id=library.project_id, name="配音素材库", kind=AssetLibraryKind.VOICE
            )
            self._libs.append(existing)
            raise IntegrityError("INSERT", {}, Exception("duplicate key"))
        self._libs.append(library)
        return library


class FakeStorage:
    def __init__(self, download_ok=True):
        self.download_ok = download_ok
        self.uploads = []

    def download_asset(self, source, local_path):
        if not self.download_ok:
            return False
        import os

        with open(local_path, "wb") as f:
            f.write(b"FAKEAUDIO" * 100)
        return os.path.exists(local_path) and os.path.getsize(local_path) > 0

    def upload_file(self, local_path, storage_key, content_type=None):
        self.uploads.append((storage_key, content_type))
        return storage_key

    def delete_file(self, storage_key):
        self.deleted = getattr(self, "deleted", [])
        self.deleted.append(storage_key)

    def get_download_url(self, key, expires_seconds=3600):
        return f"https://oss.example.com/signed/{key}?sig=xxx"


def _build_app(*, job, libs=None, projects=None, storage=None, lib_fail=False, rollback_raises=False):
    from fastapi import FastAPI

    app = FastAPI()
    from app.api.routes.tts import _get_repository, router
    from app.auth import get_current_user
    from app.core.storage import get_storage_service
    from app.dependencies import (
        get_asset_library_repository,
        get_asset_repository,
        get_audio_url_signer,
        get_project_repository,
    )

    app.include_router(router, prefix="/tts")

    fake_user = MagicMock()
    fake_user.user.id = "user-1"
    app.dependency_overrides[get_current_user] = lambda: fake_user
    app.dependency_overrides[_get_repository] = lambda: FakeTTSRepo(job)

    asset_repo = FakeAssetRepo()
    app.dependency_overrides[get_asset_repository] = lambda: asset_repo
    app.dependency_overrides[get_asset_library_repository] = lambda: FakeAssetLibraryRepo(
        libs, lib_fail, rollback_raises=rollback_raises
    )
    app.dependency_overrides[get_project_repository] = lambda: FakeProjectRepo(
        projects if projects is not None else [FakeProject()]
    )
    storage = storage or FakeStorage()
    app.dependency_overrides[get_storage_service] = lambda: storage
    app.dependency_overrides[get_audio_url_signer] = lambda: (lambda key: f"https://signed/{key}")
    return app, asset_repo, storage


class TestSaveToLibraryAssets:
    def test_save_creates_audio_asset_in_voice_library(self):
        """保存成功：转存音频 + 创建 ready/audio asset，返回结构完整。"""
        from fastapi.testclient import TestClient

        voice_lib = AssetLibrary.create(project_id="proj-1", name="配音素材库", kind=AssetLibraryKind.VOICE)
        app, asset_repo, storage = _build_app(job=_completed_job(), libs=[voice_lib])
        client = TestClient(app)

        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={"name": "我的配音"})
        assert resp.status_code == 201, resp.text
        data = resp.json()
        assert data["id"]
        assert data["name"] == "我的配音"
        assert data["duration"] == 6.0
        assert data["voice_id"] == "longxiaochun_v2"
        assert data["status"] == "completed"
        assert data["audio_url"].startswith("https://signed/")

        assert len(asset_repo.created) == 1
        asset = asset_repo.created[0]
        assert asset.file_type == "audio"
        assert asset.status.value == "ready"
        assert asset.duration == 6.0
        assert asset.storage_key.startswith("uploads/voice/tts/")
        assert asset.metadata["source"] == "tts_job"
        assert asset.metadata["tts_job_id"] == "job-save-001"
        assert asset.uploaded_by_user_id == "user-1"
        # 音频确实转存到了素材 OSS 路径，且 content_type 正确
        assert storage.uploads[0][0] == "uploads/voice/tts/job-save-001.mp3"
        assert storage.uploads[0][1] == "audio/mpeg"

    def test_save_auto_creates_voice_library_when_missing(self):
        """用户有项目但没有 voice 素材库 → 自动创建后挂 asset。"""
        from fastapi.testclient import TestClient

        app, asset_repo, _ = _build_app(job=_completed_job(), libs=[], projects=[FakeProject()])
        client = TestClient(app)

        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 201, resp.text
        asset = asset_repo.created[0]
        assert asset.project_id == "proj-1"

    def test_save_concurrent_library_creation_integrity_error(self):
        """并发建库竞态：create 抛 IntegrityError → 回滚重查返回抢先创建的库。"""
        from fastapi.testclient import TestClient

        app, asset_repo, _ = _build_app(job=_completed_job(), libs=[], projects=[FakeProject()], lib_fail=True)
        client = TestClient(app)

        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 201, resp.text
        assert len(asset_repo.created) == 1

    def test_save_rejects_uncompleted_job(self):
        """未完成的 job → 400。"""
        from fastapi.testclient import TestClient

        app, _, _ = _build_app(job=_completed_job(status=TTSJobStatus.PROCESSING))
        client = TestClient(app)
        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 400

    def test_save_rejects_job_without_audio(self):
        """已完成但无 output url/key → 400。"""
        from fastapi.testclient import TestClient

        app, _, _ = _build_app(
            job=_completed_job(output_audio_url="", output_audio_key=""),
            libs=[AssetLibrary.create(project_id="proj-1", name="配音库", kind=AssetLibraryKind.VOICE)],
        )
        client = TestClient(app)
        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 400

    def test_save_download_failure_returns_502(self):
        """TTS 音频下载失败 → 502，不创建 asset。"""
        from fastapi.testclient import TestClient

        voice_lib = AssetLibrary.create(project_id="proj-1", name="配音素材库", kind=AssetLibraryKind.VOICE)
        app, asset_repo, _ = _build_app(job=_completed_job(), libs=[voice_lib], storage=FakeStorage(download_ok=False))
        client = TestClient(app)
        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 502
        assert asset_repo.created == []

    def test_save_concurrent_race_tolerates_closed_session(self):
        """IntegrityError 后 session.rollback() 抛异常（session 已关闭）→ 容错继续重查成功。"""
        from fastapi.testclient import TestClient

        app, asset_repo, _ = _build_app(
            job=_completed_job(), libs=[], projects=[FakeProject()], lib_fail=True, rollback_raises=True
        )
        client = TestClient(app)
        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 201, resp.text
        assert len(asset_repo.created) == 1

    def test_save_db_failure_cleans_orphan_oss_file(self):
        """asset_repository.create 抛异常 → 已上传的 OSS 文件被删除，返回 502。"""
        from fastapi.testclient import TestClient

        class FailAssetRepo(FakeAssetRepo):
            def create(self, asset):
                raise RuntimeError("DB connection lost")

        voice_lib = AssetLibrary.create(project_id="proj-1", name="配音素材库", kind=AssetLibraryKind.VOICE)
        storage = FakeStorage()
        app, _, _ = _build_app(job=_completed_job(), libs=[voice_lib], storage=storage)
        # 替换 asset repo 为会失败的实现
        from app.dependencies import get_asset_repository

        app.dependency_overrides[get_asset_repository] = lambda: FailAssetRepo()
        client = TestClient(app)

        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 502, resp.text
        # OSS 文件已上传后又被清理
        assert storage.uploads, "音频应已上传"
        assert hasattr(storage, "deleted") and storage.deleted, "失败后应删除孤儿 OSS 文件"

    def test_save_no_project_returns_400(self):
        """用户没有任何可访问项目 → 400。"""
        from fastapi.testclient import TestClient

        app, _, _ = _build_app(job=_completed_job(), projects=[])
        client = TestClient(app)
        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 400

    def test_save_other_users_job_returns_404(self):
        """保存他人 job → 404。"""
        from fastapi.testclient import TestClient

        app, _, _ = _build_app(job=_completed_job(user_id="other-user"))
        client = TestClient(app)
        resp = client.post("/tts/jobs/job-save-001/save-to-library", json={})
        assert resp.status_code == 404
