"""Tests for FallbackLLMProvider.embed() cascade skip logic (SUPPORTS_EMBED).

Covers:
- Providers with SUPPORTS_EMBED=False are skipped (no .embed() call)
- Providers with SUPPORTS_EMBED=True are called normally
- Cascade order is preserved after skipping
- All-providers-unsupported raises NotImplementedError
- Mixed cascade: skip → success returns correct result
- Skip reason appears in last_exc message
"""

from __future__ import annotations

import logging
from unittest.mock import AsyncMock, patch

import pytest

from cortex.llm.fallback import FallbackLLMProvider
from cortex.llm.provider import EmbedResponse, LLMProvider


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

class _EmbedCapableProvider(LLMProvider):
    """Stub provider that supports embeddings."""
    SUPPORTS_EMBED = True

    def __init__(self, name: str = "capable", response: EmbedResponse | None = None):
        super().__init__()
        self._name = name
        self._response = response or EmbedResponse(
            embeddings=[[0.1, 0.2, 0.3]],
            model="test-model",
            tokens_in=5,
        )
        # Override the method after init with a mock
        self.embed = AsyncMock(return_value=self._response)
        self._usage_log = []

    async def complete(self, *a, **kw):
        raise NotImplementedError

    async def extract(self, *a, **kw):
        raise NotImplementedError

    async def embed(self, *a, **kw):
        # Placeholder — overridden in __init__ by AsyncMock
        raise NotImplementedError  # pragma: no cover

    def __repr__(self) -> str:
        return f"<{self._name}>"


class _NoEmbedProvider(LLMProvider):
    """Stub provider that does NOT support embeddings."""
    SUPPORTS_EMBED = False

    def __init__(self, name: str = "no-embed"):
        super().__init__()
        self._name = name
        # Override with mock — should never be called due to SUPPORTS_EMBED=False
        self.embed = AsyncMock(side_effect=AssertionError("Should never be called"))
        self._usage_log = []

    async def complete(self, *a, **kw):
        raise NotImplementedError

    async def extract(self, *a, **kw):
        raise NotImplementedError

    async def embed(self, *a, **kw):
        raise AssertionError("Should never be called")  # pragma: no cover

    def __repr__(self) -> str:
        return f"<{self._name}>"


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestEmbedCascadeSkip:
    """SUPPORTS_EMBED=False providers are skipped without a network call."""

    @pytest.mark.asyncio
    async def test_skip_unsupported_provider(self):
        """Provider with SUPPORTS_EMBED=False should never have .embed() called."""
        no_embed = _NoEmbedProvider("venice")
        capable = _EmbedCapableProvider("openai")

        fallback = FallbackLLMProvider(providers=[no_embed, capable])
        result = await fallback.embed(texts=["hello"])

        # The unsupported provider was skipped
        no_embed.embed.assert_not_called()
        # The capable provider was used
        capable.embed.assert_awaited_once()
        assert result.embeddings == [[0.1, 0.2, 0.3]]

    @pytest.mark.asyncio
    async def test_capable_provider_called_directly(self):
        """Provider with SUPPORTS_EMBED=True should be called normally."""
        capable = _EmbedCapableProvider("openai")
        fallback = FallbackLLMProvider(providers=[capable])

        result = await fallback.embed(texts=["test"])

        capable.embed.assert_awaited_once_with(texts=["test"], model=None, input_type="query")
        assert result.model == "test-model"

    @pytest.mark.asyncio
    async def test_cascade_order_preserved_after_skip(self):
        """After skipping an unsupported provider, the next capable one is tried."""
        no_embed_1 = _NoEmbedProvider("venice")
        no_embed_2 = _NoEmbedProvider("ollama-nonembed")
        capable = _EmbedCapableProvider("openai")

        fallback = FallbackLLMProvider(
            providers=[no_embed_1, no_embed_2, capable]
        )
        result = await fallback.embed(texts=["cascade test"])

        # Both unsupported providers skipped
        no_embed_1.embed.assert_not_called()
        no_embed_2.embed.assert_not_called()
        # Capable provider was the one that succeeded
        capable.embed.assert_awaited_once()
        assert fallback.active_provider is capable

    @pytest.mark.asyncio
    async def test_all_unsupported_raises(self):
        """If every provider has SUPPORTS_EMBED=False, embed() raises."""
        no_embed_1 = _NoEmbedProvider("venice")
        no_embed_2 = _NoEmbedProvider("local-llm")

        fallback = FallbackLLMProvider(providers=[no_embed_1, no_embed_2])

        with pytest.raises((NotImplementedError, RuntimeError)):
            await fallback.embed(texts=["should fail"])

        no_embed_1.embed.assert_not_called()
        no_embed_2.embed.assert_not_called()

    @pytest.mark.asyncio
    async def test_skip_logs_debug_message(self, caplog):
        """Skipping a SUPPORTS_EMBED=False provider emits a DEBUG log."""
        no_embed = _NoEmbedProvider("venice")
        capable = _EmbedCapableProvider("openai")

        fallback = FallbackLLMProvider(providers=[no_embed, capable])

        with caplog.at_level(logging.DEBUG, logger="cortex.llm.fallback"):
            await fallback.embed(texts=["log test"])

        skip_msgs = [r for r in caplog.records if "SUPPORTS_EMBED=False" in r.message]
        assert len(skip_msgs) >= 1

    @pytest.mark.asyncio
    async def test_capable_failure_falls_through(self):
        """A capable provider that raises still cascades to the next capable one."""
        failing = _EmbedCapableProvider("failing-openai")
        failing.embed = AsyncMock(side_effect=RuntimeError("API down"))
        failing._usage_log = []

        succeeding = _EmbedCapableProvider("backup-openai")

        fallback = FallbackLLMProvider(providers=[failing, succeeding])
        result = await fallback.embed(texts=["fallthrough"])

        failing.embed.assert_awaited_once()
        succeeding.embed.assert_awaited_once()
        assert result.embeddings == [[0.1, 0.2, 0.3]]

    @pytest.mark.asyncio
    async def test_mixed_skip_failure_success(self):
        """Skip (SUPPORTS_EMBED=False) → failure → success cascade."""
        no_embed = _NoEmbedProvider("venice")
        failing = _EmbedCapableProvider("failing")
        failing.embed = AsyncMock(side_effect=RuntimeError("timeout"))
        failing._usage_log = []

        expected = EmbedResponse(
            embeddings=[[0.9, 0.8, 0.7]],
            model="final-model",
            tokens_in=3,
        )
        succeeding = _EmbedCapableProvider("backup", response=expected)

        fallback = FallbackLLMProvider(
            providers=[no_embed, failing, succeeding]
        )
        result = await fallback.embed(texts=["mixed"])

        no_embed.embed.assert_not_called()
        failing.embed.assert_awaited_once()
        succeeding.embed.assert_awaited_once()
        assert result.embeddings == [[0.9, 0.8, 0.7]]
        assert result.model == "final-model"

    @pytest.mark.asyncio
    async def test_active_provider_set_on_success(self):
        """active_provider is set to the provider that succeeded."""
        no_embed = _NoEmbedProvider("skip-me")
        capable = _EmbedCapableProvider("winner")

        fallback = FallbackLLMProvider(providers=[no_embed, capable])
        await fallback.embed(texts=["active check"])

        assert fallback.active_provider is capable
