"""
Model Selector - Smart default model selection based on environment API keys.

This module provides intelligent model selection that:
1. Auto-detects available providers based on environment API keys
2. Uses a tag system for model selection (quality + capability tags)
3. Returns fallback chains for robust operation

Usage:
    from pantheon.utils.model_selector import get_model_selector

    selector = get_model_selector()
    models = selector.resolve_model("normal")  # Returns list[str] fallback chain
    models = selector.resolve_model("high,vision")  # Quality + capability combo
"""

import time
from typing import TYPE_CHECKING

from .log import logger


def _load_custom_models_config() -> dict:
    """Deprecated custom model config loader kept as a no-op for compatibility."""
    return {}

if TYPE_CHECKING:
    from pantheon.settings import Settings


# Deprecated custom endpoint registry kept empty so older imports remain safe.
CUSTOM_ENDPOINT_ENVS: dict[str, object] = {}

SAVED_MODEL_PROVIDERS = ("openai", "anthropic", "gemini")


def _saved_model_provider_order(raw: object = None) -> list[str]:
    """Return providers to preserve in saved model settings.

    Keep the historical core providers present even when empty, then include
    any provider keys already present in user settings.
    """
    providers = list(SAVED_MODEL_PROVIDERS)
    if isinstance(raw, dict):
        for provider in raw:
            if isinstance(provider, str) and provider not in providers:
                providers.append(provider)

    return providers

# Sentinel object for negative cache (better than empty string)
_NOT_FOUND = object()

# ============ Local Provider Detection ============

_OLLAMA_BASE_URL = "http://localhost:11434"
_OLLAMA_CACHE_TTL_SECONDS = 30
_ollama_available = False
_ollama_models: list[str] = []
_ollama_last_checked = 0.0


def get_ollama_cached_state() -> tuple[bool, list[str]]:
    return _ollama_available, list(_ollama_models)


async def fetch_ollama_status(
    base_url: str = _OLLAMA_BASE_URL,
) -> tuple[bool, list[str]]:
    try:
        import httpx

        async with httpx.AsyncClient() as client:
            resp = await client.get(f"{base_url}/api/tags", timeout=2.0)
        if not resp.is_success:
            return False, []
        models = [m["name"] for m in resp.json().get("models", [])]
        return True, models
    except Exception:
        return False, []


async def refresh_ollama_cache(
    force: bool = False, base_url: str = _OLLAMA_BASE_URL
) -> tuple[bool, list[str]]:
    global _ollama_available, _ollama_models, _ollama_last_checked

    now = time.time()
    if (
        not force
        and _ollama_last_checked > 0
        and now - _ollama_last_checked < _OLLAMA_CACHE_TTL_SECONDS
    ):
        return get_ollama_cached_state()

    available, models = await fetch_ollama_status(base_url)
    _ollama_available = available
    _ollama_models = list(models)
    _ollama_last_checked = time.time()
    return get_ollama_cached_state()


def reset_ollama_cache() -> None:
    global _ollama_available, _ollama_models, _ollama_last_checked
    _ollama_available = False
    _ollama_models = []
    _ollama_last_checked = 0.0


def normalize_saved_models(raw: object) -> dict[str, list[str]]:
    """Normalize ``models.saved_models`` into provider -> bare model names."""
    normalized: dict[str, list[str]] = {
        provider: [] for provider in _saved_model_provider_order(raw)
    }
    if not isinstance(raw, dict):
        return normalized

    for provider in normalized:
        seen: set[str] = set()
        values = raw.get(provider, [])
        if not isinstance(values, list):
            continue
        for item in values:
            if not isinstance(item, str):
                continue
            model_name = item.strip()
            if not model_name:
                continue
            prefix = f"{provider}/"
            if model_name.startswith(prefix):
                model_name = model_name[len(prefix):]
            if model_name and model_name not in seen:
                normalized[provider].append(model_name)
                seen.add(model_name)

    return normalized


def get_saved_models(settings: "Settings") -> dict[str, list[str]]:
    """Read normalized saved model names from settings."""
    return normalize_saved_models(settings.get("models.saved_models", {}))


def prefix_saved_models(provider: str, model_names: list[str]) -> list[str]:
    """Return provider-prefixed model ids for saved model names."""
    return [f"{provider}/{name}" for name in model_names if name]

# ============ Default Configuration ============
# Built-in defaults based on May 2026 flagship models
# Users can override in settings.json

DEFAULT_PROVIDER_PRIORITY = ["openai", "anthropic", "gemini", "gemini-cli", "zai", "deepseek", "minimax", "moonshot", "qwen", "groq", "mistral", "together_ai", "openrouter", "codex", "ollama"]

# Quality levels map to MODEL LISTS (not single models) for fallback chains
# Models within each level are ordered by preference
DEFAULT_PROVIDER_MODELS = {
    # OpenAI: GPT-5.5/5.4 series
    # https://platform.openai.com/docs/models
    "openai": {
        "high": ["openai/gpt-5.5", "openai/gpt-5.4", "openai/gpt-5.2"],
        "normal": ["openai/gpt-5.5", "openai/gpt-5.4", "openai/gpt-5.2-codex", "openai/gpt-5.2", "openai/gpt-5"],
        "low": ["openai/gpt-5.4-mini", "openai/gpt-5.4-nano", "openai/gpt-5-mini", "openai/gpt-5-nano"],
    },
    # Anthropic: Claude 4.6 series
    # https://docs.anthropic.com/en/docs/about-claude/models/overview
    "anthropic": {
        "high": [
            "anthropic/claude-opus-4-7",
            "anthropic/claude-opus-4-6",
            "anthropic/claude-opus-4-5-20251101",
        ],
        "normal": [
            "anthropic/claude-sonnet-4-6",
            "anthropic/claude-sonnet-4-5-20250929",
            "anthropic/claude-sonnet-4-20250514",
        ],
        "low": [
            "anthropic/claude-haiku-4-5",
        ],
    },
    # Gemini: Gemini 3.5/3.1/3/2.5 series
    # https://ai.google.dev/gemini-api/docs/models
    "gemini": {
        "high": ["gemini/gemini-3.1-pro-preview", "gemini/gemini-3-pro-preview", "gemini/gemini-2.5-pro"],
        "normal": ["gemini/gemini-3.5-flash", "gemini/gemini-3-flash-preview", "gemini/gemini-2.5-flash"],
        "low": ["gemini/gemini-3.5-flash", "gemini/gemini-3-flash-preview", "gemini/gemini-2.5-flash-lite"],
    },
    "gemini-cli": {
        "high": ["gemini-cli/gemini-3.1-pro-preview", "gemini-cli/gemini-3-pro-preview", "gemini-cli/gemini-2.5-pro"],
        "normal": ["gemini-cli/gemini-3.5-flash", "gemini-cli/gemini-3-flash-preview", "gemini-cli/gemini-2.5-flash"],
        "low": ["gemini-cli/gemini-3.5-flash", "gemini-cli/gemini-3-flash-preview", "gemini-cli/gemini-2.5-flash-lite"],
    },
    # Z.ai (Zhipu): GLM-4.6/4.5 series
    # https://open.bigmodel.cn/
    "zai": {
        "high": ["zai/glm-5", "zai/glm-4.6", "zai/glm-4.5", "zai/glm-4.5v"],
        "normal": ["zai/glm-5", "zai/glm-4.6", "zai/glm-4.5", "zai/glm-4.5v"],
        "low": ["zai/glm-4.5-air", "zai/glm-4.5-flash"],
    },
    # DeepSeek: V4 series (1M context, dual thinking/non-thinking modes)
    # https://api-docs.deepseek.com/
    # Legacy `deepseek-chat` / `deepseek-reasoner` retire 2026-07-24.
    "deepseek": {
        "high": ["deepseek/deepseek-v4-pro"],
        "normal": ["deepseek/deepseek-v4-pro"],
        "low": ["deepseek/deepseek-v4-flash"],
    },
    # MiniMax: M2.7/M2.5 series
    # https://platform.minimaxi.com/
    "minimax": {
        "high": [
            "minimax/MiniMax-M2.7",
            "minimax/MiniMax-M2.7-highspeed",
            "minimax/MiniMax-M2.5-highspeed",
            "minimax/MiniMax-M2.5",
        ],
        "normal": [
            "minimax/MiniMax-M2.7-highspeed",
            "minimax/MiniMax-M2.5-highspeed",
            "minimax/MiniMax-M2.5",
        ],
        "low": [
            "minimax/MiniMax-M2.5",
        ],
    },
    # Moonshot: Kimi K2.6/K2.5 series
    # https://platform.moonshot.cn/
    "moonshot": {
        "high": ["moonshot/kimi-k2.6", "moonshot/kimi-k2.5"],
        "normal": ["moonshot/kimi-k2.6", "moonshot/kimi-k2.5"],
        "low": ["moonshot/kimi-k2.6", "moonshot/kimi-k2.5"],
    },
    # Qwen (DashScope): Qwen3.7/Qwen3 series
    # https://help.aliyun.com/zh/model-studio/
    "qwen": {
        "high": ["qwen/qwen3.7-max", "qwen/qwen3-max", "qwen/qwen3-235b-a22b", "qwen/qwen-max", "qwen/qwq-plus"],
        "normal": ["qwen/qwen-plus", "qwen/qwen3-32b"],
        "low": ["qwen/qwen3-30b-a3b", "qwen/qwen-turbo"],
    },
    # Groq: Ultra-fast inference
    # https://console.groq.com/docs/models
    "groq": {
        "high": ["groq/openai/gpt-oss-120b", "groq/llama-3.3-70b-versatile"],
        "normal": ["groq/openai/gpt-oss-20b", "groq/qwen/qwen3-32b", "groq/meta-llama/llama-4-scout-17b-16e-instruct"],
        "low": ["groq/llama-3.1-8b-instant"],
    },
    # Mistral AI
    # https://docs.mistral.ai/getting-started/models
    "mistral": {
        "high": ["mistral/mistral-medium-3-5", "mistral/mistral-large-2512", "mistral/mistral-large-latest"],
        "normal": ["mistral/mistral-small-2603", "mistral/mistral-small-latest", "mistral/codestral-latest"],
        "low": ["mistral/mistral-small-2603", "mistral/open-mistral-nemo"],
    },
    # Together AI: Open-source model hosting
    # https://docs.together.ai/docs/serverless-models
    "together_ai": {
        "high": ["together_ai/Qwen/Qwen3.7-Max", "together_ai/deepseek-ai/DeepSeek-V4-Pro", "together_ai/Qwen/Qwen3.5-397B-A17B"],
        "normal": ["together_ai/moonshotai/Kimi-K2.6", "together_ai/zai-org/GLM-5.1", "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo"],
        "low": ["together_ai/MiniMaxAI/MiniMax-M2.7", "together_ai/openai/gpt-oss-20b", "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo"],
    },
    # Codex: OpenAI via ChatGPT OAuth (free with ChatGPT Plus)
    "codex": {
        "high": ["codex/gpt-5.5", "codex/gpt-5.4", "codex/gpt-5.2-codex"],
        "normal": ["codex/gpt-5.4-mini", "codex/gpt-5"],
        "low": ["codex/gpt-5.4-mini", "codex/o4-mini"],
    },
    # OpenRouter: Multi-provider aggregator
    # https://openrouter.ai/models
    "openrouter": {
        "high": ["openrouter/qwen/qwen3.7-max", "openrouter/x-ai/grok-4.3", "openrouter/anthropic/claude-sonnet-4-6"],
        "normal": ["openrouter/google/gemini-3.5-flash", "openrouter/mistralai/mistral-medium-3-5", "openrouter/google/gemini-2.5-flash"],
        "low": ["openrouter/openai/gpt-oss-120b", "openrouter/meta-llama/llama-3.3-70b-instruct"],
    },
}

# Capability tags map to catalog supports_* fields
CAPABILITY_MAP = {
    "vision": "supports_vision",
    "reasoning": "supports_reasoning",
    "audio_in": "supports_audio_input",
    "audio_out": "supports_audio_output",
    "computer": "supports_computer_use",
    "web": "supports_web_search",
    "pdf": "supports_pdf_input",
    "tools": "supports_function_calling",
    "schema": "supports_response_schema",
    "prefill": "supports_assistant_prefill",
}

# Quality level tags
QUALITY_TAGS = {"high", "normal", "low"}

# Ultimate fallback model when nothing else works (must be concrete model, not tag)
ULTIMATE_FALLBACK = "openai/gpt-5.4"

# Recommended fallback tag for general use
FALLBACK_TAG = "low"

# Provider name -> environment variable for API key
PROVIDER_API_KEYS = {
    "openai": "OPENAI_API_KEY",
    "anthropic": "ANTHROPIC_API_KEY",
    "gemini": "GEMINI_API_KEY",
    "google": "GOOGLE_API_KEY",
    "azure": "AZURE_API_KEY",
    "cohere": "COHERE_API_KEY",
    "replicate": "REPLICATE_API_KEY",
    "huggingface": "HUGGINGFACE_API_KEY",
    "together_ai": "TOGETHER_API_KEY",
    "openrouter": "OPENROUTER_API_KEY",
    "groq": "GROQ_API_KEY",
    "mistral": "MISTRAL_API_KEY",
    "deepseek": "DEEPSEEK_API_KEY",
    "minimax": "MINIMAX_API_KEY",
    "zai": "ZAI_API_KEY",
    "moonshot": "MOONSHOT_API_KEY",
    "qwen": "DASHSCOPE_API_KEY",
    "codex": "",  # OAuth-based, no env var key
    "gemini-cli": "",  # OAuth-based, no env var key
    "ollama": "",  # Local, no env var key — exposed when cached Ollama status is available
}

# ============ Image Generation Model Defaults ============
# Quality levels for image generation models
DEFAULT_IMAGE_GEN_MODELS = {
    # Gemini: Nano Banana series
    # https://ai.google.dev/gemini-api/docs/image-generation
    # Note: Use gemini/ prefix to ensure correct provider detection (same as chat models)
    "gemini": {
        "high": ["gemini/gemini-3-pro-image-preview"],                                        # Nano Banana Pro
        "normal": ["gemini/gemini-3.1-flash-image-preview"],                                  # Nano Banana 2
        "low": ["gemini/gemini-2.5-flash-image"],                                             # Nano Banana stable
    },
    # OpenAI: GPT-Image series
    # https://platform.openai.com/docs/models
    "openai": {
        "high": ["gpt-image-2", "chatgpt-image-latest", "gpt-image-1.5"],
        "normal": ["gpt-image-2", "chatgpt-image-latest", "gpt-image-1.5"],
        "low": ["gpt-image-2", "gpt-image-1"],
    },
}


class ModelSelector:
    """Smart model selector based on environment API keys and tags."""

    def __init__(self, settings: "Settings"):
        """Initialize selector with settings.

        Args:
            settings: Pantheon Settings instance for reading configuration
        """
        self.settings = settings
        self._detected_provider: str | None = None
        self._available_providers: set[str] | None = None

    def _get_available_providers(self) -> set[str]:
        """Get set of providers with valid API keys (cached)."""
        if self._available_providers is not None:
            return self._available_providers

        from pantheon.settings import get_settings

        settings = get_settings()

        self._available_providers = set()

        for provider, env_key in PROVIDER_API_KEYS.items():
            if not env_key:
                continue
            api_key_value = settings.get_api_key(env_key) or ""
            if api_key_value:
                self._available_providers.add(provider)

        # Check OAuth providers (e.g., Codex, Gemini CLI)
        try:
            from pantheon.utils.oauth import CodexOAuthManager, GeminiCliOAuthManager
            if CodexOAuthManager().is_authenticated():
                self._available_providers.add("codex")
            if GeminiCliOAuthManager().is_authenticated():
                self._available_providers.add("gemini-cli")
        except Exception:
            pass

        # Ollama is refreshed by async chatroom code; selector only reads cached state.
        ollama_available, _ = get_ollama_cached_state()
        if ollama_available:
            self._available_providers.add("ollama")

        # Universal OpenAI-compatible fallback: expose OpenAI when fallback is configured.
        llm_base = settings.get_api_key("LLM_API_BASE") or ""
        llm_key = settings.get_api_key("LLM_API_KEY") or ""
        if llm_base and llm_key:
            self._available_providers.add("openai")

        return self._available_providers

    def detect_available_provider(self) -> str | None:
        """Detect first available provider based on API keys.

        Priority:
        1. User-configured priority list
        2. Code default priority list
        3. Any other available provider

        Returns:
            Provider name if found, None otherwise
        """
        if self._detected_provider is not None:
            return self._detected_provider

        # Get available providers from environment
        available = self._get_available_providers()
        if not available:
            logger.warning("No LLM providers detected from environment API keys")
            return None

        # 1. Priority: user config > code defaults
        priority = self.settings.get(
            "models.provider_priority", DEFAULT_PROVIDER_PRIORITY
        )

        # 2. Check priority list
        for provider in priority:
            if provider in available:
                self._detected_provider = provider
                logger.info(f"Selected provider '{provider}' from priority list")
                return provider

        # 3. Check any available provider not in priority list
        for provider in available:
            if provider not in priority:
                self._detected_provider = provider
                logger.info(
                    f"Selected provider '{provider}' (not in priority list, "
                    f"consider adding to settings.json)"
                )
                return provider

        return None

    def _get_provider_models(self, provider: str) -> dict[str, list[str]]:
        """Get model configuration for a provider.

        Priority: user config > code defaults > auto-generated

        Args:
            provider: Provider name (e.g., "openai", "anthropic")

        Returns:
            Dict mapping quality levels to model lists
        """
        # Ollama models are maintained by a background refresh so this stays non-blocking.
        if provider == "ollama":
            _available, models = get_ollama_cached_state()
            if models:
                prefixed = [f"ollama/{m}" for m in models]
                return {"high": prefixed, "normal": prefixed, "low": prefixed}
            return {}

        # Try user configuration first
        user_config = self.settings.get(f"models.provider_models.{provider}", {})

        # Get code defaults
        default_config = DEFAULT_PROVIDER_MODELS.get(provider, {})

        # Merge: user config overrides defaults
        if user_config or default_config:
            merged = {**default_config, **user_config}
            return merged

        # No configuration - auto-generate from catalog
        return self._auto_generate_provider_config(provider)

    def _auto_generate_provider_config(self, provider: str) -> dict[str, list[str]]:
        """Auto-generate provider config from catalog (sorted by price).

        Used when provider has API key but no configuration.

        Args:
            provider: Provider name

        Returns:
            Dict mapping quality levels to model lists
        """
        from pantheon.utils.provider_registry import models_by_provider as get_models, get_model_info

        logger.warning(
            f"Provider '{provider}' not configured. Auto-generating from catalog. "
            f"Consider adding it to settings.json models.provider_models for better control."
        )

        all_models = get_models(provider)
        if not all_models:
            logger.warning(f"Provider '{provider}' not found in catalog")
            return {}

        # Collect chat models with prices
        models_with_prices: list[tuple[str, float]] = []
        for model in all_models:
            try:
                info = get_model_info(model)
                mode = info.get("mode", "chat")
                if mode in ("chat", None):
                    input_cost = info.get("input_cost_per_token", 0) or 0
                    models_with_prices.append((model, input_cost))
            except Exception:
                pass

        if not models_with_prices:
            return {}

        # Sort by price descending (most expensive first)
        models_with_prices.sort(key=lambda x: x[1], reverse=True)

        # Split into thirds for quality levels
        n = len(models_with_prices)
        third = max(1, n // 3)

        config = {
            "high": [m[0] for m in models_with_prices[:third]],
            "normal": [m[0] for m in models_with_prices[third : 2 * third]],
            "low": [m[0] for m in models_with_prices[2 * third :]],
        }

        # Ensure each level has at least one model
        if not config["normal"]:
            config["normal"] = config["high"][-1:] if config["high"] else []
        if not config["low"]:
            config["low"] = config["normal"][-1:] if config["normal"] else []

        logger.info(f"Auto-generated config for '{provider}': {config}")
        return config

    def _check_model_capability(self, model: str, capability: str) -> bool:
        """Check if a model supports a specific capability.

        Args:
            model: Model name (e.g., "openai/gpt-4o")
            capability: Capability tag (e.g., "vision", "reasoning")

        Returns:
            True if model supports the capability
        """
        if capability not in CAPABILITY_MAP:
            return False

        try:
            from pantheon.utils.provider_registry import get_model_info

            info = get_model_info(model)
            field = CAPABILITY_MAP[capability]
            return bool(info.get(field))
        except Exception:
            return False

    def resolve_model(self, tag: str) -> list[str]:
        """Resolve tag(s) to a model fallback chain.

        Supports:
        - Quality tags: "high", "normal", "low"
        - Capability tags: "vision", "reasoning", "tools", etc.
        - Combinations: "high,vision", "low,reasoning"

        Args:
            tag: Single tag or comma-separated tags (e.g., "normal", "high,vision")

        Returns:
            List of models as fallback chain, can be passed directly to Agent(model=...)
        """
        # Parse tags
        tags = [t.strip().lower() for t in tag.split(",")]

        provider = self._detected_provider or self.detect_available_provider()
        if not provider:
            logger.warning(
                f"No provider available, using fallback model: {ULTIMATE_FALLBACK}"
            )
            return [ULTIMATE_FALLBACK]

        # Get provider configuration
        provider_models = self._get_provider_models(provider)
        if not provider_models:
            logger.warning(
                f"No models configured for provider '{provider}', "
                f"using fallback: {ULTIMATE_FALLBACK}"
            )
            return [ULTIMATE_FALLBACK]

        # Separate quality and capability tags
        quality_tag = next((t for t in tags if t in QUALITY_TAGS), "normal")
        capability_tags = [t for t in tags if t in CAPABILITY_MAP]

        # Get models for the quality level
        models = provider_models.get(quality_tag, [])
        if isinstance(models, str):
            models = [models]

        # If no capability tags, return the full quality level list
        if not capability_tags:
            return models if models else [ULTIMATE_FALLBACK]

        # Filter models by capability requirements
        result: list[str] = []
        for model in models:
            if all(
                self._check_model_capability(model, cap) for cap in capability_tags
            ):
                result.append(model)

        # If no models match at current level, search higher levels
        if not result:
            quality_order = ["high", "normal", "low"]
            try:
                start_idx = quality_order.index(quality_tag)
            except ValueError:
                start_idx = 1  # Default to normal

            # Search higher quality levels
            for quality in quality_order[:start_idx]:
                higher_models = provider_models.get(quality, [])
                if isinstance(higher_models, str):
                    higher_models = [higher_models]

                for model in higher_models:
                    if all(
                        self._check_model_capability(model, cap)
                        for cap in capability_tags
                    ):
                        result.append(model)

        # Append remaining models from original quality level as fallback
        for model in models:
            if model not in result:
                result.append(model)

        return result if result else [ULTIMATE_FALLBACK]

    def get_default_model(self) -> list[str]:
        """Get default model fallback chain (normal quality).

        Returns:
            List of models for fallback chain
        """
        return self.resolve_model("normal")

    def find_models_with_capability(self, capability: str) -> list[str]:
        """Find all models supporting a specific capability.

        Args:
            capability: Capability tag (e.g., "vision", "reasoning")

        Returns:
            List of models supporting the capability
        """
        if capability not in CAPABILITY_MAP:
            logger.warning(f"Unknown capability: {capability}")
            return []

        provider = self._detected_provider or self.detect_available_provider()
        if not provider:
            return []

        provider_models = self._get_provider_models(provider)
        result: list[str] = []

        # Check all quality levels
        for quality in QUALITY_TAGS:
            models = provider_models.get(quality, [])
            if isinstance(models, str):
                models = [models]

            for model in models:
                if model not in result and self._check_model_capability(
                    model, capability
                ):
                    result.append(model)

        return result

    def find_capable_models_across_providers(
        self,
        capability: str,
        prefer_provider: str | None = None,
        tier_order: list[str] | None = None,
    ) -> list[str]:
        """Find a fallback chain of capable models reachable with current credentials.

        Unlike `resolve_model("vision")` which only searches the active provider
        and silently falls back to non-capable models when that provider has
        none, this walks the priority list and returns every model whose
        catalog entry actually has the capability flag set.

        Search order:
        1. ``prefer_provider`` (if any of its models match).
        2. The user's configured priority list (``models.provider_priority``)
           or ``DEFAULT_PROVIDER_PRIORITY``.
        3. Any other provider with credentials.

        Within each provider, tiers are walked in ``tier_order`` (default:
        normal → high → low) so a solid mid-tier model wins by default,
        falling back to a stronger one and only then to the cheap tier.

        Returns:
            Ordered list of ``provider/model`` strings (deduplicated). Empty
            list if no provider with valid credentials lists a capable model.
            Callers should iterate and try each on failure (e.g. when a
            provider returns 429 / auth errors and we need to fall through
            to a different provider).
        """
        if capability not in CAPABILITY_MAP:
            logger.warning(f"Unknown capability: {capability}")
            return []

        tier_order = tier_order or ["normal", "high", "low"]
        available = self._get_available_providers()
        if not available:
            return []

        priority = list(self.settings.get(
            "models.provider_priority", DEFAULT_PROVIDER_PRIORITY
        ))
        # Stable order: prefer_provider first (if set), then priority list,
        # then any other available provider not in priority.
        ordered: list[str] = []
        if prefer_provider and prefer_provider in available:
            ordered.append(prefer_provider)
        for p in priority:
            if p in available and p not in ordered:
                ordered.append(p)
        for p in available:
            if p not in ordered:
                ordered.append(p)

        chain: list[str] = []
        seen: set[str] = set()
        for provider in ordered:
            provider_models = self._get_provider_models(provider)
            if not provider_models:
                continue
            for tier in tier_order:
                models = provider_models.get(tier, [])
                if isinstance(models, str):
                    models = [models]
                for model in models:
                    if model in seen:
                        continue
                    if self._check_model_capability(model, capability):
                        chain.append(model)
                        seen.add(model)
        return chain

    def resolve_model_for_provider(self, tag: str, provider: str | None) -> list[str]:
        """Resolve tag(s) using a preferred provider when possible.

        This is used when the current dialog is already running on a concrete
        provider/model and an internal helper requests a quality tag like
        ``low`` or ``high``. In that case we should stay on the same provider
        instead of re-running global provider auto-selection and accidentally
        hopping to a different backend.
        """
        if not provider:
            return self.resolve_model(tag)

        provider_models = self._get_provider_models(provider)
        if not provider_models:
            return self.resolve_model(tag)

        tags = [t.strip().lower() for t in tag.split(",")]
        quality_tag = next((t for t in tags if t in QUALITY_TAGS), "normal")
        capability_tags = [t for t in tags if t in CAPABILITY_MAP]

        models = provider_models.get(quality_tag, [])
        if isinstance(models, str):
            models = [models]
        if not models:
            return self.resolve_model(tag)

        if not capability_tags:
            return list(models)

        result: list[str] = []
        for model in models:
            if all(
                self._check_model_capability(model, cap) for cap in capability_tags
            ):
                result.append(model)

        if result:
            return result

        return self.resolve_model(tag)

    def resolve_image_gen_model(self, quality: str = "normal") -> list[str]:
        """Resolve image generation model based on available providers.

        Args:
            quality: Quality level ("high" or "normal")

        Returns:
            List of image generation models as fallback chain
        """
        available = self._get_available_providers()
        
        # Priority order for image generation providers
        priority = ["openai", "gemini"]
        
        for provider in priority:
            if provider in available:
                user_config = self.settings.get(f"image_gen_models.{provider}", {})
                provider_models = user_config or DEFAULT_IMAGE_GEN_MODELS.get(provider, {})
                models = provider_models.get(quality, [])
                if models:
                    return models if isinstance(models, list) else [models]
        
        # Ultimate fallback
        return ["gemini/gemini-3-pro-image-preview"]

    def get_provider_info(self) -> dict:
        """Get information about current provider selection.

        Returns:
            Dict with provider info for debugging
        """
        return {
            "detected_provider": self._detected_provider
            or self.detect_available_provider(),
            "available_providers": list(self._get_available_providers()),
            "priority": self.settings.get(
                "models.provider_priority", DEFAULT_PROVIDER_PRIORITY
            ),
        }

    def list_available_models(self) -> dict:
        """List all available models grouped by provider.

        Returns models from providers that have valid API keys configured.

        Returns:
            {
                "success": True,
                "available_providers": ["openai", "anthropic"],
                "current_provider": "openai",
                "models_by_provider": {
                    "openai": ["openai/gpt-5.4", "openai/gpt-5.2", ...],
                    "anthropic": ["anthropic/claude-opus-4-5-20251101", ...]
                },
                "supported_tags": ["high", "normal", "low", "vision", ...]
            }
        """
        available_providers = list(self._get_available_providers())
        current_provider = self._detected_provider or self.detect_available_provider()

        # Collect models for each available provider
        models_by_provider: dict[str, list[str]] = {}
        saved_models = get_saved_models(self.settings)
        for provider in available_providers:
            provider_config = self._get_provider_models(provider)
            # Merge all quality levels and deduplicate while preserving order
            all_models: list[str] = []
            seen: set[str] = set()
            for quality in ["high", "normal", "low"]:
                models = provider_config.get(quality, [])
                if isinstance(models, str):
                    models = [models]
                for model in models:
                    if model not in seen:
                        all_models.append(model)
                        seen.add(model)
            for model in prefix_saved_models(provider, saved_models.get(provider, [])):
                if model not in seen:
                    all_models.append(model)
                    seen.add(model)
            models_by_provider[provider] = all_models

        # Collect supported tags
        supported_tags = list(QUALITY_TAGS) + list(CAPABILITY_MAP.keys())

        # Collect models that support reasoning/thinking
        from .provider_registry import get_model_info
        reasoning_models: list[str] = []
        for provider_models in models_by_provider.values():
            for model in provider_models:
                try:
                    info = get_model_info(model)
                except Exception:
                    info = {}
                if info.get("supports_reasoning"):
                    reasoning_models.append(model)

        return {
            "success": True,
            "available_providers": available_providers,
            "current_provider": current_provider,
            "models_by_provider": models_by_provider,
            "reasoning_models": reasoning_models,
            "supported_tags": supported_tags,
        }


# ============ Module-level Helpers ============

_selector_instance: ModelSelector | None = None


def get_model_selector() -> ModelSelector:
    """Get or create the global ModelSelector instance.

    Returns:
        ModelSelector instance
    """
    global _selector_instance

    if _selector_instance is None:
        from pantheon.settings import get_settings

        _selector_instance = ModelSelector(get_settings())

    return _selector_instance


def reset_model_selector() -> None:
    """Reset the global ModelSelector instance (for testing)."""
    global _selector_instance
    _selector_instance = None
    reset_ollama_cache()


def get_default_model() -> list[str]:
    """Convenience function to get default model fallback chain.

    Returns:
        List of models for fallback chain
    """
    return get_model_selector().get_default_model()


__all__ = [
    "ModelSelector",
    "get_model_selector",
    "reset_model_selector",
    "reset_ollama_cache",
    "fetch_ollama_status",
    "refresh_ollama_cache",
    "get_ollama_cached_state",
    "get_default_model",
    "PROVIDER_API_KEYS",
    "CAPABILITY_MAP",
    "QUALITY_TAGS",
    "DEFAULT_PROVIDER_PRIORITY",
    "DEFAULT_PROVIDER_MODELS",
    "DEFAULT_IMAGE_GEN_MODELS",
    "ULTIMATE_FALLBACK",
    "FALLBACK_TAG",
    "CUSTOM_ENDPOINT_ENVS",
    "SAVED_MODEL_PROVIDERS",
    "normalize_saved_models",
    "get_saved_models",
    "prefix_saved_models",
]
