# 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

"""
Telegram Channel

Connect the agent to Telegram for mobile access.
Now with progressive trust and capability-based security.

Phase 1 (v1.6): Multi-user support via UserManager
"""

import asyncio
import logging
import os
from pathlib import Path
from typing import Callable, Optional

from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
    Application,
    CallbackQueryHandler,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)

from ..core.agent import Agent
from ..core.security import Capability, SecureSession, TrustLevel

# Phase 1: Multi-user authentication
from .auth import (
    AuthMode,
    ChannelType,
    ChannelUser,
    create_authenticator,
)
from .connect_wizard import ConnectMixin
from .formatting import FormatMode

logger = logging.getLogger(__name__)


class TelegramChannel(ConnectMixin):
    """
    Telegram interface for the agent.

    Security Features:
    - Progressive trust (STRANGER → KNOWN → TRUSTED → OWNER)
    - Capability-based permissions
    - Budget tracking
    - Inline confirmations for sensitive actions

    Phase 1 (v1.6): Multi-user support
    - Account linking via /link command
    - Per-user data isolation
    - Role-based access (admin/staff/readonly)

    Usage:
        agent = Agent()
        telegram = TelegramChannel(agent, token="...")
        telegram.run()
    """

    # ConnectMixin adapter properties
    format_mode = FormatMode.HTML
    supports_buttons = True
    supports_message_deletion = True

    def __init__(
        self,
        agent: Agent,
        token: str = None,
        allowed_users: list = None,
        auth_mode: AuthMode = None,
    ):
        self.agent = agent
        self.token = token or agent.config.channels.telegram_token
        self.allowed_users = allowed_users or agent.config.channels.telegram_allowed_users
        self.app: Optional[Application] = None

        # Callback for proactive messages
        self._send_message_callback: Optional[Callable] = None

        # Phase 1: Multi-user authentication
        self.authenticator = create_authenticator(
            config=agent.config, mode=auth_mode, allowed_ids=self.allowed_users
        )

        # ConnectMixin: store Telegram Chat objects for wizard_send routing
        self._wizard_chats: dict[str, object] = {}

    # ── ConnectMixin adapter methods ────────────────────────────────

    async def wizard_send(self, recipient_id: str, text: str) -> None:
        chat = self._wizard_chats.get(recipient_id)
        if chat:
            await chat.send_message(text, parse_mode="HTML")
        elif self.app:
            await self.app.bot.send_message(chat_id=int(recipient_id), text=text, parse_mode="HTML")

    async def wizard_send_menu(
        self, recipient_id: str, text: str, options: list[tuple[str, str]]
    ) -> None:
        """Send a menu with inline keyboard buttons."""
        # Build rows of 3 buttons
        keyboard = []
        row = []
        for key, label in options:
            row.append(InlineKeyboardButton(label, callback_data=f"connect:{key}"))
            if len(row) >= 3:
                keyboard.append(row)
                row = []
        if row:
            keyboard.append(row)

        chat = self._wizard_chats.get(recipient_id)
        if chat:
            await chat.send_message(
                text, parse_mode="HTML",
                reply_markup=InlineKeyboardMarkup(keyboard),
            )
        elif self.app:
            await self.app.bot.send_message(
                chat_id=int(recipient_id), text=text, parse_mode="HTML",
                reply_markup=InlineKeyboardMarkup(keyboard),
            )

    async def wizard_delete_message(self, recipient_id: str, message_ref) -> bool:
        try:
            await message_ref.delete()
            return True
        except Exception:
            return False

    def _check_authorized(self, user_id: int) -> bool:
        """Check if user is authorized (legacy compatibility)."""
        if not self.allowed_users:
            return True  # Allow all if no whitelist
        return user_id in self.allowed_users

    def _get_channel_user(self, update: Update) -> Optional[ChannelUser]:
        """Get authenticated ChannelUser for the update."""
        tg_user = update.effective_user
        return self.authenticator.authenticate(
            channel_type=ChannelType.TELEGRAM,
            channel_id=str(tg_user.id),
            display_name=tg_user.first_name or tg_user.username or "User",
        )

    def _get_session(self, user_id: int) -> SecureSession:
        """Get the secure session for a user. Auto-promotes owner to OWNER trust."""
        session = self.agent.sessions.get_or_create_session(str(user_id), "telegram")

        # Auto-promote owner on every session fetch (idempotent)
        owner_id = getattr(self.agent.config.channels, "owner_telegram_id", None)
        if owner_id and int(user_id) == int(owner_id):
            if session.trust_level != TrustLevel.OWNER:
                session.set_trust_level(TrustLevel.OWNER)
                session.daily_budget = 50.0  # Owner gets higher budget
                self.agent.sessions.save_session(session)
                logger.info(f"Auto-promoted owner {user_id} to OWNER trust")

        return session

    def _is_access_allowed(self, user_id: int) -> bool:
        """Check if a user has access to this Familiar instance.

        Access rules (checked in order):
        1. Owner always has access.
        2. User appears in preauth.json (owner ran 'grant <id> staff') → allowed.
           This MUST be checked before the allowed_users whitelist so that
           'grant 987654321 staff' works without the owner also adding the ID
           to telegram_allowed_users manually.
        3. If telegram_allowed_users is explicitly set, only those IDs.
        4. If owner is set but allowed list is empty and user is not in
           preauth → deny (private instance, owner-only until users are granted).
        5. Legacy: no owner set, no allowed list → open access (dev/test only).
        """
        owner_id = getattr(self.agent.config.channels, "owner_telegram_id", None)
        allowed = self.agent.config.channels.telegram_allowed_users

        # 1. Owner always in
        if owner_id and int(user_id) == int(owner_id):
            return True

        # 2. Check preauth table — 'grant <id> staff' puts the user here
        try:
            from familiar.skills.user_management.skill import get_preauth_role

            if get_preauth_role(str(user_id)) is not None:
                return True
        except Exception:
            pass  # If skill unavailable, fall through to whitelist check

        # 3. Explicit whitelist
        if allowed:
            return int(user_id) in [int(x) for x in allowed]

        # 4. Owner set, no whitelist, not in preauth → private instance
        if owner_id:
            return False

        # 5. Legacy: no owner, no whitelist → open (dev/test)
        return True

    def _trust_emoji(self, level: TrustLevel) -> str:
        """Get emoji for trust level."""
        return {
            TrustLevel.STRANGER: "🔴",
            TrustLevel.KNOWN: "🟡",
            TrustLevel.TRUSTED: "🟢",
            TrustLevel.OWNER: "⭐",
        }.get(level, "❓")

    async def _reject_unauthorized(self, update: Update):
        """Send a friendly rejection for unauthorized users."""
        await update.message.reply_text(
            "🔒 <b>Private Familiar Instance</b>\n\n"
            "This Familiar belongs to someone else.\n"
            "Contact the owner if you'd like access.\n\n"
            "<i>Want your own? Visit familiarai.ai</i>",
            parse_mode="HTML",
        )

    async def _handle_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /start command."""
        # Access gate: check before anything else
        if not self._is_access_allowed(update.effective_user.id):
            await self._reject_unauthorized(update)
            return

        channel_user = self._get_channel_user(update)

        if not channel_user:
            await self._reject_unauthorized(update)
            return

        user = update.effective_user
        session = self._get_session(user.id)
        session.record_interaction(positive=True)

        status = self.agent.get_status()

        # Build user status section based on link status
        if channel_user.is_linked:
            user_status = (
                f"<b>Your Account:</b>\n"
                f"• User: {channel_user.user.display_name}\n"
                f"• Role: {channel_user.user.role.value}\n"
                f"• Account: Linked ✅\n"
            )
            link_hint = ""
        else:
            user_status = (
                f"<b>Guest Access:</b>\n• Name: {user.first_name}\n• Account: Not linked\n"
            )
            link_hint = "\n💡 <i>Use /link email@org.org for full features!</i>\n"

        await update.message.reply_text(
            f"🐍 <b>Welcome to Familiar!</b>\n\n"
            f"Hi {user.first_name}! I'm your secure AI assistant.\n\n"
            f"{user_status}"
            f"• Trust: {self._trust_emoji(session.trust_level)} {session.trust_level.value.upper()}\n"
            f"• Budget: ${session.remaining_budget:.2f}/day\n"
            f"{link_hint}\n"
            f"<b>System:</b>\n"
            f"• Model: {status['provider']}\n"
            f"• Skills: {status['skills_loaded']}\n"
            f"• Security: {status['security_mode']}\n\n"
            f"<b>Commands:</b>\n"
            f"/whoami - Your account info\n"
            f"/link - Link your account\n"
            f"/connect - Connect services (LLM, email...)\n"
            f"/trust - Trust level info\n"
            f"/grant - Grant staff access to a user (Owner only)\n"
            f"/revoke - Revoke access (Owner only)\n"
            f"/preauth - List pre-authorized users (Owner only)\n"
            f"/budget - Check budget\n"
            f"/status - Full status\n"
            f"/clear - Clear history\n\n"
            f"<i>🔒 Security: All actions logged. Staff access granted by owner.</i>",
            parse_mode="HTML",
        )
        self.agent.sessions.save_session(session)

    async def _handle_trust(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /trust command - show trust level info."""
        if not self._is_access_allowed(update.effective_user.id):
            return

        session = self._get_session(update.effective_user.id)

        def indicator(level: TrustLevel) -> str:
            if session.trust_level.value == level.value:
                return "➡️"
            elif list(TrustLevel).index(session.trust_level) > list(TrustLevel).index(level):
                return "✅"
            return "⬜"

        # Calculate progress
        if session.trust_level == TrustLevel.STRANGER:
            progress = f"{session.positive_interactions}/10 interactions to KNOWN"
        elif session.trust_level == TrustLevel.KNOWN:
            progress = f"{session.positive_interactions}/50 to TRUSTED"
        elif session.trust_level == TrustLevel.TRUSTED:
            progress = "Request OWNER from admin"
        else:
            progress = "Maximum level! ⭐"

        await update.message.reply_text(
            f"🔐 <b>Trust System</b>\n\n"
            f"{indicator(TrustLevel.STRANGER)} <b>STRANGER</b> - Time, weather only\n"
            f"{indicator(TrustLevel.KNOWN)} <b>KNOWN</b> - + Reminders, notes, calendar\n"
            f"{indicator(TrustLevel.TRUSTED)} <b>TRUSTED</b> - + Email, web (with confirm)\n"
            f"{indicator(TrustLevel.OWNER)} <b>OWNER</b> - Full access (logged)\n\n"
            f"<b>Your Level:</b> {session.trust_level.value.upper()}\n"
            f"<b>Score:</b> {session.trust_score:.1f}\n"
            f"<b>Progress:</b> {progress}",
            parse_mode="HTML",
        )

    async def _handle_budget(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /budget command."""
        if not self._is_access_allowed(update.effective_user.id):
            return

        session = self._get_session(update.effective_user.id)
        pct = (
            min(100, session.spent_today / session.daily_budget * 100)
            if session.daily_budget > 0
            else 0
        )
        bar = f"[{'█' * int(pct / 10)}{'░' * (10 - int(pct / 10))}]"

        await update.message.reply_text(
            f"💰 <b>Budget Status</b>\n\n"
            f"<b>Daily Limit:</b> ${session.daily_budget:.2f}\n\n"
            f"{bar} {pct:.1f}%\n\n"
            f"<b>Spent:</b> ${session.spent_today:.4f}\n"
            f"<b>Remaining:</b> ${session.remaining_budget:.4f}\n\n"
            f"<i>Resets at midnight UTC</i>",
            parse_mode="HTML",
        )

    async def _handle_caps(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /caps command - show capabilities."""
        if not self._is_access_allowed(update.effective_user.id):
            return

        session = self._get_session(update.effective_user.id)
        caps = sorted([c.value for c in session.capabilities])
        cap_list = "\n".join(f"  ✅ <code>{c}</code>" for c in caps) or "  (none yet)"

        await update.message.reply_text(
            f"🔑 <b>Your Capabilities</b>\n\n"
            f"<b>Granted ({len(caps)}):</b>\n{cap_list}\n\n"
            f"<b>Earn more by:</b>\n"
            f"• Positive interactions\n"
            f"• Building trust over time",
            parse_mode="HTML",
        )

    async def _handle_clear(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /clear command."""
        if not self._is_access_allowed(update.effective_user.id):
            return

        chat_id = update.effective_chat.id
        self.agent.clear_history(chat_id, "telegram")
        await update.message.reply_text("🧹 Conversation cleared!")

    async def _handle_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /status command."""
        if not self._is_access_allowed(update.effective_user.id):
            return

        session = self._get_session(update.effective_user.id)
        status = self.agent.get_status()

        await update.message.reply_text(
            f"📊 <b>Full Status</b>\n\n"
            f"<b>You:</b>\n"
            f"• Trust: {self._trust_emoji(session.trust_level)} {session.trust_level.value.upper()}\n"
            f"• Budget: ${session.remaining_budget:.2f} left\n"
            f"• Interactions: {session.message_count}\n\n"
            f"<b>System:</b>\n"
            f"• Model: {status['provider']}\n"
            f"• Memory: {status['memory_entries']} entries\n"
            f"• Skills: {status['skills_loaded']}\n"
            f"• Tools: {status['tools_available']}\n"
            f"• Tasks: {status['scheduled_tasks']}\n"
            f"• Security: {status['security_mode']}\n"
            f"• Sessions: {status['active_sessions']}",
            parse_mode="HTML",
        )

    async def _handle_model(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /model command."""
        if not self._is_access_allowed(update.effective_user.id):
            return

        if not context.args:
            from ..core.providers import get_available_providers

            providers = get_available_providers()
            await update.message.reply_text(
                f"Current: {self.agent.provider.name}\n\n"
                f"Available: {', '.join(providers)}\n\n"
                f"Usage: /model <provider>"
            )
            return

        result = self.agent.switch_provider(context.args[0])
        await update.message.reply_text(f"✓ {result}")

    async def _handle_remember(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /remember command."""
        if not self._is_access_allowed(update.effective_user.id):
            return

        session = self._get_session(update.effective_user.id)

        # Check capability
        if not session.has_capability(Capability.WRITE_MEMORY):
            await update.message.reply_text(
                "⚠️ You don't have permission to write memory yet.\n"
                f"Your trust level: {session.trust_level.value}\n"
                "Keep interacting to build trust!"
            )
            return

        if len(context.args) < 2:
            await update.message.reply_text("Usage: /remember <key> <value>")
            return

        key = context.args[0]
        value = " ".join(context.args[1:])
        self.agent.remember(key, value)
        await update.message.reply_text(f"✓ Remembered: {key}")

    async def _handle_recall(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /recall command."""
        if not self._is_access_allowed(update.effective_user.id):
            return

        session = self._get_session(update.effective_user.id)

        # Check capability
        if not session.has_capability(Capability.READ_MEMORY):
            await update.message.reply_text(
                "⚠️ You don't have permission to search memory yet.\n"
                f"Your trust level: {session.trust_level.value}"
            )
            return

        if not context.args:
            await update.message.reply_text("Usage: /recall <query>")
            return

        query = " ".join(context.args)
        results = self.agent.recall(query)

        if not results:
            await update.message.reply_text(f"No memories matching '{query}'")
            return

        lines = []
        for entry in results[:10]:
            lines.append(f"• {entry.key}: {entry.value}")

        await update.message.reply_text("\n".join(lines))

    # ============================================================
    # PHASE 1: MULTI-USER AUTHENTICATION COMMANDS
    # ============================================================

    async def _handle_link(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """
        Handle /link command - link Telegram to user account.

        Usage: /link your@email.org
        """
        if not context.args:
            await update.message.reply_text(
                "🔗 <b>Link Your Account</b>\n\n"
                "Connect this Telegram to your Familiar account.\n\n"
                "<b>Usage:</b> /link your@email.org\n\n"
                "You'll receive a code to confirm the link.",
                parse_mode="HTML",
            )
            return

        email = context.args[0].lower().strip()
        tg_id = str(update.effective_user.id)

        # Check if already linked
        channel_user = self._get_channel_user(update)
        if channel_user and channel_user.is_linked:
            await update.message.reply_text(
                f"✅ Already linked to: {channel_user.user.email}\n\n"
                "Use /unlink to disconnect first."
            )
            return

        # Start link process
        success, message = self.authenticator.start_link(
            channel_type=ChannelType.TELEGRAM, channel_id=tg_id, email=email
        )

        if success:
            await update.message.reply_text(
                f"🔗 <b>Confirm Account Link</b>\n\n{message}", parse_mode="HTML"
            )
        else:
            await update.message.reply_text(f"❌ {message}")

    async def _handle_confirm_link(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """
        Handle /confirm command - confirm account link with code.

        Usage: /confirm 123456
        """
        if not context.args:
            await update.message.reply_text("Usage: /confirm <code>")
            return

        code = context.args[0].strip()
        tg_id = str(update.effective_user.id)

        success, message = self.authenticator.confirm_link(
            channel_type=ChannelType.TELEGRAM, channel_id=tg_id, code=code
        )

        await update.message.reply_text(message)

    async def _handle_unlink(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /unlink command - unlink Telegram from account."""
        tg_id = str(update.effective_user.id)

        channel_user = self._get_channel_user(update)
        if not channel_user or not channel_user.is_linked:
            await update.message.reply_text("This Telegram is not linked to any account.")
            return

        success, message = self.authenticator.unlink(
            channel_type=ChannelType.TELEGRAM, channel_id=tg_id
        )

        await update.message.reply_text(message)

    # ── /grant · /revoke · /preauth ───────────────────────────────────────────

    async def _handle_grant(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """
        /grant <identity> [role]
        Shortcut to user_preauth grant. Identity is email or Telegram user ID.
        Role defaults to 'staff' if omitted.

        Examples:
            /grant sarah@gjl.org
            /grant sarah@gjl.org staff
            /grant 123456789 readonly
        """
        # OWNER-only: /grant can modify user access
        session = self._get_session(update.effective_user.id)
        from familiar.core.security import TrustLevel

        if not session or session.trust_level != TrustLevel.OWNER:
            await update.message.reply_text("⛔ Only the OWNER can use /grant.")
            return

        session = self._get_session(update.effective_user.id)
        args = context.args or []

        if not args:
            await update.message.reply_text(
                "Usage: <code>/grant &lt;email or Telegram ID&gt; [role]</code>\n"
                "Role defaults to <b>staff</b> if omitted.\n\n"
                "Examples:\n"
                "  <code>/grant sarah@gjl.org</code>\n"
                "  <code>/grant sarah@gjl.org readonly</code>\n"
                "  <code>/grant 123456789 staff</code>",
                parse_mode="HTML",
            )
            return

        identity = args[0]
        role = args[1].lower() if len(args) > 1 else "staff"

        result = self.agent.tools.execute(
            "user_preauth",
            {"action": "grant", "identity": identity, "role": role},
            context={
                "session": session,
                "session_manager": self.agent.sessions,
                "agent": self.agent,
            },
        )
        await update.message.reply_text(result)

    async def _handle_revoke(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """
        /revoke <identity>
        Shortcut to user_preauth revoke. Immediately downgrades any live session.
        """
        # OWNER-only: /revoke can modify user access
        session = self._get_session(update.effective_user.id)
        from familiar.core.security import TrustLevel

        if not session or session.trust_level != TrustLevel.OWNER:
            await update.message.reply_text("⛔ Only the OWNER can use /revoke.")
            return
        args = context.args or []

        if not args:
            await update.message.reply_text(
                "Usage: <code>/revoke &lt;email or Telegram ID&gt;</code>",
                parse_mode="HTML",
            )
            return

        result = self.agent.tools.execute(
            "user_preauth",
            {"action": "revoke", "identity": args[0]},
            context={
                "session": session,
                "session_manager": self.agent.sessions,
                "agent": self.agent,
            },
        )
        await update.message.reply_text(result)

    async def _handle_preauth(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """/preauth — list all pre-authorized users (Owner only)."""
        # OWNER-only: /preauth manages access control
        session = self._get_session(update.effective_user.id)
        from familiar.core.security import TrustLevel

        if not session or session.trust_level != TrustLevel.OWNER:
            await update.message.reply_text("⛔ Only the OWNER can use /preauth.")
            return
        result = self.agent.tools.execute(
            "user_preauth",
            {"action": "list"},
            context={
                "session": session,
                "session_manager": self.agent.sessions,
                "agent": self.agent,
            },
        )
        await update.message.reply_text(result)

    async def _handle_whoami(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /whoami command - show current user info."""
        channel_user = self._get_channel_user(update)

        if not channel_user:
            await self._reject_unauthorized(update)
            return

        if channel_user.is_linked:
            user = channel_user.user
            await update.message.reply_text(
                f"👤 <b>Your Account</b>\n\n"
                f"<b>Name:</b> {user.display_name}\n"
                f"<b>Email:</b> {user.email}\n"
                f"<b>Role:</b> {user.role.value}\n"
                f"<b>Status:</b> {user.status.value}\n"
                f"<b>Telegram:</b> Linked ✅\n\n"
                f"<i>Use /unlink to disconnect this Telegram.</i>",
                parse_mode="HTML",
            )
        else:
            await update.message.reply_text(
                f"👤 <b>Guest Access</b>\n\n"
                f"<b>Name:</b> {channel_user.display_name}\n"
                f"<b>Telegram ID:</b> {channel_user.channel_id}\n"
                f"<b>Linked:</b> No ❌\n\n"
                f"<i>Use /link email@org.org to connect your account\n"
                f"for full features and data persistence.</i>",
                parse_mode="HTML",
            )

    # ============================================================
    # END PHASE 1 COMMANDS
    # ============================================================

    async def _handle_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle inline keyboard callbacks (confirmations)."""
        query = update.callback_query
        await query.answer()

        user_id = query.from_user.id
        session = self._get_session(user_id)
        data = query.data

        if data.startswith("confirm:"):
            token = data[8:]
            result = session.confirm_action(token)

            if result:
                await query.edit_message_text(
                    query.message.text + "\n\n✅ <b>Confirmed</b> — executing...", parse_mode="HTML"
                )

                # Execute the confirmed action
                action = result.get("action", "")
                if action:
                    try:
                        loop = asyncio.get_event_loop()
                        response = await loop.run_in_executor(
                            None,
                            lambda: self.agent.chat(
                                f"[CONFIRMED] Execute: {action}",
                                user_id=user_id,
                                channel="telegram",
                            ),
                        )
                        await query.message.reply_text(
                            response,
                            parse_mode="HTML",
                        )
                    except Exception as e:
                        await query.message.reply_text(f"⚠️ Action failed: {e}")
            else:
                await query.edit_message_text(
                    query.message.text + "\n\n⚠️ <b>Expired or invalid</b>", parse_mode="HTML"
                )

        elif data.startswith("cancel:"):
            token = data[7:]
            if token in session.pending_confirmations:
                del session.pending_confirmations[token]

            await query.edit_message_text(
                query.message.text + "\n\n❌ <b>Cancelled</b>", parse_mode="HTML"
            )

        elif data.startswith("tool_confirm:"):
            token = data[len("tool_confirm:") :]
            pending = session.pending_confirmations.get(token)

            if not pending:
                await query.edit_message_text(
                    query.message.text + "\n\n⚠️ <b>Expired</b> — please try again.",
                    parse_mode="HTML",
                )
                return

            # Check expiry
            import datetime as _dt

            expires = _dt.datetime.fromisoformat(pending["expires_at"])
            if _dt.datetime.now(_dt.timezone.utc) > expires:
                del session.pending_confirmations[token]
                await query.edit_message_text(
                    query.message.text + "\n\n⏱️ <b>Timed out</b> — please try again.",
                    parse_mode="HTML",
                )
                self.agent.sessions.save_session(session)
                return

            # Remove from pending before execution
            del session.pending_confirmations[token]
            self.agent.sessions.save_session(session)

            await query.edit_message_text(
                query.message.text + "\n\n✅ <b>Confirmed</b> — executing…",
                parse_mode="HTML",
            )

            # Re-execute the stored tool call directly with _confirmed=True
            tool_input = dict(pending["tool_input"])
            tool_input["_confirmed"] = True
            try:
                result = await asyncio.get_event_loop().run_in_executor(
                    None,
                    lambda: self.agent.tools.execute(
                        pending["tool_name"],
                        tool_input,
                        context={
                            "session": session,
                            "session_manager": self.agent.sessions,
                            "agent": self.agent,
                            "user_id": user_id,
                            "channel": "telegram",
                        },
                    ),
                )
                # Phase 4: capture context from confirmed tool result
                try:
                    from familiar.core.agent import _capture_context_from_result

                    _capture_context_from_result(
                        pending["tool_name"],
                        pending.get("tool_input", {}),
                        result,
                        session,
                        self.agent.sessions,
                    )
                except Exception:
                    pass
                await query.message.reply_text(result or "✅ Done.")
            except Exception as e:
                logger.error(f"Confirmed tool execution failed: {e}")
                await query.message.reply_text(f"⚠️ Action failed: {e}")
            return

        elif data.startswith("tool_cancel:"):
            token = data[len("tool_cancel:") :]
            session.pending_confirmations.pop(token, None)
            await query.edit_message_text(
                query.message.text + "\n\n❌ <b>Cancelled.</b>",
                parse_mode="HTML",
            )
            self.agent.sessions.save_session(session)
            return

        elif data.startswith("email:"):
            action = data[len("email:"):]
            session = self._get_session(user_id)
            email_menu = session.session_context.get("email_menu", {})

            if action == "connect":
                chat = query.message.chat
                rid = str(chat.id)
                self._wizard_chats[rid] = chat
                await self.handle_connect_menu_selection(rid, "email_menu")

            elif action.startswith("view:"):
                account_key = action[len("view:"):]
                result_text = email_menu.get("results", {}).get(account_key)
                if not result_text:
                    await query.message.reply_text("⚠️ Expired — check email again.")
                    return
                back_kb = InlineKeyboardMarkup([
                    [InlineKeyboardButton("⬅️ Back to accounts", callback_data="email:back")],
                    [InlineKeyboardButton("➕ Connect another", callback_data="email:connect")],
                ])
                await query.message.reply_text(result_text, reply_markup=back_kb)

            elif action == "back":
                summaries = email_menu.get("summaries")
                if summaries:
                    await self._send_email_account_menu(
                        chat_id=query.message.chat.id, summaries=summaries,
                    )
                else:
                    await query.message.reply_text("⚠️ Expired — check email again.")
            return

        elif data.startswith("cal:"):
            action = data[len("cal:"):]
            session = self._get_session(user_id)
            cal_menu = session.session_context.get("calendar_menu", {})

            if action == "connect":
                chat = query.message.chat
                rid = str(chat.id)
                self._wizard_chats[rid] = chat
                await self.handle_connect_menu_selection(rid, "calendar_menu")

            elif action.startswith("view:"):
                account_key = action[len("view:"):]
                result_text = cal_menu.get("results", {}).get(account_key)
                if not result_text:
                    await query.message.reply_text("⚠️ Expired — check calendar again.")
                    return
                back_kb = InlineKeyboardMarkup([
                    [InlineKeyboardButton("⬅️ Back to accounts", callback_data="cal:back")],
                    [InlineKeyboardButton("➕ Connect another", callback_data="cal:connect")],
                ])
                await query.message.reply_text(result_text, reply_markup=back_kb)

            elif action == "back":
                summaries = cal_menu.get("summaries")
                if summaries:
                    await self._send_calendar_account_menu(
                        chat_id=query.message.chat.id, summaries=summaries,
                    )
                else:
                    await query.message.reply_text("⚠️ Expired — check calendar again.")
            return

        elif data.startswith("connect:"):
            # Handle /connect inline keyboard selections via ConnectMixin
            session = self._get_session(user_id)
            if session.trust_level != TrustLevel.OWNER:
                await query.edit_message_text("🔒 OWNER access required.")
                return
            await query.answer()
            chat = query.message.chat
            rid = str(chat.id)
            self._wizard_chats[rid] = chat
            key = data.split(":", 1)[1]
            await self.handle_connect_menu_selection(rid, key)
            return

        self.agent.sessions.save_session(session)

    # ── /connect: Service Connection Wizard (v1.0.14) ─────────────────

    async def _handle_connect(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /connect command — delegates to ConnectMixin."""
        user_id = update.effective_user.id
        if not self._is_access_allowed(user_id):
            return

        session = self._get_session(user_id)
        if session.trust_level != TrustLevel.OWNER:
            await update.message.reply_text(
                "🔒 /connect requires OWNER trust level.\n"
                "Only the instance owner can configure services."
            )
            return

        # Store chat for wizard_send routing
        chat = update.effective_chat
        rid = str(chat.id)
        self._wizard_chats[rid] = chat
        args = list(context.args) if context.args else []
        await self.handle_connect_command(rid, args)

    async def _connect_menu(self, update: Update):
        """Show available services with inline keyboard."""
        import os

        # Detect current state
        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"))
        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"))
        has_calendar = (data_dir / "google_token.json").exists() or has_caldav
        current = self.agent.provider.name

        check = "✅"
        empty = "⬜"

        lines = [
            "🔌 <b>Connect Services</b>\n",
            "<b>LLM Providers:</b>",
            f"  {check if has_anthropic else empty} Anthropic (Claude) — best quality",
            f"  {check if has_openai else empty} OpenAI (GPT)",
            f"  {check if has_gemini else empty} Gemini (Google AI)",
            f"  {'✅' if 'Ollama' in current else empty} Ollama (local, free)",
            f"  Current: <b>{current}</b>\n",
            "<b>Integrations:</b>",
            f"  {check if has_google else empty} Google Workspace (Calendar, Drive)",
            f"  {check if has_calendar else empty} Calendar (Google/CalDAV)",
            f"  {check if has_email else empty} Email (IMAP/SMTP)",
            f"  {check if has_twilio else empty} SMS (Twilio)",
            f"  {empty} Browser (Playwright)",
            f"  {empty} Voice (Whisper)\n",
            "<i>🔐 All credentials stored in ~/.familiar/.env (chmod 600)</i>",
        ]

        keyboard = [
            [
                InlineKeyboardButton("Claude ☁️", callback_data="connect:anthropic"),
                InlineKeyboardButton("GPT ☁️", callback_data="connect:openai"),
                InlineKeyboardButton("Gemini ☁️", callback_data="connect:gemini"),
            ],
            [
                InlineKeyboardButton("Ollama 🏠", callback_data="connect:ollama"),
                InlineKeyboardButton("📅 Calendar", callback_data="connect:calendar_menu"),
                InlineKeyboardButton("📧 Email", callback_data="connect:email_menu"),
            ],
            [
                InlineKeyboardButton("📅 Google", callback_data="connect:google_menu"),
                InlineKeyboardButton("📱 SMS", callback_data="connect:sms_menu"),
                InlineKeyboardButton("📡 Status", callback_data="connect:status_menu"),
            ],
            [
                InlineKeyboardButton("🌐 Browser", callback_data="connect:browser_menu"),
                InlineKeyboardButton("🎤 Voice", callback_data="connect:voice_menu"),
            ],
        ]

        await update.message.reply_text(
            "\n".join(lines),
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def _handle_connect_callback(self, query, user_id: int):
        """Handle inline keyboard taps from /connect menu — run wizards directly."""
        session = self._get_session(user_id)
        if session.trust_level != TrustLevel.OWNER:
            await query.edit_message_text("🔒 OWNER access required.")
            return

        service = query.data.split(":", 1)[1]

        # Acknowledge the button press
        await query.answer()

        # Create a fake Update-like wrapper that lets us send new messages
        # to the chat (since edit_message_text can only modify the button msg)
        chat = query.message.chat

        if service == "anthropic":
            await chat.send_message(
                "☁️ <b>Connect Anthropic (Claude)</b>\n\n"
                "1. Get an API key from:\n"
                "   https://console.anthropic.com\n\n"
                "2. Send it here:\n"
                "   <code>/connect anthropic sk-ant-api03-...</code>\n\n"
                "<i>Key is stored locally and never shared.</i>",
                parse_mode="HTML",
            )
        elif service == "openai":
            await chat.send_message(
                "☁️ <b>Connect OpenAI (GPT)</b>\n\n"
                "1. Get an API key from:\n"
                "   https://platform.openai.com/api-keys\n\n"
                "2. Send it here:\n"
                "   <code>/connect openai sk-...</code>\n\n"
                "<i>Key is stored locally and never shared.</i>",
                parse_mode="HTML",
            )
        elif service == "gemini":
            await chat.send_message(
                "☁️ <b>Connect Gemini (Google AI)</b>\n\n"
                "1. Get an API key from:\n"
                "   https://aistudio.google.com/apikey\n\n"
                "2. Send it here:\n"
                "   <code>/connect gemini AIza...</code>\n\n"
                "<i>Key is stored locally and never shared.</i>",
                parse_mode="HTML",
            )
        elif service == "ollama":
            await chat.send_message(
                "🏠 <b>Configure Ollama (Local)</b>\n\n"
                "Switch to a specific Ollama model:\n"
                "   <code>/connect ollama llama3.1:8b</code>\n\n"
                "Pull a new model first:\n"
                "   <code>ollama pull llama3.1:8b</code>\n\n"
                "Current: <b>" + self.agent.provider.name + "</b>",
                parse_mode="HTML",
            )
        elif service == "google_install":
            # Actually run the install
            await chat.send_message("📦 Installing Google libraries...")
            await self._google_install_libs_for_chat(chat)
        elif service == "google_check":
            await self._google_check_status_for_chat(chat)
        elif service.startswith("google_auth_"):
            svc = service.replace("google_auth_", "")
            data_dir = Path.home() / ".familiar" / "data"
            creds_file = data_dir / "google_credentials.json"
            await self._google_auth_headless_for_chat(chat, creds_file, data_dir, svc)
        elif service == "google_menu":
            # Run the full Google wizard and send as new message
            await self._connect_google_for_chat(chat)
        elif service.startswith("email_"):
            provider = service.replace("email_", "")
            if provider == "menu":
                await self._connect_email_for_chat(chat)
            elif provider == "custom":
                await chat.send_message(
                    "📧 <b>Custom Email (IMAP/SMTP)</b>\n\n"
                    "Send:\n"
                    "  <code>/connect email custom your@email.com PASSWORD imap.server smtp.server</code>",
                    parse_mode="HTML",
                )
            elif provider == "proton":
                await self._connect_proton_bridge(chat)
            elif provider in ("gmail", "outlook", "yahoo"):
                await self._start_email_wizard(chat, provider)
        elif service == "calendar_menu":
            await self._connect_calendar_for_chat(chat)
        elif service.startswith("calendar_"):
            cal_action = service.replace("calendar_", "")
            if cal_action == "google":
                await self._start_google_calendar_wizard(chat)
            elif cal_action == "caldav":
                await self._start_caldav_wizard(chat)
            elif cal_action == "test":
                await chat.send_message("Testing calendar connection...")
                await self._calendar_test_for_chat(chat)
        elif service == "sms_menu":
            await self._connect_sms_for_chat(chat)
        elif service == "browser_menu":
            await self._connect_browser_for_chat(chat)
        elif service == "voice_menu":
            await self._connect_voice_for_chat(chat)
        elif service == "status_menu":
            await self._connect_status_for_chat(chat)

    # ── Chat-based variants of connect wizards (for callback buttons) ──

    async def _connect_google_for_chat(self, chat):
        """Run Google wizard and send result as a new message to chat."""
        data_dir = Path.home() / ".familiar" / "data"
        creds_file = data_dir / "google_credentials.json"

        has_libs = self._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",
        }

        check, cross = "✅", "❌"
        step_lines = []
        next_action = None

        if not has_libs:
            step_lines.append(f"{cross} <b>Step 1:</b> Install Google libraries")
            next_action = (
                "👉 <b>Next:</b> Tap <b>Install Libraries</b> below, or run on the server:\n"
                "  <code>pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client</code>"
            )
        else:
            step_lines.append(f"{check} <b>Step 1:</b> Google libraries installed")

        if not has_creds:
            step_lines.append(f"{cross} <b>Step 2:</b> OAuth credentials.json")
            if not next_action:
                actual_path = str(data_dir / "google_credentials.json")
                next_action = (
                    "👉 <b>Easiest:</b> Run the guided setup script on the server:\n"
                    "  <code>python -m familiar.onboard.google_setup</code>\n\n"
                    "Or follow these steps manually:\n"
                    "1. Go to https://console.cloud.google.com\n"
                    "2. Create project → Enable APIs (Calendar, Drive, Gmail, People, Docs)\n"
                    "3. Create OAuth Client ID → <b>Desktop app</b>\n"
                    "4. Download the JSON file, place it at:\n"
                    f"  <code>{actual_path}</code>\n\n"
                    "Or send the file directly in this chat — "
                    "Familiar will save it automatically."
                )
        else:
            step_lines.append(f"{check} <b>Step 2:</b> 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"{check} <b>Step 3:</b> Authorized: {', '.join(authorized)}")
        if not_authorized:
            step_lines.append(
                f"{'⬜' if authorized else cross} <b>Step 3:</b> Not yet: {', '.join(not_authorized)}"
            )
            if not next_action and has_libs and has_creds:
                svc = not_authorized[0].lower()
                next_action = f"👉 <b>Next:</b> Tap <b>Authorize {not_authorized[0]}</b> below."

        if has_libs and has_creds and not not_authorized:
            next_action = '🎉 <b>All Google services connected!</b> Try: "What\'s on my calendar?"'

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

        await chat.send_message(
            "\n".join(["📅 <b>Google Workspace Setup</b>\n", *step_lines, "", next_action or ""]),
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard) if keyboard else None,
        )

    async def _connect_email_for_chat(self, chat):
        """Show email wizard as new message."""
        import os

        try:
            from familiar.skills.email.accounts import get_all_accounts
            accounts = get_all_accounts()
        except Exception:
            accounts = []

        if accounts:
            acct_lines = [
                f"  ✅ {a.get('provider', 'custom').capitalize()}: <b>{a['address']}</b>"
                for a in accounts
            ]
            status = "\n".join(acct_lines)
            action = "Add another provider or reconfigure:"
        else:
            status = "  No accounts configured"
            action = "Choose your email provider:"

        keyboard = [
            [
                InlineKeyboardButton("Gmail", callback_data="connect:email_gmail"),
                InlineKeyboardButton("Outlook", callback_data="connect:email_outlook"),
            ],
            [
                InlineKeyboardButton("Yahoo", callback_data="connect:email_yahoo"),
                InlineKeyboardButton("Proton Mail", callback_data="connect:email_proton"),
            ],
            [InlineKeyboardButton("Custom IMAP/SMTP", callback_data="connect:email_custom")],
        ]

        await chat.send_message(
            f"📧 <b>Email Setup</b>\n\n{status}\n\n{action}",
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def _connect_sms_for_chat(self, chat):
        """Show SMS wizard."""
        await chat.send_message(
            "📱 <b>SMS Setup (Twilio)</b>\n\n"
            "1. Sign up at https://twilio.com\n"
            "2. Get Account SID, Auth Token, and a phone number\n\n"
            "Send (3 values, space-separated):\n"
            "  <code>/connect sms &lt;SID&gt; &lt;TOKEN&gt; &lt;PHONE&gt;</code>\n\n"
            "Example:\n"
            "  <code>/connect sms ACe0a988d7... 9f3c8b... +15551234567</code>",
            parse_mode="HTML",
        )

    async def _connect_browser_for_chat(self, chat):
        """Check browser status."""
        try:
            import playwright  # noqa: F401

            await chat.send_message(
                "🌐 <b>Browser</b>\n\n✅ Playwright installed and ready.",
                parse_mode="HTML",
            )
        except ImportError:
            await chat.send_message(
                "🌐 <b>Browser</b>\n\n"
                "❌ Playwright not installed.\n\n"
                "Run:\n"
                "  <code>pip install playwright && python -m playwright install chromium</code>",
                parse_mode="HTML",
            )

    async def _connect_voice_for_chat(self, chat):
        """Check voice status."""
        parts = ["🎤 <b>Voice / Transcription</b>\n"]
        try:
            import whisper  # noqa: F401

            parts.append("✅ Whisper (speech-to-text) installed")
        except ImportError:
            parts.append("❌ Whisper not installed: <code>pip install openai-whisper</code>")
        try:
            import pyttsx3  # noqa: F401

            parts.append("✅ pyttsx3 (text-to-speech) installed")
        except ImportError:
            parts.append("⬜ pyttsx3 not installed (optional): <code>pip install pyttsx3</code>")
        await chat.send_message("\n".join(parts), parse_mode="HTML")

    async def _connect_status_for_chat(self, chat):
        """Show connection status."""
        import os

        lines = ["📡 <b>Connection Status</b>\n"]
        lines.append(f"  {'✅' if os.environ.get('ANTHROPIC_API_KEY') else '❌'} Anthropic")
        lines.append(f"  {'✅' if os.environ.get('OPENAI_API_KEY') else '❌'} OpenAI")
        lines.append(f"  ✅ Ollama ({self.agent.provider.name})")
        lines.append(f"  {'✅' if os.environ.get('EMAIL_ADDRESS') else '❌'} Email")
        lines.append(
            f"  {'✅' if (Path.home() / '.familiar' / 'data' / 'google_credentials.json').exists() else '❌'} Google"
        )
        lines.append(f"  {'✅' if os.environ.get('TWILIO_ACCOUNT_SID') else '❌'} SMS")
        await chat.send_message("\n".join(lines), parse_mode="HTML")

    async def _google_install_libs_for_chat(self, chat):
        """Install Google libs and report to chat."""
        import subprocess

        try:
            subprocess.run(
                [
                    "pip",
                    "install",
                    "google-auth-oauthlib",
                    "google-auth-httplib2",
                    "google-api-python-client",
                    "--break-system-packages",
                    "-q",
                ],
                check=True,
                timeout=120,
            )
            await chat.send_message(
                "✅ Google libraries installed! Run /connect google to continue."
            )
        except Exception as e:
            await chat.send_message(f"❌ Install failed: {e}")

    async def _google_check_status_for_chat(self, chat):
        """Check Google token status and report to chat."""
        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 = ["📅 <b>Google Token Status</b>\n"]
        for name, path in token_files.items():
            lines.append(f"  {'✅' if path.exists() else '❌'} {name}")
        await chat.send_message("\n".join(lines), parse_mode="HTML")

    async def _google_auth_headless_for_chat(self, chat, creds_file, data_dir, service_name):
        """Start headless OAuth and send URL to chat."""
        if not creds_file.exists():
            await chat.send_message(
                "❌ Missing credentials.json. Run /connect google for setup steps."
            )
            return
        try:
            import http.server
            import socket
            import threading
            import urllib.parse

            from google_auth_oauthlib.flow import InstalledAppFlow

            scopes_map = {
                "calendar": ["https://www.googleapis.com/auth/calendar"],
                "drive": ["https://www.googleapis.com/auth/drive"],
                "contacts": ["https://www.googleapis.com/auth/contacts.readonly"],
                "gmail": ["https://www.googleapis.com/auth/gmail.modify"],
            }
            scopes = scopes_map.get(service_name, scopes_map["calendar"])
            flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), scopes=scopes)

            # Find a free local port for the redirect receiver.
            # Google deprecated urn:ietf:wg:oauth:2.0:oob in 2022 — localhost
            # redirect is the correct headless pattern for installed apps.
            with socket.socket() as sock:
                sock.bind(("127.0.0.1", 0))
                port = sock.getsockname()[1]

            redirect_uri = f"http://localhost:{port}"
            flow.redirect_uri = redirect_uri
            auth_url, _ = flow.authorization_url(prompt="consent")

            # One-shot local HTTP server to receive the redirect code.
            _auth_code: dict = {}

            class _Handler(http.server.BaseHTTPRequestHandler):
                def do_GET(self):
                    params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
                    _auth_code["code"] = params.get("code", [None])[0]
                    _auth_code["error"] = params.get("error", [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  # Suppress access log noise

            server = http.server.HTTPServer(("127.0.0.1", port), _Handler)
            server.timeout = 300  # 5-minute window

            threading.Thread(target=server.handle_request, daemon=True).start()

            # Store flow + server ref; code handler in _connect_google_code uses these
            self._pending_google_flow = flow
            self._pending_google_service = service_name
            self._pending_google_server = server

            await chat.send_message(
                f"🔑 <b>Authorize {service_name.title()}</b>\n\n"
                f"1. Open this URL in a browser on the <b>same machine</b> as Familiar:\n"
                f"   {auth_url}\n\n"
                f"2. Approve access — you'll be redirected to localhost:{port} automatically\n\n"
                f"<i>If Familiar runs on a remote server (no local browser), copy the URL to any "
                f"browser, approve, then paste the full redirect URL back here:</i>\n"
                f"   <code>/connect google code PASTE_FULL_REDIRECT_URL</code>",
                parse_mode="HTML",
            )
        except ImportError:
            await chat.send_message(
                "❌ Google libraries not installed. Tap Install Libraries first."
            )
        except Exception as e:
            await chat.send_message(f"❌ Auth error: {e}")

    async def _connect_llm_provider(self, update: Update, provider: str, args: list):
        """Connect a cloud LLM provider by saving the API key."""
        import os
        from pathlib import Path

        provider_info = {
            "anthropic": {
                "name": "Anthropic (Claude)",
                "prefix": "sk-ant-",
                "url": "https://console.anthropic.com",
                "env_var": "ANTHROPIC_API_KEY",
            },
            "openai": {
                "name": "OpenAI (GPT)",
                "prefix": "sk-",
                "url": "https://platform.openai.com/api-keys",
                "env_var": "OPENAI_API_KEY",
            },
            "gemini": {
                "name": "Gemini (Google AI)",
                "prefix": "AIza",
                "url": "https://aistudio.google.com/apikey",
                "env_var": "GEMINI_API_KEY",
            },
        }
        info = provider_info.get(provider, provider_info["openai"])

        if not args:
            await update.message.reply_text(
                f"☁️ <b>Connect {info['name']}</b>\n\n"
                f"1. Get your API key from:\n   {info['url']}\n\n"
                f"2. Send:\n   <code>/connect {provider} {info['prefix']}...</code>\n\n"
                f"<i>🔐 Key is saved to ~/.familiar/.env only.</i>",
                parse_mode="HTML",
            )
            return

        api_key = args[0]

        # Basic key format validation
        if provider == "anthropic" and not api_key.startswith("sk-ant-"):
            await update.message.reply_text(
                "⚠️ That doesn't look like an Anthropic key.\n"
                "Anthropic keys start with <code>sk-ant-</code>",
                parse_mode="HTML",
            )
            return
        if provider == "openai" and not api_key.startswith("sk-"):
            await update.message.reply_text(
                "⚠️ That doesn't look like an OpenAI key.\nOpenAI keys start with <code>sk-</code>",
                parse_mode="HTML",
            )
            return
        if provider == "gemini" and not api_key.startswith("AIza"):
            await update.message.reply_text(
                "⚠️ That doesn't look like a Gemini key.\n"
                "Gemini keys start with <code>AIza</code>",
                parse_mode="HTML",
            )
            return

        env_var = info["env_var"]

        # 1. Set in current process
        os.environ[env_var] = api_key
        os.environ["DEFAULT_PROVIDER"] = provider

        # 2. Persist to .env file
        env_path = Path.home() / ".familiar" / ".env"
        try:
            self._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 update.message.reply_text(
                f"⚠️ Key set for this session but failed to persist: {e}"
            )
            return

        # 3. Switch provider
        try:
            self.agent.switch_provider(provider)
            name = self.agent.provider.name

            # Delete the user's message containing the API key
            try:
                await update.message.delete()
            except Exception:
                pass  # May fail if bot lacks delete permission

            await update.effective_chat.send_message(
                f"✅ <b>Connected!</b>\n\n"
                f"Provider: <b>{name}</b>\n"
                f"Key: <code>{api_key[:12]}...{api_key[-4:]}</code>\n"
                f"Saved to: <code>~/.familiar/.env</code>\n\n"
                f"<i>Your message with the full key has been deleted for safety.</i>",
                parse_mode="HTML",
            )
        except Exception as e:
            await update.message.reply_text(
                f"⚠️ Key saved but provider switch failed:\n{e}\n\n"
                f"The key is persisted. Try restarting Familiar."
            )

    async def _connect_ollama(self, update: Update, args: list):
        """Manage Ollama models: switch, pull, list, or remove."""
        sub = args[0].lower() if args else ""

        # /connect ollama list — show installed models
        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()

                current = getattr(self.agent.config.llm, "ollama_model", "unknown")
                lightweight = getattr(self.agent.config.llm, "lightweight_model", "")

                text = f"🦙 <b>Ollama Models</b>\n\n<b>Active:</b> {current}\n"
                if lightweight:
                    text += f"<b>Background:</b> {lightweight}\n"

                text += (
                    f"\n<b>Installed:</b>\n"
                    f"<pre>{models}</pre>\n\n"
                    f"<b>Commands:</b>\n"
                    f"/connect ollama pull &lt;model&gt; — download a model\n"
                    f"/connect ollama &lt;model&gt; — switch active model\n"
                    f"/connect ollama bg &lt;model&gt; — set background model\n"
                    f"/connect ollama remove &lt;model&gt; — delete a model\n\n"
                    f"<b>Recommended models:</b>\n"
                    f"• <code>llama3.2</code> — 2GB, good general purpose\n"
                    f"• <code>qwen2.5:3b</code> — 2GB, strong reasoning\n"
                    f"• <code>mistral</code> — 4GB, excellent quality\n"
                    f"• <code>deepseek-r1:14b</code> — 9GB, best local reasoning\n"
                    f"• <code>nomic-embed-text</code> — 137MB, semantic search"
                )

                await update.message.reply_text(text, parse_mode="HTML")
            except FileNotFoundError:
                await update.message.reply_text("⚠️ Ollama not found.\n\nInstall: https://ollama.ai")
            except Exception as e:
                await update.message.reply_text(f"⚠️ Ollama error: {e}")
            return

        # /connect ollama pull <model> — download a new model
        if sub == "pull":
            model = args[1] if len(args) > 1 else ""
            if not model:
                await update.message.reply_text(
                    "Usage: <code>/connect ollama pull modelname</code>\n\n"
                    "Examples:\n"
                    "  <code>/connect ollama pull mistral</code>\n"
                    "  <code>/connect ollama pull deepseek-r1:14b</code>\n"
                    "  <code>/connect ollama pull nomic-embed-text</code>",
                    parse_mode="HTML",
                )
                return

            await update.message.reply_text(
                f"⏳ Downloading <b>{model}</b>... this may take a few minutes.",
                parse_mode="HTML",
            )

            try:
                result = await asyncio.create_subprocess_exec(
                    "ollama",
                    "pull",
                    model,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                stdout, stderr = await asyncio.wait_for(
                    result.communicate(),
                    timeout=600,  # 10 min timeout
                )

                if result.returncode == 0:
                    await update.message.reply_text(
                        f"✅ <b>{model}</b> downloaded successfully!\n\n"
                        f"Switch to it: <code>/connect ollama {model}</code>\n"
                        f"Or set as background: <code>/connect ollama bg {model}</code>",
                        parse_mode="HTML",
                    )
                else:
                    error = stderr.decode().strip() or stdout.decode().strip()
                    await update.message.reply_text(
                        f"⚠️ Failed to pull {model}:\n<pre>{error[:500]}</pre>",
                        parse_mode="HTML",
                    )
            except asyncio.TimeoutError:
                await update.message.reply_text(
                    f"⏱️ Download of {model} is taking longer than 10 minutes.\n"
                    f"It may still be running in the background.\n"
                    f"Check with: <code>/connect ollama list</code>",
                    parse_mode="HTML",
                )
            except FileNotFoundError:
                await update.message.reply_text("⚠️ Ollama not found. Install: https://ollama.ai")
            return

        # /connect ollama bg <model> — set lightweight/background model
        if sub == "bg":
            model = args[1] if len(args) > 1 else ""
            if not model:
                await update.message.reply_text(
                    "Usage: <code>/connect ollama bg modelname</code>\n\n"
                    "Sets a small model for background tasks (planning, memory extraction).\n"
                    "Recommended: <code>qwen2.5:0.5b</code> or <code>smollm2:360m</code>",
                    parse_mode="HTML",
                )
                return

            self.agent.config.llm.lightweight_model = model
            self.agent.config.llm.lightweight_provider = "ollama"

            # Reinitialize the lightweight provider
            try:
                from ..core.providers import get_lightweight_provider

                self.agent._lightweight_provider = get_lightweight_provider(self.agent.config)
                await update.message.reply_text(
                    f"✅ Background model set to <b>{model}</b>\n\n"
                    f"Used for: planning, memory extraction, triage\n"
                    f"<i>Main model handles conversation.</i>",
                    parse_mode="HTML",
                )
            except Exception as e:
                await update.message.reply_text(f"⚠️ Failed to set background model: {e}")
            return

        # /connect ollama remove <model> — delete a model
        if sub == "remove" or sub == "delete":
            model = args[1] if len(args) > 1 else ""
            if not model:
                await update.message.reply_text(
                    "Usage: <code>/connect ollama remove modelname</code>",
                    parse_mode="HTML",
                )
                return

            try:
                result = await asyncio.create_subprocess_exec(
                    "ollama",
                    "rm",
                    model,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                stdout, stderr = await asyncio.wait_for(result.communicate(), timeout=30)

                if result.returncode == 0:
                    await update.message.reply_text(f"🗑️ <b>{model}</b> removed.", parse_mode="HTML")
                else:
                    error = stderr.decode().strip()
                    await update.message.reply_text(f"⚠️ {error}")
            except Exception as e:
                await update.message.reply_text(f"⚠️ Error: {e}")
            return

        # /connect ollama <model> — switch active model
        model = sub
        try:
            from ..core.providers import OllamaProvider

            provider = OllamaProvider(model, self.agent.config.llm.ollama_base_url)
            self.agent.provider = provider
            self.agent.config.llm.ollama_model = model
            self.agent.config.llm.default_provider = "ollama"

            await update.message.reply_text(
                f"✅ Switched to Ollama: <b>{model}</b>\n\n"
                f"<i>To make permanent, update config.yaml:\n"
                f"  ollama_model: {model}</i>",
                parse_mode="HTML",
            )
        except Exception as e:
            await update.message.reply_text(
                f"⚠️ Failed to switch to {model}:\n{e}\n\n"
                f"Pull it first: <code>/connect ollama pull {model}</code>",
                parse_mode="HTML",
            )

    async def _connect_google(self, update: Update, args: list):
        """
        Google Workspace connection wizard.

        Multi-step flow:
          /connect google           — show status and next step
          /connect google check     — verify all tokens
          /connect google auth      — trigger headless OAuth for calendar
          /connect google auth drive — trigger headless OAuth for drive
          /connect google install   — install Google libraries
        """
        sub = args[0].lower() if args else ""
        data_dir = Path.home() / ".familiar" / "data"
        creds_file = data_dir / "google_credentials.json"

        # ── Step dispatcher ──
        if sub == "check":
            await self._google_check_status(update, creds_file, data_dir)
            return
        elif sub == "install":
            await self._google_install_libs(update)
            return
        elif sub == "auth":
            service_name = args[1].lower() if len(args) > 1 else "calendar"
            await self._google_auth_headless(update, creds_file, data_dir, service_name)
            return
        elif sub == "code":
            auth_code = " ".join(args[1:]) if len(args) > 1 else ""
            if not auth_code:
                await update.message.reply_text(
                    "Usage: <code>/connect google code YOUR_AUTH_CODE</code>",
                    parse_mode="HTML",
                )
                return
            await self._google_handle_code(update, auth_code)
            return

        # ── Default: show guided setup ──

        # Detect current state
        has_libs = self._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",
        }

        check = "✅"
        cross = "❌"

        # Determine which step the user is on
        step_lines = []
        next_action = None

        # Step 1: Libraries
        if not has_libs:
            step_lines.append(f"{cross} <b>Step 1:</b> Install Google libraries")
            next_action = (
                "👉 <b>Next:</b> Run this or send:\n"
                "  <code>/connect google install</code>\n\n"
                "Or on the server:\n"
                "  <code>pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client</code>"
            )
        else:
            step_lines.append(f"{check} <b>Step 1:</b> Google libraries installed")

        # Step 2: Credentials
        if not has_creds:
            step_lines.append(f"{cross} <b>Step 2:</b> OAuth credentials.json")
            if not next_action:
                actual_path = str(data_dir / "google_credentials.json")
                next_action = (
                    "👉 <b>Next:</b> Create a Google Cloud project:\n\n"
                    "1. Go to https://console.cloud.google.com\n"
                    '2. Create project: "Familiar AI Assistant"\n'
                    "3. Enable APIs: Calendar, Drive, People, Gmail\n"
                    "4. Create OAuth consent screen (External, test mode)\n"
                    "5. Create OAuth Client ID → <b>Desktop app</b>\n"
                    "6. Download the JSON file\n\n"
                    "<b>Then place it here:</b>\n"
                    f"  <code>{actual_path}</code>\n\n"
                    "Or send the file directly in this chat — "
                    "Familiar will save it automatically."
                )
        else:
            step_lines.append(f"{check} <b>Step 2:</b> credentials.json present")

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

        if authorized:
            step_lines.append(f"{check} <b>Step 3:</b> Authorized: {', '.join(authorized)}")
        if not_authorized:
            step_lines.append(
                f"{'⬜' if authorized else cross} <b>Step 3:</b> Not yet authorized: {', '.join(not_authorized)}"
            )
            if not next_action and has_libs and has_creds:
                svc = not_authorized[0].lower()
                next_action = (
                    f"👉 <b>Next:</b> Authorize {not_authorized[0]}:\n"
                    f"  <code>/connect google auth {svc}</code>\n\n"
                    f"This will generate an authorization URL.\n"
                    f"Open it in a browser, approve access, and\n"
                    f"paste the code back here."
                )

        # All done?
        if has_libs and has_creds and not not_authorized:
            next_action = '🎉 <b>All Google services connected!</b> Try: "What\'s on my calendar?"'

        lines = [
            "📅 <b>Google Workspace Setup</b>\n",
            *step_lines,
            "",
            next_action or "",
        ]

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

        await update.message.reply_text(
            "\n".join(lines),
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard) if keyboard else None,
        )

    @staticmethod
    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

    async def _google_check_status(self, update: Update, creds_file: Path, data_dir: Path):
        """Show detailed Google connection status."""
        check = "✅"
        cross = "❌"

        has_libs = self._check_google_libs()
        has_creds = creds_file.exists()

        # Check each token
        services = {
            "Calendar": ("google_token.json", "calendar"),
            "Drive": ("google_token_drive.json", "drive"),
            "Contacts": ("google_token_contacts.json", "contacts"),
            "Gmail": ("google_token_gmail.json", "gmail"),
        }

        lines = [
            "📅 <b>Google Connection Status</b>\n",
            f"  {check if has_libs else cross} Google API libraries",
            f"  {check if has_creds else cross} OAuth credentials.json",
            "",
            "<b>Service Tokens:</b>",
        ]

        for name, (token_file, svc) in services.items():
            token_path = data_dir / token_file
            if token_path.exists():
                # Check if token is valid
                try:
                    import json as _json

                    token_data = _json.loads(token_path.read_text())
                    expiry = token_data.get("expiry", "unknown")
                    lines.append(
                        f"  {check} {name} (expires: {expiry[:10] if len(expiry) > 10 else expiry})"
                    )
                except Exception:
                    lines.append(f"  {check} {name} (token present)")
            else:
                lines.append(f"  {cross} {name}")

        await update.message.reply_text("\n".join(lines), parse_mode="HTML")

    async def _google_install_libs(self, update: Update):
        """Attempt to install Google API libraries."""
        if self._check_google_libs():
            await update.message.reply_text("✅ Google API libraries already installed!")
            return

        await update.message.reply_text(
            "📦 Installing Google API libraries... this may take a moment."
        )

        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 self._check_google_libs():
                await update.message.reply_text(
                    "✅ <b>Google libraries installed!</b>\n\n"
                    "Next: Place your credentials.json file at:\n"
                    "  <code>~/.familiar/data/google_credentials.json</code>\n\n"
                    "Then run <code>/connect google</code> to continue.",
                    parse_mode="HTML",
                )
            else:
                error = result.stderr[-300:] if result.stderr else "Unknown error"
                await update.message.reply_text(
                    f"⚠️ Installation failed. Try manually on the server:\n\n"
                    f"<code>pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client</code>\n\n"
                    f"Error: <code>{error}</code>",
                    parse_mode="HTML",
                )
        except Exception as e:
            await update.message.reply_text(f"⚠️ Installation failed: {e}")

    async def _google_auth_headless(
        self, update: Update, creds_file: Path, data_dir: Path, service: str
    ):
        """
        Run Google OAuth in headless mode — generates a URL for the user
        to open in their browser, then accepts the auth code via Telegram.
        """
        if not self._check_google_libs():
            await update.message.reply_text(
                "⚠️ Google libraries not installed.\nRun: <code>/connect google install</code>",
                parse_mode="HTML",
            )
            return

        if not creds_file.exists():
            await update.message.reply_text(
                "⚠️ credentials.json not found.\n"
                "Place it at: <code>~/.familiar/data/google_credentials.json</code>",
                parse_mode="HTML",
            )
            return

        # Determine scopes and token file for the service
        service_config = {
            "calendar": {
                "scopes": ["https://www.googleapis.com/auth/calendar"],
                "token_file": data_dir / "google_token.json",
                "name": "Calendar",
            },
            "drive": {
                "scopes": [
                    "https://www.googleapis.com/auth/drive.readonly",
                    "https://www.googleapis.com/auth/drive.file",
                ],
                "token_file": data_dir / "google_token_drive.json",
                "name": "Drive",
            },
            "contacts": {
                "scopes": ["https://www.googleapis.com/auth/contacts.readonly"],
                "token_file": data_dir / "google_token_contacts.json",
                "name": "Contacts",
            },
            "gmail": {
                "scopes": [
                    "https://www.googleapis.com/auth/gmail.readonly",
                    "https://www.googleapis.com/auth/gmail.send",
                ],
                "token_file": data_dir / "google_token_gmail.json",
                "name": "Gmail",
            },
        }

        if service not in service_config:
            await update.message.reply_text(
                f"Unknown service: {service}\nAvailable: {', '.join(service_config.keys())}"
            )
            return

        cfg = service_config[service]

        # Check if already authorized
        if cfg["token_file"].exists():
            await update.message.reply_text(
                f"✅ {cfg['name']} is already authorized!\n\n"
                f"To re-authorize, delete the token:\n"
                f"  <code>rm {cfg['token_file']}</code>\n"
                f"Then run this command again.",
                parse_mode="HTML",
            )
            return

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

            from google_auth_oauthlib.flow import InstalledAppFlow

            # Find a free local port for the redirect receiver.
            # Google deprecated urn:ietf:wg:oauth:2.0:oob in 2022.
            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),
                cfg["scopes"],
                redirect_uri=redirect_uri,
            )

            auth_url, _ = flow.authorization_url(prompt="consent")

            # One-shot local HTTP server to catch the redirect automatically
            _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()

            # Store the flow in context for the code callback
            if not hasattr(self, "_pending_google_flows"):
                self._pending_google_flows = {}

            self._pending_google_flows[update.effective_user.id] = {
                "flow": flow,
                "token_file": cfg["token_file"],
                "service_name": cfg["name"],
                "server": server,
            }

            await update.message.reply_text(
                f"🔐 <b>Authorize {cfg['name']}</b>\n\n"
                f"1. Open this link in a browser on the <b>same machine</b> 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 ✅\n\n"
                f"<i>Remote server? Copy the URL to any browser, approve, then paste the full "
                f"redirect URL here: <code>/connect google code PASTE_REDIRECT_URL</code></i>",
                parse_mode="HTML",
            )
        except Exception as e:
            await update.message.reply_text(f"⚠️ OAuth flow failed: {e}")

    async def _google_handle_code(self, update: Update, auth_code: str):
        """Handle the OAuth authorization code from the user."""
        user_id = update.effective_user.id

        if not hasattr(self, "_pending_google_flows") or user_id not in self._pending_google_flows:
            await update.message.reply_text(
                "⚠️ No pending Google authorization.\n"
                "Start with: <code>/connect google auth calendar</code>",
                parse_mode="HTML",
            )
            return

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

        try:
            import urllib.parse

            # Accept either a bare auth code or a full redirect URL
            # (e.g. http://localhost:PORT/?code=4/0A...&scope=...)
            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

            # Save token
            token_file.write_text(creds.to_json())

            await update.message.reply_text(
                f"✅ <b>{service_name} authorized!</b>\n\n"
                f"Token saved to:\n"
                f"  <code>{token_file}</code>\n\n"
                f'Try it: "What\'s on my calendar today?"',
                parse_mode="HTML",
            )
        except Exception as e:
            await update.message.reply_text(
                f"⚠️ Authorization failed: {e}\n\n"
                f"The code may have expired. Try again:\n"
                f"  <code>/connect google auth {service_name.lower()}</code>",
                parse_mode="HTML",
            )

    async def _connect_status(self, update: Update):
        """Show current connection status for all services."""
        import os

        check = "✅"
        cross = "❌"
        empty = "⬜"

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

        # Browser
        has_browser = False
        try:
            import playwright  # noqa: F401

            has_browser = True
        except ImportError:
            pass

        # Voice
        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

        current = self.agent.provider.name
        email_detail = f" ({os.environ.get('EMAIL_ADDRESS', '')})" if has_email else ""

        # Calendar status
        if has_caldav:
            cal_status = f"{check} CalDAV ({os.environ.get('CALDAV_URL', '')[:40]})"
        elif has_calendar_token:
            cal_status = f"{check} Google Calendar (OAuth active)"
        else:
            cal_status = f"{cross} Calendar — run /connect calendar"

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

        await update.message.reply_text("\n".join(lines), parse_mode="HTML")

    # ── /connect calendar: Calendar Integration Wizard ────────────────

    async def _connect_calendar(self, update: Update, args: list):
        """
        Calendar connection wizard.

        Flow:
          /connect calendar              — show status or provider picker
          /connect calendar google       — guide Google Calendar setup
          /connect calendar caldav URL USER PASSWORD — save CalDAV creds
          /connect calendar test         — test current connection
          /connect calendar set KEY VAL  — set individual env var
        """
        import os

        sub = args[0].lower() if args else ""

        if sub == "test":
            await self._calendar_test_connection(update)
            return
        elif sub == "set" and len(args) >= 3:
            await self._calendar_set_credential(update, args[1].upper(), " ".join(args[2:]))
            return
        elif sub == "google":
            chat = update.effective_chat
            await self._start_google_calendar_wizard(chat)
            return
        elif sub == "caldav":
            if len(args) >= 4:
                # /connect calendar caldav URL USER PASSWORD — inline still works
                await self._calendar_save_caldav(update, args[1], args[2], " ".join(args[3:]))
                return
            # No inline args — start interactive wizard
            chat = update.effective_chat
            await self._start_caldav_wizard(chat)
            return

        # Default: show status + picker
        data_dir = Path.home() / ".familiar" / "data"
        has_google_cal = (data_dir / "google_token.json").exists()
        has_caldav = bool(os.environ.get("CALDAV_URL"))

        if has_google_cal or has_caldav:
            provider = "Google Calendar" if has_google_cal else "CalDAV"
            caldav_url = os.environ.get("CALDAV_URL", "")
            detail = f" ({caldav_url[:40]})" if has_caldav else ""
            await update.message.reply_text(
                f"📅 <b>Calendar Configuration</b>\n\n"
                f"  ✅ Provider: <b>{provider}</b>{detail}\n\n"
                f"<b>Commands:</b>\n"
                f"  <code>/connect calendar test</code> — verify connection\n"
                f"  <code>/connect calendar caldav</code> — reconfigure CalDAV\n"
                f"  <code>/connect calendar google</code> — switch to Google\n\n"
                f'Try: "What\'s on my calendar today?"',
                parse_mode="HTML",
            )
            return

        keyboard = [
            [
                InlineKeyboardButton("Google Calendar", callback_data="connect:calendar_google"),
                InlineKeyboardButton("CalDAV", callback_data="connect:calendar_caldav"),
            ],
            [
                InlineKeyboardButton("🧪 Test Connection", callback_data="connect:calendar_test"),
            ],
        ]

        await update.message.reply_text(
            "📅 <b>Connect Calendar</b>\n\n"
            "Choose your calendar provider:\n\n"
            "  <b>Google Calendar</b> — via Google OAuth\n"
            "  <b>CalDAV</b> — Nextcloud, Radicale, ownCloud, Synology, etc.",
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def _connect_calendar_for_chat(self, chat):
        """Show calendar wizard as new message (for callback buttons)."""
        import os

        # Multi-account status
        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"  ✅ {label}: {atype.title()}{detail}")
            status_block = "\n".join(acct_lines)
        else:
            data_dir = Path.home() / ".familiar" / "data"
            has_google_cal = (data_dir / "google_token.json").exists()
            has_caldav = bool(os.environ.get("CALDAV_URL"))
            if has_google_cal:
                status_block = "  ✅ Google Calendar (OAuth active)"
            elif has_caldav:
                status_block = f"  ✅ CalDAV ({os.environ.get('CALDAV_URL', '')[:40]})"
            else:
                status_block = "  ⬜ Not configured"

        keyboard = [
            [
                InlineKeyboardButton("Google Calendar", callback_data="connect:calendar_google"),
                InlineKeyboardButton("CalDAV", callback_data="connect:calendar_caldav"),
            ],
            [
                InlineKeyboardButton("🧪 Test Connection", callback_data="connect:calendar_test"),
            ],
        ]

        await chat.send_message(
            f"📅 <b>Calendar Setup</b>\n\n"
            f"{status_block}\n\n"
            f"Add or reconfigure a calendar provider:",
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def _calendar_save_caldav(self, update: Update, url: str, user: str, password: str):
        """Save CalDAV credentials and test connection."""
        import os

        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:
            self._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 update.message.reply_text(
                f"⚠️ 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}")

        # Delete the message containing the password
        try:
            await update.message.delete()
        except Exception:
            pass

        # Test connection
        try:
            import caldav

            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 update.effective_chat.send_message(
                f"✅ <b>CalDAV Connected!</b>\n\n"
                f"Server: <code>{url[:50]}</code>\n"
                f"User: <code>{user}</code>\n"
                f"Calendars found: {len(calendars)}\n"
                + (("\n".join(f"  • {n}" for n in cal_names) + "\n") if cal_names else "")
                + f"\nSaved to: <code>~/.familiar/.env</code>\n\n"
                f"<i>Your message with credentials has been deleted for safety.</i>\n\n"
                f"Add another account? Use /connect calendar google or /connect calendar caldav",
                parse_mode="HTML",
            )
        except ImportError:
            await update.effective_chat.send_message(
                "⚠️ Credentials saved but <b>caldav</b> library not installed.\n"
                "Run: <code>pip install familiar-agent[nextcloud]</code>",
                parse_mode="HTML",
            )
        except Exception as e:
            await update.effective_chat.send_message(
                f"⚠️ Credentials saved but connection test failed:\n<code>{e}</code>\n\n"
                f"Check your URL, username, and password.\n"
                f"Reconfigure: <code>/connect calendar caldav URL USER PASSWORD</code>",
                parse_mode="HTML",
            )

    async def _calendar_test_connection(self, update: Update):
        """Test the currently configured calendar provider."""
        from familiar.skills.calendar.skill import _calendar_provider

        provider = _calendar_provider()
        if provider == "none":
            await update.message.reply_text(
                "📅 No calendar configured.\n\nUse /connect calendar 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 update.message.reply_text(
                    f"✅ CalDAV connection OK — {len(calendars)} calendar(s) found."
                )
            except Exception as e:
                await update.message.reply_text(f"❌ CalDAV connection failed: {e}")
        else:
            try:
                from familiar.skills.calendar.skill import _get_calendar_service

                _get_calendar_service()
                await update.message.reply_text("✅ Google Calendar connection OK.")
            except Exception as e:
                await update.message.reply_text(f"❌ Google Calendar connection failed: {e}")

    async def _calendar_test_for_chat(self, chat):
        """Test calendar connection and send result to chat."""
        from familiar.skills.calendar.skill import _calendar_provider

        provider = _calendar_provider()
        if provider == "none":
            await chat.send_message("📅 No calendar configured. Use /connect calendar 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 chat.send_message(
                    f"✅ CalDAV connection OK — {len(calendars)} calendar(s) found."
                )
            except Exception as e:
                await chat.send_message(f"❌ CalDAV connection failed: {e}")
        else:
            try:
                from familiar.skills.calendar.skill import _get_calendar_service

                _get_calendar_service()
                await chat.send_message("✅ Google Calendar connection OK.")
            except Exception as e:
                await chat.send_message(f"❌ Google Calendar connection failed: {e}")

    # ── Interactive CalDAV Setup Wizard ──────────────────────────────

    # _start_caldav_wizard: now provided by ConnectMixin

    async def _caldav_handle_setup_message(self, update: Update):
        """Route incoming messages during the CalDAV setup wizard."""
        state = self._pending_caldav_setup
        if not state:
            return

        chat = update.effective_chat
        text = update.message.text.strip()
        step = state["step"]

        if step == "url":
            if not text.startswith(("http://", "https://")):
                await chat.send_message(
                    "That doesn't look like a URL. "
                    "Please enter a URL starting with http:// or https://:"
                )
                return

            state["url"] = text
            state["step"] = "user"

            await chat.send_message(
                "Enter your <b>CalDAV username</b>:",
                parse_mode="HTML",
            )

        elif step == "user":
            if not text:
                await chat.send_message("Username cannot be empty. Please try again:")
                return

            state["user"] = text
            state["step"] = "password"

            await chat.send_message(
                "🔐 Enter your <b>CalDAV password</b>:\n\n"
                "<i>Your message will be deleted immediately for safety.</i>",
                parse_mode="HTML",
            )

        elif step == "password":
            # Delete the password message immediately
            try:
                await update.message.delete()
            except Exception:
                pass

            url = state["url"]
            user = state["user"]
            self._pending_caldav_setup = None

            await chat.send_message("Testing CalDAV connection...")
            await self._caldav_save_and_test_for_chat(chat, url, user, text)

    async def _caldav_save_and_test_for_chat(self, chat, url: str, user: str, password: str):
        """Save CalDAV credentials and test connection — sends results to chat."""
        import os

        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:
            self._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 chat.send_message(
                f"⚠️ Credentials set for this session but failed to persist: {e}"
            )
            return

        # Test connection
        try:
            import caldav

            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 chat.send_message(
                f"✅ <b>CalDAV Connected!</b>\n\n"
                f"Server: <code>{url[:50]}</code>\n"
                f"User: <code>{user}</code>\n"
                f"Calendars found: {len(calendars)}\n"
                + (("\n".join(f"  • {n}" for n in cal_names) + "\n") if cal_names else "")
                + f"\nSaved to: <code>~/.familiar/.env</code>\n\n"
                f"<i>Your password message was deleted for safety.</i>",
                parse_mode="HTML",
            )
        except ImportError:
            await chat.send_message(
                "⚠️ Credentials saved but <b>caldav</b> library not installed.\n"
                "Run: <code>pip install familiar-agent[nextcloud]</code>",
                parse_mode="HTML",
            )
        except Exception as e:
            await chat.send_message(
                f"⚠️ Credentials saved but connection test failed:\n<code>{e}</code>\n\n"
                f"Check your URL, username, and password.\n"
                f"To retry: <code>/connect calendar caldav</code>",
                parse_mode="HTML",
            )

    # _start_google_calendar_wizard: now provided by ConnectMixin

    async def _calendar_set_credential(self, update: Update, key: str, value: str):
        """Set individual calendar credential."""
        import os

        allowed = {"CALDAV_URL", "CALDAV_USER", "CALDAV_PASSWORD", "CALENDAR_PROVIDER"}
        if key not in allowed:
            await update.message.reply_text(
                f"Unknown key: {key}\n\nAllowed: {', '.join(sorted(allowed))}"
            )
            return

        os.environ[key] = value
        env_path = Path.home() / ".familiar" / ".env"
        try:
            self._update_env_file(env_path, {key: value})
        except Exception as e:
            logger.error(f"Failed to write .env: {e}")

        # Delete message if it contains a password
        if "PASSWORD" in key:
            try:
                await update.message.delete()
            except Exception:
                pass
            await update.effective_chat.send_message(f"✅ {key} updated (message deleted for safety).")
        else:
            await update.message.reply_text(f"✅ {key} = <code>{value}</code>", parse_mode="HTML")

    # ── /connect email: Email Integration Wizard ─────────────────────

    # Provider presets for common email services
    EMAIL_PRESETS = {
        "gmail": {
            "name": "Gmail",
            "imap_server": "imap.gmail.com",
            "smtp_server": "smtp.gmail.com",
            "imap_port": 993,
            "smtp_port": 587,
            "password_help": (
                "Gmail requires an <b>App Password</b> (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": "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": "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": (
                "<b>Proton Mail Bridge</b> runs locally and exposes IMAP/SMTP.\n\n"
                "<b>Setup:</b>\n"
                "1. Install Bridge from https://proton.me/mail/bridge\n"
                "   • Linux: <code>flatpak install flathub ch.protonmail.protonmail-bridge</code>\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 → <b>Mailbox configuration</b>\n"
                "4. Copy the <b>Bridge password</b> (NOT your Proton password)\n"
                "   • It looks like: <code>aBcDeFgHiJkLmNoP</code>\n"
                "5. Note your Proton email address\n\n"
                "<b>Bridge must be running</b> whenever Familiar checks email.\n"
                "Ports: IMAP 1143, SMTP 1025 (localhost only, no TLS needed).\n"
            ),
        },
    }

    async def _connect_email(self, update: Update, args: list):
        """
        Email connection wizard.

        Flow:
          /connect email                — show status or provider picker
          /connect email gmail           — start Gmail setup
          /connect email outlook         — start Outlook setup
          /connect email custom          — custom IMAP/SMTP
          /connect email test            — test current connection
          /connect email set <key> <val> — set individual credential
        """
        import os

        sub = args[0].lower() if args else ""

        # ── Subcommand routing ──
        if sub == "test":
            await self._email_test_connection(update)
            return
        elif sub == "set":
            if len(args) >= 3:
                await self._email_set_credential(update, args[1].upper(), " ".join(args[2:]))
            else:
                await update.message.reply_text(
                    "Usage: <code>/connect email set EMAIL_ADDRESS user@gmail.com</code>",
                    parse_mode="HTML",
                )
            return
        elif sub == "proton":
            # If email+password provided inline, use the manual preset flow
            if len(args) > 1 and "@" in args[1]:
                await self._email_setup_provider(update, "proton", args[1:])
            else:
                await self._connect_proton_bridge(update)
            return
        elif sub in self.EMAIL_PRESETS:
            # If email+password provided inline, use the direct flow
            if len(args) > 1 and "@" in args[1]:
                await self._email_setup_provider(update, sub, args[1:])
            else:
                await self._start_email_wizard(update, sub)
            return
        elif sub == "custom":
            await self._email_setup_custom(update)
            return

        # ── Default: show status and next step ──
        has_address = bool(os.environ.get("EMAIL_ADDRESS"))
        has_password = bool(os.environ.get("EMAIL_PASSWORD"))
        has_server = bool(os.environ.get("EMAIL_IMAP_SERVER"))

        if has_address and has_password and has_server:
            # Already configured — show status
            addr = os.environ.get("EMAIL_ADDRESS", "")
            server = os.environ.get("EMAIL_IMAP_SERVER", "")
            await update.message.reply_text(
                f"📧 <b>Email Configuration</b>\n\n"
                f"  ✅ Address: <code>{addr}</code>\n"
                f"  ✅ Password: ••••••••\n"
                f"  ✅ IMAP: <code>{server}</code>\n\n"
                f"<b>Commands:</b>\n"
                f"  <code>/connect email test</code> — verify connection\n"
                f"  <code>/connect email gmail</code> — reconfigure\n\n"
                f'Try: "Check my email"',
                parse_mode="HTML",
            )
            return

        # Not configured — show provider picker
        keyboard = [
            [
                InlineKeyboardButton("Gmail", callback_data="connect:email_gmail"),
                InlineKeyboardButton("Outlook", callback_data="connect:email_outlook"),
            ],
            [
                InlineKeyboardButton("Yahoo", callback_data="connect:email_yahoo"),
                InlineKeyboardButton("Proton Mail", callback_data="connect:email_proton"),
            ],
            [
                InlineKeyboardButton("Custom IMAP", callback_data="connect:email_custom"),
            ],
        ]

        await update.message.reply_text(
            "📧 <b>Connect Email</b>\n\nChoose your email provider:\n",
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def _email_setup_provider(self, update: Update, provider_key: str, extra_args: list):
        """Guide user through email setup for a known provider."""
        preset = self.EMAIL_PRESETS[provider_key]

        # If they provided the email address inline: /connect email gmail user@gmail.com password
        if extra_args:
            email_addr = extra_args[0]
            password = extra_args[1] if len(extra_args) > 1 else None

            if password:
                # Full inline setup
                await self._email_save_and_test(update, email_addr, password, preset)
                return
            else:
                # Have address, need password
                await update.message.reply_text(
                    f"📧 <b>{preset['name']} Setup</b>\n\n"
                    f"Address: <code>{email_addr}</code>\n\n"
                    f"{preset['password_help']}\n"
                    f"Send your password:\n"
                    f"  <code>/connect email {provider_key} {email_addr} YOUR_PASSWORD</code>\n\n"
                    f"<i>🔐 Your message will be deleted after saving.</i>",
                    parse_mode="HTML",
                )
                return

        # No args — show instructions
        await update.message.reply_text(
            f"📧 <b>{preset['name']} Setup</b>\n\n"
            f"{preset['password_help']}\n"
            f"Then send:\n"
            f"  <code>/connect email {provider_key} your@email.com YOUR_APP_PASSWORD</code>\n\n"
            f"<i>🔐 Your message will be deleted after saving.</i>",
            parse_mode="HTML",
        )

    async def _email_setup_custom(self, update: Update):
        """Guide user through custom IMAP/SMTP setup."""
        await update.message.reply_text(
            "📧 <b>Custom Email Setup</b>\n\n"
            "Set each value individually:\n\n"
            "<code>/connect email set EMAIL_ADDRESS user@example.com</code>\n"
            "<code>/connect email set EMAIL_PASSWORD your-password</code>\n"
            "<code>/connect email set EMAIL_IMAP_SERVER imap.example.com</code>\n"
            "<code>/connect email set EMAIL_SMTP_SERVER smtp.example.com</code>\n"
            "<code>/connect email set EMAIL_IMAP_PORT 993</code>\n"
            "<code>/connect email set EMAIL_SMTP_PORT 587</code>\n\n"
            "Then test:\n"
            "  <code>/connect email test</code>\n\n"
            "<i>🔐 Messages with passwords will be deleted.</i>",
            parse_mode="HTML",
        )

    async def _email_set_credential(self, update: Update, key: str, value: str):
        """Set a single email credential."""
        import os

        allowed_keys = {
            "EMAIL_ADDRESS",
            "EMAIL_PASSWORD",
            "EMAIL_IMAP_SERVER",
            "EMAIL_SMTP_SERVER",
            "EMAIL_IMAP_PORT",
            "EMAIL_SMTP_PORT",
        }

        if key not in allowed_keys:
            await update.message.reply_text(
                f"Unknown key: {key}\nAllowed: {', '.join(sorted(allowed_keys))}"
            )
            return

        # Set in process
        os.environ[key] = value

        # Persist to .env
        env_path = Path.home() / ".familiar" / ".env"
        try:
            self._update_env_file(env_path, {key: value})
        except Exception as e:
            await update.message.reply_text(f"⚠️ Failed to save: {e}")
            return

        # Delete message if it contains a password
        is_sensitive = "PASSWORD" in key
        if is_sensitive:
            try:
                await update.message.delete()
            except Exception:
                pass

        display_val = "••••••••" if is_sensitive else f"<code>{value}</code>"
        deleted_note = "\n<i>Your message was deleted for security.</i>" if is_sensitive else ""

        await update.effective_chat.send_message(
            f"✅ Set <b>{key}</b> = {display_val}{deleted_note}",
            parse_mode="HTML",
        )

    async def _email_save_and_test(
        self, update: Update, email_addr: str, password: str, preset: dict
    ):
        """Save email credentials and test the connection."""
        import os

        # Set all env vars
        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

        # Persist
        env_path = Path.home() / ".familiar" / ".env"
        try:
            self._update_env_file(env_path, env_vars)
        except Exception as e:
            await update.message.reply_text(f"⚠️ Failed to save: {e}")
            return

        # Delete the message containing the password
        try:
            await update.message.delete()
        except Exception:
            pass

        # Test the connection
        await update.effective_chat.send_message(
            f"📧 Credentials saved for <b>{email_addr}</b>.\n"
            f"Testing IMAP connection to {preset['imap_server']}...",
            parse_mode="HTML",
        )

        success, result = self._test_imap_connection(env_vars)

        if success:
            await update.effective_chat.send_message(
                f"✅ <b>Email connected!</b>\n\n"
                f"  Address: <code>{email_addr}</code>\n"
                f"  IMAP: <code>{preset['imap_server']}</code> ✅\n"
                f"  {result}\n\n"
                f"<i>Your password was deleted from chat.</i>\n\n"
                f'Try: "Check my email" or "Search for messages from..."',
                parse_mode="HTML",
            )
        else:
            await update.effective_chat.send_message(
                f"⚠️ <b>Credentials saved but connection failed</b>\n\n"
                f"  Error: {result}\n\n"
                f"Check your password and try:\n"
                f"  <code>/connect email test</code>\n\n"
                f"<i>Your password was deleted from chat.</i>",
                parse_mode="HTML",
            )

    @staticmethod
    def _test_imap_connection(env_vars: dict) -> tuple:
        """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}"

    async def _email_test_connection(self, update: Update):
        """Test current email configuration."""
        import os

        addr = os.environ.get("EMAIL_ADDRESS")
        if not addr:
            await update.message.reply_text(
                "⚠️ Email not configured.\nUse <code>/connect email</code> to set up.",
                parse_mode="HTML",
            )
            return

        await update.message.reply_text(f"Testing connection to {addr}...")

        env_vars = {
            "EMAIL_ADDRESS": os.environ.get("EMAIL_ADDRESS", ""),
            "EMAIL_PASSWORD": os.environ.get("EMAIL_PASSWORD", ""),
            "EMAIL_IMAP_SERVER": os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com"),
            "EMAIL_SMTP_SERVER": os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
            "EMAIL_IMAP_PORT": os.environ.get("EMAIL_IMAP_PORT", "993"),
            "EMAIL_SMTP_PORT": os.environ.get("EMAIL_SMTP_PORT", "587"),
        }

        success, result = self._test_imap_connection(env_vars)

        if success:
            await update.message.reply_text(f"✅ Email working! {result}")
        else:
            await update.message.reply_text(f"❌ Connection failed: {result}")

    # _start_email_wizard: now provided by ConnectMixin

    async def _email_handle_setup_message(self, update: Update):
        """Route incoming messages during the email setup wizard."""
        state = self._pending_email_setup
        if not state:
            return

        chat = update.effective_chat
        text = update.message.text.strip()
        step = state["step"]

        if step == "email":
            if "@" not in text or "." not in text:
                await chat.send_message(
                    "That doesn't look like an email address. Please try again:"
                )
                return

            state["email"] = text
            state["step"] = "password"

            preset = self.EMAIL_PRESETS[state["provider"]]
            name = preset["name"]
            await chat.send_message(
                f"🔐 Enter your <b>{name} password</b>:\n\n"
                "<i>Your message will be deleted immediately.</i>",
                parse_mode="HTML",
            )

        elif step == "password":
            # Delete the password message immediately
            try:
                await update.message.delete()
            except Exception:
                pass

            provider_key = state["provider"]
            email = state["email"]
            preset = self.EMAIL_PRESETS[provider_key]
            self._pending_email_setup = None

            await chat.send_message(
                f"📧 Credentials saved for <b>{email}</b>.\n"
                f"Testing IMAP connection to {preset['imap_server']}...",
                parse_mode="HTML",
            )

            # Save and test using existing infrastructure
            await self._email_save_and_test_for_chat(chat, email, text, preset)

    async def _email_save_and_test_for_chat(
        self, chat, email_addr: str, password: str, preset: dict
    ):
        """Save email credentials and test — sends results to chat object."""
        import os

        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:
            self._update_env_file(env_path, env_vars)
        except Exception as e:
            await chat.send_message(f"⚠️ Failed to save: {e}")
            return

        success, result = self._test_imap_connection(env_vars)

        if success:
            await chat.send_message(
                f"✅ <b>Email connected!</b>\n\n"
                f"  Address: <code>{email_addr}</code>\n"
                f"  IMAP: <code>{preset['imap_server']}</code> ✅\n"
                f"  {result}\n\n"
                f"<i>Your password was deleted from chat.</i>\n\n"
                f'Try: "Check my email" or "Search for messages from..."',
                parse_mode="HTML",
            )
        else:
            await chat.send_message(
                f"⚠️ <b>Credentials saved but connection failed</b>\n\n"
                f"  Error: {result}\n\n"
                f"Check your password and try:\n"
                f"  <code>/connect email test</code>\n\n"
                f"<i>Your password was deleted from chat.</i>",
                parse_mode="HTML",
            )

    # ── Proton Mail Bridge Automated Setup ───────────────────────────

    async def _connect_proton_bridge(self, update_or_chat, args=None):
        """Entry point for automated Proton Mail Bridge setup wizard."""
        import shutil

        # Accept either Update or Chat object
        if hasattr(update_or_chat, "effective_chat"):
            chat = update_or_chat.effective_chat
        else:
            chat = update_or_chat

        await chat.send_message(
            "📧 <b>Proton Mail Bridge Setup</b>\n\n"
            "Checking for Proton Mail Bridge...",
            parse_mode="HTML",
        )

        # Detect bridge binary
        bridge_bin = self._proton_find_bridge()

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

            await chat.send_message("📦 Bridge not found. Installing via Flatpak...")
            success = await self._proton_install_bridge(chat)
            if not success:
                await self._proton_fallback_manual(chat, "Flatpak install failed.")
                return

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

        await chat.send_message(
            f"✅ Bridge found: <code>{bridge_bin}</code>",
            parse_mode="HTML",
        )

        # Start the multi-step wizard
        self._pending_proton_setup = {
            "chat_id": chat.id,
            "step": "email",
            "bridge_bin": bridge_bin,
        }

        await chat.send_message(
            "📧 Enter your <b>Proton email address</b>:",
            parse_mode="HTML",
        )

    @staticmethod
    def _proton_find_bridge():
        """Find the protonmail-bridge binary. Returns command string or None."""
        import shutil
        import subprocess

        # Check native binary first
        native = shutil.which("protonmail-bridge")
        if native:
            return native

        # Check flatpak
        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, chat) -> bool:
        """Install Proton Mail Bridge via Flatpak."""
        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 chat.send_message("✅ Proton Mail Bridge installed via Flatpak.")
                return True
            else:
                err = stderr.decode(errors="replace")[:500]
                logger.error(f"Flatpak install failed: {err}")
                return False
        except asyncio.TimeoutError:
            await chat.send_message("⚠️ Bridge installation timed out.")
            return False
        except Exception as e:
            logger.error(f"Bridge install error: {e}")
            return False

    async def _proton_handle_setup_message(self, update: Update):
        """Route incoming messages during Proton Bridge setup wizard."""
        state = self._pending_proton_setup
        if not state:
            return

        chat = update.effective_chat
        text = update.message.text.strip()
        step = state["step"]

        if step == "email":
            if "@" not in text or "." not in text:
                await chat.send_message(
                    "That doesn't look like an email address. Please try again:"
                )
                return

            state["email"] = text
            state["step"] = "password"
            await chat.send_message(
                "🔐 Enter your <b>Proton password</b>:\n\n"
                "<i>Your message will be deleted immediately.</i>",
                parse_mode="HTML",
            )

        elif step == "password":
            # Delete the password message immediately
            try:
                await update.message.delete()
            except Exception:
                pass

            state["password"] = text
            state["step"] = "logging_in"
            await chat.send_message("🔄 Logging into Proton Mail Bridge...")
            await self._proton_do_login(chat, state["email"], text)

        elif step == "2fa":
            # Delete the 2FA message
            try:
                await update.message.delete()
            except Exception:
                pass

            state["step"] = "completing_2fa"
            await chat.send_message("🔄 Submitting 2FA code...")
            await self._proton_do_2fa(chat, text)

    async def _proton_do_login(self, chat, email: str, password: str):
        """Pipe credentials to protonmail-bridge --cli and attempt login."""
        state = self._pending_proton_setup
        bridge_bin = state["bridge_bin"]

        try:
            # Build the command
            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,
            )

            # Send login sequence
            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()

            # Wait for response
            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:
                # 2FA required — keep process alive
                self._proton_bridge_proc = proc
                state["step"] = "2fa"
                await chat.send_message(
                    "🔐 <b>2FA required</b>\n\nEnter your 2FA code:\n\n"
                    "<i>Your message will be deleted immediately.</i>",
                    parse_mode="HTML",
                )
                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._pending_proton_setup = None
                await self._proton_fallback_manual(
                    chat,
                    f"Bridge login failed. CLI output:\n<code>{output[:300]}</code>",
                )
                return

            # Login appears successful — close CLI and proceed
            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(chat)

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

    async def _proton_do_2fa(self, chat, code: str):
        """Submit 2FA code to the running Bridge CLI process."""
        proc = self._proton_bridge_proc
        if not proc or proc.returncode is not None:
            self._pending_proton_setup = None
            self._proton_bridge_proc = None
            await self._proton_fallback_manual(chat, "Bridge process ended unexpectedly.")
            return

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

            # Read response
            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._pending_proton_setup = None
                self._proton_bridge_proc = None
                await self._proton_fallback_manual(chat, "2FA verification failed.")
                return

            # Success — close CLI
            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._proton_bridge_proc = None
            await self._proton_finish_setup(chat)

        except Exception as e:
            logger.error(f"Proton 2FA error: {e}")
            self._pending_proton_setup = None
            self._proton_bridge_proc = None
            await self._proton_fallback_manual(chat, f"2FA automation failed: {e}")

    async def _proton_finish_setup(self, chat):
        """Extract Bridge credentials, start Bridge, configure email, test."""
        import os

        state = self._pending_proton_setup
        email = state["email"]
        bridge_bin = state["bridge_bin"]

        # Extract Bridge password
        await chat.send_message("🔑 Extracting Bridge credentials...")
        bridge_password = await self._proton_extract_bridge_password(bridge_bin, email)

        if not bridge_password:
            self._pending_proton_setup = None
            await self._proton_fallback_manual(
                chat,
                "Could not extract Bridge password automatically.\n"
                "Open Bridge → click your account → copy the Bridge password.",
            )
            return

        # Start Bridge in background (noninteractive mode)
        await chat.send_message("🚀 Starting Bridge in background...")
        await self._proton_start_background(bridge_bin)

        # Wait for Bridge ports to come up
        await asyncio.sleep(3)

        # Configure email env vars using the proton preset
        preset = self.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:
            self._update_env_file(env_path, env_vars)
        except Exception as e:
            logger.error(f"Failed to save .env: {e}")

        # Test the IMAP connection
        await chat.send_message("🔍 Testing IMAP connection...")
        success, result = self._test_imap_connection(env_vars)

        self._pending_proton_setup = None

        if success:
            await chat.send_message(
                f"✅ <b>Proton Mail connected!</b>\n\n"
                f"  Address: <code>{email}</code>\n"
                f"  IMAP: <code>127.0.0.1:1143</code> ✅\n"
                f"  {result}\n\n"
                f"Bridge is running in the background.\n\n"
                f'Try: "Check my email" or "Search for messages from..."',
                parse_mode="HTML",
            )
        else:
            await chat.send_message(
                f"⚠️ <b>Credentials saved but connection failed</b>\n\n"
                f"  Error: {result}\n\n"
                f"Make sure Bridge is running, then try:\n"
                f"  <code>/connect email test</code>\n\n"
                f"Bridge password has been saved — you don't need to re-enter it.",
                parse_mode="HTML",
            )

    async def _proton_extract_bridge_password(self, bridge_bin: str, email: str):
        """Run Bridge CLI 'info' command and extract the Bridge password."""
        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,
            )

            # Send 'info' command to get account details
            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")

            # Bridge CLI shows: Password: aBcDeFgHiJkLmNoP
            match = re.search(r"[Pp]assword:\s*(\S+)", output)
            if match:
                pwd = match.group(1)
                # Filter out labels that aren't actual passwords
                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

    async def _proton_start_background(self, bridge_bin: str):
        """Start Bridge in noninteractive (background) mode."""
        import socket

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

            # Check if bridge is already running on IMAP port
            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

            # Start as detached background process
            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):
        """Read from async stream until no more data arrives for a short window."""
        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, chat, reason: str):
        """Fall back to manual setup instructions when automation fails."""
        self._pending_proton_setup = None
        preset = self.EMAIL_PRESETS["proton"]
        await chat.send_message(
            f"⚠️ <b>Automated setup could not complete</b>\n\n"
            f"{reason}\n\n"
            f"<b>Manual setup:</b>\n"
            f"{preset['password_help']}\n"
            f"Then send:\n"
            f"  <code>/connect email proton your@email.com BRIDGE_PASSWORD</code>\n\n"
            f"<i>🔐 Your message will be deleted after saving.</i>",
            parse_mode="HTML",
        )

    # ── /connect sms: Twilio SMS Wizard ──────────────────────────────

    async def _connect_sms(self, update: Update, args: list):
        """
        SMS (Twilio) connection wizard.

        Flow:
          /connect sms                                    — show status or guide
          /connect sms ACCOUNT_SID AUTH_TOKEN +15551234567 — one-shot setup
          /connect sms test +1XXXXXXXXXX                  — send test SMS
        """
        import os

        sub = args[0] if args else ""

        # Test subcommand
        if sub == "test":
            to_number = args[1] if len(args) > 1 else ""
            await self._sms_test(update, to_number)
            return

        # One-shot setup: /connect sms AC... token... +1...
        if sub.startswith("AC") and len(args) >= 3:
            await self._sms_save_credentials(update, args[0], args[1], args[2])
            return

        # Check current state
        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 = self._check_twilio_lib()

        if has_sid and has_token and has_phone:
            phone = os.environ.get("TWILIO_PHONE_NUMBER", "")
            await update.message.reply_text(
                f"📱 <b>SMS Configuration</b>\n\n"
                f"  ✅ Twilio SID: <code>{os.environ.get('TWILIO_ACCOUNT_SID', '')[:8]}...</code>\n"
                f"  ✅ Auth Token: ••••••••\n"
                f"  ✅ Phone: <code>{phone}</code>\n"
                f"  {'✅' if has_lib else '❌'} twilio package\n\n"
                f"<b>Commands:</b>\n"
                f"  <code>/connect sms test +1XXXXXXXXXX</code> — send test\n\n"
                f"Cost: ~$1/month + $0.0079/SMS",
                parse_mode="HTML",
            )
            return

        # Not configured — show setup guide
        await update.message.reply_text(
            "📱 <b>Connect SMS (Twilio)</b>\n\n"
            "<b>Setup:</b>\n"
            "1. Create account at https://www.twilio.com\n"
            "2. Get a phone number (~$1/month)\n"
            "3. Find credentials on Twilio Console dashboard\n\n"
            "<b>One-shot setup (3 values, space-separated):</b>\n"
            "  <code>/connect sms &lt;SID&gt; &lt;TOKEN&gt; &lt;PHONE&gt;</code>\n\n"
            "<b>Example:</b>\n"
            "  <code>/connect sms ACe0a988d7... 9f3c8b... +15551234567</code>\n\n"
            "  • SID starts with <b>AC</b> (from Console &gt; Account Info)\n"
            "  • Token is the 32-char hex string below it\n"
            "  • Phone is your Twilio number (not your personal number)\n\n"
            "<i>🔐 Your message will be deleted after saving.</i>\n\n"
            "Cost: ~$1/month + $0.0079/SMS sent",
            parse_mode="HTML",
        )

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

            return True
        except ImportError:
            return False

    async def _sms_save_credentials(self, update: Update, sid: str, token: str, phone: str):
        """Save Twilio credentials and install package if needed."""
        import os

        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:
            self._update_env_file(env_path, env_vars)
        except Exception as e:
            await update.message.reply_text(f"⚠️ Failed to save: {e}")
            return

        # Delete message with credentials
        try:
            await update.message.delete()
        except Exception:
            pass

        # Install twilio if needed
        if not self._check_twilio_lib():
            await update.effective_chat.send_message("📦 Installing twilio package...")
            try:
                import subprocess

                result = subprocess.run(
                    ["pip", "install", "twilio"],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    timeout=60,
                )
                if result.returncode != 0:
                    err = result.stderr.decode(errors="replace").strip()
                    logger.warning(f"Failed to install twilio: {err[-200:]}")
            except Exception:
                pass

        has_lib = self._check_twilio_lib()

        await update.effective_chat.send_message(
            f"✅ <b>SMS configured!</b>\n\n"
            f"  SID: <code>{sid[:8]}...</code>\n"
            f"  Phone: <code>{phone}</code>\n"
            f"  twilio package: {'✅' if has_lib else '❌ (pip install twilio)'}\n\n"
            f"<i>Your credentials were deleted from chat.</i>\n\n"
            f"Test: <code>/connect sms test +1XXXXXXXXXX</code>",
            parse_mode="HTML",
        )

    async def _sms_test(self, update: Update, to_number: str):
        """Send a test SMS."""
        import os

        if not to_number:
            await update.message.reply_text(
                "Usage: <code>/connect sms test +1XXXXXXXXXX</code>",
                parse_mode="HTML",
            )
            return

        if not os.environ.get("TWILIO_ACCOUNT_SID"):
            await update.message.reply_text("⚠️ 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! 🐍 SMS is working.",
                from_=os.environ["TWILIO_PHONE_NUMBER"],
                to=to_number,
            )
            await update.message.reply_text(f"✅ Test SMS sent to {to_number}\nSID: {message.sid}")
        except ImportError:
            await update.message.reply_text(
                "❌ twilio package not installed.\nRun: pip install twilio"
            )
        except Exception as e:
            await update.message.reply_text(f"❌ SMS failed: {e}")

    # ── /connect browser: Playwright Setup ───────────────────────────

    async def _connect_browser(self, update: Update):
        """Browser automation (Playwright) setup checker and installer."""
        # Check current state
        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

        check = "✅"
        cross = "❌"

        if has_playwright and has_chromium:
            await update.message.reply_text(
                f"🌐 <b>Browser Automation</b>\n\n"
                f"  {check} playwright package\n"
                f"  {check} Chromium browser\n\n"
                f'Ready! Try: "Search the web for..."\n\n'
                f"<i>Requires TRUSTED or OWNER trust level.</i>",
                parse_mode="HTML",
            )
            return

        lines = [
            "🌐 <b>Browser Automation Setup</b>\n",
            f"  {check if has_playwright else cross} playwright package",
            f"  {check if has_chromium else cross} Chromium browser\n",
        ]

        if not has_playwright:
            lines.append(
                "<b>Install on the server/server:</b>\n"
                "  <code>pip install playwright</code>\n"
                "  <code>playwright install chromium</code>\n"
            )
        elif not has_chromium:
            lines.append("<b>Install Chromium:</b>\n  <code>playwright install chromium</code>\n")

        lines.append(
            "<i>⚠️ Chromium installation requires SSH access.\n"
            "It downloads ~150MB and needs system libraries.</i>"
        )

        await update.message.reply_text("\n".join(lines), parse_mode="HTML")

    # ── /connect voice: Whisper/TTS Setup ────────────────────────────

    async def _connect_voice(self, update: Update):
        """Voice transcription and TTS setup checker."""
        import os

        # Check what's available
        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"))

        check = "✅"
        empty = "⬜"

        has_any_stt = has_faster_whisper or has_whisper
        has_any_tts = has_piper or has_openai_tts

        lines = [
            "🎤 <b>Voice & Transcription</b>\n",
            "<b>Speech-to-Text:</b>",
            f"  {check if has_faster_whisper else empty} faster-whisper (recommended, fast)",
            f"  {check if has_whisper else empty} openai-whisper (original, slower)",
            "",
            "<b>Text-to-Speech:</b>",
            f"  {check if has_piper else empty} piper-tts (local, fast)",
            f"  {check if has_openai_tts else empty} OpenAI TTS (cloud, high quality)",
            "",
        ]

        if has_any_stt:
            engine = "faster-whisper" if has_faster_whisper else "openai-whisper"
            lines.append(f"✅ Transcription ready ({engine})")
        else:
            lines.extend(
                [
                    "<b>Install on the server/server:</b>",
                    "  <code>pip install faster-whisper</code>  (recommended)",
                    "  — or —",
                    "  <code>pip install openai-whisper</code>",
                    "",
                ]
            )

        if not has_any_tts:
            lines.extend(
                [
                    "<b>For TTS:</b>",
                    "  <code>pip install piper-tts</code>  (local)",
                    "  — or set OPENAI_API_KEY for cloud TTS —",
                ]
            )

        lines.append("\n<i>⚠️ Whisper models download on first use (~75MB for tiny).</i>")

        await update.message.reply_text("\n".join(lines), parse_mode="HTML")

    @staticmethod
    def _update_env_file(env_path, updates: dict):
        """Update or append key=value pairs in a .env file."""
        from pathlib import Path

        env_path = Path(env_path)
        env_path.parent.mkdir(parents=True, exist_ok=True)

        # Read existing lines
        existing_lines = []
        if env_path.exists():
            existing_lines = env_path.read_text().splitlines()

        # Track which keys we've updated
        updated_keys = set()
        new_lines = []

        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)

        # Append any keys that weren't already in the file
        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)

    async def _handle_document(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle file uploads — auto-detect and save known config files."""
        if not update.message or not update.message.document:
            return

        user_id = update.effective_user.id
        if not self._is_access_allowed(user_id):
            return

        session = self._get_session(user_id)
        doc = update.message.document
        filename = doc.file_name or ""

        # Auto-detect Google credentials/client_secret file
        is_google_creds = filename.endswith(".json") and any(
            pattern in filename.lower()
            for pattern in ["credential", "client_secret", "client_id", "oauth"]
        )

        if is_google_creds:
            if session.trust_level != TrustLevel.OWNER:
                await update.message.reply_text("🔒 Only the OWNER can upload config files.")
                return

            data_dir = Path.home() / ".familiar" / "data"
            data_dir.mkdir(parents=True, exist_ok=True)
            dest = data_dir / "google_credentials.json"

            try:
                file = await doc.get_file()
                await file.download_to_drive(str(dest))
                await update.message.reply_text(
                    f"✅ <b>Google credentials saved!</b>\n\n"
                    f"Saved to: <code>{dest}</code>\n\n"
                    f"Now run <code>/connect google</code> to continue authorization.",
                    parse_mode="HTML",
                )
                logger.info(f"Google credentials.json saved by owner {user_id}")
            except Exception as e:
                await update.message.reply_text(f"❌ Failed to save file: {e}")
            return

        # For any other file, pass to the LLM as context
        await update.message.reply_text(
            f"📎 Received: {filename}\n"
            f"File handling for general documents coming soon.\n"
            f"For now I auto-detect: Google credentials.json"
        )

    async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle regular messages."""

        # ── Owner PIN verification (universal) ──
        owner_id = getattr(self.agent.config.channels, "owner_telegram_id", None)
        pin_hash = os.environ.get("OWNER_PIN_HASH")

        if not owner_id and pin_hash and update.message and update.message.text:
            from ..core.security import (
                check_pin_rate_limit,
                claim_ownership,
                record_pin_attempt,
                verify_owner_pin,
            )

            candidate = update.message.text.strip()
            uid = str(update.effective_user.id)

            # Check rate limit
            allowed, lockout_msg = check_pin_rate_limit(uid)
            if not allowed:
                await update.message.reply_text(lockout_msg)
                return

            if verify_owner_pin(candidate):
                record_pin_attempt(uid, success=True)
                # PIN correct — claim ownership via universal function
                # PIN correct — claim ownership via universal function
                user_id = update.effective_user.id
                claim_ownership(str(user_id), "telegram", self.agent.sessions)
                self.agent.config.channels.owner_telegram_id = user_id

                # Add to allowed users
                if not self.agent.config.channels.telegram_allowed_users:
                    self.agent.config.channels.telegram_allowed_users = []
                if user_id not in self.agent.config.channels.telegram_allowed_users:
                    self.agent.config.channels.telegram_allowed_users.append(user_id)

                logger.info(f"Owner claimed via PIN: Telegram user {user_id}")
                await update.message.reply_text(
                    "🔐 Owner verified. You now have full access.\n\n"
                    "Your Telegram ID has been saved. You won't need the PIN again.\n"
                    "Say hello to get started!"
                )
                return
            else:
                if candidate.isdigit() and 4 <= len(candidate) <= 8:
                    record_pin_attempt(uid, success=False)
                    logger.warning(f"Failed owner PIN attempt from user {update.effective_user.id}")
                    await update.message.reply_text(
                        "❌ Incorrect PIN. If you are the owner, check the PIN shown during installation."
                    )
                    return
                else:
                    await update.message.reply_text(
                        "🔒 This bot requires owner verification.\n"
                        "Please enter your owner PIN to claim this bot."
                    )
                    return

        # Access gate
        if not self._is_access_allowed(update.effective_user.id):
            await self._reject_unauthorized(update)
            return

        # Phase 1: Use ChannelAuthenticator
        channel_user = self._get_channel_user(update)

        if not channel_user:
            await self._reject_unauthorized(update)
            return

        chat_id = update.effective_chat.id
        user_message = update.message.text

        # Intercept messages during setup wizards (ConnectMixin)
        rid = str(chat_id)
        self._wizard_chats[rid] = update.effective_chat
        if await self.handle_wizard_message(rid, user_message, update.message):
            return

        # Log with user info
        user_info = (
            channel_user.user.email
            if channel_user.is_linked
            else f"guest:{channel_user.channel_id}"
        )
        logger.info(f"[Telegram:{user_info}] {user_message[:50]}...")

        # Send typing indicator
        await update.message.chat.send_action("typing")

        try:
            # Get UserContext for linked users
            user_context = channel_user.to_context() if channel_user.is_linked else None

            # Run agent in thread pool with persistent typing indicator
            loop = asyncio.get_event_loop()

            # Pass user_id for data isolation.
            # For unlinked/guest users we use the raw Telegram channel_id (a
            # numeric string) so that preauth lookups in security.py and
            # session persistence key on the same ID that was used when the
            # owner ran 'grant 987654321 staff'.  The old f"guest_{chat_id}"
            # key caused preauth misses because the session was keyed on
            # "guest_987654321" while preauth.json stored "987654321".
            user_id = channel_user.user_id or channel_user.channel_id or f"guest_{chat_id}"

            # Keep typing indicator alive while LLM is processing
            typing_active = True

            async def keep_typing():
                while typing_active:
                    try:
                        await update.message.chat.send_action("typing")
                    except Exception:
                        pass
                    await asyncio.sleep(4)

            typing_task = asyncio.create_task(keep_typing())

            try:
                response = await loop.run_in_executor(
                    None,
                    lambda: self.agent.chat(
                        message=user_message,
                        user_id=user_id,
                        channel="telegram",
                        user_context=user_context,  # Phase 1: Pass user context for permissions
                    ),
                )
            finally:
                typing_active = False
                typing_task.cancel()

            # ── Confirmation intercept ───────────────────────────────────
            from familiar.core.confirmations import (
                CALENDAR_MENU_PREFIX,
                EMAIL_MENU_PREFIX,
                SENTINEL_PREFIX,
            )

            if isinstance(response, str) and response.startswith(SENTINEL_PREFIX):
                token = response[len(SENTINEL_PREFIX) :]
                session = self._get_session(update.effective_user.id)
                pending = session.pending_confirmations.get(token)
                if pending:
                    await self._send_confirmation_keyboard(
                        chat_id=chat_id,
                        token=token,
                        preview=pending["preview"],
                        risk=pending.get("risk", "medium"),
                    )
                else:
                    await update.message.reply_text("⚠️ Confirmation expired. Please try again.")
                return

            # ── Email menu intercept ──────────────────────────────────────
            if isinstance(response, str) and response.startswith(EMAIL_MENU_PREFIX):
                token = response[len(EMAIL_MENU_PREFIX):]
                session = self._get_session(update.effective_user.id)
                email_menu = session.session_context.get("email_menu")
                if email_menu and email_menu.get("token") == token:
                    await self._send_email_account_menu(
                        chat_id=chat_id,
                        summaries=email_menu["summaries"],
                    )
                else:
                    await update.message.reply_text("⚠️ Email menu expired. Please check again.")
                return

            # ── Calendar menu intercept ────────────────────────────────────
            if isinstance(response, str) and response.startswith(CALENDAR_MENU_PREFIX):
                token = response[len(CALENDAR_MENU_PREFIX):]
                session = self._get_session(update.effective_user.id)
                cal_menu = session.session_context.get("calendar_menu")
                if cal_menu and cal_menu.get("token") == token:
                    await self._send_calendar_account_menu(
                        chat_id=chat_id,
                        summaries=cal_menu["summaries"],
                    )
                else:
                    await update.message.reply_text("⚠️ Calendar menu expired. Please check again.")
                return

            # Split long messages (Telegram limit is 4096)
            await self._send_long_message(update.message, response)

        except Exception as e:
            logger.error(f"Error: {e}")
            await update.message.reply_text(f"❌ Error: {str(e)}")

    async def _send_long_message(self, message, text: str, max_len: int = 4000):
        """Send a potentially long message, splitting if needed."""
        if len(text) <= max_len:
            await message.reply_text(text, parse_mode="HTML")
            return

        # Split on newlines if possible
        chunks = []
        current = ""

        for line in text.split("\n"):
            if len(current) + len(line) + 1 > max_len:
                if current:
                    chunks.append(current)
                current = line
            else:
                current = current + "\n" + line if current else line

        if current:
            chunks.append(current)

        for chunk in chunks:
            try:
                await message.reply_text(chunk, parse_mode="HTML")
            except Exception:
                # Fallback without HTML if parsing fails
                await message.reply_text(chunk)

    async def _send_email_account_menu(self, chat_id, summaries):
        """Send an inline keyboard listing email accounts for selection."""
        lines = ["📧 <b>Email Accounts</b>", ""]
        for _key, label, summary in summaries:
            emoji = "📭" if summary == "0 unread" else "📬"
            lines.append(f"  {emoji} {label}: {summary}")
        lines.append("")
        lines.append("<i>Tap an account to see details:</i>")

        keyboard = []
        for account_key, label, summary in summaries:
            btn_label = label if len(label) <= 30 else label[:27] + "..."
            emoji = "📭" if summary == "0 unread" else "📬"
            keyboard.append([InlineKeyboardButton(
                f"{emoji} {btn_label} ({summary})",
                callback_data=f"email:view:{account_key}",
            )])
        keyboard.append([InlineKeyboardButton(
            "➕ Connect another email",
            callback_data="email:connect",
        )])

        await self.app.bot.send_message(
            chat_id=chat_id, text="\n".join(lines), parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def _send_calendar_account_menu(self, chat_id, summaries):
        """Send an inline keyboard listing calendar accounts for selection."""
        lines = ["📅 <b>Calendar Accounts</b>", ""]
        for _key, label, summary in summaries:
            emoji = "📭" if summary == "0 events" else "📆"
            lines.append(f"  {emoji} {label}: {summary}")
        lines.append("")
        lines.append("<i>Tap an account to see details:</i>")

        keyboard = []
        for account_key, label, summary in summaries:
            btn_label = label if len(label) <= 30 else label[:27] + "..."
            emoji = "📭" if summary == "0 events" else "📆"
            keyboard.append([InlineKeyboardButton(
                f"{emoji} {btn_label} ({summary})",
                callback_data=f"cal:view:{account_key}",
            )])
        keyboard.append([InlineKeyboardButton(
            "➕ Connect another calendar",
            callback_data="cal:connect",
        )])

        await self.app.bot.send_message(
            chat_id=chat_id, text="\n".join(lines), parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def _send_confirmation_keyboard(
        self,
        chat_id: int,
        token: str,
        preview: str,
        risk: str = "medium",
    ):
        """
        Send an inline keyboard confirmation for a write action.
        Called when agent.chat returns a SENTINEL_PREFIX string.
        """
        risk_prefix = {"high": "⚠️", "medium": "📋", "low": "✅"}.get(risk, "📋")
        keyboard = [
            [
                InlineKeyboardButton("✅ Confirm", callback_data=f"tool_confirm:{token}"),
                InlineKeyboardButton("❌ Cancel", callback_data=f"tool_cancel:{token}"),
            ]
        ]
        text_parts = [
            risk_prefix + " <b>Confirm action</b>",
            "",
            "<pre>" + preview + "</pre>",
            "",
            "<i>Expires in 5 minutes</i>",
        ]
        await self.app.bot.send_message(
            chat_id=chat_id,
            text="\n".join(text_parts),
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def send_confirmation_request(
        self, chat_id: int, action: str, description: str, token: str
    ):
        """Send a confirmation request with inline buttons."""
        keyboard = [
            [
                InlineKeyboardButton("✅ Confirm", callback_data=f"confirm:{token}"),
                InlineKeyboardButton("❌ Cancel", callback_data=f"cancel:{token}"),
            ]
        ]

        await self.app.bot.send_message(
            chat_id=chat_id,
            text=f"⚠️ <b>Confirmation Required</b>\n\n"
            f"<b>Action:</b> {action}\n"
            f"<b>Details:</b> {description}\n\n"
            f"<i>Expires in 5 minutes</i>",
            parse_mode="HTML",
            reply_markup=InlineKeyboardMarkup(keyboard),
        )

    async def send_proactive_message(self, chat_id: int, text: str):
        """Send a message proactively (for scheduled tasks)."""
        if self.app:
            await self.app.bot.send_message(chat_id=chat_id, text=text)

    def run(self):
        """Start the Telegram bot."""
        if not self.token:
            raise ValueError("Telegram token not configured")

        # Suppress httpx polling logs to prevent bot token leaking in URLs
        logging.getLogger("httpx").setLevel(logging.WARNING)

        logger.info("Starting Telegram channel with security...")
        logger.info(f"Auth mode: {self.authenticator.mode.value}")

        self.app = Application.builder().token(self.token).build()

        # Register handlers
        self.app.add_handler(CommandHandler("start", self._handle_start))
        self.app.add_handler(CommandHandler("trust", self._handle_trust))
        self.app.add_handler(CommandHandler("budget", self._handle_budget))
        self.app.add_handler(CommandHandler("caps", self._handle_caps))
        self.app.add_handler(CommandHandler("clear", self._handle_clear))
        self.app.add_handler(CommandHandler("status", self._handle_status))
        self.app.add_handler(CommandHandler("model", self._handle_model))
        self.app.add_handler(CommandHandler("remember", self._handle_remember))
        self.app.add_handler(CommandHandler("recall", self._handle_recall))

        # Phase 1: Multi-user authentication commands
        self.app.add_handler(CommandHandler("link", self._handle_link))
        self.app.add_handler(CommandHandler("confirm", self._handle_confirm_link))
        self.app.add_handler(CommandHandler("unlink", self._handle_unlink))
        self.app.add_handler(CommandHandler("whoami", self._handle_whoami))
        self.app.add_handler(CommandHandler("grant", self._handle_grant))
        self.app.add_handler(CommandHandler("revoke", self._handle_revoke))
        self.app.add_handler(CommandHandler("preauth", self._handle_preauth))

        # v1.0.14: Service connection wizard
        self.app.add_handler(CommandHandler("connect", self._handle_connect))

        self.app.add_handler(CallbackQueryHandler(self._handle_callback))
        self.app.add_handler(MessageHandler(filters.Document.ALL, self._handle_document))
        self.app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))

        # Start scheduler if enabled
        self.agent.start_scheduler()

        # Wire scheduler delivery → Telegram
        if self.agent.scheduler:
            owner_id = os.environ.get("OWNER_TELEGRAM_ID", "")
            if owner_id:

                def deliver_to_telegram(message, channel, chat_id):
                    """Deliver scheduler output to Telegram."""
                    target = chat_id or owner_id
                    if target and self.app:
                        import asyncio

                        try:
                            loop = asyncio.get_event_loop()
                            if loop.is_running():
                                asyncio.ensure_future(
                                    self.app.bot.send_message(chat_id=int(target), text=message)
                                )
                            else:
                                loop.run_until_complete(
                                    self.app.bot.send_message(chat_id=int(target), text=message)
                                )
                        except Exception as e:
                            logger.error(f"Telegram delivery failed: {e}")

                self.agent.scheduler.set_delivery_callback(deliver_to_telegram)
                logger.info(f"Scheduler delivery → Telegram (owner: {owner_id})")

        # Run
        logger.info(f"Bot running with security mode: {self.agent.security_mode.value}")
        self.app.run_polling(allowed_updates=Update.ALL_TYPES)
