"""Fallback chain -- automatic rerouting when primary routing target fails.

Priority order:
1. Local Ollama (primary)
2. Other local instances (future: LAN discovery)
3. Cloud API via BYOK (cheapest first)
4. OpenRouter as universal fallback

Detect failure within 3 seconds. Reroute invisibly.
"""

from __future__ import annotations

import logging
import time
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, ConfigDict, Field

from llmhosts.proxy.models import UnifiedRequest, UnifiedResponse

if TYPE_CHECKING:
    from llmhosts.router.models import BackendInfo

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Priority constants -- lower = tried first
# ---------------------------------------------------------------------------

PRIORITY_LOCAL_OLLAMA = 10
PRIORITY_LOCAL_OTHER = 20
PRIORITY_CLOUD_CHEAPEST = 50
PRIORITY_OPENROUTER = 100


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------


class FallbackTarget(BaseModel):
    """A single target in the fallback chain."""

    model_config = ConfigDict(frozen=False)

    backend_type: str
    url: str
    model: str
    priority: int
    timeout_seconds: float = 3.0


class FallbackFailure(BaseModel):
    """Record of a single failed attempt."""

    backend_type: str
    error: str
    latency_ms: float


class FallbackResult(BaseModel):
    """Metadata returned alongside the successful response."""

    attempts: int
    targets_tried: list[str]
    final_target: str
    total_latency_ms: float
    failures: list[FallbackFailure] = Field(default_factory=list)


# ---------------------------------------------------------------------------
# Circuit breaker
# ---------------------------------------------------------------------------


class CircuitBreaker:
    """Simple circuit breaker: after *failure_threshold* consecutive failures,
    mark the backend as **open** (skip it) for *cooldown_seconds*.

    States
    ------
    - **closed** (normal): requests flow through.
    - **open**: requests are rejected immediately.
    - **half-open**: after the cooldown, the next request is allowed through
      as a probe.  Success -> closed.  Failure -> open again.
    """

    def __init__(self, failure_threshold: int = 3, cooldown_seconds: float = 30.0) -> None:
        self._failure_threshold = failure_threshold
        self._cooldown_seconds = cooldown_seconds
        self._consecutive_failures: int = 0
        self._opened_at: float | None = None  # monotonic timestamp
        self._state: str = "closed"  # "closed", "open", "half_open"

    # -- public API ---------------------------------------------------------

    @property
    def state(self) -> str:
        """Return the current state, transitioning from open -> half_open if cooldown expired."""
        if self._state == "open" and self._opened_at is not None:
            elapsed = time.monotonic() - self._opened_at
            if elapsed >= self._cooldown_seconds:
                self._state = "half_open"
        return self._state

    def is_open(self) -> bool:
        """Return True if the circuit is open (backend should be skipped)."""
        return self.state == "open"

    def is_closed(self) -> bool:
        """Return True if the circuit is closed (backend can be used)."""
        return self.state == "closed"

    def allow_request(self) -> bool:
        """Return True if a request should be allowed through.

        In closed or half-open state a request is allowed.
        In open state the request is blocked.
        """
        current = self.state
        return current in ("closed", "half_open")

    def record_failure(self) -> None:
        """Record a failure. Transition to open if threshold is reached."""
        self._consecutive_failures += 1
        if self._consecutive_failures >= self._failure_threshold:
            self._state = "open"
            self._opened_at = time.monotonic()
            logger.warning(
                "Circuit breaker opened after %d consecutive failures (cooldown=%.1fs)",
                self._consecutive_failures,
                self._cooldown_seconds,
            )

    def record_success(self) -> None:
        """Record a success. Resets failure count and closes the circuit."""
        self._consecutive_failures = 0
        self._state = "closed"
        self._opened_at = None

    def reset(self) -> None:
        """Force-reset to closed state."""
        self._consecutive_failures = 0
        self._state = "closed"
        self._opened_at = None

    def __repr__(self) -> str:
        return (
            f"CircuitBreaker(state={self.state!r}, failures={self._consecutive_failures}, "
            f"threshold={self._failure_threshold}, cooldown={self._cooldown_seconds}s)"
        )


# ---------------------------------------------------------------------------
# Fallback chain
# ---------------------------------------------------------------------------

# Type alias for the dispatch function that the caller provides.
# Signature: async def dispatch(request, backend_type, url, model, timeout) -> UnifiedResponse
DispatchFn = Callable[
    [UnifiedRequest, str, str, str, float],
    Coroutine[Any, Any, UnifiedResponse],
]


class FallbackChain:
    """Automatic fallback when primary routing target fails.

    Usage::

        chain_builder = FallbackChain()
        targets = chain_builder.build_chain(backends, preferred_model="llama3.2:8b")
        response, result = await chain_builder.execute_with_fallback(request, targets, dispatch_fn)
    """

    def __init__(self) -> None:
        self._failure_counts: dict[str, int] = {}
        self._circuit_breakers: dict[str, CircuitBreaker] = {}

    # -- circuit breaker management -----------------------------------------

    def _get_breaker(self, backend_type: str) -> CircuitBreaker:
        """Get or create a circuit breaker for the given backend."""
        if backend_type not in self._circuit_breakers:
            self._circuit_breakers[backend_type] = CircuitBreaker()
        return self._circuit_breakers[backend_type]

    def record_failure(self, backend_type: str) -> None:
        """Record a failure for circuit breaker tracking."""
        self._failure_counts[backend_type] = self._failure_counts.get(backend_type, 0) + 1
        self._get_breaker(backend_type).record_failure()

    def record_success(self, backend_type: str) -> None:
        """Record success, reset failure count."""
        self._failure_counts[backend_type] = 0
        self._get_breaker(backend_type).record_success()

    def get_failure_count(self, backend_type: str) -> int:
        """Return the total failure count for a backend."""
        return self._failure_counts.get(backend_type, 0)

    # -- chain building -----------------------------------------------------

    def build_chain(
        self,
        backends: dict[str, BackendInfo],
        preferred_model: str,
    ) -> list[FallbackTarget]:
        """Build an ordered fallback chain for a specific request.

        Priority order:
        1. Local Ollama (primary)
        2. Other local backends
        3. Cloud APIs sorted by cheapest first (simplified: openai < anthropic < openrouter)
        4. OpenRouter as universal fallback

        Backends whose circuit breaker is open are placed at the end of the chain
        (they will only be tried if everything else fails and the breaker allows a
        half-open probe).
        """
        targets: list[FallbackTarget] = []
        deferred: list[FallbackTarget] = []

        for backend_type, info in backends.items():
            if not info.is_healthy:
                continue

            # Determine the model to request from this backend
            model = self._resolve_model_for_backend(info, preferred_model)

            # Assign priority tier
            if backend_type == "ollama":
                priority = PRIORITY_LOCAL_OLLAMA
            elif info.is_local:
                priority = PRIORITY_LOCAL_OTHER
            elif backend_type == "openrouter":
                priority = PRIORITY_OPENROUTER
            else:
                priority = PRIORITY_CLOUD_CHEAPEST

            target = FallbackTarget(
                backend_type=backend_type,
                url=info.url,
                model=model,
                priority=priority,
                timeout_seconds=3.0 if info.is_local else 10.0,
            )

            # Circuit-broken backends go to the deferred list
            breaker = self._get_breaker(backend_type)
            if breaker.is_open():
                deferred.append(target)
            else:
                targets.append(target)

        # Sort primary targets by priority (lower = first)
        targets.sort(key=lambda t: t.priority)
        # Append deferred (circuit-open) targets at the end
        deferred.sort(key=lambda t: t.priority)
        targets.extend(deferred)

        return targets

    # -- execution ----------------------------------------------------------

    async def execute_with_fallback(
        self,
        request: UnifiedRequest,
        chain: list[FallbackTarget],
        dispatch_fn: DispatchFn,
    ) -> tuple[UnifiedResponse, FallbackResult]:
        """Try each target in chain until one succeeds.

        Parameters
        ----------
        request:
            The unified request to dispatch.
        chain:
            Ordered list of fallback targets (from :meth:`build_chain`).
        dispatch_fn:
            Async callable with signature
            ``(request, backend_type, url, model, timeout) -> UnifiedResponse``.
            Should raise on failure (timeout, HTTP error, etc.).

        Returns
        -------
        tuple:
            ``(UnifiedResponse, FallbackResult)`` -- the successful response
            plus metadata about all attempts.

        Raises
        ------
        RuntimeError:
            If every target in the chain fails.
        """
        failures: list[FallbackFailure] = []
        targets_tried: list[str] = []
        overall_start = time.perf_counter()

        for target in chain:
            breaker = self._get_breaker(target.backend_type)
            if not breaker.allow_request():
                logger.debug(
                    "Skipping %s -- circuit breaker open",
                    target.backend_type,
                )
                continue

            targets_tried.append(target.backend_type)
            attempt_start = time.perf_counter()

            try:
                logger.debug(
                    "Fallback attempt: %s (model=%s, timeout=%.1fs)",
                    target.backend_type,
                    target.model,
                    target.timeout_seconds,
                )
                response = await dispatch_fn(
                    request,
                    target.backend_type,
                    target.url,
                    target.model,
                    target.timeout_seconds,
                )
                # Success
                self.record_success(target.backend_type)
                total_latency = (time.perf_counter() - overall_start) * 1000

                result = FallbackResult(
                    attempts=len(targets_tried),
                    targets_tried=targets_tried,
                    final_target=target.backend_type,
                    total_latency_ms=total_latency,
                    failures=failures,
                )
                if len(targets_tried) > 1:
                    logger.info(
                        "Fallback succeeded on %s after %d attempt(s) (%.1fms total)",
                        target.backend_type,
                        len(targets_tried),
                        total_latency,
                    )
                return response, result

            except Exception as exc:
                attempt_latency = (time.perf_counter() - attempt_start) * 1000
                error_msg = f"{type(exc).__name__}: {exc}"
                logger.warning(
                    "Fallback target %s failed in %.1fms: %s",
                    target.backend_type,
                    attempt_latency,
                    error_msg,
                )
                self.record_failure(target.backend_type)
                failures.append(
                    FallbackFailure(
                        backend_type=target.backend_type,
                        error=error_msg,
                        latency_ms=attempt_latency,
                    )
                )

        # All targets exhausted
        total_latency = (time.perf_counter() - overall_start) * 1000
        tried_str = ", ".join(targets_tried) if targets_tried else "(none)"
        error_msg = f"All fallback targets exhausted. Tried: {tried_str}. Total latency: {total_latency:.1f}ms"
        logger.error(error_msg)
        raise RuntimeError(error_msg)

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

    @staticmethod
    def _resolve_model_for_backend(info: BackendInfo, preferred_model: str) -> str:
        """Determine which model name to use for a given backend.

        If the preferred model is in the backend's model list, use it directly.
        Otherwise fall back to the preferred model name unchanged (the backend
        may still accept it, or the dispatch function will raise an error).
        """
        if preferred_model in info.models:
            return preferred_model

        # Try base-name match (e.g. "llama3.2" matches "llama3.2:8b")
        preferred_base = preferred_model.split(":")[0].lower()
        for m in info.models:
            if m.lower().split(":")[0] == preferred_base:
                return m

        return preferred_model
