# 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

"""
Cross-channel Connect Wizard Mixin.

Extracts interactive service-onboarding wizards (email, calendar, CalDAV,
Google Workspace, LLM providers, SMS, browser, voice) into a shared mixin
so every channel gets the same /connect experience.

Each channel class inherits ConnectMixin and implements three async adapter
methods plus three properties:

    Properties:
        format_mode -> FormatMode
        supports_buttons -> bool
        supports_message_deletion -> bool

    Methods:
        wizard_send(recipient_id, text)
        wizard_send_menu(recipient_id, text, options)
        wizard_delete_message(recipient_id, message_ref) -> bool
"""

from __future__ import annotations

import asyncio
import logging
import os
from pathlib import Path

import yaml

from .formatting import FormatMode, fmt_bold, fmt_code, fmt_italic

logger = logging.getLogger(__name__)

# Optional: caldav library for CalDAV testing
try:
    import caldav
except ImportError:
    caldav = None  # type: ignore[assignment]

# ── Email provider presets (format-neutral) ─────────────────────────

EMAIL_PRESETS: dict[str, dict] = {
    "gmail": {
        "name": "Gmail",
        "imap_server": "imap.gmail.com",
        "smtp_server": "smtp.gmail.com",
        "imap_port": 993,
        "smtp_port": 587,
        "password_help_plain": (
            "Gmail requires an App Password (not your regular password):\n\n"
            "1. Go to https://myaccount.google.com/apppasswords\n"
            '2. Select "Mail" -> "Other (Familiar)"\n'
            "3. Copy the 16-character password\n"
        ),
    },
    "outlook": {
        "name": "Outlook / Office 365",
        "imap_server": "outlook.office365.com",
        "smtp_server": "smtp.office365.com",
        "imap_port": 993,
        "smtp_port": 587,
        "password_help_plain": (
            "Use your Outlook password or an App Password if MFA is enabled.\n"
        ),
    },
    "yahoo": {
        "name": "Yahoo Mail",
        "imap_server": "imap.mail.yahoo.com",
        "smtp_server": "smtp.mail.yahoo.com",
        "imap_port": 993,
        "smtp_port": 587,
        "password_help_plain": (
            "Generate an App Password at https://login.yahoo.com/account/security\n"
        ),
    },
    "proton": {
        "name": "ProtonMail (Bridge)",
        "imap_server": "127.0.0.1",
        "smtp_server": "127.0.0.1",
        "imap_port": 1143,
        "smtp_port": 1025,
        "password_help_plain": (
            "Proton Mail Bridge runs locally and exposes IMAP/SMTP.\n\n"
            "Setup:\n"
            "1. Install Bridge from https://proton.me/mail/bridge\n"
            "   Linux: flatpak install flathub ch.protonmail.protonmail-bridge\n"
            "   Or download the .deb/.rpm from the link above\n"
            "2. Open Bridge and sign in with your Proton account\n"
            "3. Click your account name -> Mailbox configuration\n"
            "4. Copy the Bridge password (NOT your Proton password)\n"
            "   It looks like: aBcDeFgHiJkLmNoP\n"
            "5. Note your Proton email address\n\n"
            "Bridge must be running whenever Familiar checks email.\n"
            "Ports: IMAP 1143, SMTP 1025 (localhost only, no TLS needed).\n"
        ),
    },
}


def _password_help(provider_key: str, mode: FormatMode) -> str:
    """Render password help text with appropriate formatting."""
    preset = EMAIL_PRESETS[provider_key]
    plain = preset["password_help_plain"]
    if mode is FormatMode.HTML:
        # Re-render with HTML tags for richer presentation
        if provider_key == "gmail":
            return (
                f"Gmail requires an {fmt_bold('App Password', mode)} (not your regular password):\n\n"
                f"1. Go to https://myaccount.google.com/apppasswords\n"
                f'2. Select "Mail" \u2192 "Other (Familiar)"\n'
                f"3. Copy the 16-character password\n"
            )
        if provider_key == "proton":
            return (
                f"{fmt_bold('Proton Mail Bridge', mode)} runs locally and exposes IMAP/SMTP.\n\n"
                f"{fmt_bold('Setup:', mode)}\n"
                f"1. Install Bridge from https://proton.me/mail/bridge\n"
                f"   \u2022 Linux: {fmt_code('flatpak install flathub ch.protonmail.protonmail-bridge', mode)}\n"
                f"   \u2022 Or download the .deb/.rpm from the link above\n"
                f"2. Open Bridge and sign in with your Proton account\n"
                f"3. Click your account name \u2192 {fmt_bold('Mailbox configuration', mode)}\n"
                f"4. Copy the {fmt_bold('Bridge password', mode)} (NOT your Proton password)\n"
                f"   \u2022 It looks like: {fmt_code('aBcDeFgHiJkLmNoP', mode)}\n"
                f"5. Note your Proton email address\n\n"
                f"{fmt_bold('Bridge must be running', mode)} whenever Familiar checks email.\n"
                f"Ports: IMAP 1143, SMTP 1025 (localhost only, no TLS needed).\n"
            )
    return plain


# ── Env file + IMAP helpers (static, no channel dependency) ─────────


def _update_env_file(env_path, updates: dict):
    """Update or append key=value pairs in a .env file."""
    env_path = Path(env_path)
    env_path.parent.mkdir(parents=True, exist_ok=True)

    existing_lines: list[str] = []
    if env_path.exists():
        existing_lines = env_path.read_text().splitlines()

    updated_keys: set[str] = set()
    new_lines: list[str] = []

    for line in existing_lines:
        stripped = line.strip()
        if stripped and not stripped.startswith("#") and "=" in stripped:
            key = stripped.split("=", 1)[0].strip()
            if key in updates:
                new_lines.append(f"{key}={updates[key]}")
                updated_keys.add(key)
                continue
        new_lines.append(line)

    for key, value in updates.items():
        if key not in updated_keys:
            new_lines.append(f"{key}={value}")

    env_path.write_text("\n".join(new_lines) + "\n")
    env_path.chmod(0o600)


def _update_yaml_config(updates: dict[str, dict]) -> None:
    """Merge nested key updates into ~/.familiar/config.yaml.

    *updates* maps top-level section names to dicts of key/value pairs, e.g.
    ``{"compliance": {"mode": "hipaa"}, "agent": {"enable_pii_detection": True}}``.
    """
    config_path = Path.home() / ".familiar" / "config.yaml"
    config_path.parent.mkdir(parents=True, exist_ok=True)

    data: dict = {}
    if config_path.exists():
        data = yaml.safe_load(config_path.read_text()) or {}

    for section, kvs in updates.items():
        if section not in data:
            data[section] = {}
        data[section].update(kvs)

    config_path.write_text(yaml.dump(data, default_flow_style=False))


def _test_imap_connection(env_vars: dict) -> tuple[bool, str]:
    """Test IMAP connection.  Returns (success, message)."""
    import imaplib

    try:
        server = env_vars["EMAIL_IMAP_SERVER"]
        port = int(env_vars["EMAIL_IMAP_PORT"])
        if port == 993:
            mail = imaplib.IMAP4_SSL(server, port)
        else:
            mail = imaplib.IMAP4(server, port)
            mail.starttls()
        mail.login(env_vars["EMAIL_ADDRESS"], env_vars["EMAIL_PASSWORD"])
        mail.select("INBOX")
        status, messages = mail.search(None, "ALL")
        count = len(messages[0].split()) if status == "OK" and messages[0] else 0
        mail.logout()
        return True, f"Inbox: {count} messages"
    except imaplib.IMAP4.error as e:
        return False, f"IMAP auth failed: {e}"
    except Exception as e:
        return False, f"Connection error: {e}"


def _check_google_libs() -> bool:
    """Check if Google API libraries are installed."""
    try:
        import google.oauth2.credentials  # noqa: F401
        import google_auth_oauthlib.flow  # noqa: F401
        import googleapiclient.discovery  # noqa: F401

        return True
    except ImportError:
        return False


def _check_twilio_lib() -> bool:
    try:
        from twilio.rest import Client  # noqa: F401

        return True
    except ImportError:
        return False


def _test_http_service(
    url: str,
    *,
    headers: dict | None = None,
    params: dict | None = None,
    auth: tuple[str, str] | None = None,
    method: str = "GET",
) -> tuple[bool, str]:
    """Reusable HTTP connectivity test.  Returns (success, message)."""
    import httpx

    try:
        with httpx.Client(timeout=10, verify=True) as client:
            resp = client.request(
                method, url,
                headers=headers or {},
                params=params,
                auth=auth,
            )
        if resp.status_code < 400:
            return True, f"OK (HTTP {resp.status_code})"
        return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
    except httpx.ConnectError as e:
        return False, f"Connection failed: {e}"
    except Exception as e:
        return False, f"Error: {e}"


def _check_tcp_port(host: str, port: int, timeout: float = 2.0) -> bool:
    """Check if a TCP port is reachable."""
    import socket

    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except (OSError, TimeoutError):
        return False


# ── ConnectMixin ─────────────────────────────────────────────────────


class ConnectMixin:
    """Shared interactive service-connection wizards for all channels.

    Each channel subclass must set/implement:

        format_mode: FormatMode
        supports_buttons: bool
        supports_message_deletion: bool

        async def wizard_send(self, recipient_id: str, text: str) -> None
        async def wizard_send_menu(self, recipient_id: str, text: str,
                                   options: list[tuple[str, str]]) -> None
        async def wizard_delete_message(self, recipient_id: str, message_ref) -> bool
    """

    # Subclass adapter properties (defaults for channels that forget)
    format_mode: FormatMode = FormatMode.PLAIN
    supports_buttons: bool = False
    supports_message_deletion: bool = False

    def _ensure_wizard_state(self):
        if not hasattr(self, "_wizard_state"):
            self._wizard_state: dict[str, dict] = {}

    # ── Default wizard_send_menu (numbered menus for button-less channels) ──

    async def wizard_send_menu(
        self, recipient_id: str, text: str, options: list[tuple[str, str]]
    ) -> None:
        """Send a menu.  Channels with buttons override this.

        *options* is a list of ``(callback_key, label)`` tuples.
        The default implementation renders a numbered text menu and stores the
        mapping so ``handle_wizard_message`` can resolve numbered replies.
        """
        self._ensure_wizard_state()
        lines = [text, ""]
        for i, (_key, label) in enumerate(options, 1):
            lines.append(f"  {i}. {label}")
        lines.append("")
        lines.append(fmt_italic("Reply with a number to choose.", self.format_mode))

        # Store menu state so we can map "2" -> callback_key later
        self._wizard_state[recipient_id] = {
            "type": "menu",
            "options": options,
        }

        await self.wizard_send(recipient_id, "\n".join(lines))

    # ── Public entry points ──────────────────────────────────────────

    async def handle_connect_command(
        self, recipient_id: str, args: list[str]
    ) -> None:
        """Dispatch ``/connect [service] [args...]``."""
        self._ensure_wizard_state()

        if not args:
            await self._connect_main_menu(recipient_id)
            return

        service = args[0].lower()
        rest = args[1:] if len(args) > 1 else []

        dispatch: dict[str, str] = {
            "anthropic": "anthropic",
            "claude": "anthropic",
            "openai": "openai",
            "gpt": "openai",
            "gemini": "gemini",
            "google_ai": "gemini",
            "ollama": "ollama",
            "email": "email",
            "calendar": "calendar",
            "google": "google",
            "sms": "sms",
            "browser": "browser",
            "playwright": "browser",
            "voice": "voice",
            "whisper": "voice",
            "transcription": "voice",
            "healthcare": "healthcare",
            "hipaa": "healthcare",
            "compliance": "healthcare",
            "status": "status",
            "jellyfin": "jellyfin",
            "gitea": "gitea",
            "forgejo": "gitea",
            "homeassistant": "homeassistant",
            "hass": "homeassistant",
            "home-assistant": "homeassistant",
            "joplin": "joplin",
            "pihole": "pihole",
            "pi-hole": "pihole",
            "adguard": "pihole",
            "nextcloud": "nextcloud",
            "nc": "nextcloud",
            "proton": "proton_services",
            "proton_services": "proton_services",
            "protonvpn": "proton_services",
        }

        target = dispatch.get(service)
        if target is None:
            await self.wizard_send(
                recipient_id,
                f"Unknown service: {service}\n\nUse /connect to see available services.",
            )
            return

        handler = {
            "anthropic": lambda: self._wizard_llm_provider(recipient_id, "anthropic", rest),
            "openai": lambda: self._wizard_llm_provider(recipient_id, "openai", rest),
            "gemini": lambda: self._wizard_llm_provider(recipient_id, "gemini", rest),
            "ollama": lambda: self._wizard_ollama(recipient_id, rest),
            "email": lambda: self._wizard_email(recipient_id, rest),
            "calendar": lambda: self._wizard_calendar(recipient_id, rest),
            "google": lambda: self._wizard_google(recipient_id, rest),
            "sms": lambda: self._wizard_sms(recipient_id, rest),
            "browser": lambda: self._wizard_browser(recipient_id),
            "voice": lambda: self._wizard_voice(recipient_id),
            "healthcare": lambda: self._wizard_healthcare(recipient_id, rest),
            "status": lambda: self._wizard_status(recipient_id),
            "jellyfin": lambda: self._wizard_jellyfin(recipient_id, rest),
            "gitea": lambda: self._wizard_gitea(recipient_id, rest),
            "homeassistant": lambda: self._wizard_homeassistant(recipient_id, rest),
            "joplin": lambda: self._wizard_joplin(recipient_id, rest),
            "pihole": lambda: self._wizard_pihole(recipient_id, rest),
            "nextcloud": lambda: self._wizard_nextcloud(recipient_id, rest),
            "proton_services": lambda: self._wizard_proton_services(recipient_id, rest),
        }
        await handler[target]()

    async def handle_connect_menu_selection(
        self, recipient_id: str, key: str
    ) -> bool:
        """Handle a button/callback press.  *key* is the callback data suffix
        after ``connect:``.  Returns True if handled."""
        self._ensure_wizard_state()
        m = self.format_mode

        # Simple informational callbacks
        if key == "anthropic":
            await self.wizard_send(
                recipient_id,
                f"\u2601\ufe0f {fmt_bold('Connect Anthropic (Claude)', m)}\n\n"
                f"1. Get an API key from:\n"
                f"   https://console.anthropic.com\n\n"
                f"2. Send it here:\n"
                f"   {fmt_code('/connect anthropic sk-ant-api03-...', m)}\n\n"
                f"{fmt_italic('Key is stored locally and never shared.', m)}",
            )
            return True
        if key == "openai":
            await self.wizard_send(
                recipient_id,
                f"\u2601\ufe0f {fmt_bold('Connect OpenAI (GPT)', m)}\n\n"
                f"1. Get an API key from:\n"
                f"   https://platform.openai.com/api-keys\n\n"
                f"2. Send it here:\n"
                f"   {fmt_code('/connect openai sk-...', m)}\n\n"
                f"{fmt_italic('Key is stored locally and never shared.', m)}",
            )
            return True
        if key == "gemini":
            await self.wizard_send(
                recipient_id,
                f"\u2601\ufe0f {fmt_bold('Connect Gemini (Google AI)', m)}\n\n"
                f"1. Get an API key from:\n"
                f"   https://aistudio.google.com/apikey\n\n"
                f"2. Send it here:\n"
                f"   {fmt_code('/connect gemini AIza...', m)}\n\n"
                f"{fmt_italic('Key is stored locally and never shared.', m)}",
            )
            return True
        if key == "ollama":
            provider_name = getattr(getattr(self, "agent", None), "provider", None)
            current = provider_name.name if provider_name else "unknown"
            await self.wizard_send(
                recipient_id,
                f"\U0001f3e0 {fmt_bold('Configure Ollama (Local)', m)}\n\n"
                f"Switch to a specific Ollama model:\n"
                f"   {fmt_code('/connect ollama llama3.1:8b', m)}\n\n"
                f"Pull a new model first:\n"
                f"   {fmt_code('ollama pull llama3.1:8b', m)}\n\n"
                f"Current: {fmt_bold(current, m)}",
            )
            return True

        # Email sub-menu
        if key == "email_menu":
            await self._wizard_email(recipient_id, [])
            return True
        if key == "email_custom":
            await self._wizard_email_custom(recipient_id)
            return True
        if key == "email_proton":
            await self._wizard_proton_bridge(recipient_id)
            return True
        if key.startswith("email_") and key.replace("email_", "") in EMAIL_PRESETS:
            provider = key.replace("email_", "")
            await self._start_email_wizard(recipient_id, provider)
            return True

        # Calendar sub-menu
        if key == "calendar_menu":
            await self._wizard_calendar(recipient_id, [])
            return True
        if key == "calendar_google":
            await self._start_google_calendar_wizard(recipient_id)
            return True
        if key == "calendar_caldav":
            await self._start_caldav_wizard(recipient_id)
            return True
        if key == "calendar_test":
            await self._wizard_calendar_test(recipient_id)
            return True

        # Google sub-menu
        if key == "google_menu":
            await self._wizard_google(recipient_id, [])
            return True
        if key == "google_install":
            await self._wizard_google_install_libs(recipient_id)
            return True
        if key == "google_check":
            await self._wizard_google_check_status(recipient_id)
            return True
        if key.startswith("google_auth_"):
            svc = key.replace("google_auth_", "")
            await self._wizard_google_auth(recipient_id, svc)
            return True

        # Other menus
        if key == "sms_menu":
            await self._wizard_sms(recipient_id, [])
            return True
        if key == "browser_menu":
            await self._wizard_browser(recipient_id)
            return True
        if key == "voice_menu":
            await self._wizard_voice(recipient_id)
            return True
        if key == "healthcare_menu":
            await self._wizard_healthcare(recipient_id, [])
            return True
        if key.startswith("healthcare_"):
            action = key.replace("healthcare_", "")
            await self._wizard_healthcare(recipient_id, [action])
            return True
        if key == "status_menu":
            await self._wizard_status(recipient_id)
            return True

        # Self-hosted sub-menus
        if key == "selfhosted_menu":
            await self._selfhosted_submenu(recipient_id)
            return True
        if key == "jellyfin_menu":
            await self._wizard_jellyfin(recipient_id, [])
            return True
        if key == "gitea_menu":
            await self._wizard_gitea(recipient_id, [])
            return True
        if key == "homeassistant_menu":
            await self._wizard_homeassistant(recipient_id, [])
            return True
        if key == "joplin_menu":
            await self._wizard_joplin(recipient_id, [])
            return True
        if key == "pihole_menu":
            await self._wizard_pihole(recipient_id, [])
            return True
        if key == "nextcloud_menu":
            await self._wizard_nextcloud(recipient_id, [])
            return True
        if key == "proton_services_menu":
            await self._wizard_proton_services(recipient_id, [])
            return True

        return False

    async def handle_wizard_message(
        self, recipient_id: str, text: str, message_ref=None
    ) -> bool:
        """Intercept a user message during an active wizard.

        Returns True if the message was consumed by the wizard.
        """
        self._ensure_wizard_state()
        state = self._wizard_state.get(recipient_id)
        if not state:
            return False

        wtype = state.get("type")

        # Numbered-menu resolution
        if wtype == "menu":
            stripped = text.strip()
            if stripped.isdigit():
                idx = int(stripped) - 1
                options = state.get("options", [])
                if 0 <= idx < len(options):
                    key = options[idx][0]
                    self._wizard_state.pop(recipient_id, None)
                    return await self.handle_connect_menu_selection(recipient_id, key)
            # Not a valid selection — remind user
            await self.wizard_send(
                recipient_id,
                fmt_italic("Please reply with a number from the menu above.", self.format_mode),
            )
            return True

        # Email wizard
        if wtype == "email":
            await self._email_handle_step(recipient_id, text, message_ref)
            return True

        # CalDAV wizard
        if wtype == "caldav":
            await self._caldav_handle_step(recipient_id, text, message_ref)
            return True

        # Proton Bridge wizard
        if wtype == "proton":
            await self._proton_handle_step(recipient_id, text, message_ref)
            return True

        # Nextcloud wizard
        if wtype == "nextcloud":
            await self._nextcloud_handle_step(recipient_id, text, message_ref)
            return True

        return False

    async def handle_connect_status(self, recipient_id: str) -> None:
        """Show full connection status overview."""
        await self._wizard_status(recipient_id)

    # ── Main menu ────────────────────────────────────────────────────

    async def _connect_main_menu(self, recipient_id: str):
        m = self.format_mode

        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"))
        try:
            from familiar.skills.email.accounts import get_all_accounts
            has_email = bool(get_all_accounts())
        except Exception:
            has_email = bool(os.environ.get("EMAIL_ADDRESS"))
        has_twilio = bool(os.environ.get("TWILIO_ACCOUNT_SID"))
        data_dir = Path.home() / ".familiar" / "data"
        has_google = (data_dir / "google_credentials.json").exists()
        has_caldav = bool(os.environ.get("CALDAV_URL"))
        try:
            from familiar.skills.calendar.accounts import get_all_accounts as _cal_accounts
            has_calendar = bool(_cal_accounts())
        except Exception:
            has_calendar = (data_dir / "google_token.json").exists() or has_caldav
        provider = getattr(getattr(self, "agent", None), "provider", None)
        current = provider.name if provider else "unknown"
        agent = getattr(self, "agent", None)
        compliance_mode = getattr(agent, "compliance_mode", None)
        compliance_str = compliance_mode.value if hasattr(compliance_mode, "value") else str(compliance_mode or "none")
        pii_on = getattr(getattr(agent, "config", None), "agent", None)
        pii_enabled = getattr(pii_on, "enable_pii_detection", False) if pii_on else False
        has_healthcare = compliance_str != "none" or pii_enabled

        # Self-hosted services
        has_jellyfin = bool(os.environ.get("JELLYFIN_URL"))
        has_gitea = bool(os.environ.get("GITEA_URL"))
        has_homeassistant = bool(os.environ.get("HOMEASSISTANT_URL"))
        has_joplin = bool(os.environ.get("JOPLIN_TOKEN"))
        has_pihole = bool(os.environ.get("PIHOLE_URL"))
        has_nextcloud = bool(os.environ.get("NEXTCLOUD_URL"))
        any_selfhosted = any([has_jellyfin, has_gitea, has_homeassistant,
                              has_joplin, has_pihole, has_nextcloud])

        ck = "\u2705"
        em = "\u2b1c"

        lines = [
            f"\U0001f50c {fmt_bold('Connect Services', m)}\n",
            f"{fmt_bold('LLM Providers:', m)}",
            f"  {ck if has_anthropic else em} Anthropic (Claude) \u2014 best quality",
            f"  {ck if has_openai else em} OpenAI (GPT)",
            f"  {ck if has_gemini else em} Gemini (Google AI)",
            f"  {ck if 'Ollama' in current else em} Ollama (local, free)",
            f"  Current: {fmt_bold(current, m)}\n",
            f"{fmt_bold('Integrations:', m)}",
            f"  {ck if has_google else em} Google Workspace (Calendar, Drive)",
            f"  {ck if has_calendar else em} Calendar (Google/CalDAV)",
            f"  {ck if has_email else em} Email (IMAP/SMTP)",
            f"  {ck if has_twilio else em} SMS (Twilio)",
            f"  {em} Browser (Playwright)",
            f"  {em} Voice (Whisper)",
            f"  {ck if has_healthcare else em} Healthcare / Compliance\n",
            f"{fmt_bold('Self-Hosted:', m)}",
            f"  {ck if has_jellyfin else em} Jellyfin (media)",
            f"  {ck if has_gitea else em} Gitea (code)",
            f"  {ck if has_homeassistant else em} Home Assistant",
            f"  {ck if has_joplin else em} Joplin (notes)",
            f"  {ck if has_pihole else em} Pi-hole / AdGuard",
            f"  {ck if has_nextcloud else em} Nextcloud\n",
            f"{fmt_italic('All credentials stored in ~/.familiar/.env (chmod 600)', m)}",
        ]

        options = [
            ("anthropic", "Claude \u2601\ufe0f"),
            ("openai", "GPT \u2601\ufe0f"),
            ("gemini", "Gemini \u2601\ufe0f"),
            ("ollama", "Ollama \U0001f3e0"),
            ("calendar_menu", "\U0001f4c5 Calendar"),
            ("email_menu", "\U0001f4e7 Email"),
            ("google_menu", "\U0001f4c5 Google"),
            ("sms_menu", "\U0001f4f1 SMS"),
            ("status_menu", "\U0001f4e1 Status"),
            ("browser_menu", "\U0001f310 Browser"),
            ("voice_menu", "\U0001f3a4 Voice"),
            ("healthcare_menu", "\U0001f3e5 Healthcare"),
            ("selfhosted_menu", "\U0001f3e0 Self-Hosted"),
        ]

        await self.wizard_send_menu(recipient_id, "\n".join(lines), options)

    async def _selfhosted_submenu(self, recipient_id: str):
        m = self.format_mode
        ck = "\u2705"
        em = "\u2b1c"

        has_jellyfin = bool(os.environ.get("JELLYFIN_URL"))
        has_gitea = bool(os.environ.get("GITEA_URL"))
        has_homeassistant = bool(os.environ.get("HOMEASSISTANT_URL"))
        has_joplin = bool(os.environ.get("JOPLIN_TOKEN"))
        has_pihole = bool(os.environ.get("PIHOLE_URL"))
        has_nextcloud = bool(os.environ.get("NEXTCLOUD_URL"))
        has_proton_bridge = _check_tcp_port("127.0.0.1", 1143)

        lines = [
            f"\U0001f3e0 {fmt_bold('Self-Hosted Services', m)}\n",
            f"  {ck if has_jellyfin else em} Jellyfin \U0001f3ac (media server)",
            f"  {ck if has_gitea else em} Gitea \U0001f419 (code forge)",
            f"  {ck if has_homeassistant else em} Home Assistant \U0001f3e0 (smart home)",
            f"  {ck if has_joplin else em} Joplin \U0001f4dd (notes)",
            f"  {ck if has_pihole else em} Pi-hole / AdGuard \U0001f6e1\ufe0f (DNS filter)",
            f"  {ck if has_nextcloud else em} Nextcloud \u2601\ufe0f (files & calendar)",
            f"  {ck if has_proton_bridge else em} Proton \U0001f512 (Bridge & VPN)\n",
            f"{fmt_italic('Select a service to configure:', m)}",
        ]

        options = [
            ("jellyfin_menu", "\U0001f3ac Jellyfin"),
            ("gitea_menu", "\U0001f419 Gitea"),
            ("homeassistant_menu", "\U0001f3e0 Home Assistant"),
            ("joplin_menu", "\U0001f4dd Joplin"),
            ("pihole_menu", "\U0001f6e1\ufe0f Pi-hole"),
            ("nextcloud_menu", "\u2601\ufe0f Nextcloud"),
            ("proton_services_menu", "\U0001f512 Proton"),
        ]

        await self.wizard_send_menu(recipient_id, "\n".join(lines), options)

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

    async def _wizard_llm_provider(self, recipient_id: str, provider: str, args: list):
        m = self.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:
            await self.wizard_send(
                recipient_id,
                f"\u2601\ufe0f {fmt_bold(f'Connect {name}', m)}\n\n"
                f"1. Get your API key from:\n   {url}\n\n"
                f"2. Send:\n   {fmt_code(f'/connect {provider} {prefix}...', m)}\n\n"
                f"{fmt_italic('Key is saved to ~/.familiar/.env only.', m)}",
            )
            return

        api_key = args[0]

        # Basic key format validation
        if provider == "anthropic" and not api_key.startswith("sk-ant-"):
            await self.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 self.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 self.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

        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 self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Key set for this session but failed to persist: {e}",
            )
            return

        # Switch provider
        agent = getattr(self, "agent", None)
        try:
            if agent:
                agent.switch_provider(provider)
            provider_display = agent.provider.name if agent else provider

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

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

            await self.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 self.wizard_send(
                        recipient_id,
                        f"\u2705 {fmt_bold(model, m)} downloaded!\n\n"
                        f"Switch to it: {fmt_code(f'/connect ollama {model}', m)}",
                    )
                else:
                    err = stderr.decode(errors="replace")[-200:]
                    await self.wizard_send(recipient_id, f"\u26a0\ufe0f Pull failed: {err}")
            except asyncio.TimeoutError:
                await self.wizard_send(recipient_id, f"\u26a0\ufe0f Pull timed out for {model}")
            except Exception as e:
                await self.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 self.wizard_send(recipient_id, f"Usage: {fmt_code('/connect ollama bg modelname', m)}")
                return
            agent = getattr(self, "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 self.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 self.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 self.wizard_send(recipient_id, f"\u2705 Removed {model}")
                else:
                    await self.wizard_send(recipient_id, f"\u26a0\ufe0f Remove failed: {stderr.decode()[:200]}")
            except Exception as e:
                await self.wizard_send(recipient_id, f"\u26a0\ufe0f Remove error: {e}")
            return

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

    # ── Email wizard ─────────────────────────────────────────────────

    async def _wizard_email(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0].lower() if args else ""

        if sub == "test":
            await self._wizard_email_test(recipient_id)
            return
        if sub == "set" and len(args) >= 3:
            await self._wizard_email_set_credential(recipient_id, args[1].upper(), " ".join(args[2:]))
            return
        if sub == "proton":
            if len(args) > 1 and "@" in args[1]:
                await self._wizard_email_setup_inline(recipient_id, "proton", args[1:])
            else:
                await self._wizard_proton_bridge(recipient_id)
            return
        if sub in EMAIL_PRESETS:
            if len(args) > 1 and "@" in args[1]:
                await self._wizard_email_setup_inline(recipient_id, sub, args[1:])
            else:
                await self._start_email_wizard(recipient_id, sub)
            return
        if sub == "custom":
            await self._wizard_email_custom(recipient_id)
            return

        # Default: status or picker
        from familiar.skills.email.accounts import get_all_accounts

        accounts = get_all_accounts()
        if accounts:
            acct_lines = []
            for acct in accounts:
                provider = acct.get("provider", "custom").capitalize()
                acct_lines.append(
                    f"  \u2705 {provider}: {fmt_code(acct['address'], m)}"
                )
            await self.wizard_send(
                recipient_id,
                f"\U0001f4e7 {fmt_bold('Email Configuration', m)}\n\n"
                + "\n".join(acct_lines)
                + f"\n\n{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect email test', m)} \u2014 verify all connections\n"
                f"  {fmt_code('/connect email gmail', m)} \u2014 add/reconfigure provider\n\n"
                f"{fmt_bold('Add another account?', m)}\n"
                f"  {fmt_code('/connect email', m)} + choose a provider\n\n"
                f'Try: "Check my email" or "Check my gmail"',
            )
            return

        # Not configured
        options = [
            ("email_gmail", "Gmail"),
            ("email_outlook", "Outlook"),
            ("email_yahoo", "Yahoo"),
            ("email_proton", "Proton Mail"),
            ("email_custom", "Custom IMAP"),
        ]
        await self.wizard_send_menu(
            recipient_id,
            f"\U0001f4e7 {fmt_bold('Connect Email', m)}\n\nChoose your email provider:",
            options,
        )

    async def _wizard_email_custom(self, recipient_id: str):
        m = self.format_mode
        await self.wizard_send(
            recipient_id,
            f"\U0001f4e7 {fmt_bold('Custom Email Setup', m)}\n\n"
            f"Set each value individually:\n\n"
            f"{fmt_code('/connect email set EMAIL_ADDRESS user@example.com', m)}\n"
            f"{fmt_code('/connect email set EMAIL_PASSWORD your-password', m)}\n"
            f"{fmt_code('/connect email set EMAIL_IMAP_SERVER imap.example.com', m)}\n"
            f"{fmt_code('/connect email set EMAIL_SMTP_SERVER smtp.example.com', m)}\n"
            f"{fmt_code('/connect email set EMAIL_IMAP_PORT 993', m)}\n"
            f"{fmt_code('/connect email set EMAIL_SMTP_PORT 587', m)}\n\n"
            f"Then test:\n"
            f"  {fmt_code('/connect email test', m)}",
        )

    async def _wizard_email_setup_inline(self, recipient_id: str, provider_key: str, extra_args: list):
        """Handle inline email setup: /connect email gmail user@gmail.com PASSWORD"""
        m = self.format_mode
        preset = {**EMAIL_PRESETS[provider_key], "_provider_key": provider_key}
        email_addr = extra_args[0]
        password = extra_args[1] if len(extra_args) > 1 else None

        if password:
            await self._email_save_and_test(recipient_id, email_addr, password, preset)
            return

        preset_name = preset["name"]
        await self.wizard_send(
            recipient_id,
            f"\U0001f4e7 {fmt_bold(f'{preset_name} Setup', m)}\n\n"
            f"Address: {fmt_code(email_addr, m)}\n\n"
            f"{_password_help(provider_key, m)}\n"
            f"Send your password:\n"
            f"  {fmt_code(f'/connect email {provider_key} {email_addr} YOUR_PASSWORD', m)}",
        )

    async def _start_email_wizard(self, recipient_id: str, provider_key: str):
        """Start the interactive email setup wizard."""
        self._ensure_wizard_state()
        m = self.format_mode
        preset = EMAIL_PRESETS[provider_key]
        name = preset["name"]
        help_text = _password_help(provider_key, m)

        self._wizard_state[recipient_id] = {
            "type": "email",
            "step": "email",
            "provider": provider_key,
        }

        await self.wizard_send(
            recipient_id,
            f"\U0001f4e7 {fmt_bold(f'{name} Setup', m)}\n\n"
            f"{help_text}\n"
            f"Enter your {fmt_bold(f'{name} email address', m)}:",
        )

    async def _email_handle_step(self, recipient_id: str, text: str, message_ref):
        self._ensure_wizard_state()
        m = self.format_mode
        state = self._wizard_state.get(recipient_id)
        if not state:
            return
        step = state["step"]
        text = text.strip()

        if step == "email":
            if "@" not in text or "." not in text:
                await self.wizard_send(
                    recipient_id,
                    "That doesn't look like an email address. Please try again:",
                )
                return
            state["email"] = text
            state["step"] = "password"
            preset = EMAIL_PRESETS[state["provider"]]

            warning = ""
            if not self.supports_message_deletion:
                warning = (
                    f"\n\n\u26a0\ufe0f {fmt_italic('This channel cannot delete messages. '
                    'Your password will remain visible in chat history. '
                    'Consider setting credentials via the server CLI instead.', m)}"
                )

            preset_name = EMAIL_PRESETS[state["provider"]]["name"]
            await self.wizard_send(
                recipient_id,
                f"\U0001f510 Enter your {fmt_bold(f'{preset_name} password', m)}:"
                f"{warning}"
                + ("\n\n" + fmt_italic("Your message will be deleted immediately.", m)
                   if self.supports_message_deletion else ""),
            )

        elif step == "password":
            if self.supports_message_deletion and message_ref is not None:
                try:
                    await self.wizard_delete_message(recipient_id, message_ref)
                except Exception:
                    pass

            provider_key = state["provider"]
            email = state["email"]
            preset = {**EMAIL_PRESETS[provider_key], "_provider_key": provider_key}
            self._wizard_state.pop(recipient_id, None)

            await self.wizard_send(
                recipient_id,
                f"\U0001f4e7 Credentials saved for {fmt_bold(email, m)}.\n"
                f"Testing IMAP connection to {preset['imap_server']}...",
            )
            await self._email_save_and_test(recipient_id, email, text, preset)

    async def _email_save_and_test(self, recipient_id: str, email_addr: str, password: str, preset: dict):
        m = self.format_mode
        env_vars = {
            "EMAIL_ADDRESS": email_addr,
            "EMAIL_PASSWORD": password,
            "EMAIL_IMAP_SERVER": preset["imap_server"],
            "EMAIL_SMTP_SERVER": preset["smtp_server"],
            "EMAIL_IMAP_PORT": str(preset["imap_port"]),
            "EMAIL_SMTP_PORT": str(preset["smtp_port"]),
        }
        for k, v in env_vars.items():
            os.environ[k] = v

        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
            return

        # Save to multi-account registry
        provider_key = preset.get("_provider_key", "custom")
        try:
            from familiar.skills.email.accounts import add_account, _unique_id, _load_accounts_file

            data = _load_accounts_file()
            acct_id = _unique_id(provider_key, data["accounts"])
            account = {
                "id": acct_id,
                "address": email_addr,
                "password": password,
                "imap_server": preset["imap_server"],
                "smtp_server": preset["smtp_server"],
                "imap_port": preset["imap_port"],
                "smtp_port": preset["smtp_port"],
                "provider": provider_key,
            }
            add_account(account)
        except Exception as e:
            logger.warning(f"Failed to save to account registry: {e}")

        success, result = _test_imap_connection(env_vars)
        if success:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Email connected!', m)}\n\n"
                f"  Address: {fmt_code(email_addr, m)}\n"
                f"  IMAP: {fmt_code(preset['imap_server'], m)} \u2705\n"
                f"  {result}\n\n"
                f'Try: "Check my email" or "Search for messages from..."\n\n'
                f"{fmt_bold('Add another account?', m)}\n"
                f"  {fmt_code('/connect email', m)}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f {fmt_bold('Credentials saved but connection failed', m)}\n\n"
                f"  Error: {result}\n\n"
                f"Check your password and try:\n"
                f"  {fmt_code('/connect email test', m)}",
            )

    async def _wizard_email_test(self, recipient_id: str):
        m = self.format_mode
        from familiar.skills.email.accounts import get_all_accounts

        accounts = get_all_accounts()
        if not accounts:
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Email not configured.\nUse {fmt_code('/connect email', m)} to set up.",
            )
            return
        await self.wizard_send(
            recipient_id, f"Testing {len(accounts)} account(s)..."
        )
        results = []
        for acct in accounts:
            env_vars = {
                "EMAIL_ADDRESS": acct["address"],
                "EMAIL_PASSWORD": acct["password"],
                "EMAIL_IMAP_SERVER": acct["imap_server"],
                "EMAIL_SMTP_SERVER": acct["smtp_server"],
                "EMAIL_IMAP_PORT": str(acct["imap_port"]),
                "EMAIL_SMTP_PORT": str(acct["smtp_port"]),
            }
            success, result = _test_imap_connection(env_vars)
            provider = acct.get("provider", "custom").capitalize()
            if success:
                results.append(f"\u2705 {provider} ({acct['address']}): {result}")
            else:
                results.append(f"\u274c {provider} ({acct['address']}): {result}")
        await self.wizard_send(recipient_id, "\n".join(results))

    async def _wizard_email_set_credential(self, recipient_id: str, key: str, value: str):
        m = self.format_mode
        allowed = {
            "EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_SERVER",
            "EMAIL_SMTP_SERVER", "EMAIL_IMAP_PORT", "EMAIL_SMTP_PORT",
        }
        if key not in allowed:
            await self.wizard_send(
                recipient_id, f"Unknown key: {key}\nAllowed: {', '.join(sorted(allowed))}"
            )
            return
        os.environ[key] = value
        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, {key: value})
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
            return
        is_sensitive = "PASSWORD" in key
        display_val = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" if is_sensitive else fmt_code(value, m)
        await self.wizard_send(
            recipient_id,
            f"\u2705 Set {fmt_bold(key, m)} = {display_val}",
        )

    # ── CalDAV wizard ────────────────────────────────────────────────

    async def _wizard_calendar(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0].lower() if args else ""

        if sub == "test":
            await self._wizard_calendar_test(recipient_id)
            return
        if sub == "google":
            await self._start_google_calendar_wizard(recipient_id)
            return
        if sub == "caldav":
            if len(args) >= 4:
                await self._caldav_save_and_test(recipient_id, args[1], args[2], " ".join(args[3:]))
                return
            await self._start_caldav_wizard(recipient_id)
            return

        # Default: status or picker (multi-account aware)
        try:
            from familiar.skills.calendar.accounts import get_all_accounts
            accounts = get_all_accounts()
        except Exception:
            accounts = []

        if accounts:
            acct_lines = []
            for acct in accounts:
                label = acct.get("label") or acct["id"]
                atype = acct.get("type", "unknown")
                if atype == "caldav":
                    detail = f" ({acct.get('url', '')[:40]})"
                else:
                    detail = ""
                acct_lines.append(f"  \u2705 {label}: {fmt_bold(atype.title() + detail, m)}")
            await self.wizard_send(
                recipient_id,
                f"\U0001f4c5 {fmt_bold('Calendar Configuration', m)}\n\n"
                + "\n".join(acct_lines) + "\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect calendar test', m)} \u2014 verify connection\n"
                f"  {fmt_code('/connect calendar caldav', m)} \u2014 add CalDAV account\n"
                f"  {fmt_code('/connect calendar google', m)} \u2014 add Google account\n\n"
                f'Try: "What\'s on my calendar today?"',
            )
            return

        options = [
            ("calendar_google", "Google Calendar"),
            ("calendar_caldav", "CalDAV"),
            ("calendar_test", "\U0001f9ea Test Connection"),
        ]
        await self.wizard_send_menu(
            recipient_id,
            f"\U0001f4c5 {fmt_bold('Calendar Setup', m)}\n\n"
            f"Current: {fmt_bold('Not configured', m)}\n\n"
            f"Choose your calendar provider:",
            options,
        )

    async def _start_caldav_wizard(self, recipient_id: str):
        self._ensure_wizard_state()
        m = self.format_mode
        self._wizard_state[recipient_id] = {"type": "caldav", "step": "url"}
        await self.wizard_send(
            recipient_id,
            f"\U0001f4c5 {fmt_bold('CalDAV Calendar Setup', m)}\n\n"
            f"CalDAV works with Nextcloud, Radicale, ownCloud, Synology, "
            f"Baikal, and most self-hosted calendar servers.\n\n"
            f"Enter your {fmt_bold('CalDAV server URL', m)}:\n\n"
            f"{fmt_italic('Example: https://cloud.example.org/remote.php/dav/calendars/alice/', m)}",
        )

    async def _caldav_handle_step(self, recipient_id: str, text: str, message_ref):
        self._ensure_wizard_state()
        m = self.format_mode
        state = self._wizard_state.get(recipient_id)
        if not state:
            return
        step = state["step"]
        text = text.strip()

        if step == "url":
            if not text.startswith(("http://", "https://")):
                await self.wizard_send(
                    recipient_id,
                    "That doesn't look like a URL. "
                    "Please enter a URL starting with http:// or https://:",
                )
                return
            state["url"] = text
            state["step"] = "user"
            await self.wizard_send(
                recipient_id,
                f"Enter your {fmt_bold('CalDAV username', m)}:",
            )

        elif step == "user":
            if not text:
                await self.wizard_send(recipient_id, "Username cannot be empty. Please try again:")
                return
            state["user"] = text
            state["step"] = "password"

            warning = ""
            if not self.supports_message_deletion:
                warning = (
                    f"\n\n\u26a0\ufe0f {fmt_italic('This channel cannot delete messages. '
                    'Your password will remain visible in chat history. '
                    'Consider setting credentials via the server CLI instead.', m)}"
                )

            await self.wizard_send(
                recipient_id,
                f"\U0001f510 Enter your {fmt_bold('CalDAV password', m)}:"
                f"{warning}"
                + ("\n\n" + fmt_italic("Your message will be deleted immediately for safety.", m)
                   if self.supports_message_deletion else ""),
            )

        elif step == "password":
            if self.supports_message_deletion and message_ref is not None:
                try:
                    await self.wizard_delete_message(recipient_id, message_ref)
                except Exception:
                    pass

            url = state["url"]
            user = state["user"]
            self._wizard_state.pop(recipient_id, None)
            await self.wizard_send(recipient_id, "Testing CalDAV connection...")
            await self._caldav_save_and_test(recipient_id, url, user, text)

    async def _caldav_save_and_test(self, recipient_id: str, url: str, user: str, password: str):
        m = self.format_mode
        os.environ["CALDAV_URL"] = url
        os.environ["CALDAV_USER"] = user
        os.environ["CALDAV_PASSWORD"] = password
        os.environ["CALENDAR_PROVIDER"] = "caldav"

        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, {
                "CALDAV_URL": url, "CALDAV_USER": user,
                "CALDAV_PASSWORD": password, "CALENDAR_PROVIDER": "caldav",
            })
        except Exception as e:
            logger.error(f"Failed to write .env: {e}")
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Credentials set for this session but failed to persist: {e}",
            )
            return

        # Save to calendar account registry
        try:
            from familiar.skills.calendar.accounts import (
                _load_accounts_file, _unique_id, add_account,
            )
            data = _load_accounts_file()
            acct_id = _unique_id("caldav", data["accounts"])
            account = {
                "id": acct_id, "type": "caldav", "label": "",
                "url": url, "user": user, "password": password,
            }
            add_account(account)
        except Exception as e:
            logger.warning(f"Failed to save calendar account to registry: {e}")

        if caldav is not None:
            try:
                client = caldav.DAVClient(url=url, username=user, password=password)
                principal = client.principal()
                calendars = principal.calendars()
                cal_names = [str(c) for c in calendars[:5]]

                await self.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold('CalDAV Connected!', m)}\n\n"
                    f"Server: {fmt_code(url[:50], m)}\n"
                    f"User: {fmt_code(user, m)}\n"
                    f"Calendars found: {len(calendars)}\n"
                    + ("\n".join(f"  \u2022 {n}" for n in cal_names) + "\n" if cal_names else "")
                    + f"\nSaved to: {fmt_code('~/.familiar/.env', m)}\n\n"
                    f"Add another account? Use {fmt_code('/connect calendar google', m)} or "
                    f"{fmt_code('/connect calendar caldav', m)}",
                )
                return
            except Exception as e:
                await self.wizard_send(
                    recipient_id,
                    f"\u26a0\ufe0f Credentials saved but connection test failed:\n{fmt_code(str(e), m)}\n\n"
                    f"Check your URL, username, and password.\n"
                    f"To retry: {fmt_code('/connect calendar caldav', m)}",
                )
                return
        else:
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Credentials saved but {fmt_bold('caldav', m)} library not installed.\n"
                f"Run: {fmt_code('pip install familiar-agent[nextcloud]', m)}",
            )

    async def _wizard_calendar_test(self, recipient_id: str):
        m = self.format_mode

        # Try multi-account test first
        try:
            from familiar.skills.calendar.accounts import get_all_accounts
            accounts = get_all_accounts()
        except Exception:
            accounts = []

        if accounts:
            results = []
            for acct in accounts:
                label = acct.get("label") or acct["id"]
                atype = acct.get("type", "unknown")
                try:
                    if atype == "caldav":
                        from familiar.skills.calendar.skill import _get_caldav_client_for_account
                        client = _get_caldav_client_for_account(acct)
                        principal = client.principal()
                        calendars = principal.calendars()
                        results.append(
                            f"\u2705 {label} (CalDAV) \u2014 {len(calendars)} calendar(s)"
                        )
                    elif atype == "google":
                        from familiar.skills.calendar.skill import _get_google_service_for_account
                        _get_google_service_for_account(acct)
                        results.append(f"\u2705 {label} (Google) \u2014 OK")
                    else:
                        results.append(f"\u26a0\ufe0f {label}: unknown type '{atype}'")
                except Exception as e:
                    results.append(f"\u274c {label} ({atype}): {e}")
            await self.wizard_send(recipient_id, "\n".join(results))
            return

        # Legacy single-provider test
        try:
            from familiar.skills.calendar.skill import _calendar_provider

            provider = _calendar_provider()
        except ImportError:
            await self.wizard_send(recipient_id, "\U0001f4c5 Calendar module not available.")
            return

        if provider == "none":
            await self.wizard_send(
                recipient_id,
                f"\U0001f4c5 No calendar configured.\n\nUse {fmt_code('/connect calendar', m)} to set up.",
            )
            return

        if provider == "caldav":
            try:
                from familiar.skills.calendar.skill import _get_caldav_client

                client = _get_caldav_client()
                principal = client.principal()
                calendars = principal.calendars()
                await self.wizard_send(
                    recipient_id,
                    f"\u2705 CalDAV connection OK \u2014 {len(calendars)} calendar(s) found.",
                )
            except Exception as e:
                await self.wizard_send(recipient_id, f"\u274c CalDAV connection failed: {e}")
        else:
            try:
                from familiar.skills.calendar.skill import _get_calendar_service

                _get_calendar_service()
                await self.wizard_send(recipient_id, "\u2705 Google Calendar connection OK.")
            except Exception as e:
                await self.wizard_send(recipient_id, f"\u274c Google Calendar connection failed: {e}")

    # ── Google Calendar wizard ───────────────────────────────────────

    async def _start_google_calendar_wizard(self, recipient_id: str):
        m = self.format_mode
        data_dir = Path.home() / ".familiar" / "data"
        creds_file = data_dir / "google_credentials.json"
        token_file = data_dir / "google_token.json"

        has_libs = _check_google_libs()
        has_creds = creds_file.exists()
        has_token = token_file.exists()

        if not has_libs:
            await self.wizard_send(recipient_id, "\U0001f4e6 Installing Google Calendar libraries...")
            await self._wizard_google_install_libs(recipient_id)
            has_libs = _check_google_libs()
            if not has_libs:
                return

        if not has_creds:
            actual_path = str(creds_file)
            await self.wizard_send(
                recipient_id,
                f"\U0001f4c5 {fmt_bold('Google Calendar Setup', m)}\n\n"
                f"\u2705 Libraries installed\n"
                f"\u274c OAuth credentials needed\n\n"
                f"\U0001f449 {fmt_bold('Easiest:', m)} Run the guided setup script on the server:\n"
                f"  {fmt_code('python -m familiar.onboard.google_setup', m)}\n\n"
                f"Or follow these steps manually:\n"
                f"1. Go to https://console.cloud.google.com\n"
                f"2. Create project \u2192 Enable Calendar API\n"
                f"3. Create OAuth Client ID \u2192 {fmt_bold('Desktop app', m)}\n"
                f"4. Download the JSON file\n\n"
                f"Then either:\n"
                f"  \u2022 Place it at: {fmt_code(actual_path, m)}\n"
                f"  \u2022 Or upload it directly in this chat",
            )
            return

        # Validate credentials type (must be Desktop app, not Web app)
        try:
            import json as _json
            creds_data = _json.loads(creds_file.read_text())
            if "web" in creds_data and "installed" not in creds_data:
                await self.wizard_send(
                    recipient_id,
                    f"\u274c {fmt_bold('Wrong credentials type', m)}\n\n"
                    f"Your {fmt_code('credentials.json', m)} is for a "
                    f"{fmt_bold('Web application', m)}, but Familiar needs "
                    f"{fmt_bold('Desktop app', m)} credentials.\n\n"
                    f"{fmt_bold('How to fix:', m)}\n"
                    f"1. Go to https://console.cloud.google.com/apis/credentials\n"
                    f"2. Click {fmt_bold('+ CREATE CREDENTIALS', m)} \u2192 "
                    f"{fmt_bold('OAuth client ID', m)}\n"
                    f"3. Application type: {fmt_bold('Desktop app', m)}\n"
                    f"4. Download the new JSON file\n"
                    f"5. Replace {fmt_code(str(creds_file), m)}\n\n"
                    f"Or run: {fmt_code('python -m familiar.onboard.google_setup', m)}",
                )
                return
        except Exception:
            pass

        if not has_token:
            await self.wizard_send(
                recipient_id,
                f"\U0001f4c5 {fmt_bold('Google Calendar Setup', m)}\n\n"
                f"\u2705 Libraries installed\n"
                f"\u2705 credentials.json found\n"
                f"\u23f3 Starting OAuth authorization...",
            )
            await self._wizard_google_auth(recipient_id, "calendar")
            # Register after OAuth completes (token file now exists)
            if token_file.exists():
                self._register_google_calendar_account()
            return

        # Already authorized — ensure registered
        self._register_google_calendar_account()
        await self.wizard_send(
            recipient_id,
            f"\U0001f4c5 {fmt_bold('Google Calendar', m)}\n\n"
            f"\u2705 Libraries installed\n"
            f"\u2705 credentials.json present\n"
            f"\u2705 Calendar authorized\n\n"
            f'\U0001f389 Google Calendar is connected! Try: "What\'s on my calendar?"\n\n'
            f"Add another account? Use {fmt_code('/connect calendar caldav', m)} or "
            f"{fmt_code('/connect calendar google', m)}",
        )

    def _register_google_calendar_account(self):
        """Save Google Calendar account to the registry (idempotent)."""
        try:
            from familiar.skills.calendar.accounts import (
                _load_accounts_file, _unique_id, add_account,
            )
            data = _load_accounts_file()
            # Check if a google account already exists
            for acct in data["accounts"]:
                if acct.get("type") == "google":
                    return  # Already registered
            acct_id = _unique_id("google", data["accounts"])
            account = {
                "id": acct_id, "type": "google", "label": "",
                "token_file": "google_token.json", "calendar_id": "primary",
            }
            add_account(account)
        except Exception as e:
            logger.warning(f"Failed to save Google calendar account to registry: {e}")

    # ── Google Workspace wizard ──────────────────────────────────────

    async def _wizard_google(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0].lower() if args else ""
        data_dir = Path.home() / ".familiar" / "data"
        creds_file = data_dir / "google_credentials.json"

        if sub == "check":
            await self._wizard_google_check_status(recipient_id)
            return
        if sub == "install":
            await self._wizard_google_install_libs(recipient_id)
            return
        if sub == "auth":
            svc = args[1].lower() if len(args) > 1 else "calendar"
            await self._wizard_google_auth(recipient_id, svc)
            return
        if sub == "code":
            auth_code = " ".join(args[1:]) if len(args) > 1 else ""
            if not auth_code:
                await self.wizard_send(
                    recipient_id,
                    f"Usage: {fmt_code('/connect google code YOUR_AUTH_CODE', m)}",
                )
                return
            await self._wizard_google_handle_code(recipient_id, auth_code)
            return

        # Default: show guided setup
        has_libs = _check_google_libs()
        has_creds = creds_file.exists()

        token_files = {
            "Calendar": data_dir / "google_token.json",
            "Drive": data_dir / "google_token_drive.json",
            "Contacts": data_dir / "google_token_contacts.json",
            "Gmail": data_dir / "google_token_gmail.json",
        }

        ck, cr = "\u2705", "\u274c"
        step_lines: list[str] = []
        next_action = None

        if not has_libs:
            step_lines.append(f"{cr} {fmt_bold('Step 1:', m)} Install Google libraries")
            next_action = (
                f"\U0001f449 {fmt_bold('Next:', m)} Run:\n"
                f"  {fmt_code('/connect google install', m)}"
            )
        else:
            step_lines.append(f"{ck} {fmt_bold('Step 1:', m)} Google libraries installed")

        if not has_creds:
            step_lines.append(f"{cr} {fmt_bold('Step 2:', m)} OAuth credentials.json")
            if not next_action:
                actual_path = str(data_dir / "google_credentials.json")
                next_action = (
                    f"\U0001f449 {fmt_bold('Next:', m)} Create a Google Cloud project:\n\n"
                    f"1. Go to https://console.cloud.google.com\n"
                    f'2. Create project: "Familiar AI Assistant"\n'
                    f"3. Enable APIs: Calendar, Drive, People, Gmail\n"
                    f"4. Create OAuth consent screen (External, test mode)\n"
                    f"5. Create OAuth Client ID \u2192 {fmt_bold('Desktop app', m)}\n"
                    f"6. Download the JSON file\n\n"
                    f"{fmt_bold('Then place it here:', m)}\n"
                    f"  {fmt_code(actual_path, m)}"
                )
        else:
            step_lines.append(f"{ck} {fmt_bold('Step 2:', m)} credentials.json present")

        authorized, not_authorized = [], []
        for name, path in token_files.items():
            (authorized if path.exists() else not_authorized).append(name)

        if authorized:
            step_lines.append(f"{ck} {fmt_bold('Step 3:', m)} Authorized: {', '.join(authorized)}")
        if not_authorized:
            prefix = "\u2b1c" if authorized else cr
            step_lines.append(
                f"{prefix} {fmt_bold('Step 3:', m)} Not yet authorized: {', '.join(not_authorized)}"
            )
            if not next_action and has_libs and has_creds:
                svc = not_authorized[0].lower()
                next_action = (
                    f"\U0001f449 {fmt_bold('Next:', m)} Authorize {not_authorized[0]}:\n"
                    f"  {fmt_code(f'/connect google auth {svc}', m)}"
                )

        if has_libs and has_creds and not not_authorized:
            next_action = f'\U0001f389 {fmt_bold("All Google services connected!", m)} Try: "What\'s on my calendar?"'

        lines = [
            f"\U0001f4c5 {fmt_bold('Google Workspace Setup', m)}\n",
            *step_lines, "",
            next_action or "",
        ]

        options: list[tuple[str, str]] = []
        if not has_libs:
            options.append(("google_install", "Install Libraries"))
        if has_libs and has_creds and not_authorized:
            for svc in not_authorized[:3]:
                options.append((f"google_auth_{svc.lower()}", f"Authorize {svc}"))
        if has_creds:
            options.append(("google_check", "Check Status"))

        if options:
            await self.wizard_send_menu(recipient_id, "\n".join(lines), options)
        else:
            await self.wizard_send(recipient_id, "\n".join(lines))

    async def _wizard_google_install_libs(self, recipient_id: str):
        if _check_google_libs():
            await self.wizard_send(recipient_id, "\u2705 Google API libraries already installed!")
            return

        await self.wizard_send(recipient_id, "\U0001f4e6 Installing Google API libraries...")
        try:
            import subprocess

            result = subprocess.run(
                ["pip", "install", "google-auth-oauthlib", "google-auth-httplib2",
                 "google-api-python-client"],
                stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                text=True, timeout=120,
            )
            if result.returncode == 0 and _check_google_libs():
                m = self.format_mode
                await self.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold('Google libraries installed!', m)}\n\n"
                    f"Next: Place your credentials.json file at:\n"
                    f"  {fmt_code('~/.familiar/data/google_credentials.json', m)}\n\n"
                    f"Then run {fmt_code('/connect google', m)} to continue.",
                )
            else:
                m = self.format_mode
                error = result.stderr[-300:] if result.stderr else "Unknown error"
                await self.wizard_send(
                    recipient_id,
                    f"\u26a0\ufe0f Installation failed. Try manually on the server:\n\n"
                    f"{fmt_code('pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client', m)}\n\n"
                    f"Error: {fmt_code(error, m)}",
                )
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Installation failed: {e}")

    async def _wizard_google_check_status(self, recipient_id: str):
        m = self.format_mode
        data_dir = Path.home() / ".familiar" / "data"
        token_files = {
            "Calendar": data_dir / "google_token.json",
            "Drive": data_dir / "google_token_drive.json",
            "Contacts": data_dir / "google_token_contacts.json",
            "Gmail": data_dir / "google_token_gmail.json",
        }
        lines = [f"\U0001f4c5 {fmt_bold('Google Token Status', m)}\n"]
        for name, path in token_files.items():
            lines.append(f"  {'\u2705' if path.exists() else '\u274c'} {name}")
        await self.wizard_send(recipient_id, "\n".join(lines))

    async def _wizard_google_auth(self, recipient_id: str, service: str):
        """Start headless Google OAuth flow for a service."""
        m = self.format_mode
        data_dir = Path.home() / ".familiar" / "data"
        creds_file = data_dir / "google_credentials.json"

        if not _check_google_libs():
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Google libraries not installed.\nRun: {fmt_code('/connect google install', m)}",
            )
            return

        if not creds_file.exists():
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f credentials.json not found.\n"
                f"Place it at: {fmt_code('~/.familiar/data/google_credentials.json', m)}",
            )
            return

        # Validate credentials type
        try:
            import json as _json
            creds_data = _json.loads(creds_file.read_text())
            if "web" in creds_data and "installed" not in creds_data:
                await self.wizard_send(
                    recipient_id,
                    f"\u274c {fmt_bold('Wrong credentials type', m)}\n\n"
                    f"Your {fmt_code('credentials.json', m)} is a {fmt_bold('Web app', m)} client. "
                    f"Familiar needs a {fmt_bold('Desktop app', m)} client.\n\n"
                    f"Go to https://console.cloud.google.com/apis/credentials\n"
                    f"Create a new OAuth client ID \u2192 {fmt_bold('Desktop app', m)}\n"
                    f"Download and replace {fmt_code(str(creds_file), m)}",
                )
                return
        except Exception:
            pass

        scopes_map = {
            "calendar": ["https://www.googleapis.com/auth/calendar"],
            "drive": [
                "https://www.googleapis.com/auth/drive.readonly",
                "https://www.googleapis.com/auth/drive.file",
            ],
            "contacts": ["https://www.googleapis.com/auth/contacts.readonly"],
            "gmail": [
                "https://www.googleapis.com/auth/gmail.readonly",
                "https://www.googleapis.com/auth/gmail.send",
            ],
        }
        token_map = {
            "calendar": data_dir / "google_token.json",
            "drive": data_dir / "google_token_drive.json",
            "contacts": data_dir / "google_token_contacts.json",
            "gmail": data_dir / "google_token_gmail.json",
        }

        if service not in scopes_map:
            await self.wizard_send(
                recipient_id,
                f"Unknown service: {service}\nAvailable: {', '.join(scopes_map.keys())}",
            )
            return

        token_file = token_map[service]
        if token_file.exists():
            await self.wizard_send(
                recipient_id,
                f"\u2705 {service.title()} is already authorized!\n\n"
                f"To re-authorize, delete the token:\n"
                f"  {fmt_code(f'rm {token_file}', m)}\n"
                f"Then run this command again.",
            )
            return

        try:
            import http.server
            import socket
            import threading
            import urllib.parse

            from google_auth_oauthlib.flow import InstalledAppFlow

            with socket.socket() as sock:
                sock.bind(("127.0.0.1", 0))
                port = sock.getsockname()[1]

            redirect_uri = f"http://localhost:{port}"
            flow = InstalledAppFlow.from_client_secrets_file(
                str(creds_file), scopes_map[service], redirect_uri=redirect_uri,
            )
            auth_url, _ = flow.authorization_url(prompt="consent")

            _auth_code_holder: dict = {}

            class _Handler(http.server.BaseHTTPRequestHandler):
                def do_GET(self):
                    params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
                    _auth_code_holder["code"] = params.get("code", [None])[0]
                    body = b"<html><body><h2>Authorization complete. You may close this tab.</h2></body></html>"
                    self.send_response(200)
                    self.send_header("Content-Type", "text/html")
                    self.send_header("Content-Length", str(len(body)))
                    self.end_headers()
                    self.wfile.write(body)

                def log_message(self, *args):
                    pass

            server = http.server.HTTPServer(("127.0.0.1", port), _Handler)
            server.timeout = 300
            threading.Thread(target=server.handle_request, daemon=True).start()

            if not hasattr(self, "_pending_google_flows"):
                self._pending_google_flows = {}

            self._pending_google_flows[recipient_id] = {
                "flow": flow,
                "token_file": token_file,
                "service_name": service.title(),
                "server": server,
            }

            await self.wizard_send(
                recipient_id,
                f"\U0001f510 {fmt_bold(f'Authorize {service.title()}', m)}\n\n"
                f"1. Open this link in a browser on the {fmt_bold('same machine', m)} as Familiar:\n\n"
                f"{auth_url}\n\n"
                f"2. Sign in with your Google account and approve permissions\n"
                f"3. You'll be redirected to localhost:{port} automatically \u2705\n\n"
                f"{fmt_italic('Remote server? Copy the URL to any browser, approve, then paste the full redirect URL here:', m)}\n"
                f"   {fmt_code('/connect google code PASTE_REDIRECT_URL', m)}",
            )
        except ImportError:
            await self.wizard_send(
                recipient_id,
                "\u274c Google libraries not installed. Run /connect google install first.",
            )
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Auth error: {e}")

    async def _wizard_google_handle_code(self, recipient_id: str, auth_code: str):
        m = self.format_mode
        if not hasattr(self, "_pending_google_flows") or recipient_id not in self._pending_google_flows:
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f No pending Google authorization.\n"
                f"Start with: {fmt_code('/connect google auth calendar', m)}",
            )
            return

        pending = self._pending_google_flows.pop(recipient_id)
        flow = pending["flow"]
        token_file = pending["token_file"]
        service_name = pending["service_name"]

        try:
            import urllib.parse

            raw = auth_code.strip()
            if raw.startswith("http"):
                params = urllib.parse.parse_qs(urllib.parse.urlparse(raw).query)
                code_value = params.get("code", [raw])[0]
            else:
                code_value = raw

            flow.fetch_token(code=code_value)
            creds = flow.credentials
            token_file.write_text(creds.to_json())

            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold(f'{service_name} authorized!', m)}\n\n"
                f"Token saved to:\n  {fmt_code(str(token_file), m)}\n\n"
                f'Try it: "What\'s on my calendar today?"',
            )
        except Exception as e:
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Authorization failed: {e}\n\n"
                f"The code may have expired. Try again:\n"
                f"  {fmt_code(f'/connect google auth {service_name.lower()}', m)}",
            )

    # ── Proton Bridge wizard ─────────────────────────────────────────

    async def _wizard_proton_bridge(self, recipient_id: str):
        """Start automated Proton Mail Bridge setup."""
        import shutil

        m = self.format_mode
        await self.wizard_send(
            recipient_id,
            f"\U0001f4e7 {fmt_bold('Proton Mail Bridge Setup', m)}\n\n"
            f"Checking for Proton Mail Bridge...",
        )

        bridge_bin = self._proton_find_bridge()

        if not bridge_bin:
            has_flatpak = shutil.which("flatpak")
            if not has_flatpak:
                await self._proton_fallback_manual(
                    recipient_id, "Flatpak not available for automatic Bridge install."
                )
                return

            await self.wizard_send(recipient_id, "\U0001f4e6 Bridge not found. Installing via Flatpak...")
            success = await self._proton_install_bridge(recipient_id)
            if not success:
                await self._proton_fallback_manual(recipient_id, "Flatpak install failed.")
                return

            bridge_bin = self._proton_find_bridge()
            if not bridge_bin:
                await self._proton_fallback_manual(
                    recipient_id, "Bridge binary not found after install."
                )
                return

        await self.wizard_send(
            recipient_id,
            f"\u2705 Bridge found: {fmt_code(bridge_bin, m)}",
        )

        self._ensure_wizard_state()
        self._wizard_state[recipient_id] = {
            "type": "proton",
            "step": "email",
            "bridge_bin": bridge_bin,
        }

        await self.wizard_send(
            recipient_id,
            f"\U0001f4e7 Enter your {fmt_bold('Proton email address', m)}:",
        )

    @staticmethod
    def _proton_find_bridge():
        import shutil
        import subprocess

        native = shutil.which("protonmail-bridge")
        if native:
            return native
        try:
            result = subprocess.run(
                ["flatpak", "info", "ch.protonmail.protonmail-bridge"],
                capture_output=True, timeout=10,
            )
            if result.returncode == 0:
                return "flatpak run ch.protonmail.protonmail-bridge"
        except Exception:
            pass
        return None

    async def _proton_install_bridge(self, recipient_id: str) -> bool:
        try:
            proc = await asyncio.create_subprocess_exec(
                "flatpak", "install", "-y", "flathub",
                "ch.protonmail.protonmail-bridge",
                stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
            )
            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
            if proc.returncode == 0:
                await self.wizard_send(recipient_id, "\u2705 Proton Mail Bridge installed via Flatpak.")
                return True
            else:
                logger.error(f"Flatpak install failed: {stderr.decode(errors='replace')[:500]}")
                return False
        except asyncio.TimeoutError:
            await self.wizard_send(recipient_id, "\u26a0\ufe0f Bridge installation timed out.")
            return False
        except Exception as e:
            logger.error(f"Bridge install error: {e}")
            return False

    async def _proton_handle_step(self, recipient_id: str, text: str, message_ref):
        self._ensure_wizard_state()
        m = self.format_mode
        state = self._wizard_state.get(recipient_id)
        if not state:
            return
        step = state["step"]
        text = text.strip()

        if step == "email":
            if "@" not in text or "." not in text:
                await self.wizard_send(
                    recipient_id,
                    "That doesn't look like an email address. Please try again:",
                )
                return
            state["email"] = text
            state["step"] = "password"
            await self.wizard_send(
                recipient_id,
                f"\U0001f510 Enter your {fmt_bold('Proton password', m)}:"
                + ("\n\n" + fmt_italic("Your message will be deleted immediately.", m)
                   if self.supports_message_deletion else ""),
            )

        elif step == "password":
            if self.supports_message_deletion and message_ref is not None:
                try:
                    await self.wizard_delete_message(recipient_id, message_ref)
                except Exception:
                    pass

            state["password"] = text
            state["step"] = "logging_in"
            await self.wizard_send(recipient_id, "\U0001f504 Logging into Proton Mail Bridge...")
            await self._proton_do_login(recipient_id, state["email"], text)

        elif step == "2fa":
            if self.supports_message_deletion and message_ref is not None:
                try:
                    await self.wizard_delete_message(recipient_id, message_ref)
                except Exception:
                    pass

            state["step"] = "completing_2fa"
            await self.wizard_send(recipient_id, "\U0001f504 Submitting 2FA code...")
            await self._proton_do_2fa(recipient_id, text)

    async def _proton_do_login(self, recipient_id: str, email: str, password: str):
        self._ensure_wizard_state()
        m = self.format_mode
        state = self._wizard_state.get(recipient_id)
        if not state:
            return
        bridge_bin = state["bridge_bin"]

        try:
            if bridge_bin.startswith("flatpak run"):
                cmd = bridge_bin.split() + ["--cli"]
            else:
                cmd = [bridge_bin, "--cli"]

            proc = await asyncio.create_subprocess_exec(
                *cmd,
                stdin=asyncio.subprocess.PIPE,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )

            proc.stdin.write(b"login\n")
            await proc.stdin.drain()
            await asyncio.sleep(1)
            proc.stdin.write(email.encode() + b"\n")
            await proc.stdin.drain()
            await asyncio.sleep(1)
            proc.stdin.write(password.encode() + b"\n")
            await proc.stdin.drain()

            try:
                output = await asyncio.wait_for(
                    self._proton_read_until_prompt(proc.stdout), timeout=30,
                )
            except asyncio.TimeoutError:
                output = ""

            output_lower = output.lower()

            if "two factor" in output_lower or "2fa" in output_lower or "totp" in output_lower:
                if not hasattr(self, "_proton_bridge_proc"):
                    self._proton_bridge_proc = {}
                self._proton_bridge_proc[recipient_id] = proc
                state["step"] = "2fa"
                await self.wizard_send(
                    recipient_id,
                    f"\U0001f510 {fmt_bold('2FA required', m)}\n\nEnter your 2FA code:"
                    + ("\n\n" + fmt_italic("Your message will be deleted immediately.", m)
                       if self.supports_message_deletion else ""),
                )
                return

            if "error" in output_lower or "invalid" in output_lower or "failed" in output_lower:
                proc.stdin.write(b"exit\n")
                await proc.stdin.drain()
                try:
                    await asyncio.wait_for(proc.wait(), timeout=5)
                except asyncio.TimeoutError:
                    proc.kill()
                self._wizard_state.pop(recipient_id, None)
                await self._proton_fallback_manual(
                    recipient_id,
                    f"Bridge login failed. CLI output:\n{fmt_code(output[:300], m)}",
                )
                return

            proc.stdin.write(b"exit\n")
            await proc.stdin.drain()
            try:
                await asyncio.wait_for(proc.wait(), timeout=5)
            except asyncio.TimeoutError:
                proc.kill()

            await self._proton_finish_setup(recipient_id)

        except Exception as e:
            logger.error(f"Proton bridge login error: {e}")
            self._wizard_state.pop(recipient_id, None)
            await self._proton_fallback_manual(recipient_id, f"Bridge CLI automation failed: {e}")

    async def _proton_do_2fa(self, recipient_id: str, code: str):
        m = self.format_mode
        procs = getattr(self, "_proton_bridge_proc", {})
        proc = procs.get(recipient_id)
        if not proc or proc.returncode is not None:
            self._wizard_state.pop(recipient_id, None)
            procs.pop(recipient_id, None)
            await self._proton_fallback_manual(recipient_id, "Bridge process ended unexpectedly.")
            return

        try:
            proc.stdin.write(code.encode() + b"\n")
            await proc.stdin.drain()

            try:
                output = await asyncio.wait_for(
                    self._proton_read_until_prompt(proc.stdout), timeout=15,
                )
            except asyncio.TimeoutError:
                output = ""

            output_lower = output.lower()
            if "error" in output_lower or "invalid" in output_lower or "failed" in output_lower:
                proc.stdin.write(b"exit\n")
                await proc.stdin.drain()
                try:
                    await asyncio.wait_for(proc.wait(), timeout=5)
                except asyncio.TimeoutError:
                    proc.kill()
                self._wizard_state.pop(recipient_id, None)
                procs.pop(recipient_id, None)
                await self._proton_fallback_manual(recipient_id, "2FA verification failed.")
                return

            proc.stdin.write(b"exit\n")
            await proc.stdin.drain()
            try:
                await asyncio.wait_for(proc.wait(), timeout=5)
            except asyncio.TimeoutError:
                proc.kill()

            procs.pop(recipient_id, None)
            await self._proton_finish_setup(recipient_id)

        except Exception as e:
            logger.error(f"Proton 2FA error: {e}")
            self._wizard_state.pop(recipient_id, None)
            procs.pop(recipient_id, None)
            await self._proton_fallback_manual(recipient_id, f"2FA automation failed: {e}")

    async def _proton_finish_setup(self, recipient_id: str):
        self._ensure_wizard_state()
        m = self.format_mode
        state = self._wizard_state.get(recipient_id)
        if not state:
            return
        email = state["email"]
        bridge_bin = state["bridge_bin"]

        await self.wizard_send(recipient_id, "\U0001f511 Extracting Bridge credentials...")
        bridge_password = await self._proton_extract_bridge_password(bridge_bin, email)

        if not bridge_password:
            self._wizard_state.pop(recipient_id, None)
            await self._proton_fallback_manual(
                recipient_id,
                "Could not extract Bridge password automatically.\n"
                "Open Bridge \u2192 click your account \u2192 copy the Bridge password.",
            )
            return

        await self.wizard_send(recipient_id, "\U0001f680 Starting Bridge in background...")
        await self._proton_start_background(bridge_bin)
        await asyncio.sleep(3)

        preset = EMAIL_PRESETS["proton"]
        env_vars = {
            "EMAIL_ADDRESS": email,
            "EMAIL_PASSWORD": bridge_password,
            "EMAIL_IMAP_SERVER": preset["imap_server"],
            "EMAIL_SMTP_SERVER": preset["smtp_server"],
            "EMAIL_IMAP_PORT": str(preset["imap_port"]),
            "EMAIL_SMTP_PORT": str(preset["smtp_port"]),
        }
        for k, v in env_vars.items():
            os.environ[k] = v

        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            logger.error(f"Failed to save .env: {e}")

        # Save to multi-account registry
        try:
            from familiar.skills.email.accounts import add_account, _unique_id, _load_accounts_file

            data = _load_accounts_file()
            acct_id = _unique_id("proton", data["accounts"])
            account = {
                "id": acct_id,
                "address": email,
                "password": bridge_password,
                "imap_server": preset["imap_server"],
                "smtp_server": preset["smtp_server"],
                "imap_port": preset["imap_port"],
                "smtp_port": preset["smtp_port"],
                "provider": "proton",
            }
            add_account(account)
        except Exception as e:
            logger.warning(f"Failed to save to account registry: {e}")

        await self.wizard_send(recipient_id, "\U0001f50d Testing IMAP connection...")
        success, result = _test_imap_connection(env_vars)
        self._wizard_state.pop(recipient_id, None)

        if success:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Proton Mail connected!', m)}\n\n"
                f"  Address: {fmt_code(email, m)}\n"
                f"  IMAP: {fmt_code('127.0.0.1:1143', m)} \u2705\n"
                f"  {result}\n\n"
                f"Bridge is running in the background.\n\n"
                f'Try: "Check my email" or "Search for messages from..."\n\n'
                f"{fmt_bold('Add another account?', m)}\n"
                f"  {fmt_code('/connect email', m)}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f {fmt_bold('Credentials saved but connection failed', m)}\n\n"
                f"  Error: {result}\n\n"
                f"Make sure Bridge is running, then try:\n"
                f"  {fmt_code('/connect email test', m)}",
            )

    async def _proton_extract_bridge_password(self, bridge_bin: str, email: str):
        import re

        try:
            if bridge_bin.startswith("flatpak run"):
                cmd = bridge_bin.split() + ["--cli"]
            else:
                cmd = [bridge_bin, "--cli"]

            proc = await asyncio.create_subprocess_exec(
                *cmd, stdin=asyncio.subprocess.PIPE,
                stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
            )
            proc.stdin.write(b"info\n")
            await proc.stdin.drain()
            await asyncio.sleep(2)
            proc.stdin.write(b"exit\n")
            await proc.stdin.drain()

            try:
                stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15)
            except asyncio.TimeoutError:
                proc.kill()
                return None

            output = stdout.decode(errors="replace")
            match = re.search(r"[Pp]assword:\s*(\S+)", output)
            if match:
                pwd = match.group(1)
                if len(pwd) >= 8 and pwd.lower() not in ("password:", "password"):
                    return pwd
            return None
        except Exception as e:
            logger.error(f"Bridge password extraction error: {e}")
            return None

    @staticmethod
    async def _proton_start_background(bridge_bin: str):
        import socket

        try:
            if bridge_bin.startswith("flatpak run"):
                cmd = bridge_bin.split() + ["--noninteractive"]
            else:
                cmd = [bridge_bin, "--noninteractive"]

            try:
                with socket.create_connection(("127.0.0.1", 1143), timeout=2):
                    logger.info("Bridge already running (port 1143 open)")
                    return
            except (OSError, ConnectionRefusedError, TimeoutError):
                pass

            await asyncio.create_subprocess_exec(
                *cmd, stdout=asyncio.subprocess.DEVNULL,
                stderr=asyncio.subprocess.DEVNULL, start_new_session=True,
            )
            logger.info("Started Proton Mail Bridge in background")
        except Exception as e:
            logger.warning(f"Could not start Bridge background: {e}")

    @staticmethod
    async def _proton_read_until_prompt(stream, max_bytes=8192):
        chunks = []
        while True:
            try:
                chunk = await asyncio.wait_for(stream.read(max_bytes), timeout=3)
                if not chunk:
                    break
                chunks.append(chunk)
            except asyncio.TimeoutError:
                break
        return b"".join(chunks).decode(errors="replace")

    async def _proton_fallback_manual(self, recipient_id: str, reason: str):
        m = self.format_mode
        self._ensure_wizard_state()
        self._wizard_state.pop(recipient_id, None)
        preset = EMAIL_PRESETS["proton"]
        await self.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f {fmt_bold('Automated setup could not complete', m)}\n\n"
            f"{reason}\n\n"
            f"{fmt_bold('Manual setup:', m)}\n"
            f"{_password_help('proton', m)}\n"
            f"Then send:\n"
            f"  {fmt_code('/connect email proton your@email.com BRIDGE_PASSWORD', m)}",
        )

    # ── SMS (Twilio) wizard ──────────────────────────────────────────

    async def _wizard_sms(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0] if args else ""

        if sub == "test":
            to_number = args[1] if len(args) > 1 else ""
            await self._wizard_sms_test(recipient_id, to_number)
            return

        if sub.startswith("AC") and len(args) >= 3:
            await self._wizard_sms_save(recipient_id, args[0], args[1], args[2])
            return

        has_sid = bool(os.environ.get("TWILIO_ACCOUNT_SID"))
        has_token = bool(os.environ.get("TWILIO_AUTH_TOKEN"))
        has_phone = bool(os.environ.get("TWILIO_PHONE_NUMBER"))
        has_lib = _check_twilio_lib()

        if has_sid and has_token and has_phone:
            phone = os.environ.get("TWILIO_PHONE_NUMBER", "")
            await self.wizard_send(
                recipient_id,
                f"\U0001f4f1 {fmt_bold('SMS Configuration', m)}\n\n"
                f"  \u2705 Twilio SID: {fmt_code(os.environ.get('TWILIO_ACCOUNT_SID', '')[:8] + '...', m)}\n"
                f"  \u2705 Auth Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n"
                f"  \u2705 Phone: {fmt_code(phone, m)}\n"
                f"  {'\u2705' if has_lib else '\u274c'} twilio package\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect sms test +1XXXXXXXXXX', m)} \u2014 send test\n\n"
                f"Cost: ~$1/month + $0.0079/SMS",
            )
            return

        await self.wizard_send(
            recipient_id,
            f"\U0001f4f1 {fmt_bold('Connect SMS (Twilio)', m)}\n\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Create account at https://www.twilio.com\n"
            f"2. Get a phone number (~$1/month)\n"
            f"3. Find credentials on Twilio Console dashboard\n\n"
            f"{fmt_bold('One-shot setup (3 values, space-separated):', m)}\n"
            f"  {fmt_code('/connect sms <SID> <TOKEN> <PHONE>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect sms ACe0a988d7... 9f3c8b... +15551234567', m)}\n\n"
            f"  \u2022 SID starts with {fmt_bold('AC', m)} (from Console > Account Info)\n"
            f"  \u2022 Token is the 32-char hex string below it\n"
            f"  \u2022 Phone is your Twilio number (not your personal number)\n\n"
            f"Cost: ~$1/month + $0.0079/SMS sent",
        )

    async def _wizard_sms_save(self, recipient_id: str, sid: str, token: str, phone: str):
        m = self.format_mode
        env_vars = {"TWILIO_ACCOUNT_SID": sid, "TWILIO_AUTH_TOKEN": token, "TWILIO_PHONE_NUMBER": phone}
        for k, v in env_vars.items():
            os.environ[k] = v
        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
            return

        has_lib = _check_twilio_lib()
        await self.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('SMS configured!', m)}\n\n"
            f"  SID: {fmt_code(sid[:8] + '...', m)}\n"
            f"  Phone: {fmt_code(phone, m)}\n"
            f"  twilio package: {'\u2705' if has_lib else '\u274c (pip install twilio)'}\n\n"
            f"Test: {fmt_code('/connect sms test +1XXXXXXXXXX', m)}",
        )

    async def _wizard_sms_test(self, recipient_id: str, to_number: str):
        m = self.format_mode
        if not to_number:
            await self.wizard_send(
                recipient_id,
                f"Usage: {fmt_code('/connect sms test +1XXXXXXXXXX', m)}",
            )
            return
        if not os.environ.get("TWILIO_ACCOUNT_SID"):
            await self.wizard_send(recipient_id, "\u26a0\ufe0f Twilio not configured. Use /connect sms")
            return
        try:
            from twilio.rest import Client

            client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
            message = client.messages.create(
                body="Hello from Familiar! \U0001f40d SMS is working.",
                from_=os.environ["TWILIO_PHONE_NUMBER"],
                to=to_number,
            )
            await self.wizard_send(recipient_id, f"\u2705 Test SMS sent to {to_number}\nSID: {message.sid}")
        except ImportError:
            await self.wizard_send(recipient_id, "\u274c twilio package not installed.\nRun: pip install twilio")
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u274c SMS failed: {e}")

    # ── Browser (Playwright) checker ─────────────────────────────────

    async def _wizard_browser(self, recipient_id: str):
        m = self.format_mode
        has_playwright = False
        has_chromium = False

        try:
            import playwright  # noqa: F401
            has_playwright = True
        except ImportError:
            pass

        if has_playwright:
            try:
                from playwright.sync_api import sync_playwright
                with sync_playwright() as p:
                    browser = p.chromium.launch(headless=True)
                    browser.close()
                    has_chromium = True
            except Exception:
                pass

        ck, cr = "\u2705", "\u274c"
        if has_playwright and has_chromium:
            await self.wizard_send(
                recipient_id,
                f"\U0001f310 {fmt_bold('Browser Automation', m)}\n\n"
                f"  {ck} playwright package\n"
                f"  {ck} Chromium browser\n\n"
                f'Ready! Try: "Search the web for..."\n\n'
                f"{fmt_italic('Requires TRUSTED or OWNER trust level.', m)}",
            )
            return

        lines = [
            f"\U0001f310 {fmt_bold('Browser Automation Setup', m)}\n",
            f"  {ck if has_playwright else cr} playwright package",
            f"  {ck if has_chromium else cr} Chromium browser\n",
        ]
        if not has_playwright:
            lines.append(
                f"{fmt_bold('Install on the server:', m)}\n"
                f"  {fmt_code('pip install playwright', m)}\n"
                f"  {fmt_code('playwright install chromium', m)}\n"
            )
        elif not has_chromium:
            lines.append(f"{fmt_bold('Install Chromium:', m)}\n  {fmt_code('playwright install chromium', m)}\n")

        lines.append(fmt_italic("Chromium requires ~150MB and system libraries.", m))
        await self.wizard_send(recipient_id, "\n".join(lines))

    # ── Voice (Whisper/TTS) checker ──────────────────────────────────

    async def _wizard_voice(self, recipient_id: str):
        m = self.format_mode
        has_faster_whisper = False
        has_whisper = False
        has_piper = False

        try:
            import faster_whisper  # noqa: F401
            has_faster_whisper = True
        except ImportError:
            pass
        try:
            import whisper  # noqa: F401
            has_whisper = True
        except ImportError:
            pass
        try:
            import piper  # noqa: F401
            has_piper = True
        except ImportError:
            pass

        has_openai_tts = bool(os.environ.get("OPENAI_API_KEY"))
        ck, em = "\u2705", "\u2b1c"
        has_any_stt = has_faster_whisper or has_whisper
        has_any_tts = has_piper or has_openai_tts

        lines = [
            f"\U0001f3a4 {fmt_bold('Voice & Transcription', m)}\n",
            f"{fmt_bold('Speech-to-Text:', m)}",
            f"  {ck if has_faster_whisper else em} faster-whisper (recommended, fast)",
            f"  {ck if has_whisper else em} openai-whisper (original, slower)",
            "",
            f"{fmt_bold('Text-to-Speech:', m)}",
            f"  {ck if has_piper else em} piper-tts (local, fast)",
            f"  {ck if has_openai_tts else em} OpenAI TTS (cloud, high quality)",
            "",
        ]

        if has_any_stt:
            engine = "faster-whisper" if has_faster_whisper else "openai-whisper"
            lines.append(f"\u2705 Transcription ready ({engine})")
        else:
            lines.extend([
                f"{fmt_bold('Install on the server:', m)}",
                f"  {fmt_code('pip install faster-whisper', m)}  (recommended)",
                f"  \u2014 or \u2014",
                f"  {fmt_code('pip install openai-whisper', m)}",
                "",
            ])

        if not has_any_tts:
            lines.extend([
                f"{fmt_bold('For TTS:', m)}",
                f"  {fmt_code('pip install piper-tts', m)}  (local)",
                f"  \u2014 or set OPENAI_API_KEY for cloud TTS \u2014",
            ])

        lines.append(f"\n{fmt_italic('Whisper models download on first use (~75MB for tiny).', m)}")
        await self.wizard_send(recipient_id, "\n".join(lines))

    # ── Healthcare / Compliance ──────────────────────────────────────

    async def _wizard_healthcare(self, recipient_id: str, args: list):
        """``/connect healthcare [hipaa|pii|disable]``"""
        m = self.format_mode

        agent = getattr(self, "agent", None)
        compliance_mode = getattr(agent, "compliance_mode", None)
        compliance_str = compliance_mode.value if hasattr(compliance_mode, "value") else str(compliance_mode or "none")
        pii_cfg = getattr(getattr(agent, "config", None), "agent", None)
        pii_on = getattr(pii_cfg, "enable_pii_detection", False) if pii_cfg else False

        if not args:
            # Show current status + options menu
            if compliance_str == "hipaa":
                current = "HIPAA mode active (encryption + PII blocking + audit logging)"
            elif pii_on:
                current = "PII detection enabled (redact mode)"
            else:
                current = "Not enabled"

            lines = [
                f"\U0001f3e5 {fmt_bold('Healthcare / Compliance', m)}\n",
                f"{fmt_bold('Current:', m)} {current}\n",
                f"{fmt_bold('Options:', m)}",
                f"  \u2022 {fmt_bold('HIPAA', m)} \u2014 encryption + PII blocking + audit logging",
                f"  \u2022 {fmt_bold('PII only', m)} \u2014 redact sensitive data (SSN, email, phone), no full HIPAA",
                f"  \u2022 {fmt_bold('Disable', m)} \u2014 no compliance features\n",
                f"Commands:",
                f"  {fmt_code('/connect healthcare hipaa', m)}",
                f"  {fmt_code('/connect healthcare pii', m)}",
                f"  {fmt_code('/connect healthcare disable', m)}",
            ]

            options = [
                ("healthcare_hipaa", "\U0001f3e5 Enable HIPAA"),
                ("healthcare_pii", "\U0001f50d PII Only"),
                ("healthcare_disable", "\u274c Disable"),
            ]

            await self.wizard_send_menu(recipient_id, "\n".join(lines), options)
            return

        action = args[0].lower()

        if action == "hipaa":
            try:
                _update_yaml_config({
                    "compliance": {"mode": "hipaa", "require_encryption": True, "log_phi_access": True},
                    "agent": {"enable_pii_detection": True},
                })
                # Live-update agent config if available
                if agent:
                    if hasattr(agent, "config") and hasattr(agent.config, "compliance"):
                        agent.config.compliance.mode = "hipaa"
                        agent.config.compliance.require_encryption = True
                        agent.config.compliance.log_phi_access = True
                    if hasattr(agent, "config") and hasattr(agent.config, "agent"):
                        agent.config.agent.enable_pii_detection = True
                    if hasattr(agent, "guardrails"):
                        agent.guardrails.set_compliance_mode("hipaa")
                await self.wizard_send(
                    recipient_id,
                    f"{fmt_bold('HIPAA mode enabled', m)} \u2705\n\n"
                    f"\u2022 PII detection: {fmt_bold('BLOCK', m)} (SSN, email, phone, credit card)\n"
                    f"\u2022 Encryption: required\n"
                    f"\u2022 PHI audit logging: on\n\n"
                    f"Saved to {fmt_code('~/.familiar/config.yaml', m)}",
                )
            except Exception as e:
                await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to enable HIPAA mode: {e}")

        elif action == "pii":
            try:
                _update_yaml_config({
                    "compliance": {"mode": "none"},
                    "agent": {"enable_pii_detection": True},
                })
                if agent:
                    if hasattr(agent, "config") and hasattr(agent.config, "compliance"):
                        agent.config.compliance.mode = "none"
                    if hasattr(agent, "config") and hasattr(agent.config, "agent"):
                        agent.config.agent.enable_pii_detection = True
                    # Ensure guardrails has a PII detector in redact mode
                    if hasattr(agent, "guardrails"):
                        g = agent.guardrails
                        if not g._pii_detector:
                            from familiar.core.guardrails import PIIConfig, PIIDetector
                            g._pii_detector = PIIDetector(PIIConfig())
                            g.pii_config = g._pii_detector.config
                await self.wizard_send(
                    recipient_id,
                    f"{fmt_bold('PII detection enabled', m)} \u2705\n\n"
                    f"\u2022 Action: {fmt_bold('REDACT', m)} (SSN, email, phone, credit card)\n"
                    f"\u2022 Compliance mode: none (no HIPAA overhead)\n\n"
                    f"Saved to {fmt_code('~/.familiar/config.yaml', m)}",
                )
            except Exception as e:
                await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to enable PII detection: {e}")

        elif action == "disable":
            try:
                _update_yaml_config({
                    "compliance": {"mode": "none", "require_encryption": False, "log_phi_access": False},
                    "agent": {"enable_pii_detection": False},
                })
                if agent:
                    if hasattr(agent, "config") and hasattr(agent.config, "compliance"):
                        agent.config.compliance.mode = "none"
                        agent.config.compliance.require_encryption = False
                        agent.config.compliance.log_phi_access = False
                    if hasattr(agent, "config") and hasattr(agent.config, "agent"):
                        agent.config.agent.enable_pii_detection = False
                    if hasattr(agent, "guardrails"):
                        agent.guardrails._pii_detector = None
                        agent.guardrails.pii_config = None
                await self.wizard_send(
                    recipient_id,
                    f"{fmt_bold('Healthcare / compliance disabled', m)} \u2705\n\n"
                    f"\u2022 PII detection: off\n"
                    f"\u2022 Compliance mode: none\n\n"
                    f"Saved to {fmt_code('~/.familiar/config.yaml', m)}",
                )
            except Exception as e:
                await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to disable compliance: {e}")

        else:
            await self.wizard_send(
                recipient_id,
                f"Unknown healthcare option: {action}\n\n"
                f"Use: {fmt_code('/connect healthcare hipaa', m)}, "
                f"{fmt_code('/connect healthcare pii', m)}, or "
                f"{fmt_code('/connect healthcare disable', m)}",
            )

    # ── Self-hosted: Jellyfin ────────────────────────────────────────

    async def _wizard_jellyfin(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0] if args else ""

        if sub == "test":
            await self._wizard_jellyfin_test(recipient_id)
            return

        if sub.startswith("http") and len(args) >= 3:
            await self._wizard_jellyfin_save(recipient_id, args[0], args[1], args[2])
            return

        has_url = os.environ.get("JELLYFIN_URL", "")
        has_token = bool(os.environ.get("JELLYFIN_TOKEN"))
        has_user_id = bool(os.environ.get("JELLYFIN_USER_ID"))

        if has_url and has_token and has_user_id:
            await self.wizard_send(
                recipient_id,
                f"\U0001f3ac {fmt_bold('Jellyfin Configuration', m)}\n\n"
                f"  \u2705 URL: {fmt_code(has_url, m)}\n"
                f"  \u2705 API Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n"
                f"  \u2705 User ID: {fmt_code(os.environ.get('JELLYFIN_USER_ID', '')[:8] + '...', m)}\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect jellyfin test', m)} \u2014 test connection\n",
            )
            return

        await self.wizard_send(
            recipient_id,
            f"\U0001f3ac {fmt_bold('Connect Jellyfin', m)}\n\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Open Jellyfin Dashboard \u2192 API Keys \u2192 Create\n"
            f"2. Copy the API key\n"
            f"3. Go to Users \u2192 click your user \u2192 copy the GUID from the URL\n"
            f"   (the long hex string like {fmt_code('a1b2c3d4e5f6...', m)})\n\n"
            f"{fmt_bold('One-shot setup (3 values, space-separated):', m)}\n"
            f"  {fmt_code('/connect jellyfin <URL> <TOKEN> <USER_ID>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect jellyfin https://jellyfin.example.org abc123 a1b2c3d4...', m)}",
        )

    async def _wizard_jellyfin_save(self, recipient_id: str, url: str, token: str, user_id: str):
        m = self.format_mode
        url = url.rstrip("/")
        env_vars = {"JELLYFIN_URL": url, "JELLYFIN_TOKEN": token, "JELLYFIN_USER_ID": user_id}
        for k, v in env_vars.items():
            os.environ[k] = v
        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
            return

        await self.wizard_send(recipient_id, "Testing Jellyfin connection...")
        await self._wizard_jellyfin_test(recipient_id)

    async def _wizard_jellyfin_test(self, recipient_id: str):
        m = self.format_mode
        url = os.environ.get("JELLYFIN_URL", "")
        token = os.environ.get("JELLYFIN_TOKEN", "")
        if not url or not token:
            await self.wizard_send(
                recipient_id,
                f"\u274c No Jellyfin credentials configured.\n"
                f"Run: {fmt_code('/connect jellyfin', m)}",
            )
            return
        ok, msg = _test_http_service(
            f"{url}/Users",
            headers={"X-Emby-Token": token},
        )
        if ok:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Jellyfin connected!', m)}\n\n"
                f"  Server: {fmt_code(url, m)}\n"
                f"  Status: {msg}\n\n"
                f"Saved to: {fmt_code('~/.familiar/.env', m)}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u274c {fmt_bold('Jellyfin connection failed', m)}\n\n"
                f"  {msg}\n\n"
                f"Check your URL and API token.",
            )

    # ── Self-hosted: Gitea ───────────────────────────────────────────

    async def _wizard_gitea(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0] if args else ""

        if sub == "test":
            await self._wizard_gitea_test(recipient_id)
            return

        if sub.startswith("http") and len(args) >= 2:
            await self._wizard_gitea_save(recipient_id, args[0], args[1])
            return

        has_url = os.environ.get("GITEA_URL", "")
        has_token = bool(os.environ.get("GITEA_TOKEN"))

        if has_url and has_token:
            await self.wizard_send(
                recipient_id,
                f"\U0001f419 {fmt_bold('Gitea Configuration', m)}\n\n"
                f"  \u2705 URL: {fmt_code(has_url, m)}\n"
                f"  \u2705 Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect gitea test', m)} \u2014 test connection\n",
            )
            return

        await self.wizard_send(
            recipient_id,
            f"\U0001f419 {fmt_bold('Connect Gitea / Forgejo', m)}\n\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Go to Settings \u2192 Applications \u2192 Generate Token\n"
            f"2. Copy the access token\n\n"
            f"{fmt_bold('One-shot setup (2 values, space-separated):', m)}\n"
            f"  {fmt_code('/connect gitea <URL> <TOKEN>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect gitea https://gitea.example.org gta_abc123...', m)}\n\n"
            f"{fmt_italic('Also works with Forgejo instances.', m)}",
        )

    async def _wizard_gitea_save(self, recipient_id: str, url: str, token: str):
        m = self.format_mode
        url = url.rstrip("/")
        env_vars = {"GITEA_URL": url, "GITEA_TOKEN": token}
        for k, v in env_vars.items():
            os.environ[k] = v
        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
            return

        await self.wizard_send(recipient_id, "Testing Gitea connection...")
        await self._wizard_gitea_test(recipient_id)

    async def _wizard_gitea_test(self, recipient_id: str):
        m = self.format_mode
        url = os.environ.get("GITEA_URL", "")
        token = os.environ.get("GITEA_TOKEN", "")
        if not url or not token:
            await self.wizard_send(
                recipient_id,
                f"\u274c No Gitea credentials configured.\n"
                f"Run: {fmt_code('/connect gitea', m)}",
            )
            return
        ok, msg = _test_http_service(
            f"{url}/api/v1/repos/search",
            headers={"Authorization": f"token {token}"},
        )
        if ok:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Gitea connected!', m)}\n\n"
                f"  Server: {fmt_code(url, m)}\n"
                f"  Status: {msg}\n\n"
                f"Saved to: {fmt_code('~/.familiar/.env', m)}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u274c {fmt_bold('Gitea connection failed', m)}\n\n"
                f"  {msg}\n\n"
                f"Check your URL and access token.",
            )

    # ── Self-hosted: Home Assistant ──────────────────────────────────

    async def _wizard_homeassistant(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0] if args else ""

        if sub == "test":
            await self._wizard_homeassistant_test(recipient_id)
            return

        if sub.startswith("http") and len(args) >= 2:
            await self._wizard_homeassistant_save(recipient_id, args[0], args[1])
            return

        has_url = os.environ.get("HOMEASSISTANT_URL", "")
        has_token = bool(os.environ.get("HOMEASSISTANT_TOKEN"))

        if has_url and has_token:
            await self.wizard_send(
                recipient_id,
                f"\U0001f3e0 {fmt_bold('Home Assistant Configuration', m)}\n\n"
                f"  \u2705 URL: {fmt_code(has_url, m)}\n"
                f"  \u2705 Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect homeassistant test', m)} \u2014 test connection\n",
            )
            return

        await self.wizard_send(
            recipient_id,
            f"\U0001f3e0 {fmt_bold('Connect Home Assistant', m)}\n\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Open Home Assistant \u2192 Profile (bottom-left)\n"
            f"2. Scroll to Long-Lived Access Tokens \u2192 Create Token\n"
            f"3. Copy the token\n\n"
            f"{fmt_bold('One-shot setup (2 values, space-separated):', m)}\n"
            f"  {fmt_code('/connect homeassistant <URL> <TOKEN>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect homeassistant http://homeassistant.local:8123 eyJ0...', m)}\n\n"
            f"{fmt_italic('Aliases: /connect hass, /connect home-assistant', m)}",
        )

    async def _wizard_homeassistant_save(self, recipient_id: str, url: str, token: str):
        m = self.format_mode
        url = url.rstrip("/")
        env_vars = {"HOMEASSISTANT_URL": url, "HOMEASSISTANT_TOKEN": token}
        for k, v in env_vars.items():
            os.environ[k] = v
        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
            return

        await self.wizard_send(recipient_id, "Testing Home Assistant connection...")
        await self._wizard_homeassistant_test(recipient_id)

    async def _wizard_homeassistant_test(self, recipient_id: str):
        m = self.format_mode
        url = os.environ.get("HOMEASSISTANT_URL", "")
        token = os.environ.get("HOMEASSISTANT_TOKEN", "")
        if not url or not token:
            await self.wizard_send(
                recipient_id,
                f"\u274c No Home Assistant credentials configured.\n"
                f"Run: {fmt_code('/connect homeassistant', m)}",
            )
            return
        ok, msg = _test_http_service(
            f"{url}/api/",
            headers={"Authorization": f"Bearer {token}"},
        )
        if ok:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Home Assistant connected!', m)}\n\n"
                f"  Server: {fmt_code(url, m)}\n"
                f"  Status: {msg}\n\n"
                f"Saved to: {fmt_code('~/.familiar/.env', m)}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u274c {fmt_bold('Home Assistant connection failed', m)}\n\n"
                f"  {msg}\n\n"
                f"Check your URL and long-lived access token.",
            )

    # ── Self-hosted: Joplin ──────────────────────────────────────────

    async def _wizard_joplin(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0] if args else ""

        if sub == "test":
            await self._wizard_joplin_test(recipient_id)
            return

        # /connect joplin <TOKEN> [URL]
        if args and not sub.startswith("http") and sub != "test":
            token = args[0]
            url = args[1] if len(args) > 1 else "http://localhost:41184"
            await self._wizard_joplin_save(recipient_id, token, url)
            return

        has_token = bool(os.environ.get("JOPLIN_TOKEN"))
        has_url = os.environ.get("JOPLIN_URL", "http://localhost:41184")

        if has_token:
            await self.wizard_send(
                recipient_id,
                f"\U0001f4dd {fmt_bold('Joplin Configuration', m)}\n\n"
                f"  \u2705 URL: {fmt_code(has_url, m)}\n"
                f"  \u2705 Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect joplin test', m)} \u2014 test connection\n",
            )
            return

        await self.wizard_send(
            recipient_id,
            f"\U0001f4dd {fmt_bold('Connect Joplin', m)}\n\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Open Joplin desktop \u2192 Options \u2192 Web Clipper\n"
            f"2. Enable the Web Clipper service\n"
            f"3. Copy the authorization token\n\n"
            f"{fmt_bold('One-shot setup:', m)}\n"
            f"  {fmt_code('/connect joplin <TOKEN> [URL]', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect joplin abc123def456', m)}\n"
            f"  {fmt_code('/connect joplin abc123 http://192.168.1.5:41184', m)}\n\n"
            f"{fmt_italic('URL defaults to http://localhost:41184', m)}",
        )

    async def _wizard_joplin_save(self, recipient_id: str, token: str, url: str):
        m = self.format_mode
        url = url.rstrip("/")
        env_vars = {"JOPLIN_TOKEN": token, "JOPLIN_URL": url}
        for k, v in env_vars.items():
            os.environ[k] = v
        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
            return

        await self.wizard_send(recipient_id, "Testing Joplin connection...")
        await self._wizard_joplin_test(recipient_id)

    async def _wizard_joplin_test(self, recipient_id: str):
        m = self.format_mode
        token = os.environ.get("JOPLIN_TOKEN", "")
        url = os.environ.get("JOPLIN_URL", "http://localhost:41184")
        if not token:
            await self.wizard_send(
                recipient_id,
                f"\u274c No Joplin token configured.\n"
                f"Run: {fmt_code('/connect joplin', m)}",
            )
            return
        ok, msg = _test_http_service(
            f"{url}/folders",
            params={"token": token},
        )
        if ok:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Joplin connected!', m)}\n\n"
                f"  URL: {fmt_code(url, m)}\n"
                f"  Status: {msg}\n\n"
                f"Saved to: {fmt_code('~/.familiar/.env', m)}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u274c {fmt_bold('Joplin connection failed', m)}\n\n"
                f"  {msg}\n\n"
                f"Make sure Joplin is running and the Web Clipper is enabled.",
            )

    # ── Self-hosted: Pi-hole / AdGuard ───────────────────────────────

    async def _wizard_pihole(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0] if args else ""

        if sub == "test":
            await self._wizard_pihole_test(recipient_id)
            return

        if sub.startswith("http") and len(args) >= 2:
            pihole_type = "adguard" if (len(args) > 2 and args[2].lower() == "adguard") else "pihole"
            await self._wizard_pihole_save(recipient_id, args[0], args[1], pihole_type)
            return

        has_url = os.environ.get("PIHOLE_URL", "")
        has_token = bool(os.environ.get("PIHOLE_TOKEN"))
        pihole_type = os.environ.get("PIHOLE_TYPE", "pihole")

        if has_url and has_token:
            await self.wizard_send(
                recipient_id,
                f"\U0001f6e1\ufe0f {fmt_bold('Pi-hole / AdGuard Configuration', m)}\n\n"
                f"  \u2705 URL: {fmt_code(has_url, m)}\n"
                f"  \u2705 Token/Auth: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n"
                f"  Type: {fmt_bold(pihole_type, m)}\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect pihole test', m)} \u2014 test connection\n",
            )
            return

        await self.wizard_send(
            recipient_id,
            f"\U0001f6e1\ufe0f {fmt_bold('Connect Pi-hole / AdGuard', m)}\n\n"
            f"{fmt_bold('Pi-hole setup:', m)}\n"
            f"1. Open Pi-hole Admin \u2192 Settings \u2192 API\n"
            f"2. Copy the API token (or set one)\n\n"
            f"{fmt_bold('AdGuard Home setup:', m)}\n"
            f"1. Note your AdGuard admin username and password\n"
            f"2. Format as {fmt_code('username:password', m)} for the token field\n\n"
            f"{fmt_bold('One-shot setup:', m)}\n"
            f"  {fmt_code('/connect pihole <URL> <TOKEN> [adguard]', m)}\n\n"
            f"{fmt_bold('Examples:', m)}\n"
            f"  {fmt_code('/connect pihole http://pi.hole abc123', m)}\n"
            f"  {fmt_code('/connect pihole http://192.168.1.1:3000 admin:pass adguard', m)}\n\n"
            f"{fmt_italic('Add \"adguard\" at the end for AdGuard Home instances.', m)}",
        )

    async def _wizard_pihole_save(self, recipient_id: str, url: str, token: str, pihole_type: str):
        m = self.format_mode
        url = url.rstrip("/")
        env_vars = {"PIHOLE_URL": url, "PIHOLE_TOKEN": token, "PIHOLE_TYPE": pihole_type}
        for k, v in env_vars.items():
            os.environ[k] = v
        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            await self.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
            return

        await self.wizard_send(recipient_id, f"Testing {pihole_type} connection...")
        await self._wizard_pihole_test(recipient_id)

    async def _wizard_pihole_test(self, recipient_id: str):
        m = self.format_mode
        url = os.environ.get("PIHOLE_URL", "")
        token = os.environ.get("PIHOLE_TOKEN", "")
        pihole_type = os.environ.get("PIHOLE_TYPE", "pihole")
        if not url or not token:
            await self.wizard_send(
                recipient_id,
                f"\u274c No Pi-hole/AdGuard credentials configured.\n"
                f"Run: {fmt_code('/connect pihole', m)}",
            )
            return
        if pihole_type == "adguard":
            parts = token.split(":", 1)
            user = parts[0]
            passwd = parts[1] if len(parts) > 1 else ""
            ok, msg = _test_http_service(
                f"{url}/control/status",
                auth=(user, passwd),
            )
        else:
            ok, msg = _test_http_service(
                f"{url}/admin/api.php",
                params={"status": "", "auth": token},
            )
        if ok:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold(f'{pihole_type.title()} connected!', m)}\n\n"
                f"  Server: {fmt_code(url, m)}\n"
                f"  Status: {msg}\n\n"
                f"Saved to: {fmt_code('~/.familiar/.env', m)}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u274c {fmt_bold(f'{pihole_type.title()} connection failed', m)}\n\n"
                f"  {msg}\n\n"
                f"Check your URL and authentication.",
            )

    # ── Self-hosted: Nextcloud (multi-step) ──────────────────────────

    async def _wizard_nextcloud(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0] if args else ""

        if sub == "test":
            await self._wizard_nextcloud_test(recipient_id)
            return

        # One-shot: /connect nextcloud <URL> <USER> <TOKEN>
        if sub.startswith("http") and len(args) >= 3:
            await self._nextcloud_save_and_test(recipient_id, args[0], args[1], args[2])
            return

        has_url = os.environ.get("NEXTCLOUD_URL", "")
        has_user = bool(os.environ.get("NEXTCLOUD_USER"))
        has_token = bool(os.environ.get("NEXTCLOUD_TOKEN"))

        if has_url and has_user and has_token:
            await self.wizard_send(
                recipient_id,
                f"\u2601\ufe0f {fmt_bold('Nextcloud Configuration', m)}\n\n"
                f"  \u2705 URL: {fmt_code(has_url, m)}\n"
                f"  \u2705 User: {fmt_code(os.environ.get('NEXTCLOUD_USER', ''), m)}\n"
                f"  \u2705 App Password: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n\n"
                f"{fmt_bold('Commands:', m)}\n"
                f"  {fmt_code('/connect nextcloud test', m)} \u2014 test connection\n",
            )
            return

        # Start interactive wizard or show instructions
        if self.supports_buttons:
            await self._start_nextcloud_wizard(recipient_id)
        else:
            await self.wizard_send(
                recipient_id,
                f"\u2601\ufe0f {fmt_bold('Connect Nextcloud', m)}\n\n"
                f"{fmt_bold('Setup:', m)}\n"
                f"1. Open Nextcloud \u2192 Settings \u2192 Security\n"
                f"2. Create an App password (name it 'Familiar')\n"
                f"3. Copy the generated password\n\n"
                f"{fmt_bold('One-shot setup (3 values, space-separated):', m)}\n"
                f"  {fmt_code('/connect nextcloud <URL> <USER> <APP_PASSWORD>', m)}\n\n"
                f"{fmt_bold('Example:', m)}\n"
                f"  {fmt_code('/connect nextcloud https://cloud.example.org alice AbCdE-fGhIj-KlMnO-pQrSt-uVwXy', m)}\n\n"
                f"{fmt_italic('Also configures CalDAV calendar automatically.', m)}",
            )

    async def _start_nextcloud_wizard(self, recipient_id: str):
        self._ensure_wizard_state()
        m = self.format_mode
        self._wizard_state[recipient_id] = {"type": "nextcloud", "step": "url"}
        await self.wizard_send(
            recipient_id,
            f"\u2601\ufe0f {fmt_bold('Nextcloud Setup', m)}\n\n"
            f"Nextcloud provides files, calendar, contacts, and more.\n"
            f"This wizard will configure access and auto-setup CalDAV.\n\n"
            f"Enter your {fmt_bold('Nextcloud URL', m)}:\n\n"
            f"{fmt_italic('Example: https://cloud.example.org', m)}",
        )

    async def _nextcloud_handle_step(self, recipient_id: str, text: str, message_ref):
        self._ensure_wizard_state()
        m = self.format_mode
        state = self._wizard_state.get(recipient_id)
        if not state:
            return
        step = state["step"]
        text = text.strip()

        if step == "url":
            if not text.startswith(("http://", "https://")):
                await self.wizard_send(
                    recipient_id,
                    "That doesn't look like a URL. "
                    "Please enter a URL starting with http:// or https://:",
                )
                return
            state["url"] = text.rstrip("/")
            state["step"] = "user"
            await self.wizard_send(
                recipient_id,
                f"Enter your {fmt_bold('Nextcloud username', m)}:",
            )

        elif step == "user":
            if not text:
                await self.wizard_send(recipient_id, "Username cannot be empty. Please try again:")
                return
            state["user"] = text
            state["step"] = "password"

            warning = ""
            if not self.supports_message_deletion:
                warning = (
                    f"\n\n\u26a0\ufe0f {fmt_italic('This channel cannot delete messages. '
                    'Your password will remain visible in chat history. '
                    'Consider using one-shot: /connect nextcloud <URL> <USER> <PASS>', m)}"
                )

            await self.wizard_send(
                recipient_id,
                f"\U0001f510 Enter your {fmt_bold('Nextcloud App password', m)}:\n\n"
                f"Create one at: Settings \u2192 Security \u2192 App passwords"
                f"{warning}"
                + ("\n\n" + fmt_italic("Your message will be deleted immediately for safety.", m)
                   if self.supports_message_deletion else ""),
            )

        elif step == "password":
            if self.supports_message_deletion and message_ref is not None:
                try:
                    await self.wizard_delete_message(recipient_id, message_ref)
                except Exception:
                    pass

            url = state["url"]
            user = state["user"]
            self._wizard_state.pop(recipient_id, None)
            await self.wizard_send(recipient_id, "Testing Nextcloud connection...")
            await self._nextcloud_save_and_test(recipient_id, url, user, text)

    async def _nextcloud_save_and_test(self, recipient_id: str, url: str, user: str, token: str):
        m = self.format_mode
        url = url.rstrip("/")
        env_vars = {
            "NEXTCLOUD_URL": url,
            "NEXTCLOUD_USER": user,
            "NEXTCLOUD_TOKEN": token,
        }
        for k, v in env_vars.items():
            os.environ[k] = v
        env_path = Path.home() / ".familiar" / ".env"
        try:
            _update_env_file(env_path, env_vars)
        except Exception as e:
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Credentials set for this session but failed to persist: {e}",
            )
            return

        # Auto-derive CalDAV credentials from Nextcloud
        caldav_url = f"{url}/remote.php/dav/calendars/{user}/"
        caldav_vars = {
            "CALDAV_URL": caldav_url,
            "CALDAV_USER": user,
            "CALDAV_PASSWORD": token,
            "CALENDAR_PROVIDER": "caldav",
        }
        for k, v in caldav_vars.items():
            os.environ[k] = v
        try:
            _update_env_file(env_path, caldav_vars)
        except Exception:
            pass

        # Register CalDAV account in calendar account registry
        try:
            from familiar.skills.calendar.accounts import (
                _load_accounts_file, _unique_id, add_account,
            )
            data = _load_accounts_file()
            # Avoid duplicates: check if a caldav account with this URL exists
            existing = [a for a in data["accounts"] if a.get("type") == "caldav" and a.get("url") == caldav_url]
            if not existing:
                acct_id = _unique_id("caldav", data["accounts"])
                account = {
                    "id": acct_id, "type": "caldav",
                    "label": f"Nextcloud ({user})",
                    "url": caldav_url, "user": user, "password": token,
                }
                add_account(account)
        except Exception as e:
            logger.warning(f"Failed to register Nextcloud CalDAV account: {e}")

        # Test connection via WebDAV
        ok, msg = _test_http_service(
            f"{url}/remote.php/dav/files/{user}/",
            auth=(user, token),
            method="PROPFIND",
        )
        if ok:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Nextcloud connected!', m)}\n\n"
                f"  Server: {fmt_code(url, m)}\n"
                f"  User: {fmt_code(user, m)}\n"
                f"  WebDAV: {msg}\n"
                f"  CalDAV: auto-configured \u2705\n\n"
                f"Saved to: {fmt_code('~/.familiar/.env', m)}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f {fmt_bold('Nextcloud credentials saved', m)} but connection test failed:\n"
                f"  {msg}\n\n"
                f"Check your URL, username, and app password.\n"
                f"To retry: {fmt_code('/connect nextcloud test', m)}",
            )

    async def _wizard_nextcloud_test(self, recipient_id: str):
        m = self.format_mode
        url = os.environ.get("NEXTCLOUD_URL", "")
        user = os.environ.get("NEXTCLOUD_USER", "")
        token = os.environ.get("NEXTCLOUD_TOKEN", "")
        if not url or not user or not token:
            await self.wizard_send(
                recipient_id,
                f"\u274c No Nextcloud credentials configured.\n"
                f"Run: {fmt_code('/connect nextcloud', m)}",
            )
            return
        ok, msg = _test_http_service(
            f"{url}/remote.php/dav/files/{user}/",
            auth=(user, token),
            method="PROPFIND",
        )
        if ok:
            await self.wizard_send(
                recipient_id,
                f"\u2705 {fmt_bold('Nextcloud connected!', m)}\n\n"
                f"  Server: {fmt_code(url, m)}\n"
                f"  User: {fmt_code(user, m)}\n"
                f"  Status: {msg}",
            )
        else:
            await self.wizard_send(
                recipient_id,
                f"\u274c {fmt_bold('Nextcloud connection failed', m)}\n\n"
                f"  {msg}\n\n"
                f"Check your URL, username, and app password.",
            )

    # ── Self-hosted: Proton (status-only) ────────────────────────────

    async def _wizard_proton_services(self, recipient_id: str, args: list):
        m = self.format_mode
        sub = args[0] if args else ""

        if sub == "test":
            await self._wizard_proton_services_test(recipient_id)
            return

        bridge_imap = _check_tcp_port("127.0.0.1", 1143)
        bridge_smtp = _check_tcp_port("127.0.0.1", 1025)
        bridge_ok = bridge_imap and bridge_smtp

        import shutil
        has_vpn_cli = shutil.which("protonvpn-cli") is not None

        ck = "\u2705"
        cr = "\u274c"

        lines = [
            f"\U0001f512 {fmt_bold('Proton Services Status', m)}\n",
            f"{fmt_bold('Proton Bridge (Email):', m)}",
            f"  {ck if bridge_imap else cr} IMAP (port 1143)",
            f"  {ck if bridge_smtp else cr} SMTP (port 1025)",
            f"  Status: {fmt_bold('Running', m) if bridge_ok else fmt_bold('Not detected', m)}\n",
            f"{fmt_bold('Proton VPN:', m)}",
            f"  {ck if has_vpn_cli else cr} protonvpn-cli {'found' if has_vpn_cli else 'not found'}\n",
        ]

        if not bridge_ok:
            lines.extend([
                f"{fmt_bold('To start Bridge:', m)}",
                f"  {fmt_code('flatpak run ch.protonmail.protonmail-bridge', m)}",
                f"  or install from https://proton.me/mail/bridge\n",
            ])

        if not has_vpn_cli:
            lines.extend([
                f"{fmt_bold('To install VPN CLI:', m)}",
                f"  {fmt_code('pip install protonvpn-cli', m)}\n",
            ])

        lines.extend([
            f"{fmt_bold('Email setup:', m)}",
            f"  Use {fmt_code('/connect email proton', m)} to configure Proton email\n",
            f"{fmt_bold('Diagnostics:', m)}",
            f"  {fmt_code('/connect proton test', m)} \u2014 detailed connection test",
        ])

        await self.wizard_send(recipient_id, "\n".join(lines))

    async def _wizard_proton_services_test(self, recipient_id: str):
        m = self.format_mode
        ck = "\u2705"
        cr = "\u274c"

        bridge_imap = _check_tcp_port("127.0.0.1", 1143)
        bridge_smtp = _check_tcp_port("127.0.0.1", 1025)

        import shutil
        has_vpn_cli = shutil.which("protonvpn-cli") is not None

        vpn_status = "not installed"
        if has_vpn_cli:
            try:
                result = await asyncio.create_subprocess_exec(
                    "protonvpn-cli", "status",
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                stdout, _ = await asyncio.wait_for(result.communicate(), timeout=5)
                vpn_status = stdout.decode().strip()[:200] or "no output"
            except Exception as e:
                vpn_status = f"error: {e}"

        lines = [
            f"\U0001f512 {fmt_bold('Proton Diagnostics', m)}\n",
            f"{fmt_bold('Proton Bridge:', m)}",
            f"  {ck if bridge_imap else cr} IMAP \u2014 127.0.0.1:1143",
            f"  {ck if bridge_smtp else cr} SMTP \u2014 127.0.0.1:1025\n",
            f"{fmt_bold('Proton VPN:', m)}",
            f"  {ck if has_vpn_cli else cr} CLI: {'installed' if has_vpn_cli else 'not found'}",
            f"  Status: {fmt_code(vpn_status, m)}\n",
        ]

        has_email = bool(os.environ.get("EMAIL_ADDRESS"))
        if has_email:
            lines.append(f"  {ck} Email configured: {os.environ.get('EMAIL_ADDRESS', '')}")
        else:
            lines.append(
                f"  {cr} Email not configured \u2014 run {fmt_code('/connect email proton', m)}"
            )

        await self.wizard_send(recipient_id, "\n".join(lines))

    # ── Status overview ──────────────────────────────────────────────

    async def _wizard_status(self, recipient_id: str):
        m = self.format_mode
        ck, cr, em = "\u2705", "\u274c", "\u2b1c"

        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"))
        try:
            from familiar.skills.email.accounts import get_all_accounts
            email_accounts = get_all_accounts()
            has_email = bool(email_accounts)
        except Exception:
            email_accounts = []
            has_email = bool(os.environ.get("EMAIL_ADDRESS"))
        has_twilio = bool(os.environ.get("TWILIO_ACCOUNT_SID"))
        has_google = (Path.home() / ".familiar" / "data" / "google_credentials.json").exists()
        data_dir = Path.home() / ".familiar" / "data"
        has_gmail_token = (data_dir / "google_token_gmail.json").exists()
        has_calendar_token = (data_dir / "google_token.json").exists()
        has_drive_token = (data_dir / "google_token_drive.json").exists()
        has_caldav = bool(os.environ.get("CALDAV_URL"))

        has_browser = False
        try:
            import playwright  # noqa: F401
            has_browser = True
        except ImportError:
            pass

        has_voice = False
        try:
            import faster_whisper  # noqa: F401
            has_voice = True
        except ImportError:
            try:
                import whisper  # noqa: F401
                has_voice = True
            except ImportError:
                pass

        agent = getattr(self, "agent", None)
        current = agent.provider.name if agent else "unknown"
        email_detail = (
            f" ({', '.join(a['address'] for a in email_accounts)})"
            if email_accounts else ""
        )

        # Multi-account calendar status
        try:
            from familiar.skills.calendar.accounts import get_all_accounts as _cal_accts
            _cal_accounts = _cal_accts()
        except Exception:
            _cal_accounts = []

        if _cal_accounts:
            cal_parts = []
            for _ca in _cal_accounts:
                _cl = _ca.get("label") or _ca["id"]
                _ct = _ca.get("type", "?")
                cal_parts.append(f"{_cl}({_ct})")
            cal_status = f"{ck} Calendar: {', '.join(cal_parts)}"
        elif has_caldav:
            cal_status = f"{ck} CalDAV ({os.environ.get('CALDAV_URL', '')[:40]})"
        elif has_calendar_token:
            cal_status = f"{ck} Google Calendar (OAuth active)"
        else:
            cal_status = f"{cr} Calendar \u2014 run /connect calendar"

        lines = [
            f"\U0001f4e1 {fmt_bold('Connection Status', m)}\n",
            f"{fmt_bold('Active Provider:', m)} {current}\n",
            f"{fmt_bold('LLM Providers:', m)}",
            f"  {ck if has_anthropic else cr} Anthropic (Claude)",
            f"  {ck if has_openai else cr} OpenAI (GPT)",
            f"  {ck if has_gemini else cr} Gemini (Google AI)",
            f"  {ck if 'Ollama' in current else em} Ollama (local)\n",
            f"{fmt_bold('Integrations:', m)}",
            f"  {ck if has_google else cr} Google Workspace (credentials)",
            f"  {ck if has_gmail_token else em} Gmail API {ck + ' active' if has_gmail_token else em + ' run /connect google auth gmail'}",
            f"  {cal_status}",
            f"  {ck if has_drive_token else em} Google Drive {ck + ' active' if has_drive_token else em + ' run /connect google auth drive'}",
            f"  {ck if has_email else em} Email IMAP/SMTP{email_detail}",
            f"  {ck if has_twilio else cr} SMS (Twilio)",
            f"  {ck if has_browser else cr} Browser (Playwright)",
            f"  {ck if has_voice else cr} Voice (Whisper)",
        ]

        # Compliance / PII status
        compliance_mode = getattr(agent, "compliance_mode", None)
        compliance_str = compliance_mode.value if hasattr(compliance_mode, "value") else str(compliance_mode or "none")
        pii_cfg = getattr(getattr(agent, "config", None), "agent", None)
        pii_on = getattr(pii_cfg, "enable_pii_detection", False) if pii_cfg else False
        if compliance_str == "hipaa":
            hc_status = f"{ck} HIPAA mode (encryption + PII blocking + audit)"
        elif pii_on:
            hc_status = f"{ck} PII detection enabled (redact mode)"
        else:
            hc_status = f"{em} Healthcare / Compliance \u2014 run /connect healthcare"
        lines.append(f"\n{fmt_bold('Compliance:', m)}")
        lines.append(f"  {hc_status}")

        # Self-hosted services
        has_jellyfin = bool(os.environ.get("JELLYFIN_URL"))
        has_gitea = bool(os.environ.get("GITEA_URL"))
        has_homeassistant = bool(os.environ.get("HOMEASSISTANT_URL"))
        has_joplin = bool(os.environ.get("JOPLIN_TOKEN"))
        has_pihole = bool(os.environ.get("PIHOLE_URL"))
        has_nextcloud = bool(os.environ.get("NEXTCLOUD_URL"))
        has_proton_bridge = _check_tcp_port("127.0.0.1", 1143)
        any_selfhosted = any([has_jellyfin, has_gitea, has_homeassistant,
                              has_joplin, has_pihole, has_nextcloud, has_proton_bridge])

        lines.append(f"\n{fmt_bold('Self-Hosted:', m)}")
        lines.append(f"  {ck if has_jellyfin else em} Jellyfin (media)")
        lines.append(f"  {ck if has_gitea else em} Gitea (code)")
        lines.append(f"  {ck if has_homeassistant else em} Home Assistant")
        lines.append(f"  {ck if has_joplin else em} Joplin (notes)")
        lines.append(f"  {ck if has_pihole else em} Pi-hole / AdGuard")
        lines.append(f"  {ck if has_nextcloud else em} Nextcloud")
        lines.append(f"  {ck if has_proton_bridge else em} Proton Bridge")

        await self.wizard_send(recipient_id, "\n".join(lines))
