"""Model configuration management.

Handles loading and saving model configuration from TOML files, providing a
structured way to define available models and providers.
"""

from __future__ import annotations

import importlib.util
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, TypedDict

from icarus.utils.toml import load_toml, save_toml

if TYPE_CHECKING:
    from collections.abc import Mapping

logger = logging.getLogger(__name__)


class ModelConfigError(Exception):
    """Raised when model configuration or creation fails."""


@dataclass(frozen=True)
class ModelSpec:
    """A model specification in `provider:model` format.

    Examples:
        >>> spec = ModelSpec.parse("anthropic:claude-sonnet-4-5")
        >>> spec.provider
        'anthropic'
        >>> spec.model
        'claude-sonnet-4-5'
        >>> str(spec)
        'anthropic:claude-sonnet-4-5'
    """

    provider: str
    """The provider name (e.g., `'anthropic'`, `'openai'`)."""

    model: str
    """The model identifier (e.g., `'claude-sonnet-4-5'`, `'gpt-4o'`)."""

    def __post_init__(self) -> None:
        """Validate the model spec after initialization.

        Raises:
            ValueError: If provider or model is empty.
        """
        if not self.provider:
            msg = "Provider cannot be empty"
            raise ValueError(msg)
        if not self.model:
            msg = "Model cannot be empty"
            raise ValueError(msg)

    @classmethod
    def parse(cls, spec: str) -> ModelSpec:
        """Parse a model specification string.

        Args:
            spec: Model specification in `'provider:model'` format.

        Returns:
            Parsed ModelSpec instance.

        Raises:
            ValueError: If the spec is not in valid `'provider:model'` format.
        """
        if ":" not in spec:
            msg = (
                f"Invalid model spec '{spec}': must be in provider:model format "
                "(e.g., 'anthropic:claude-sonnet-4-5')"
            )
            raise ValueError(msg)
        provider, model = spec.split(":", 1)
        return cls(provider=provider, model=model)

    @classmethod
    def try_parse(cls, spec: str) -> ModelSpec | None:
        """Try to parse a model specification, returning None on failure.

        Args:
            spec: Model specification to parse.

        Returns:
            Parsed ModelSpec if valid, None otherwise.
        """
        try:
            return cls.parse(spec)
        except ValueError:
            return None

    def __str__(self) -> str:
        """Return the model spec as a string in `provider:model` format."""
        return f"{self.provider}:{self.model}"


class ProviderConfig(TypedDict, total=False):
    """Configuration for a model provider.

    The optional `class_path` field allows bypassing `init_chat_model` entirely
    and instantiating an arbitrary `BaseChatModel` subclass via importlib.

    !!! warning

        Setting `class_path` executes arbitrary Python code from the user's
        config file. This has the same trust model as `pyproject.toml` build
        scripts — the user controls their own machine.
    """

    models: list[str]
    """List of model identifiers available from this provider."""

    api_key_env: str
    """Environment variable name containing the API key."""

    base_url: str
    """Custom base URL."""

    # Level 2: arbitrary BaseChatModel classes

    class_path: str
    """Fully-qualified Python class in `module.path:ClassName` format.

    When set, `create_model` imports this class and instantiates it directly
    instead of calling `init_chat_model`.
    """

    params: dict[str, Any]
    """Extra keyword arguments forwarded to the model constructor.

    Flat keys (e.g., `temperature = 0`) are provider-wide defaults applied to
    every model from this provider. Model-keyed sub-tables (e.g.,
    `[params."qwen3:4b"]`) override individual values for that model only;
    the merge is shallow (model wins on conflict).
    """


DEFAULT_CONFIG_DIR = Path.home() / ".icarus"
"""Directory for user-level ICARUS configuration (`~/.icarus`)."""

DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
"""Path to the user's model configuration file (`~/.icarus/config.toml`)."""

ENV_FILE = DEFAULT_CONFIG_DIR / ".env"
"""Path to the user's credential env file (`~/.icarus/.env`)."""


PROVIDER_API_KEY_ENV: dict[str, str] = {
    "anthropic": "ANTHROPIC_API_KEY",
    "azure_ai": "AZURE_INFERENCE_CREDENTIAL",
    "azure_openai": "AZURE_OPENAI_API_KEY",
    "cohere": "COHERE_API_KEY",
    "deepseek": "DEEPSEEK_API_KEY",
    "fireworks": "FIREWORKS_API_KEY",
    "google_genai": "GOOGLE_API_KEY",
    "groq": "GROQ_API_KEY",
    "huggingface": "HUGGINGFACEHUB_API_TOKEN",
    "ibm": "WATSONX_APIKEY",
    "mistralai": "MISTRAL_API_KEY",
    "nvidia": "NVIDIA_API_KEY",
    "openai": "OPENAI_API_KEY",
    "openrouter": "OPENROUTER_API_KEY",
    "perplexity": "PPLX_API_KEY",
    "together": "TOGETHER_API_KEY",
    "xai": "XAI_API_KEY",
}
"""Well-known providers mapped to the env var that holds their API key.

Used by `has_provider_credentials` to verify credentials *before* model
creation, so the UI can show a warning icon and a specific error message
(e.g., "ANTHROPIC_API_KEY not set") instead of letting the provider fail at call
time.

Providers not listed here fall through to the config-file check or the langchain
registry fallback.
"""

PROVIDERS_WITH_ENDPOINTS: frozenset[str] = frozenset({"azure_ai", "custom"})
"""Providers where each model requires an explicit endpoint URL.

The UI renders an endpoint-grouped model input instead of a flat list.
"""

KEYLESS_PROVIDERS: frozenset[str] = frozenset({"ollama"})
"""Providers that don't require an API key.

These providers are considered credentialed based on service availability
(e.g., Ollama responds to HTTP health checks) rather than env-var presence.
"""

PROVIDER_MULTI_ENV: dict[str, list[dict[str, Any]]] = {
    "google_vertexai": [
        {"name": "GOOGLE_APPLICATION_CREDENTIALS", "hint": "Path to service account JSON (or use ADC)", "required": False, "is_file_path": True},
        {"name": "GOOGLE_CLOUD_PROJECT", "hint": "GCP project ID", "required": True},
        {"name": "GOOGLE_CLOUD_LOCATION", "hint": "Region (default: us-central1)", "required": False},
    ],
    "bedrock_converse": [
        {"name": "AWS_BEARER_TOKEN_BEDROCK", "hint": "Bedrock API key", "required": True},
        {"name": "AWS_DEFAULT_REGION", "hint": "Region (default: us-east-1)", "required": False},
    ],
}
"""Providers that require multiple environment variables.

Each entry is a list of dicts:
  - name: env var name
  - hint: UI placeholder text
  - required: True if the var must be set for the provider to be credentialed
  - is_file_path: True if the UI should show a file picker (optional, default False)
"""


def _get_required_multi_env(provider: str) -> list[dict[str, Any]]:
    """Return the required env-var entries for a multi-env provider, or []."""
    multi = PROVIDER_MULTI_ENV.get(provider)
    if not multi:
        return []
    return [e for e in multi if e.get("required", True)]


# Module-level caches — cleared by `clear_caches()`.
_available_models_cache: dict[str, list[str]] | None = None
_builtin_providers_cache: dict[str, Any] | None = None
_default_config_cache: ModelConfig | None = None


def clear_caches() -> None:
    """Reset all module-level caches.

    Useful in tests and after config file changes that must take effect
    immediately within the same process.
    """
    global _available_models_cache, _builtin_providers_cache, _default_config_cache  # noqa: PLW0603
    _available_models_cache = None
    _builtin_providers_cache = None
    _default_config_cache = None


def _get_builtin_providers() -> dict[str, Any]:
    """Return langchain's built-in provider registry.

    Tries the newer `_BUILTIN_PROVIDERS` name first, then falls back to
    the legacy `_SUPPORTED_PROVIDERS` for older langchain versions.

    Results are cached after the first call; use `clear_caches()` to reset.

    Returns:
        The provider registry dict from `langchain.chat_models.base`.
    """
    global _builtin_providers_cache  # noqa: PLW0603  # Module-level cache requires global statement
    if _builtin_providers_cache is not None:
        return _builtin_providers_cache

    # Deferred: langchain.chat_models pulls in heavy provider registry,
    # only needed when resolving provider names for model config.
    from langchain.chat_models import base

    registry: dict[str, Any] | None = getattr(base, "_BUILTIN_PROVIDERS", None)
    if registry is None:
        registry = getattr(base, "_SUPPORTED_PROVIDERS", None)
    _builtin_providers_cache = registry if registry is not None else {}
    return _builtin_providers_cache


def _get_provider_profile_modules() -> list[tuple[str, str]]:
    """Build a `(provider, profile_module)` list from langchain's provider registry.

    Reads the built-in provider registry from `langchain.chat_models.base`
    to discover every provider that `init_chat_model` knows about, then derives
    the `<package>.data._profiles` module path for each.

    Returns:
        List of `(provider_name, profile_module_path)` tuples.
    """
    providers = _get_builtin_providers()

    result: list[tuple[str, str]] = []
    seen: set[tuple[str, str]] = set()

    for provider_name, (module_path, *_rest) in providers.items():
        package_root = module_path.split(".", maxsplit=1)[0]
        profile_module = f"{package_root}.data._profiles"
        key = (provider_name, profile_module)
        if key not in seen:
            seen.add(key)
            result.append((provider_name, profile_module))

    return result


def _load_provider_profiles(module_path: str) -> dict[str, Any]:
    """Load `_PROFILES` from a provider's data module.

    Tries a direct ``importlib.import_module`` first (works in PyInstaller
    bundles where hidden imports are declared), then falls back to
    file-based loading via ``find_spec`` for editable/source installs.

    Args:
        module_path: Dotted module path (e.g., `"langchain_openai.data._profiles"`).

    Returns:
        The `_PROFILES` dictionary from the module, or an empty dict if
            the module has no such attribute.

    Raises:
        ImportError: If the package is not installed or the profile module
            cannot be found on disk.
    """
    # Fast path: direct import works in both normal installs and PyInstaller
    # frozen bundles (provided the package is in hiddenimports).
    try:
        module = importlib.import_module(module_path)
        return getattr(module, "_PROFILES", {})
    except ImportError:
        pass

    # Fallback: file-based loading for editable installs where the data
    # subpackage isn't importable but the .py file exists on disk.
    parts = module_path.split(".")
    package_root = parts[0]

    spec = importlib.util.find_spec(package_root)
    if spec is None:
        msg = f"Package {package_root} is not installed"
        raise ImportError(msg)

    if spec.origin:
        package_dir = Path(spec.origin).parent
    elif spec.submodule_search_locations:
        package_dir = Path(next(iter(spec.submodule_search_locations)))
    else:
        msg = f"Cannot determine location for {package_root}"
        raise ImportError(msg)

    relative_parts = parts[1:]
    profiles_path = package_dir.joinpath(
        *relative_parts[:-1], f"{relative_parts[-1]}.py"
    )

    if not profiles_path.exists():
        msg = f"Profile module not found: {profiles_path}"
        raise ImportError(msg)

    file_spec = importlib.util.spec_from_file_location(module_path, profiles_path)
    if file_spec is None or file_spec.loader is None:
        msg = f"Could not create module spec for {profiles_path}"
        raise ImportError(msg)

    module = importlib.util.module_from_spec(file_spec)
    file_spec.loader.exec_module(module)
    return getattr(module, "_PROFILES", {})


def get_available_models() -> dict[str, list[str]]:
    """Get available models from config files and credentialed providers.

    Returns models from two sources:

    1. **Config-defined providers** (``~/.icarus/config.toml``)
       — always included, since the user explicitly configured them.
    2. **Installed langchain providers** — only included when the provider
       has confirmed credentials (env var set).  Providers without
       credentials are omitted to avoid flooding the list with unusable
       models.

    Results are cached after the first call; use `clear_caches()` to reset.

    Returns:
        Dictionary mapping provider names to lists of model identifiers.
    """
    global _available_models_cache  # noqa: PLW0603  # Module-level cache requires global statement
    if _available_models_cache is not None:
        return _available_models_cache

    available: dict[str, list[str]] = {}

    # Start with config-defined providers — only if they have credentials.
    # Keyless providers (e.g., Ollama) are skipped here and handled below
    # with live discovery so unreachable servers don't produce ghost models.
    config = ModelConfig.load()
    # Track providers the user has explicitly configured with models AND
    # credentials.  Only these skip the SDK auto-list — so deleting a key
    # and re-adding it falls back to showing all SDK models again.
    configured_providers: set[str] = set()
    for provider_name, provider_config in config.providers.items():
        if provider_name in KEYLESS_PROVIDERS:
            continue
        config_models = provider_config.get("models", [])
        has_creds = _has_env_credentials(provider_name)
        if config_models and has_creds:
            available[provider_name] = list(config_models)
            configured_providers.add(provider_name)
        elif not config_models and has_creds:
            # User has credentials but explicitly selected no models — respect that.
            configured_providers.add(provider_name)

    # Add models from installed langchain providers — only if credentialed
    # and the user hasn't already configured them in config.toml.
    provider_modules = _get_provider_profile_modules()

    for provider, module_path in provider_modules:
        # Skip providers without credentials
        if not _has_env_credentials(provider):
            continue
        # User explicitly configured this provider — respect their choice
        if provider in configured_providers:
            continue

        try:
            profiles = _load_provider_profiles(module_path)
        except ImportError:
            logger.debug(
                "Could not import profiles from %s (package may not be installed)",
                module_path,
            )
            continue
        except Exception:
            logger.warning(
                "Failed to load profiles from %s, skipping provider '%s'",
                module_path,
                provider,
                exc_info=True,
            )
            continue

        # Filter to models that support tool calling and text I/O.
        models = [
            name
            for name, profile in profiles.items()
            if profile.get("tool_calling", False)
            and profile.get("text_inputs", True) is not False
            and profile.get("text_outputs", True) is not False
        ]

        models.sort()
        if not models:
            continue

        # Provider not in config — show all SDK models as available
        available[provider] = models

    # Keyless providers: only list models that actually exist on the
    # running server.  If the server is unreachable, show nothing —
    # prevents ghost models from appearing in the picker.
    from icarus.config.ollama import discover_ollama_models, get_ollama_base_url

    discovered = discover_ollama_models(get_ollama_base_url())
    if discovered:
        live_names = {m["name"] for m in discovered}
        ollama_config = config.providers.get("ollama")
        if ollama_config is not None:
            # User has configured Ollama — respect their model selection.
            # Empty list = user explicitly disabled all models.
            config_models = ollama_config.get("models", [])
            available["ollama"] = [m for m in config_models if m in live_names]
        else:
            # Ollama not configured yet — show all discovered models
            available["ollama"] = sorted(live_names)

    _available_models_cache = available
    return available


def _has_env_credentials(provider: str) -> bool:
    """Check if a provider's credential env vars are set and non-empty.

    Uses ``get_credential_env_var`` to resolve the env var name (config
    override → hardcoded map), then checks the environment.

    Keyless providers (e.g., Ollama) always return True — they are
    credentialed by service availability, not env vars.

    Multi-env providers (e.g., Vertex AI, Bedrock) require all *required*
    env vars to be set.

    Args:
        provider: Provider name.

    Returns:
        True if the env var exists and is non-empty, False otherwise.
    """
    if provider in KEYLESS_PROVIDERS:
        return True

    required = _get_required_multi_env(provider)
    if required:
        return all(bool(os.environ.get(e["name"])) for e in required)

    env_var = get_credential_env_var(provider)
    if not env_var:
        return False
    return bool(os.environ.get(env_var))


def _is_langchain_supported_provider(provider: str) -> bool:
    """Check if a provider is in langchain's built-in provider registry.

    Args:
        provider: Provider name to check.

    Returns:
        True if the provider is known to `init_chat_model`.
    """
    return provider in _get_builtin_providers()


def has_provider_credentials(provider: str) -> bool | None:
    """Check if credentials are available for a provider.

    Checks in order:

    1. Keyless providers (e.g., Ollama) — always considered credentialed.
    2. Config-file providers (`config.toml`) — takes priority so user
        overrides (e.g., custom `api_key_env` or `base_url`) are respected.
    3. Hardcoded `PROVIDER_API_KEY_ENV` mapping (anthropic, openai, etc.).
    4. Langchain's `_SUPPORTED_PROVIDERS` registry — if the provider is known
        to `init_chat_model`, credential status is unknown; the provider
        itself will report auth failures at model-creation time.

    Args:
        provider: Provider name.

    Returns:
        True if credentials are confirmed available, False if confirmed
            missing, or None if credential status cannot be determined.
    """
    if provider in KEYLESS_PROVIDERS:
        return True

    # Config-file providers take priority when api_key_env is specified.
    config = ModelConfig.load()
    if config.providers.get(provider):
        result = config.has_credentials(provider)
        if result is not None:
            return result
        # No api_key_env in config — fall through to hardcoded map.

    # Fall back to hardcoded well-known providers.
    env_var = PROVIDER_API_KEY_ENV.get(provider)
    if env_var:
        return bool(os.environ.get(env_var))

    # Multi-env providers
    if provider in PROVIDER_MULTI_ENV:
        return _has_env_credentials(provider)

    # Custom (OpenAI-compatible) — credentials are optional and vary per
    # endpoint (local servers need none, remote APIs may need a key).
    # Let it through; the endpoint will reject bad auth at call time.
    if provider == "custom":
        return True

    # If langchain knows this provider, let it through — we can't verify
    # credentials, but the provider will raise its own auth error if needed.
    if _is_langchain_supported_provider(provider):
        return None

    return False


def get_credential_env_var(provider: str) -> str | None:
    """Return the env var name that holds credentials for a provider.

    Checks the config file first (user override), then falls back to the
    hardcoded `PROVIDER_API_KEY_ENV` map.

    Args:
        provider: Provider name.

    Returns:
        Environment variable name, or None if unknown.
    """
    config = ModelConfig.load()
    config_env = config.get_api_key_env(provider)
    if config_env:
        return config_env
    # Multi-env providers: return the first required var name for error messages.
    required = _get_required_multi_env(provider)
    if required:
        return required[0]["name"]
    return PROVIDER_API_KEY_ENV.get(provider)


@dataclass(frozen=True)
class ModelConfig:
    """Parsed model configuration from `config.toml`.

    Instances are immutable once constructed. The `providers` mapping is
    wrapped in `MappingProxyType` to prevent accidental mutation of the
    globally cached singleton returned by `load()`.
    """

    default_model: str | None = None
    """The user's intentional default model (from config file `[models].default`)."""

    recent_model: str | None = None
    """The most recently switched-to model (from config file `[models].recent`)."""

    providers: Mapping[str, ProviderConfig] = field(default_factory=dict)
    """Read-only mapping of provider names to their configurations."""

    def __post_init__(self) -> None:
        """Freeze the providers dict into a read-only proxy."""
        if not isinstance(self.providers, MappingProxyType):
            object.__setattr__(self, "providers", MappingProxyType(self.providers))

    @classmethod
    def load(cls, config_path: Path | None = None) -> ModelConfig:
        """Load config from file.

        When called with the default path, results are cached for the
        lifetime of the process. Use `clear_caches()` to reset.

        Args:
            config_path: Path to config file. Defaults to ~/.icarus/config.toml.

        Returns:
            Parsed `ModelConfig` instance.
                Returns empty config if file is missing, unreadable, or contains
                invalid TOML syntax.
        """
        global _default_config_cache  # noqa: PLW0603  # Module-level cache requires global statement
        is_default = config_path is None
        if is_default and _default_config_cache is not None:
            return _default_config_cache

        if config_path is None:
            config_path = DEFAULT_CONFIG_PATH

        data = load_toml(config_path)
        models_section = data.get("models", {})
        config = cls(
            default_model=models_section.get("default"),
            recent_model=models_section.get("recent"),
            providers=models_section.get("providers", {}),
        )

        # Validate config consistency
        config._validate()

        if is_default:
            _default_config_cache = config

        return config

    def _validate(self) -> None:
        """Validate internal consistency of the config.

        Issues warnings for invalid configurations but does not raise exceptions,
        allowing the app to continue with potentially degraded functionality.
        """
        # Warn if default_model is set but doesn't use provider:model format
        if self.default_model and ":" not in self.default_model:
            logger.warning(
                "default_model '%s' should use provider:model format "
                "(e.g., 'anthropic:claude-sonnet-4-5')",
                self.default_model,
            )

        # Warn if recent_model is set but doesn't use provider:model format
        if self.recent_model and ":" not in self.recent_model:
            logger.warning(
                "recent_model '%s' should use provider:model format "
                "(e.g., 'anthropic:claude-sonnet-4-5')",
                self.recent_model,
            )

        # Validate class_path format and params references
        for name, provider in self.providers.items():
            class_path = provider.get("class_path")
            if class_path and ":" not in class_path:
                logger.warning(
                    "Provider '%s' has invalid class_path '%s': "
                    "must be in module.path:ClassName format "
                    "(e.g., 'my_package.models:MyChatModel')",
                    name,
                    class_path,
                )

            params = provider.get("params", {})
            models = set(provider.get("models", []))
            for key, value in params.items():
                if isinstance(value, dict) and key not in models:
                    logger.debug(
                        "Provider '%s' has params for '%s' "
                        "which is not in its models list",
                        name,
                        key,
                    )

    def get_all_models(self) -> list[tuple[str, str]]:
        """Get all models as `(model_name, provider_name)` tuples.

        Returns:
            List of tuples containing `(model_name, provider_name)`.
        """
        return [
            (model, provider_name)
            for provider_name, provider_config in self.providers.items()
            for model in provider_config.get("models", [])
        ]

    def get_provider_for_model(self, model_name: str) -> str | None:
        """Find the provider that contains this model.

        Args:
            model_name: The model identifier to look up.

        Returns:
            Provider name if found, None otherwise.
        """
        for provider_name, provider_config in self.providers.items():
            if model_name in provider_config.get("models", []):
                return provider_name
        return None

    def has_credentials(self, provider_name: str) -> bool | None:
        """Check if credentials are available for a provider.

        This is the config-file-driven credential check, supporting custom
        providers (e.g., local Ollama with no key required). For the hardcoded
        `PROVIDER_API_KEY_ENV`-based check used in the hot-swap path, see the
        module-level `has_provider_credentials()`.

        Args:
            provider_name: The provider to check.

        Returns:
            True if credentials are confirmed available, False if confirmed
                missing, or None if no `api_key_env` is configured and
                credential status cannot be determined.
        """
        provider = self.providers.get(provider_name)
        if not provider:
            return False
        env_var = provider.get("api_key_env")
        if not env_var:
            return None  # No key configured — can't verify
        return bool(os.environ.get(env_var))

    def get_base_url(self, provider_name: str) -> str | None:
        """Get custom base URL.

        Args:
            provider_name: The provider to get base URL for.

        Returns:
            Base URL if configured, None otherwise.
        """
        provider = self.providers.get(provider_name)
        return provider.get("base_url") if provider else None

    def get_api_key_env(self, provider_name: str) -> str | None:
        """Get the environment variable name for a provider's API key.

        Args:
            provider_name: The provider to get API key env var for.

        Returns:
            Environment variable name if configured, None otherwise.
        """
        provider = self.providers.get(provider_name)
        return provider.get("api_key_env") if provider else None

    def get_class_path(self, provider_name: str) -> str | None:
        """Get the custom class path for a provider.

        Args:
            provider_name: The provider to look up.

        Returns:
            Class path in `module.path:ClassName` format, or None.
        """
        provider = self.providers.get(provider_name)
        return provider.get("class_path") if provider else None

    def get_kwargs(
        self, provider_name: str, *, model_name: str | None = None
    ) -> dict[str, Any]:
        """Get extra constructor kwargs for a provider.

        Reads the `params` table from the provider config. Flat keys are
        provider-wide defaults; model-keyed sub-tables are per-model
        overrides that shallow-merge on top (model wins on conflict).

        Args:
            provider_name: The provider to look up.
            model_name: Optional model name for per-model overrides.

        Returns:
            Dictionary of extra kwargs (empty if none configured).
        """
        provider = self.providers.get(provider_name)
        if not provider:
            return {}
        params = provider.get("params", {})
        # Flat keys are provider-wide defaults; dict values are per-model overrides
        result = {k: v for k, v in params.items() if not isinstance(v, dict)}
        if model_name:
            overrides = params.get(model_name)
            if isinstance(overrides, dict):
                result.update(overrides)
        return result


def _save_model_field(
    field: str, model_spec: str, config_path: Path | None = None
) -> bool:
    """Read-modify-write a `[models].<field>` key in the config file.

    Args:
        field: Key name under the `[models]` table (e.g., `'default'` or `'recent'`).
        model_spec: The model to save in `provider:model` format.
        config_path: Path to config file.

            Defaults to `~/.icarus/config.toml`.

    Returns:
        True if save succeeded, False if it failed due to I/O errors.
    """
    if config_path is None:
        config_path = DEFAULT_CONFIG_PATH

    try:
        data = load_toml(config_path)
        data.setdefault("models", {})[field] = model_spec
        save_toml(config_path, data)
    except OSError:
        logger.exception("Could not save %s model preference", field)
        return False
    else:
        # Invalidate config cache so the next load() picks up the change.
        global _default_config_cache  # noqa: PLW0603  # Module-level cache requires global statement
        _default_config_cache = None
        return True


def save_default_model(model_spec: str, config_path: Path | None = None) -> bool:
    """Update the default model in config file.

    Reads existing config (if any), updates `[models].default`, and writes
    back using proper TOML serialization.

    Args:
        model_spec: The model to set as default in `provider:model` format.
        config_path: Path to config file.

            Defaults to `~/.icarus/config.toml`.

    Returns:
        True if save succeeded, False if it failed due to I/O errors.

    Note:
        This function does not preserve comments in the config file.
    """
    return _save_model_field("default", model_spec, config_path)


def clear_default_model(config_path: Path | None = None) -> bool:
    """Remove the default model from the config file.

    Deletes the `[models].default` key so that future launches fall back to
    `[models].recent` or environment auto-detection.

    Args:
        config_path: Path to config file.

            Defaults to `~/.icarus/config.toml`.

    Returns:
        True if the key was removed (or was already absent), False on I/O error.
    """
    if config_path is None:
        config_path = DEFAULT_CONFIG_PATH

    try:
        data = load_toml(config_path)
        models_section = data.get("models")
        if not isinstance(models_section, dict) or "default" not in models_section:
            return True  # Already absent

        del models_section["default"]
        save_toml(config_path, data)
    except OSError:
        logger.exception("Could not clear default model preference")
        return False
    else:
        global _default_config_cache  # noqa: PLW0603  # Module-level cache requires global statement
        _default_config_cache = None
        return True


def save_recent_model(model_spec: str, config_path: Path | None = None) -> bool:
    """Update the recently used model in config file.

    Writes to `[models].recent` instead of `[models].default`, so that `/model`
    switches do not overwrite the user's intentional default.

    Args:
        model_spec: The model to save in `provider:model` format.
        config_path: Path to config file.

            Defaults to `~/.icarus/config.toml`.

    Returns:
        True if save succeeded, False if it failed due to I/O errors.

    Note:
        This function does not preserve comments in the config file.
    """
    return _save_model_field("recent", model_spec, config_path)


# ---------------------------------------------------------------------------
# Provider registry — unified read/write for any UI (desktop, TUI, mobile)
# ---------------------------------------------------------------------------


def _mask_value(val: str) -> str | None:
    """Mask a credential value for safe display."""
    if not val:
        return None
    return (val[:4] + "••••" + val[-4:]) if len(val) > 12 else "••••"


def _collect_env_vars(name: str, prov: ProviderConfig) -> list[dict[str, Any]]:
    """Extract env var requirements from a provider config entry."""
    env_vars: list[dict[str, Any]] = []
    seen: set[str] = set()

    # Multi-env providers (Vertex AI, Bedrock) — return all declared vars.
    multi = PROVIDER_MULTI_ENV.get(name)
    if multi:
        for entry in multi:
            env_name = entry["name"]
            seen.add(env_name)
            val = os.environ.get(env_name, "")
            env_vars.append({
                "name": env_name,
                "is_set": bool(val),
                "masked": _mask_value(val),
                "hint": entry.get("hint", ""),
                "is_file_path": entry.get("is_file_path", False),
                "required": entry.get("required", True),
            })
        return env_vars

    # Single-env providers — existing behavior.
    api_key = prov.get("api_key_env") or PROVIDER_API_KEY_ENV.get(name)
    if api_key:
        seen.add(api_key)
        val = os.environ.get(api_key, "")
        env_vars.append({"name": api_key, "is_set": bool(val), "masked": _mask_value(val)})

    # Extra env vars referenced in params (e.g., $CUSTOM_API_KEY).
    for v in prov.get("params", {}).values():
        if isinstance(v, str) and v.startswith("$") and v[1:] not in seen:
            env_name = v[1:]
            seen.add(env_name)
            val = os.environ.get(env_name, "")
            env_vars.append({"name": env_name, "is_set": bool(val), "masked": _mask_value(val)})

    return env_vars


def _build_keyless_entry(config: ModelConfig) -> dict[str, Any]:
    """Build registry fields for a keyless provider (e.g., Ollama).

    Calls ``discover_ollama_models`` which returns ``[]`` on any error,
    so a separate health-check round-trip is unnecessary.
    """
    from icarus.config.ollama import discover_ollama_models, get_ollama_base_url

    base_url = get_ollama_base_url()
    discovered = discover_ollama_models(base_url)
    return {
        "env_vars": [],
        "available_models": [m["name"] for m in discovered],
        "is_keyless": True,
        "is_available": len(discovered) > 0,
        "base_url": base_url,
    }


def get_provider_registry() -> list[dict[str, Any]]:
    """Return all providers with credential status, configured and SDK models.

    Merges config.toml providers with well-known providers from
    ``PROVIDER_API_KEY_ENV``.  Reusable by sidecar, TUI, and future UIs.

    Each entry includes ``available_models`` — tool-capable models from the
    provider's SDK profile (empty for providers without ``_PROFILES``).
    """
    config = ModelConfig.load()
    seen: set[str] = set()
    registry: list[dict[str, Any]] = []

    # Pre-load SDK model lists (tool-capable only)
    sdk: dict[str, list[str]] = {}
    for provider, module_path in _get_provider_profile_modules():
        try:
            profiles = _load_provider_profiles(module_path)
        except Exception:  # noqa: BLE001
            continue
        models = sorted(
            name for name, p in profiles.items()
            if p.get("tool_calling")
            and p.get("text_inputs", True) is not False
            and p.get("text_outputs", True) is not False
        )
        if models:
            sdk[provider] = models

    for name, prov in config.providers.items():
        seen.add(name)
        entry: dict[str, Any] = {
            "name": name,
            "env_vars": _collect_env_vars(name, prov),
            "models": list(prov.get("models", [])),
            "available_models": sdk.get(name, []),
        }
        if name in PROVIDERS_WITH_ENDPOINTS:
            entry["has_endpoints"] = True
            params = prov.get("params", {})
            entry["model_endpoints"] = {
                m: params[m]["endpoint"]
                for m in prov.get("models", [])
                if isinstance(params.get(m), dict) and "endpoint" in params.get(m, {})
            }
        if name in KEYLESS_PROVIDERS:
            entry.update(_build_keyless_entry(config))
        registry.append(entry)

    for name, env_var in PROVIDER_API_KEY_ENV.items():
        if name not in seen:
            val = os.environ.get(env_var, "")
            entry: dict[str, Any] = {
                "name": name,
                "env_vars": [{"name": env_var, "is_set": bool(val), "masked": _mask_value(val)}],
                "models": [],
                "available_models": sdk.get(name, []),
            }
            if name in PROVIDERS_WITH_ENDPOINTS:
                entry["has_endpoints"] = True
                entry["model_endpoints"] = {}
            registry.append(entry)

    # Multi-env providers not yet seen (Vertex AI, Bedrock)
    for name in PROVIDER_MULTI_ENV:
        if name in seen:
            continue
        seen.add(name)
        entry: dict[str, Any] = {
            "name": name,
            "env_vars": _collect_env_vars(name, {}),
            "models": [],
            "available_models": sdk.get(name, []),
        }
        if name in PROVIDERS_WITH_ENDPOINTS:
            entry["has_endpoints"] = True
            entry["model_endpoints"] = {}
        registry.append(entry)

    # Custom (OpenAI-compatible) — always present so the UI card is clickable.
    if "custom" not in seen:
        registry.append({
            "name": "custom",
            "env_vars": [],
            "models": [],
            "available_models": [],
            "has_endpoints": True,
            "model_endpoints": {},
        })

    # Ollama — keyless, auto-discovered
    if "ollama" not in seen:
        ollama_entry = _build_keyless_entry(config)
        ollama_entry["name"] = "ollama"
        ollama_entry["models"] = list(
            config.providers.get("ollama", {}).get("models", []),
        )
        registry.append(ollama_entry)

    val = os.environ.get("TAVILY_API_KEY", "")
    registry.append({
        "name": "tavily",
        "env_vars": [{"name": "TAVILY_API_KEY", "is_set": bool(val), "masked": _mask_value(val)}],
        "models": [],
        "available_models": [],
    })

    return registry


def save_credentials(
    credentials: dict[str, str],
    provider_models: dict[str, list[str]] | None = None,
    provider_endpoints: dict[str, dict[str, str]] | None = None,
    env_path: Path | None = None,
) -> None:
    """Save credentials to env file and model lists to config.toml.

    Args:
        credentials: Env var name → value. Empty value deletes the key.
        provider_models: Provider name → list of model names.
        provider_endpoints: Provider name → {model_name: endpoint_url}.
            Used by endpoint-grouped providers like ``azure_ai``.
        env_path: Path to env file. Defaults to ``~/.icarus/.env``.

    Reusable by sidecar, TUI, server, and future UIs.
    """
    import dotenv

    target = env_path or ENV_FILE
    target.parent.mkdir(parents=True, exist_ok=True)
    if not target.exists():
        target.touch()

    for key, value in credentials.items():
        value = value.strip()
        if value:
            dotenv.set_key(str(target), key, value)
            os.environ[key] = value
        else:
            dotenv.set_key(str(target), key, "")
            os.environ.pop(key, None)

    if provider_models:
        for name, models in provider_models.items():
            # Keyless providers use base_url, not per-model endpoints —
            # skip endpoints pass-through to avoid corrupting params.
            endpoints = None if name in KEYLESS_PROVIDERS else (provider_endpoints or {}).get(name)
            save_provider_config(name, models=models, endpoints=endpoints)

    # Persist provider-level base_url for keyless providers (e.g., Ollama).
    if provider_endpoints:
        for provider, endpoints in provider_endpoints.items():
            if provider in KEYLESS_PROVIDERS and "base_url" in endpoints:
                save_provider_config(provider, base_url=endpoints["base_url"])

    clear_caches()


def save_provider_config(
    provider_name: str,
    *,
    models: list[str] | None = None,
    endpoints: dict[str, str] | None = None,
    base_url: str | None = None,
    config_path: Path | None = None,
) -> bool:
    """Update a provider's model list and per-model endpoints in config.toml.

    Preserves all existing provider fields (class_path, params, etc.).

    Args:
        provider_name: Provider name (e.g., ``"azure_ai"``).
        models: List of model names to save.
        endpoints: Model name → endpoint URL mapping. Stored as per-model
            param overrides in ``[params."model-name"]``.
        base_url: Provider-level base URL (e.g., Ollama server address).
        config_path: Path to config file. Defaults to ``~/.icarus/config.toml``.
    """
    if config_path is None:
        config_path = DEFAULT_CONFIG_PATH

    try:
        data: dict[str, Any] = load_toml(config_path)

        provider = (
            data.setdefault("models", {})
            .setdefault("providers", {})
            .setdefault(provider_name, {})
        )
        if models is not None:
            provider["models"] = models

            # Clean per-model param overrides for removed models — the
            # credentials screen is a real-time config editor, so removing
            # a model must not leave orphaned params behind.
            existing_params = provider.get("params", {})
            current_models = set(models)
            for key in list(existing_params):
                if isinstance(existing_params.get(key), dict) and key not in current_models:
                    del existing_params[key]
            if not existing_params:
                provider.pop("params", None)

        if base_url is not None:
            provider["base_url"] = base_url

        if endpoints:
            ep_params = provider.setdefault("params", {})
            # Set per-model endpoint overrides
            for model_name, endpoint_url in endpoints.items():
                if endpoint_url:
                    ep_params.setdefault(model_name, {})["endpoint"] = endpoint_url

        save_toml(config_path, data)
    except OSError:
        logger.exception("Could not save provider config for %s", provider_name)
        return False
    else:
        global _default_config_cache  # noqa: PLW0603
        _default_config_cache = None
        return True
