"""Router engine -- Tier 0 rule-based deterministic routing.

The :class:`Router` evaluates a prioritised list of :class:`RoutingRule` objects
against each incoming :class:`UnifiedRequest` to select the best backend,
model, and URL.

Future tiers (kNN, ModernBERT, Qwen-0.5B) will augment -- not replace -- the
rule-based engine.  Rules always run first and can short-circuit the pipeline.
"""

from __future__ import annotations

import logging
import time
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, cast

from llmhosts.privacy.detector import PIIDetector
from llmhosts.privacy.models import PrivacyClassification, PrivacySettings, PrivacyTier
from llmhosts.router.aliases import PROVIDER_URLS, AliasResolver, ResolvedModel
from llmhosts.router.cost import CLOUD_PRICING, estimate_request_tokens
from llmhosts.router.models import AlternativeRoute, BackendInfo, CostEstimate, RoutingDecision, RoutingRule

if TYPE_CHECKING:
    from llmhosts.proxy.models import UnifiedRequest

logger = logging.getLogger(__name__)


class Router:
    """Three-tier hybrid router.  Currently implements Tier 0 (rule-based).

    Tier 0: Rule-based deterministic routing (this implementation)
    Tier 1: kNN embedding router (Phase 1, [smart] tier)
    Tier 2: ModernBERT classifier (Phase 1, [smart] tier)
    Tier 3: Qwen-0.5B reasoning (Phase 2, [full] tier)

    Usage::

        router = Router()
        router.register_backend(BackendInfo(backend_type="ollama", url="http://127.0.0.1:11434", ...))
        decision = await router.route(request)
    """

    def __init__(self, privacy_settings: PrivacySettings | None = None) -> None:
        self._rules: list[RoutingRule] = self._default_rules()
        self._backends: dict[str, BackendInfo] = {}
        self._alias_resolver = AliasResolver()
        self._privacy_settings = privacy_settings or PrivacySettings()
        self._pii_detector = PIIDetector(
            strict=self._privacy_settings.privacy_mode == "paranoid",
            custom_patterns=self._privacy_settings.custom_pii_patterns or None,
        )

    # ------------------------------------------------------------------
    # Privacy settings
    # ------------------------------------------------------------------

    @property
    def privacy_settings(self) -> PrivacySettings:
        """Return the current privacy settings."""
        return self._privacy_settings

    def update_privacy_settings(self, settings: PrivacySettings) -> None:
        """Update privacy settings and rebuild the PII detector."""
        self._privacy_settings = settings
        self._pii_detector = PIIDetector(
            strict=settings.privacy_mode == "paranoid",
            custom_patterns=settings.custom_pii_patterns or None,
        )

    # ------------------------------------------------------------------
    # Backend management
    # ------------------------------------------------------------------

    def register_backend(self, info: BackendInfo) -> None:
        """Register an available backend."""
        self._backends[info.backend_type] = info
        logger.info(
            "Registered backend '%s' at %s with %d model(s)",
            info.backend_type,
            info.url,
            len(info.models),
        )

    def unregister_backend(self, backend_type: str) -> None:
        """Remove a backend."""
        removed = self._backends.pop(backend_type, None)
        if removed:
            logger.info("Unregistered backend '%s'", backend_type)

    def update_backend_health(self, backend_type: str, healthy: bool, latency_ms: float = 0.0) -> None:
        """Update health status and latency for a backend."""
        backend = self._backends.get(backend_type)
        if backend is not None:
            backend.is_healthy = healthy
            if latency_ms > 0:
                # Exponential moving average for latency
                if backend.avg_latency_ms > 0:
                    backend.avg_latency_ms = backend.avg_latency_ms * 0.7 + latency_ms * 0.3
                else:
                    backend.avg_latency_ms = latency_ms
            backend.last_check = datetime.now(tz=timezone.utc)

    def get_backend(self, backend_type: str) -> BackendInfo | None:
        """Return backend info for a given type, or None."""
        return self._backends.get(backend_type)

    def list_backends(self) -> list[BackendInfo]:
        """Return all registered backends."""
        return list(self._backends.values())

    # ------------------------------------------------------------------
    # Rule management
    # ------------------------------------------------------------------

    def add_rule(self, rule: RoutingRule) -> None:
        """Add a routing rule. Rules are re-sorted by priority after adding."""
        self._rules.append(rule)
        self._rules.sort(key=lambda r: r.priority)

    def remove_rule(self, name: str) -> bool:
        """Remove a rule by name. Returns True if found and removed."""
        before = len(self._rules)
        self._rules = [r for r in self._rules if r.name != name]
        return len(self._rules) < before

    def list_rules(self) -> list[RoutingRule]:
        """Return all routing rules sorted by priority."""
        return list(self._rules)

    # ------------------------------------------------------------------
    # Main routing entry point
    # ------------------------------------------------------------------

    async def route(self, request: UnifiedRequest) -> RoutingDecision:
        """Make a routing decision for the given request.

        Evaluation pipeline:
        1. Run PII detection on request messages
        2. Apply privacy constraints (override tier if privacy mode dictates)
        3. Resolve model aliases
        4. Run rules in priority order (lowest number = highest priority)
        5. Enforce privacy tier on the selected route
        6. If no rule matched, apply default fallback logic

        Returns a :class:`RoutingDecision` with the selected backend and model.
        """
        start = time.perf_counter()

        # Step 1: Privacy classification
        privacy = self._classify_privacy(request)
        logger.debug(
            "Privacy classification: tier=%s, pii_types=%s",
            privacy.tier.value,
            privacy.pii_types_found,
        )

        # Gather all available local models across backends
        all_local_models = self._collect_local_models()

        # Resolve the model alias
        resolved = self._alias_resolver.resolve(request.model, all_local_models)
        logger.debug(
            "Resolved '%s' -> canonical='%s' provider='%s'", request.model, resolved.canonical, resolved.provider
        )

        # Try each rule in priority order
        alternatives: list[AlternativeRoute] = []
        for rule in self._rules:
            if not rule.enabled:
                continue
            decision = self._evaluate_rule(rule, request, resolved, alternatives)
            if decision is not None:
                decision.latency_ms = (time.perf_counter() - start) * 1000
                decision.alternatives = alternatives
                decision.privacy_tier = privacy.tier.value
                decision.privacy_pii_types = privacy.pii_types_found
                # Enforce privacy constraints on the decision
                return self._enforce_privacy(decision, privacy, resolved)

        # Fallback: nothing matched
        elapsed = (time.perf_counter() - start) * 1000
        decision = self._no_backend_decision(request, resolved, alternatives, elapsed)
        decision.privacy_tier = privacy.tier.value
        decision.privacy_pii_types = privacy.pii_types_found
        return decision

    # ------------------------------------------------------------------
    # Rule evaluation
    # ------------------------------------------------------------------

    def _evaluate_rule(
        self,
        rule: RoutingRule,
        request: UnifiedRequest,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
    ) -> RoutingDecision | None:
        """Evaluate a single rule.  Returns a decision if the rule matches, else None."""
        handler = _RULE_HANDLERS.get(rule.condition)
        if handler is None:
            logger.warning("Unknown rule condition '%s' in rule '%s'", rule.condition, rule.name)
            return None
        return cast("RoutingDecision | None", handler(self, rule, request, resolved, alternatives))

    # -- model_exact -------------------------------------------------------

    def _rule_model_exact(
        self,
        rule: RoutingRule,
        request: UnifiedRequest,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
    ) -> RoutingDecision | None:
        """Route when user requests a specific model available on a specific backend."""
        # Check if the resolved model is directly available on any healthy backend
        for backend in self._healthy_backends():
            if resolved.canonical in backend.models or request.model in backend.models:
                model_used = request.model if request.model in backend.models else resolved.canonical
                return RoutingDecision(
                    backend_type=backend.backend_type,
                    backend_url=self._backend_url(backend, model_used),
                    model=model_used,
                    tier="rule",
                    confidence=1.0,
                    reasoning=f"Exact model '{model_used}' found on {backend.backend_type}",
                    factors={"rule": rule.name, "match_type": "exact"},
                )
        return None

    # -- model_prefix ------------------------------------------------------

    def _rule_model_prefix(
        self,
        rule: RoutingRule,
        request: UnifiedRequest,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
    ) -> RoutingDecision | None:
        """Route when model name matches a prefix configured in the rule parameters."""
        prefix = rule.parameters.get("prefix", "")
        target_backend = rule.parameters.get("backend", "")
        if not prefix or not target_backend:
            return None

        if not resolved.canonical.startswith(prefix) and not request.model.startswith(prefix):
            return None

        backend = self._backends.get(target_backend)
        if backend is None or not backend.is_healthy:
            return None

        return RoutingDecision(
            backend_type=backend.backend_type,
            backend_url=self._backend_url(backend, resolved.canonical),
            model=resolved.canonical,
            tier="rule",
            confidence=0.95,
            reasoning=f"Model prefix '{prefix}' matched, routing to {target_backend}",
            factors={"rule": rule.name, "prefix": prefix},
        )

    # -- prefer_local ------------------------------------------------------

    def _rule_prefer_local(
        self,
        rule: RoutingRule,
        request: UnifiedRequest,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
    ) -> RoutingDecision | None:
        """Prefer Ollama when the requested model (or a local equivalent) is available."""
        ollama = self._backends.get("ollama")
        if ollama is None or not ollama.is_healthy:
            return None

        ollama_models_lower = {m.lower(): m for m in ollama.models}

        # Case 1: The exact model (or canonical) is available on Ollama
        for candidate in (request.model, resolved.canonical):
            candidate_lower = candidate.lower()
            if candidate_lower in ollama_models_lower:
                actual_model = ollama_models_lower[candidate_lower]
                # Record cloud alternative
                self._add_cloud_alternative(resolved, alternatives, "Local model preferred over cloud")
                return RoutingDecision(
                    backend_type="ollama",
                    backend_url=ollama.url,
                    model=actual_model,
                    tier="rule",
                    confidence=0.95,
                    reasoning=f"Model '{actual_model}' available locally on Ollama (prefer_local rule)",
                    factors={"rule": rule.name, "match_type": "direct_local"},
                )

            # Check base name without tag
            base = candidate_lower.split(":")[0]
            for avail_lower, avail_original in ollama_models_lower.items():
                if avail_lower.split(":")[0] == base:
                    self._add_cloud_alternative(resolved, alternatives, "Local model preferred over cloud")
                    return RoutingDecision(
                        backend_type="ollama",
                        backend_url=ollama.url,
                        model=avail_original,
                        tier="rule",
                        confidence=0.90,
                        reasoning=f"Model '{avail_original}' (base match for '{candidate}') available locally",
                        factors={"rule": rule.name, "match_type": "base_name_local"},
                    )

        # Case 2: A quality-equivalent local model exists
        if resolved.local_equivalents:
            best_local = resolved.local_equivalents[0]
            self._add_cloud_alternative(resolved, alternatives, "Local equivalent preferred for cost savings")
            return RoutingDecision(
                backend_type="ollama",
                backend_url=ollama.url,
                model=best_local,
                tier="rule",
                confidence=0.75,
                reasoning=(
                    f"Local equivalent '{best_local}' substituted for cloud model "
                    f"'{resolved.canonical}' (quality-equivalent, $0 cost)"
                ),
                factors={
                    "rule": rule.name,
                    "match_type": "quality_equivalent",
                    "original_model": resolved.canonical,
                    "equivalent_model": best_local,
                },
            )

        return None

    # -- cost_optimize -----------------------------------------------------

    def _rule_cost_optimize(
        self,
        rule: RoutingRule,
        request: UnifiedRequest,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
    ) -> RoutingDecision | None:
        """Choose the cheapest backend.  Local = $0, so it always wins when available."""
        # If there's a local backend with the model, it's free
        ollama = self._backends.get("ollama")
        if ollama is not None and ollama.is_healthy:
            ollama_models_lower = {m.lower(): m for m in ollama.models}
            for candidate in (request.model.lower(), resolved.canonical.lower()):
                if candidate in ollama_models_lower:
                    return RoutingDecision(
                        backend_type="ollama",
                        backend_url=ollama.url,
                        model=ollama_models_lower[candidate],
                        tier="rule",
                        confidence=0.85,
                        reasoning=f"Cost optimisation: '{ollama_models_lower[candidate]}' runs locally for $0",
                        factors={"rule": rule.name, "estimated_cost": 0.0},
                    )

            # Check local equivalents
            if resolved.local_equivalents:
                best = resolved.local_equivalents[0]
                return RoutingDecision(
                    backend_type="ollama",
                    backend_url=ollama.url,
                    model=best,
                    tier="rule",
                    confidence=0.70,
                    reasoning=f"Cost optimisation: local equivalent '{best}' is free vs cloud",
                    factors={
                        "rule": rule.name,
                        "estimated_cost": 0.0,
                        "cloud_model": resolved.canonical,
                    },
                )

        # Among cloud backends, pick the cheapest
        cloud_options = self._cloud_cost_options(resolved, request)
        if cloud_options:
            # Sort by total estimated cost
            cloud_options.sort(key=lambda c: c["cost"].total_estimated_cost)
            best_cloud = cloud_options[0]
            # Add more expensive options as alternatives
            for opt in cloud_options[1:]:
                alternatives.append(
                    AlternativeRoute(
                        backend_type=opt["backend_type"],
                        model=opt["model"],
                        score=1.0 - opt["cost"].total_estimated_cost,
                        reason_not_chosen=f"More expensive: ${opt['cost'].total_estimated_cost:.6f}",
                    )
                )
            backend = self._backends.get(best_cloud["backend_type"])
            if backend is not None:
                return RoutingDecision(
                    backend_type=best_cloud["backend_type"],
                    backend_url=self._backend_url(backend, best_cloud["model"]),
                    model=best_cloud["model"],
                    tier="rule",
                    confidence=0.80,
                    reasoning=f"Cost optimisation: '{best_cloud['model']}' is cheapest cloud option (${best_cloud['cost'].total_estimated_cost:.6f})",
                    factors={
                        "rule": rule.name,
                        "estimated_cost": best_cloud["cost"].total_estimated_cost,
                    },
                )

        return None

    # -- provider_preference -----------------------------------------------

    def _rule_provider_preference(
        self,
        rule: RoutingRule,
        request: UnifiedRequest,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
    ) -> RoutingDecision | None:
        """Route to a user-preferred provider if available and healthy."""
        preferred = rule.parameters.get("provider", "")
        if not preferred:
            return None

        backend = self._backends.get(preferred)
        if backend is None or not backend.is_healthy:
            return None

        # Check if the model is available or compatible with this provider
        if resolved.canonical in backend.models or resolved.provider == preferred:
            return RoutingDecision(
                backend_type=backend.backend_type,
                backend_url=self._backend_url(backend, resolved.canonical),
                model=resolved.canonical,
                tier="rule",
                confidence=0.85,
                reasoning=f"User preference: routing to preferred provider '{preferred}'",
                factors={"rule": rule.name, "preferred_provider": preferred},
            )

        return None

    # -- cloud_fallback ----------------------------------------------------

    def _rule_cloud_fallback(
        self,
        rule: RoutingRule,
        request: UnifiedRequest,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
    ) -> RoutingDecision | None:
        """Fall back to a cloud API when no local option is available."""
        # Only fires when the model's provider is a cloud provider
        provider: str = resolved.provider
        if provider == "ollama" or provider == "unknown":
            # If provider is ollama, prefer_local should have handled it.
            # If unknown, try to infer from canonical name.
            inferred = self._infer_cloud_provider(resolved.canonical)
            if inferred is None:
                return None
            provider = inferred

        backend = self._backends.get(provider)
        if backend is not None and backend.is_healthy:
            return RoutingDecision(
                backend_type=backend.backend_type,
                backend_url=self._backend_url(backend, resolved.canonical),
                model=resolved.canonical,
                tier="rule",
                confidence=0.90,
                reasoning=f"Cloud fallback: routing '{resolved.canonical}' to {provider} API",
                factors={"rule": rule.name, "provider": provider},
            )

        # Try OpenRouter as a universal fallback
        openrouter = self._backends.get("openrouter")
        if openrouter is not None and openrouter.is_healthy:
            return RoutingDecision(
                backend_type="openrouter",
                backend_url=self._backend_url(openrouter, resolved.canonical),
                model=resolved.canonical,
                tier="rule",
                confidence=0.70,
                reasoning=f"Cloud fallback via OpenRouter for '{resolved.canonical}' ({provider} backend not registered)",
                factors={"rule": rule.name, "provider": "openrouter", "original_provider": provider},
            )

        return None

    # ------------------------------------------------------------------
    # Default rules
    # ------------------------------------------------------------------

    @staticmethod
    def _default_rules() -> list[RoutingRule]:
        """Return the default routing rule set.

        Priority order (lower = runs first):
            5 - model_exact: if the user specifies an exact model that exists, route there
           10 - prefer_local: route to Ollama if model is available locally
           20 - cost_optimize: prefer the cheapest option for equivalent quality
          100 - cloud_fallback: use a cloud API if nothing local is available
        """
        return [
            RoutingRule(
                name="exact_model_match",
                priority=5,
                condition="model_exact",
                parameters={},
            ),
            RoutingRule(
                name="prefer_local",
                priority=10,
                condition="prefer_local",
                parameters={},
            ),
            RoutingRule(
                name="cost_optimise",
                priority=20,
                condition="cost_optimize",
                parameters={},
            ),
            RoutingRule(
                name="cloud_fallback",
                priority=100,
                condition="cloud_fallback",
                parameters={},
            ),
        ]

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

    def _healthy_backends(self) -> list[BackendInfo]:
        """Return all healthy backends sorted by local-first, then latency."""
        backends = [b for b in self._backends.values() if b.is_healthy]
        backends.sort(key=lambda b: (0 if b.is_local else 1, b.avg_latency_ms))
        return backends

    def _collect_local_models(self) -> list[str]:
        """Gather all model names from local (is_local=True) healthy backends."""
        models: list[str] = []
        for backend in self._backends.values():
            if backend.is_local and backend.is_healthy:
                models.extend(backend.models)
        return models

    def _backend_url(self, backend: BackendInfo, model: str) -> str:
        """Return the API endpoint URL for a backend.

        For Ollama this is the base URL.  For cloud providers it's the
        known API endpoint from :data:`PROVIDER_URLS`, falling back to the
        registered URL.
        """
        if backend.backend_type == "ollama":
            return backend.url
        return PROVIDER_URLS.get(backend.backend_type, backend.url)

    def _add_cloud_alternative(
        self,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
        reason: str,
    ) -> None:
        """Add the cloud version of a model as an alternative route."""
        if resolved.provider not in ("ollama", "unknown"):
            pricing = CLOUD_PRICING.get(resolved.canonical)
            cost_note = ""
            if pricing:
                # Rough estimate for a typical request (500 in, 500 out tokens)
                est = 500 * pricing["input"] + 500 * pricing["output"]
                cost_note = f" (est. ${est:.4f}/req)"
            alternatives.append(
                AlternativeRoute(
                    backend_type=resolved.provider,
                    model=resolved.canonical,
                    score=0.5,
                    reason_not_chosen=f"{reason}{cost_note}",
                )
            )

    def _cloud_cost_options(
        self,
        resolved: ResolvedModel,
        request: UnifiedRequest,
    ) -> list[dict[str, Any]]:
        """Build cost estimates for cloud backends compatible with the model."""
        input_tokens = estimate_request_tokens([{"content": m.content} for m in request.messages])
        # Assume output is roughly equal to input for estimation
        output_tokens = max(input_tokens, request.max_tokens or input_tokens)

        model = resolved.canonical
        pricing = CLOUD_PRICING.get(model)
        if pricing is None:
            return []

        # Determine which provider(s) can serve this model
        model_provider = self._infer_cloud_provider(model)

        options: list[dict[str, Any]] = []
        for backend in self._healthy_backends():
            if backend.is_local:
                continue
            # Only consider backends that match the model's provider,
            # or universal backends like OpenRouter
            if backend.backend_type not in (model_provider, "openrouter"):
                continue
            cost = CostEstimate(
                backend_type=backend.backend_type,
                model=model,
                estimated_input_tokens=input_tokens,
                estimated_output_tokens=output_tokens,
                cost_per_input_token=pricing["input"],
                cost_per_output_token=pricing["output"],
                total_estimated_cost=input_tokens * pricing["input"] + output_tokens * pricing["output"],
            )
            options.append({"backend_type": backend.backend_type, "model": model, "cost": cost})
        return options

    @staticmethod
    def _infer_cloud_provider(model: str) -> str | None:
        """Infer a cloud provider from the model name for fallback purposes."""
        model_lower = model.lower()
        if model_lower.startswith(("gpt-", "o1-", "o3-")):
            return "openai"
        if model_lower.startswith("claude-"):
            return "anthropic"
        if model_lower.startswith("gemini-"):
            return "google"
        if model_lower.startswith("mistral-"):
            return "mistral"
        if model_lower.startswith("command-"):
            return "cohere"
        return None

    # ------------------------------------------------------------------
    # Privacy enforcement
    # ------------------------------------------------------------------

    def _classify_privacy(self, request: UnifiedRequest) -> PrivacyClassification:
        """Run PII detection across all request messages."""
        messages = []
        for m in request.messages:
            if isinstance(m.content, (str, list)):
                messages.append({"content": m.content})
            else:
                messages.append({"content": ""})

        classification = self._pii_detector.classify_messages(messages)

        # Apply privacy mode override
        override = self._privacy_settings.effective_tier_override()
        if override is not None and override.value > classification.tier.value:
            classification = PrivacyClassification(
                tier=override,
                pii_types_found=classification.pii_types_found,
                pii_count=classification.pii_count,
                reasoning=f"Privacy mode '{self._privacy_settings.privacy_mode}' overrides to {override.value}; {classification.reasoning}",
                scan_time_ms=classification.scan_time_ms,
            )

        return classification

    def _enforce_privacy(
        self,
        decision: RoutingDecision,
        privacy: PrivacyClassification,
        resolved: ResolvedModel,
    ) -> RoutingDecision:
        """Enforce privacy tier constraints on a routing decision.

        - PUBLIC: any backend is fine.
        - PRIVATE: must route to local backends only.
        - SENSITIVE: must route to local backends only, logged separately.
        """
        if privacy.tier == PrivacyTier.PUBLIC and self._privacy_settings.allows_cloud(privacy):
            return decision

        # PRIVATE or SENSITIVE: force local routing
        if decision.backend_type in ("ollama",) or self._is_local_backend(decision.backend_type):
            # Already local, add privacy annotation
            decision.factors["privacy_enforced"] = True
            decision.factors["privacy_tier"] = privacy.tier.value
            return decision

        # Need to re-route to a local backend
        local_decision = self._force_local_routing(decision, resolved, privacy)
        if local_decision is not None:
            return local_decision

        # No local backend available -- block the request with an explanation
        return RoutingDecision(
            backend_type="none",
            backend_url="",
            model=decision.model,
            tier=decision.tier,
            confidence=0.0,
            reasoning=(
                f"Request contains {privacy.tier.value}-tier PII ({', '.join(privacy.pii_types_found)}). "
                f"Cloud routing blocked but no local backend is available."
            ),
            factors={
                "privacy_enforced": True,
                "privacy_tier": privacy.tier.value,
                "pii_types": privacy.pii_types_found,
                "blocked": True,
            },
            privacy_tier=privacy.tier.value,
            privacy_pii_types=privacy.pii_types_found,
            latency_ms=decision.latency_ms,
        )

    def _is_local_backend(self, backend_type: str) -> bool:
        """Check if a backend type is local."""
        backend = self._backends.get(backend_type)
        return backend is not None and backend.is_local

    def _force_local_routing(
        self,
        original: RoutingDecision,
        resolved: ResolvedModel,
        privacy: PrivacyClassification,
    ) -> RoutingDecision | None:
        """Attempt to re-route a cloud decision to a local backend for privacy."""
        ollama = self._backends.get("ollama")
        if ollama is None or not ollama.is_healthy:
            return None

        # Try to find the same model or an equivalent locally
        ollama_models_lower = {m.lower(): m for m in ollama.models}

        # Check exact match
        for candidate in (original.model, resolved.canonical):
            candidate_lower = candidate.lower()
            if candidate_lower in ollama_models_lower:
                actual = ollama_models_lower[candidate_lower]
                return RoutingDecision(
                    backend_type="ollama",
                    backend_url=ollama.url,
                    model=actual,
                    tier=original.tier,
                    confidence=original.confidence * 0.9,
                    reasoning=(
                        f"Privacy override: rerouted to local '{actual}' "
                        f"({privacy.tier.value} PII detected: {', '.join(privacy.pii_types_found)})"
                    ),
                    factors={
                        "privacy_enforced": True,
                        "privacy_tier": privacy.tier.value,
                        "original_backend": original.backend_type,
                        "original_model": original.model,
                    },
                    privacy_tier=privacy.tier.value,
                    privacy_pii_types=privacy.pii_types_found,
                    latency_ms=original.latency_ms,
                )

        # Try local equivalents
        if resolved.local_equivalents:
            best = resolved.local_equivalents[0]
            return RoutingDecision(
                backend_type="ollama",
                backend_url=ollama.url,
                model=best,
                tier=original.tier,
                confidence=original.confidence * 0.7,
                reasoning=(
                    f"Privacy override: rerouted to local equivalent '{best}' "
                    f"({privacy.tier.value} PII detected: {', '.join(privacy.pii_types_found)})"
                ),
                factors={
                    "privacy_enforced": True,
                    "privacy_tier": privacy.tier.value,
                    "original_backend": original.backend_type,
                    "original_model": original.model,
                    "equivalent_model": best,
                },
                privacy_tier=privacy.tier.value,
                privacy_pii_types=privacy.pii_types_found,
                latency_ms=original.latency_ms,
            )

        # Try any model on Ollama as a last resort
        if ollama.models:
            fallback_model = ollama.models[0]
            return RoutingDecision(
                backend_type="ollama",
                backend_url=ollama.url,
                model=fallback_model,
                tier=original.tier,
                confidence=0.3,
                reasoning=(
                    f"Privacy override: forced local routing to '{fallback_model}' "
                    f"({privacy.tier.value} PII detected; no equivalent model available locally)"
                ),
                factors={
                    "privacy_enforced": True,
                    "privacy_tier": privacy.tier.value,
                    "original_backend": original.backend_type,
                    "original_model": original.model,
                    "fallback": True,
                },
                privacy_tier=privacy.tier.value,
                privacy_pii_types=privacy.pii_types_found,
                latency_ms=original.latency_ms,
            )

        return None

    def _no_backend_decision(
        self,
        request: UnifiedRequest,
        resolved: ResolvedModel,
        alternatives: list[AlternativeRoute],
        elapsed_ms: float,
    ) -> RoutingDecision:
        """Return a decision when no rule matched and no backend is available."""
        # Last resort: if there are ANY healthy backends, send to the first one
        healthy = self._healthy_backends()
        if healthy:
            fallback = healthy[0]
            return RoutingDecision(
                backend_type=fallback.backend_type,
                backend_url=self._backend_url(fallback, resolved.canonical),
                model=resolved.canonical,
                tier="rule",
                confidence=0.30,
                reasoning=f"No rule matched; falling back to first healthy backend '{fallback.backend_type}'",
                factors={"fallback": True},
                alternatives=alternatives,
                latency_ms=elapsed_ms,
            )

        # Truly nothing available
        return RoutingDecision(
            backend_type="none",
            backend_url="",
            model=request.model,
            tier="rule",
            confidence=0.0,
            reasoning="No healthy backends available. Start Ollama or add a cloud API key.",
            factors={"error": "no_backends"},
            alternatives=alternatives,
            latency_ms=elapsed_ms,
        )


# ---------------------------------------------------------------------------
# Rule handler registry
# ---------------------------------------------------------------------------

# Maps rule condition names to bound methods.  Populated after class definition
# so that type-checkers see the methods on Router.
_RULE_HANDLERS: dict[
    str,
    Any,  # Callable[[Router, RoutingRule, UnifiedRequest, ResolvedModel, list[AlternativeRoute]], RoutingDecision | None]
] = {
    "model_exact": Router._rule_model_exact,
    "model_prefix": Router._rule_model_prefix,
    "prefer_local": Router._rule_prefer_local,
    "cost_optimize": Router._rule_cost_optimize,
    "provider_preference": Router._rule_provider_preference,
    "cloud_fallback": Router._rule_cloud_fallback,
}
