"""
Kalibr Router - Intelligent model routing with outcome learning.
"""

import os
import logging
import uuid
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Union

from kalibr.provision import resolve_credentials
from opentelemetry import trace as otel_trace
from opentelemetry.trace import SpanContext, TraceFlags, NonRecordingSpan, set_span_in_context
from opentelemetry.context import Context

logger = logging.getLogger(__name__)

# Type for paths - either string or dict
PathSpec = Union[str, Dict[str, Any]]


def _create_context_with_trace_id(trace_id_hex: str) -> Optional[Context]:
    """Create an OTel context with a specific trace_id.

    This allows child spans to inherit the intelligence service's trace_id,
    enabling JOINs between outcomes and traces tables.
    """
    try:
        # Convert 32-char hex string to 128-bit int
        trace_id_int = int(trace_id_hex, 16)
        if trace_id_int == 0:
            return None

        # Create span context with our trace_id
        span_context = SpanContext(
            trace_id=trace_id_int,
            span_id=0xDEADBEEF,  # Placeholder, real span will have its own
            is_remote=True,  # Treat as remote parent so new span_id is generated
            trace_flags=TraceFlags(TraceFlags.SAMPLED),
        )

        # Create a non-recording parent span and set in context
        parent_span = NonRecordingSpan(span_context)
        return set_span_in_context(parent_span)
    except (ValueError, TypeError) as e:
        logger.warning(f"Could not create OTel context with trace_id: {e}")
        return None


@dataclass
class TraceHandle:
    """Handle for a manually-started trace. Pass trace.id to router.report()."""
    id: str
    model_id: str
    path: Optional[str]


class Router:
    """
    Routes LLM requests to the best model based on learned outcomes.

    Three scoring modes (in priority order):
    1. score_when: Continuous scoring (0.0-1.0). Best for quality optimization.
       Example: score_when=lambda out: min(1.0, len(out) / 500)
    2. success_when: Binary scoring (True/False). Good for pass/fail checks.
       Example: success_when=lambda out: "@" in out
    3. Default: When neither is provided, Kalibr auto-scores using heuristics
       (response length, structure, finish reason). Gives day-one metrics
       without any evaluation code.

    Examples:
        # Continuous scoring (best quality signal)
        router = Router(
            goal="summarize",
            paths=["gpt-4o", "claude-sonnet-4-20250514"],
            score_when=lambda out: min(1.0, len(out) / 500)
        )
        response = router.completion(messages=[...])

        # Binary auto-reporting
        router = Router(
            goal="extract_email",
            paths=["gpt-4o", "claude-sonnet-4-20250514"],
            success_when=lambda out: "@" in out
        )
        response = router.completion(messages=[...])
        # report() called automatically

        # Zero-config (default heuristic scoring)
        router = Router(
            goal="chat",
            paths=["gpt-4o", "claude-sonnet-4-20250514"]
        )
        response = router.completion(messages=[...])
        # Auto-scored using heuristics - no evaluation code needed

        # Manual reporting for complex validation
        router = Router(
            goal="book_meeting",
            paths=["gpt-4o", "claude-sonnet-4-20250514"]
        )
        response = router.completion(messages=[...])
        # ... complex validation logic ...
        router.report(success=meeting_booked)

    Warning:
        Router is not thread-safe. For concurrent requests, create separate
        Router instances per thread/task. For sequential requests in a single
        thread, Router can be reused across multiple completion() calls.
    """

    def __init__(
        self,
        goal: str,
        paths: Optional[List[PathSpec]] = None,
        success_when: Optional[Callable[[str], bool]] = None,
        score_when: Optional[Callable[[str], float]] = None,
        exploration_rate: Optional[float] = None,
        auto_register: bool = True,
    ):
        """
        Initialize router.

        Three scoring modes (in priority order):
        1. score_when: Continuous scoring (0.0-1.0). Best for quality optimization.
           Example: score_when=lambda out: min(1.0, len(out) / 500)
        2. success_when: Binary scoring (True/False). Good for pass/fail checks.
           Example: success_when=lambda out: "@" in out
        3. Default: When neither is provided, Kalibr auto-scores using heuristics
           (response length, structure, finish reason). Gives day-one metrics
           without any evaluation code.

        Args:
            goal: Name of the goal (e.g., "book_meeting", "summarize")
            paths: List of models or path configs. Examples:
                   ["gpt-4o", "claude-sonnet-4-20250514"]
                   [{"model": "gpt-4o", "tools": ["search"]}]
                   [{"model": "gpt-4o", "params": {"temperature": 0.7}}]
            success_when: Optional function to auto-evaluate success from LLM output.
                         Takes the output string and returns True/False.
                         When provided, report() is called automatically after completion().
                         Use for simple validations (output length, contains key string).
                         For complex validation (API calls, multi-step checks), omit this
                         and call report() manually.
                         Examples:
                             success_when=lambda out: len(out) > 0  # Not empty
                             success_when=lambda out: "@" in out     # Contains email
            score_when: Optional function to auto-evaluate quality from LLM output.
                       Takes the output string and returns a float (0.0-1.0).
                       Takes priority over success_when if both are provided.
                       Examples:
                           score_when=lambda out: min(1.0, len(out) / 500)
            exploration_rate: Override exploration rate (0.0-1.0)
            auto_register: If True, register paths on init
        """
        self.goal = goal

        # Validate required credentials
        api_key, tenant_id = resolve_credentials()

        if not api_key:
            raise ValueError(
                "No API key found. Set KALIBR_API_KEY or KALIBR_PROVISIONING_TOKEN.\n"
                "Get your API key from: https://dashboard.kalibr.systems/settings"
            )

        if not tenant_id:
            raise ValueError(
                "KALIBR_TENANT_ID environment variable not set.\n"
                "Find your Tenant ID at: https://dashboard.kalibr.systems/settings"
            )

        self.success_when = success_when
        self.score_when = score_when
        self.exploration_rate = exploration_rate
        self._last_trace_id: Optional[str] = None
        self._last_model_id: Optional[str] = None
        self._last_decision: Optional[dict] = None
        self._outcome_reported = False

        # Normalize paths to list of dicts
        self._paths = self._normalize_paths(paths or ["gpt-4o"])

        # Register paths if requested
        if auto_register:
            self._register_paths()

    def _normalize_paths(self, paths: List[PathSpec]) -> List[Dict[str, Any]]:
        """Convert paths to consistent format."""
        normalized = []
        for p in paths:
            if isinstance(p, str):
                normalized.append({"model": p, "tools": None, "params": None})
            elif isinstance(p, dict):
                normalized.append({
                    "model": p.get("model") or p.get("model_id"),
                    "tools": p.get("tools") or p.get("tool_id"),
                    "params": p.get("params"),
                })
            else:
                raise ValueError(f"Invalid path spec: {p}")
        return normalized

    def _register_paths(self):
        """Register paths with intelligence service."""
        from kalibr.intelligence import register_path

        for path in self._paths:
            try:
                register_path(
                    goal=self.goal,
                    model_id=path["model"],
                    tool_id=path["tools"][0] if isinstance(path["tools"], list) and path["tools"] else path["tools"],
                    params=path["params"],
                )
            except Exception as e:
                # Log but don't fail - path might already exist
                logger.debug(f"Path registration note: {e}")

    def completion(
        self,
        messages: List[Dict[str, str]],
        force_model: Optional[str] = None,
        **kwargs
    ) -> Any:
        """
        Make a completion request with intelligent routing.

        Args:
            messages: OpenAI-format messages
            force_model: Override routing and use this model
            **kwargs: Additional args passed to provider

        Returns:
            OpenAI-compatible ChatCompletion response with added attribute:
                - kalibr_trace_id: Trace ID for explicit outcome reporting
        """
        from kalibr.intelligence import decide

        # Reset state for new request
        self._outcome_reported = False

        # Step 1: Get routing decision FIRST (before creating span)
        decision = None
        model_id = None
        tool_id = None
        params = {}

        if force_model:
            model_id = force_model
            self._last_decision = {"model_id": model_id, "forced": True}
        else:
            try:
                decision = decide(goal=self.goal)
                model_id = decision.get("model_id") or self._paths[0]["model"]
                tool_id = decision.get("tool_id")
                params = decision.get("params") or {}
                self._last_decision = decision
            except Exception as e:
                logger.warning(f"Routing failed, using fallback: {e}")
                model_id = self._paths[0]["model"]
                tool_id = self._paths[0].get("tools")
                params = self._paths[0].get("params") or {}
                self._last_decision = {"model_id": model_id, "fallback": True, "error": str(e)}

        # Step 2: Determine trace_id
        decision_trace_id = self._last_decision.get("trace_id") if self._last_decision else None

        if decision_trace_id:
            trace_id = decision_trace_id
        else:
            trace_id = uuid.uuid4().hex  # Fallback: generate OTel-compatible format

        self._last_trace_id = trace_id
        self._last_model_id = model_id

        # Step 3: Create OTel context with intelligence trace_id
        otel_context = _create_context_with_trace_id(trace_id) if trace_id else None

        # Step 4: Create span with custom context (child spans inherit trace_id)
        tracer = otel_trace.get_tracer("kalibr.router")

        with tracer.start_as_current_span(
            "kalibr.router.completion",
            context=otel_context,
            attributes={
                "kalibr.goal": self.goal,
                "kalibr.trace_id": trace_id,
                "kalibr.model_id": model_id,
            }
        ) as router_span:
            # Add decision attributes
            if force_model:
                router_span.set_attribute("kalibr.forced", True)
            elif decision:
                router_span.set_attribute("kalibr.path_id", decision.get("path_id", ""))
                router_span.set_attribute("kalibr.reason", decision.get("reason", ""))
                router_span.set_attribute("kalibr.exploration", decision.get("exploration", False))
                router_span.set_attribute("kalibr.confidence", decision.get("confidence", 0.0))
            else:
                router_span.set_attribute("kalibr.fallback", True)

            # Step 5: Build ordered candidate paths for fallback
            # First: intelligence-selected path, then remaining registered paths
            candidate_paths = []
            selected_path = {"model": model_id, "tools": tool_id, "params": params}
            candidate_paths.append(selected_path)

            # Add remaining paths, skipping duplicates of the selected model
            for path in self._paths:
                if path["model"] != model_id:
                    candidate_paths.append(path)

            # Step 6: Try each candidate path with fallback
            from kalibr.intelligence import report_outcome

            last_exception = None
            for i, candidate in enumerate(candidate_paths):
                candidate_model = candidate["model"]
                candidate_tools = candidate.get("tools")
                candidate_params = candidate.get("params") or {}

                is_fallback = (i > 0)
                if is_fallback:
                    logger.warning(f"Primary path failed, trying fallback: {candidate_model}")

                try:
                    response = self._dispatch(
                        candidate_model,
                        messages,
                        candidate_tools,
                        **{**candidate_params, **kwargs}
                    )

                    # Success! Update state to reflect which model succeeded
                    self._last_model_id = candidate_model

                    # Auto-report if any scoring mechanism is provided (or use defaults)
                    if not self._outcome_reported:
                        try:
                            output = response.choices[0].message.content or ""

                            if self.score_when:
                                # Priority 1: User-provided continuous scorer
                                score = self.score_when(output)
                                score = min(1.0, max(0.0, float(score)))
                                self.report(success=score >= 0.5, score=score)
                            elif self.success_when:
                                # Priority 2: User-provided binary scorer
                                success = self.success_when(output)
                                self.report(success=success)
                            else:
                                # Priority 3: Default heuristic scoring (zero-config)
                                score = self._default_score(response)
                                self.report(success=score >= 0.5, score=score)
                        except Exception as e:
                            logger.warning(f"Auto-outcome evaluation failed: {e}")

                    # Add trace_id to response for explicit linkage
                    response.kalibr_trace_id = trace_id
                    return response

                except Exception as e:
                    last_exception = e

                    # Log the failure with model name and error
                    logger.warning(f"Model {candidate_model} failed: {type(e).__name__}: {e}")

                    # Report failure for this path to enable Thompson Sampling learning
                    try:
                        report_outcome(
                            trace_id=trace_id,
                            goal=self.goal,
                            success=False,
                            failure_reason=f"provider_error: {type(e).__name__}",
                            model_id=candidate_model,
                        )
                    except Exception:
                        pass

                    # Continue to next candidate
                    continue

            # All paths failed - set error attributes and raise
            router_span.set_attribute("error", True)
            router_span.set_attribute("error.type", type(last_exception).__name__)
            self._outcome_reported = True  # Prevent double-reporting on raise
            raise last_exception

    # Alias for common naming confusion
    complete = completion

    def execute(self, task: str, input_data: Any, **kwargs) -> Any:
        """Execute any ML task with intelligent routing.

        Args:
            task: HuggingFace task type ("automatic_speech_recognition", "text_to_image", etc.)
            input_data: Task-appropriate input (audio bytes, text prompt, etc.)
            **kwargs: Additional task-specific parameters

        Returns:
            Task-appropriate response (transcription text, PIL image, etc.)
        """
        from kalibr.intelligence import decide, report_outcome

        # Reset state for new request
        self._outcome_reported = False

        # Step 1: Get routing decision
        try:
            decision = decide(goal=self.goal)
            model_id = decision.get("model_id") or self._paths[0]["model"]
            self._last_decision = decision
        except Exception as e:
            logger.warning(f"Routing failed, using fallback: {e}")
            model_id = self._paths[0]["model"]
            self._last_decision = {"model_id": model_id, "fallback": True, "error": str(e)}

        # Step 2: Determine trace_id
        decision_trace_id = self._last_decision.get("trace_id") if self._last_decision else None
        trace_id = decision_trace_id or uuid.uuid4().hex
        self._last_trace_id = trace_id
        self._last_model_id = model_id

        # Step 3: Execute the task via HuggingFace Inference API
        try:
            from huggingface_hub import InferenceClient
        except ImportError:
            raise ImportError("Install 'huggingface_hub' package: pip install huggingface_hub")

        token = os.environ.get("HF_API_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
        client = InferenceClient(
            token=token,
            base_url="https://router.huggingface.co",
        )

        # Map task names to InferenceClient methods.
        # Must stay in sync with PATCHED_METHODS in huggingface_instr.py (all 17).
        task_method_map = {
            # Text
            "chat_completion": client.chat_completion,
            "text_generation": client.text_generation,
            "translation": client.translation,
            "summarization": client.summarization,
            "fill_mask": client.fill_mask,
            "table_question_answering": client.table_question_answering,
            # Audio
            "automatic_speech_recognition": client.automatic_speech_recognition,
            "text_to_speech": client.text_to_speech,
            "audio_classification": client.audio_classification,
            # Image
            "text_to_image": client.text_to_image,
            "image_to_text": client.image_to_text,
            "image_classification": client.image_classification,
            "image_segmentation": client.image_segmentation,
            "object_detection": client.object_detection,
            # Embedding
            "feature_extraction": client.feature_extraction,
            # Classification
            "text_classification": client.text_classification,
            "token_classification": client.token_classification,
        }

        method = task_method_map.get(task)
        if method is None:
            raise ValueError(
                f"Unsupported task '{task}'. Supported tasks: {', '.join(sorted(task_method_map.keys()))}"
            )

        try:
            response = method(input_data, model=model_id, **kwargs)
        except Exception as e:
            # Report failure
            try:
                report_outcome(
                    trace_id=trace_id,
                    goal=self.goal,
                    success=False,
                    failure_reason=f"provider_error: {type(e).__name__}",
                    model_id=model_id,
                )
            except Exception:
                pass
            self._outcome_reported = True
            raise

        # Step 4: Score and report
        if not self._outcome_reported:
            try:
                if self.score_when:
                    score = self.score_when(response)
                    score = min(1.0, max(0.0, float(score)))
                    self.report(success=score >= 0.5, score=score)
                elif self.success_when:
                    success = self.success_when(response)
                    self.report(success=success)
                else:
                    score = self._default_score(response)
                    self.report(success=score >= 0.5, score=score)
            except Exception as e:
                logger.warning(f"Auto-outcome evaluation failed: {e}")

        return response

    def _default_score(self, response) -> float:
        """
        Compute a heuristic quality score from an LLM response.
        Used when no success_when or score_when is provided.
        Gives users day-one quality metrics without writing evaluation code.

        Handles multiple response types:
        - Chat completion (has .choices[0].message.content) -> text heuristics
        - bytes (audio) -> 1.0 if non-empty
        - Transcription (has .text attribute) -> text heuristics on .text
        - PIL.Image -> 1.0 if not None
        - Other -> 0.5 (neutral, let user-provided scorer handle it)

        Signals for text scoring (all normalized to 0-1, then weighted average):
        - non_empty: 1.0 if response has content, 0.0 if empty
        - length_score: normalized response length (sigmoid around 200 chars)
        - structure_score: bonus for JSON validity, markdown headers, bullet points
        - finish_reason_score: 1.0 for "stop", 0.5 for "length" (truncated), 0.0 for error
        """
        # Handle bytes (e.g., audio output)
        if isinstance(response, bytes):
            return 1.0 if len(response) > 0 else 0.0

        # Handle PIL.Image
        try:
            from PIL import Image
            if isinstance(response, Image.Image):
                return 1.0
        except ImportError:
            pass

        # Handle transcription-style responses (has .text but no .choices)
        if hasattr(response, "text") and not hasattr(response, "choices"):
            content = response.text or ""
            return self._score_text_content(content)

        # Handle chat completion responses
        try:
            content = response.choices[0].message.content or ""
        except (AttributeError, IndexError):
            # Unknown response type - return neutral score
            if response is None:
                return 0.0
            return 0.5

        return self._score_text_content(content, response)

    def _score_text_content(self, content: str, response: Any = None) -> float:
        """Score text content using heuristics. Used by _default_score for text-based responses."""
        # Signal 1: Non-empty (binary)
        non_empty = 1.0 if len(content.strip()) > 0 else 0.0
        if non_empty == 0.0:
            return 0.0  # Empty response is always 0

        # Signal 2: Response length (sigmoid - most responses 50-2000 chars)
        import math
        char_count = len(content)
        # Sigmoid centered at 200 chars, gives ~0.5 at 200, ~0.95 at 1000
        length_score = 1.0 / (1.0 + math.exp(-0.005 * (char_count - 200)))

        # Signal 3: Structure (JSON, markdown, lists indicate structured output)
        structure_score = 0.5  # baseline
        content_stripped = content.strip()
        # JSON detection
        if (content_stripped.startswith('{') and content_stripped.endswith('}')) or \
           (content_stripped.startswith('[') and content_stripped.endswith(']')):
            try:
                import json
                json.loads(content_stripped)
                structure_score = 1.0  # Valid JSON
            except (json.JSONDecodeError, ValueError):
                structure_score = 0.3  # Looks like JSON but invalid
        # Markdown/list detection
        elif any(marker in content for marker in ['## ', '- ', '* ', '1. ', '```']):
            structure_score = 0.8

        # Signal 4: Finish reason
        finish_score = 0.5  # default when no response object
        if response is not None:
            try:
                finish_reason = response.choices[0].finish_reason
                if finish_reason == "stop":
                    finish_score = 1.0
                elif finish_reason == "length":
                    finish_score = 0.5  # Truncated
                else:
                    finish_score = 0.3  # Unknown/error
            except (AttributeError, IndexError):
                finish_score = 0.5

        # Weighted average
        score = (
            non_empty * 0.1 +
            length_score * 0.3 +
            structure_score * 0.3 +
            finish_score * 0.3
        )

        return round(min(1.0, max(0.0, score)), 3)

    def start_trace(
        self,
        model_id: str,
        path: Optional[str] = None,
    ) -> "TraceHandle":
        """
        Manually start a trace for an external model call not natively supported by Kalibr.

        Use this when calling model providers directly (HuggingFace InferenceClient,
        Together AI, Ollama, etc.) that are not routed through completion() or execute().

        Args:
            model_id: The model being called (e.g., "deepseek-ai/DeepSeek-R1")
            path: Optional path identifier for this execution path

        Returns:
            TraceHandle with .id — pass this to router.report(trace_id=trace.id)

        Example::

            trace = router.start_trace(model_id="deepseek-ai/DeepSeek-R1", path="hf")
            output = my_hf_client.chat.completions.create(model="deepseek-ai/DeepSeek-R1", messages=messages)
            router.report(success=True, trace_id=trace.id)
        """
        trace_id = uuid.uuid4().hex
        self._last_trace_id = trace_id
        self._last_model_id = model_id
        self._outcome_reported = False
        return TraceHandle(id=trace_id, model_id=model_id, path=path)

    def report(
        self,
        success: bool,
        reason: Optional[str] = None,
        score: Optional[float] = None,
        trace_id: Optional[str] = None,
        failure_category: Optional[str] = None,
    ):
        """
        Report outcome for the last completion.

        Args:
            success: Whether the task succeeded
            reason: Optional failure reason
            score: Optional quality score (0.0-1.0)
            trace_id: Optional explicit trace ID (uses last completion's trace_id if not provided)
            failure_category: Optional structured failure category for clustering
        """
        if self._outcome_reported:
            logger.warning("Outcome already reported for this completion. Each completion() requires a separate report() call.")
            return

        if failure_category is not None:
            from kalibr.intelligence import FAILURE_CATEGORIES
            if failure_category not in FAILURE_CATEGORIES:
                raise ValueError(
                    f"Invalid failure_category '{failure_category}'. "
                    f"Must be one of: {', '.join(FAILURE_CATEGORIES)}"
                )

        from kalibr.intelligence import report_outcome

        trace_id = trace_id or self._last_trace_id
        if not trace_id:
            raise ValueError("Must call completion() before report(). No trace_id available.")

        try:
            report_outcome(
                trace_id=trace_id,
                goal=self.goal,
                success=success,
                score=score,
                failure_reason=reason,
                failure_category=failure_category,
                model_id=self._last_model_id,
            )
            self._outcome_reported = True
        except Exception as e:
            logger.warning(f"Failed to report outcome: {e}")

    def add_path(
        self,
        model: str,
        tools: Optional[List[str]] = None,
        params: Optional[Dict] = None,
    ):
        """Add a new path dynamically."""
        from kalibr.intelligence import register_path

        path = {"model": model, "tools": tools, "params": params}
        self._paths.append(path)

        register_path(
            goal=self.goal,
            model_id=model,
            tool_id=tools[0] if tools else None,
            params=params,
        )

    def synthesize(self, text: str, voice: Optional[str] = None, **kwargs) -> Any:
        """Route a text-to-speech request to the best voice model.

        Args:
            text: Text to synthesize
            voice: Optional voice ID
            **kwargs: Additional args passed to provider

        Returns:
            SimpleNamespace with: audio, kalibr_trace_id, model, cost_usd
        """
        from types import SimpleNamespace
        from kalibr.pricing import compute_cost_flexible

        model_id = kwargs.pop("model", None) or (
            self._paths[0]["model"] if self._paths else "tts-1"
        )
        trace_id = uuid.uuid4().hex
        self._last_trace_id = trace_id
        self._last_model_id = model_id

        tracer = otel_trace.get_tracer("kalibr.router")
        otel_context = _create_context_with_trace_id(trace_id)

        with tracer.start_as_current_span(
            "kalibr.router.synthesize",
            context=otel_context,
            attributes={
                "kalibr.goal": self.goal,
                "kalibr.trace_id": trace_id,
                "kalibr.model_id": model_id,
                "voice.operation": "tts",
            },
        ):
            result = self._dispatch_voice_tts(model_id, text, voice, **kwargs)
            character_count = len(text) if isinstance(text, str) else 0
            vendor = self._detect_voice_vendor(model_id)
            cost = compute_cost_flexible(vendor, model_id, {"characters": character_count})

            return SimpleNamespace(
                audio=result,
                kalibr_trace_id=trace_id,
                model=model_id,
                cost_usd=cost,
            )

    def transcribe(self, audio: Any, language: Optional[str] = None, **kwargs) -> Any:
        """Route a speech-to-text request to the best voice model.

        Args:
            audio: Audio data (file path, bytes, or file-like object)
            language: Optional language hint
            **kwargs: Additional args passed to provider

        Returns:
            SimpleNamespace with: text, kalibr_trace_id, model, cost_usd
        """
        from types import SimpleNamespace
        from kalibr.pricing import compute_cost_flexible

        model_id = kwargs.pop("model", None) or (
            self._paths[0]["model"] if self._paths else "whisper-1"
        )
        trace_id = uuid.uuid4().hex
        self._last_trace_id = trace_id
        self._last_model_id = model_id

        tracer = otel_trace.get_tracer("kalibr.router")
        otel_context = _create_context_with_trace_id(trace_id)

        with tracer.start_as_current_span(
            "kalibr.router.transcribe",
            context=otel_context,
            attributes={
                "kalibr.goal": self.goal,
                "kalibr.trace_id": trace_id,
                "kalibr.model_id": model_id,
                "voice.operation": "stt",
            },
        ):
            result = self._dispatch_voice_stt(model_id, audio, language, **kwargs)
            text = result if isinstance(result, str) else getattr(result, "text", str(result))
            audio_seconds = kwargs.get("audio_duration_seconds", 0.0)
            vendor = self._detect_voice_vendor(model_id)
            cost = compute_cost_flexible(vendor, model_id, {"audio_seconds": audio_seconds})

            return SimpleNamespace(
                text=text,
                kalibr_trace_id=trace_id,
                model=model_id,
                cost_usd=cost,
            )

    def _detect_voice_vendor(self, model_id: str) -> str:
        """Detect voice vendor from model prefix."""
        model_lower = model_id.lower()
        if model_lower.startswith(("tts-", "whisper")):
            return "openai"
        if model_lower.startswith("eleven_") or "eleven" in model_lower:
            return "elevenlabs"
        if model_lower.startswith(("nova", "aura", "enhanced", "base")):
            return "deepgram"
        return "openai"

    def _dispatch_voice_tts(
        self, model_id: str, text: str, voice: Optional[str] = None, **kwargs
    ) -> Any:
        """Dispatch TTS to the appropriate provider."""
        vendor = self._detect_voice_vendor(model_id)
        if vendor == "openai":
            return self._call_openai_audio_tts(model_id, text, voice, **kwargs)
        elif vendor == "elevenlabs":
            return self._call_elevenlabs(model_id, text, voice, **kwargs)
        elif vendor == "deepgram":
            return self._call_deepgram_tts(model_id, text, **kwargs)
        else:
            return self._call_openai_audio_tts(model_id, text, voice, **kwargs)

    def _dispatch_voice_stt(
        self, model_id: str, audio: Any, language: Optional[str] = None, **kwargs
    ) -> Any:
        """Dispatch STT to the appropriate provider."""
        vendor = self._detect_voice_vendor(model_id)
        if vendor == "openai":
            return self._call_openai_audio_stt(model_id, audio, language, **kwargs)
        elif vendor == "deepgram":
            return self._call_deepgram_stt(model_id, audio, language, **kwargs)
        else:
            return self._call_openai_audio_stt(model_id, audio, language, **kwargs)

    def _call_openai_audio_tts(
        self, model: str, text: str, voice: Optional[str] = None, **kwargs
    ) -> Any:
        """Call OpenAI TTS API."""
        try:
            from openai import OpenAI
        except ImportError:
            raise ImportError("Install 'openai' package: pip install openai")

        client = OpenAI()
        return client.audio.speech.create(
            model=model, input=text, voice=voice or "alloy", **kwargs
        )

    def _call_openai_audio_stt(
        self, model: str, audio: Any, language: Optional[str] = None, **kwargs
    ) -> Any:
        """Call OpenAI Whisper STT API."""
        try:
            from openai import OpenAI
        except ImportError:
            raise ImportError("Install 'openai' package: pip install openai")

        client = OpenAI()
        call_kwargs = {"model": model, "file": audio, **kwargs}
        if language:
            call_kwargs["language"] = language
        return client.audio.transcriptions.create(**call_kwargs)

    def _call_elevenlabs(
        self, model: str, text: str, voice: Optional[str] = None, **kwargs
    ) -> Any:
        """Call ElevenLabs TTS API."""
        try:
            from elevenlabs.client import ElevenLabs as EL
        except ImportError:
            raise ImportError("Install 'elevenlabs' package: pip install elevenlabs")

        client = EL()
        return client.generate(text=text, voice=voice or "Rachel", model=model, **kwargs)

    def _call_deepgram_tts(self, model: str, text: str, **kwargs) -> Any:
        """Call Deepgram TTS API."""
        try:
            from deepgram import DeepgramClient
        except ImportError:
            raise ImportError("Install 'deepgram-sdk' package: pip install deepgram-sdk")

        client = DeepgramClient()
        return client.speak.v("1").save(text, model=model, **kwargs)

    def _call_deepgram_stt(
        self, model: str, audio: Any, language: Optional[str] = None, **kwargs
    ) -> Any:
        """Call Deepgram STT API."""
        try:
            from deepgram import DeepgramClient, PrerecordedOptions
        except ImportError:
            raise ImportError("Install 'deepgram-sdk' package: pip install deepgram-sdk")

        client = DeepgramClient()
        options = PrerecordedOptions(model=model)
        if language:
            options.language = language
        return client.listen.rest.v("1").transcribe_file({"buffer": audio}, options)

    def _dispatch(
        self,
        model_id: str,
        messages: List[Dict],
        tools: Optional[Any] = None,
        **kwargs
    ) -> Any:
        """Dispatch to the appropriate provider."""
        if model_id.startswith(("gpt-", "o1-", "o3-")):
            return self._call_openai(model_id, messages, tools, **kwargs)
        elif model_id.startswith("claude-"):
            return self._call_anthropic(model_id, messages, tools, **kwargs)
        elif model_id.startswith(("gemini-", "models/gemini")):
            return self._call_google(model_id, messages, tools, **kwargs)
        elif model_id.startswith("deepseek-"):
            return self._call_deepseek(model_id, messages, tools, **kwargs)
        elif "/" in model_id and not model_id.startswith(("models/", "ft:")):
            # org/model format = HuggingFace
            return self._call_huggingface(model_id, messages, tools, **kwargs)
        else:
            # Default to OpenAI-compatible
            logger.info(f"Unknown model prefix '{model_id}', trying OpenAI")
            return self._call_openai(model_id, messages, tools, **kwargs)

    def _call_openai(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
        """Call OpenAI API."""
        try:
            from openai import OpenAI
        except ImportError:
            raise ImportError("Install 'openai' package: pip install openai")

        client = OpenAI()

        call_kwargs = {"model": model, "messages": messages, **kwargs}
        # Note: tools parameter from path config is for Kalibr routing only.
        # Users can pass actual tool definitions via **kwargs if needed.

        return client.chat.completions.create(**call_kwargs)

    def _call_deepseek(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
        """Call DeepSeek API using OpenAI-compatible client with DeepSeek base URL."""
        try:
            from openai import OpenAI
        except ImportError:
            raise ImportError("Install 'openai' package: pip install openai")

        api_key = os.environ.get("DEEPSEEK_API_KEY")
        if not api_key:
            raise ValueError(
                "DEEPSEEK_API_KEY environment variable not set.\n"
                "Get your API key from: https://platform.deepseek.com"
            )

        client = OpenAI(
            api_key=api_key,
            base_url="https://api.deepseek.com",
        )

        call_kwargs = {"model": model, "messages": messages, **kwargs}
        return client.chat.completions.create(**call_kwargs)

    def _call_anthropic(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
        """Call Anthropic API and convert response to OpenAI format."""
        try:
            from anthropic import Anthropic
        except ImportError:
            raise ImportError("Install 'anthropic' package: pip install anthropic")

        client = Anthropic()

        # Convert messages (handle system message)
        system = None
        anthropic_messages = []
        for m in messages:
            if m["role"] == "system":
                system = m["content"]
            else:
                anthropic_messages.append({"role": m["role"], "content": m["content"]})

        call_kwargs = {"model": model, "messages": anthropic_messages, "max_tokens": kwargs.pop("max_tokens", 4096)}
        if system:
            call_kwargs["system"] = system
        # Note: tools parameter from path config is for Kalibr routing only.
        # Users can pass actual tool definitions via **kwargs if needed.
        call_kwargs.update(kwargs)

        response = client.messages.create(**call_kwargs)

        # Convert to OpenAI format
        return self._anthropic_to_openai_response(response, model)

    def _call_google(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
        """Call Google API and convert response to OpenAI format."""
        try:
            import google.generativeai as genai
        except ImportError:
            raise ImportError("Install 'google-generativeai' package: pip install google-generativeai")

        # Configure if API key available
        api_key = os.environ.get("GOOGLE_API_KEY")
        if api_key:
            genai.configure(api_key=api_key)

        # Convert messages to Google format
        model_name = model.replace("models/", "") if model.startswith("models/") else model
        gmodel = genai.GenerativeModel(model_name)

        # Simple conversion - concatenate messages
        prompt = "\n".join([f"{m['role']}: {m['content']}" for m in messages])

        response = gmodel.generate_content(prompt)

        # Convert to OpenAI format
        return self._google_to_openai_response(response, model)

    def _call_huggingface(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
        """Call HuggingFace Inference API and convert response to OpenAI format.

        HuggingFace migrated from api-inference.huggingface.co (now returns HTTP 410)
        to router.huggingface.co. InferenceClient >= 0.x picks this up automatically
        when no explicit base_url is set, but we explicitly set the router endpoint
        to guarantee correct routing regardless of huggingface_hub version.
        """
        try:
            from huggingface_hub import InferenceClient
        except ImportError:
            raise ImportError("Install 'huggingface_hub' package: pip install huggingface_hub")

        token = os.environ.get("HF_API_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")

        # Use the new router endpoint explicitly.
        # api-inference.huggingface.co returns HTTP 410 as of early 2026.
        client = InferenceClient(
            token=token,
            base_url="https://router.huggingface.co",
        )

        call_kwargs = {"model": model, "messages": messages, **kwargs}

        response = client.chat.completions.create(**call_kwargs)

        return self._huggingface_to_openai_response(response, model)

    def _huggingface_to_openai_response(self, response: Any, model: str) -> Any:
        """Convert HuggingFace chat completion response to OpenAI format."""
        from types import SimpleNamespace

        # HuggingFace chat completions return OpenAI-compatible format,
        # but normalize to SimpleNamespace for consistent attribute access
        choices = []
        for choice in response.choices:
            choices.append(SimpleNamespace(
                index=choice.index,
                message=SimpleNamespace(
                    role=choice.message.role,
                    content=choice.message.content or "",
                ),
                finish_reason=getattr(choice, "finish_reason", "stop"),
            ))

        usage_obj = getattr(response, "usage", None)
        return SimpleNamespace(
            id=getattr(response, "id", f"hf-{model}"),
            model=model,
            choices=choices,
            usage=SimpleNamespace(
                prompt_tokens=getattr(usage_obj, "prompt_tokens", 0),
                completion_tokens=getattr(usage_obj, "completion_tokens", 0),
                total_tokens=getattr(usage_obj, "total_tokens", 0),
            ),
        )

    def _anthropic_to_openai_response(self, response: Any, model: str) -> Any:
        """Convert Anthropic response to OpenAI format."""
        from types import SimpleNamespace

        content = ""
        if response.content:
            content = response.content[0].text if hasattr(response.content[0], "text") else str(response.content[0])

        return SimpleNamespace(
            id=response.id,
            model=model,
            choices=[
                SimpleNamespace(
                    index=0,
                    message=SimpleNamespace(
                        role="assistant",
                        content=content,
                    ),
                    finish_reason=response.stop_reason,
                )
            ],
            usage=SimpleNamespace(
                prompt_tokens=response.usage.input_tokens,
                completion_tokens=response.usage.output_tokens,
                total_tokens=response.usage.input_tokens + response.usage.output_tokens,
            ),
        )

    def _google_to_openai_response(self, response: Any, model: str) -> Any:
        """Convert Google response to OpenAI format."""
        from types import SimpleNamespace
        import uuid

        content = response.text if hasattr(response, "text") else str(response)

        return SimpleNamespace(
            id=f"google-{uuid.uuid4().hex[:8]}",
            model=model,
            choices=[
                SimpleNamespace(
                    index=0,
                    message=SimpleNamespace(
                        role="assistant",
                        content=content,
                    ),
                    finish_reason="stop",
                )
            ],
            usage=SimpleNamespace(
                prompt_tokens=getattr(response, "usage_metadata", {}).get("prompt_token_count", 0),
                completion_tokens=getattr(response, "usage_metadata", {}).get("candidates_token_count", 0),
                total_tokens=getattr(response, "usage_metadata", {}).get("total_token_count", 0),
            ),
        )

    def __getattr__(self, name):
        suggestions = {
            "predict": "completion",
            "generate": "completion",
            "chat": "completion",
            "invoke": "completion",
            "run": "completion",
        }
        if name in suggestions:
            raise AttributeError(
                f"Router has no method '{name}'. Did you mean '{suggestions[name]}()'?"
            )
        raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")

    def as_langchain(self):
        """Return a LangChain-compatible chat model."""
        try:
            from kalibr_langchain.chat_model import KalibrChatModel
            return KalibrChatModel(router=self)
        except ImportError:
            raise ImportError("Install 'kalibr-langchain' package for LangChain integration")
