# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
ProviderManager — extracted from agent.py (PR 2).

Handles LLM provider lifecycle:
- Provider initialization with fallback cascade
- Model router setup for multi-LLM orchestration
- Provider selection per request (auto/manual routing, PHI guard)
- Provider fallback on failure
- Routing configuration (get/set)
"""

import logging
import os
import threading

from . import defaults
from .caching import LRUCache
from .providers import (
    LLMProvider,
    get_best_ollama_model,
    get_provider,
    invalidate_provider_cache,
)
from .resilience import ProviderCapacityTracker

try:
    from .providers import OllamaProvider as _OllamaProvider
except ImportError:
    _OllamaProvider = None

logger = logging.getLogger(__name__)

_PRESETS_PATH = None  # set lazily
_PRESETS_LOCK = threading.Lock()


def _get_presets_path():
    global _PRESETS_PATH
    if _PRESETS_PATH is None:
        from .paths import DATA_DIR

        _PRESETS_PATH = DATA_DIR / "routing_presets.json"
    return _PRESETS_PATH


def _load_presets() -> dict:
    """Load presets from disk, or return default structure."""
    path = _get_presets_path()
    with _PRESETS_LOCK:
        if path.exists():
            import json

            with open(path) as f:
                return json.load(f)
    return _default_presets()


def _save_presets(data: dict):
    """Atomic write presets to disk (lock-protected against concurrent saves)."""
    import json

    path = _get_presets_path()
    with _PRESETS_LOCK:
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".tmp")
        with open(tmp, "w") as f:
            json.dump(data, f, indent=2)
        tmp.replace(path)


def _default_presets() -> dict:
    return {
        "version": 1,
        "active_preset": None,
        "presets": [
            {"slot": i, "name": f"Preset {i}", "mode": None, "routes": None} for i in range(1, 6)
        ],
    }


def _resolve_routed_provider(route_model: str, config) -> "LLMProvider | None":
    """Resolve a routed model name (e.g. 'ollama/qwen2.5:0.5b') to a provider.

    Handles the 'provider/model' format that manual routing uses, creating
    an OllamaProvider with the exact model name instead of falling back to
    the default Ollama model from config.
    """
    # Try the PROVIDERS registry first (works for 'claude-sonnet', 'gpt-4o', etc.)
    try:
        return get_provider(route_model, config)
    except (ValueError, KeyError):
        pass

    # Parse 'ollama/model:tag' format
    if "/" in route_model:
        provider_key, model_name = route_model.split("/", 1)
        if provider_key == "ollama" and _OllamaProvider:
            base_url = getattr(config.llm, "ollama_base_url", "http://localhost:11434")
            return _OllamaProvider(model_name, base_url)
        # Try cloud provider with specific model
        try:
            return get_provider(provider_key, config)
        except (ValueError, KeyError):
            pass

    return None


class ProviderManager:
    """Manages LLM provider initialization, routing, selection, and fallback."""

    # Local-first defaults: use Ollama models by default so new users
    # don't burn cloud API credits before explicitly opting in.
    _DEFAULT_MANUAL_ROUTES = {
        "coding": "ollama/llama3.2",
        "complex": "ollama/llama3.2",
        "creative": "ollama/qwen2.5:7b",
        "long": "ollama/llama3.2",
        "moderate": "ollama/qwen2.5:7b",
        "private": "ollama/llama3.2",
        "simple": "ollama/qwen2.5:7b",
    }

    def __init__(self, agent_ref):
        self._agent = agent_ref
        self.model_router = None
        self._routing_mode = "manual"
        self._manual_routes = dict(self._DEFAULT_MANUAL_ROUTES)
        self.capacity_tracker = ProviderCapacityTracker()
        self._cached_ollama_models: "list[str] | None" = None
        # Response cache — deduplicates identical requests within 5 min window
        self._response_cache: LRUCache = LRUCache(max_size=200, ttl_seconds=300)

    def init_provider(self) -> LLMProvider:
        """Initialize the LLM provider.

        Checks both API key presence AND package availability
        before selecting a provider. Falls through gracefully:
        anthropic → openai → ollama (native, zero deps).
        """
        config = self._agent.config
        provider_name = config.llm.default_provider

        has_anthropic_key = bool(os.environ.get("ANTHROPIC_API_KEY"))
        has_openai_key = bool(os.environ.get("OPENAI_API_KEY"))
        has_gemini_key = bool(os.environ.get("GEMINI_API_KEY"))

        try:
            import anthropic  # noqa: F401

            has_anthropic_pkg = True
        except ImportError:
            has_anthropic_pkg = False

        try:
            import openai  # noqa: F401

            has_openai_pkg = True
        except ImportError:
            has_openai_pkg = False

        try:
            from google import genai  # noqa: F401

            has_gemini_pkg = True
        except ImportError:
            has_gemini_pkg = False

        has_anthropic = has_anthropic_key and has_anthropic_pkg
        has_openai = has_openai_key and has_openai_pkg
        has_gemini = has_gemini_key and has_gemini_pkg

        if has_anthropic_key and not has_anthropic_pkg:
            logger.warning("ANTHROPIC_API_KEY is set but 'anthropic' package not installed.")
        if has_openai_key and not has_openai_pkg:
            logger.warning("OPENAI_API_KEY is set but 'openai' package not installed.")
        if has_gemini_key and not has_gemini_pkg:
            logger.warning("GEMINI_API_KEY is set but 'google-genai' package not installed.")

        # Fallback logic: configured provider → cloud alternatives → ollama
        if provider_name in ["anthropic", "claude"] and not has_anthropic:
            if has_gemini:
                provider_name = "gemini"
                logger.warning("Anthropic unavailable, falling back to Gemini")
            elif has_openai:
                provider_name = "openai"
                logger.warning("Anthropic unavailable, falling back to OpenAI")
            else:
                provider_name = "ollama"
                logger.warning("No cloud providers available, falling back to Ollama (local)")

        if provider_name in ["openai", "gpt"] and not has_openai:
            if has_gemini:
                provider_name = "gemini"
                logger.warning("OpenAI unavailable, falling back to Gemini")
            elif has_anthropic:
                provider_name = "anthropic"
                logger.warning("OpenAI unavailable, falling back to Anthropic")
            else:
                provider_name = "ollama"
                logger.warning("No cloud providers available, falling back to Ollama (local)")

        if provider_name in ["gemini", "google"] and not has_gemini:
            if has_anthropic:
                provider_name = "anthropic"
                logger.warning("Gemini unavailable, falling back to Anthropic")
            elif has_openai:
                provider_name = "openai"
                logger.warning("Gemini unavailable, falling back to OpenAI")
            else:
                provider_name = "ollama"
                logger.warning("No cloud providers available, falling back to Ollama (local)")

        provider = get_provider(provider_name, config)
        self._validate_connectivity(provider)
        return provider

    def _validate_connectivity(self, provider: LLMProvider) -> None:
        """Warn-only connectivity check after provider init. Never raises."""
        try:
            key = provider.provider_key
            if key == "ollama":
                import urllib.request

                req = urllib.request.Request(f"{provider.base_url}/api/tags", method="GET")
                urllib.request.urlopen(req, timeout=defaults.OLLAMA_HEALTH_TIMEOUT)
            elif key == "anthropic":
                api_key = os.environ.get("ANTHROPIC_API_KEY", "")
                if not api_key.startswith("sk-ant-"):
                    logger.warning(
                        "ANTHROPIC_API_KEY does not start with 'sk-ant-' — may be invalid"
                    )
            elif key == "openai":
                api_key = os.environ.get("OPENAI_API_KEY", "")
                if not api_key.startswith("sk-"):
                    logger.warning("OPENAI_API_KEY does not start with 'sk-' — may be invalid")
            elif key == "gemini":
                api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY", "")
                if len(api_key) < 10:
                    logger.warning("GEMINI_API_KEY appears too short — may be invalid")
        except Exception as e:
            logger.warning(f"Connectivity check for {provider.name} failed: {e}")

    def init_model_router(self):
        """Initialize multi-LLM model router if multiple providers are available."""
        config = self._agent.config
        try:
            from .model_router import ModelRouter, RoutingStrategy
        except ImportError:
            logger.debug("Model router module not available")
            return

        has_anthropic = bool(os.environ.get("ANTHROPIC_API_KEY"))
        has_openai = bool(os.environ.get("OPENAI_API_KEY"))
        has_gemini = bool(os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"))
        has_ollama = False
        ollama_models = []

        try:
            import urllib.request

            req = urllib.request.Request(
                f"{config.llm.ollama_base_url}/api/tags",
                method="GET",
            )
            with urllib.request.urlopen(req, timeout=defaults.OLLAMA_TAG_TIMEOUT) as resp:
                import json

                data = json.loads(resp.read())
                ollama_models = [m["name"] for m in data.get("models", [])]
                has_ollama = len(ollama_models) > 0
                self._cached_ollama_models = ollama_models
        except Exception:
            logger.debug("Ollama availability check failed, assuming unavailable")

        available_count = sum([has_anthropic, has_openai, has_gemini, has_ollama])
        # Activate router if 2+ cloud providers OR Ollama has 2+ models
        has_multi_ollama = has_ollama and len(ollama_models) >= 2
        if available_count < 2 and not has_multi_ollama:
            logger.debug(f"Model router: {available_count} provider(s), skipping (need 2+)")
            return

        router = ModelRouter(
            default_model=self._agent.provider.name,
            default_provider=config.llm.default_provider,
            strategy=RoutingStrategy.FIRST_MATCH,
        )

        # Private data → always route to local Ollama
        if has_ollama:
            ollama_model = config.llm.ollama_model
            router.add_model(
                name=f"ollama/{ollama_model}",
                provider="ollama",
                predicate=lambda q: q.is_private,
                priority=5,
            )

        # Complex reasoning or tool use → user's chosen provider first,
        # then fallback to other available providers
        default_prov = config.llm.default_provider.lower()

        def _complex_pred(q):
            return q.complexity > 0.5 or q.requires_reasoning or q.requires_tools

        def _simple_pred(q):
            return q.is_simple

        _cloud_complex = {
            "anthropic": ("claude-sonnet", "anthropic"),
            "openai": ("gpt-4o", "openai"),
            "gemini": ("gemini-2.5-flash", "gemini"),
        }
        _cloud_simple = {
            "anthropic": ("claude-haiku", "anthropic"),
            "openai": ("gpt-4o-mini", "openai"),
            "gemini": ("gemini-2.5-flash", "gemini"),
        }
        _cloud_available = {
            "anthropic": has_anthropic,
            "openai": has_openai,
            "gemini": has_gemini,
        }

        # User's chosen provider gets highest priority for both slots
        if default_prov in _cloud_complex and _cloud_available.get(default_prov):
            name, prov = _cloud_complex[default_prov]
            router.add_model(
                name=name,
                provider=prov,
                predicate=_complex_pred,
                priority=10,
            )
            name_s, prov_s = _cloud_simple[default_prov]
            router.add_model(
                name=name_s,
                provider=prov_s,
                predicate=_simple_pred,
                priority=30,
            )

        # Other available providers as lower-priority fallbacks
        for pkey in ("anthropic", "openai", "gemini"):
            if pkey == default_prov or not _cloud_available.get(pkey):
                continue
            name, prov = _cloud_complex[pkey]
            if not router.has_model(name):
                router.add_model(
                    name=name,
                    provider=prov,
                    predicate=_complex_pred,
                    priority=20,
                )
            name_s, prov_s = _cloud_simple[pkey]
            if not router.has_model(name_s):
                router.add_model(
                    name=name_s,
                    provider=prov_s,
                    predicate=_simple_pred,
                    priority=40,
                )

        # Register remaining provider models
        extra_models = {}
        if has_anthropic:
            extra_models.update(
                {
                    "claude-opus": "anthropic",
                    "claude-sonnet": "anthropic",
                    "claude-haiku": "anthropic",
                }
            )
        if has_openai:
            extra_models.update(
                {
                    "gpt-4o": "openai",
                    "gpt-4o-mini": "openai",
                }
            )
        if has_gemini:
            extra_models.update(
                {
                    "gemini-2.5-flash": "gemini",
                }
            )
        if has_ollama:
            for om in ollama_models:
                extra_models[f"ollama/{om}"] = "ollama"
        for name, prov in extra_models.items():
            if not router.has_model(name):
                router.add_model(name=name, provider=prov)

        # Build fallback chain — user's chosen provider first
        _fb_models = {
            "anthropic": "claude-sonnet",
            "openai": "gpt-4o",
            "gemini": "gemini-2.5-flash",
        }
        fallbacks = []
        # Default provider first
        if default_prov in _fb_models and _cloud_available.get(default_prov):
            fallbacks.append(_fb_models[default_prov])
        # Then other cloud providers
        for pkey in ("anthropic", "openai", "gemini"):
            if pkey == default_prov or not _cloud_available.get(pkey):
                continue
            fallbacks.append(_fb_models[pkey])
        # Local fallback last
        if has_ollama:
            fallbacks.append(f"ollama/{config.llm.ollama_model}")
        router.set_fallback_chain(fallbacks)

        self.model_router = router
        logger.info(
            f"Model router active: {available_count} providers, "
            f"{router.get_model_count()} models configured, "
            f"fallback chain: {' → '.join(fallbacks)}"
        )

        # Auto-load active routing preset
        try:
            data = _load_presets()
            active = data.get("active_preset")
            if active:
                preset = next((p for p in data["presets"] if p["slot"] == active), None)
                if preset and preset["mode"]:
                    self._routing_mode = preset["mode"]
                    self._manual_routes = dict(preset["routes"])
                    logger.info(f"Loaded routing preset '{preset['name']}' (slot {active})")
        except Exception as e:
            logger.debug(f"Routing preset auto-load skipped: {e}")

    def select_provider(self, message: str, session):
        """
        Route message to the best available provider.

        Returns (active_provider, model_name).
        """
        agent = self._agent
        active_provider = agent.provider
        model_name = active_provider.model_name
        manual_routed = False

        # Manual routing
        if self.model_router and self._routing_mode == "manual" and self._manual_routes:
            try:
                analyzer = self.model_router.analyzer
                qc = analyzer.analyze(message)
                route_model = self._manual_routes.get(qc.query_type.value)
                if route_model and self.model_router.has_model(route_model):
                    routed = _resolve_routed_provider(route_model, agent.config)
                    if routed:
                        active_provider = routed
                        model_name = route_model
                        manual_routed = True
                        logger.info(f"Manual route: {qc.query_type.value} -> {route_model}")
            except Exception as e:
                logger.debug(f"Manual routing fallback to auto: {e}")

        # Auto routing (skipped if manual routing already succeeded)
        if self.model_router and not manual_routed:
            try:
                model_name, provider_name = self.model_router.route(message)
                routed = _resolve_routed_provider(model_name, agent.config)
                if not routed:
                    routed = get_provider(provider_name, agent.config)
                active_provider = routed
                if provider_name != agent.config.llm.default_provider:
                    logger.info(f"Routed to {model_name} ({provider_name})")
            except Exception as e:
                logger.debug(f"Router fallback to default: {e}")

        # PHI guard — hard constraint: PHI never leaves device in HIPAA mode
        if agent.compliance_mode and hasattr(active_provider, "provider_key"):
            try:
                from .compliance import check_phi_content

                if check_phi_content(message, agent.compliance_mode):
                    is_cloud = active_provider.provider_key in ("anthropic", "openai", "gemini")
                    if is_cloud:
                        try:
                            local_provider = get_provider("ollama", agent.config)
                            logger.warning(
                                f"PHI detected — rerouted from {model_name} to "
                                f"local Ollama ({agent.config.llm.ollama_model})"
                            )
                            return local_provider, model_name
                        except Exception as fallback_err:
                            logger.error("PHI detected but no local provider available")
                            raise RuntimeError("PHI_NO_LOCAL_FALLBACK") from fallback_err
            except ImportError:
                logger.debug("PHI detection library not available, skipping PHI guard")

        return active_provider, model_name

    def provider_fallback(self, active_provider, primary_error: Exception):
        """
        Attempt a provider fallback after a failure.

        Respects manual routing: if all manual routes point to a single
        provider (e.g. all Ollama), only fallback within that provider
        instead of escalating to cloud APIs the user didn't configure.

        Returns the fallback provider or None if no fallback is available.
        """
        agent = self._agent
        active_key = active_provider.provider_key

        # Record circuit breaker failure
        if self.model_router:
            try:
                self.model_router.record_failure_for_provider(active_key)
            except Exception as e:
                logger.debug(f"Circuit breaker update failed: {e}")

        # Record rate limit events for capacity tracking
        err_type = type(primary_error).__name__
        err_str = str(primary_error).lower()
        if "ratelimit" in err_type.lower() or "rate limit" in err_str or "429" in err_str:
            self.capacity_tracker.record_rate_limit(active_key)
            logger.info(f"Capacity tracker: recorded rate limit for {active_key}")

        # Build fallback order respecting manual routing config.
        # If user configured only Ollama models, don't try cloud providers.
        fallback_order = self._get_fallback_order(active_key)

        fallback = None
        attempted = []
        skipped = []

        for fb_name in fallback_order:
            if fb_name == active_key:
                continue
            if self.capacity_tracker.is_rate_limited(fb_name):
                skipped.append(fb_name)
                logger.info(f"Skipping {fb_name} fallback — rate limited (capacity tracker)")
                continue
            attempted.append(fb_name)
            try:
                if fb_name == "ollama":
                    fallback = get_best_ollama_model(agent.config)
                    if not fallback:
                        fallback = get_provider("ollama", agent.config)
                else:
                    fallback = get_provider(fb_name, agent.config)
                logger.warning(f"{active_provider.name} failed — falling back to {fallback.name}")
                break
            except Exception as e:
                logger.debug("Error suppressed: %s", e)
                continue

        if fallback is None:
            logger.warning(
                "All fallback providers exhausted. Tried: %s. Skipped: %s",
                attempted,
                skipped,
            )

        return fallback

    def _get_fallback_order(self, active_key: str) -> list:
        """Build fallback provider order respecting manual routing config.

        If all manual routes use the same provider family (e.g. all 'ollama/*'),
        only fallback within that family — don't escalate to cloud APIs.
        """
        if self._routing_mode == "manual" and self._manual_routes:
            # Extract provider families from manual routes
            providers_in_routes = set()
            for route_model in self._manual_routes.values():
                if "/" in route_model:
                    providers_in_routes.add(route_model.split("/", 1)[0])
                else:
                    # Try to infer from PROVIDERS registry
                    try:
                        p = get_provider(route_model, self._agent.config)
                        providers_in_routes.add(p.provider_key)
                    except (ValueError, KeyError):
                        pass

            if providers_in_routes and all(p == "ollama" for p in providers_in_routes):
                # User configured only local models — don't try cloud
                return ["ollama"]

        return ["ollama", "anthropic", "openai", "gemini"]

    def get_routing_config(self) -> dict:
        """Return current routing configuration for the dashboard."""
        agent = self._agent
        from .model_router import QueryContext, QueryType

        result = {
            "mode": self._routing_mode,
            "router_active": self.model_router is not None,
            "available_models": [],
            "categories": {},
        }

        if not self.model_router:
            result["available_models"] = [agent.provider.name]
            return result

        router_models = set(self.model_router.get_model_names())
        # Merge in all Ollama catalog + installed models
        try:
            from familiar.channels.connect_wizard._llm import OLLAMA_MODEL_CATALOG

            for tag, *_ in OLLAMA_MODEL_CATALOG:
                router_models.add(f"ollama/{tag}")
        except ImportError:
            pass
        try:
            import httpx as _httpx

            resp = _httpx.get(
                "http://localhost:11434/api/tags", timeout=defaults.OLLAMA_API_TIMEOUT
            )
            for m in resp.json().get("models", []):
                name = m.get("name", "")
                if name:
                    router_models.add(f"ollama/{name}")
        except Exception as e:
            logger.debug("Ollama model listing failed: %s", e)

        # Add image generation providers to available models
        try:
            from familiar.skills.image_gen.skill import _detect_providers
            for p in _detect_providers():
                for m in p.get("models", []):
                    router_models.add(f"image/{m}")
                router_models.add(f"image/{p['name']}")
        except Exception:
            pass

        result["available_models"] = sorted(router_models)

        category_descriptions = {
            "simple": "Quick factual answers, greetings",
            "moderate": "Standard Q&A conversations",
            "complex": "Multi-step reasoning tasks",
            "coding": "Code generation and review",
            "creative": "Writing and brainstorming",
            "private": "Contains sensitive data (local only)",
            "long": "Large input context",
            "image": "Image generation (Gemini, DALL-E, ComfyUI, A1111)",
        }
        category_flags = {
            "simple": {"is_simple": True, "complexity": 0.1},
            "moderate": {"complexity": 0.5},
            "complex": {"is_complex": True, "complexity": 0.9, "requires_reasoning": True},
            "coding": {"is_coding": True, "complexity": 0.6, "requires_tools": True},
            "creative": {"is_creative": True, "complexity": 0.5},
            "private": {"is_private": True, "complexity": 0.5},
            "long": {"is_long_context": True, "complexity": 0.5},
            "image": {"is_image": True, "complexity": 0.3},
        }

        for qt in QueryType:
            flags = category_flags.get(qt.value, {"complexity": 0.5})
            ctx = QueryContext(**flags)
            try:
                auto_model, _ = self.model_router.route(ctx)
            except Exception:
                auto_model = self.model_router.default_model
            manual_model = self._manual_routes.get(qt.value, auto_model)
            result["categories"][qt.value] = {
                "auto_model": auto_model,
                "model": manual_model,
                "description": category_descriptions.get(qt.value, ""),
            }

        return result

    def get_presets(self) -> dict:
        """Return all 5 preset slots + active_preset indicator."""
        return _load_presets()

    def save_preset(self, slot: int, name: str = None) -> dict:
        """Save current routing config to a slot (1-5)."""
        if slot < 1 or slot > 5:
            raise ValueError("Slot must be 1-5")
        data = _load_presets()
        preset = next(p for p in data["presets"] if p["slot"] == slot)
        preset["mode"] = self._routing_mode
        preset["routes"] = dict(self._manual_routes)
        if name:
            preset["name"] = name
        _save_presets(data)
        return data

    def load_preset(self, slot: int) -> dict:
        """Load a preset into the active routing config."""
        data = _load_presets()
        preset = next(p for p in data["presets"] if p["slot"] == slot)
        if preset["mode"] is None:
            raise ValueError(f"Slot {slot} is empty")
        self._routing_mode = preset["mode"]
        self._manual_routes = dict(preset["routes"])
        return self.get_routing_config()

    def set_active_preset(self, slot: "int | None") -> dict:
        """Set which preset auto-loads on startup (None to clear)."""
        data = _load_presets()
        if slot is not None:
            preset = next(p for p in data["presets"] if p["slot"] == slot)
            if preset["mode"] is None:
                raise ValueError(f"Slot {slot} is empty — save a config first")
        data["active_preset"] = slot
        _save_presets(data)
        return data

    def rename_preset(self, slot: int, name: str) -> dict:
        """Rename a preset slot."""
        data = _load_presets()
        preset = next(p for p in data["presets"] if p["slot"] == slot)
        preset["name"] = name
        _save_presets(data)
        return data

    def delete_preset(self, slot: int) -> dict:
        """Clear a preset slot."""
        data = _load_presets()
        preset = next(p for p in data["presets"] if p["slot"] == slot)
        preset["mode"] = None
        preset["routes"] = None
        preset["name"] = f"Preset {slot}"
        if data["active_preset"] == slot:
            data["active_preset"] = None
        _save_presets(data)
        return data

    def set_routing_config(self, mode: str, routes: dict = None) -> dict:
        """Update routing mode and manual route assignments."""
        if mode not in ("auto", "manual"):
            raise ValueError(f"Invalid routing mode: {mode!r} (expected 'auto' or 'manual')")
        self._routing_mode = mode
        if routes is not None:
            self._manual_routes = dict(routes)
        return self.get_routing_config()

    def switch_provider(self, provider_name: str) -> str:
        """Switch to a different LLM provider.

        Updates the live provider, in-memory config, config.yaml, and .env
        so the change survives a dashboard restart.
        """
        agent = self._agent
        try:
            invalidate_provider_cache(provider_name)
            agent.provider = get_provider(provider_name, agent.config)
            # Update in-memory config so router respects the switch immediately
            agent.config.llm.default_provider = provider_name
            self.init_model_router()

            # Persist to config.yaml so the change survives restart
            try:
                import yaml
                from familiar.core.paths import AGENT_DIR
                config_path = AGENT_DIR / "config.yaml"
                cfg: dict = {}
                if config_path.exists():
                    with open(config_path) as f:
                        cfg = yaml.safe_load(f) or {}
                if "llm" not in cfg:
                    cfg["llm"] = {}
                cfg["llm"]["default_provider"] = provider_name
                with open(config_path, "w") as f:
                    yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=False)
                logger.debug("Persisted default_provider=%r to config.yaml", provider_name)
            except Exception as _cfg_err:
                logger.warning("Could not persist provider to config.yaml: %s", _cfg_err)

            # Also update .env so DEFAULT_PROVIDER env var doesn't override on restart
            try:
                from familiar.core.paths import AGENT_DIR as _AGENT_DIR
                env_path = _AGENT_DIR / ".env"
                if env_path.exists():
                    lines = env_path.read_text().splitlines()
                    updated = False
                    for i, line in enumerate(lines):
                        if line.startswith("DEFAULT_PROVIDER="):
                            lines[i] = f"DEFAULT_PROVIDER={provider_name}"
                            updated = True
                            break
                    if not updated:
                        lines.append(f"DEFAULT_PROVIDER={provider_name}")
                    env_path.write_text("\n".join(lines) + "\n")
                    # Keep os.environ in sync for the current process
                    os.environ["DEFAULT_PROVIDER"] = provider_name
                    logger.debug("Persisted DEFAULT_PROVIDER=%r to .env", provider_name)
            except Exception as _env_err:
                logger.warning("Could not persist provider to .env: %s", _env_err)

            return f"Switched to {agent.provider.name}"
        except ValueError as e:
            return str(e)
