"""GPU-less smart routing -- intelligent cloud-only routing for BYOK users.

When no local GPU or models are available, this router picks the cheapest
cloud model that is GOOD ENOUGH for the task.  It uses heuristic complexity
classification to avoid sending trivial requests to expensive models.

Complexity tiers:
    - **Simple**: Q&A, translation, summarization -> cheapest model
    - **Medium**: Code generation, analysis -> mid-tier model
    - **Complex**: Multi-step reasoning, creative writing -> best model
"""

from __future__ import annotations

import logging
import re
from typing import Any

from pydantic import BaseModel, Field

from llmhosts.router.cost import CLOUD_PRICING, estimate_request_tokens

logger = logging.getLogger(__name__)

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


class TaskComplexity(BaseModel):
    """Result of heuristic complexity classification."""

    level: str  # "simple", "medium", "complex"
    confidence: float
    signals: dict[str, Any] = Field(default_factory=dict)  # what triggered this classification
    estimated_output_tokens: int = 256


class CloudModelSelection(BaseModel):
    """Selected cloud model with reasoning and alternatives."""

    provider: str
    model: str
    reason: str
    estimated_cost: float
    alternatives: list[dict[str, Any]] = Field(default_factory=list)  # other options with costs


# ---------------------------------------------------------------------------
# Cloud model catalog per complexity tier
# ---------------------------------------------------------------------------

# Each entry: (provider, model_id, display_name)
# Order within tier: cheapest first
_SIMPLE_MODELS: list[tuple[str, str, str]] = [
    ("openai", "gpt-4o-mini", "GPT-4o Mini"),
    ("anthropic", "claude-3-5-haiku-20241022", "Claude 3.5 Haiku"),
    ("google", "gemini-1.5-flash", "Gemini 1.5 Flash"),
    ("mistral", "mistral-small-latest", "Mistral Small"),
    ("openai", "gpt-3.5-turbo", "GPT-3.5 Turbo"),
]

_MEDIUM_MODELS: list[tuple[str, str, str]] = [
    ("openai", "gpt-4o", "GPT-4o"),
    ("anthropic", "claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet"),
    ("google", "gemini-1.5-pro", "Gemini 1.5 Pro"),
    ("mistral", "mistral-large-latest", "Mistral Large"),
]

_COMPLEX_MODELS: list[tuple[str, str, str]] = [
    ("openai", "gpt-4o", "GPT-4o"),
    ("anthropic", "claude-3-opus-20240229", "Claude 3 Opus"),
    ("openai", "o1", "OpenAI o1"),
]

_TIER_MODELS: dict[str, list[tuple[str, str, str]]] = {
    "simple": _SIMPLE_MODELS,
    "medium": _MEDIUM_MODELS,
    "complex": _COMPLEX_MODELS,
}

# ---------------------------------------------------------------------------
# Heuristic signal patterns (compiled once)
# ---------------------------------------------------------------------------

# Code markers -- suggest at least medium complexity
_CODE_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"```"),  # fenced code blocks
    re.compile(r"\bdef\s+\w+"),  # Python function def
    re.compile(r"\bfunction\s+\w+"),  # JS function
    re.compile(r"\bclass\s+\w+"),  # class definition
    re.compile(r"\b(?:import|from)\s+\w+"),  # import statements
    re.compile(r"\b(?:const|let|var)\s+\w+"),  # JS variable declarations
    re.compile(r"(?:->|=>)\s*\{"),  # arrow functions / return type
    re.compile(r"\bfor\s*\("),  # for loops
    re.compile(r"\bwhile\s*\("),  # while loops
    re.compile(r"(?:SELECT|INSERT|UPDATE|DELETE)\s", re.IGNORECASE),  # SQL
]

# Reasoning markers -- suggest complex
_REASONING_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"\bstep[- ]by[- ]step\b", re.IGNORECASE),
    re.compile(r"\banalyze\b", re.IGNORECASE),
    re.compile(r"\banalyse\b", re.IGNORECASE),
    re.compile(r"\bcompare\s+(?:and\s+)?contrast\b", re.IGNORECASE),
    re.compile(r"\bevaluate\b", re.IGNORECASE),
    re.compile(r"\breason(?:ing)?\s+(?:about|through)\b", re.IGNORECASE),
    re.compile(r"\bthink\s+(?:carefully|deeply|through)\b", re.IGNORECASE),
    re.compile(r"\bbreak\s+(?:down|this\s+down)\b", re.IGNORECASE),
    re.compile(r"\bpros?\s+and\s+cons?\b", re.IGNORECASE),
    re.compile(r"\bcritical(?:ly)?\s+(?:assess|think|review)\b", re.IGNORECASE),
    re.compile(r"\bmulti[- ]?step\b", re.IGNORECASE),
    re.compile(r"\bchain[- ]of[- ]thought\b", re.IGNORECASE),
]

# Simple indicators -- strong signals for simple classification
_SIMPLE_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"\btranslat(?:e|ion)\b", re.IGNORECASE),
    re.compile(r"\bsummar(?:ize|y)\b", re.IGNORECASE),
    re.compile(r"\bwhat\s+is\b", re.IGNORECASE),
    re.compile(r"\bdefine\b", re.IGNORECASE),
    re.compile(r"\bexplain\s+(?:what|the)\b", re.IGNORECASE),
    re.compile(r"\blist\s+\d+\b", re.IGNORECASE),
    re.compile(r"\bhow\s+(?:do|does)\s+\w+\s+work\b", re.IGNORECASE),
    re.compile(r"\bfix\s+(?:this|the)\s+(?:typo|grammar|spelling)\b", re.IGNORECASE),
    re.compile(r"\brewrite\s+(?:this|the)\b", re.IGNORECASE),
]

# Creative / complex writing
_CREATIVE_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"\bwrite\s+(?:a|an)\s+(?:story|essay|poem|article|blog)\b", re.IGNORECASE),
    re.compile(r"\bcreative\s+writing\b", re.IGNORECASE),
    re.compile(r"\bdesign\s+(?:a|an)\s+(?:system|architecture)\b", re.IGNORECASE),
    re.compile(r"\barchitect(?:ure)?\b", re.IGNORECASE),
]


# ---------------------------------------------------------------------------
# GPULessRouter
# ---------------------------------------------------------------------------


class GPULessRouter:
    """Smart routing for users with no local GPU/models.

    Picks the cheapest cloud model that's GOOD ENOUGH for the task.
    Uses heuristics to classify task complexity:

    - Simple: Q&A, translation, summarization -> cheapest model
    - Medium: Code generation, analysis -> mid-tier model
    - Complex: Multi-step reasoning, creative writing -> best model
    """

    def __init__(self, available_providers: list[str]) -> None:
        """Initialise with the list of providers the user has keys for.

        Args:
            available_providers: e.g. ``["openai", "anthropic"]``
        """
        self._providers = [p.strip().lower() for p in available_providers]

    # ------------------------------------------------------------------
    # Complexity classification
    # ------------------------------------------------------------------

    def classify_complexity(self, messages: list[dict[str, Any]]) -> TaskComplexity:
        """Classify request complexity from message content.

        Uses heuristics: message length, code markers, reasoning keywords,
        system prompt complexity, conversation length.

        Args:
            messages: OpenAI-format message list (``role`` + ``content``).

        Returns:
            A :class:`TaskComplexity` with level, confidence, and signal breakdown.
        """
        signals: dict[str, Any] = {}
        scores: dict[str, float] = {"simple": 0.0, "medium": 0.0, "complex": 0.0}

        # Concatenate all content for pattern matching
        all_content = self._extract_text(messages)
        user_content = self._extract_text([m for m in messages if m.get("role") == "user"])
        system_content = self._extract_text([m for m in messages if m.get("role") == "system"])

        # ----- Signal 1: Message count (conversation depth) -----
        msg_count = len(messages)
        signals["message_count"] = msg_count
        if msg_count <= 2:
            scores["simple"] += 1.0
        elif msg_count <= 6:
            scores["medium"] += 0.5
        else:
            scores["complex"] += 1.0
            signals["deep_conversation"] = True

        # ----- Signal 2: Total content length -----
        total_chars = len(all_content)
        signals["total_chars"] = total_chars
        if total_chars < 200:
            scores["simple"] += 1.5
        elif total_chars < 1000:
            scores["medium"] += 0.5
        elif total_chars < 3000:
            scores["complex"] += 0.5
        else:
            scores["complex"] += 1.5
            signals["very_long_content"] = True

        # ----- Signal 3: System prompt complexity -----
        sys_len = len(system_content)
        signals["system_prompt_chars"] = sys_len
        if sys_len > 500:
            scores["complex"] += 1.0
            signals["complex_system_prompt"] = True
        elif sys_len > 200:
            scores["medium"] += 0.5

        # ----- Signal 4: Code markers -----
        code_hits = 0
        for pattern in _CODE_PATTERNS:
            matches = pattern.findall(all_content)
            if matches:
                code_hits += len(matches)
        signals["code_marker_hits"] = code_hits
        if code_hits >= 3:
            scores["medium"] += 2.0
            signals["code_heavy"] = True
        elif code_hits >= 1:
            scores["medium"] += 1.0

        # ----- Signal 5: Reasoning markers -----
        reasoning_hits = 0
        for pattern in _REASONING_PATTERNS:
            matches = pattern.findall(all_content)
            if matches:
                reasoning_hits += len(matches)
        signals["reasoning_marker_hits"] = reasoning_hits
        if reasoning_hits >= 2:
            scores["complex"] += 2.5
            signals["reasoning_heavy"] = True
        elif reasoning_hits >= 1:
            scores["complex"] += 1.0

        # ----- Signal 6: Simple task indicators -----
        simple_hits = 0
        for pattern in _SIMPLE_PATTERNS:
            matches = pattern.findall(user_content)
            if matches:
                simple_hits += len(matches)
        signals["simple_marker_hits"] = simple_hits
        if simple_hits >= 2:
            scores["simple"] += 2.0
            signals["clearly_simple"] = True
        elif simple_hits >= 1:
            scores["simple"] += 1.0

        # ----- Signal 7: Creative / architecture patterns -----
        creative_hits = 0
        for pattern in _CREATIVE_PATTERNS:
            matches = pattern.findall(all_content)
            if matches:
                creative_hits += len(matches)
        signals["creative_marker_hits"] = creative_hits
        if creative_hits >= 1:
            scores["complex"] += 1.5
            signals["creative_task"] = True

        # ----- Determine winner -----
        signals["raw_scores"] = dict(scores)
        total_score = sum(scores.values()) or 1.0

        # Pick the level with the highest score
        level = max(scores, key=lambda k: scores[k])
        confidence = scores[level] / total_score

        # Estimate output tokens based on complexity
        estimated_output = self._estimate_output_tokens(level, total_chars, code_hits)

        logger.debug(
            "Complexity classification: level=%s confidence=%.2f scores=%s",
            level,
            confidence,
            scores,
        )

        return TaskComplexity(
            level=level,
            confidence=round(confidence, 3),
            signals=signals,
            estimated_output_tokens=estimated_output,
        )

    # ------------------------------------------------------------------
    # Model selection
    # ------------------------------------------------------------------

    def select_model(
        self,
        complexity: TaskComplexity,
        providers: list[str] | None = None,
    ) -> CloudModelSelection:
        """Select the optimal cloud model for the task complexity.

        Picks the cheapest model from the complexity tier that the user
        has keys for.

        Args:
            complexity: The classified task complexity.
            providers: Override for available providers.  If ``None``,
                       uses the providers passed at init.

        Returns:
            A :class:`CloudModelSelection` with the chosen model and alternatives.

        Raises:
            ValueError: If no providers are available for the complexity tier.
        """
        active_providers = providers or self._providers
        if not active_providers:
            raise ValueError("No cloud providers available. Add API keys with: llmhost keys add <provider> <key>")

        tier_models = _TIER_MODELS.get(complexity.level, _SIMPLE_MODELS)

        # Filter to models whose provider the user has keys for
        available: list[tuple[str, str, str, float]] = []
        for provider, model_id, display_name in tier_models:
            if provider in active_providers:
                cost = self.estimate_cost(model_id, estimated_tokens=complexity.estimated_output_tokens)
                available.append((provider, model_id, display_name, cost))

        if not available:
            # Fall back: try models from a lower tier
            for fallback_level in ("medium", "simple"):
                if fallback_level == complexity.level:
                    continue
                fallback_models = _TIER_MODELS.get(fallback_level, [])
                for provider, model_id, display_name in fallback_models:
                    if provider in active_providers:
                        cost = self.estimate_cost(model_id, estimated_tokens=complexity.estimated_output_tokens)
                        available.append((provider, model_id, display_name, cost))
                if available:
                    break

        if not available:
            raise ValueError(
                f"No models available for complexity='{complexity.level}' with providers: {', '.join(active_providers)}"
            )

        # Sort by estimated cost (cheapest first)
        available.sort(key=lambda x: x[3])

        best_provider, best_model, best_name, best_cost = available[0]

        # Build alternatives list
        alternatives: list[dict[str, Any]] = []
        for provider, model_id, display_name, cost in available[1:]:
            alternatives.append(
                {
                    "provider": provider,
                    "model": model_id,
                    "display_name": display_name,
                    "estimated_cost": round(cost, 8),
                }
            )

        reason = (
            f"Selected {best_name} ({best_model}) for {complexity.level} task "
            f"(confidence: {complexity.confidence:.0%}, est. cost: ${best_cost:.6f})"
        )

        return CloudModelSelection(
            provider=best_provider,
            model=best_model,
            reason=reason,
            estimated_cost=round(best_cost, 8),
            alternatives=alternatives,
        )

    # ------------------------------------------------------------------
    # Cost estimation
    # ------------------------------------------------------------------

    def estimate_cost(
        self,
        model: str,
        messages: list[dict[str, Any]] | None = None,
        *,
        estimated_tokens: int | None = None,
    ) -> float:
        """Estimate request cost based on model pricing and token count.

        Args:
            model: The model identifier (e.g. ``"gpt-4o-mini"``).
            messages: Optional message list for token estimation.
            estimated_tokens: Optional pre-calculated output token estimate.

        Returns:
            Estimated cost in USD.
        """
        pricing = CLOUD_PRICING.get(model)
        if pricing is None:
            return 0.0

        input_tokens = estimate_request_tokens(messages) if messages is not None else 150

        output_tokens = estimated_tokens or 256

        cost = input_tokens * pricing["input"] + output_tokens * pricing["output"]
        return cost

    # ------------------------------------------------------------------
    # Convenience: classify + select in one call
    # ------------------------------------------------------------------

    def route(self, messages: list[dict[str, Any]]) -> tuple[TaskComplexity, CloudModelSelection]:
        """Classify complexity and select the best model in one step.

        Args:
            messages: OpenAI-format message list.

        Returns:
            Tuple of ``(complexity, selection)``.
        """
        complexity = self.classify_complexity(messages)
        selection = self.select_model(complexity)
        return complexity, selection

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _extract_text(messages: list[dict[str, Any]]) -> str:
        """Extract all text content from a message list."""
        parts: list[str] = []
        for msg in messages:
            content = msg.get("content", "")
            if isinstance(content, str):
                parts.append(content)
            elif isinstance(content, list):
                for block in content:
                    if isinstance(block, dict):
                        text = block.get("text", "")
                        if isinstance(text, str):
                            parts.append(text)
        return "\n".join(parts)

    @staticmethod
    def _estimate_output_tokens(level: str, input_chars: int, code_hits: int) -> int:
        """Estimate expected output tokens based on complexity signals.

        Simple tasks produce short outputs; complex tasks may produce much more.
        Code tasks tend to produce longer outputs.
        """
        base: dict[str, int] = {
            "simple": 128,
            "medium": 512,
            "complex": 1024,
        }
        tokens = base.get(level, 256)

        # Code tasks tend to have longer outputs
        if code_hits >= 3:
            tokens = int(tokens * 1.5)

        # If the input is very long, output tends to be proportional
        input_tokens_est = max(1, input_chars // 4)
        if input_tokens_est > 2000:
            tokens = max(tokens, int(input_tokens_est * 0.3))

        # Cap at a reasonable maximum
        return min(tokens, 4096)
