# 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

"""
ConnectMixin — core dispatch, menus, status, and UI builders.

Service-specific wizard logic lives in the sibling modules (``_llm``,
``_email_calendar``, ``_integrations``, ``_selfhosted``, ``_security``).
"""

from __future__ import annotations

import logging
import os
import sys
from collections.abc import Awaitable, Callable
from pathlib import Path

from ..formatting import FormatMode, fmt_bold, fmt_italic
from . import (
    _email_calendar,
    _integrations,
    _llm,
    _security,
    _selfhosted,
)
from ._browse import (
    SEARCHABLE_SERVICES,
    browse_all,
    format_browse_results,
    format_search_results,
    search_service,
)
from ._helpers import (
    SERVICE_BUTTON_LABELS,
    SERVICE_DISPLAY,
    TAB_CATEGORIES,
    TAB_ORDER,
    _check_tcp_port,
)

logger = logging.getLogger(__name__)

# ── Wizard-state type → step-handler registry ────────────────────────

_StepHandler = Callable[..., Awaitable[None]]

_WIZARD_HANDLERS: dict[str, _StepHandler] = {
    "email": _email_calendar.email_handle_step,
    "caldav": _email_calendar.caldav_handle_step,
    "proton": _email_calendar.proton_handle_step,
    "nextcloud": _selfhosted.nextcloud_handle_step,
    "jellyfin": _selfhosted.jellyfin_handle_step,
    "gitea": _selfhosted.gitea_handle_step,
    "homeassistant": _selfhosted.homeassistant_handle_step,
    "joplin": _selfhosted.joplin_handle_step,
    "pihole": _selfhosted.pihole_handle_step,
    "vaultwarden": _security.vaultwarden_handle_step,
    "llm_provider": _llm.llm_provider_handle_step,
    "github": _integrations.github_handle_step,
    "slack": _integrations.slack_handle_step,
}


# ── 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))

    # ── Card / Tab helpers ──────────────────────────────────────────

    @staticmethod
    def _check_package_installed(*names: str) -> bool:
        """Return True if any of the named packages is importable."""
        import importlib

        for name in names:
            try:
                importlib.import_module(name)
                return True
            except Exception:
                pass
        return False

    def _get_service_status(self) -> dict[str, bool]:
        """Return ``{service_key: connected_bool}`` for every service."""
        data_dir = Path.home() / ".familiar" / "data"

        # LLM
        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"))
        current = self._get_current_provider_name()
        has_ollama = "Ollama" in current or _check_tcp_port("127.0.0.1", 11434)

        # Integrations
        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
        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"))
        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
        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 = _check_tcp_port("127.0.0.1", 1143)
        has_email_server = bool(os.environ.get("EMAIL_SERVER_DOMAIN"))

        # Security
        has_vaultwarden = bool(os.environ.get("VAULTWARDEN_URL"))
        _key_dir = Path.home() / ".familiar" / "keys"
        has_encryption = any(_key_dir.glob("*.key")) if _key_dir.exists() else False

        phi_configured = False
        try:
            import json as _json

            _phi_path = data_dir / "phi_config.json"
            if _phi_path.exists():
                _phi = _json.loads(_phi_path.read_text())
                phi_configured = _phi.get("safe_mode", False) or _phi.get("sensitivity") not in (
                    None,
                    "standard",
                )
        except Exception:
            pass

        rbac_configured = False
        try:
            import json as _json

            _rbac_path = data_dir / "rbac.json"
            if _rbac_path.exists():
                _rbac = _json.loads(_rbac_path.read_text())
                rbac_configured = bool(_rbac.get("custom_roles") or _rbac.get("assignments"))
        except Exception:
            pass

        has_users = False
        try:
            import json as _json

            _users_path = data_dir / "users.json"
            if _users_path.exists():
                _users = _json.loads(_users_path.read_text())
                has_users = bool(_users.get("users"))
        except Exception:
            pass

        return {
            "anthropic": has_anthropic,
            "openai": has_openai,
            "gemini": has_gemini,
            "ollama": has_ollama,
            "google": has_google,
            "calendar": has_calendar,
            "email": has_email,
            "sms": has_twilio,
            "browser": self._check_package_installed("playwright"),
            "voice": self._check_package_installed("faster_whisper", "whisper"),
            "websearch": True,
            "healthcare": has_healthcare,
            "github": bool(os.environ.get("GITHUB_TOKEN")),
            "slack": bool(os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_APP_TOKEN")),
            "jellyfin": has_jellyfin,
            "gitea": has_gitea,
            "homeassistant": has_homeassistant,
            "joplin": has_joplin,
            "pihole": has_pihole,
            "nextcloud": has_nextcloud,
            "proton": has_proton,
            "email_server": has_email_server,
            "vaultwarden": has_vaultwarden,
            "encryption": has_encryption,
            "phi_detection": phi_configured,
            "rbac": rbac_configured,
            "user_management": has_users,
        }

    def _get_current_provider_name(self) -> str:
        """Return the name of the active LLM provider."""
        provider = getattr(getattr(self, "agent", None), "provider", None)
        return provider.name if provider else "unknown"

    def _build_tab_content(
        self, status: dict[str, bool], tab_id: str
    ) -> tuple[str, list[tuple[str, str]]]:
        """Build status text + options list for a single tab.

        Returns ``(text_lines, options)`` where *options* is a list of
        ``(callback_key, label)`` tuples suitable for ``wizard_send_menu``.
        """
        m = self.format_mode
        cat = TAB_CATEGORIES.get(tab_id)
        if not cat:
            return ("Unknown tab.", [])

        ck = "\u2705"
        em = "\u2b1c"
        lines: list[str] = []

        for svc in cat["services"]:
            connected = status.get(svc, False)
            display = SERVICE_DISPLAY.get(svc, svc)
            lines.append(f"  {ck if connected else em} {display}")

        if tab_id == "llm":
            current = self._get_current_provider_name()
            lines.append(f"\n  Current: {fmt_bold(current, m)}")

        text = "\n".join(lines)
        options = [
            SERVICE_BUTTON_LABELS[svc] for svc in cat["services"] if svc in SERVICE_BUTTON_LABELS
        ]
        return (text, options)

    def _build_card_header(self, active_tab: str) -> str:
        """Header text with tab indicator for button-capable channels."""
        m = self.format_mode
        parts = [f"\U0001f50c {fmt_bold('Connect Services', m)}\n"]
        tabs = []
        for tid in TAB_ORDER:
            cat = TAB_CATEGORIES[tid]
            label = cat["label"]
            if tid == active_tab:
                tabs.append(f"{fmt_bold(f'▸ {label}', m)}")
            else:
                tabs.append(label)
        parts.append("  ".join(tabs))
        return "\n".join(parts)

    def _build_flat_menu(self, status: dict[str, bool]) -> tuple[str, list[tuple[str, str]]]:
        """Generate the full grouped text + all options for plain-text channels.

        Reproduces the original ``_connect_main_menu`` output exactly.
        """
        m = self.format_mode
        ck = "\u2705"
        em = "\u2b1c"
        current = self._get_current_provider_name()

        lines = [
            f"\U0001f50c {fmt_bold('Connect Services', m)}\n",
            f"{fmt_bold('LLM Providers:', m)}",
            f"  {ck if status.get('anthropic') else em} Anthropic (Claude) \u2014 best quality",
            f"  {ck if status.get('openai') else em} OpenAI (GPT)",
            f"  {ck if status.get('gemini') else em} Gemini (Google AI)",
            f"  {ck if status.get('ollama') else em} Ollama (local, free)",
            f"  Current: {fmt_bold(current, m)}\n",
            f"{fmt_bold('Integrations:', m)}",
            f"  {ck if status.get('google') else em} Google Workspace (Calendar, Drive)",
            f"  {ck if status.get('calendar') else em} Calendar (Google/CalDAV)",
            f"  {ck if status.get('email') else em} Email (IMAP/SMTP)",
            f"  {ck if status.get('sms') else em} SMS (Twilio)",
            f"  {em} Browser (Playwright)",
            f"  {em} Voice (Whisper)",
            f"  {ck} WebSearch (DuckDuckGo)",
            f"  {ck if status.get('healthcare') else em} Healthcare / Compliance\n",
            f"{fmt_bold('Self-Hosted:', m)}",
            f"  {ck if status.get('jellyfin') else em} Jellyfin (media)",
            f"  {ck if status.get('gitea') else em} Forgejo / Gitea (code)",
            f"  {ck if status.get('homeassistant') else em} Home Assistant",
            f"  {ck if status.get('joplin') else em} Joplin (notes)",
            f"  {ck if status.get('pihole') else em} Pi-hole / AdGuard",
            f"  {ck if status.get('nextcloud') else em} Nextcloud\n",
            f"{fmt_bold('Security:', m)}",
            f"  {ck if status.get('vaultwarden') else em} Vaultwarden (passwords)",
            f"  {ck if status.get('encryption') else em} Encryption (at rest)\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"),
            ("websearch_menu", "\U0001f50d WebSearch"),
            ("selfhosted_menu", "\U0001f3e0 Self-Hosted"),
            ("security_menu", "\U0001f512 Security"),
        ]
        return ("\n".join(lines), options)

    async def wizard_send_card_menu(
        self,
        recipient_id: str,
        header: str,
        tab_content_text: str,
        tab_options: list[tuple[str, str]],
        active_tab: str,
    ) -> None:
        """Send a tabbed card menu.

        Default implementation falls back to the flat grouped menu via
        ``wizard_send_menu`` so plain-text channels keep working.
        """
        status = self._get_service_status()
        text, options = self._build_flat_menu(status)
        await self.wizard_send_menu(recipient_id, text, options)

    async def _show_tab(self, recipient_id: str, tab_id: str) -> None:
        """Display a specific tab of the card menu."""
        status = self._get_service_status()
        header = self._build_card_header(tab_id)
        tab_text, tab_options = self._build_tab_content(status, tab_id)
        await self.wizard_send_card_menu(
            recipient_id,
            header,
            tab_text,
            tab_options,
            tab_id,
        )

    # ── 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",
            "vaultwarden": "vaultwarden",
            "bitwarden": "vaultwarden",
            "vault": "vaultwarden",
            "encryption": "encryption",
            "encrypt": "encryption",
            "phi": "phi_detection",
            "phi_detection": "phi_detection",
            "rbac": "rbac",
            "roles": "rbac",
            "users": "user_management",
            "user_management": "user_management",
            "security": "security",
            "email-server": "email_server",
            "email_server": "email_server",
            "mailserver": "email_server",
            "websearch": "websearch",
            "web-search": "websearch",
            "searxng": "websearch",
            "github": "github",
            "gh": "github",
            "slack": "slack",
        }

        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

        # fmt: off
        handler = {
            "anthropic":       lambda: _llm.wizard_llm_provider(self, recipient_id, "anthropic", rest),
            "openai":          lambda: _llm.wizard_llm_provider(self, recipient_id, "openai", rest),
            "gemini":          lambda: _llm.wizard_llm_provider(self, recipient_id, "gemini", rest),
            "ollama":          lambda: _llm.wizard_ollama(self, recipient_id, rest),
            "email":           lambda: _email_calendar.wizard_email(self, recipient_id, rest),
            "calendar":        lambda: _email_calendar.wizard_calendar(self, recipient_id, rest),
            "google":          lambda: _email_calendar.wizard_google(self, recipient_id, rest),
            "sms":             lambda: _integrations.wizard_sms(self, recipient_id, rest),
            "browser":         lambda: _integrations.wizard_browser(self, recipient_id),
            "voice":           lambda: _integrations.wizard_voice(self, recipient_id),
            "healthcare":      lambda: _integrations.wizard_healthcare(self, recipient_id, rest),
            "status":          lambda: self._wizard_status(recipient_id),
            "jellyfin":        lambda: _selfhosted.wizard_jellyfin(self, recipient_id, rest),
            "gitea":           lambda: _selfhosted.wizard_gitea(self, recipient_id, rest),
            "homeassistant":   lambda: _selfhosted.wizard_homeassistant(self, recipient_id, rest),
            "joplin":          lambda: _selfhosted.wizard_joplin(self, recipient_id, rest),
            "pihole":          lambda: _selfhosted.wizard_pihole(self, recipient_id, rest),
            "nextcloud":       lambda: _selfhosted.wizard_nextcloud(self, recipient_id, rest),
            "proton_services": lambda: _selfhosted.wizard_proton_services(self, recipient_id, rest),
            "vaultwarden":     lambda: _security.wizard_vaultwarden(self, recipient_id, rest),
            "encryption":      lambda: _security.wizard_encryption(self, recipient_id),
            "phi_detection":   lambda: _security.wizard_phi_detection(self, recipient_id),
            "rbac":            lambda: _security.wizard_rbac(self, recipient_id),
            "user_management": lambda: _security.wizard_user_management(self, recipient_id),
            "security":        lambda: self._security_submenu(recipient_id),
            "email_server":    lambda: _selfhosted.wizard_email_server(self, recipient_id, rest),
            "websearch":       lambda: _integrations.wizard_websearch(self, recipient_id, rest),
            "github":          lambda: _integrations.wizard_github(self, recipient_id, rest),
            "slack":           lambda: _integrations.wizard_slack(self, recipient_id, rest),
        }
        # fmt: on
        await handler[target]()

    # ── Browse / Search ──────────────────────────────────────────

    async def handle_browse_command(self, recipient_id: str) -> None:
        """Show browsable data from all connected services."""
        self._ensure_wizard_state()
        await self.wizard_send(recipient_id, "\u23f3 Loading connected services...")
        results = browse_all()

        if self.supports_buttons and results:
            # Button channels: show service names as menu, cache results
            self._wizard_state[recipient_id] = {"type": "browse", "results": results}
            options = [
                (f"browse_{key}", f"{svc.get('display_name', key)} ({svc.get('summary', '')})")
                for key, svc in results.items()
            ]
            svc_list = ", ".join(SEARCHABLE_SERVICES)
            await self.wizard_send_menu(
                recipient_id,
                f"\U0001f4cb {fmt_bold('Connected Services', self.format_mode)}\n\n"
                f"Select a service to see details.\n"
                f"Use /search <service> <query> to search.\n"
                f"Services: {svc_list}",
                options,
            )
        else:
            # Text channels: show everything at once
            text = format_browse_results(results, self.format_mode)
            await self.wizard_send(recipient_id, text)

    async def handle_search_command(self, recipient_id: str, args: list[str]) -> None:
        """Search a connected service.  ``args[0]`` = service, ``args[1:]`` = query."""
        if not args:
            svc_list = ", ".join(SEARCHABLE_SERVICES)
            await self.wizard_send(
                recipient_id,
                f"Usage: /search <service> <query>\nServices: {svc_list}",
            )
            return

        service = args[0].lower()
        query = " ".join(args[1:]).strip()
        if not query:
            await self.wizard_send(
                recipient_id,
                f"Usage: /search {service} <query>",
            )
            return

        await self.wizard_send(recipient_id, f"\U0001f50d Searching {service}...")
        result = search_service(service, query)
        text = format_search_results(service, result, self.format_mode)
        await self.wizard_send(recipient_id, text)

    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()

        # Browse detail: when user clicks a browse_<service> button, show items
        if key.startswith("browse_"):
            browse_state = self._wizard_state.get(recipient_id, {})
            if browse_state.get("type") == "browse":
                svc_key = key[len("browse_") :]
                results = browse_state.get("results", {})
                svc = results.get(svc_key)
                if svc:
                    text = format_browse_results({svc_key: svc}, self.format_mode)
                    await self.wizard_send(recipient_id, text)
                    return True

        # Clear any stale wizard state so old prompts don't intercept input
        self._wizard_state.pop(recipient_id, None)

        # Delegate to service modules first
        for mod in (_llm, _email_calendar, _integrations, _selfhosted, _security):
            if await mod.handle_menu_selection(self, recipient_id, key):
                return True

        # Core-only callbacks
        if key == "selfhosted_menu":
            await self._show_tab(recipient_id, "selfhosted")
            return True
        if key == "security_menu":
            await self._show_tab(recipient_id, "security")
            return True
        if key == "status_menu":
            await self._wizard_status(recipient_id)
            return True

        # Browser install / recheck (needs sys.executable — kept here)
        if key == "browser_install_cmd":
            from familiar.core.deps import BROWSER_PACKAGES, ensure_packages_async

            await self.wizard_send(recipient_id, "Installing browser packages...")
            ok, failed = await ensure_packages_async(BROWSER_PACKAGES)
            if ok:
                import subprocess as _sp

                await self.wizard_send(recipient_id, "Installing Chromium browser...")
                _sp.run(
                    [sys.executable, "-m", "playwright", "install", "chromium"],
                    capture_output=True,
                    timeout=300,
                )
                await self.wizard_send(recipient_id, "Browser installed. Re-checking...")
            else:
                await self.wizard_send(recipient_id, f"Failed to install: {', '.join(failed)}")
            await _integrations.wizard_browser(self, recipient_id)
            return True
        if key == "browser_recheck":
            await _integrations.wizard_browser(self, recipient_id)
            return True

        # Voice install / recheck
        if key == "voice_install_cmd":
            from familiar.core.deps import (
                VOICE_STT_PACKAGES,
                VOICE_TTS_PACKAGES,
                ensure_packages_async,
            )

            await self.wizard_send(recipient_id, "Installing voice packages...")
            ok_stt, _ = await ensure_packages_async(VOICE_STT_PACKAGES)
            ok_tts, _ = await ensure_packages_async(VOICE_TTS_PACKAGES)
            if ok_stt:
                await self.wizard_send(recipient_id, "Voice packages installed. Re-checking...")
            else:
                await self.wizard_send(recipient_id, "STT install failed. Check server logs.")
            await _integrations.wizard_voice(self, recipient_id)
            return True
        if key == "voice_recheck":
            await _integrations.wizard_voice(self, 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

        # Card-menu (tabbed) numbered resolution — same logic as "menu"
        if wtype == "card_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)
            await self.wizard_send(
                recipient_id,
                fmt_italic("Please reply with a number from the menu above.", self.format_mode),
            )
            return True

        # Dispatch to service step handlers
        handler = _WIZARD_HANDLERS.get(wtype)
        if handler is not None:
            await handler(self, 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):
        """Show the Connect Services menu — tabbed card on capable channels,
        flat grouped list on plain-text channels."""
        await self._show_tab(recipient_id, "llm")

    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)
        has_email_server = bool(os.environ.get("EMAIL_SERVER_DOMAIN"))

        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} Forgejo / 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)",
            f"  {ck if has_email_server else em} Email Server \U0001f4ec (self-hosted mail)\n",
            f"{fmt_italic('Select a service to configure:', m)}",
        ]

        options = [
            ("jellyfin_menu", "\U0001f3ac Jellyfin"),
            ("gitea_menu", "\U0001f419 Forgejo / 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"),
            ("email_server_menu", "\U0001f4ec Email Server"),
        ]

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

    async def _security_submenu(self, recipient_id: str):
        m = self.format_mode
        ck = "\u2705"
        em = "\u2b1c"
        data_dir = Path.home() / ".familiar" / "data"

        # Vaultwarden
        has_vaultwarden = bool(os.environ.get("VAULTWARDEN_URL"))

        # Encryption
        _key_dir = Path.home() / ".familiar" / "keys"
        has_encryption_key = any(_key_dir.glob("*.key")) if _key_dir.exists() else False

        # PHI Detection
        phi_configured = False
        try:
            import json as _json

            _phi_path = data_dir / "phi_config.json"
            if _phi_path.exists():
                _phi = _json.loads(_phi_path.read_text())
                phi_configured = _phi.get("safe_mode", False) or _phi.get("sensitivity") not in (
                    None,
                    "standard",
                )
        except Exception:
            pass

        # RBAC
        rbac_configured = False
        try:
            import json as _json

            _rbac_path = data_dir / "rbac.json"
            if _rbac_path.exists():
                _rbac = _json.loads(_rbac_path.read_text())
                rbac_configured = bool(_rbac.get("custom_roles") or _rbac.get("assignments"))
        except Exception:
            pass

        # Users
        has_users = False
        try:
            import json as _json

            _users_path = data_dir / "users.json"
            if _users_path.exists():
                _users = _json.loads(_users_path.read_text())
                has_users = bool(_users.get("users"))
        except Exception:
            pass

        lines = [
            f"\U0001f512 {fmt_bold('Security Services', m)}\n",
            f"  {ck if has_vaultwarden else em} Vaultwarden \U0001f511 (password manager)",
            f"  {ck if has_encryption_key else em} Encryption \U0001f510 (data at rest)",
            f"  {ck if phi_configured else em} PHI Detection \U0001f3e5 (safe mode)",
            f"  {ck if rbac_configured else em} RBAC \U0001f6e1\ufe0f (role-based access)",
            f"  {ck if has_users else em} User Management \U0001f465 (accounts)\n",
            f"{fmt_italic('Select a service to configure:', m)}",
        ]

        options = [
            ("vaultwarden_menu", "\U0001f511 Vaultwarden"),
            ("encryption_menu", "\U0001f510 Encryption"),
            ("phi_detection_menu", "\U0001f3e5 PHI Detection"),
            ("rbac_menu", "\U0001f6e1\ufe0f RBAC"),
            ("user_management_menu", "\U0001f465 Users"),
        ]

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

    # ── 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)",
            f"  {ck} WebSearch (DuckDuckGo)",
        ]

        # 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)
        has_email_server = bool(os.environ.get("EMAIL_SERVER_DOMAIN"))

        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} Forgejo / 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")
        lines.append(f"  {ck if has_email_server else em} Email Server (self-hosted)")

        # Security services
        has_vaultwarden = bool(os.environ.get("VAULTWARDEN_URL"))
        _key_dir = Path.home() / ".familiar" / "keys"
        has_encryption_key = any(_key_dir.glob("*.key")) if _key_dir.exists() else False
        _users_path = data_dir / "users.json"
        _has_users = _users_path.exists()

        lines.append(f"\n{fmt_bold('Security:', m)}")
        lines.append(f"  {ck if has_vaultwarden else em} Vaultwarden (passwords)")
        lines.append(f"  {ck if has_encryption_key else em} Encryption (at rest)")
        lines.append(f"  {em} PHI / RBAC / Users \u2014 run /connect security")

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