"""LLM integration module using LiteLLM Router."""

from __future__ import annotations

import asyncio
import base64
import copy
import random
import threading
import time
import warnings
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

# Suppress LiteLLM's async logging warnings (coroutine never awaited)
warnings.filterwarnings(
    "ignore",
    message="coroutine 'Logging.async_success_handler' was never awaited",
    category=RuntimeWarning,
)

import litellm

# Suppress LiteLLM's "Provider List" debug messages for custom providers
litellm.suppress_debug_info = True
from litellm.exceptions import (
    APIConnectionError,
    RateLimitError,
    ServiceUnavailableError,
    Timeout,
)
from litellm.router import Router
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import Choices
from loguru import logger

if TYPE_CHECKING:
    from markitai.config import LLMConfig, PromptsConfig

# Optional: cairosvg for SVG → PNG rasterization (LLM vision)
try:
    import cairosvg
except (ImportError, OSError):
    cairosvg = None  # type: ignore[assignment]

from markitai.constants import (
    DEFAULT_IO_CONCURRENCY,
    DEFAULT_MAX_OUTPUT_TOKENS_HARD_CAP,
    DEFAULT_MAX_RETRIES,
    DEFAULT_RETRY_BASE_DELAY,
    DEFAULT_RETRY_MAX_DELAY,
    DEFAULT_VISION_MAX_DIMENSION,
)
from markitai.llm import content
from markitai.llm.cache import ContentCache, PersistentCache
from markitai.llm.document import DocumentMixin
from markitai.llm.models import (
    MarkitaiLLMLogger,
    get_model_info_cached,
    get_response_cost,
)
from markitai.llm.types import (
    LLMResponse,
    LLMRuntime,
)
from markitai.llm.vision import VisionMixin
from markitai.prompts import PromptManager
from markitai.providers.common import has_images
from markitai.utils.text import format_error_message, preview_items_for_log

# =============================================================================
# Local Provider Support
# =============================================================================
# LiteLLM Router does not support custom providers (claude-agent/, copilot/).
# We need a wrapper class that uses litellm.acompletion() directly for these.


class LocalProviderWrapper:
    """Wrapper for local provider models that mimics Router interface.

    LiteLLM Router's get_llm_provider() does not recognize custom providers,
    so we need to call litellm.acompletion() directly for local provider models.

    Implements simple-shuffle load balancing strategy using weighted random selection.
    For image requests, prioritizes models with confirmed image support.
    """

    # Models with confirmed image/vision support via their provider.
    # Note: Copilot has a ~2000px dimension limit, but CopilotProvider
    # handles resizing automatically via _resize_image_if_needed()
    _IMAGE_CAPABLE_PATTERNS = (
        "claude-agent/",  # All claude-agent models support vision
        "chatgpt/",  # All ChatGPT models support vision (GPT-5.x)
        "gemini-cli/",  # All Gemini CLI models support vision
        "copilot/claude-",  # All Copilot Claude models
        "copilot/gemini-",  # All Copilot Gemini models
        "copilot/gpt-4.1",  # GPT-4.1 series
        "copilot/gpt-4o",  # All GPT-4o variants (including mini)
        "copilot/gpt-5",  # GPT-5 series (gpt-5, gpt-5-mini, gpt-5.1*, gpt-5.4*)
        "copilot/raptor-",  # GitHub's fine-tuned GPT-5 mini (inherits vision)
    )
    # Note: copilot/gpt-3.5*, copilot/gpt-4 (non-4o/4.1), copilot/grok-* do NOT support vision

    def __init__(self, model_list: list[dict[str, Any]]) -> None:
        """Initialize with model list.

        Args:
            model_list: List of model configurations (same format as Router)
        """
        self.model_list = model_list
        # Map model_name to list of (model_id, weight) tuples for load balancing
        self._model_groups: dict[str, list[tuple[str, float]]] = {}
        for model_config in model_list:
            model_name = model_config.get("model_name", "default")
            litellm_params = model_config.get("litellm_params", {})
            model_id = litellm_params.get("model", "")
            weight = litellm_params.get("weight", 1.0)

            if model_name not in self._model_groups:
                self._model_groups[model_name] = []
            self._model_groups[model_name].append((model_id, weight))

        # Per-model cooldown tracking (model_id → monotonic expiry time)
        self._model_cooldowns: dict[str, float] = {}
        self._cooldown_lock = threading.Lock()

        # Log model groups for debugging
        for name, models in self._model_groups.items():
            if len(models) > 1:
                model_strs = [f"{m}(w={w})" for m, w in models]
                logger.debug(
                    f"[LocalProviderWrapper] Model group '{name}': {', '.join(model_strs)}"
                )

    def _has_images(self, messages: list[Any]) -> bool:
        """Check if messages contain image content."""
        return has_images(messages)

    def _is_image_capable(self, model_id: str) -> bool:
        """Check if a model supports image/vision requests.

        Args:
            model_id: Full model identifier (e.g., "claude-agent/haiku", "copilot/gemini-3-pro")

        Returns:
            True if model has confirmed image support
        """
        return any(model_id.startswith(p) for p in self._IMAGE_CAPABLE_PATTERNS)

    def record_cooldown(self, model_id: str, seconds: float) -> None:
        """Record that a model should be avoided for the given duration.

        Args:
            model_id: The model identifier to put in cooldown
            seconds: Duration in seconds to avoid routing to this model
        """
        with self._cooldown_lock:
            self._model_cooldowns[model_id] = time.monotonic() + seconds
        logger.info(
            f"[LocalProviderWrapper] Model {model_id} in cooldown for {seconds:.0f}s"
        )

    def _select_model(self, model_name: str, has_images: bool = False) -> str:
        """Select a model using weighted random selection (simple-shuffle).

        For image requests, prioritizes models with confirmed image support.

        Args:
            model_name: Logical model name (e.g., "default")
            has_images: Whether the request contains images

        Returns:
            Selected model ID
        """
        models = self._model_groups.get(model_name)
        if not models:
            # If model_name not in groups, assume it's already the model ID
            return model_name

        # For image requests, prefer image-capable models
        if has_images and len(models) > 1:
            image_capable = [(m, w) for m, w in models if self._is_image_capable(m)]
            if image_capable:
                # Use only image-capable models
                if len(image_capable) < len(models):
                    excluded = [m for m, _ in models if not self._is_image_capable(m)]
                    logger.debug(
                        f"[LocalProviderWrapper] Image request: preferring image-capable "
                        f"models, excluding {excluded}"
                    )
                models = image_capable
            # Note: If no image-capable models found, we proceed with available models
            # and let the underlying provider handle any limitations

        if len(models) == 1:
            return models[0][0]

        # Filter out weight<=0 models (disabled by user)
        active = [(m, w) for m, w in models if w > 0]
        if not active:
            # All weights are 0 — uniform random selection
            return random.choice(models)[0]

        # Filter out models in cooldown (snapshot for thread safety)
        with self._cooldown_lock:
            cooldowns = dict(self._model_cooldowns)
        if cooldowns:
            now = time.monotonic()
            available = [(m, w) for m, w in active if cooldowns.get(m, 0) <= now]
            if not available:
                # All active models in cooldown — pick soonest to expire
                soonest = min(active, key=lambda x: cooldowns.get(x[0], 0))
                logger.debug(
                    f"[LocalProviderWrapper] All models in cooldown, "
                    f"using soonest-expiring: {soonest[0]}"
                )
                return soonest[0]
            active = available

        if len(active) == 1:
            return active[0][0]

        # Weighted random selection
        total_weight = sum(w for _, w in active)
        r = random.uniform(0, total_weight)
        cumulative = 0.0
        for model_id, weight in active:
            cumulative += weight
            if r <= cumulative:
                return model_id

        # Fallback to last model (shouldn't happen)
        return active[-1][0]

    async def acompletion(
        self,
        model: str,
        messages: list[Any],
        **kwargs: Any,
    ) -> Any:
        """Make async completion call using litellm directly.

        Args:
            model: Logical model name (e.g., "default")
            messages: Chat messages
            **kwargs: Additional parameters (max_tokens, metadata, etc.)

        Returns:
            LiteLLM ModelResponse
        """
        # Check if request contains images
        has_images = self._has_images(messages)

        # Select model (with image-awareness)
        model_id = self._select_model(model, has_images=has_images)

        # Remove metadata since litellm.acompletion doesn't support it
        kwargs.pop("metadata", None)

        try:
            # Check if this model's provider has a directly registered handler.
            # This is needed for providers like chatgpt/ where LiteLLM has a
            # native handler that takes priority over custom_provider_map.
            # We call ALL local provider handlers directly for consistency.
            if "/" in model_id:
                from markitai.providers import get_provider

                provider_prefix = model_id.split("/", 1)[0]
                handler = get_provider(provider_prefix)
                if handler is not None:
                    response = await handler.acompletion(
                        model=model_id,
                        messages=messages,
                        **kwargs,
                    )
                    return response

            # Fallback to litellm.acompletion for models without a registered handler
            response = await litellm.acompletion(
                model=model_id,
                messages=messages,
                **kwargs,
            )
            return response
        except Exception as e:
            error_msg = str(e).lower()
            is_rate_limit = any(
                p in error_msg
                for p in ("429", "rate limit", "quota", "too many requests")
            )
            if is_rate_limit:
                cooldown_seconds = 60.0
                import re as _re

                match = _re.search(r"(\d+)\s*s", error_msg)
                if match:
                    cooldown_seconds = float(match.group(1))
                self.record_cooldown(model_id, cooldown_seconds)
            raise


def _is_all_local_providers(model_list: list[dict[str, Any]]) -> bool:
    """Check if all models in list use local providers.

    Args:
        model_list: List of model configurations

    Returns:
        True if ALL models use local providers (claude-agent/, copilot/)
    """
    from markitai.providers import is_local_provider_model

    if not model_list:
        return False

    for model_config in model_list:
        model_id = model_config.get("litellm_params", {}).get("model", "")
        if not is_local_provider_model(model_id):
            return False
    return True


class HybridRouter:
    """Router that combines LiteLLM Router with LocalProviderWrapper.

    Handles mixed configurations where both standard models (gemini/*, deepseek/*)
    and local providers (claude-agent/*, copilot/*) are configured.

    Routes requests to the appropriate backend based on the selected model:
    - Local provider models -> LocalProviderWrapper
    - Standard models -> LiteLLM Router
    """

    # Model-level error patterns that indicate the model itself is unavailable
    # (not a content/request issue). These warrant long cooldown.
    MODEL_LEVEL_ERROR_PATTERNS = (
        "user location is not supported",
        "failed_precondition",
        "model is not available",
        "model not found",
        "model_not_available",
        "region is not supported",
        "not available in your region",
    )

    def __init__(
        self,
        standard_router: Router,
        local_wrapper: LocalProviderWrapper,
    ) -> None:
        """Initialize HybridRouter.

        Args:
            standard_router: LiteLLM Router for standard models
            local_wrapper: LocalProviderWrapper for local provider models
        """
        self.standard_router = standard_router
        self.local_wrapper = local_wrapper

        # Combine model lists for weighted selection
        # Format: (model_id, weight, is_local)
        self._all_models: list[tuple[str, float, bool]] = []

        # Add standard models
        for model_config in standard_router.model_list:
            model_id = model_config.get("litellm_params", {}).get("model", "")
            weight = model_config.get("litellm_params", {}).get("weight", 1.0)
            self._all_models.append((model_id, weight, False))

        # Add local provider models
        for model_config in local_wrapper.model_list:
            model_id = model_config.get("litellm_params", {}).get("model", "")
            weight = model_config.get("litellm_params", {}).get("weight", 1.0)
            self._all_models.append((model_id, weight, True))

        # Build model groups for logical model name resolution
        self._model_groups: dict[str, list[tuple[str, float, bool]]] = {}
        for model_config in standard_router.model_list:
            model_name = model_config.get("model_name", "default")
            model_id = model_config.get("litellm_params", {}).get("model", "")
            weight = model_config.get("litellm_params", {}).get("weight", 1.0)
            if model_name not in self._model_groups:
                self._model_groups[model_name] = []
            self._model_groups[model_name].append((model_id, weight, False))

        for model_config in local_wrapper.model_list:
            model_name = model_config.get("model_name", "default")
            model_id = model_config.get("litellm_params", {}).get("model", "")
            weight = model_config.get("litellm_params", {}).get("weight", 1.0)
            if model_name not in self._model_groups:
                self._model_groups[model_name] = []
            self._model_groups[model_name].append((model_id, weight, True))

        # Pre-compute image-capable models per group to avoid per-call filtering
        self._image_capable_cache: dict[str, list[tuple[str, float, bool]]] = {}
        for group_name, models in self._model_groups.items():
            self._image_capable_cache[group_name] = [
                (m, w, is_local)
                for m, w, is_local in models
                if self._is_image_capable(m)
            ]

        # Per-model cooldown tracking (model_id → monotonic expiry time)
        self._model_cooldowns: dict[str, float] = {}
        self._cooldown_lock = threading.Lock()

        # Log hybrid router configuration
        local_models = [m for m, _, is_local in self._all_models if is_local]
        standard_models = [m for m, _, is_local in self._all_models if not is_local]
        logger.debug(
            f"[HybridRouter] Initialized with {len(standard_models)} standard "
            f"and {len(local_models)} local provider models"
        )

    def _has_images(self, messages: list[Any]) -> bool:
        """Check if messages contain image content."""
        return has_images(messages)

    def _is_local_model(self, model_id: str) -> bool:
        """Check if model_id is a local provider model."""
        from markitai.providers import is_local_provider_model

        return is_local_provider_model(model_id)

    def _is_image_capable(self, model_id: str) -> bool:
        """Check if model supports images."""
        # Local provider models - use LocalProviderWrapper's logic
        if self._is_local_model(model_id):
            return self.local_wrapper._is_image_capable(model_id)

        # Standard models - check litellm model info
        info = get_model_info_cached(model_id)
        return info.get("supports_vision", False)

    def record_cooldown(self, model_id: str, seconds: float) -> None:
        """Record that a model should be avoided for the given duration.

        Args:
            model_id: The model identifier to put in cooldown
            seconds: Duration in seconds to avoid routing to this model
        """
        with self._cooldown_lock:
            self._model_cooldowns[model_id] = time.monotonic() + seconds
        logger.info(f"[HybridRouter] Model {model_id} in cooldown for {seconds:.0f}s")

    def _select_model(self, model_name: str, has_images: bool = False) -> str:
        """Select a model using weighted random selection.

        For image requests, prioritizes image-capable models.

        Args:
            model_name: Logical model name (e.g., "default")
            has_images: Whether the request contains images

        Returns:
            Selected model ID
        """
        models = self._model_groups.get(model_name)
        if not models:
            # If model_name not in groups, assume it's already the model ID
            return model_name

        # For image requests, prefer image-capable models
        if has_images and len(models) > 1:
            image_capable = self._image_capable_cache.get(model_name, [])
            if image_capable:
                if len(image_capable) < len(models):
                    image_capable_ids = {m for m, _, _ in image_capable}
                    excluded = [m for m, _, _ in models if m not in image_capable_ids]
                    logger.debug(
                        f"[HybridRouter] Image request: preferring image-capable "
                        f"models, excluding {excluded}"
                    )
                models = image_capable

        if len(models) == 1:
            return models[0][0]

        # Filter out weight<=0 models (disabled by user)
        active = [(m, w, loc) for m, w, loc in models if w > 0]
        if not active:
            # All weights are 0 — uniform random selection
            return random.choice(models)[0]

        # Filter out models in cooldown (snapshot for thread safety)
        with self._cooldown_lock:
            cooldowns = dict(self._model_cooldowns)
        if cooldowns:
            now = time.monotonic()
            available = [
                (m, w, loc) for m, w, loc in active if cooldowns.get(m, 0) <= now
            ]
            if not available:
                # All active models in cooldown — pick soonest to expire
                soonest = min(active, key=lambda x: cooldowns.get(x[0], 0))
                logger.debug(
                    f"[HybridRouter] All models in cooldown, "
                    f"using soonest-expiring: {soonest[0]}"
                )
                return soonest[0]
            active = available

        if len(active) == 1:
            return active[0][0]

        # Weighted random selection
        total_weight = sum(w for _, w, _ in active)
        r = random.uniform(0, total_weight)
        cumulative = 0.0
        for model_id, weight, _ in active:
            cumulative += weight
            if r <= cumulative:
                return model_id

        return active[-1][0]

    async def acompletion(
        self,
        model: str,
        messages: list[Any],
        **kwargs: Any,
    ) -> Any:
        """Route completion request to appropriate backend.

        Args:
            model: Logical model name (e.g., "default")
            messages: Chat messages
            **kwargs: Additional parameters

        Returns:
            LiteLLM ModelResponse
        """
        has_images = self._has_images(messages)
        selected_model = self._select_model(model, has_images)

        try:
            if self._is_local_model(selected_model):
                logger.debug(
                    f"[HybridRouter] Routing to local provider: {selected_model}"
                )
                return await self.local_wrapper.acompletion(
                    selected_model, messages, **kwargs
                )
            else:
                logger.debug(
                    f"[HybridRouter] Routing to standard router: {selected_model}"
                )
                return await self.standard_router.acompletion(
                    selected_model, messages, **kwargs
                )
        except Exception as e:
            error_msg = str(e).lower()
            is_rate_limit = any(
                p in error_msg
                for p in ("429", "rate limit", "quota", "too many requests")
            )
            if is_rate_limit:
                cooldown_seconds = 60.0
                import re as _re

                match = _re.search(r"(\d+)\s*s", error_msg)
                if match:
                    cooldown_seconds = float(match.group(1))
                self.record_cooldown(selected_model, cooldown_seconds)

            # Model-level errors: region restriction, model not available, etc.
            # Apply long cooldown so retries pick a different model.
            is_model_level = any(
                p in error_msg for p in self.MODEL_LEVEL_ERROR_PATTERNS
            )
            if is_model_level:
                self.record_cooldown(selected_model, 3600.0)
                logger.warning(
                    f"[HybridRouter] Model {selected_model} unavailable "
                    f"(model-level error), cooldown 3600s: "
                    f"{format_error_message(e)}"
                )
            raise

    @property
    def model_list(self) -> list[dict[str, Any]]:
        """Get combined model list from both routers."""
        return self.standard_router.model_list + self.local_wrapper.model_list


# Enable automatic max_tokens adjustment to model limits
# When user-specified max_tokens exceeds model's max_output_tokens,
# LiteLLM will automatically cap it to the model's limit
litellm.modify_params = True

# Retryable exceptions (kept here as they depend on litellm types)
RETRYABLE_ERRORS = (
    RateLimitError,
    APIConnectionError,
    Timeout,
    ServiceUnavailableError,
)


# Global callback instance (uses MarkitaiLLMLogger from models.py)
_markitai_llm_logger = MarkitaiLLMLogger()


class LLMProcessor(VisionMixin, DocumentMixin):
    """LLM processor using LiteLLM Router for load balancing."""

    # Static method proxies to content module
    extract_protected_content = staticmethod(content.extract_protected_content)
    protect_content = staticmethod(content.protect_content)
    unprotect_content = staticmethod(content.unprotect_content)
    fix_malformed_image_refs = staticmethod(content.fix_malformed_image_refs)
    clean_frontmatter = staticmethod(content.clean_frontmatter)
    smart_truncate = staticmethod(content.smart_truncate)
    split_text_by_pages = staticmethod(content.split_text_by_pages)

    def __init__(
        self,
        config: LLMConfig,
        prompts_config: PromptsConfig | None = None,
        runtime: LLMRuntime | None = None,
        no_cache: bool = False,
        no_cache_patterns: list[str] | None = None,
        cache_global_dir: Path | str | None = None,
    ) -> None:
        """
        Initialize LLM processor.

        Args:
            config: LLM configuration
            prompts_config: Optional prompts configuration
            runtime: Optional shared runtime for concurrency control.
                     If provided, uses runtime's semaphore instead of creating one.
            no_cache: If True, skip reading from cache but still write results.
                      Follows Bun's --no-cache semantics (force fresh, update cache).
            no_cache_patterns: List of glob patterns to skip cache for specific files.
                              Patterns are matched against relative paths from input_dir.
                              E.g., ["*.pdf", "reports/**", "file.docx"]
            cache_global_dir: Global cache directory. If provided, overrides the default
                              ~/.markitai directory. Should be passed from config.cache.global_dir.
        """
        self.config = config
        self._runtime = runtime
        self._router: Router | LocalProviderWrapper | HybridRouter | None = None
        self._vision_router: Router | LocalProviderWrapper | HybridRouter | None = None
        self._semaphore: asyncio.Semaphore | None = None
        self._io_semaphore: asyncio.Semaphore | None = None
        self._prompt_manager = PromptManager(prompts_config)

        # Usage tracking (global across all contexts)
        # Use defaultdict to avoid check-then-create race conditions
        def _make_usage_dict() -> dict[str, Any]:
            return {
                "requests": 0,
                "input_tokens": 0,
                "output_tokens": 0,
                "cost_usd": 0.0,
            }

        self._usage: defaultdict[str, dict[str, Any]] = defaultdict(_make_usage_dict)

        # Per-context usage tracking for batch processing
        self._context_usage: defaultdict[str, defaultdict[str, dict[str, Any]]] = (
            defaultdict(lambda: defaultdict(_make_usage_dict))
        )

        # Call counter for each context (file)
        self._call_counter: defaultdict[str, int] = defaultdict(int)

        # Lock for thread-safe access to usage tracking dicts in concurrent contexts
        # Using threading.Lock instead of asyncio.Lock because:
        # 1. Dict operations are CPU-bound and don't need await
        # 2. Works in both sync and async contexts
        # The lock hold time is minimal (only simple dict updates)
        self._usage_lock = threading.Lock()

        # In-memory content cache for session-level deduplication (fast, no I/O)
        self._cache = ContentCache()
        self._cache_hits = 0
        self._cache_misses = 0

        # Persistent cache for cross-session reuse (SQLite-based)
        # no_cache=True: skip reading but still write (Bun semantics)
        # no_cache_patterns: skip reading for specific files matching patterns
        # Resolve cache_global_dir to Path if provided as string
        resolved_cache_dir: Path | None = None
        if cache_global_dir is not None:
            resolved_cache_dir = Path(cache_global_dir).expanduser()
        self._persistent_cache = PersistentCache(
            global_dir=resolved_cache_dir,
            skip_read=no_cache,
            no_cache_patterns=no_cache_patterns,
        )

        # Image cache for avoiding repeated file reads during document processing
        # Key: file path string, Value: (bytes, base64_encoded_string)
        # Uses OrderedDict for LRU eviction when limits are reached
        from collections import OrderedDict

        self._image_cache: OrderedDict[str, tuple[bytes, str]] = OrderedDict()
        self._image_cache_lock = threading.Lock()
        self._image_cache_max_size = 200  # Max number of images to cache
        self._image_cache_max_bytes = 500 * 1024 * 1024  # 500MB max total cache size
        self._image_cache_bytes = 0  # Current total bytes in cache

        # Register LiteLLM callback for additional details
        self._setup_callbacks()

        # Eagerly initialize vision router to avoid first-call latency
        if config.model_list:
            _ = self.vision_router

    def _setup_callbacks(self) -> None:
        """Register LiteLLM callbacks and custom providers."""
        # Add our custom logger to litellm callbacks if not already added
        if _markitai_llm_logger not in (litellm.callbacks or []):
            if litellm.callbacks is None:
                litellm.callbacks = []
            litellm.callbacks.append(_markitai_llm_logger)

        # Register custom providers (claude-agent, copilot, etc.)
        from markitai.providers import register_providers

        register_providers()

    def _get_next_call_index(self, context: str) -> int:
        """Get the next call index for a given context.

        Thread-safe: uses lock for atomic increment.
        """
        with self._usage_lock:
            self._call_counter[context] += 1
            return self._call_counter[context]

    def reset_call_counter(self, context: str = "") -> None:
        """Reset call counter for a context or all contexts.

        Thread-safe: uses lock for safe modification.
        """
        with self._usage_lock:
            if context:
                self._call_counter.pop(context, None)
            else:
                self._call_counter.clear()

    @property
    def router(self) -> Router | LocalProviderWrapper | HybridRouter:
        """Get or create the LiteLLM Router (or LocalProviderWrapper/HybridRouter for local models)."""
        if self._router is None:
            self._router = self._create_router()
        return self._router

    @property
    def semaphore(self) -> asyncio.Semaphore:
        """Get the LLM concurrency semaphore.

        If a runtime was provided, uses the shared semaphore from runtime.
        Otherwise creates a local semaphore.
        """
        if self._runtime is not None:
            return self._runtime.semaphore
        if self._semaphore is None:
            self._semaphore = asyncio.Semaphore(self.config.concurrency)
        return self._semaphore

    @property
    def io_semaphore(self) -> asyncio.Semaphore:
        """Get the I/O concurrency semaphore for file operations.

        Separate from LLM semaphore to allow higher I/O parallelism.
        """
        if self._runtime is not None:
            return self._runtime.io_semaphore
        if self._io_semaphore is None:
            self._io_semaphore = asyncio.Semaphore(DEFAULT_IO_CONCURRENCY)
        return self._io_semaphore

    def _create_router(self) -> Router | LocalProviderWrapper | HybridRouter:
        """Create LiteLLM Router from configuration.

        If all models use local providers (claude-agent/, copilot/), returns
        a LocalProviderWrapper instead. If mixed, returns a HybridRouter.

        Returns:
            Router, LocalProviderWrapper, or HybridRouter instance
        """
        if not self.config.model_list:
            raise ValueError("No models configured in llm.model_list")

        # Build model list with resolved API keys and max_tokens
        # Skip models whose SDKs are not available
        from markitai.config import EnvVarNotFoundError
        from markitai.providers import is_local_provider_available

        model_list = []
        skipped_models = []
        disabled_models = []
        skipped_env_vars: list[str] = []
        for model_config in self.config.model_list:
            model_id = model_config.litellm_params.model

            # Skip local provider models if their SDK is not available
            if not is_local_provider_available(model_id):
                skipped_models.append(model_id)
                continue

            # weight <= 0 means model is disabled (e.g. no API quota)
            if model_config.litellm_params.weight <= 0:
                disabled_models.append(model_id)
                continue

            model_entry = {
                "model_name": model_config.model_name,
                "litellm_params": {
                    "model": model_id,
                },
            }

            # Add optional params — skip model if env var is missing
            try:
                api_key = model_config.litellm_params.get_resolved_api_key()
            except EnvVarNotFoundError as e:
                skipped_env_vars.append(e.var_name)
                logger.warning(
                    f"[Router] Skipping model {model_id}: "
                    f"environment variable {e.var_name} not set"
                )
                continue

            if api_key:
                model_entry["litellm_params"]["api_key"] = api_key

            try:
                api_base = model_config.litellm_params.get_resolved_api_base()
            except EnvVarNotFoundError as e:
                skipped_env_vars.append(e.var_name)
                logger.warning(
                    f"[Router] Skipping model {model_id}: "
                    f"environment variable {e.var_name} not set"
                )
                continue

            if api_base:
                model_entry["litellm_params"]["api_base"] = api_base

            if model_config.litellm_params.weight != 1:
                model_entry["litellm_params"]["weight"] = (
                    model_config.litellm_params.weight
                )

            # Note: max_tokens is NOT set at Router level
            # It will be calculated dynamically per-request based on input size
            # This avoids context overflow issues with shared context models

            if model_config.model_info:
                model_entry["model_info"] = model_config.model_info.model_dump()

            model_list.append(model_entry)

        if skipped_models:
            logger.debug(
                f"[Router] Skipped {len(skipped_models)} models (SDK unavailable): "
                f"{preview_items_for_log(skipped_models)}"
            )
        if disabled_models:
            logger.debug(
                f"[Router] Skipped {len(disabled_models)} disabled models (weight=0): "
                f"{preview_items_for_log(disabled_models)}"
            )

        if not model_list:
            disabled_count = sum(
                1 for m in self.config.model_list if m.litellm_params.weight <= 0
            )
            if disabled_count == len(self.config.model_list):
                raise ValueError(
                    f"All {disabled_count} configured models have weight=0 (disabled). "
                    "Set weight > 0 on at least one model to enable it."
                )
            if skipped_env_vars:
                vars_str = ", ".join(skipped_env_vars)
                raise ValueError(
                    f"No available models: missing environment variable(s): {vars_str}. "
                    "Set them via 'export VAR=value' or add to .env file."
                )
            raise ValueError(
                "No available models after filtering. "
                "Check that required SDKs are installed for configured models."
            )

        # Normalize all model_name to "default" for unified load balancing pool
        for entry in model_list:
            entry["model_name"] = "default"

        # Log router configuration (compact format)
        model_names = [e["litellm_params"]["model"].split("/")[-1] for e in model_list]

        # Check if all models use local providers
        # LiteLLM Router doesn't support custom providers, so we use a wrapper
        if _is_all_local_providers(model_list):
            logger.info(
                f"[Router] Local provider pool ({len(model_list)}): "
                f"{preview_items_for_log(model_names)}"
            )
            return LocalProviderWrapper(model_list=model_list)

        # Separate local providers from standard models
        from markitai.providers import is_local_provider_model

        standard_models = []
        local_models = []
        for entry in model_list:
            model_id = entry["litellm_params"]["model"]
            if is_local_provider_model(model_id):
                local_models.append(entry)
            else:
                standard_models.append(entry)

        if not standard_models:
            raise ValueError(
                "No standard models available after filtering local providers. "
                "Use only local providers (claude-agent/, copilot/) or add standard models."
            )

        # Build router settings
        router_settings = self.config.router_settings.model_dump()

        # Disable internal retries - we handle retries ourselves for better logging
        router_settings["num_retries"] = 0

        standard_names = [
            e["litellm_params"]["model"].split("/")[-1] for e in standard_models
        ]

        # Create standard router
        standard_router = Router(model_list=standard_models, **router_settings)

        # If we have both local and standard models, use HybridRouter
        if local_models:
            local_wrapper = LocalProviderWrapper(model_list=local_models)
            local_names = [e["litellm_params"]["model"] for e in local_models]
            logger.info(
                f"[Router] Creating HybridRouter: {len(standard_models)} standard + "
                f"{len(local_models)} local models"
            )
            logger.debug(
                f"[Router] Standard: {', '.join(standard_names)}, "
                f"Local: {', '.join(local_names)}"
            )
            return HybridRouter(
                standard_router=standard_router,
                local_wrapper=local_wrapper,
            )

        logger.info(
            f"[Router] Creating with strategy={router_settings.get('routing_strategy')}, "
            f"models={len(standard_models)}: {preview_items_for_log(standard_names)}"
        )

        return standard_router

    def _create_router_from_models(
        self, models: list[Any], router_settings: dict[str, Any] | None = None
    ) -> Router | LocalProviderWrapper | HybridRouter:
        """Create a Router from a subset of model configurations.

        If all models use local providers (claude-agent/, copilot/), returns
        a LocalProviderWrapper. If mixed, returns a HybridRouter.

        Args:
            models: List of ModelConfig objects from self.config.model_list
            router_settings: Optional router settings (uses default if not provided)

        Returns:
            Router, LocalProviderWrapper, or HybridRouter instance
        """
        # Build model list with resolved API keys and max_tokens
        # Skip models whose SDKs are not available
        from markitai.config import EnvVarNotFoundError
        from markitai.providers import is_local_provider_available

        model_list = []
        disabled_models = []
        skipped_env_vars: list[str] = []
        for model_config in models:
            model_id = model_config.litellm_params.model

            # Skip local provider models if their SDK is not available
            if not is_local_provider_available(model_id):
                continue

            # weight <= 0 means model is disabled (e.g. no API quota)
            if model_config.litellm_params.weight <= 0:
                disabled_models.append(model_id)
                continue

            model_entry = {
                "model_name": model_config.model_name,
                "litellm_params": {
                    "model": model_id,
                },
            }

            # Add optional params — skip model if env var is missing
            try:
                api_key = model_config.litellm_params.get_resolved_api_key()
            except EnvVarNotFoundError as e:
                skipped_env_vars.append(e.var_name)
                logger.warning(
                    f"[Router] Skipping model {model_id}: "
                    f"environment variable {e.var_name} not set"
                )
                continue

            if api_key:
                model_entry["litellm_params"]["api_key"] = api_key

            try:
                api_base = model_config.litellm_params.get_resolved_api_base()
            except EnvVarNotFoundError as e:
                skipped_env_vars.append(e.var_name)
                logger.warning(
                    f"[Router] Skipping model {model_id}: "
                    f"environment variable {e.var_name} not set"
                )
                continue

            if api_base:
                model_entry["litellm_params"]["api_base"] = api_base

            if model_config.litellm_params.weight != 1:
                model_entry["litellm_params"]["weight"] = (
                    model_config.litellm_params.weight
                )

            # Note: max_tokens calculated dynamically per-request

            if model_config.model_info:
                model_entry["model_info"] = model_config.model_info.model_dump()

            model_list.append(model_entry)

        if not model_list:
            disabled_count = sum(1 for m in models if m.litellm_params.weight <= 0)
            if skipped_env_vars:
                vars_str = ", ".join(skipped_env_vars)
                raise ValueError(
                    f"No available models: missing environment variable(s): {vars_str}. "
                    "Set them via 'export VAR=value' or add to .env file."
                )
            if disabled_count == len(models):
                raise ValueError(
                    f"All {disabled_count} configured models have weight=0 (disabled). "
                    "Set weight > 0 on at least one model to enable it."
                )
            raise ValueError(
                "No available models after filtering. "
                "Check that required SDKs are installed for configured models."
            )

        if disabled_models:
            logger.debug(
                f"[Router] Skipped {len(disabled_models)} disabled models (weight=0): "
                f"{preview_items_for_log(disabled_models)}"
            )

        # Check if all models use local providers
        if _is_all_local_providers(model_list):
            return LocalProviderWrapper(model_list=model_list)

        # Separate local providers from standard models
        from markitai.providers import is_local_provider_model

        local_models = []
        standard_models = []
        for entry in model_list:
            model_id = entry["litellm_params"]["model"]
            if is_local_provider_model(model_id):
                local_models.append(entry)
            else:
                standard_models.append(entry)

        if not standard_models:
            raise ValueError(
                "No standard models available after filtering local providers. "
                "Use only local providers (claude-agent/, copilot/) or add standard models."
            )

        # Use provided settings or default
        settings = router_settings or self.config.router_settings.model_dump()
        settings["num_retries"] = 0  # We handle retries ourselves

        # Create standard router
        standard_router = Router(model_list=standard_models, **settings)

        # If we have both local and standard models, use HybridRouter
        if local_models:
            local_wrapper = LocalProviderWrapper(model_list=local_models)
            local_names = [e["litellm_params"]["model"] for e in local_models]
            standard_names = [e["litellm_params"]["model"] for e in standard_models]
            logger.debug(
                f"[Router] Using HybridRouter: local={local_names}, standard={standard_names}"
            )
            return HybridRouter(
                standard_router=standard_router,
                local_wrapper=local_wrapper,
            )

        return standard_router

    def _is_vision_model(self, model_config: Any) -> bool:
        """Check if a model supports vision.

        Priority:
        1. Config override (model_info.supports_vision) if explicitly set
        2. Local providers (claude-agent/, copilot/) - always support vision
        3. Auto-detect from litellm.get_model_info()

        Args:
            model_config: Model configuration object

        Returns:
            True if model supports vision
        """
        model_id = model_config.litellm_params.model

        # Check config override first
        if (
            model_config.model_info
            and model_config.model_info.supports_vision is not None
        ):
            return model_config.model_info.supports_vision

        # Local providers (claude-agent/, copilot/) support vision via attachments
        # when using non-streaming mode (which is our default)
        from markitai.providers import is_local_provider_model

        if is_local_provider_model(model_id):
            return True

        # Auto-detect from litellm
        info = get_model_info_cached(model_id)
        return info.get("supports_vision", False)

    @property
    def vision_router(self) -> Router | LocalProviderWrapper | HybridRouter:
        """Get or create Router with only vision-capable models (lazy).

        Filters models using auto-detection from litellm or config override.
        Falls back to main router if no vision models found.

        Returns:
            Router or LocalProviderWrapper with vision-capable models only
        """
        if self._vision_router is None:
            vision_models = [
                m for m in self.config.model_list if self._is_vision_model(m)
            ]

            # Also check that at least one vision model is enabled (weight > 0)
            enabled_vision = [m for m in vision_models if m.litellm_params.weight > 0]

            if not enabled_vision:
                # No enabled vision models - fall back to main router
                reason = (
                    "no vision-capable models configured"
                    if not vision_models
                    else "all vision models disabled (weight=0)"
                )
                logger.warning(
                    f"[Router] {reason}, falling back to main router for vision"
                )
                self._vision_router = self.router
            else:
                model_names = [
                    m.litellm_params.model.split("/")[-1] for m in enabled_vision
                ]
                logger.info(
                    f"[Router] Vision router ({len(enabled_vision)}): "
                    f"{preview_items_for_log(model_names)}"
                )
                self._vision_router = self._create_router_from_models(enabled_vision)

        return self._vision_router

    async def _call_llm(
        self,
        model: str,
        messages: list[dict[str, Any]],
        context: str = "",
    ) -> LLMResponse:
        """
        Make an LLM call with rate limiting, retry logic, and detailed logging.

        Smart router selection: automatically uses vision_router when messages
        contain images, otherwise uses the main router.

        Args:
            model: Logical model name (e.g., "default")
            messages: Chat messages
            context: Context identifier for logging (e.g., filename)

        Returns:
            LLMResponse with content and usage info
        """
        # Generate call ID for logging
        call_index = self._get_next_call_index(context) if context else 0
        call_id = f"{context}:{call_index}" if context else f"call:{call_index}"

        # Smart router selection based on message content
        requires_vision = has_images(messages)
        router = self.vision_router if requires_vision else self.router

        max_retries = self.config.router_settings.num_retries
        return await self._call_llm_with_retry(
            model=model,
            messages=messages,
            call_id=call_id,
            context=context,
            max_retries=max_retries,
            router=router,
        )

    def _calculate_dynamic_max_tokens(
        self,
        messages: list[Any],
        target_model_id: str | None = None,
        router: Router | LocalProviderWrapper | HybridRouter | None = None,
    ) -> int | None:
        """Calculate dynamic max_tokens based on input size and target model.

        Uses the target model's limits when available, otherwise returns None
        to let LiteLLM use model defaults.

        When router is provided, uses the minimum max_output_tokens across all
        models in the router to ensure compatibility with any model the router
        might select.

        Also detects table-heavy content (>20 rows) and applies a higher token floor.

        Args:
            messages: Chat messages to estimate input tokens
            target_model_id: Specific model ID (from router pre-selection)
            router: Optional Router or LocalProviderWrapper for model limit lookup

        Returns:
            Safe max_tokens value, or None to let LiteLLM use model defaults
        """
        import re

        # Estimate input tokens (use gpt-4 tokenizer as reasonable approximation)
        try:
            # Note: gpt-4 tokenizer does not account for images
            input_tokens = litellm.token_counter(model="gpt-4", messages=messages)

            # Manual correction for images (token_counter often ignores them)
            # Standard high-res image is ~1105-1445 tokens for most models
            for msg in messages:
                content = msg.get("content", [])
                if isinstance(content, list):
                    for item in content:
                        if isinstance(item, dict) and (
                            item.get("type") == "image_url" or "image" in item
                        ):
                            # Add 1600 tokens per image as a safe buffer
                            input_tokens += 1600
        except Exception:
            # Fallback: rough estimate based on character count
            total_chars = sum(len(str(m.get("content", ""))) for m in messages)
            input_tokens = total_chars // 4  # ~4 chars per token

        # Detect table-heavy content (tables require more output tokens for formatting)
        content_str = str(messages)
        table_rows = len(re.findall(r"\|[^|]+\|", content_str))
        is_table_heavy = table_rows > 20  # More than 20 table rows

        # Get model limits - use minimum across all router models if available
        max_context: int | None = None
        max_output: int | None = None

        # If router is provided, get minimum max_output_tokens across all models
        # This ensures compatibility with any model the router might select
        if router:
            all_max_outputs: list[int] = []
            all_max_contexts: list[int] = []
            for model_config in router.model_list:
                model_id = model_config.get("litellm_params", {}).get("model")
                if model_id:
                    info = get_model_info_cached(model_id)
                    if info.get("max_output_tokens"):
                        all_max_outputs.append(info["max_output_tokens"])
                    if info.get("max_input_tokens"):
                        all_max_contexts.append(info["max_input_tokens"])
            if all_max_outputs:
                max_output = min(all_max_outputs)
            if all_max_contexts:
                max_context = min(all_max_contexts)
        elif target_model_id:
            info = get_model_info_cached(target_model_id)
            max_context = info.get("max_input_tokens")
            max_output = info.get("max_output_tokens")

        # If target model info unavailable, return None to let LiteLLM handle it
        if not max_context or not max_output:
            logger.trace(
                f"[DynamicTokens] Could not get limits for model={target_model_id}, "
                "returning None to use LiteLLM defaults"
            )
            return None

        # Calculate available output space
        # Reserve buffer for safety (tokenizer differences, system overhead)
        # Use a larger buffer (2000) to avoid edge cases with large context models
        buffer = max(2000, int(input_tokens * 0.2))
        available_context = max_context - input_tokens - buffer

        # max_tokens = min(model's max_output, available context space)
        max_tokens = min(max_output, available_context)

        # Apply a reasonable cap for standard document conversion
        # 128k output is plenty for even the largest single page/chunk
        # This prevents requesting "too many" tokens which some providers dislike
        max_tokens = min(max_tokens, DEFAULT_MAX_OUTPUT_TOKENS_HARD_CAP)

        # Ensure reasonable minimum (higher for table-heavy content)
        min_floor = 4000 if is_table_heavy else 1000
        max_tokens = max(max_tokens, min_floor)

        logger.trace(
            f"[DynamicTokens] input_est={input_tokens}, model={target_model_id}, "
            f"max_output_lim={max_output}, calculated={max_tokens}"
        )

        return max_tokens

    def _get_router_primary_model(
        self, router: Router | LocalProviderWrapper | HybridRouter
    ) -> str | None:
        """Get the highest-weight model ID from a Router's model_list.

        Returns the model with the greatest weight, which is most likely
        to be selected during routing. Used for dynamic max_tokens calculation
        and error reporting.

        Args:
            router: LiteLLM Router, LocalProviderWrapper, or HybridRouter instance

        Returns:
            Model ID string (e.g., "chatgpt/gpt-5.3"), or None if unavailable
        """
        try:
            model_list = router.model_list
            if not model_list:
                return None

            best_model = None
            best_weight = -1.0
            for config in model_list:
                params = config.get("litellm_params", {})
                model_id = params.get("model", "")
                weight = params.get("weight", 1.0)
                if weight > best_weight:
                    best_weight = weight
                    best_model = model_id

            return best_model
        except Exception as e:
            logger.debug("[LLM] Failed to select highest-weight model: {}", e)
        return None

    async def _call_llm_with_retry(
        self,
        model: str,
        messages: list[dict[str, Any]],
        call_id: str,
        context: str = "",
        max_retries: int = DEFAULT_MAX_RETRIES,
        router: Router | LocalProviderWrapper | HybridRouter | None = None,
    ) -> LLMResponse:
        """
        Make an LLM call with custom retry logic and detailed logging.

        Args:
            model: Logical model name (e.g., "default")
            messages: Chat messages
            call_id: Unique identifier for this call (for logging)
            context: Context identifier for usage tracking (e.g., filename)
            max_retries: Maximum number of retry attempts
            router: Router or LocalProviderWrapper to use (defaults to self.router)

        Returns:
            LLMResponse with content and usage info
        """
        # Use provided router or default to main router
        active_router = router or self.router
        last_exception: Exception | None = None

        # Calculate dynamic max_tokens based on input size and target model
        target_model_id = self._get_router_primary_model(active_router)
        max_tokens = self._calculate_dynamic_max_tokens(messages, target_model_id)

        for attempt in range(max_retries + 1):
            start_time = time.perf_counter()

            async with self.semaphore:
                try:
                    # Log request start
                    if attempt == 0:
                        logger.debug(f"[LLM:{call_id}] Request to {model}")
                    else:
                        # Log retry attempt
                        error_type = (
                            type(last_exception).__name__
                            if last_exception
                            else "Unknown"
                        )
                        status_code = getattr(last_exception, "status_code", "N/A")
                        logger.warning(
                            f"[LLM:{call_id}] Retry #{attempt}: {error_type} "
                            f"status={status_code}"
                        )

                    response = await active_router.acompletion(
                        model=model,
                        messages=cast(list[AllMessageValues], messages),
                        max_tokens=max_tokens,
                        metadata={"call_id": call_id, "attempt": attempt},
                    )

                    elapsed_ms = (time.perf_counter() - start_time) * 1000
                    # litellm returns Choices (not StreamingChoices) for non-streaming
                    choice = cast(Choices, response.choices[0])
                    content = choice.message.content or ""
                    actual_model = response.model or model

                    # Calculate cost (uses _hidden_params for local providers)
                    cost = get_response_cost(response)

                    # Track usage (usage attr exists at runtime but not in type stubs)
                    usage = getattr(response, "usage", None)
                    input_tokens = usage.prompt_tokens if usage else 0
                    output_tokens = usage.completion_tokens if usage else 0

                    self._track_usage(
                        actual_model, input_tokens, output_tokens, cost, context
                    )

                    # Log result
                    logger.info(
                        f"[LLM:{call_id}] {actual_model} "
                        f"tokens={input_tokens}+{output_tokens} "
                        f"time={elapsed_ms:.0f}ms cost=${cost:.6f}"
                    )

                    # Detect empty response (0 output tokens with substantial input)
                    # This usually indicates a model failure that should be retried
                    if output_tokens == 0 and input_tokens > 100:
                        if attempt < max_retries:
                            logger.warning(
                                f"[LLM:{call_id}] Empty response (0 output tokens), "
                                f"retrying with different model..."
                            )
                            # Treat as retryable error
                            await asyncio.sleep(
                                min(
                                    DEFAULT_RETRY_BASE_DELAY * (2**attempt),
                                    DEFAULT_RETRY_MAX_DELAY,
                                )
                            )
                            continue
                        else:
                            logger.error(
                                f"[LLM:{call_id}] Empty response after {max_retries + 1} "
                                f"attempts, returning empty content"
                            )

                    return LLMResponse(
                        content=content,
                        model=actual_model,
                        input_tokens=input_tokens,
                        output_tokens=output_tokens,
                        cost_usd=cost,
                    )

                except RETRYABLE_ERRORS as e:
                    elapsed_ms = (time.perf_counter() - start_time) * 1000
                    last_exception = e

                    # Check for quota/billing errors that should NOT be retried
                    # These errors are wrapped by LiteLLM as APIConnectionError but
                    # are actually non-recoverable without user action
                    error_msg_lower = str(e).lower()
                    non_retryable_patterns = (
                        "quota",
                        "billing",
                        "payment",
                        "subscription",
                        "402",
                        "insufficient_quota",
                        "exceeded your current quota",
                    )
                    if any(
                        pattern in error_msg_lower for pattern in non_retryable_patterns
                    ):
                        status_code = getattr(e, "status_code", "N/A")
                        logger.error(
                            f"[LLM:{call_id}] Quota/billing error (not retrying): "
                            f"status={status_code} {format_error_message(e)} "
                            f"time={elapsed_ms:.0f}ms"
                        )
                        raise

                    if attempt == max_retries:
                        # Final failure after all retries
                        error_type = type(e).__name__
                        status_code = getattr(e, "status_code", "N/A")
                        provider = getattr(e, "llm_provider", "N/A")
                        logger.error(
                            f"[LLM:{call_id}] Failed after {max_retries + 1} attempts: "
                            f"{error_type} status={status_code} provider={provider} "
                            f"time={elapsed_ms:.0f}ms"
                        )
                        raise

                    # Calculate exponential backoff delay
                    delay = min(
                        DEFAULT_RETRY_BASE_DELAY * (2**attempt), DEFAULT_RETRY_MAX_DELAY
                    )
                    await asyncio.sleep(delay)

                except Exception as e:
                    elapsed_ms = (time.perf_counter() - start_time) * 1000
                    error_msg_lower = str(e).lower()

                    # Model-level errors are retryable (HybridRouter cooldown
                    # ensures the next attempt picks a different model)
                    if any(
                        p in error_msg_lower
                        for p in HybridRouter.MODEL_LEVEL_ERROR_PATTERNS
                    ):
                        last_exception = e
                        status_code = getattr(e, "status_code", "N/A")
                        if attempt < max_retries:
                            logger.warning(
                                f"[LLM:{call_id}] Model-level error "
                                f"(status={status_code}), retrying: "
                                f"{format_error_message(e)} "
                                f"time={elapsed_ms:.0f}ms"
                            )
                            delay = min(
                                DEFAULT_RETRY_BASE_DELAY * (2**attempt),
                                DEFAULT_RETRY_MAX_DELAY,
                            )
                            await asyncio.sleep(delay)
                            continue
                        else:
                            logger.error(
                                f"[LLM:{call_id}] Model-level error after "
                                f"{max_retries + 1} attempts: "
                                f"{format_error_message(e)} "
                                f"time={elapsed_ms:.0f}ms"
                            )
                            raise

                    # Check for authentication errors and provide friendly hints
                    status_code = getattr(e, "status_code", "N/A")
                    auth_patterns = (
                        "authentication",
                        "api_key",
                        "api key",
                        "unauthorized",
                        "401",
                        "403",
                        "invalid x-api-key",
                        "incorrect api key",
                    )
                    if any(p in error_msg_lower for p in auth_patterns):
                        target = self._get_router_primary_model(active_router)
                        logger.error(
                            f"[LLM:{call_id}] Authentication failed for model "
                            f"'{target}':\n"
                            f"  {format_error_message(e)}\n\n"
                            f"  Hint: Use MODEL=<provider/model> with the "
                            f"corresponding API key env var,\n"
                            f"  or run 'markitai init' to configure interactively."
                        )
                    else:
                        logger.error(
                            f"[LLM:{call_id}] Failed: status={status_code} "
                            f"{format_error_message(e)} time={elapsed_ms:.0f}ms"
                        )
                    raise

        # Should not reach here, but just in case
        raise RuntimeError(f"[LLM:{call_id}] Unexpected state in retry loop")

    def _track_usage(
        self,
        model: str,
        input_tokens: int,
        output_tokens: int,
        cost: float,
        context: str = "",
    ) -> None:
        """Track usage statistics per model (and optionally per context).

        Thread-safe: uses lock to protect concurrent access to usage dicts.

        Args:
            model: Model name
            input_tokens: Number of input tokens
            output_tokens: Number of output tokens
            cost: Cost in USD
            context: Optional context identifier (e.g., filename)
        """
        with self._usage_lock:
            # Track global usage (defaultdict auto-creates entries)
            self._usage[model]["requests"] += 1
            self._usage[model]["input_tokens"] += input_tokens
            self._usage[model]["output_tokens"] += output_tokens
            self._usage[model]["cost_usd"] += cost

            # Track per-context usage if context provided
            if context:
                self._context_usage[context][model]["requests"] += 1
                self._context_usage[context][model]["input_tokens"] += input_tokens
                self._context_usage[context][model]["output_tokens"] += output_tokens
                self._context_usage[context][model]["cost_usd"] += cost

    def get_usage(self) -> dict[str, dict[str, Any]]:
        """Get global usage statistics.

        Thread-safe: uses lock and returns a deep copy.
        """

        with self._usage_lock:
            return copy.deepcopy(self._usage)

    def get_total_cost(self) -> float:
        """Get total cost across all models.

        Thread-safe: uses lock for consistent read.
        """
        with self._usage_lock:
            return sum(u["cost_usd"] for u in self._usage.values())

    def get_context_usage(self, context: str) -> dict[str, dict[str, Any]]:
        """Get usage statistics for a specific context.

        Thread-safe: uses lock and returns a deep copy.

        Args:
            context: Context identifier (e.g., filename)

        Returns:
            Usage statistics for that context, or empty dict if not found
        """

        with self._usage_lock:
            return copy.deepcopy(self._context_usage.get(context, {}))

    def get_context_cost(self, context: str) -> float:
        """Get total cost for a specific context.

        Thread-safe: uses lock for consistent read.

        Args:
            context: Context identifier (e.g., filename)

        Returns:
            Total cost for that context
        """
        with self._usage_lock:
            context_usage = self._context_usage.get(context, {})
            return sum(u["cost_usd"] for u in context_usage.values())

    def clear_context_usage(self, context: str) -> None:
        """Clear usage tracking for a specific context.

        Thread-safe: uses lock for safe modification.

        Args:
            context: Context identifier to clear
        """
        with self._usage_lock:
            self._context_usage.pop(context, None)
            self._call_counter.pop(context, None)

    def get_cache_stats(self) -> dict[str, Any]:
        """Get cache statistics.

        Returns:
            Dict with memory cache stats, persistent cache stats, and combined hit rate
        """
        total = self._cache_hits + self._cache_misses
        hit_rate = self._cache_hits / total if total > 0 else 0.0
        return {
            "memory": {
                "hits": self._cache_hits,
                "misses": self._cache_misses,
                "hit_rate": round(hit_rate * 100, 2),
                "size": self._cache.size,
            },
            "persistent": self._persistent_cache.stats(),
        }

    def clear_cache(self, scope: str = "memory") -> dict[str, Any]:
        """Clear the content cache and reset statistics.

        Args:
            scope: "memory" (in-memory only), "global", or "all"

        Returns:
            Dict with counts of cleared entries
        """
        result: dict[str, Any] = {"memory": 0, "global": 0}

        if scope in ("memory", "all"):
            result["memory"] = self._cache.size
            self._cache.clear()
            self._cache_hits = 0
            self._cache_misses = 0

        if scope in ("global", "all"):
            result["global"] = self._persistent_cache.clear()

        return result

    def clear_image_cache(self) -> None:
        """Clear the image cache to free memory after document processing."""
        with self._image_cache_lock:
            self._image_cache.clear()
            self._image_cache_bytes = 0

    def _get_cached_image(self, image_path: Path) -> tuple[bytes, str]:
        """Get image bytes and base64 encoding, using cache if available.

        Uses LRU eviction when cache limits are reached (both count and bytes).
        Also ensures image is under 5MB limit for LLM API compatibility.

        Args:
            image_path: Path to the image file

        Returns:
            Tuple of (raw bytes, base64 encoded string)
        """
        path_key = str(image_path)

        with self._image_cache_lock:
            if path_key in self._image_cache:
                # Move to end for LRU (most recently used)
                self._image_cache.move_to_end(path_key)
                return self._image_cache[path_key]

        # Read and encode image
        image_data = image_path.read_bytes()

        # Convert LLM-unsupported formats (BMP, TIFF) to PNG
        from markitai.utils.mime import LLM_CONVERTIBLE_EXTENSIONS

        if image_path.suffix.lower() in LLM_CONVERTIBLE_EXTENSIONS:
            try:
                import io

                from PIL import Image

                with io.BytesIO(image_data) as buffer:
                    img = Image.open(buffer)
                    if img.mode != "RGB":
                        img = img.convert("RGB")
                    out = io.BytesIO()
                    img.save(out, format="PNG")
                    image_data = out.getvalue()
                    out.close()
                    logger.debug(
                        "[LLM] Converted {} to PNG for LLM compatibility",
                        image_path.name,
                    )
            except Exception as e:
                logger.warning(
                    "[LLM] Failed to convert {} to PNG: {}", image_path.name, e
                )

        # Rasterize SVG to PNG via cairosvg (optional dependency)
        if image_path.suffix.lower() == ".svg" and cairosvg is not None:
            try:
                svg_result = cairosvg.svg2png(
                    bytestring=image_data,
                    output_width=DEFAULT_VISION_MAX_DIMENSION,
                )
                assert isinstance(svg_result, bytes)
                image_data = svg_result
                logger.debug(
                    "[LLM] Rasterized {} to PNG for LLM compatibility",
                    image_path.name,
                )
            except Exception as e:
                logger.warning(
                    "[LLM] Failed to rasterize SVG {}: {}", image_path.name, e
                )

        # Check size limit (5MB API limit after base64 encoding)
        # Base64 encoding increases size by ~33%, so 5MB / 1.33 ≈ 3.76MB
        # Using 3.5MB raw bytes to ensure base64 encoded size stays under 5MB
        MAX_IMAGE_SIZE = 3.5 * 1024 * 1024
        if len(image_data) > MAX_IMAGE_SIZE:
            try:
                import io

                from PIL import Image

                with io.BytesIO(image_data) as buffer:
                    img = Image.open(buffer)
                    # Resize logic: iterative downscaling if needed
                    quality = 85
                    max_dim = DEFAULT_VISION_MAX_DIMENSION

                    while True:
                        if max(img.size) > max_dim:
                            img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)

                        out_buffer = io.BytesIO()
                        # Use JPEG for compression efficiency unless transparency is needed
                        fmt = "JPEG"
                        if img.mode in ("RGBA", "LA") or (
                            img.format and img.format.upper() == "PNG"
                        ):
                            # If PNG is too big, convert to JPEG (losing transparency) or resize more
                            # For document analysis, JPEG is usually fine
                            if len(image_data) > 8 * 1024 * 1024:  # If huge, force JPEG
                                img = img.convert("RGB")
                                fmt = "JPEG"
                            else:
                                fmt = "PNG"

                        if fmt == "JPEG" and img.mode != "RGB":
                            img = img.convert("RGB")

                        if fmt == "JPEG":
                            img.save(out_buffer, format=fmt, quality=quality)
                        else:
                            img.save(out_buffer, format=fmt)
                        new_data = out_buffer.getvalue()

                        if len(new_data) <= MAX_IMAGE_SIZE:
                            image_data = new_data
                            logger.debug(
                                f"Resized large image {image_path.name}: {len(new_data) / 1024 / 1024:.2f}MB"
                            )
                            break

                        # If still too big, reduce quality/size
                        if quality > 50 and fmt == "JPEG":
                            quality -= 15
                        else:
                            max_dim = int(max_dim * 0.75)
                            if max_dim < 512:  # Safety floor
                                logger.warning(
                                    f"Could not compress {image_path.name} below 5MB even at 512px"
                                )
                                break

            except Exception as e:
                logger.warning(f"Failed to resize large image {image_path.name}: {e}")

        base64_image = base64.b64encode(image_data).decode()

        # Calculate entry size: raw bytes + base64 string (roughly 1.33x raw size)
        entry_bytes = len(image_data) + len(base64_image)

        # Evict old entries if adding this would exceed limits
        with self._image_cache_lock:
            while self._image_cache and (
                len(self._image_cache) >= self._image_cache_max_size
                or self._image_cache_bytes + entry_bytes > self._image_cache_max_bytes
            ):
                # Remove oldest entry (first item in OrderedDict)
                _, oldest_value = self._image_cache.popitem(last=False)
                old_bytes = len(oldest_value[0]) + len(oldest_value[1])
                self._image_cache_bytes -= old_bytes

            # Cache if entry size is reasonable (skip very large single images)
            if entry_bytes < self._image_cache_max_bytes // 2:
                self._image_cache[path_key] = (image_data, base64_image)
                self._image_cache_bytes += entry_bytes

        return image_data, base64_image
