"""P1 长文本分段合成单元测试。

覆盖：
- text_splitter.split_text 分段逻辑
- audio_merger.AudioMerger 合并逻辑
- workflow 分段合成路径（同步 / 异步 / 失败）
"""

from __future__ import annotations

import os
import tempfile
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch

import pytest

from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.tts_job.audio_merger import AudioMergeError, AudioMerger
from packages.application.tts_job.text_splitter import split_text
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain.tts_job import TTSJob, TTSJobStatus

# ── text_splitter ─────────────────────────────────────────────


class TestSplitText:
    """测试文本分段工具。"""

    def test_short_text_no_split(self) -> None:
        """短文本不拆分。"""
        assert split_text("你好世界", max_chars=500) == ["你好世界"]

    def test_empty_text(self) -> None:
        """空文本返回空列表。"""
        assert split_text("") == []
        assert split_text("   ") == []

    def test_exact_threshold(self) -> None:
        """恰好等于阈值不拆分。"""
        text = "a" * 500
        assert split_text(text, max_chars=500) == [text]

    def test_split_at_sentence_boundary(self) -> None:
        """在句子边界处分段。"""
        text = "第一句话。" * 60  # 300 chars
        text += "第二句话。" * 60  # 300 chars → total 600
        segments = split_text(text, max_chars=500)
        assert len(segments) >= 2
        for seg in segments:
            assert len(seg) <= 500

    def test_split_at_newline(self) -> None:
        """在换行符处分段。"""
        text = "段落一\n" * 100  # 300 chars
        text += "段落二\n" * 100  # 300 chars
        segments = split_text(text, max_chars=500)
        assert len(segments) >= 2

    def test_long_sentence_hard_split(self) -> None:
        """超长句子硬切。"""
        text = "a" * 1200
        segments = split_text(text, max_chars=500)
        assert len(segments) >= 3
        for seg in segments:
            assert len(seg) <= 500

    def test_merge_short_segments(self) -> None:
        """短段合并减少 API 调用。"""
        # 多个短句子应该被合并
        text = "你好。" * 120  # 360 chars, each sentence 3 chars
        segments = split_text(text, max_chars=500)
        # 短段应该被合并，段数应该比较少
        assert len(segments) < 120

    def test_preserves_order(self) -> None:
        """分段保持原始顺序。"""
        text = "第一段。第二段。第三段。" + "x" * 490
        segments = split_text(text, max_chars=500)
        # 第一个段应该以 "第一段" 开头
        assert segments[0].startswith("第一段")


# ── audio_merger ─────────────────────────────────────────────


class TestAudioMerger:
    """测试 FFmpeg 音频合并器。"""

    def test_empty_list_raises(self) -> None:
        """空列表抛出 AudioMergeError。"""
        merger = AudioMerger()
        with pytest.raises(AudioMergeError, match="没有可合并"):
            merger.merge([])

    def test_single_file_returns_bytes(self) -> None:
        """单文件直接返回内容。"""
        with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
            f.write(b"fake audio content")
            f.flush()
            path = f.name

        try:
            merger = AudioMerger()
            data = merger.merge([path])
            assert data == b"fake audio content"
        finally:
            os.unlink(path)

    @patch("packages.application.tts_job.audio_merger.run_ffmpeg")
    def test_ffmpeg_called_correctly(self, mock_run_ffmpeg: MagicMock) -> None:
        """多文件调用 FFmpeg concat（通过 run_ffmpeg 统一入口）。"""
        # run_ffmpeg 成功返回 (stdout, stderr)
        mock_run_ffmpeg.return_value = ("", "")

        # 创建临时文件
        paths = []
        for _ in range(3):
            with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
                f.write(b"audio")
                paths.append(f.name)

        try:
            merger = AudioMerger()
            # Mock open for reading the merged output
            with patch("builtins.open", create=True) as mock_open:
                mock_open.return_value.__enter__ = lambda s: s
                mock_open.return_value.read = lambda: b"merged audio"
                try:
                    merger.merge(paths, output_format="mp3")
                except (FileNotFoundError, OSError):
                    pass  # Expected since we're mocking

            # 验证 run_ffmpeg 被调用
            mock_run_ffmpeg.assert_called_once()
            cmd = mock_run_ffmpeg.call_args[0][0]
            from shared.ffmpeg_utils import FFMPEG_BIN

            assert cmd[0] == FFMPEG_BIN
            assert "-f" in cmd
            assert "concat" in cmd
        finally:
            for p in paths:
                os.unlink(p)

    @patch("packages.application.tts_job.audio_merger.run_ffmpeg")
    def test_ffmpeg_failure_raises(self, mock_run_ffmpeg: MagicMock) -> None:
        """FFmpeg 失败抛出 AudioMergeError（通过 run_ffmpeg 抛出 CalledProcessError）。"""
        from subprocess import CalledProcessError

        mock_run_ffmpeg.side_effect = CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr="error details")

        paths = []
        for _ in range(2):
            with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
                f.write(b"audio")
                paths.append(f.name)

        try:
            merger = AudioMerger()
            with pytest.raises(AudioMergeError, match="FFmpeg 合并失败"):
                merger.merge(paths)
            mock_run_ffmpeg.assert_called_once()
        finally:
            for p in paths:
                os.unlink(p)


# ── workflow segment methods ─────────────────────────────────


def _make_job(**kwargs) -> TTSJob:
    defaults = {
        "id": "test_job_seg",
        "user_id": "user_001",
        "input_text": "x" * 600,  # > 500 threshold
        "voice_id": "voice_001",
        "voice_model": "",
        "project_id": "",
        "voice_clone_profile_id": "",
        "status": TTSJobStatus.PENDING,
        "output_audio_url": "",
        "output_audio_key": "",
        "duration": 0.0,
        "file_size": 0,
        "sample_rate": 22050,
        "format": "mp3",
        "error_message": "",
        "retry_count": 0,
        "max_retries": 3,
        "metadata": {},
        "started_at": None,
        "completed_at": None,
        "created_at": datetime.now(timezone.utc),
        "updated_at": datetime.now(timezone.utc),
    }
    defaults.update(kwargs)
    return TTSJob(**defaults)


def _make_workflow(
    cosyvoice_service: MagicMock | None = None,
    repo: MagicMock | None = None,
    storage: MagicMock | None = None,
) -> TTSWorkflowService:
    if cosyvoice_service is None:
        cosyvoice_service = MagicMock(spec=CosyVoiceService)
    if repo is None:
        repo = MagicMock()
        repo.get.return_value = _make_job()
        repo.update.side_effect = lambda j: j
    if storage is None:
        storage = MagicMock()
        storage.upload_file.return_value = "https://oss.example.com/merged.mp3"
    return TTSWorkflowService(
        repository=repo,
        cosyvoice_service=cosyvoice_service,
        storage_service=storage,
    )


class TestStartSegmentSynthesis:
    """测试 _start_segment_synthesis 分段合成入口。"""

    def test_short_text_no_segment(self) -> None:
        """短文本不触发分段。"""
        service = MagicMock(spec=CosyVoiceService)
        service.submit_synthesize_task.return_value = {
            "task_id": "task_1",
            "audio_url": "",
        }
        repo = MagicMock()
        repo.get.return_value = _make_job(input_text="短文本")
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(cosyvoice_service=service, repo=repo)
        job = workflow.start_synthesis("test_job_seg")

        # 短文本走普通路径，不调用分段
        assert job.status == TTSJobStatus.PROCESSING

    @patch("packages.application.tts_job.workflow.safe_download_file")
    def test_long_text_sync_segments(self, mock_download: MagicMock) -> None:
        """长文本同步分段：所有段立即返回 audio_url，直接合并。"""
        # Mock 分段音频下载
        mock_download.return_value = 1024  # 模拟文件大小

        service = MagicMock(spec=CosyVoiceService)
        # 每个分段都同步返回 audio_url
        service.submit_synthesize_task.side_effect = [
            {"task_id": "", "audio_url": "https://temp.com/seg1.mp3", "duration": 2.0, "file_size": 1000},
            {"task_id": "", "audio_url": "https://temp.com/seg2.mp3", "duration": 3.0, "file_size": 1500},
        ]

        storage = MagicMock()
        storage.upload_file.return_value = "https://oss.example.com/merged.mp3"

        repo = MagicMock()
        repo.get.return_value = _make_job()
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)

        # Mock AudioMerger 避免真实 FFmpeg 调用
        with patch("packages.application.tts_job.workflow.AudioMerger") as MockMerger:
            mock_merger = MagicMock()
            mock_merger.merge.return_value = b"merged audio data"
            MockMerger.return_value = mock_merger

            job = workflow.start_synthesis("test_job_seg")

        assert job.status == TTSJobStatus.COMPLETED
        assert job.output_audio_url == "https://oss.example.com/merged.mp3"
        assert job.duration == 5.0  # 2.0 + 3.0

    def test_long_text_async_segments(self) -> None:
        """长文本异步分段：返回 task_id，存入 metadata。"""
        service = MagicMock(spec=CosyVoiceService)
        # 每个分段返回 task_id（异步）
        service.submit_synthesize_task.side_effect = [
            {"task_id": "seg_task_1", "audio_url": "", "duration": 0.0, "file_size": 0},
            {"task_id": "seg_task_2", "audio_url": "", "duration": 0.0, "file_size": 0},
        ]

        repo = MagicMock()
        repo.get.return_value = _make_job()
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(cosyvoice_service=service, repo=repo)
        job = workflow.start_synthesis("test_job_seg")

        assert job.status == TTSJobStatus.PROCESSING
        assert "segment_task_ids" in job.metadata
        assert job.metadata["segment_task_ids"] == ["seg_task_1", "seg_task_2"]

    def test_segment_submit_failure_marks_failed(self) -> None:
        """分段提交失败时标记 job 为 failed。"""
        service = MagicMock(spec=CosyVoiceService)
        service.submit_synthesize_task.side_effect = CosyVoiceError("API error")

        repo = MagicMock()
        repo.get.return_value = _make_job()
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(cosyvoice_service=service, repo=repo)
        job = workflow.start_synthesis("test_job_seg")

        assert job.status == TTSJobStatus.FAILED


class TestUploadMergedToOSS:
    """测试 _upload_merged_to_oss 辅助方法。"""

    def test_success(self) -> None:
        """成功上传返回 URL 和 key。"""
        storage = MagicMock()
        storage.upload_file.return_value = "https://oss.example.com/merged.mp3"

        workflow = _make_workflow(storage=storage)
        url, key = workflow._upload_merged_to_oss(b"audio data", "user_001", "job_001", "mp3")

        assert url == "https://oss.example.com/merged.mp3"
        assert key == "tts-outputs/user_001/job_001.mp3"
        storage.upload_file.assert_called_once()
        call_args = storage.upload_file.call_args
        assert call_args[1]["content_type"] == "audio/mpeg"

    def test_failure_returns_empty(self) -> None:
        """上传失败返回空字符串。"""
        storage = MagicMock()
        storage.upload_file.side_effect = Exception("OSS error")

        workflow = _make_workflow(storage=storage)
        url, key = workflow._upload_merged_to_oss(b"audio data", "user_001", "job_001", "mp3")

        assert url == ""
        assert key == ""


class TestHandleSegmentFailure:
    """测试 _handle_segment_failure。"""

    def test_marks_job_failed(self) -> None:
        """标记 job 为 failed 并更新。"""
        repo = MagicMock()
        job = _make_job(status=TTSJobStatus.PROCESSING)
        repo.get.return_value = job
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(repo=repo)
        workflow._handle_segment_failure(job, "分段 1 合成失败")

        assert job.status == TTSJobStatus.FAILED
        assert "分段 1 合成失败" in job.error_message
        repo.update.assert_called_once()


class TestPollSegmentTasks:
    """测试 _poll_segment_tasks 分段缺失重新合成（适配同步接口）。"""

    @patch("packages.application.tts_job.workflow.safe_download_file")
    def test_all_segments_done(self, mock_download: MagicMock) -> None:
        """所有分段缺少 audio_url 时重新同步合成，合并后标记完成。"""
        # Mock 下载分段音频
        mock_download.return_value = 1024  # 模拟文件大小

        service = MagicMock(spec=CosyVoiceService)
        service.submit_synthesize_task.side_effect = [
            {"audio_url": "https://temp.com/seg1.mp3", "duration": 2.0, "file_size": 100},
            {"audio_url": "https://temp.com/seg2.mp3", "duration": 3.0, "file_size": 200},
        ]

        storage = MagicMock()
        storage.upload_file.return_value = "https://oss.example.com/merged.mp3"

        repo = MagicMock()
        job = _make_job(
            status=TTSJobStatus.PROCESSING,
            # 长文本触发分段，用于重新合成时切分
            input_text="这是一段很长的测试文本。" * 30,
            metadata={
                "segment_task_ids": ["task_1", "task_2"],
                "segment_audio_urls": ["", ""],
                "segment_format": "mp3",
            },
        )
        repo.get.return_value = job
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)

        with patch("packages.application.tts_job.workflow.AudioMerger") as MockMerger:
            mock_merger = MagicMock()
            mock_merger.merge.return_value = b"merged data"
            MockMerger.return_value = mock_merger

            result = workflow._poll_segment_tasks(job)

        assert result.status == TTSJobStatus.COMPLETED
        # 两个缺失分段都重新合成了
        assert service.submit_synthesize_task.call_count == 2

    def test_segment_resynthesis_failure(self) -> None:
        """分段重新合成失败时标记 job failed。"""
        service = MagicMock(spec=CosyVoiceService)
        service.submit_synthesize_task.side_effect = CosyVoiceError("Synthesis failed")

        repo = MagicMock()
        job = _make_job(
            status=TTSJobStatus.PROCESSING,
            input_text="这是一段很长的测试文本。" * 30,
            metadata={
                "segment_task_ids": ["task_1"],
                "segment_audio_urls": [""],
            },
        )
        repo.get.return_value = job
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(cosyvoice_service=service, repo=repo)
        result = workflow._poll_segment_tasks(job)

        assert result.status == TTSJobStatus.FAILED

    @patch("packages.application.tts_job.workflow.safe_download_file")
    def test_partial_audio_urls_reuse_existing(self, mock_download: MagicMock) -> None:
        """部分分段已有 audio_url 时直接复用，缺失的重新合成。"""
        mock_download.return_value = 1024  # 模拟文件大小

        service = MagicMock(spec=CosyVoiceService)
        # 只有 1 个分段需要重新合成
        service.submit_synthesize_task.return_value = {
            "audio_url": "https://temp.com/seg2.mp3",
            "duration": 3.0,
            "file_size": 200,
        }

        storage = MagicMock()
        storage.upload_file.return_value = "https://oss.example.com/merged.mp3"

        repo = MagicMock()
        job = _make_job(
            status=TTSJobStatus.PROCESSING,
            input_text="这是一段很长的测试文本。" * 30,
            metadata={
                "segment_task_ids": ["task_1", "task_2"],
                "segment_audio_urls": ["https://temp.com/seg1.mp3", ""],
                "segment_format": "mp3",
            },
        )
        repo.get.return_value = job
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)

        with patch("packages.application.tts_job.workflow.AudioMerger") as MockMerger:
            mock_merger = MagicMock()
            mock_merger.merge.return_value = b"merged data"
            MockMerger.return_value = mock_merger

            result = workflow._poll_segment_tasks(job)

        assert result.status == TTSJobStatus.COMPLETED
        # 只有 1 个缺失分段被重新合成
        assert service.submit_synthesize_task.call_count == 1


class TestPollAndProcessSynthesisSegmentDetection:
    """测试 poll_and_process_synthesis 正确识别分段任务。"""

    def test_detects_segment_task(self) -> None:
        """metadata 中有 segment_task_ids 时走分段轮询路径。"""
        service = MagicMock(spec=CosyVoiceService)

        repo = MagicMock()
        job = _make_job(
            status=TTSJobStatus.PROCESSING,
            metadata={
                "segment_task_ids": ["task_1", "task_2"],
                "segment_audio_urls": ["", ""],
            },
        )
        repo.get.return_value = job
        repo.update.side_effect = lambda j: j

        workflow = _make_workflow(cosyvoice_service=service, repo=repo)

        with patch.object(workflow, "_poll_segment_tasks") as mock_poll:
            mock_poll.return_value = job
            workflow.poll_and_process_synthesis("test_job_seg")
            mock_poll.assert_called_once()

    def test_normal_task_no_segment(self) -> None:
        """普通任务不走分段路径。"""
        service = MagicMock(spec=CosyVoiceService)
        service.poll_synthesize_task.return_value = {
            "audio_url": "https://temp.com/audio.mp3",
            "duration": 5.0,
            "file_size": 5000,
        }

        repo = MagicMock()
        job = _make_job(
            status=TTSJobStatus.PROCESSING,
            metadata={"cosyvoice_task_id": "task_normal"},
        )
        repo.get.return_value = job
        repo.update.side_effect = lambda j: j

        storage = MagicMock()
        storage.upload_file.return_value = "https://oss.example.com/audio.mp3"

        workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)

        with patch("packages.application.tts_job.workflow.safe_download_file") as mock_download:
            mock_download.return_value = 1024  # 模拟文件大小

            result = workflow.poll_and_process_synthesis("test_job_seg")

        assert result.status == TTSJobStatus.COMPLETED
