# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Model Router (v1.4.0 Phase 4 - Enhancement #15)

Intelligent model selection and automatic fallback for cost/latency optimization:
- Rule-based routing with query analysis
- Automatic fallback chains on failure
- Cost and latency tracking per model
- Load balancing strategies
- Circuit breaker integration
- Provider health monitoring

Usage:
    from familiar.core import ModelRouter, QueryContext, RoutingStrategy

    router = ModelRouter()

    # Add models with routing rules
    router.add_model(
        "claude-opus",
        provider="anthropic",
        predicate=lambda q: q.complexity > 0.8 or q.requires_reasoning
    )
    router.add_model(
        "claude-haiku",
        provider="anthropic",
        predicate=lambda q: q.is_simple
    )
    router.add_model(
        "ollama/llama3",
        provider="ollama",
        predicate=lambda q: q.is_private
    )

    # Set fallback chain
    router.set_fallback_chain(["claude-sonnet", "claude-haiku", "ollama/llama3"])

    # Route a query
    model, provider = router.route(query, context={"user_tier": "premium"})

    # Or use with automatic execution and fallback
    response = await router.execute(query, messages=[...])
"""

import asyncio
import logging
import re
import statistics
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Tuple, Union

logger = logging.getLogger(__name__)


# ============================================================
# CONSTANTS
# ============================================================


def _utcnow() -> datetime:
    """Get current UTC time as timezone-aware datetime."""
    return datetime.now(timezone.utc)


# Model pricing (USD per 1K tokens) - updated Jan 2026
MODEL_COSTS: Dict[str, Dict[str, float]] = {
    # Anthropic
    "claude-opus": {"input": 0.015, "output": 0.075},
    "claude-opus-4": {"input": 0.015, "output": 0.075},
    "claude-sonnet": {"input": 0.003, "output": 0.015},
    "claude-sonnet-4": {"input": 0.003, "output": 0.015},
    "claude-haiku": {"input": 0.00025, "output": 0.00125},
    "claude-haiku-4": {"input": 0.00025, "output": 0.00125},
    # OpenAI
    "gpt-4": {"input": 0.03, "output": 0.06},
    "gpt-4-turbo": {"input": 0.01, "output": 0.03},
    "gpt-4o": {"input": 0.005, "output": 0.015},
    "gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
    "gpt-3.5-turbo": {"input": 0.0005, "output": 0.0015},
    # Google Gemini
    "gemini-2.5-flash": {"input": 0.00015, "output": 0.0006},
    "gemini-2.5-pro": {"input": 0.00125, "output": 0.01},
    "gemini-2.0-flash": {"input": 0.0001, "output": 0.0004},
    # Local (free)
    "ollama/llama3.2": {"input": 0.0, "output": 0.0},
    "ollama/deepseek-r1": {"input": 0.0, "output": 0.0},
    "ollama/qwen2.5": {"input": 0.0, "output": 0.0},
    "ollama/qwen3": {"input": 0.0, "output": 0.0},
    "ollama/qwen3.5": {"input": 0.0, "output": 0.0},
    "ollama/mistral": {"input": 0.0, "output": 0.0},
    "ollama/gemma3": {"input": 0.0, "output": 0.0},
    "ollama/phi4": {"input": 0.0, "output": 0.0},
    "ollama/smollm2": {"input": 0.0, "output": 0.0},
    "ollama/tinyllama": {"input": 0.0, "output": 0.0},
    "local": {"input": 0.0, "output": 0.0},
}

# Model capabilities for automatic routing
MODEL_CAPABILITIES: Dict[str, Dict[str, Any]] = {
    "claude-opus": {
        "reasoning": 0.95,
        "coding": 0.90,
        "creativity": 0.95,
        "speed": 0.3,
        "cost_tier": "high",
        "context_window": 200000,
    },
    "claude-sonnet": {
        "reasoning": 0.85,
        "coding": 0.88,
        "creativity": 0.85,
        "speed": 0.7,
        "cost_tier": "medium",
        "context_window": 200000,
    },
    "claude-haiku": {
        "reasoning": 0.70,
        "coding": 0.75,
        "creativity": 0.70,
        "speed": 0.95,
        "cost_tier": "low",
        "context_window": 200000,
    },
    "gpt-4o": {
        "reasoning": 0.88,
        "coding": 0.85,
        "creativity": 0.85,
        "speed": 0.75,
        "cost_tier": "medium",
        "context_window": 128000,
    },
    "gpt-4o-mini": {
        "reasoning": 0.72,
        "coding": 0.70,
        "creativity": 0.70,
        "speed": 0.90,
        "cost_tier": "low",
        "context_window": 128000,
    },
    "gemini-2.5-flash": {
        "reasoning": 0.82,
        "coding": 0.80,
        "creativity": 0.80,
        "speed": 0.90,
        "cost_tier": "low",
        "context_window": 1000000,
    },
    "gemini-2.5-pro": {
        "reasoning": 0.90,
        "coding": 0.88,
        "creativity": 0.88,
        "speed": 0.60,
        "cost_tier": "medium",
        "context_window": 1000000,
    },
    "gemini-2.0-flash": {
        "reasoning": 0.78,
        "coding": 0.75,
        "creativity": 0.75,
        "speed": 0.92,
        "cost_tier": "low",
        "context_window": 1000000,
    },
    "ollama/llama3.2": {
        "reasoning": 0.65,
        "coding": 0.60,
        "creativity": 0.65,
        "speed": 0.50,
        "cost_tier": "free",
        "context_window": 131072,
        "private": True,
    },
    "ollama/deepseek-r1": {
        "reasoning": 0.80,
        "coding": 0.75,
        "creativity": 0.70,
        "speed": 0.35,
        "cost_tier": "free",
        "context_window": 65536,
        "private": True,
    },
    "ollama/qwen3.5": {
        "reasoning": 0.78,
        "coding": 0.78,
        "creativity": 0.75,
        "speed": 0.40,
        "cost_tier": "free",
        "context_window": 131072,
        "private": True,
    },
    "ollama/mistral": {
        "reasoning": 0.68,
        "coding": 0.65,
        "creativity": 0.68,
        "speed": 0.55,
        "cost_tier": "free",
        "context_window": 32768,
        "private": True,
    },
}


# ============================================================
# ENUMS
# ============================================================


class RoutingStrategy(str, Enum):
    """Strategy for selecting among eligible models."""

    FIRST_MATCH = "first_match"  # Use first matching rule
    LOWEST_COST = "lowest_cost"  # Cheapest eligible model
    LOWEST_LATENCY = "lowest_latency"  # Fastest eligible model
    ROUND_ROBIN = "round_robin"  # Rotate among eligible
    WEIGHTED = "weighted"  # Weighted random selection
    CAPABILITY_MATCH = "capability"  # Best capability match


class ModelStatus(str, Enum):
    """Health status of a model endpoint."""

    HEALTHY = "healthy"
    DEGRADED = "degraded"  # High latency or error rate
    UNHEALTHY = "unhealthy"  # Circuit breaker open
    UNKNOWN = "unknown"


class QueryType(str, Enum):
    """Detected type of query for routing."""

    SIMPLE = "simple"  # Quick factual, greeting
    MODERATE = "moderate"  # Standard Q&A
    COMPLEX = "complex"  # Multi-step reasoning
    CODING = "coding"  # Code generation/review
    CREATIVE = "creative"  # Writing, brainstorming
    PRIVATE = "private"  # Contains sensitive data
    LONG_CONTEXT = "long"  # Large input


# ============================================================
# QUERY ANALYSIS
# ============================================================


@dataclass
class QueryContext:
    """
    Analyzed context of a query for routing decisions.

    Can be created manually or via QueryAnalyzer.analyze().
    """

    # Raw query
    query: str = ""

    # Computed properties
    complexity: float = 0.5  # 0-1, higher = more complex
    token_estimate: int = 0  # Estimated tokens
    query_type: QueryType = QueryType.MODERATE

    # Flags for routing predicates
    is_simple: bool = False
    is_complex: bool = False
    is_coding: bool = False
    is_creative: bool = False
    is_private: bool = False
    is_long_context: bool = False
    requires_reasoning: bool = False
    requires_tools: bool = False

    # Optional context
    user_tier: str = "standard"  # standard, premium, enterprise
    priority: str = "normal"  # low, normal, high, critical
    max_cost: Optional[float] = None  # Budget limit for this request
    max_latency_ms: Optional[int] = None
    required_capabilities: List[str] = field(default_factory=list)

    # Metadata
    metadata: Dict[str, Any] = field(default_factory=dict)

    def __post_init__(self):
        # Sync flags with complexity
        if self.complexity < 0.3:
            self.is_simple = True
        elif self.complexity > 0.7:
            self.is_complex = True
            self.requires_reasoning = True


class QueryAnalyzer:
    """
    Analyzes queries to determine routing characteristics.

    Uses heuristics and patterns to estimate:
    - Complexity (simple greeting vs multi-step reasoning)
    - Type (coding, creative, factual)
    - Privacy sensitivity
    - Token count
    """

    # Patterns for query classification
    SIMPLE_PATTERNS = [
        r"^(hi|hello|hey|thanks|thank you|ok|okay|yes|no|sure)\b",
        r"^what (is|are) (the )?(time|date|weather)",
        r"^(who|what|when|where) is \w+\??$",
    ]

    COMPLEX_PATTERNS = [
        r"(explain|analyze|compare|evaluate|assess|critique)",
        r"(step[- ]by[- ]step|in detail|thoroughly)",
        r"(pros and cons|advantages and disadvantages)",
        r"(why|how) (does|do|did|would|could|should)",
        r"(plan|strategy|approach|methodology)",
    ]

    CODING_PATTERNS = [
        r"(code|function|class|method|api|bug|error|exception)",
        r"(python|javascript|typescript|java|rust|go|sql)",
        r"(implement|refactor|debug|optimize|test)",
        r"```",
        r"(def |class |function |const |let |var )",
    ]

    CREATIVE_PATTERNS = [
        r"(write|compose|create|generate|draft)",
        r"(story|poem|essay|article|blog|script)",
        r"(creative|imaginative|original)",
        r"(brainstorm|ideate|suggest ideas)",
    ]

    PRIVATE_PATTERNS = [
        r"\b\d{3}[-.]?\d{2}[-.]?\d{4}\b",  # SSN
        r"\b\d{16}\b",  # Credit card
        r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",  # Email
        r"(password|secret|api[_-]?key|token|credential)",
        r"(confidential|private|sensitive|internal only)",
    ]

    REASONING_PATTERNS = [
        r"(reason|think|consider|analyze|evaluate)",
        r"(logical|mathematical|scientific)",
        r"(proof|theorem|hypothesis|conclusion)",
        r"(if.*then|because|therefore|hence|thus)",
    ]

    TOOL_PATTERNS = [
        r"(search|browse|look up|google|find online)",
        r"(send|read|check).*(email|message|sms)",
        r"(open|visit|go to|navigate|read).*(url|website|page|link|http)",
        r"(schedule|create|add).*(event|meeting|reminder|task)",
        r"(download|upload|save|archive)",
        r"(summarize|extract).*(page|article|website|url)",
        r"(web|internet|online)",
    ]

    def __init__(self, token_counter: Optional[Any] = None):
        """
        Initialize analyzer.

        Args:
            token_counter: Optional TokenCounter instance for accurate counts
        """
        self.token_counter = token_counter

        # Compile patterns
        self._simple_re = [re.compile(p, re.IGNORECASE) for p in self.SIMPLE_PATTERNS]
        self._complex_re = [re.compile(p, re.IGNORECASE) for p in self.COMPLEX_PATTERNS]
        self._coding_re = [re.compile(p, re.IGNORECASE) for p in self.CODING_PATTERNS]
        self._creative_re = [re.compile(p, re.IGNORECASE) for p in self.CREATIVE_PATTERNS]
        self._private_re = [re.compile(p, re.IGNORECASE) for p in self.PRIVATE_PATTERNS]
        self._reasoning_re = [re.compile(p, re.IGNORECASE) for p in self.REASONING_PATTERNS]
        self._tool_re = [re.compile(p, re.IGNORECASE) for p in self.TOOL_PATTERNS]

    def analyze(
        self,
        query: str,
        messages: Optional[List[Dict[str, Any]]] = None,
        context: Optional[Dict[str, Any]] = None,
    ) -> QueryContext:
        """
        Analyze a query and return routing context.

        Args:
            query: The user's query text
            messages: Optional full message history for context
            context: Optional additional context (user_tier, priority, etc.)

        Returns:
            QueryContext with computed properties
        """
        context = context or {}

        # Estimate tokens
        if messages:
            total_text = query + " ".join(
                m.get("content", "") for m in messages if isinstance(m.get("content"), str)
            )
        else:
            total_text = query

        if self.token_counter:
            token_estimate = self.token_counter.count(total_text)
        else:
            # Rough estimate: ~4 chars per token
            token_estimate = len(total_text) // 4

        # Classify query type and complexity
        is_simple = any(p.search(query) for p in self._simple_re)
        is_complex = any(p.search(query) for p in self._complex_re)
        is_coding = any(p.search(query) for p in self._coding_re)
        is_creative = any(p.search(query) for p in self._creative_re)
        is_private = any(p.search(total_text) for p in self._private_re)
        requires_reasoning = any(p.search(query) for p in self._reasoning_re)
        requires_tools = any(p.search(query) for p in self._tool_re)
        is_long_context = token_estimate > 4000

        # Compute complexity score
        complexity = self._compute_complexity(
            query=query,
            token_estimate=token_estimate,
            is_simple=is_simple,
            is_complex=is_complex,
            is_coding=is_coding,
            requires_reasoning=requires_reasoning,
        )

        # Determine primary query type
        if is_coding:
            query_type = QueryType.CODING
        elif is_creative:
            query_type = QueryType.CREATIVE
        elif is_private:
            query_type = QueryType.PRIVATE
        elif is_long_context:
            query_type = QueryType.LONG_CONTEXT
        elif is_simple:
            query_type = QueryType.SIMPLE
        elif is_complex:
            query_type = QueryType.COMPLEX
        else:
            query_type = QueryType.MODERATE

        return QueryContext(
            query=query,
            complexity=complexity,
            token_estimate=token_estimate,
            query_type=query_type,
            is_simple=is_simple,
            is_complex=is_complex or requires_reasoning,
            is_coding=is_coding,
            is_creative=is_creative,
            is_private=is_private,
            is_long_context=is_long_context,
            requires_reasoning=requires_reasoning,
            requires_tools=requires_tools,
            user_tier=context.get("user_tier", "standard"),
            priority=context.get("priority", "normal"),
            max_cost=context.get("max_cost"),
            max_latency_ms=context.get("max_latency_ms"),
            required_capabilities=context.get("required_capabilities", []),
            metadata=context.get("metadata", {}),
        )

    def _compute_complexity(
        self,
        query: str,
        token_estimate: int,
        is_simple: bool,
        is_complex: bool,
        is_coding: bool,
        requires_reasoning: bool,
    ) -> float:
        """Compute complexity score from 0-1."""
        score = 0.5  # Base

        # Adjust based on patterns
        if is_simple:
            score -= 0.3
        if is_complex:
            score += 0.2
        if is_coding:
            score += 0.15
        if requires_reasoning:
            score += 0.2

        # Adjust based on length
        if token_estimate < 50:
            score -= 0.1
        elif token_estimate > 500:
            score += 0.1
        elif token_estimate > 2000:
            score += 0.2

        # Adjust based on question complexity indicators
        question_words = len(re.findall(r"\b(what|why|how|when|where|who|which)\b", query.lower()))
        if question_words > 2:
            score += 0.1

        # Clamp to [0, 1]
        return max(0.0, min(1.0, score))


# ============================================================
# MODEL CONFIGURATION
# ============================================================


@dataclass
class ModelConfig:
    """Configuration for a routable model."""

    # Identity
    name: str  # e.g., "claude-sonnet"
    provider: str  # e.g., "anthropic", "openai", "ollama"

    # Routing predicate - returns True if this model should handle the query
    predicate: Optional[Callable[[QueryContext], bool]] = None

    # Priority (lower = checked first)
    priority: int = 100

    # Weight for weighted routing
    weight: float = 1.0

    # Capabilities (override MODEL_CAPABILITIES)
    capabilities: Dict[str, Any] = field(default_factory=dict)

    # Cost override (override MODEL_COSTS)
    cost_per_1k_input: Optional[float] = None
    cost_per_1k_output: Optional[float] = None

    # Health thresholds
    max_latency_ms: int = 30000  # Consider degraded above this
    max_error_rate: float = 0.1  # Consider unhealthy above this

    # Circuit breaker settings
    circuit_breaker_threshold: int = 5  # Failures before opening
    circuit_breaker_timeout: int = 60  # Seconds before half-open

    # Whether this model can be used as fallback
    allow_fallback: bool = True

    # Enabled
    enabled: bool = True

    def get_cost(self, input_tokens: int, output_tokens: int) -> float:
        """Calculate cost for given token counts."""
        input_cost = self.cost_per_1k_input
        output_cost = self.cost_per_1k_output

        if input_cost is None or output_cost is None:
            costs = MODEL_COSTS.get(self.name, {"input": 0.01, "output": 0.03})
            input_cost = input_cost or costs["input"]
            output_cost = output_cost or costs["output"]

        return (input_tokens / 1000 * input_cost) + (output_tokens / 1000 * output_cost)

    def get_capability(self, capability: str) -> float:
        """Get capability score (0-1)."""
        if capability in self.capabilities:
            return self.capabilities[capability]

        model_caps = MODEL_CAPABILITIES.get(self.name, {})
        return model_caps.get(capability, 0.5)


# ============================================================
# USAGE TRACKING
# ============================================================


@dataclass
class ModelUsageStats:
    """Usage statistics for a model."""

    model: str
    total_requests: int = 0
    successful_requests: int = 0
    failed_requests: int = 0
    total_input_tokens: int = 0
    total_output_tokens: int = 0
    total_cost_usd: float = 0.0
    total_latency_ms: float = 0.0

    # Recent latencies for percentile calculation
    recent_latencies: List[float] = field(default_factory=list)
    max_recent: int = 100

    # Timestamps
    first_used: Optional[datetime] = None
    last_used: Optional[datetime] = None

    @property
    def success_rate(self) -> float:
        if self.total_requests == 0:
            return 1.0
        return self.successful_requests / self.total_requests

    @property
    def error_rate(self) -> float:
        return 1.0 - self.success_rate

    @property
    def avg_latency_ms(self) -> float:
        if self.total_requests == 0:
            return 0.0
        return self.total_latency_ms / self.total_requests

    @property
    def p50_latency_ms(self) -> float:
        if not self.recent_latencies:
            return 0.0
        return statistics.median(self.recent_latencies)

    @property
    def p95_latency_ms(self) -> float:
        if len(self.recent_latencies) < 2:
            return self.avg_latency_ms
        sorted_latencies = sorted(self.recent_latencies)
        idx = int(len(sorted_latencies) * 0.95)
        return sorted_latencies[min(idx, len(sorted_latencies) - 1)]

    @property
    def avg_cost_per_request(self) -> float:
        if self.total_requests == 0:
            return 0.0
        return self.total_cost_usd / self.total_requests

    def record_request(
        self,
        success: bool,
        latency_ms: float,
        input_tokens: int = 0,
        output_tokens: int = 0,
        cost_usd: float = 0.0,
    ):
        """Record a request's metrics."""
        now = _utcnow()

        self.total_requests += 1
        if success:
            self.successful_requests += 1
        else:
            self.failed_requests += 1

        self.total_input_tokens += input_tokens
        self.total_output_tokens += output_tokens
        self.total_cost_usd += cost_usd
        self.total_latency_ms += latency_ms

        # Track recent latencies
        self.recent_latencies.append(latency_ms)
        if len(self.recent_latencies) > self.max_recent:
            self.recent_latencies.pop(0)

        if self.first_used is None:
            self.first_used = now
        self.last_used = now

    def to_dict(self) -> Dict[str, Any]:
        return {
            "model": self.model,
            "total_requests": self.total_requests,
            "successful_requests": self.successful_requests,
            "failed_requests": self.failed_requests,
            "success_rate": round(self.success_rate, 4),
            "total_input_tokens": self.total_input_tokens,
            "total_output_tokens": self.total_output_tokens,
            "total_cost_usd": round(self.total_cost_usd, 6),
            "avg_latency_ms": round(self.avg_latency_ms, 2),
            "p50_latency_ms": round(self.p50_latency_ms, 2),
            "p95_latency_ms": round(self.p95_latency_ms, 2),
            "avg_cost_per_request": round(self.avg_cost_per_request, 6),
            "first_used": self.first_used.isoformat() if self.first_used else None,
            "last_used": self.last_used.isoformat() if self.last_used else None,
        }


# ============================================================
# CIRCUIT BREAKER (MODEL-LEVEL)
# ============================================================


class ModelCircuitBreaker:
    """
    Circuit breaker for model endpoints.

    States:
    - CLOSED: Normal operation, requests pass through
    - OPEN: Too many failures, requests fail fast
    - HALF_OPEN: Testing if service recovered
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        timeout_seconds: int = 60,
        half_open_max_calls: int = 3,
    ):
        self.failure_threshold = failure_threshold
        self.timeout_seconds = timeout_seconds
        self.half_open_max_calls = half_open_max_calls

        self._failures = 0
        self._successes = 0
        self._state = "closed"
        self._opened_at: Optional[datetime] = None
        self._half_open_calls = 0
        self._lock = threading.Lock()

    @property
    def state(self) -> str:
        with self._lock:
            if self._state == "open":
                # Check if timeout elapsed
                if self._opened_at and _utcnow() > self._opened_at + timedelta(
                    seconds=self.timeout_seconds
                ):
                    self._state = "half_open"
                    self._half_open_calls = 0
            return self._state

    @property
    def is_closed(self) -> bool:
        return self.state == "closed"

    @property
    def is_open(self) -> bool:
        return self.state == "open"

    def allow_request(self) -> bool:
        """Check if a request should be allowed."""
        state = self.state

        if state == "closed":
            return True
        elif state == "open":
            return False
        else:  # half_open
            with self._lock:
                if self._half_open_calls < self.half_open_max_calls:
                    self._half_open_calls += 1
                    return True
                return False

    def record_success(self):
        """Record a successful request."""
        with self._lock:
            if self._state == "half_open":
                self._successes += 1
                if self._successes >= self.half_open_max_calls:
                    self._state = "closed"
                    self._failures = 0
                    self._successes = 0
                    logger.info("Circuit breaker closed after successful recovery")
            else:
                self._failures = 0

    def record_failure(self):
        """Record a failed request."""
        with self._lock:
            self._failures += 1

            if self._state == "half_open":
                self._state = "open"
                self._opened_at = _utcnow()
                logger.warning("Circuit breaker reopened after failure in half-open state")
            elif self._failures >= self.failure_threshold:
                self._state = "open"
                self._opened_at = _utcnow()
                logger.warning(f"Circuit breaker opened after {self._failures} failures")

    def reset(self):
        """Reset the circuit breaker."""
        with self._lock:
            self._failures = 0
            self._successes = 0
            self._state = "closed"
            self._opened_at = None
            self._half_open_calls = 0


# ============================================================
# MODEL ROUTER
# ============================================================


class ModelRouter:
    """
    Intelligent model router with automatic fallback.

    Features:
    - Rule-based routing with query analysis
    - Automatic fallback chain on failure
    - Cost and latency tracking
    - Circuit breakers per model
    - Multiple routing strategies

    Usage:
        router = ModelRouter()

        # Add models
        router.add_model("claude-opus", provider="anthropic",
            predicate=lambda q: q.complexity > 0.8)
        router.add_model("claude-haiku", provider="anthropic",
            predicate=lambda q: q.is_simple)

        # Set fallback chain
        router.set_fallback_chain(["claude-sonnet", "claude-haiku"])

        # Route
        model, provider = router.route(query_context)
    """

    def __init__(
        self,
        default_model: str = "claude-sonnet",
        default_provider: str = "anthropic",
        strategy: RoutingStrategy = RoutingStrategy.FIRST_MATCH,
        analyzer: Optional[QueryAnalyzer] = None,
        compliance_config: Optional[Any] = None,
    ):
        """
        Initialize router.

        Args:
            default_model: Model to use when no rules match
            default_provider: Provider for default model
            strategy: How to select among eligible models
            analyzer: QueryAnalyzer instance (created if not provided)
            compliance_config: Optional ComplianceConfig — used to enforce
                local_llm_required in HIPAA/regulated deployments (F-02 fix).
        """
        self.default_model = default_model
        self.default_provider = default_provider
        self.strategy = strategy
        self.analyzer = analyzer or QueryAnalyzer()
        # F-02 FIX: store compliance config so route() can enforce local-only
        self._compliance_config = compliance_config

        # Model configurations
        self._models: Dict[str, ModelConfig] = {}

        # Fallback chain
        self._fallback_chain: List[str] = []

        # Per-model stats and circuit breakers
        self._stats: Dict[str, ModelUsageStats] = {}
        self._circuit_breakers: Dict[str, ModelCircuitBreaker] = {}

        # Round-robin state
        self._rr_index = 0
        self._rr_lock = threading.Lock()

        # Provider instances (optional - for execute())
        self._providers: Dict[str, Any] = {}

    def add_model(
        self,
        name: str,
        provider: str,
        predicate: Optional[Callable[[QueryContext], bool]] = None,
        priority: int = 100,
        weight: float = 1.0,
        **kwargs,
    ) -> "ModelRouter":
        """
        Add a model to the router.

        Args:
            name: Model name (e.g., "claude-sonnet")
            provider: Provider name (e.g., "anthropic")
            predicate: Function returning True if model should handle query
            priority: Lower = checked first
            weight: Weight for weighted routing
            **kwargs: Additional ModelConfig options

        Returns:
            self for chaining
        """
        config = ModelConfig(
            name=name,
            provider=provider,
            predicate=predicate,
            priority=priority,
            weight=weight,
            **kwargs,
        )

        self._models[name] = config

        # Initialize stats and circuit breaker
        if name not in self._stats:
            self._stats[name] = ModelUsageStats(model=name)

        if name not in self._circuit_breakers:
            self._circuit_breakers[name] = ModelCircuitBreaker(
                failure_threshold=config.circuit_breaker_threshold,
                timeout_seconds=config.circuit_breaker_timeout,
            )

        logger.debug(f"Added model: {name} (provider={provider}, priority={priority})")
        return self

    def remove_model(self, name: str) -> bool:
        """Remove a model from the router."""
        if name in self._models:
            del self._models[name]
            return True
        return False

    def set_fallback_chain(self, models: List[str]) -> "ModelRouter":
        """
        Set the fallback chain for when primary model fails.

        Args:
            models: List of model names in fallback order

        Returns:
            self for chaining
        """
        self._fallback_chain = models
        return self

    def register_provider(self, name: str, provider: Any) -> "ModelRouter":
        """
        Register a provider instance for execute().

        Args:
            name: Provider name (e.g., "anthropic")
            provider: Provider instance with chat() method

        Returns:
            self for chaining
        """
        self._providers[name] = provider
        return self

    def get_model_status(self, name: str) -> ModelStatus:
        """Get health status of a model."""
        if name not in self._models:
            return ModelStatus.UNKNOWN

        config = self._models[name]

        if not config.enabled:
            return ModelStatus.UNHEALTHY

        # Check circuit breaker
        cb = self._circuit_breakers.get(name)
        if cb and cb.is_open:
            return ModelStatus.UNHEALTHY

        # Check stats
        stats = self._stats.get(name)
        if stats:
            if stats.error_rate > config.max_error_rate:
                return ModelStatus.DEGRADED
            if stats.p95_latency_ms > config.max_latency_ms:
                return ModelStatus.DEGRADED

        return ModelStatus.HEALTHY

    def route(
        self,
        query: Union[str, QueryContext],
        context: Optional[Dict[str, Any]] = None,
        exclude: Optional[List[str]] = None,
    ) -> Tuple[str, str]:
        """
        Route a query to the best model.

        Args:
            query: Query string or pre-analyzed QueryContext
            context: Optional routing context
            exclude: Models to exclude from consideration

        Returns:
            Tuple of (model_name, provider_name)
        """
        # ── F-02 FIX: HIPAA / compliance local-LLM enforcement ────────────
        # If the compliance config requires local LLM, filter out every
        # cloud provider unless BAA_CONFIRMED is explicitly set in the env.
        # This prevents PHI from silently routing to Anthropic/OpenAI/etc.
        local_only: bool = False
        if self._compliance_config is not None:
            if getattr(self._compliance_config, "local_llm_required", False):
                import os

                if os.environ.get("FAMILIAR_BAA_CONFIRMED", "").lower() not in ("1", "true", "yes"):
                    local_only = True
                    logger.info(
                        "Compliance mode: local_llm_required=True and "
                        "FAMILIAR_BAA_CONFIRMED not set — restricting routing "
                        "to local/Ollama providers only."
                    )

        def _is_local_provider(provider_name: str) -> bool:
            return provider_name.lower() in ("ollama", "local", "llamacpp", "lmstudio")

        # ── end F-02 ──────────────────────────────────────────────────────

        # Analyze query if needed
        if isinstance(query, str):
            qc = self.analyzer.analyze(query, context=context)
        else:
            qc = query

        exclude = set(exclude or [])

        # F-02: add cloud models to the exclude set when local_only
        if local_only:
            exclude = exclude | {
                name for name, cfg in self._models.items() if not _is_local_provider(cfg.provider)
            }

        # Find eligible models
        eligible = self._find_eligible_models(qc, exclude)

        if not eligible:
            if local_only:
                raise RuntimeError(
                    "HIPAA compliance requires a local LLM (local_llm_required=True) "
                    "but no local/Ollama models are registered or available. "
                    "Add an Ollama model via router.add_model('ollama/llama3', provider='ollama', ...) "
                    "or set FAMILIAR_BAA_CONFIRMED=true if a Business Associate Agreement "
                    "is in place with your cloud LLM provider."
                )
            logger.debug(f"No eligible models, using default: {self.default_model}")
            return (self.default_model, self.default_provider)

        # Select based on strategy
        selected = self._select_model(eligible, qc)

        if selected:
            config = self._models[selected]
            # F-02: final safety check — never silently return a cloud model
            # in local_only mode (defensive, should be unreachable)
            if local_only and not _is_local_provider(config.provider):
                raise RuntimeError(
                    f"Internal routing error: selected cloud model '{selected}' "
                    f"(provider='{config.provider}') despite local_llm_required=True. "
                    "This is a bug — please report it."
                )
            logger.debug(f"Routed to {selected} (strategy={self.strategy.value})")
            return (selected, config.provider)

        # Fallback to default
        if local_only and not _is_local_provider(self.default_provider):
            raise RuntimeError(
                "HIPAA compliance requires a local LLM but the default provider "
                f"'{self.default_provider}' is not local. "
                "Register an Ollama model or set FAMILIAR_BAA_CONFIRMED=true."
            )
        return (self.default_model, self.default_provider)

    def _find_eligible_models(self, qc: QueryContext, exclude: set) -> List[str]:
        """Find models whose predicates match the query."""
        eligible = []

        # Sort by priority
        sorted_models = sorted(self._models.values(), key=lambda m: m.priority)

        for config in sorted_models:
            if not config.enabled:
                continue

            if config.name in exclude:
                continue

            # Check circuit breaker
            cb = self._circuit_breakers.get(config.name)
            if cb and not cb.allow_request():
                logger.debug(f"Skipping {config.name}: circuit breaker open")
                continue

            # Check cost constraint
            if qc.max_cost is not None:
                estimated_cost = config.get_cost(qc.token_estimate, qc.token_estimate)
                if estimated_cost > qc.max_cost:
                    continue

            # Check predicate
            if config.predicate is None:
                # No predicate = always eligible (but lower priority)
                eligible.append(config.name)
            elif config.predicate(qc):
                eligible.append(config.name)

        return eligible

    def _select_model(self, eligible: List[str], qc: QueryContext) -> Optional[str]:
        """Select a model from eligible list based on strategy."""
        if not eligible:
            return None

        if self.strategy == RoutingStrategy.FIRST_MATCH:
            return eligible[0]

        elif self.strategy == RoutingStrategy.LOWEST_COST:
            return min(
                eligible,
                key=lambda m: self._models[m].get_cost(qc.token_estimate, qc.token_estimate),
            )

        elif self.strategy == RoutingStrategy.LOWEST_LATENCY:

            def get_latency(m):
                stats = self._stats.get(m)
                if stats and stats.total_requests > 0:
                    return stats.p50_latency_ms
                return float("inf")

            return min(eligible, key=get_latency)

        elif self.strategy == RoutingStrategy.ROUND_ROBIN:
            with self._rr_lock:
                idx = self._rr_index % len(eligible)
                self._rr_index += 1
                return eligible[idx]

        elif self.strategy == RoutingStrategy.WEIGHTED:
            import random

            weights = [self._models[m].weight for m in eligible]
            total = sum(weights)
            r = random.random() * total
            cumulative = 0
            for m, w in zip(eligible, weights):
                cumulative += w
                if r <= cumulative:
                    return m
            return eligible[-1]

        elif self.strategy == RoutingStrategy.CAPABILITY_MATCH:
            # Score each model on required capabilities
            def capability_score(m):
                config = self._models[m]
                if not qc.required_capabilities:
                    # Default scoring
                    if qc.is_coding:
                        return config.get_capability("coding")
                    elif qc.is_creative:
                        return config.get_capability("creativity")
                    elif qc.requires_reasoning:
                        return config.get_capability("reasoning")
                    else:
                        return config.get_capability("speed")
                else:
                    return sum(config.get_capability(c) for c in qc.required_capabilities) / len(
                        qc.required_capabilities
                    )

            return max(eligible, key=capability_score)

        return eligible[0]

    async def execute(
        self,
        query: Union[str, QueryContext],
        messages: List[Dict[str, Any]],
        context: Optional[Dict[str, Any]] = None,
        max_fallbacks: int = 3,
        **kwargs,
    ) -> Dict[str, Any]:
        """
        Route and execute a query with automatic fallback.

        Args:
            query: Query or QueryContext
            messages: Message history for the LLM
            context: Routing context
            max_fallbacks: Maximum fallback attempts
            **kwargs: Additional arguments for provider.chat()

        Returns:
            Response dict with model, response, usage, cost

        Raises:
            RuntimeError: If all models fail
        """
        # Analyze query
        if isinstance(query, str):
            qc = self.analyzer.analyze(query, messages=messages, context=context)
        else:
            qc = query

        # Build execution order: primary + fallbacks
        primary_model, primary_provider = self.route(qc, context=context)

        # F-02 FIX: pre-compute the local-only constraint so the fallback
        # chain cannot silently route to a cloud provider when compliance
        # requires local LLM.  Mirrors the logic in route().
        import os as _os

        _local_only = (
            self._compliance_config is not None
            and getattr(self._compliance_config, "local_llm_required", False)
            and _os.environ.get("FAMILIAR_BAA_CONFIRMED", "").lower() not in ("1", "true", "yes")
        )

        def _is_local_provider(p: str) -> bool:
            return p.lower() in ("ollama", "local", "llamacpp", "lmstudio")

        execution_order = [primary_model]
        for fallback in self._fallback_chain:
            if fallback not in execution_order:
                config = self._models.get(fallback)
                if config and config.allow_fallback and config.enabled:
                    # F-02: skip cloud fallbacks in local-only compliance mode
                    if _local_only and not _is_local_provider(config.provider):
                        logger.debug(
                            f"Skipping fallback '{fallback}' (provider='{config.provider}'): "
                            "local_llm_required=True blocks cloud fallback."
                        )
                        continue
                    execution_order.append(fallback)

        execution_order = execution_order[: max_fallbacks + 1]

        # Try each model
        last_error = None
        excluded = set()

        for model_name in execution_order:
            config = self._models.get(model_name)
            if not config:
                continue

            provider = self._providers.get(config.provider)
            if not provider:
                logger.warning(f"No provider registered for {config.provider}")
                continue

            # Check circuit breaker
            cb = self._circuit_breakers.get(model_name)
            if cb and not cb.allow_request():
                logger.debug(f"Skipping {model_name}: circuit breaker open")
                continue

            try:
                start_time = time.time()

                # Execute
                if asyncio.iscoroutinefunction(provider.chat):
                    response = await provider.chat(messages=messages, model=model_name, **kwargs)
                else:
                    response = provider.chat(messages=messages, model=model_name, **kwargs)

                latency_ms = (time.time() - start_time) * 1000

                # Extract usage
                usage = getattr(response, "usage", {}) or {}
                input_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0)
                output_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)

                # Calculate cost
                cost = config.get_cost(input_tokens, output_tokens)

                # Record success
                self._stats[model_name].record_request(
                    success=True,
                    latency_ms=latency_ms,
                    input_tokens=input_tokens,
                    output_tokens=output_tokens,
                    cost_usd=cost,
                )

                if cb:
                    cb.record_success()

                return {
                    "model": model_name,
                    "provider": config.provider,
                    "response": response,
                    "input_tokens": input_tokens,
                    "output_tokens": output_tokens,
                    "cost_usd": cost,
                    "latency_ms": latency_ms,
                    "fallback_used": model_name != primary_model,
                }

            except Exception as e:
                last_error = e
                logger.warning(f"Model {model_name} failed: {e}")

                # Record failure
                latency_ms = (time.time() - start_time) * 1000
                self._stats[model_name].record_request(
                    success=False,
                    latency_ms=latency_ms,
                )

                if cb:
                    cb.record_failure()

                excluded.add(model_name)
                continue

        raise RuntimeError(f"All models failed. Last error: {last_error}")

    def record_usage(
        self,
        model: str,
        success: bool,
        latency_ms: float,
        input_tokens: int = 0,
        output_tokens: int = 0,
        cost_usd: Optional[float] = None,
    ):
        """
        Manually record usage for a model.

        Useful when execute() isn't used.
        """
        if model not in self._stats:
            self._stats[model] = ModelUsageStats(model=model)

        if cost_usd is None and model in self._models:
            cost_usd = self._models[model].get_cost(input_tokens, output_tokens)

        self._stats[model].record_request(
            success=success,
            latency_ms=latency_ms,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            cost_usd=cost_usd or 0.0,
        )

        # Update circuit breaker
        cb = self._circuit_breakers.get(model)
        if cb:
            if success:
                cb.record_success()
            else:
                cb.record_failure()

    def get_stats(self, model: Optional[str] = None) -> Dict[str, Any]:
        """
        Get usage statistics.

        Args:
            model: Specific model, or None for all

        Returns:
            Statistics dict
        """
        if model:
            if model in self._stats:
                return self._stats[model].to_dict()
            return {}

        return {name: stats.to_dict() for name, stats in self._stats.items()}

    def get_cost_report(self) -> Dict[str, Any]:
        """
        Get cost report across all models.

        Returns:
            Cost breakdown by model
        """
        report = {
            "total_cost_usd": 0.0,
            "total_requests": 0,
            "by_model": {},
        }

        for name, stats in self._stats.items():
            report["total_cost_usd"] += stats.total_cost_usd
            report["total_requests"] += stats.total_requests
            report["by_model"][name] = {
                "cost_usd": round(stats.total_cost_usd, 6),
                "requests": stats.total_requests,
                "avg_cost": round(stats.avg_cost_per_request, 6),
                "input_tokens": stats.total_input_tokens,
                "output_tokens": stats.total_output_tokens,
            }

        report["total_cost_usd"] = round(report["total_cost_usd"], 6)
        return report

    def get_health_report(self) -> Dict[str, Any]:
        """
        Get health status of all models.

        Returns:
            Health report
        """
        return {
            name: {
                "status": self.get_model_status(name).value,
                "circuit_breaker": self._circuit_breakers[name].state
                if name in self._circuit_breakers
                else "unknown",
                "success_rate": round(self._stats[name].success_rate, 4)
                if name in self._stats
                else None,
                "p50_latency_ms": round(self._stats[name].p50_latency_ms, 2)
                if name in self._stats
                else None,
                "enabled": self._models[name].enabled if name in self._models else False,
            }
            for name in self._models
        }

    def reset_stats(self, model: Optional[str] = None):
        """Reset usage statistics."""
        if model:
            if model in self._stats:
                self._stats[model] = ModelUsageStats(model=model)
        else:
            self._stats = {name: ModelUsageStats(model=name) for name in self._models}

    def reset_circuit_breakers(self, model: Optional[str] = None):
        """Reset circuit breakers."""
        if model:
            if model in self._circuit_breakers:
                self._circuit_breakers[model].reset()
        else:
            for cb in self._circuit_breakers.values():
                cb.reset()


# ============================================================
# CONVENIENCE FUNCTIONS
# ============================================================

# Global router instance
_router: Optional[ModelRouter] = None
_router_lock = threading.Lock()


def get_model_router() -> ModelRouter:
    """Get the global ModelRouter instance."""
    global _router
    with _router_lock:
        if _router is None:
            _router = ModelRouter()
        return _router


def set_model_router(router: ModelRouter):
    """Set the global ModelRouter instance."""
    global _router
    with _router_lock:
        _router = router


def reset_model_router():
    """Reset the global ModelRouter instance."""
    global _router
    with _router_lock:
        _router = None


def create_router(
    models: Optional[List[Dict[str, Any]]] = None,
    fallback_chain: Optional[List[str]] = None,
    strategy: RoutingStrategy = RoutingStrategy.FIRST_MATCH,
    default_model: str = "claude-sonnet",
) -> ModelRouter:
    """
    Create a configured ModelRouter.

    Args:
        models: List of model configs with name, provider, predicate, etc.
        fallback_chain: Fallback model order
        strategy: Routing strategy
        default_model: Default model when no rules match

    Returns:
        Configured ModelRouter

    Example:
        router = create_router(
            models=[
                {"name": "claude-opus", "provider": "anthropic",
                 "predicate": lambda q: q.complexity > 0.8},
                {"name": "claude-haiku", "provider": "anthropic",
                 "predicate": lambda q: q.is_simple},
            ],
            fallback_chain=["claude-sonnet", "claude-haiku"],
        )
    """
    router = ModelRouter(
        default_model=default_model,
        strategy=strategy,
    )

    if models:
        for config in models:
            router.add_model(**config)

    if fallback_chain:
        router.set_fallback_chain(fallback_chain)

    return router


def create_standard_router(
    include_local: bool = True,
    strategy: RoutingStrategy = RoutingStrategy.FIRST_MATCH,
) -> ModelRouter:
    """
    Create a router with standard model configurations.

    Args:
        include_local: Include Ollama models for private queries
        strategy: Routing strategy

    Returns:
        Pre-configured ModelRouter
    """
    router = ModelRouter(
        default_model="claude-sonnet",
        strategy=strategy,
    )

    # High-capability model for complex tasks
    router.add_model(
        name="claude-opus",
        provider="anthropic",
        predicate=lambda q: q.complexity > 0.8 or q.requires_reasoning,
        priority=10,
    )

    # Coding specialist
    router.add_model(
        name="claude-sonnet",
        provider="anthropic",
        predicate=lambda q: q.is_coding or q.is_creative,
        priority=20,
    )

    # Fast model for simple queries
    router.add_model(
        name="claude-haiku",
        provider="anthropic",
        predicate=lambda q: q.is_simple,
        priority=30,
    )

    # Local model for private data
    if include_local:
        router.add_model(
            name="ollama/llama3",
            provider="ollama",
            predicate=lambda q: q.is_private,
            priority=5,  # Highest priority for private
            capabilities={"private": True},
        )

    # Set fallback chain
    fallbacks = ["claude-sonnet", "claude-haiku"]
    if include_local:
        fallbacks.append("ollama/llama3")
    router.set_fallback_chain(fallbacks)

    return router


# ============================================================
# EXPORTS
# ============================================================

__all__ = [
    # Core classes
    "ModelRouter",
    "QueryContext",
    "QueryAnalyzer",
    "ModelConfig",
    "ModelUsageStats",
    "ModelCircuitBreaker",
    # Enums
    "RoutingStrategy",
    "ModelStatus",
    "QueryType",
    # Constants
    "MODEL_COSTS",
    "MODEL_CAPABILITIES",
    # Convenience functions
    "get_model_router",
    "set_model_router",
    "reset_model_router",
    "create_router",
    "create_standard_router",
]

# Compatibility alias
ModelRoutingConfig = ModelConfig
