"""Tests for TTS plugin dispatch in tools/tts_tool.py (issue #30398).

Covers the three core invariants of the plugin dispatcher:

1. Built-in provider names short-circuit — plugins NEVER win over a
   built-in. Even if a plugin somehow ended up in the registry with a
   built-in name (which the registry already blocks), the dispatcher
   re-checks defensively.
2. Command-type providers declared under ``tts.providers.<name>: type:
   command`` (PR #17843) win over a plugin with the same name. Config
   is more local than plugin install.
3. Plugin dispatch fires only when the configured provider is neither
   a built-in nor a command-type entry, AND a plugin is registered
   under that name. Unknown names fall through.

Also exercises:
- Plugin exceptions surface to the outer error envelope (don't crash)
- Plugin returning a different path is honored
- voice_compatible: True triggers ffmpeg opus conversion path
- voice_compatible: False keeps the file as-is

The dispatcher is exercised in isolation — we don't actually call
``text_to_speech_tool`` because that would require real audio file
writes. Each test directly calls
``tools.tts_tool._dispatch_to_plugin_provider`` / the predicate
helpers.
"""

from __future__ import annotations

from typing import Optional

import pytest

from agent import tts_registry
from agent.tts_provider import TTSProvider
from tools import tts_tool


class _FakeTTSProvider(TTSProvider):
    def __init__(
        self,
        name: str,
        voice_compat: bool = False,
        raise_exc: Optional[BaseException] = None,
        return_path: Optional[str] = None,
    ):
        self._name = name
        self._voice_compat = voice_compat
        self._raise_exc = raise_exc
        self._return_path = return_path
        # Recorded for assertions
        self.last_call: Optional[dict] = None

    @property
    def name(self) -> str:
        return self._name

    @property
    def voice_compatible(self) -> bool:
        return self._voice_compat

    def synthesize(self, text, output_path, **kw):
        self.last_call = {
            "text": text,
            "output_path": output_path,
            "kwargs": dict(kw),
        }
        if self._raise_exc is not None:
            raise self._raise_exc
        return self._return_path if self._return_path is not None else output_path


@pytest.fixture(autouse=True)
def _reset_registry():
    tts_registry._reset_for_tests()
    yield
    tts_registry._reset_for_tests()


# ---------------------------------------------------------------------------
# Resolution invariants
# ---------------------------------------------------------------------------


class TestBuiltinAlwaysWins:
    """Built-in TTS provider names short-circuit the dispatcher.

    Even with a plugin registered (which the registry would reject —
    but the dispatcher is defensive), built-in names return None so
    the caller's elif chain handles them natively.
    """

    @pytest.mark.parametrize(
        "builtin",
        ["edge", "openai", "elevenlabs", "minimax", "gemini",
         "mistral", "xai", "piper", "kittentts", "neutts"],
    )
    def test_dispatcher_short_circuits_builtin(self, builtin):
        result = tts_tool._dispatch_to_plugin_provider(
            text="hello",
            output_path="/tmp/out.mp3",
            provider=builtin,
            tts_config={},
        )
        assert result is None, (
            f"Built-in {builtin!r} must short-circuit plugin dispatch. "
            "If this test fails, the dispatcher would silently let a "
            "plugin with a built-in name shadow the native handler — "
            "violating the precedence rule from PR #17843."
        )

    def test_dispatcher_short_circuits_builtin_case_insensitive(self):
        for variant in ("EDGE", "Edge", "  edge  ", "eDgE"):
            assert (
                tts_tool._dispatch_to_plugin_provider(
                    text="hello", output_path="/tmp/x.mp3",
                    provider=variant, tts_config={},
                ) is None
            )


class TestCommandProviderWins:
    """A same-name ``tts.providers.<name>: type: command`` config beats a plugin.

    Locality: a user's command-provider config is more specific than
    whichever plugin happens to be installed.
    """

    def test_command_config_beats_plugin(self):
        tts_registry.register_provider(_FakeTTSProvider(name="my-tts"))

        result = tts_tool._dispatch_to_plugin_provider(
            text="hello",
            output_path="/tmp/out.mp3",
            provider="my-tts",
            tts_config={
                "providers": {
                    "my-tts": {
                        "type": "command",
                        "command": "echo 'hi' > {output_path}",
                    },
                },
            },
        )
        # Plugin path returns None → caller falls back to command
        # provider dispatch (handled by the outer text_to_speech_tool
        # via _resolve_command_provider_config).
        assert result is None


class TestPluginDispatch:
    """Happy path: configured name matches a registered plugin, dispatcher fires."""

    def test_registered_plugin_called(self):
        provider = _FakeTTSProvider(name="cartesia")
        tts_registry.register_provider(provider)

        result = tts_tool._dispatch_to_plugin_provider(
            text="hello world",
            output_path="/tmp/out.mp3",
            provider="cartesia",
            tts_config={},
        )
        assert result == "/tmp/out.mp3"
        assert provider.last_call is not None
        assert provider.last_call["text"] == "hello world"
        assert provider.last_call["output_path"] == "/tmp/out.mp3"

    def test_unregistered_name_returns_none(self):
        result = tts_tool._dispatch_to_plugin_provider(
            text="hello",
            output_path="/tmp/out.mp3",
            provider="unknown-tts",
            tts_config={},
        )
        assert result is None


    def test_provider_exception_bubbles_up(self):
        """Plugin exceptions are NOT swallowed by the dispatcher — they bubble
        up so the outer ``text_to_speech_tool`` try/except converts them to
        the standard error envelope. Matches command-provider failure
        behavior."""
        provider = _FakeTTSProvider(
            name="cartesia",
            raise_exc=RuntimeError("network down"),
        )
        tts_registry.register_provider(provider)

        with pytest.raises(RuntimeError, match="network down"):
            tts_tool._dispatch_to_plugin_provider(
                text="hi",
                output_path="/tmp/out.mp3",
                provider="cartesia",
                tts_config={},
            )


# ---------------------------------------------------------------------------
# voice_compatible flag
# ---------------------------------------------------------------------------


class TestVoiceCompatibleHelper:
    def test_voice_compatible_true(self):
        tts_registry.register_provider(
            _FakeTTSProvider(name="cartesia", voice_compat=True)
        )
        assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is True


    def test_unregistered_provider_returns_false(self):
        assert tts_tool._plugin_provider_is_voice_compatible("unknown") is False


    def test_provider_property_exception_returns_false(self):
        """A buggy ``voice_compatible`` property raising must not crash the
        TTS pipeline."""

        class _ExplodingProvider(_FakeTTSProvider):
            @property
            def voice_compatible(self) -> bool:
                raise RuntimeError("boom")

        tts_registry.register_provider(_ExplodingProvider(name="cartesia"))
        assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is False
