# 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

"""LLM provider wizards — Anthropic, OpenAI, Gemini, Ollama."""

from __future__ import annotations

import asyncio
import logging
import os
from pathlib import Path

import yaml

from ..formatting import fmt_bold, fmt_code, fmt_italic, fmt_link
from ._helpers import _service_doc_blurb, _test_http_service, _update_env_file
from ._protocol import WizardHost

logger = logging.getLogger(__name__)


# ── Cloud LLM providers ─────────────────────────────────────────────


async def wizard_llm_provider(
    host: WizardHost, recipient_id: str, provider: str, args: list
) -> None:
    m = host.format_mode
    provider_info = {
        "anthropic": (
            "Anthropic (Claude)",
            "sk-ant-",
            "https://console.anthropic.com",
            "ANTHROPIC_API_KEY",
        ),
        "openai": (
            "OpenAI (GPT)",
            "sk-",
            "https://platform.openai.com/api-keys",
            "OPENAI_API_KEY",
        ),
        "gemini": (
            "Gemini (Google AI)",
            "AIza",
            "https://aistudio.google.com/apikey",
            "GEMINI_API_KEY",
        ),
    }
    name, prefix, url, env_var = provider_info[provider]

    if not args:
        link = fmt_link("Get your API key", url, m)
        if host.supports_buttons:
            await start_llm_provider_wizard(host, recipient_id, provider)
        else:
            await host.wizard_send(
                recipient_id,
                f"\u2601\ufe0f {fmt_bold(f'Connect {name}', m)}\n\n"
                f"1. {link}\n\n"
                f"2. Send:\n"
                f"   {fmt_code(f'/connect {provider} {prefix}...', m)}\n\n"
                f"{fmt_italic('Key is saved to ~/.familiar/.env only.', m)}",
            )
        return

    sub = args[0].lower() if args else ""
    if sub == "test":
        await wizard_llm_provider_test(host, recipient_id, provider)
        return

    api_key = args[0]

    # Basic key format validation
    if provider == "anthropic" and not api_key.startswith("sk-ant-"):
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f That doesn't look like an Anthropic key.\n"
            f"Anthropic keys start with {fmt_code('sk-ant-', m)}",
        )
        return
    if provider == "openai" and not api_key.startswith("sk-"):
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f That doesn't look like an OpenAI key.\n"
            f"OpenAI keys start with {fmt_code('sk-', m)}",
        )
        return
    if provider == "gemini" and not api_key.startswith("AIza"):
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f That doesn't look like a Gemini key.\n"
            f"Gemini keys start with {fmt_code('AIza', m)}",
        )
        return

    # Check for existing key — if different and non-placeholder, warn
    _PLACEHOLDER_KEYS = {"sk-ant-xyz", "sk-xyz", "AIza-xyz", ""}
    existing_key = os.environ.get(env_var, "")
    if existing_key and existing_key not in _PLACEHOLDER_KEYS and existing_key != api_key:
        masked_old = f"{existing_key[:12]}...{existing_key[-4:]}"
        logger.info(f"Replacing existing {env_var} ({masked_old}) with new key")

    os.environ[env_var] = api_key
    os.environ["DEFAULT_PROVIDER"] = provider

    env_path = Path.home() / ".familiar" / ".env"
    try:
        _update_env_file(env_path, {env_var: api_key, "DEFAULT_PROVIDER": provider})
    except Exception as e:
        logger.error(f"Failed to write .env: {e}")
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f Key set for this session but failed to persist: {e}",
        )
        return

    # Switch provider
    agent = getattr(host, "agent", None)
    replaced_note = ""
    if existing_key and existing_key not in _PLACEHOLDER_KEYS and existing_key != api_key:
        replaced_note = f"\n{fmt_italic('(Replaced previous key)', m)}"
    try:
        if agent:
            agent.switch_provider(provider)
        provider_display = agent.provider.name if agent else provider

        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('Connected!', m)}\n\n"
            f"Provider: {fmt_bold(provider_display, m)}\n"
            f"Key: {fmt_code(f'{api_key[:12]}...{api_key[-4:]}', m)}\n"
            f"Saved to: {fmt_code('~/.familiar/.env', m)}" + replaced_note,
        )
    except Exception as e:
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f Key saved but provider switch failed:\n{e}\n\n"
            f"The key is persisted. Try restarting Familiar.",
        )


async def wizard_llm_provider_test(host: WizardHost, recipient_id: str, provider: str) -> None:
    """Test a cloud LLM provider's API key with a real SDK call."""
    m = host.format_mode
    provider_info = {
        "anthropic": ("Anthropic (Claude)", "ANTHROPIC_API_KEY"),
        "openai": ("OpenAI (GPT)", "OPENAI_API_KEY"),
        "gemini": ("Gemini (Google AI)", "GEMINI_API_KEY"),
    }
    name, env_var = provider_info[provider]
    api_key = os.environ.get(env_var, "")

    if not api_key:
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f No {name} API key configured.\n\n"
            f"Set one first:"
            f" {fmt_code(f'/connect {provider} <key>', m)}",
        )
        return

    masked = f"{api_key[:8]}...{api_key[-4:]}"
    await host.wizard_send(recipient_id, f"\u23f3 Testing {name} key...")

    try:
        if provider == "anthropic":
            import anthropic

            client = anthropic.Anthropic(api_key=api_key)
            resp = client.messages.create(
                model="claude-haiku-4-5-20251001",
                max_tokens=1,
                messages=[{"role": "user", "content": "ping"}],
            )
            model_used = resp.model
            await host.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold(f'{name} — Working', m)}\n\n"
                f"Key: {fmt_code(masked, m)}\n"
                f"Model tested: {fmt_code(model_used, m)}\n"
                f"Status: authenticated and functional",
            )

        elif provider == "openai":
            import openai

            client = openai.OpenAI(api_key=api_key)
            models = list(client.models.list())
            count = len(models)
            await host.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold(f'{name} — Working', m)}\n\n"
                f"Key: {fmt_code(masked, m)}\n"
                f"Models available: {fmt_bold(str(count), m)}\n"
                f"Status: authenticated and functional",
            )

        elif provider == "gemini":
            import openai as _openai

            client = _openai.OpenAI(
                api_key=api_key,
                base_url=("https://generativelanguage.googleapis.com/v1beta/openai/"),
            )
            models = list(client.models.list())
            count = len(models)
            await host.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold(f'{name} — Working', m)}\n\n"
                f"Key: {fmt_code(masked, m)}\n"
                f"Models available: {fmt_bold(str(count), m)}\n"
                f"Status: authenticated and functional",
            )

    except Exception as e:
        err = str(e).lower()
        if "auth" in err or "invalid" in err or "api key" in err or "401" in err or "403" in err:
            hint = "API key invalid or expired. Check your key and try again."
        elif "rate" in err or "quota" in err or "429" in err:
            hint = "API key valid but quota exceeded or rate-limited."
        elif "connect" in err or "timeout" in err or "resolve" in err:
            hint = "Network error. Check your internet connection."
        else:
            hint = str(e)[:200]
        await host.wizard_send(
            recipient_id,
            f"\u274c {fmt_bold(f'{name} — Failed', m)}\n\n"
            f"Key: {fmt_code(masked, m)}\n"
            f"Error: {hint}",
        )


async def start_llm_provider_wizard(host: WizardHost, recipient_id: str, provider: str) -> None:
    host._ensure_wizard_state()
    m = host.format_mode
    provider_info = {
        "anthropic": (
            "Anthropic (Claude)",
            "sk-ant-",
            "https://console.anthropic.com",
        ),
        "openai": (
            "OpenAI (GPT)",
            "sk-",
            "https://platform.openai.com/api-keys",
        ),
        "gemini": (
            "Gemini (Google AI)",
            "AIza",
            "https://aistudio.google.com/apikey",
        ),
    }
    name, prefix, url = provider_info[provider]
    host._wizard_state[recipient_id] = {
        "type": "llm_provider",
        "step": "key",
        "provider": provider,
    }
    link = fmt_link("Get your API key", url, m)
    deletion_note = (
        "\n\n" + fmt_italic("Your message will be deleted immediately for safety.", m)
        if host.supports_message_deletion
        else ""
    )
    await host.wizard_send(
        recipient_id,
        f"\u2601\ufe0f {fmt_bold(f'Connect {name}', m)}\n\n"
        f"{link}\n\n"
        f"\U0001f510 {fmt_bold('Paste your API key below', m)}:\n\n"
        f"{fmt_italic(f'Keys start with {prefix}...', m)}" + deletion_note,
    )


async def llm_provider_handle_step(
    host: WizardHost,
    recipient_id: str,
    text: str,
    message_ref: object,
) -> None:
    host._ensure_wizard_state()
    m = host.format_mode
    state = host._wizard_state.get(recipient_id)
    if not state:
        return
    text = text.strip()
    provider = state["provider"]

    if host.supports_message_deletion and message_ref is not None:
        try:
            await host.wizard_delete_message(recipient_id, message_ref)
        except Exception:
            pass

    # Validate key prefix
    prefixes = {
        "anthropic": "sk-ant-",
        "openai": "sk-",
        "gemini": "AIza",
    }
    expected_prefix = prefixes.get(provider, "")
    if expected_prefix and not text.startswith(expected_prefix):
        provider_names = {
            "anthropic": "Anthropic",
            "openai": "OpenAI",
            "gemini": "Gemini",
        }
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f That doesn't look like"
            f" a {provider_names[provider]} key.\n"
            f"Keys start with {fmt_code(expected_prefix, m)}\n\n"
            f"Please try again:",
        )
        return  # keep state alive for retry

    # Valid key — delegate to existing save logic
    host._wizard_state.pop(recipient_id, None)
    await wizard_llm_provider(host, recipient_id, provider, [text])


# ── Ollama model catalog ─────────────────────────────────────────────

# (model_tag, display_name, size, description, doc_url)
OLLAMA_MODEL_CATALOG: list[tuple[str, str, str, str, str]] = [
    (
        "llama3.2",
        "Llama 3.2",
        "2 GB",
        "Meta \u2014 good general-purpose model",
        "https://ollama.com/library/llama3.2",
    ),
    (
        "deepseek-r1:14b",
        "DeepSeek R1 (14B)",
        "9 GB",
        "DeepSeek \u2014 best local reasoning",
        "https://ollama.com/library/deepseek-r1",
    ),
    (
        "qwen2.5:14b",
        "Qwen 2.5 (14B)",
        "9 GB",
        "Alibaba \u2014 strong reasoning and code",
        "https://ollama.com/library/qwen2.5",
    ),
    (
        "qwen3:8b",
        "Qwen 3 (8B)",
        "5 GB",
        "Alibaba \u2014 hybrid thinking",
        "https://ollama.com/library/qwen3",
    ),
    (
        "qwen3.5:27b",
        "Qwen 3.5 (27B)",
        "16 GB",
        "Alibaba \u2014 latest, strong reasoning and code",
        "https://ollama.com/library/qwen3.5",
    ),
    (
        "mistral",
        "Mistral (7B)",
        "4 GB",
        "Mistral AI \u2014 excellent quality",
        "https://ollama.com/library/mistral",
    ),
    (
        "gemma3:4b",
        "Gemma 3 (4B)",
        "3 GB",
        "Google \u2014 compact and capable",
        "https://ollama.com/library/gemma3",
    ),
    (
        "phi4:14b",
        "Phi-4 (14B)",
        "9 GB",
        "Microsoft \u2014 strong at math and code",
        "https://ollama.com/library/phi4",
    ),
    (
        "smollm2:1.7b",
        "SmolLM2 (1.7B)",
        "1 GB",
        "Hugging Face \u2014 lightweight background tasks",
        "https://ollama.com/library/smollm2",
    ),
    (
        "nomic-embed-text",
        "Nomic Embed",
        "137 MB",
        "Nomic AI \u2014 text embeddings for semantic search",
        "https://ollama.com/library/nomic-embed-text",
    ),
]


def _sanitize_model_key(model_tag: str) -> str:
    """Convert a model tag to a safe callback key suffix."""
    return model_tag.replace(":", "_").replace(".", "-")


def _unsanitize_model_key(key_suffix: str) -> str:
    """Reverse of _sanitize_model_key — recover the model tag."""
    # Reverse: first underscore after a known prefix is the colon
    # Strategy: check catalog for a match
    for tag, *_ in OLLAMA_MODEL_CATALOG:
        if _sanitize_model_key(tag) == key_suffix:
            return tag
    # Fallback: best-effort reversal (underscores back to colons
    # only for the last segment)
    if "_" in key_suffix:
        parts = key_suffix.rsplit("_", 1)
        candidate = f"{parts[0].replace('-', '.')}:{parts[1].replace('-', '.')}"
        return candidate
    return key_suffix.replace("-", ".")


async def _get_installed_ollama_models(
    base_url: str = "http://localhost:11434",
) -> set[str]:
    """Query Ollama /api/tags and return set of installed model names."""
    try:
        import httpx

        async with httpx.AsyncClient(timeout=5) as client:
            resp = await client.get(f"{base_url}/api/tags")
            data = resp.json()
        return {m.get("name", "") for m in data.get("models", [])}
    except Exception:
        return set()


async def _show_ollama_model_catalog(host: WizardHost, recipient_id: str) -> None:
    """Show the Ollama model catalog with install/switch buttons."""
    m = host.format_mode

    # Detect Ollama and installed models
    base_url = "http://localhost:11434"
    try:
        config_path = Path.home() / ".familiar" / "config.yaml"
        if config_path.exists():
            cfg = yaml.safe_load(config_path.read_text()) or {}
            base_url = cfg.get("llm", {}).get("ollama_base_url", base_url)
    except Exception:
        pass

    installed = await _get_installed_ollama_models(base_url)
    # Normalize: Ollama returns "model:latest" for untagged pulls.
    # Add both the raw name and its :latest variant so exact-tag
    # matching works for catalog entries with and without a tag.
    installed_norm = set()
    for name in installed:
        installed_norm.add(name)
        if ":" not in name:
            installed_norm.add(f"{name}:latest")

    agent = getattr(host, "agent", None)
    current_cfg = getattr(getattr(agent, "config", None), "llm", None)
    current_model = getattr(current_cfg, "ollama_model", "") if current_cfg else ""

    # Build text
    lines = [
        f"\U0001f999 {fmt_bold('Ollama Model Library', m)}\n",
    ]
    if current_model:
        lines.append(f"{fmt_bold('Active:', m)} {fmt_code(current_model, m)}\n")

    lines.append(
        f"Select a model to download and activate.\n"
        f"{fmt_bold('Installed models', m)} show \u2705,"
        f" others show \u2b07\ufe0f.\n"
    )

    # Build buttons
    options: list[tuple[str, str]] = []
    for tag, display, size, desc, url in OLLAMA_MODEL_CATALOG:
        is_installed = tag in installed_norm or f"{tag}:latest" in installed_norm
        prefix = "\u2705" if is_installed else "\u2b07\ufe0f"
        active = " \u2b50" if tag == current_model else ""
        btn_label = f"{prefix} {display} ({size}){active}"
        cb_key = f"ollama_model_{_sanitize_model_key(tag)}"
        options.append((cb_key, btn_label))

        # Add to text listing
        link = fmt_link(display, url, m)
        status = "\u2705" if is_installed else "\u2b07\ufe0f"
        lines.append(f"  {status} {link} \u2014 {size} \u2014 {desc}")

    lines.append(f"\n{fmt_italic('Click a model to download + activate it.', m)}")

    if host.supports_buttons:
        await host.wizard_send_menu(recipient_id, "\n".join(lines), options)
    else:
        # CLI fallback — just show the list with commands
        lines.append(
            f"\n{fmt_bold('Commands:', m)}\n"
            f"  {fmt_code('/connect ollama pull <model>', m)}"
            f" \u2014 download\n"
            f"  {fmt_code('/connect ollama <model>', m)}"
            f" \u2014 switch\n"
            f"  {fmt_code('/connect ollama remove <model>', m)}"
            f" \u2014 delete"
        )
        await host.wizard_send(recipient_id, "\n".join(lines))


# ── Ollama CLI wizard ────────────────────────────────────────────────


async def wizard_ollama(host: WizardHost, recipient_id: str, args: list) -> None:
    m = host.format_mode
    sub = args[0].lower() if args else ""

    if sub == "list" or not sub:
        try:
            result = await asyncio.create_subprocess_exec(
                "ollama",
                "list",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout, _ = await asyncio.wait_for(result.communicate(), timeout=10)
            models = stdout.decode().strip()

            agent = getattr(host, "agent", None)
            current = getattr(getattr(agent, "config", None), "llm", None)
            current_model = getattr(current, "ollama_model", "unknown") if current else "unknown"
            lightweight = getattr(current, "lightweight_model", "") if current else ""

            text = (
                f"\U0001f999 {fmt_bold('Ollama Models', m)}\n\n"
                f"{fmt_bold('Active:', m)} {current_model}\n"
            )
            if lightweight:
                text += f"{fmt_bold('Background:', m)} {lightweight}\n"

            text += (
                f"\n{fmt_bold('Installed:', m)}\n"
                f"{fmt_code(models, m)}\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"/connect ollama pull <model>"
                f" \u2014 download a model\n"
                f"/connect ollama <model>"
                f" \u2014 switch active model\n"
                f"/connect ollama bg <model>"
                f" \u2014 set background model\n"
                f"/connect ollama remove <model>"
                f" \u2014 delete a model\n"
                f"/connect ollama test"
                f" \u2014 test connectivity\n\n"
                f"{fmt_bold('Recommended models:', m)}\n"
                f"\u2022 {fmt_code('llama3.2', m)}"
                f" \u2014 2GB, good general purpose\n"
                f"\u2022 {fmt_code('qwen2.5:3b', m)}"
                f" \u2014 2GB, strong reasoning\n"
                f"\u2022 {fmt_code('mistral', m)}"
                f" \u2014 4GB, excellent quality\n"
                f"\u2022 {fmt_code('deepseek-r1:14b', m)}"
                f" \u2014 9GB, best local reasoning\n"
                f"\u2022 {fmt_code('nomic-embed-text', m)}"
                f" \u2014 137MB, semantic search"
            )
            await host.wizard_send(recipient_id, text)
        except FileNotFoundError:
            blurb = _service_doc_blurb("ollama", m)
            await host.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Ollama not found.\n{blurb}",
            )
        except Exception as e:
            await host.wizard_send(recipient_id, f"\u26a0\ufe0f Ollama error: {e}")
        return

    if sub == "test":
        await wizard_ollama_test(host, recipient_id)
        return

    if sub == "pull":
        model = args[1] if len(args) > 1 else ""
        if not model:
            await host.wizard_send(
                recipient_id,
                f"Usage: {fmt_code('/connect ollama pull modelname', m)}"
                f"\n\nExamples:\n"
                f"  {fmt_code('/connect ollama pull mistral', m)}\n"
                f"  {fmt_code('/connect ollama pull deepseek-r1:14b', m)}",
            )
            return

        await host.wizard_send(
            recipient_id,
            f"\u23f3 Downloading {fmt_bold(model, m)}... this may take a few minutes.",
        )
        try:
            proc = await asyncio.create_subprocess_exec(
                "ollama",
                "pull",
                model,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
            if proc.returncode == 0:
                await host.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold(model, m)} downloaded!\n\n"
                    f"Switch to it:"
                    f" {fmt_code(f'/connect ollama {model}', m)}",
                )
            else:
                err = stderr.decode(errors="replace")[-200:]
                await host.wizard_send(
                    recipient_id,
                    f"\u26a0\ufe0f Pull failed: {err}",
                )
        except asyncio.TimeoutError:
            await host.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Pull timed out for {model}",
            )
        except Exception as e:
            await host.wizard_send(recipient_id, f"\u26a0\ufe0f Pull error: {e}")
        return

    if sub == "bg":
        model = args[1] if len(args) > 1 else ""
        if not model:
            await host.wizard_send(
                recipient_id,
                f"Usage: {fmt_code('/connect ollama bg modelname', m)}",
            )
            return
        agent = getattr(host, "agent", None)
        if agent and hasattr(agent.config, "llm"):
            agent.config.llm.lightweight_model = model
        env_path = Path.home() / ".familiar" / ".env"
        _update_env_file(env_path, {"LIGHTWEIGHT_MODEL": model})
        await host.wizard_send(
            recipient_id,
            f"\u2705 Background model set to: {fmt_bold(model, m)}",
        )
        return

    if sub == "remove":
        model = args[1] if len(args) > 1 else ""
        if not model:
            await host.wizard_send(
                recipient_id,
                f"Usage: {fmt_code('/connect ollama remove modelname', m)}",
            )
            return
        try:
            proc = await asyncio.create_subprocess_exec(
                "ollama",
                "rm",
                model,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
            if proc.returncode == 0:
                await host.wizard_send(recipient_id, f"\u2705 Removed {model}")
            else:
                await host.wizard_send(
                    recipient_id,
                    f"\u26a0\ufe0f Remove failed: {stderr.decode()[:200]}",
                )
        except Exception as e:
            await host.wizard_send(recipient_id, f"\u26a0\ufe0f Remove error: {e}")
        return

    # Direct model switch: /connect ollama llama3.2
    model = sub
    agent = getattr(host, "agent", None)
    try:
        if agent:
            agent.switch_provider(f"ollama:{model}")
        await host.wizard_send(
            recipient_id,
            f"\u2705 Switched to Ollama: {fmt_bold(model, m)}\n\n"
            + fmt_italic(
                f"To make permanent, update config.yaml: ollama_model: {model}",
                m,
            ),
        )
    except Exception as e:
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f Failed to switch to {model}:\n{e}\n\n"
            f"Pull it first:"
            f" {fmt_code(f'/connect ollama pull {model}', m)}",
        )


async def wizard_ollama_test(host: WizardHost, recipient_id: str) -> None:
    """Test Ollama connectivity by hitting /api/tags."""
    m = host.format_mode
    config_path = Path.home() / ".familiar" / "config.yaml"
    base_url = "http://localhost:11434"
    try:
        if config_path.exists():
            cfg = yaml.safe_load(config_path.read_text()) or {}
            base_url = cfg.get("llm", {}).get("ollama_base_url", base_url)
    except Exception:
        pass

    ok, msg = _test_http_service(f"{base_url}/api/tags")
    if ok:
        # Parse model list from JSON response
        try:
            import httpx

            with httpx.Client(timeout=10) as client:
                resp = client.get(f"{base_url}/api/tags")
                data = resp.json()
            models = data.get("models", [])
            if models:
                model_names = [m_info.get("name", "?") for m_info in models]
                model_list = "\n".join(f"  \u2022 {n}" for n in model_names)
                await host.wizard_send(
                    recipient_id,
                    f"\u2705"
                    f" {fmt_bold('Ollama — Connected', m)}\n\n"
                    f"URL: {fmt_code(base_url, m)}\n"
                    f"Models installed ({len(models)}):\n"
                    f"{model_list}",
                )
            else:
                await host.wizard_send(
                    recipient_id,
                    f"\u2705"
                    f" {fmt_bold('Ollama — Connected', m)}\n\n"
                    f"URL: {fmt_code(base_url, m)}\n"
                    f"No models installed yet.\n\n"
                    f"Pull one:"
                    f" {fmt_code('/connect ollama pull llama3.2', m)}",
                )
        except Exception:
            await host.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Ollama — Connected', m)}\n\n"
                f"URL: {fmt_code(base_url, m)}\n"
                f"Status: {msg}",
            )
    else:
        await host.wizard_send(
            recipient_id,
            f"\u274c"
            f" {fmt_bold('Ollama — Not reachable', m)}\n\n"
            f"URL: {fmt_code(base_url, m)}\n"
            f"Error: {msg}\n\n"
            f"Troubleshooting:\n"
            f"  \u2022 Start Ollama:"
            f" {fmt_code('ollama serve', m)}\n"
            f"  \u2022 {fmt_link('Install Ollama', 'https://ollama.com/download', m)}",
        )


# ── Menu selection dispatch ──────────────────────────────────────────


async def handle_menu_selection(host: WizardHost, recipient_id: str, key: str) -> bool:
    """Handle LLM-related button callbacks.

    Returns ``True`` if *key* was handled, ``False`` otherwise.
    """
    if key == "anthropic":
        await start_llm_provider_wizard(host, recipient_id, "anthropic")
        return True

    if key == "openai":
        await start_llm_provider_wizard(host, recipient_id, "openai")
        return True

    if key == "gemini":
        await start_llm_provider_wizard(host, recipient_id, "gemini")
        return True

    if key == "ollama":
        await _show_ollama_model_catalog(host, recipient_id)
        return True

    # Dynamic Ollama model buttons: ollama_model_<sanitized_tag>
    if key.startswith("ollama_model_"):
        suffix = key[len("ollama_model_") :]
        model_tag = _unsanitize_model_key(suffix)
        await _handle_ollama_model_toggle(host, recipient_id, model_tag)
        return True

    return False


async def _handle_ollama_model_toggle(host: WizardHost, recipient_id: str, model_tag: str) -> None:
    """Handle a model catalog button click — pull if needed, then switch."""
    # Check if already installed
    base_url = "http://localhost:11434"
    try:
        config_path = Path.home() / ".familiar" / "config.yaml"
        if config_path.exists():
            cfg = yaml.safe_load(config_path.read_text()) or {}
            base_url = cfg.get("llm", {}).get("ollama_base_url", base_url)
    except Exception:
        pass

    installed = await _get_installed_ollama_models(base_url)
    installed_norm = set()
    for name in installed:
        installed_norm.add(name)
        if ":" not in name:
            installed_norm.add(f"{name}:latest")

    is_installed = model_tag in installed_norm or f"{model_tag}:latest" in installed_norm

    if is_installed:
        # Already installed — switch to it
        await wizard_ollama(host, recipient_id, [model_tag])
    else:
        # Not installed — pull then switch
        await wizard_ollama(host, recipient_id, ["pull", model_tag])
