"""
cortex/llm/fallback.py — FallbackLLMProvider: Cascading provider wrapper.

Wraps multiple LLMProvider instances and tries them in priority order,
falling back to the next provider on any exception. This enables graceful
degradation when a primary provider is unavailable (missing API key, quota
exhausted, network error) without crashing the plugin.

Usage::

    from cortex.llm.fallback import FallbackLLMProvider, build_provider_cascade

    # Build from an ordered list of provider names (config-driven):
    provider = build_provider_cascade(["anthropic", "openai", "ollama"])

    # Or assemble manually:
    provider = FallbackLLMProvider([anthropic_instance, openai_instance])

Design:
    - Each public method (complete / extract / embed) tries providers in order.
    - On the first successful call, returns that response immediately.
    - If ALL providers fail, re-raises the last exception so the caller can
      handle it (or let CortexPlugin's try/except catch it).
    - Individual provider failures are logged at WARNING level (not ERROR) so
      normal fallback is not alarming in logs.
    - The active_provider property exposes which provider last succeeded —
      useful for health checks and observability.

Thread / async safety: FallbackLLMProvider is stateless between calls (each
call is independent). The ``active_provider`` attribute is set per-call and
may race in concurrent contexts — it is advisory only, not a guarantee.

Dependencies: cortex.llm.provider (ABC only — no vendor SDK imports here).
Used by: cortex.api.plugin (CortexPlugin._build_llm).
"""

from __future__ import annotations

import logging
from typing import Any, List, Optional

from cortex.llm.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError, classify_error, FailureType
from cortex.llm.provider import (
    CompletionResponse,
    EmbedResponse,
    ExtractionResponse,
    LLMProvider,
    UsageRecord,
)

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Supported provider names → import paths
# ---------------------------------------------------------------------------

_PROVIDER_FACTORIES: dict[str, str] = {
    "anthropic": "cortex.llm.anthropic_provider.AnthropicProvider",
    "openai": "cortex.llm.openai_provider.OpenAIProvider",
    "venice": "cortex.llm.venice_provider.VeniceProvider",
    "ollama": "cortex.llm.ollama_provider.OllamaProvider",
}


def _import_provider_class(dotted_path: str) -> type:
    """Import and return a class given its fully-qualified dotted path."""
    module_path, cls_name = dotted_path.rsplit(".", 1)
    import importlib
    module = importlib.import_module(module_path)
    return getattr(module, cls_name)


def build_provider_cascade(
    priority: List[str],
    *,
    completion_model: str | None = None,
    embed_model: str | None = None,
    strict: bool = False,
    circuit_breaker: Optional[CircuitBreaker] = None,
) -> "FallbackLLMProvider":
    """
    Instantiate providers in *priority* order and return a FallbackLLMProvider.

    Providers that fail to import or instantiate (e.g., SDK not installed,
    API key missing) are silently skipped unless *strict=True*.

    Args:
        priority: Ordered list of provider names (e.g. ["anthropic", "openai"]).
                  Unknown names are logged and skipped.
        strict:   If True, raise on the first provider that fails to init.
                  Default False — skip broken providers and try the next.

    Returns:
        FallbackLLMProvider wrapping all successfully initialised providers.

    Raises:
        RuntimeError: If *priority* is empty or no provider could be initialised.
    """
    if not priority:
        raise RuntimeError(
            "llm_provider_priority is empty — cannot build LLM provider cascade. "
            "Add at least one provider to [cortex.llm] provider_priority in config."
        )

    # Prefixes indicating OpenAI/Voyage embed models that Ollama can't use
    _CLOUD_EMBED_PREFIXES = ("text-embedding-", "voyage-")

    providers: List[LLMProvider] = []
    for name in priority:
        dotted = _PROVIDER_FACTORIES.get(name.lower())
        if dotted is None:
            logger.warning(
                "FallbackLLMProvider: unknown provider name %r — skipping. "
                "Valid names: %s",
                name,
                ", ".join(_PROVIDER_FACTORIES),
            )
            continue
        try:
            cls = _import_provider_class(dotted)
            kwargs = {}
            if completion_model:
                kwargs["completion_model"] = completion_model
            # For Ollama, don't pass embed_model if it's a cloud-only model
            if embed_model:
                if name.lower() == "ollama" and any(
                    embed_model.startswith(p) for p in _CLOUD_EMBED_PREFIXES
                ):
                    logger.info(
                        "FallbackLLMProvider: skipping embed_model='%s' for Ollama "
                        "(incompatible cloud model). Ollama will use its default.",
                        embed_model,
                    )
                    # Don't pass embed_model — let Ollama use its default
                else:
                    kwargs["embed_model"] = embed_model
            try:
                instance = cls(**kwargs)
            except TypeError:
                # Provider doesn't accept these kwargs (e.g. Ollama)
                instance = cls()
            providers.append(instance)
            logger.debug("FallbackLLMProvider: initialised provider %r", name)
        except Exception as exc:
            if strict:
                raise
            logger.warning(
                "FallbackLLMProvider: could not initialise %r (%s) — skipping",
                name,
                exc,
            )

    if not providers:
        raise RuntimeError(
            "FallbackLLMProvider: no LLM provider could be initialised from "
            f"priority list {priority!r}. "
            "Check that at least one provider's SDK is installed and its API "
            "key is set in the environment."
        )

    return FallbackLLMProvider(providers, circuit_breaker=circuit_breaker)


# ---------------------------------------------------------------------------
# FallbackLLMProvider
# ---------------------------------------------------------------------------


class FallbackLLMProvider(LLMProvider):
    """
    An LLMProvider that cascades across multiple providers.

    Tries each provider in *providers* order. Returns the first successful
    response. Raises the last exception if all providers fail.

    Attributes:
        providers: Ordered list of delegate providers.
        active_provider: The provider that last returned a successful response
                         (None until first successful call). Advisory only.
    """

    DEFAULT_COMPLETION_MODEL: str = ""  # delegates to sub-providers
    DEFAULT_EMBED_MODEL: str = ""       # delegates to sub-providers
    PROVIDER_NAME: str = "fallback"

    def __init__(
        self,
        providers: List[LLMProvider],
        circuit_breaker: Optional[CircuitBreaker] = None,
    ) -> None:
        super().__init__()
        if not providers:
            raise ValueError("FallbackLLMProvider requires at least one provider.")
        self.providers: List[LLMProvider] = list(providers)
        self.active_provider: Optional[LLMProvider] = None
        self._breaker: Optional[CircuitBreaker] = circuit_breaker

    # --- helpers ------------------------------------------------------------

    def _provider_name(self, p: LLMProvider) -> str:
        """Return a human-readable name for *p*."""
        return getattr(p, "PROVIDER_NAME", type(p).__name__)

    # --- LLMProvider interface ----------------------------------------------

    async def complete(
        self,
        system: str,
        user: str,
        model: str | None = None,
        response_format: str = "text",
        max_tokens: int | None = None,
        temperature: float = 0.0,
    ) -> CompletionResponse:
        """Try providers in order; return first success or raise last error.

        Circuit breaker integration: complete() is a write-path operation.
        If the breaker is OPEN for a provider, that provider is skipped
        (no network call). Failures are recorded; successes reset the breaker.
        """
        last_exc: Exception = RuntimeError("No providers available")
        for provider in self.providers:
            name = self._provider_name(provider)
            # Skip providers whose breaker is open (write path)
            if self._breaker and not self._breaker.allow_write(name):
                logger.debug(
                    "FallbackLLMProvider.complete: skipping %s (circuit breaker open)",
                    name,
                )
                last_exc = CircuitBreakerOpenError(self._breaker.write_error(name))
                continue
            try:
                result = await provider.complete(
                    system=system,
                    user=user,
                    model=model,
                    response_format=response_format,
                    max_tokens=max_tokens,
                    temperature=temperature,
                )
                self.active_provider = provider
                # Mirror usage into our own log for aggregate tracking
                self._usage_log.extend(provider._usage_log[-1:])
                if self._breaker:
                    self._breaker.record_success(name)
                return result
            except Exception as exc:
                logger.warning(
                    "FallbackLLMProvider.complete: %s failed (%s), trying next",
                    self._provider_name(provider),
                    exc,
                )
                if self._breaker:
                    self._breaker.record_failure(name, exc)
                last_exc = exc
        raise last_exc

    async def extract(
        self,
        system: str,
        user: str,
        schema: dict[str, Any],
        model: str | None = None,
        max_tokens: int | None = None,
        temperature: float = 0.0,
    ) -> ExtractionResponse:
        """Try providers in order; return first success or raise last error.

        Circuit breaker integration: extract() is a write-path operation.
        Providers with open breakers are skipped. Failures/successes tracked.
        """
        last_exc: Exception = RuntimeError("No providers available")
        for provider in self.providers:
            name = self._provider_name(provider)
            if self._breaker and not self._breaker.allow_write(name):
                logger.debug(
                    "FallbackLLMProvider.extract: skipping %s (circuit breaker open)",
                    name,
                )
                last_exc = CircuitBreakerOpenError(self._breaker.write_error(name))
                continue
            try:
                result = await provider.extract(
                    system=system,
                    user=user,
                    schema=schema,
                    model=model,
                    max_tokens=max_tokens,
                    temperature=temperature,
                )
                self.active_provider = provider
                self._usage_log.extend(provider._usage_log[-1:])
                if self._breaker:
                    self._breaker.record_success(name)
                return result
            except Exception as exc:
                logger.warning(
                    "FallbackLLMProvider.extract: %s failed (%s), trying next",
                    self._provider_name(provider),
                    exc,
                )
                if self._breaker:
                    self._breaker.record_failure(name, exc)
                last_exc = exc
        raise last_exc

    async def embed(
        self,
        texts: list[str],
        model: str | None = None,
        input_type: str = "query",
    ) -> EmbedResponse:
        """Try providers in order; return first success or raise last error.

        Circuit breaker integration: embed() is a read-path operation.
        Providers are NEVER skipped by the breaker (reads always allowed).
        Failures and successes are still tracked for observability.
        """
        last_exc: Exception = RuntimeError("No providers available")
        for provider in self.providers:
            try:
                result = await provider.embed(
                    texts=texts,
                    model=model,
                    input_type=input_type,
                )
                self.active_provider = provider
                self._usage_log.extend(provider._usage_log[-1:])
                name = self._provider_name(provider)
                if self._breaker:
                    self._breaker.record_success(name)
                return result
            except Exception as exc:
                name = self._provider_name(provider)
                logger.warning(
                    "FallbackLLMProvider.embed: %s failed (%s), trying next",
                    name,
                    exc,
                )
                if self._breaker:
                    self._breaker.record_failure(name, exc)
                last_exc = exc
        raise last_exc

    # --- observability ------------------------------------------------------

    def provider_names(self) -> List[str]:
        """Return the name of each delegate provider, in cascade order."""
        return [self._provider_name(p) for p in self.providers]

    def usage_summary(self) -> dict[str, Any]:
        """Aggregate usage across all delegate providers."""
        total_cost = sum(r.cost_estimate for r in self._usage_log)
        total_in = sum(r.tokens_in for r in self._usage_log)
        total_out = sum(r.tokens_out for r in self._usage_log)
        active = (
            self._provider_name(self.active_provider)
            if self.active_provider
            else None
        )
        return {
            "calls": len(self._usage_log),
            "tokens_in": total_in,
            "tokens_out": total_out,
            "cost_usd": round(total_cost, 6),
            "active_provider": active,
            "cascade": self.provider_names(),
        }
