"""Voice clone workflow service unit tests.

Covers VoiceCloneWorkflowService - start_clone, poll_and_process_clone,
process_clone_result, process_clone_failure, retry_clone.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest

from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError
from packages.application.voice_clone.workflow import (
    VoiceCloneWorkflowError,
    VoiceCloneWorkflowService,
)
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
from packages.shared.url_security import UrlSecurityError

# ── Helpers ─────────────────────────────────────────────


def make_profile(**kwargs):
    defaults = dict(
        id="profile-123",
        user_id="user-1",
        name="我的音色",
        description="",
        source_audio_url="",
        voice_model="",
        language="zh-CN",
        gender="unknown",
        max_retries=3,
    )
    defaults.update(kwargs)
    return VoiceCloneProfile(**defaults)


class FakeProfileRepository:
    def __init__(self, profile=None):
        self._profile = profile
        self.updated = []
        self.created = []
        self.get_called = 0

    def create(self, profile):
        self.created.append(profile)
        self._profile = profile
        return profile

    def get(self, profile_id):
        self.get_called += 1
        if self._profile and self._profile.id == profile_id:
            return self._profile
        return None

    def update(self, profile):
        self.updated.append(profile)
        self._profile = profile
        return profile

    def find_by_user(self, user_id):
        if self._profile and self._profile.user_id == user_id:
            return [self._profile]
        return []


class FakeCosyVoiceService:
    def __init__(self, submit_result=None, submit_error=None, poll_result=None, poll_error=None):
        self._submit_result = submit_result or {
            "voice_id": "voice_abc123",
            "status": "DEPLOYING",
            "request_id": "req-abc",
        }
        self._submit_error = submit_error
        self._poll_result = poll_result or {"voice_id": "voice_abc123"}
        self._poll_error = poll_error
        self.submit_calls = []
        self.poll_calls = []

    def submit_clone_task(self, **kwargs):
        self.submit_calls.append(kwargs)
        if self._submit_error:
            raise self._submit_error
        return self._submit_result

    def poll_clone_task(self, voice_id, timeout=300.0):
        self.poll_calls.append({"voice_id": voice_id, "timeout": timeout})
        if self._poll_error:
            raise self._poll_error
        return self._poll_result


# ── start_clone tests ───────────────────────────────────


class TestStartClone:
    def test_with_audio_url_submits_and_returns_processing(self):
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService(
            submit_result={
                "voice_id": "voice_new123",
                "status": "DEPLOYING",
                "request_id": "req-xyz",
            }
        )
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            return_value="https://safe.example.com/audio.mp3",
        ):
            result = svc.start_clone(
                user_id="user-1",
                name="测试音色",
                source_audio_url="https://example.com/audio.mp3",
            )

        assert result.user_id == "user-1"
        assert result.name == "测试音色"
        assert result.status == VoiceCloneStatus.PROCESSING.value
        assert result.metadata["cosyvoice_task_id"] == "voice_new123"
        assert result.metadata["cosyvoice_request_id"] == "req-xyz"

        # CosyVoice was called
        assert len(cosy.submit_calls) == 1
        assert cosy.submit_calls[0]["audio_url"] == "https://safe.example.com/audio.mp3"
        assert cosy.submit_calls[0]["voice_name"] == "测试音色"

    def test_without_audio_url_returns_pending(self):
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        result = svc.start_clone(
            user_id="user-1",
            name="待上传音色",
            source_audio_url="",
        )

        assert result.status == VoiceCloneStatus.PENDING.value
        # No CosyVoice call
        assert len(cosy.submit_calls) == 0

    def test_sync_completion_status_ok(self):
        """CosyVoice returns OK status directly → mark ready."""
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService(
            submit_result={
                "voice_id": "voice_ready",
                "status": "OK",
                "request_id": "req-ok",
            }
        )
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            return_value="https://safe.example.com/a.mp3",
        ):
            result = svc.start_clone(
                user_id="user-1",
                name="秒成音色",
                source_audio_url="https://example.com/a.mp3",
            )

        assert result.status == VoiceCloneStatus.READY.value
        assert result.voice_id == "voice_ready"

    def test_url_security_failure_marks_failed(self):
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            side_effect=UrlSecurityError("SSRF detected: internal IP"),
        ):
            result = svc.start_clone(
                user_id="user-1",
                name="危险音色",
                source_audio_url="https://10.0.0.1/internal.mp3",
            )

        assert result.status == VoiceCloneStatus.FAILED.value
        assert "安全校验失败" in result.error_message
        # No CosyVoice call
        assert len(cosy.submit_calls) == 0

    def test_cosyvoice_error_marks_failed(self):
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("API rate limit exceeded"))
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            return_value="https://safe.example.com/a.mp3",
        ):
            result = svc.start_clone(
                user_id="user-1",
                name="失败音色",
                source_audio_url="https://example.com/a.mp3",
            )

        assert result.status == VoiceCloneStatus.FAILED.value
        assert "API rate limit" in result.error_message

    def test_cosyvoice_auth_error_marks_failed(self):
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService(submit_error=CosyVoiceAuthError("Invalid API key"))
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            return_value="https://safe.example.com/a.mp3",
        ):
            result = svc.start_clone(
                user_id="user-1",
                name="鉴权失败",
                source_audio_url="https://example.com/a.mp3",
            )

        assert result.status == VoiceCloneStatus.FAILED.value
        assert "Invalid API key" in result.error_message

    def test_value_error_marks_failed(self):
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService(submit_error=ValueError("audio_url is empty"))
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            return_value="https://safe.example.com/a.mp3",
        ):
            result = svc.start_clone(
                user_id="user-1",
                name="参数错误",
                source_audio_url="https://example.com/a.mp3",
            )

        assert result.status == VoiceCloneStatus.FAILED.value
        assert "audio_url is empty" in result.error_message

    def test_custom_language_and_model(self):
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"
        ):
            result = svc.start_clone(
                user_id="user-1",
                name="English Voice",
                source_audio_url="https://example.com/a.mp3",
                language="en-US",
                voice_model="cosyvoice-v3",
            )

        assert cosy.submit_calls[0]["language"] == "en-US"
        assert result.language == "en-US"
        assert result.voice_model == "cosyvoice-v3"

    def test_metadata_passed_through(self):
        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"
        ):
            result = svc.start_clone(
                user_id="user-1",
                name="带元数据",
                source_audio_url="https://example.com/a.mp3",
                metadata={"source": "upload", "format": "wav"},
            )

        assert result.metadata.get("source") == "upload"
        assert result.metadata.get("format") == "wav"
        # cosyvoice keys also present
        assert "cosyvoice_task_id" in result.metadata


# ── poll_and_process_clone tests ────────────────────────


class TestPollAndProcessClone:
    def test_poll_success_returns_ready(self):
        profile = make_profile()
        profile.mark_processing()
        profile.metadata = {"cosyvoice_task_id": "voice_pending"}
        repo = FakeProfileRepository(profile=profile)
        cosy = FakeCosyVoiceService(poll_result={"voice_id": "voice_done"})
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        result = svc.poll_and_process_clone("profile-123", timeout=60.0)

        assert result.status == VoiceCloneStatus.READY.value
        assert result.voice_id == "voice_done"
        assert len(cosy.poll_calls) == 1
        assert cosy.poll_calls[0]["voice_id"] == "voice_pending"
        assert cosy.poll_calls[0]["timeout"] == 60.0

    def test_profile_not_found_raises(self):
        from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError

        repo = FakeProfileRepository()
        cosy = FakeCosyVoiceService()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with pytest.raises(VoiceCloneNotFoundError):
            svc.poll_and_process_clone("nonexistent")

    def test_no_task_id_raises_value_error(self):
        profile = make_profile()
        profile.mark_processing()
        profile.metadata = {}  # no task_id
        repo = FakeProfileRepository(profile=profile)
        cosy = FakeCosyVoiceService()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with pytest.raises(ValueError, match="no cosyvoice_task_id"):
            svc.poll_and_process_clone("profile-123")

    def test_poll_timeout_propagates(self):
        from packages.application.cosyvoice_service import CosyVoiceTimeoutError

        profile = make_profile()
        profile.mark_processing()
        profile.metadata = {"cosyvoice_task_id": "voice_slow"}
        repo = FakeProfileRepository(profile=profile)
        cosy = FakeCosyVoiceService(poll_error=CosyVoiceTimeoutError("timed out"))
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with pytest.raises(CosyVoiceTimeoutError):
            svc.poll_and_process_clone("profile-123")


# ── process_clone_result tests ──────────────────────────


class TestProcessCloneResult:
    def test_marks_profile_ready(self):
        profile = make_profile()
        profile.mark_processing()
        repo = FakeProfileRepository(profile=profile)
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())

        result = svc.process_clone_result("profile-123", "voice_final123")

        assert result.status == VoiceCloneStatus.READY.value
        assert result.voice_id == "voice_final123"

    def test_profile_not_found_raises(self):
        from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError

        repo = FakeProfileRepository()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())

        with pytest.raises(VoiceCloneNotFoundError):
            svc.process_clone_result("nonexistent", "voice_x")


# ── process_clone_failure tests ─────────────────────────


class TestProcessCloneFailure:
    def test_marks_profile_failed(self):
        profile = make_profile()
        profile.mark_processing()
        repo = FakeProfileRepository(profile=profile)
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())

        result = svc.process_clone_failure("profile-123", "审核未通过")

        assert result.status == VoiceCloneStatus.FAILED.value
        assert "审核未通过" in result.error_message

    def test_profile_not_found_raises(self):
        from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError

        repo = FakeProfileRepository()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())

        with pytest.raises(VoiceCloneNotFoundError):
            svc.process_clone_failure("nonexistent", "error")


# ── retry_clone tests ───────────────────────────────────


class TestRetryClone:
    def test_retry_success_submits_again(self):
        profile = make_profile(source_audio_url="https://example.com/orig.mp3")
        profile.mark_failed("previous error")
        repo = FakeProfileRepository(profile=profile)
        cosy = FakeCosyVoiceService(
            submit_result={
                "voice_id": "voice_retry",
                "status": "DEPLOYING",
                "request_id": "req-retry",
            }
        )
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            return_value="https://safe.example.com/orig.mp3",
        ):
            result = svc.retry_clone("profile-123", "user-1")

        # Should be processing again
        assert result.status == VoiceCloneStatus.PROCESSING.value
        assert result.metadata["cosyvoice_task_id"] == "voice_retry"
        assert len(cosy.submit_calls) == 1

    def test_retry_url_security_failure(self):
        profile = make_profile(source_audio_url="https://10.0.0.1/audio.mp3")
        profile.mark_failed("old error")
        repo = FakeProfileRepository(profile=profile)
        cosy = FakeCosyVoiceService()
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety", side_effect=UrlSecurityError("internal IP")
        ):
            result = svc.retry_clone("profile-123", "user-1")

        assert result.status == VoiceCloneStatus.FAILED.value
        assert "安全校验失败" in result.error_message
        assert len(cosy.submit_calls) == 0

    def test_retry_cosyvoice_error(self):
        profile = make_profile(source_audio_url="https://example.com/a.mp3")
        profile.mark_failed("old error")
        repo = FakeProfileRepository(profile=profile)
        cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("still failing"))
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            return_value="https://safe.example.com/a.mp3",
        ):
            result = svc.retry_clone("profile-123", "user-1")

        assert result.status == VoiceCloneStatus.FAILED.value
        assert "still failing" in result.error_message

    def test_retry_sync_ok(self):
        profile = make_profile(source_audio_url="https://example.com/a.mp3")
        profile.mark_failed("old error")
        repo = FakeProfileRepository(profile=profile)
        cosy = FakeCosyVoiceService(
            submit_result={
                "voice_id": "voice_instant",
                "status": "OK",
                "request_id": "req-instant",
            }
        )
        svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)

        with patch(
            "packages.application.voice_clone.workflow.validate_url_safety",
            return_value="https://safe.example.com/a.mp3",
        ):
            result = svc.retry_clone("profile-123", "user-1")

        assert result.status == VoiceCloneStatus.READY.value
        assert result.voice_id == "voice_instant"


# ── Error class tests ───────────────────────────────────


class TestErrorClasses:
    def test_workflow_error_inherits_exception(self):
        err = VoiceCloneWorkflowError("test error")
        assert isinstance(err, Exception)
        assert str(err) == "test error"
