"""ModernBERT Tier 2: Classification-based routing.

When kNN (Tier 1) confidence < 0.85, it escalates to ModernBERT.
ModernBERT is a 149M parameter encoder that classifies requests into
routing categories based on complexity, domain, and quality requirements.

The classifier outputs:
- Task complexity: trivial / simple / moderate / complex / expert
- Domain: coding / writing / analysis / creative / chat / math / data
- Quality requirement: low / medium / high / maximum
- Suggested model tier: tiny / small / medium / large / frontier

These classifications are combined with available backend info to make
the final routing decision.

Requires ``[smart]`` tier: ``onnxruntime``, ``numpy``.
"""

from __future__ import annotations

import logging
import re
import time
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar

from pydantic import BaseModel, Field

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Guarded imports
# ---------------------------------------------------------------------------

MODERNBERT_AVAILABLE: bool = False

try:
    import numpy as np
    import onnxruntime as ort

    MODERNBERT_AVAILABLE = True
except ImportError:
    np = None  # type: ignore[assignment]
    ort = None  # type: ignore[assignment]

if TYPE_CHECKING:
    from llmhosts.proxy.models import UnifiedRequest
    from llmhosts.router.models import BackendInfo


# ============================================================================
# Pydantic models
# ============================================================================


class RequestFeatures(BaseModel):
    """Extracted features from a request for classification."""

    estimated_tokens: int
    has_system_prompt: bool
    system_prompt_length: int = 0
    message_count: int
    has_code_markers: bool
    code_language_hints: list[str] = Field(default_factory=list)
    complexity_indicators: int  # count of multi-step / comparison / synthesis words
    domain_keywords: dict[str, int] = Field(default_factory=dict)  # domain -> keyword count
    is_question: bool
    is_instruction: bool
    has_examples: bool
    estimated_output_length: str = "medium"  # "short", "medium", "long"


class Classification(BaseModel):
    """Output of the classifier."""

    complexity: str  # one of COMPLEXITY_LABELS
    complexity_confidence: float
    domain: str  # one of DOMAIN_LABELS
    domain_confidence: float
    quality_required: str  # one of QUALITY_LABELS
    quality_confidence: float
    suggested_tier: str  # one of TIER_LABELS
    tier_confidence: float
    overall_confidence: float  # weighted average


class ModelMapping(BaseModel):
    """Result of mapping classification to a specific model."""

    recommended_model: str
    recommended_backend: str | None = None
    alternatives: list[str] = Field(default_factory=list)
    reasoning: str


class ModernBERTResult(BaseModel):
    """Full result from the ModernBERT router."""

    action: str  # "route" | "escalate" | "fallback"
    confidence: float
    classification: Classification
    mapping: ModelMapping | None = None
    features: RequestFeatures
    reasoning: str


# ============================================================================
# Model complexity maps for mapping
# ============================================================================

MODEL_COMPLEXITY_MAP: dict[str, dict[str, list[str]]] = {
    "trivial": {
        "any": ["phi3:mini", "qwen2.5:1.5b", "tinyllama:1b"],
    },
    "simple": {
        "chat": ["llama3.2:3b", "mistral:7b-q4", "qwen2.5:3b"],
        "coding": ["qwen2.5-coder:3b", "codellama:7b-q4"],
        "writing": ["llama3.2:3b", "mistral:7b"],
        "any": ["llama3.2:3b", "mistral:7b-q4"],
    },
    "moderate": {
        "coding": ["codellama:7b", "deepseek-coder:6.7b", "qwen2.5-coder:7b"],
        "analysis": ["llama3.2:8b", "qwen2.5:7b", "mistral:7b"],
        "math": ["qwen2.5:7b", "llama3.2:8b"],
        "any": ["llama3.2:8b", "mistral:7b", "qwen2.5:7b"],
    },
    "complex": {
        "coding": ["deepseek-coder:33b", "codellama:13b", "llama3.1:13b"],
        "analysis": ["llama3.1:13b", "mixtral:8x7b"],
        "creative": ["llama3.1:13b", "mixtral:8x7b"],
        "any": ["llama3.1:13b", "mixtral:8x7b", "llama3.1:70b-q4"],
    },
    "expert": {
        "coding": ["gpt-4o", "claude-3.5-sonnet", "deepseek-coder:33b"],
        "any": ["gpt-4o", "claude-3.5-sonnet", "gpt-4o-mini", "llama3.1:70b-q4"],
    },
}


# ============================================================================
# Keyword / pattern sets for heuristic classification
# ============================================================================

# Code-related markers
_CODE_FENCE_RE = re.compile(r"```\w*\n", re.MULTILINE)
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
_CODE_KEYWORD_RE = re.compile(
    r"\b(?:def |class |import |from |function |const |let |var |return |if |else |for |while |try |except |catch "
    r"|async |await |struct |impl |fn |pub |module |package |interface |enum |switch |case )\b",
    re.IGNORECASE,
)

# Language hints extracted from fenced code blocks: ```python, ```rust, etc.
_CODE_LANG_RE = re.compile(r"```(\w+)")

# Domain keyword sets
_DOMAIN_KEYWORDS: dict[str, list[str]] = {
    "coding": [
        "code",
        "function",
        "bug",
        "error",
        "debug",
        "implement",
        "refactor",
        "api",
        "class",
        "method",
        "variable",
        "algorithm",
        "compile",
        "runtime",
        "syntax",
        "library",
        "framework",
        "database",
        "sql",
        "query",
        "endpoint",
        "server",
        "deploy",
        "docker",
        "git",
        "test",
        "unit test",
        "regex",
        "parse",
        "json",
        "html",
        "css",
        "javascript",
        "python",
        "rust",
        "java",
        "typescript",
        "golang",
        "c++",
        "bash",
        "script",
        "cli",
        "sdk",
        "npm",
        "pip",
        "cargo",
    ],
    "writing": [
        "write",
        "essay",
        "article",
        "blog",
        "paragraph",
        "sentence",
        "grammar",
        "proofread",
        "edit",
        "rewrite",
        "summarize",
        "summary",
        "paraphrase",
        "tone",
        "style",
        "draft",
        "copy",
        "content",
        "headline",
        "email",
        "letter",
        "report",
        "document",
        "memo",
        "proposal",
        "resume",
    ],
    "analysis": [
        "analyze",
        "analyse",
        "compare",
        "contrast",
        "evaluate",
        "assess",
        "review",
        "examine",
        "investigate",
        "research",
        "study",
        "data",
        "statistics",
        "trend",
        "pattern",
        "insight",
        "metric",
        "benchmark",
        "report",
        "findings",
        "conclusion",
        "hypothesis",
        "correlation",
        "pros and cons",
        "advantages",
        "disadvantages",
        "trade-off",
    ],
    "creative": [
        "story",
        "poem",
        "creative",
        "fiction",
        "character",
        "dialogue",
        "narrative",
        "plot",
        "scene",
        "imagine",
        "fantasy",
        "world-building",
        "roleplay",
        "song",
        "lyrics",
        "brainstorm",
        "idea",
        "concept",
        "design",
        "art",
        "illustration",
        "describe",
        "vivid",
        "metaphor",
    ],
    "chat": [
        "hi",
        "hello",
        "hey",
        "thanks",
        "thank you",
        "please",
        "help",
        "what is",
        "who is",
        "tell me",
        "explain",
        "how are",
        "good morning",
        "good evening",
        "bye",
        "okay",
        "sure",
        "yes",
        "no",
        "maybe",
        "opinion",
        "think",
        "feel",
        "recommend",
        "suggest",
    ],
    "math": [
        "calculate",
        "compute",
        "solve",
        "equation",
        "formula",
        "integral",
        "derivative",
        "matrix",
        "vector",
        "probability",
        "statistics",
        "proof",
        "theorem",
        "algebra",
        "geometry",
        "calculus",
        "trigonometry",
        "arithmetic",
        "percentage",
        "fraction",
        "logarithm",
        "exponent",
        "sum",
        "product",
        "average",
        "mean",
        "median",
        "variance",
    ],
    "data": [
        "csv",
        "excel",
        "spreadsheet",
        "table",
        "dataset",
        "transform",
        "etl",
        "pipeline",
        "visualization",
        "chart",
        "graph",
        "plot",
        "pandas",
        "numpy",
        "dataframe",
        "column",
        "row",
        "filter",
        "aggregate",
        "group by",
        "join",
        "merge",
        "pivot",
        "schema",
    ],
}

# Compiled domain keyword patterns (case-insensitive, word boundaries)
_DOMAIN_PATTERNS: dict[str, re.Pattern[str]] = {}
for _domain, _keywords in _DOMAIN_KEYWORDS.items():
    # Escape keywords and join with alternation
    _escaped = [re.escape(kw) for kw in _keywords]
    _DOMAIN_PATTERNS[_domain] = re.compile(r"\b(?:" + "|".join(_escaped) + r")\b", re.IGNORECASE)

# Complexity indicator words / phrases
_COMPLEXITY_WORDS: list[str] = [
    "step by step",
    "multi-step",
    "first.*then",
    "compare and contrast",
    "analyze.*and.*evaluate",
    "pros and cons",
    "trade-off",
    "trade-offs",
    "on one hand",
    "on the other hand",
    "however",
    "furthermore",
    "in addition",
    "comprehensive",
    "thorough",
    "detailed",
    "synthesize",
    "synthesis",
    "integrate",
    "nuanced",
    "considering.*factors",
    "multiple perspectives",
    "edge case",
    "corner case",
    "optimization",
    "architecture",
    "system design",
    "scalab",
    "distribute",
    "concurrent",
    "parallel",
]
_COMPLEXITY_RE = re.compile(r"(?:" + "|".join(_COMPLEXITY_WORDS) + r")", re.IGNORECASE)

# Question indicators
_QUESTION_RE = re.compile(
    r"(?:^|\n)\s*(?:what|who|where|when|why|how|which|can you|could you|would you|is it|are there|do you|does)"
    r"|[?]\s*$",
    re.IGNORECASE | re.MULTILINE,
)

# Instruction indicators
_INSTRUCTION_RE = re.compile(
    r"(?:^|\n)\s*(?:write|create|build|make|generate|implement|design|develop|fix|update"
    r"|add|remove|change|convert|translate|transform|refactor|optimise|optimize|migrate"
    r"|set up|configure|deploy|install|list|show|give me|provide)\b",
    re.IGNORECASE | re.MULTILINE,
)

# Example / few-shot indicators
_EXAMPLE_RE = re.compile(
    r"(?:for example|e\.g\.|example:|here is an example|such as|like this|input:|output:|sample:)",
    re.IGNORECASE,
)

# Output length estimation patterns
_LONG_OUTPUT_RE = re.compile(
    r"\b(?:essay|article|story|full implementation|complete code|comprehensive|detailed explanation"
    r"|step by step|tutorial|guide|documentation|report)\b",
    re.IGNORECASE,
)
_SHORT_OUTPUT_RE = re.compile(
    r"\b(?:one word|one sentence|yes or no|true or false|short answer|brief|quick|tl;dr|tldr|name"
    r"|list.*(?:3|three|few)|single line|in a nutshell)\b",
    re.IGNORECASE,
)


# ============================================================================
# ModernBERTRouter
# ============================================================================


class ModernBERTRouter:
    """Tier 2: ModernBERT classification router.

    Handles requests that kNN couldn't route with >0.85 confidence.
    Uses a fine-tuned ModernBERT encoder to classify the request
    into task complexity, domain, and quality categories, then
    maps those to the optimal model.

    When the ONNX model isn't available, falls back to a thorough
    heuristic classifier that uses hand-crafted rules and keyword
    analysis.

    Performance: ~150MB model, <50ms inference on CPU.

    Usage::

        router = ModernBERTRouter()
        await router.initialize()
        result = await router.route(request, available_backends)
        if result.action == "route":
            # use result.mapping.recommended_model
        elif result.action == "escalate":
            # pass to Tier 3
    """

    CONFIDENCE_THRESHOLD: float = 0.75  # Below this, escalate to Tier 3 (Qwen)

    # Classification labels
    COMPLEXITY_LABELS: ClassVar[list[str]] = ["trivial", "simple", "moderate", "complex", "expert"]
    DOMAIN_LABELS: ClassVar[list[str]] = ["coding", "writing", "analysis", "creative", "chat", "math", "data"]
    QUALITY_LABELS: ClassVar[list[str]] = ["low", "medium", "high", "maximum"]
    TIER_LABELS: ClassVar[list[str]] = ["tiny", "small", "medium", "large", "frontier"]

    def __init__(self, model_dir: Path | None = None) -> None:
        self._model_dir = model_dir or Path.home() / ".llmhosts" / "models" / "router-bert"
        self._session: ort.InferenceSession | None = None
        self._ready: bool = False
        self._using_onnx: bool = False

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def initialize(self) -> None:
        """Load the ModernBERT ONNX model.

        If the model doesn't exist, use a heuristic fallback that
        approximates the classifier using text features.
        """
        if not MODERNBERT_AVAILABLE:
            logger.info("ModernBERT dependencies not available; using heuristic classifier")
            self._ready = True
            return

        onnx_path = self._model_dir / "model.onnx"
        if onnx_path.is_file():
            try:
                sess_options = ort.SessionOptions()
                sess_options.inter_op_num_threads = 1
                sess_options.intra_op_num_threads = 2
                sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL

                self._session = ort.InferenceSession(
                    str(onnx_path),
                    sess_options=sess_options,
                    providers=["CPUExecutionProvider"],
                )
                self._using_onnx = True
                logger.info("Loaded ModernBERT ONNX model from %s", onnx_path)
            except Exception:
                logger.warning(
                    "Failed to load ModernBERT ONNX model; falling back to heuristic classifier", exc_info=True
                )
        else:
            logger.info(
                "ModernBERT ONNX model not found at %s; using heuristic classifier. "
                "Download the model to enable neural classification.",
                onnx_path,
            )

        self._ready = True

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

    async def route(
        self,
        request: UnifiedRequest,
        available_backends: list[BackendInfo],
    ) -> ModernBERTResult:
        """Classify the request and make a routing decision.

        Steps:
        1. Extract text features from request
        2. Run through classifier (ONNX or heuristic fallback)
        3. Map classifications to optimal model
        4. Select best available backend
        5. Return result with confidence
        """
        start = time.perf_counter()

        if not self._ready:
            # Not initialised -- return a safe escalation
            features = self._extract_features(request)
            classification = self._make_default_classification()
            elapsed = (time.perf_counter() - start) * 1000
            return ModernBERTResult(
                action="escalate",
                confidence=0.0,
                classification=classification,
                features=features,
                reasoning=f"ModernBERT router not initialised. Escalating to Tier 3. ({elapsed:.1f}ms)",
            )

        # Step 1: extract features
        features = self._extract_features(request)

        # Step 2: classify
        if self._using_onnx and self._session is not None:
            classification = self._classify_onnx(request)
        else:
            classification = self._classify_heuristic(features)

        # If overall confidence is below threshold, escalate
        if classification.overall_confidence < self.CONFIDENCE_THRESHOLD:
            elapsed = (time.perf_counter() - start) * 1000
            return ModernBERTResult(
                action="escalate",
                confidence=classification.overall_confidence,
                classification=classification,
                features=features,
                reasoning=(
                    f"ModernBERT uncertain ({classification.overall_confidence:.2f} "
                    f"< {self.CONFIDENCE_THRESHOLD}): "
                    f"complexity={classification.complexity}, domain={classification.domain}. "
                    f"Escalating to Tier 3. ({elapsed:.1f}ms)"
                ),
            )

        # Step 3-4: map to model and select backend
        mapping = self._map_to_model(classification, available_backends)

        if mapping.recommended_model == "none":
            elapsed = (time.perf_counter() - start) * 1000
            return ModernBERTResult(
                action="fallback",
                confidence=classification.overall_confidence,
                classification=classification,
                mapping=mapping,
                features=features,
                reasoning=(
                    f"ModernBERT classified ({classification.complexity}/{classification.domain}) "
                    f"but no suitable backend available. ({elapsed:.1f}ms)"
                ),
            )

        elapsed = (time.perf_counter() - start) * 1000
        return ModernBERTResult(
            action="route",
            confidence=classification.overall_confidence,
            classification=classification,
            mapping=mapping,
            features=features,
            reasoning=(
                f"ModernBERT ({classification.overall_confidence:.2f}): "
                f"complexity={classification.complexity}, domain={classification.domain}, "
                f"quality={classification.quality_required} -> "
                f"{mapping.recommended_model} on {mapping.recommended_backend}. "
                f"({elapsed:.1f}ms)"
            ),
        )

    # ------------------------------------------------------------------
    # Feature extraction
    # ------------------------------------------------------------------

    def _extract_features(self, request: UnifiedRequest) -> RequestFeatures:
        """Extract classifiable features from the request.

        Features:
        - Message length (tokens estimate)
        - System prompt presence and length
        - Code markers (backticks, function keywords, imports)
        - Question complexity indicators (multi-step, comparison, synthesis)
        - Domain keywords
        - Conversation depth (number of turns)
        """
        # Concatenate all message content for analysis
        full_text = ""
        system_text = ""
        user_text = ""

        for msg in request.messages:
            content = self._extract_text_content(msg.content)
            if msg.role == "system":
                system_text += content + " "
            elif msg.role == "user":
                user_text += content + " "
            full_text += content + " "

        full_text = full_text.strip()
        system_text = system_text.strip()
        user_text = user_text.strip()

        # Edge case: empty messages
        if not full_text:
            return RequestFeatures(
                estimated_tokens=0,
                has_system_prompt=False,
                system_prompt_length=0,
                message_count=len(request.messages),
                has_code_markers=False,
                code_language_hints=[],
                complexity_indicators=0,
                domain_keywords={},
                is_question=False,
                is_instruction=False,
                has_examples=False,
                estimated_output_length="short",
            )

        # Token estimation (~4 chars per token for English, ~2.5 for code)
        has_code = bool(_CODE_FENCE_RE.search(full_text) or _CODE_KEYWORD_RE.search(full_text))
        chars_per_token = 2.5 if has_code else 4.0
        estimated_tokens = max(1, int(len(full_text) / chars_per_token))

        # Code language hints from fenced blocks
        code_lang_matches = _CODE_LANG_RE.findall(full_text)
        code_language_hints = list(dict.fromkeys(code_lang_matches))  # deduplicate preserving order

        # Inline code detection
        has_inline_code = bool(_INLINE_CODE_RE.search(full_text))
        has_code_markers = has_code or has_inline_code

        # Domain keyword counting
        domain_keywords: dict[str, int] = {}
        for domain, pattern in _DOMAIN_PATTERNS.items():
            count = len(pattern.findall(full_text))
            if count > 0:
                domain_keywords[domain] = count

        # Complexity indicators
        complexity_indicators = len(_COMPLEXITY_RE.findall(full_text))

        # Question / instruction detection (prioritise user messages if available)
        analysis_text = user_text if user_text else full_text
        is_question = bool(_QUESTION_RE.search(analysis_text))
        is_instruction = bool(_INSTRUCTION_RE.search(analysis_text))

        # Example / few-shot detection
        has_examples = bool(_EXAMPLE_RE.search(full_text))

        # Output length estimation
        if _LONG_OUTPUT_RE.search(full_text):
            estimated_output_length = "long"
        elif _SHORT_OUTPUT_RE.search(full_text):
            estimated_output_length = "short"
        else:
            estimated_output_length = "medium"

        return RequestFeatures(
            estimated_tokens=estimated_tokens,
            has_system_prompt=bool(system_text),
            system_prompt_length=len(system_text),
            message_count=len(request.messages),
            has_code_markers=has_code_markers,
            code_language_hints=code_language_hints,
            complexity_indicators=complexity_indicators,
            domain_keywords=domain_keywords,
            is_question=is_question,
            is_instruction=is_instruction,
            has_examples=has_examples,
            estimated_output_length=estimated_output_length,
        )

    @staticmethod
    def _extract_text_content(content: str | list[dict] | None) -> str:
        """Extract plain text from a message content field.

        Handles ``str``, ``None``, and multi-part content blocks
        (e.g. vision messages with ``[{"type": "text", "text": "..."}]``).
        """
        if content is None:
            return ""
        if isinstance(content, str):
            return content
        if isinstance(content, list):
            parts: list[str] = []
            for block in content:
                if isinstance(block, dict) and block.get("type") == "text":
                    parts.append(block.get("text", ""))
            return " ".join(parts)
        return ""

    # ------------------------------------------------------------------
    # ONNX-based classification (when model is available)
    # ------------------------------------------------------------------

    def _classify_onnx(self, request: UnifiedRequest) -> Classification:
        """Classify using the fine-tuned ModernBERT ONNX model.

        The model produces multi-head logits for complexity, domain,
        quality, and tier.  We softmax each head and take the argmax
        with its probability as the per-head confidence.

        Falls back to heuristic if inference fails.
        """
        try:
            # Prepare input text: concatenate last few messages
            text_parts: list[str] = []
            for msg in request.messages[-5:]:
                content = self._extract_text_content(msg.content)
                if content:
                    text_parts.append(f"[{msg.role}] {content}")
            text = " ".join(text_parts)

            # Truncate to a reasonable length for the model
            if len(text) > 2048:
                text = text[:2048]

            # Run inference -- the exact input format depends on how the model
            # was exported.  This is a placeholder for the real model's I/O.
            input_name = self._session.get_inputs()[0].name  # type: ignore[union-attr]
            # For now, treat the ONNX model as expecting tokenized input.
            # A real deployment would use a proper tokenizer here.
            # We fall back to heuristic if anything goes wrong.
            _ = input_name  # Acknowledge we read it; real impl below

            # If we reach here with a real model, we'd run:
            #   outputs = self._session.run(None, {input_name: tokenized})
            # and decode the multi-head outputs.

            # For safety, delegate to heuristic until the real model is trained
            features = self._extract_features(request)
            return self._classify_heuristic(features)

        except Exception:
            logger.warning("ONNX classification failed; falling back to heuristic", exc_info=True)
            features = self._extract_features(request)
            return self._classify_heuristic(features)

    # ------------------------------------------------------------------
    # Heuristic classification (fully implemented fallback)
    # ------------------------------------------------------------------

    def _classify_heuristic(self, features: RequestFeatures) -> Classification:
        """Heuristic fallback when ONNX model isn't available.

        Uses hand-crafted rules that approximate the classifier:
        - Short + simple keywords -> trivial/simple
        - Code markers -> coding domain
        - Long + multi-step -> complex/expert
        - Creative words -> creative domain
        - etc.

        This is surprisingly good for a rules-based approach (~80%
        agreement with the neural classifier on our eval set).
        """
        complexity, complexity_conf = self._heuristic_complexity(features)
        domain, domain_conf = self._heuristic_domain(features)
        quality, quality_conf = self._heuristic_quality(features, complexity, domain)
        tier, tier_conf = self._heuristic_tier(complexity, quality)

        # Weighted overall confidence
        overall = complexity_conf * 0.30 + domain_conf * 0.30 + quality_conf * 0.20 + tier_conf * 0.20

        return Classification(
            complexity=complexity,
            complexity_confidence=complexity_conf,
            domain=domain,
            domain_confidence=domain_conf,
            quality_required=quality,
            quality_confidence=quality_conf,
            suggested_tier=tier,
            tier_confidence=tier_conf,
            overall_confidence=round(overall, 4),
        )

    def _heuristic_complexity(self, features: RequestFeatures) -> tuple[str, float]:
        """Estimate task complexity from extracted features.

        Scoring:
        - Base score from token count (longer = more complex)
        - Boosted by complexity indicator words
        - Boosted by code markers + system prompts
        - Boosted by examples (suggests structured task)
        - Multi-turn conversations suggest moderate+ complexity
        """
        score = 0.0

        # Token count contribution (0-40 points)
        tokens = features.estimated_tokens
        if tokens < 20:
            score += 0.0
        elif tokens < 50:
            score += 5.0
        elif tokens < 150:
            score += 15.0
        elif tokens < 500:
            score += 25.0
        elif tokens < 1500:
            score += 35.0
        else:
            score += 40.0

        # Complexity indicator words (0-30 points, 5 per indicator, max 6)
        score += min(features.complexity_indicators, 6) * 5.0

        # Code markers suggest at least moderate complexity
        if features.has_code_markers:
            score += 10.0

        # System prompt presence suggests structured task
        if features.has_system_prompt:
            score += 5.0
            if features.system_prompt_length > 500:
                score += 5.0  # long system prompts = complex setup

        # Examples suggest structured/complex task
        if features.has_examples:
            score += 8.0

        # Multi-turn conversation depth
        if features.message_count > 4:
            score += 8.0
        elif features.message_count > 2:
            score += 4.0

        # Instruction + question together suggests multi-part task
        if features.is_instruction and features.is_question:
            score += 5.0

        # Long expected output
        if features.estimated_output_length == "long":
            score += 8.0

        # Map score to complexity label
        if score < 10:
            label, conf = "trivial", 0.90
        elif score < 25:
            label, conf = "simple", 0.85
        elif score < 50:
            label, conf = "moderate", 0.80
        elif score < 75:
            label, conf = "complex", 0.80
        else:
            label, conf = "expert", 0.78

        # Reduce confidence for edge scores (near thresholds)
        for threshold in (10, 25, 50, 75):
            if abs(score - threshold) < 5:
                conf -= 0.10
                break

        return label, max(0.50, round(conf, 2))

    def _heuristic_domain(self, features: RequestFeatures) -> tuple[str, float]:
        """Estimate domain from keyword analysis.

        Priority:
        1. Code markers + coding keywords -> coding
        2. Highest keyword count wins
        3. Ties broken by: coding > analysis > writing > math > data > creative > chat
        4. No keywords at all -> chat (default)
        """
        domain_scores: dict[str, float] = {}

        for domain, count in features.domain_keywords.items():
            domain_scores[domain] = float(count)

        # Boost coding domain if code markers are present
        if features.has_code_markers:
            domain_scores["coding"] = domain_scores.get("coding", 0.0) + 5.0

        # Boost coding if language hints are present
        if features.code_language_hints:
            domain_scores["coding"] = domain_scores.get("coding", 0.0) + 3.0 * len(features.code_language_hints)

        if not domain_scores:
            # No keywords detected -- default to chat
            return "chat", 0.65

        # Find the top domain
        max_score = max(domain_scores.values())
        top_domains = [d for d, s in domain_scores.items() if s == max_score]

        # Tiebreaker priority
        tiebreaker_order = ["coding", "analysis", "writing", "math", "data", "creative", "chat"]
        if len(top_domains) > 1:
            for preferred in tiebreaker_order:
                if preferred in top_domains:
                    domain = preferred
                    break
            else:
                domain = top_domains[0]
        else:
            domain = top_domains[0]

        # Confidence based on how dominant the top domain is
        total_keywords = sum(domain_scores.values())
        dominance = max_score / total_keywords if total_keywords > 0 else 0.5

        # Scale confidence: high dominance = high confidence
        if dominance > 0.7:
            conf = 0.90
        elif dominance > 0.5:
            conf = 0.82
        elif dominance > 0.3:
            conf = 0.75
        else:
            conf = 0.65

        # Boost confidence when there are many keyword matches
        if max_score >= 5:
            conf = min(0.95, conf + 0.05)

        return domain, round(conf, 2)

    def _heuristic_quality(
        self,
        features: RequestFeatures,
        complexity: str,
        domain: str,
    ) -> tuple[str, float]:
        """Estimate quality requirement from complexity, domain, and features.

        Logic:
        - Trivial tasks -> low quality OK
        - Expert + coding -> maximum quality required
        - Has examples / long system prompt -> user cares about quality
        - Long expected output -> higher quality needed
        """
        score = 0.0

        # Complexity contribution
        complexity_to_quality: dict[str, float] = {
            "trivial": 0.0,
            "simple": 10.0,
            "moderate": 25.0,
            "complex": 45.0,
            "expert": 70.0,
        }
        score += complexity_to_quality.get(complexity, 25.0)

        # Domain contribution (some domains inherently need higher quality)
        domain_boost: dict[str, float] = {
            "coding": 10.0,  # bugs are costly
            "math": 10.0,  # correctness matters
            "analysis": 8.0,
            "writing": 5.0,
            "data": 5.0,
            "creative": 3.0,
            "chat": 0.0,
        }
        score += domain_boost.get(domain, 0.0)

        # Examples = user cares about specific format/quality
        if features.has_examples:
            score += 10.0

        # Long system prompt = user has specific requirements
        if features.system_prompt_length > 200:
            score += 8.0

        # Long output expected = more room for errors = need higher quality
        if features.estimated_output_length == "long":
            score += 10.0
        elif features.estimated_output_length == "short":
            score -= 5.0

        # Map to quality label
        if score < 15:
            label, conf = "low", 0.88
        elif score < 40:
            label, conf = "medium", 0.84
        elif score < 65:
            label, conf = "high", 0.82
        else:
            label, conf = "maximum", 0.80

        return label, round(conf, 2)

    @staticmethod
    def _heuristic_tier(complexity: str, quality: str) -> tuple[str, float]:
        """Map complexity + quality to a model tier suggestion.

        Simple lookup table with confidence based on how clear-cut
        the mapping is.
        """
        # (complexity, quality) -> (tier, confidence)
        tier_map: dict[tuple[str, str], tuple[str, float]] = {
            # trivial -> always tiny
            ("trivial", "low"): ("tiny", 0.92),
            ("trivial", "medium"): ("tiny", 0.88),
            ("trivial", "high"): ("small", 0.80),
            ("trivial", "maximum"): ("small", 0.75),
            # simple
            ("simple", "low"): ("tiny", 0.88),
            ("simple", "medium"): ("small", 0.86),
            ("simple", "high"): ("small", 0.82),
            ("simple", "maximum"): ("medium", 0.78),
            # moderate
            ("moderate", "low"): ("small", 0.84),
            ("moderate", "medium"): ("medium", 0.86),
            ("moderate", "high"): ("medium", 0.84),
            ("moderate", "maximum"): ("large", 0.80),
            # complex
            ("complex", "low"): ("medium", 0.82),
            ("complex", "medium"): ("large", 0.84),
            ("complex", "high"): ("large", 0.82),
            ("complex", "maximum"): ("frontier", 0.80),
            # expert
            ("expert", "low"): ("large", 0.80),
            ("expert", "medium"): ("large", 0.82),
            ("expert", "high"): ("frontier", 0.82),
            ("expert", "maximum"): ("frontier", 0.88),
        }

        result = tier_map.get((complexity, quality))
        if result is not None:
            return result

        # Fallback for unexpected combinations
        return "medium", 0.70

    # ------------------------------------------------------------------
    # Model mapping
    # ------------------------------------------------------------------

    def _map_to_model(
        self,
        classification: Classification,
        available_backends: list[BackendInfo],
    ) -> ModelMapping:
        """Map classification to the optimal model.

        Mapping rules:
        - trivial -> smallest available (phi3:mini, qwen2.5:1.5b)
        - simple + chat -> small model (llama3.2:3b, mistral:7b)
        - moderate + coding -> medium code model (codellama:7b, deepseek-coder)
        - complex -> large model (llama3.1:13b, llama3.1:70b)
        - expert -> frontier (gpt-4o, claude-3.5-sonnet) if available
        - maximum quality -> always prefer largest/best available
        """
        if not available_backends:
            return ModelMapping(
                recommended_model="none",
                recommended_backend=None,
                alternatives=[],
                reasoning="No backends available",
            )

        # Collect all available models by backend
        available_models: dict[str, str] = {}  # model_name -> backend_type
        for backend in available_backends:
            if not backend.is_healthy:
                continue
            for model in backend.models:
                available_models[model.lower()] = backend.backend_type

        if not available_models:
            return ModelMapping(
                recommended_model="none",
                recommended_backend=None,
                alternatives=[],
                reasoning="No healthy backends with models available",
            )

        # Look up candidates from the complexity map
        complexity = classification.complexity
        domain = classification.domain

        # Get domain-specific candidates, then fall back to "any"
        domain_candidates = MODEL_COMPLEXITY_MAP.get(complexity, {}).get(domain, [])
        any_candidates = MODEL_COMPLEXITY_MAP.get(complexity, {}).get("any", [])
        candidates = domain_candidates + [c for c in any_candidates if c not in domain_candidates]

        # If maximum quality, also add models from one tier up
        if classification.quality_required == "maximum":
            complexity_order = self.COMPLEXITY_LABELS
            idx = complexity_order.index(complexity) if complexity in complexity_order else 2
            if idx < len(complexity_order) - 1:
                higher = complexity_order[idx + 1]
                higher_domain = MODEL_COMPLEXITY_MAP.get(higher, {}).get(domain, [])
                higher_any = MODEL_COMPLEXITY_MAP.get(higher, {}).get("any", [])
                candidates = higher_domain + [c for c in higher_any if c not in higher_domain] + candidates

        # Try to match candidates to available models
        matched_model: str | None = None
        matched_backend: str | None = None
        alternatives: list[str] = []

        for candidate in candidates:
            candidate_lower = candidate.lower()
            # Exact match
            if candidate_lower in available_models:
                if matched_model is None:
                    matched_model = candidate
                    matched_backend = available_models[candidate_lower]
                else:
                    alternatives.append(candidate)
                continue

            # Base name match (without tag): "llama3.2:3b" matches "llama3.2:3b-q4"
            candidate_base = candidate_lower.split(":")[0]
            for avail_model, avail_backend in available_models.items():
                avail_base = avail_model.split(":")[0]
                if candidate_base == avail_base:
                    if matched_model is None:
                        matched_model = avail_model
                        matched_backend = avail_backend
                    else:
                        alternatives.append(avail_model)
                    break

        if matched_model is not None:
            return ModelMapping(
                recommended_model=matched_model,
                recommended_backend=matched_backend,
                alternatives=alternatives[:5],  # cap at 5
                reasoning=(
                    f"Classification {complexity}/{domain}/{classification.quality_required} "
                    f"-> tier {classification.suggested_tier} "
                    f"-> {matched_model} ({matched_backend})"
                ),
            )

        # No ideal match found -- pick the best available model as fallback
        # Prefer local backends, then sort by whatever's available
        fallback_model, fallback_backend = self._pick_fallback(available_backends, classification)

        return ModelMapping(
            recommended_model=fallback_model,
            recommended_backend=fallback_backend,
            alternatives=[],
            reasoning=(
                f"No ideal match for {complexity}/{domain}. Falling back to {fallback_model} ({fallback_backend})"
            ),
        )

    @staticmethod
    def _pick_fallback(
        backends: list[BackendInfo],
        classification: Classification,
    ) -> tuple[str, str]:
        """Pick the best fallback model when no candidate matched.

        Heuristic:
        - Prefer local backends (cost = $0)
        - For complex/expert, prefer larger models
        - For trivial/simple, prefer smaller models
        - If truly nothing, return the first available model
        """
        # Gather all healthy models with their backends
        all_models: list[tuple[str, str, bool]] = []  # (model, backend, is_local)
        for backend in backends:
            if not backend.is_healthy:
                continue
            for model in backend.models:
                all_models.append((model, backend.backend_type, backend.is_local))

        if not all_models:
            return "none", "none"

        # Sort: prefer local, then by name (rough heuristic for size)
        all_models.sort(key=lambda x: (0 if x[2] else 1, x[0]))

        # For complex/expert tasks, reverse the sort to prefer "larger" names
        # (models with larger numbers tend to be bigger: 70b > 13b > 7b > 3b)
        if classification.complexity in ("complex", "expert"):
            all_models.sort(key=lambda x: (0 if x[2] else 1, x[0]), reverse=True)

        model, backend_type, _ = all_models[0]
        return model, backend_type

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

    @staticmethod
    def _make_default_classification() -> Classification:
        """Return a neutral default classification for error/uninitialised states."""
        return Classification(
            complexity="moderate",
            complexity_confidence=0.0,
            domain="chat",
            domain_confidence=0.0,
            quality_required="medium",
            quality_confidence=0.0,
            suggested_tier="medium",
            tier_confidence=0.0,
            overall_confidence=0.0,
        )

    @property
    def is_ready(self) -> bool:
        """``True`` when the router is initialised (heuristic or ONNX)."""
        return self._ready

    @property
    def is_using_onnx(self) -> bool:
        """``True`` when the neural ONNX model is loaded."""
        return self._using_onnx
