# 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
import sys
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 TAB_CATEGORIES, TAB_ORDER, 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] = {}
        # Card-menu: track the last card message per chat for in-place editing
        self._card_messages: 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

    async def wizard_send_card_menu(
        self,
        recipient_id: str,
        header: str,
        tab_content_text: str,
        tab_options: list[tuple[str, str]],
        active_tab: str,
    ) -> None:
        """Send a tabbed card menu with inline keyboard (Telegram override)."""
        text = f"{header}\n\n{tab_content_text}"
        keyboard = self._build_card_keyboard(tab_options, active_tab)
        reply_markup = InlineKeyboardMarkup(keyboard)

        chat = self._wizard_chats.get(recipient_id)
        if chat:
            msg = await chat.send_message(
                text,
                parse_mode="HTML",
                reply_markup=reply_markup,
            )
        elif self.app:
            msg = await self.app.bot.send_message(
                chat_id=int(recipient_id),
                text=text,
                parse_mode="HTML",
                reply_markup=reply_markup,
            )
        else:
            return
        self._card_messages[recipient_id] = msg

    def _build_card_keyboard(
        self,
        tab_options: list[tuple[str, str]],
        active_tab: str,
    ) -> list[list[InlineKeyboardButton]]:
        """Build inline keyboard rows: tab bar + service buttons."""
        # Tab bar row
        tab_row = []
        for tid in TAB_ORDER:
            cat = TAB_CATEGORIES[tid]
            label = cat["label"]
            if tid == active_tab:
                label = f"\u25b8 {label}"
            tab_row.append(InlineKeyboardButton(label, callback_data=f"ctab:{tid}"))
        keyboard = [tab_row]

        # Service buttons in rows of 3
        row: list[InlineKeyboardButton] = []
        for key, btn_label in tab_options:
            row.append(InlineKeyboardButton(btn_label, callback_data=f"connect:{key}"))
            if len(row) >= 3:
                keyboard.append(row)
                row = []
        if row:
            keyboard.append(row)
        return keyboard

    async def _edit_card_tab(self, recipient_id: str, tab_id: str, query) -> None:
        """Edit the existing card message to show a different tab."""
        status = self._get_service_status()
        header = self._build_card_header(tab_id)
        tab_text, tab_options = self._build_tab_content(status, tab_id)
        text = f"{header}\n\n{tab_text}"
        keyboard = self._build_card_keyboard(tab_options, tab_id)

        try:
            await query.edit_message_text(
                text,
                parse_mode="HTML",
                reply_markup=InlineKeyboardMarkup(keyboard),
            )
        except Exception:
            # Message unchanged or deleted — ignore
            pass

    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()
                        result = await loop.run_in_executor(
                            None,
                            lambda: self.agent.chat_with_results(
                                f"[CONFIRMED] Execute: {action}",
                                user_id=user_id,
                                channel="telegram",
                            ),
                        )
                        await query.message.reply_text(
                            result.text,
                            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("ctab:"):
            # Tab switch — edit the card message in place
            session = self._get_session(user_id)
            if session.trust_level != TrustLevel.OWNER:
                await query.edit_message_text("\U0001f512 OWNER access required.")
                return
            tab_id = data[5:]
            await query.answer()
            rid = str(query.message.chat.id)
            self._wizard_chats[rid] = query.message.chat
            await self._edit_card_tab(rid, tab_id, query)
            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("\U0001f512 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 _handle_browse(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /browse command — show data from connected services."""
        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("\U0001f512 /browse requires OWNER trust level.")
            return

        chat = update.effective_chat
        rid = str(chat.id)
        self._wizard_chats[rid] = chat
        await self.handle_browse_command(rid)

    async def _handle_search(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /search command — search a connected service."""
        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("\U0001f512 /search requires OWNER trust level.")
            return

        chat = update.effective_chat
        rid = str(chat.id)
        self._wizard_chats[rid] = chat
        args = list(context.args) if context.args else []
        await self.handle_search_command(rid, args)

    # ── 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 to auto-install."
        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."""

        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, auto-install if missing."""
        try:
            import playwright  # noqa: F401

            await chat.send_message(
                "🌐 <b>Browser</b>\n\n✅ Playwright installed and ready.",
                parse_mode="HTML",
            )
        except ImportError:
            from familiar.core.deps import BROWSER_PACKAGES, ensure_packages_async

            await chat.send_message("⏳ Installing browser automation...", parse_mode="HTML")
            ok, _ = await ensure_packages_async(BROWSER_PACKAGES)
            if ok:
                import subprocess as _sp

                _sp.run(
                    [sys.executable, "-m", "playwright", "install", "chromium"],
                    capture_output=True,
                    timeout=300,
                )
                await chat.send_message(
                    "🌐 <b>Browser</b>\n\n✅ Playwright installed and ready.",
                    parse_mode="HTML",
                )
            else:
                await chat.send_message(
                    "🌐 <b>Browser</b>\n\n❌ Installation failed. Check server logs.",
                    parse_mode="HTML",
                )

    async def _connect_voice_for_chat(self, chat):
        """Check voice status, auto-install if missing."""
        has_stt = False
        try:
            import faster_whisper  # noqa: F401

            has_stt = True
        except ImportError:
            try:
                import whisper  # noqa: F401

                has_stt = True
            except ImportError:
                pass

        if not has_stt:
            from familiar.core.deps import VOICE_STT_PACKAGES, ensure_packages_async

            await chat.send_message("⏳ Installing voice packages...", parse_mode="HTML")
            ok, _ = await ensure_packages_async(VOICE_STT_PACKAGES)
            if ok:
                has_stt = True

        parts = ["🎤 <b>Voice / Transcription</b>\n"]
        if has_stt:
            parts.append("✅ Speech-to-text installed")
        else:
            parts.append("❌ STT installation failed. Check server logs.")
        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.\nGemini 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> Send:\n"
                "  <code>/connect google install</code>\n\n"
                "This will auto-install the required libraries."
            )
        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"⚠️ Google library installation failed.\n\n"
                    f"Error: <code>{error}</code>\n\n"
                    f"Try again: <code>/connect google install</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 "")
                + "\nSaved to: <code>~/.familiar/.env</code>\n\n"
                "<i>Your message with credentials has been deleted for safety.</i>\n\n"
                "Add another account? Use /connect calendar google or /connect calendar caldav",
                parse_mode="HTML",
            )
        except ImportError:
            # Auto-install caldav + vobject
            from familiar.core.deps import CALDAV_PACKAGES, ensure_packages_async

            await update.effective_chat.send_message("Installing CalDAV libraries...")
            ok, failed = await ensure_packages_async(CALDAV_PACKAGES)
            if ok:
                await update.effective_chat.send_message(
                    "✅ CalDAV libraries installed! Re-run <code>/connect calendar caldav</code> to test.",
                    parse_mode="HTML",
                )
            else:
                await update.effective_chat.send_message(
                    f"⚠️ Credentials saved but CalDAV library install failed: {', '.join(failed)}",
                )
        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 "")
                + "\nSaved to: <code>~/.familiar/.env</code>\n\n"
                "<i>Your password message was deleted for safety.</i>",
                parse_mode="HTML",
            )
        except ImportError:
            # Auto-install caldav + vobject
            from familiar.core.deps import CALDAV_PACKAGES, ensure_packages_async

            await chat.send_message("Installing CalDAV libraries...")
            ok, failed = await ensure_packages_async(CALDAV_PACKAGES)
            if ok:
                await chat.send_message(
                    "✅ CalDAV libraries installed! Re-run <code>/connect calendar caldav</code> to test.",
                    parse_mode="HTML",
                )
            else:
                await chat.send_message(
                    f"⚠️ Credentials saved but CalDAV library install failed: {', '.join(failed)}",
                )
        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\nChecking 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 '❌ (auto-installs on first use)'}\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:
            # Auto-install twilio
            from familiar.core.deps import SMS_PACKAGES, ensure_packages

            ok, _ = ensure_packages(SMS_PACKAGES)
            if ok:
                await update.message.reply_text(
                    "✅ twilio installed! Re-run the test: <code>/connect sms test "
                    + to_number
                    + "</code>",
                    parse_mode="HTML",
                )
            else:
                await update.message.reply_text(
                    "❌ twilio package could not be installed. Check server logs."
                )
        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."""
        from familiar.core.deps import BROWSER_PACKAGES, ensure_packages_async

        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

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

        await update.message.reply_text("⏳ Installing browser automation...", parse_mode="HTML")

        ok, failed = await ensure_packages_async(BROWSER_PACKAGES)
        if ok:
            import subprocess as _sp

            _sp.run(
                [sys.executable, "-m", "playwright", "install", "chromium"],
                capture_output=True,
                timeout=300,
            )
            await update.message.reply_text(
                "🌐 <b>Browser Automation</b>\n\n"
                "✅ Playwright and Chromium installed.\n"
                'Try: "Search the web for..."',
                parse_mode="HTML",
            )
        else:
            await update.message.reply_text(
                "🌐 <b>Browser Automation</b>\n\n"
                f"❌ Installation failed: {', '.join(failed)}\n"
                "Check server logs for details.",
                parse_mode="HTML",
            )

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

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

        from familiar.core.deps import (
            VOICE_STT_PACKAGES,
            VOICE_TTS_PACKAGES,
            ensure_packages_async,
        )

        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"))
        has_any_stt = has_faster_whisper or has_whisper
        has_any_tts = has_piper or has_openai_tts

        if has_any_stt and has_any_tts:
            engine = "faster-whisper" if has_faster_whisper else "openai-whisper"
            await update.message.reply_text(
                "🎤 <b>Voice & Transcription</b>\n\n"
                f"✅ STT ready ({engine})\n"
                f"✅ TTS ready\n\n"
                "<i>Whisper models download on first use (~75MB for tiny).</i>",
                parse_mode="HTML",
            )
            return

        await update.message.reply_text("⏳ Installing voice packages...", parse_mode="HTML")

        if not has_any_stt:
            await ensure_packages_async(VOICE_STT_PACKAGES)
        if not has_any_tts:
            await ensure_packages_async(VOICE_TTS_PACKAGES)

        # Re-check
        try:
            import faster_whisper  # noqa: F811, F401

            has_any_stt = True
        except ImportError:
            pass

        status = "✅ Installed" if has_any_stt else "❌ STT install failed"
        await update.message.reply_text(
            f"🎤 <b>Voice & Transcription</b>\n\n"
            f"{status}\n\n"
            "<i>Whisper models download on first use (~75MB for tiny).</i>",
            parse_mode="HTML",
        )

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

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

        # Ensure file exists before opening r+
        if not env_path.exists():
            env_path.touch(mode=0o600)

        with open(env_path, "r+") as f:
            fcntl.flock(f, fcntl.LOCK_EX)
            try:
                existing_lines = f.read().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}")

                f.seek(0)
                f.write("\n".join(new_lines) + "\n")
                f.truncate()
            finally:
                fcntl.flock(f, fcntl.LOCK_UN)

        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:
                result = await loop.run_in_executor(
                    None,
                    lambda: self.agent.chat_with_results(
                        message=user_message,
                        user_id=user_id,
                        channel="telegram",
                        user_context=user_context,  # Phase 1: Pass user context for permissions
                    ),
                )
                response = result.text
            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(CommandHandler("browse", self._handle_browse))
        self.app.add_handler(CommandHandler("search", self._handle_search))

        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)
