#!/usr/bin/env python3
# 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

"""
Familiar Onboarding Wizard (CLI)
=================================

Install -> "my AI texted me" in under 5 minutes.

This module handles the *CLI presentation* layer.  All validation,
config-writing, and dependency installation is delegated to the
shared OnboardingEngine so that other UIs (web, TUI, tkinter)
can reuse the same logic.

Flow:
    1. Detect available LLM providers
    2. User picks a channel (Telegram/Discord/Matrix/CLI)
    3. User names their assistant
    4. Offer to schedule morning briefing
    5. Write config.yaml and .env
    6. Send test message + auto-launch

Usage:
    python -m familiar --onboard
    familiar onboard
"""

import getpass
import logging
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple

from familiar.onboard_engine import OnboardingEngine

logger = logging.getLogger(__name__)


@dataclass
class OnboardResult:
    """Result returned by run_onboard() to callers."""

    success: bool
    creature_name: str = ""
    preferred_channel: str = ""

# ── Colors ────────────────────────────────────────────────────────────


class C:
    BOLD = "\033[1m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    RED = "\033[91m"
    CYAN = "\033[96m"
    DIM = "\033[2m"
    RESET = "\033[0m"

    @staticmethod
    def ok(msg):
        print(f"  {C.GREEN}✓{C.RESET} {msg}")

    @staticmethod
    def warn(msg):
        print(f"  {C.YELLOW}⚠{C.RESET} {msg}")

    @staticmethod
    def fail(msg):
        print(f"  {C.RED}✗{C.RESET} {msg}")

    @staticmethod
    def info(msg):
        print(f"  {C.CYAN}→{C.RESET} {msg}")


def clear():
    os.system("clear" if os.name == "posix" else "cls")


def banner():
    clear()
    print(f"""
{C.BOLD}{"═" * 56}

    🤝  F A M I L I A R

    An AI companion designed to take care of you.

{"═" * 56}{C.RESET}
""")


def prompt_choice(
    question: str, options: list[tuple[str, str]], default: Optional[str] = None
) -> str:
    """Show numbered choices, return the key."""
    print(f"\n  {C.BOLD}{question}{C.RESET}\n")
    for i, (key, label) in enumerate(options, 1):
        marker = f" {C.DIM}(default){C.RESET}" if key == default else ""
        print(f"    {C.CYAN}{i}{C.RESET}) {label}{marker}")
    print()
    while True:
        raw = input(f"  Choose [1-{len(options)}]: ").strip()
        if not raw and default:
            return default
        try:
            idx = int(raw) - 1
            if 0 <= idx < len(options):
                return options[idx][0]
        except ValueError as e:
            logger.debug("Value conversion error suppressed: %s", e)
        print(f"  {C.DIM}Enter a number 1-{len(options)}{C.RESET}")


def prompt_string(question: str, default: str = "", secret: bool = False) -> str:
    """Prompt for a string value."""
    suffix = f" [{default}]" if default else ""
    prompt_text = f"  {question}{suffix}: "
    if secret:
        val = getpass.getpass(prompt_text)
    else:
        val = input(prompt_text)
    return val.strip() or default


def _ollama_pull(model: str, timeout: int = 900) -> bool:
    """Pull an Ollama model with streaming progress output.

    Returns True on success, False on failure.
    """
    try:
        proc = subprocess.Popen(
            ["ollama", "pull", model],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )
        import time as _t

        start = _t.monotonic()
        for line in proc.stdout:
            line = line.strip()
            if line:
                # Overwrite the current line for progress display
                print(f"\r  {C.DIM}{line[:76]}{C.RESET}", end="", flush=True)
            if _t.monotonic() - start > timeout:
                proc.kill()
                print()
                C.warn(f"Download timed out after {timeout}s")
                return False
        proc.wait()
        print()  # newline after progress
        return proc.returncode == 0
    except FileNotFoundError:
        print()
        return False


# ── Provider Setup (CLI-interactive) ─────────────────────────────────


def setup_provider(providers: dict) -> Tuple[str, dict]:
    """Interactive provider selection. Returns (provider_name, config_dict)."""

    if providers:
        print(f"\n  {C.GREEN}Detected providers:{C.RESET}")
        for name, info in providers.items():
            C.ok(info["label"])

    # Build options
    options = []
    if "anthropic" in providers:
        options.append(("anthropic", "Anthropic Claude ✓ (key detected)"))
    else:
        options.append(("anthropic", "Anthropic Claude (need API key)"))

    if "openai" in providers:
        options.append(("openai", "OpenAI GPT ✓ (key detected)"))
    else:
        options.append(("openai", "OpenAI GPT (need API key)"))

    if "gemini" in providers:
        options.append(("gemini", "Google Gemini ✓ (key detected)"))
    else:
        options.append(("gemini", "Google Gemini (need API key)"))

    if "ollama" in providers:
        options.append(("ollama", "Ollama ✓ (local, free, private)"))
    else:
        options.append(("ollama", "Ollama (local, free — will install)"))

    # Default to best available
    default = None
    if "anthropic" in providers:
        default = "anthropic"
    elif "ollama" in providers:
        default = "ollama"
    elif "openai" in providers:
        default = "openai"
    elif "gemini" in providers:
        default = "gemini"

    choice = prompt_choice("Which LLM provider?", options, default=default)
    config = {"provider": choice}

    # Collect credentials if needed
    if choice == "anthropic" and "anthropic" not in providers:
        print(f"\n  {C.DIM}Get a key at: console.anthropic.com{C.RESET}")
        config["anthropic_key"] = prompt_string("Anthropic API key", secret=True)
    elif choice == "anthropic":
        config["anthropic_key"] = providers["anthropic"]["key"]

    if choice == "openai" and "openai" not in providers:
        print(f"\n  {C.DIM}Get a key at: platform.openai.com/api-keys{C.RESET}")
        config["openai_key"] = prompt_string("OpenAI API key", secret=True)
    elif choice == "openai":
        config["openai_key"] = providers["openai"]["key"]

    if choice == "gemini" and "gemini" not in providers:
        print(f"\n  {C.DIM}Get a key at: aistudio.google.com/apikey{C.RESET}")
        config["gemini_key"] = prompt_string("Gemini API key", secret=True)
    elif choice == "gemini":
        config["gemini_key"] = providers["gemini"]["key"]

    if choice == "ollama":
        if "ollama" not in providers:
            print(f"\n  {C.CYAN}Installing Ollama...{C.RESET}")
            try:
                subprocess.run(
                    ["bash", "-c", "curl -fsSL https://ollama.ai/install.sh | sh"],
                    check=True,
                    timeout=120,
                )
                C.ok("Ollama installed")
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                C.fail(
                    "Ollama install failed. Install manually: curl -fsSL https://ollama.ai/install.sh | sh"
                )
                C.info("Continuing anyway — you can set it up later.")

        # Ensure Ollama server is running before any model operations
        ollama_running = False
        try:
            result = subprocess.run(
                [
                    "curl",
                    "-s",
                    "-o",
                    "/dev/null",
                    "-w",
                    "%{http_code}",
                    "http://localhost:11434/api/tags",
                ],
                capture_output=True,
                timeout=3,
                text=True,
            )
            ollama_running = result.stdout.strip() == "200"
        except (subprocess.TimeoutExpired, FileNotFoundError) as e:
            logger.debug("Subprocess timeout suppressed: %s", e)
        if not ollama_running:
            print(f"  {C.CYAN}Starting Ollama server...{C.RESET}")
            try:
                subprocess.Popen(
                    ["ollama", "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
                )
                import time

                # Wait up to 10 seconds for server to be ready
                for _ in range(10):
                    time.sleep(1)
                    try:
                        result = subprocess.run(
                            [
                                "curl",
                                "-s",
                                "-o",
                                "/dev/null",
                                "-w",
                                "%{http_code}",
                                "http://localhost:11434/api/tags",
                            ],
                            capture_output=True,
                            timeout=2,
                            text=True,
                        )
                        if result.stdout.strip() == "200":
                            ollama_running = True
                            C.ok("Ollama server started")
                            break
                    except (subprocess.TimeoutExpired, FileNotFoundError) as e:
                        logger.debug("Subprocess timeout suppressed: %s", e)
                        continue
                if not ollama_running:
                    C.warn("Ollama server not responding. Run 'ollama serve' in another terminal.")
            except FileNotFoundError:
                C.warn(
                    "Ollama binary not found. Install: curl -fsSL https://ollama.ai/install.sh | sh"
                )

        # ── Model Selection ────────────────────────────────────
        ollama_info = providers.get("ollama", {})
        existing_models = ollama_info.get("models", [])
        bundles = OnboardingEngine().get_ollama_bundles()
        ram_gb = bundles["ram_gb"]
        recommended = bundles["recommended"]
        bundle_label = recommended["label"]
        bundle_models = recommended["models"]
        bundle_desc = recommended["desc"]

        if existing_models:
            print(f"\n  {C.GREEN}Installed models:{C.RESET} {', '.join(existing_models)}")

        has_embedding = any("nomic" in m or "embed" in m for m in existing_models)

        print(f"\n  {C.BOLD}Model Setup{C.RESET} ({ram_gb:.0f} GB RAM detected)\n")
        print(f"  The {bundle_label} is recommended for your hardware:")
        print(f"  {C.CYAN}{bundle_desc}{C.RESET}")
        if len(bundle_models) == 3:
            print("  • Main model — handles conversations (largest)")
            print("  • Background model — planning & memory extraction (smallest)")
            print("  • Embedding model — semantic memory search (137 MB)")
        elif len(bundle_models) == 2:
            print("  • Main model — handles all tasks")
            print("  • Embedding model — semantic memory search (137 MB)")

        setup_mode = prompt_choice(
            "\nHow would you like to set up models?",
            [
                ("bundle", f"{bundle_label} (recommended) — {bundle_desc}"),
                ("custom", "Custom — choose individual models"),
                ("skip", "Skip — I'll manage models myself"),
            ],
            default="bundle",
        )

        if setup_mode == "bundle":
            # Download missing bundle models
            for model in bundle_models:
                if model in existing_models:
                    C.ok(f"{model} already installed")
                else:
                    print(f"\n  {C.CYAN}Downloading {model}...{C.RESET}")
                    if not _ollama_pull(model):
                        action = prompt_choice(
                            f"Failed to download {model}. What would you like to do?",
                            [
                                ("retry", "Retry download"),
                                ("skip", "Skip this model"),
                                ("abort", "Abort setup"),
                            ],
                            default="retry",
                        )
                        if action == "retry":
                            if _ollama_pull(model):
                                C.ok(f"{model} ready")
                            else:
                                C.warn(f"Still failed. Run manually: ollama pull {model}")
                        elif action == "abort":
                            print("\n  Setup aborted.\n")
                            return ("ollama", config)
                        # skip: continue to next model
                    else:
                        C.ok(f"{model} ready")

            # Assign roles
            config["ollama_model"] = bundle_models[0]  # Main
            if len(bundle_models) >= 3:
                config["lightweight_model"] = bundle_models[1]  # Background

        elif setup_mode == "custom":
            # Show all available models with sizes
            available = [(m["name"], m["desc"]) for m in bundles["available_models"]]

            print(f"\n  Available models (you have {ram_gb:.0f} GB RAM):\n")
            for idx, (name, desc) in enumerate(available, 1):
                installed = " ✓" if name in existing_models else ""
                print(f"    {idx:2d}. {desc}{C.GREEN}{installed}{C.RESET}")

            print("\n  Enter model numbers separated by spaces.")
            print(
                '  Example: "4 1 10" for llama3.2 (main) + smollm2 (background) + nomic (embeddings)'
            )

            selection = input(f"\n  Model numbers [{C.CYAN}4 1 10{C.RESET}]: ").strip()
            if not selection:
                selection = "4 1 10"  # Default: llama3.2, smollm2, nomic

            try:
                indices = [int(x) - 1 for x in selection.split()]
                selected = [available[i][0] for i in indices if 0 <= i < len(available)]
            except (ValueError, IndexError):
                selected = ["llama3.2"]
                C.warn("Invalid selection, defaulting to llama3.2")

            for model in selected:
                if model in existing_models:
                    C.ok(f"{model} already installed")
                else:
                    print(f"\n  {C.CYAN}Downloading {model}...{C.RESET}")
                    if not _ollama_pull(model):
                        action = prompt_choice(
                            f"Failed to download {model}. What would you like to do?",
                            [
                                ("retry", "Retry download"),
                                ("skip", "Skip this model"),
                                ("abort", "Abort setup"),
                            ],
                            default="retry",
                        )
                        if action == "retry":
                            if _ollama_pull(model):
                                C.ok(f"{model} ready")
                            else:
                                C.warn(f"Still failed. Run manually: ollama pull {model}")
                        elif action == "abort":
                            print("\n  Setup aborted.\n")
                            return ("ollama", config)
                    else:
                        C.ok(f"{model} ready")

            # Assign roles: largest = main, smallest non-embed = background
            non_embed = [m for m in selected if "embed" not in m and "nomic" not in m]
            config["ollama_model"] = non_embed[0] if non_embed else selected[0]
            if len(non_embed) >= 2:
                config["lightweight_model"] = non_embed[-1]  # Smallest last

        else:  # skip
            if existing_models:
                config["ollama_model"] = existing_models[0]
                C.ok(f"Using existing model: {existing_models[0]}")
            else:
                config["ollama_model"] = "llama3.2"
                C.warn("No models installed. Run: ollama pull llama3.2")

        # Offer embedding model if not already installed/selected
        if not has_embedding and setup_mode != "bundle":
            print(f"\n  {C.BOLD}Semantic Memory{C.RESET}")
            print("  nomic-embed-text (137MB) enables meaning-based search across")
            print("  your conversations and documents. Without it, Familiar falls")
            print("  back to keyword matching.\n")
            add_embed = (
                input(f"  Download nomic-embed-text? [{C.CYAN}Y{C.RESET}/n]: ").strip().lower()
            )
            if add_embed != "n":
                print(f"  {C.CYAN}Downloading nomic-embed-text...{C.RESET}")
                if _ollama_pull("nomic-embed-text"):
                    C.ok("nomic-embed-text ready — semantic search enabled")
                else:
                    C.warn("Download failed. Run manually: ollama pull nomic-embed-text")

    return choice, config


# ── Channel Setup ─────────────────────────────────────────────────────


def setup_channel() -> Tuple[str, dict]:
    """Channel selection with guided token setup. Supports multiple channels."""

    print(f"\n  {C.BOLD}Select your channels{C.RESET}")
    print(f"  {C.DIM}You can choose multiple. Pick your primary first, then add more.{C.RESET}\n")

    all_channels = [
        ("telegram", "📱 Telegram (recommended — works on phone + desktop)"),
        ("discord", "🎮 Discord"),
        ("matrix", "🔗 Matrix / Element (decentralized, E2EE)"),
        ("whatsapp", "📱 WhatsApp (via whatsapp-web.js bridge)"),
        ("signal", "🔒 Signal (private, E2EE, open source)"),
        ("imessage", "🍎 iMessage [macOS only]"),
        ("teams", "🏢 Microsoft Teams [Advanced]"),
        ("dashboard", "🌐 Web Dashboard (browser UI at localhost)"),
        ("cli", "💻 Terminal / CLI (local only)"),
    ]

    # Show numbered list
    for idx, (key, label) in enumerate(all_channels, 1):
        print(f"    {idx}) {label}")

    print('\n  Enter numbers separated by spaces (e.g. "1 2" for Telegram + Discord)')
    selection = input(f"  Channels [{C.CYAN}1{C.RESET}]: ").strip()
    if not selection:
        selection = "1"

    try:
        indices = [int(x) - 1 for x in selection.split()]
        selected = [all_channels[i][0] for i in indices if 0 <= i < len(all_channels)]
    except (ValueError, IndexError):
        selected = ["telegram"]
        C.warn("Invalid selection, defaulting to Telegram")

    if not selected:
        selected = ["telegram"]

    # Primary channel is the first one selected
    primary = selected[0]
    config = {"channel": primary, "channels": selected}

    # Collect credentials for each selected channel
    for ch in selected:
        if ch == "telegram":
            print(f"""
  {C.BOLD}Telegram Setup:{C.RESET}

    1. Open Telegram, search for {C.CYAN}@BotFather{C.RESET}
    2. Send: {C.CYAN}/newbot{C.RESET}
    3. Pick a name (e.g. "My Familiar")
    4. Copy the token it gives you
""")
            token = prompt_string("Telegram bot token", secret=True)
            config["telegram_token"] = token

            print(f"""
  {C.DIM}After setup, send /start to your bot.
  Familiar will remember your chat ID automatically.{C.RESET}
""")

        elif ch == "discord":
            print(f"""
  {C.BOLD}Discord Setup:{C.RESET}

    1. Go to {C.CYAN}discord.com/developers/applications{C.RESET}
    2. Create New Application → Bot → Copy token
    3. Enable "Message Content Intent" in Bot settings
    4. Invite to your server with Send Messages permission
""")
            token = prompt_string("Discord bot token", secret=True)
            config["discord_token"] = token

        elif ch == "matrix":
            print(f"""
  {C.BOLD}Matrix Setup:{C.RESET}

    1. Create a bot account on your homeserver (e.g. Element → Register)
    2. Go to Settings → Security → Access Token, or use password auth
    3. For E2EE support: install familiar-agent[matrix]
""")
            homeserver = prompt_string("Homeserver URL (e.g. https://matrix.org)")
            config["matrix_homeserver"] = homeserver
            user_id = prompt_string("Bot user ID (e.g. @familiar:matrix.org)")
            config["matrix_user"] = user_id

            print(f"\n  {C.DIM}Choose auth method:{C.RESET}")
            print("    1) Password")
            print("    2) Access token")
            auth_choice = input(f"  Auth method [{C.CYAN}1{C.RESET}]: ").strip() or "1"
            if auth_choice == "2":
                token = prompt_string("Access token", secret=True)
                config["matrix_access_token"] = token
            else:
                password = prompt_string("Password", secret=True)
                config["matrix_password"] = password

            owner = prompt_string(
                "Your Matrix ID for owner access (e.g. @you:matrix.org)", default=""
            )
            if owner:
                config["owner_matrix_id"] = owner

        elif ch == "whatsapp":
            print(f"""
  {C.BOLD}WhatsApp Setup:{C.RESET}

    Familiar connects to WhatsApp via a Node.js bridge using
    whatsapp-web.js. The bridge runs alongside Python.

    Prerequisites:
      1. Install Node.js (v18+): {C.CYAN}https://nodejs.org/{C.RESET}
      2. Install bridge dependencies:
         {C.CYAN}npm install whatsapp-web.js qrcode-terminal ws{C.RESET}

    On first launch, the bridge will display a QR code.
    Scan it with WhatsApp on your phone to pair.
""")
            port = prompt_string("Bridge port", default="3001")
            config["whatsapp_port"] = port

            create_script = (
                input(f"  Create whatsapp_bridge.js in ~/.familiar/? [{C.CYAN}Y{C.RESET}/n]: ")
                .strip()
                .lower()
            )
            if create_script != "n":
                try:
                    from familiar.channels.whatsapp import create_bridge_script

                    script_path = create_bridge_script()
                    C.ok(f"Bridge script created: {script_path}")
                    C.info(f"Start it with: node {script_path}")
                except (ImportError, Exception) as e:
                    C.warn(f"Could not create bridge script: {e}")
                    C.info(
                        "You can create it later with: python -c 'from familiar.channels.whatsapp import create_bridge_script; create_bridge_script()'"
                    )

        elif ch == "signal":
            print(f"""
  {C.BOLD}Signal Setup:{C.RESET}

    Familiar uses signal-cli to connect to Signal.

    Install signal-cli:
      macOS:  brew install signal-cli
      Linux:  apt install signal-cli  (or download from GitHub)
      Manual: github.com/AsamK/signal-cli/releases

    You can link to an existing Signal account or register a new number.
    Run: signal-cli -u +YOURNUMBER register  (or link)
""")
            phone = prompt_string("Signal phone number with country code (e.g. +14155551234)")
            config["signal_phone"] = phone

        elif ch == "imessage":
            print(f"""
  {C.BOLD}iMessage Setup:{C.RESET}

    iMessage integration requires macOS with Messages.app.
    Familiar reads and sends messages via AppleScript.

    No additional configuration is needed — just run
    Familiar on a Mac that's signed into iMessage.
""")
            if sys.platform != "darwin":
                C.warn(
                    "iMessage requires macOS. This machine appears to be running a different OS."
                )
                C.info("You can still configure it — just deploy Familiar on a Mac.")

        elif ch == "teams":
            print(f"""
  {C.BOLD}Microsoft Teams Setup:{C.RESET}

    1. Go to {C.CYAN}portal.azure.com{C.RESET} → Azure Bot Services
    2. Create a new Bot resource
    3. Under Configuration, copy the App ID and generate a password
    4. Under Channels, connect Microsoft Teams
    5. (Optional) Set Tenant ID for single-tenant deployment
""")
            app_id = prompt_string("Teams App ID")
            config["teams_app_id"] = app_id
            app_password = prompt_string("Teams App Password", secret=True)
            config["teams_app_password"] = app_password
            tenant_id = prompt_string("Tenant ID (optional, press Enter to skip)", default="")
            if tenant_id:
                config["teams_tenant_id"] = tenant_id

        # CLI needs nothing

    if len(selected) > 1:
        print(f"\n  {C.GREEN}Channels configured: {', '.join(selected)}{C.RESET}")
        print(f"  {C.DIM}Primary: {primary} (used for proactive briefings){C.RESET}")

    return primary, config


# ── Name & Personality ────────────────────────────────────────────────


def setup_identity() -> dict:
    """Name the assistant. Article 3 — every relationship is unique."""
    print(f"""
  {C.BOLD}Name your assistant{C.RESET}

  {C.DIM}This is who Familiar will be to you. The name appears in
  greetings, briefings, and proactive messages.{C.RESET}
""")
    name = prompt_string("Assistant name", default="Familiar")

    persona = prompt_choice(
        "What's their style?",
        [
            ("hospitality", "🏛️  Warm & professional (default hospitality consciousness)"),
            ("efficient", "⚡ Efficient & concise (just the facts)"),
            ("friendly", "😊 Casual & friendly (conversational)"),
            ("formal", "📋 Formal & thorough (detailed responses)"),
        ],
        default="hospitality",
    )

    return {"name": name, "persona": persona}


# ── Proactive Briefing ────────────────────────────────────────────────


def setup_briefing() -> dict:
    """Offer morning briefing. Article 2 — anticipate from gratitude."""
    print(f"""
  {C.BOLD}Morning Briefing{C.RESET}

  {C.DIM}Familiar can send you a daily briefing with your calendar,
  tasks, emails, and anything that needs your attention.
  This is what makes it feel like a real assistant.{C.RESET}
""")
    enable = prompt_choice(
        "Enable morning briefing?",
        [
            ("yes", "Yes — send me a briefing each morning"),
            ("no", "No — I'll ask when I need it"),
        ],
        default="yes",
    )

    config = {"briefing_enabled": enable == "yes"}

    if config["briefing_enabled"]:
        while True:
            time_str = prompt_string("What time? (24h format)", default="08:00")
            import re as _re

            if _re.match(r"^([01]?\d|2[0-3]):[0-5]\d$", time_str):
                config["briefing_time"] = time_str
                break
            else:
                C.warn(f"'{time_str}' is not valid. Use HH:MM format (e.g. 08:00, 14:30)")

        # Heartbeat check-ins
        heartbeat = prompt_choice(
            "Proactive check-ins during the day?",
            [
                ("yes", "Yes — nudge me about overdue tasks and urgent items"),
                ("no", "No — only speak when I ask"),
            ],
            default="yes",
        )
        config["heartbeat_enabled"] = heartbeat == "yes"

    return config


# ── Test Message (Instant Win) ────────────────────────────────────────


def send_test_message(
    channel: str,
    channel_config: dict,
    identity: dict,
    briefing: dict,
) -> bool:
    """
    Send the first message. Article 1: the person is the point, not the platform.

    The test message doesn't describe capabilities. It does something useful.
    This is the Instant Win — the moment the user feels "this is real."
    """
    name = identity["name"]

    if briefing.get("briefing_enabled"):
        time = briefing.get("briefing_time", "8:00 AM")
        msg = (
            f"Hi, I'm {name}. I'm set up and ready.\n\n"
            f"I'll send you a morning briefing at {time} with your calendar, "
            f"tasks, and anything that needs your attention.\n\n"
            f"You can ask me anything anytime. I'm here to help."
        )
    else:
        msg = (
            f"Hi, I'm {name}. I'm set up and ready.\n\n"
            f"Ask me anything — I can help with tasks, calendar, email, "
            f"research, documents, and more.\n\n"
            f'Try: "What\'s on my calendar today?" or "Create a task to..."'
        )

    if channel == "cli":
        print(f"\n  {C.GREEN}{'─' * 50}{C.RESET}")
        print(f"  {C.GREEN}{name}:{C.RESET} {msg}")
        print(f"  {C.GREEN}{'─' * 50}{C.RESET}")
        return True

    if channel == "telegram" and channel_config.get("telegram_token"):
        return _send_telegram_test(channel_config["telegram_token"], msg)

    if channel == "discord" and channel_config.get("discord_token"):
        return _send_discord_test(channel_config["discord_token"], msg)

    if channel == "matrix" and channel_config.get("matrix_homeserver"):
        return _send_matrix_test(channel_config, msg)

    # Other channels — deferred until bridge starts
    print(f"\n  {C.DIM}{channel.title()} test message will be sent when the bot starts.{C.RESET}")
    return True


def _send_telegram_test(token: str, message: str) -> bool:
    """Send a test message via Telegram. Waits for user to /start the bot."""
    try:
        import json
        import time
        import urllib.request

        print(f"\n  {C.CYAN}Waiting for you to send /start to your bot...{C.RESET}")
        print(f"  {C.DIM}(Open Telegram, find your bot, tap Start){C.RESET}\n")

        # Poll for updates until we get a /start
        offset = 0
        chat_id = None
        for attempt in range(60):  # Wait up to 60 seconds
            try:
                url = f"https://api.telegram.org/bot{token}/getUpdates?offset={offset}&timeout=1"
                req = urllib.request.Request(url, method="GET")
                with urllib.request.urlopen(req, timeout=5) as resp:
                    data = json.loads(resp.read())
                    if data.get("ok"):
                        for update in data.get("result", []):
                            offset = update["update_id"] + 1
                            msg_obj = update.get("message", {})
                            if msg_obj.get("text", "").startswith("/start"):
                                chat_id = msg_obj["chat"]["id"]
                                break
            except Exception as e:
                logger.debug("Error suppressed: %s", e)
            if chat_id:
                break

            if attempt % 10 == 9:
                print(f"  {C.DIM}Still waiting... ({attempt + 1}s){C.RESET}")

            time.sleep(1)

        if not chat_id:
            C.warn("Didn't receive /start. You can test later by sending /start to your bot.")
            return False

        # Send the greeting
        url = f"https://api.telegram.org/bot{token}/sendMessage"
        payload = json.dumps({"chat_id": chat_id, "text": message}).encode()
        req = urllib.request.Request(
            url, data=payload, headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            result = json.loads(resp.read())
            if result.get("ok"):
                C.ok("Test message sent! Check your Telegram. ✨")

                # Save chat_id for future proactive messages
                _save_chat_id(chat_id)
                return True
            else:
                C.warn(f"Telegram API error: {result}")
                return False

    except ImportError:
        C.warn("Could not send test message (missing dependencies)")
        return False
    except Exception as e:
        C.warn(f"Test message failed: {e}")
        return False


def _send_discord_test(token: str, message: str) -> bool:
    """Send a test message to the bot's first available text channel via Discord REST API."""
    try:
        import json
        import urllib.request

        headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"}

        # Get the bot's guilds
        req = urllib.request.Request(
            "https://discord.com/api/v10/users/@me/guilds",
            headers=headers,
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            guilds = json.loads(resp.read())

        if not guilds:
            C.warn("Bot is not in any servers. Invite it first, then test later.")
            return False

        guild_id = guilds[0]["id"]

        # Get text channels in that guild
        req = urllib.request.Request(
            f"https://discord.com/api/v10/guilds/{guild_id}/channels",
            headers=headers,
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            channels = json.loads(resp.read())

        # Find first text channel (type 0)
        text_channel = next((c for c in channels if c.get("type") == 0), None)
        if not text_channel:
            C.warn("No text channels found in the server. Test message skipped.")
            return False

        # Send the message
        payload = json.dumps({"content": message}).encode()
        req = urllib.request.Request(
            f"https://discord.com/api/v10/channels/{text_channel['id']}/messages",
            data=payload,
            headers=headers,
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            json.loads(resp.read())

        C.ok(f"Test message sent to #{text_channel['name']}! Check your Discord.")
        return True

    except Exception as e:
        C.warn(f"Discord test message failed: {e}")
        print(f"  {C.DIM}The bot will send a greeting when it starts.{C.RESET}")
        return False


def _send_matrix_test(channel_config: dict, message: str) -> bool:
    """Send a test message to a Matrix room via the client-server REST API."""
    try:
        import json
        import urllib.request

        homeserver = channel_config["matrix_homeserver"].rstrip("/")
        access_token = channel_config.get("matrix_access_token", "")

        # Login with password if no access token
        if not access_token:
            user = channel_config.get("matrix_user", "")
            password = channel_config.get("matrix_password", "")
            if not password:
                C.warn("Matrix test message requires a password or access token.")
                return False

            login_payload = json.dumps(
                {
                    "type": "m.login.password",
                    "identifier": {"type": "m.id.user", "user": user},
                    "password": password,
                }
            ).encode()
            req = urllib.request.Request(
                f"{homeserver}/_matrix/client/v3/login",
                data=login_payload,
                headers={"Content-Type": "application/json"},
            )
            with urllib.request.urlopen(req, timeout=10) as resp:
                login_data = json.loads(resp.read())
            access_token = login_data.get("access_token", "")
            if not access_token:
                C.warn("Matrix login failed. Test message skipped.")
                return False

        # Get joined rooms
        req = urllib.request.Request(
            f"{homeserver}/_matrix/client/v3/joined_rooms",
            headers={"Authorization": f"Bearer {access_token}"},
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            rooms = json.loads(resp.read()).get("joined_rooms", [])

        if not rooms:
            C.warn("Bot has not joined any rooms. Invite it to a room, then test later.")
            return False

        # Send to first joined room
        import time

        txn_id = str(int(time.time() * 1000))
        room_id = rooms[0]
        payload = json.dumps({"msgtype": "m.text", "body": message}).encode()
        req = urllib.request.Request(
            f"{homeserver}/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn_id}",
            data=payload,
            headers={
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/json",
            },
            method="PUT",
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            json.loads(resp.read())

        C.ok("Test message sent! Check your Matrix client.")
        return True

    except Exception as e:
        C.warn(f"Matrix test message failed: {e}")
        print(f"  {C.DIM}The bot will send a greeting when it starts.{C.RESET}")
        return False


def _save_chat_id(chat_id: int):
    """Save the Telegram chat ID for proactive messaging and owner identity."""
    data_dir = Path.home() / ".familiar" / "data"
    data_dir.mkdir(parents=True, exist_ok=True)
    chat_file = data_dir / "telegram_chat_id"
    chat_file.write_text(str(chat_id))

    # Set in current process environment
    os.environ["OWNER_TELEGRAM_ID"] = str(chat_id)

    # Persist to .env files (merge, don't append blindly)
    for env_path in [Path.cwd() / ".env", Path.home() / ".familiar" / ".env"]:
        if env_path.exists():
            lines = env_path.read_text().splitlines()
            found = False
            for i, line in enumerate(lines):
                if line.startswith("OWNER_TELEGRAM_ID="):
                    lines[i] = f"OWNER_TELEGRAM_ID={chat_id}"
                    found = True
                    break
            if not found:
                lines.append(f"OWNER_TELEGRAM_ID={chat_id}")
            env_path.write_text("\n".join(lines) + "\n")

    # Update config.yaml with owner ID and allowed users
    for cfg_path in [Path.cwd() / "config.yaml", Path.home() / ".familiar" / "config.yaml"]:
        if cfg_path.exists():
            existing = cfg_path.read_text()
            if "owner_telegram_id" not in existing:
                existing = existing.replace(
                    "  telegram_enabled:",
                    f"  owner_telegram_id: {chat_id}\n  telegram_allowed_users:\n    - {chat_id}\n  telegram_enabled:",
                )
                cfg_path.write_text(existing)


# ── Main Wizard ───────────────────────────────────────────────────────


def run_onboard(
    project_dir: Optional[Path] = None,
    auto_launch: bool = True,
    reconfigure: bool = False,
) -> OnboardResult:
    """Run the complete onboarding flow.

    All validation and config-writing is delegated to :class:`OnboardingEngine`.
    This function handles only CLI presentation and user interaction.

    When ``reconfigure=False`` (first run), the creature narrative is
    the backbone: constitution → owner name → provider → egg → name →
    species → colors → birth → channels → PIN → briefing → encryption.

    When ``reconfigure=True``, creature phases are skipped and only
    the config steps are re-run.

    Returns:
        OnboardResult with success flag, creature name, and preferred channel.
    """

    if project_dir is None:
        project_dir = Path.cwd()

    engine = OnboardingEngine(project_dir=project_dir)

    # Check for incomplete previous session
    saved = engine.load_saved_state()
    resume_step = 0
    if saved:
        banner()
        resume = input("  Previous setup incomplete. Resume? [Y/n]: ").strip().lower()
        if resume != "n":
            engine._state = saved.get("state", {})
            resume_step = saved.get("step", 0)
            C.ok(f"Resuming from step {resume_step + 1}")
        else:
            engine.clear_saved_state()

    # ── Reconfigure path: skip creature phases ──────────────────
    if reconfigure:
        return _run_reconfigure(engine, project_dir, resume_step)

    # ── First-run unified flow ──────────────────────────────────

    # ═══════════════════════════════════════════════════════════
    # Phase 1: The Bond (creature narrative)
    # ═══════════════════════════════════════════════════════════

    from familiar.creature.intro import (
        ask_consent,
        ask_creature_name,
        ask_owner_name,
        ask_species,
        present_constitution,
        prompt_color,
        run_birth_animation,
        show_channel_info,
        show_egg,
        show_logo,
    )

    if resume_step <= 0:
        # 1. Logo
        print()
        show_logo()
        print()

        # 2. Constitution + Consent
        present_constitution()
        if not ask_consent():
            print(f"\n  {C.DIM}Maybe another time.{C.RESET}\n")
            return OnboardResult(success=False)

        # 3. Owner name
        owner_name = ask_owner_name()
        engine._state["_owner_name"] = owner_name
        engine.save_state(1)

    owner_name = engine._state.get("_owner_name", "")

    # ═══════════════════════════════════════════════════════════
    # Phase 2: The Mind (config, narratively framed)
    # ═══════════════════════════════════════════════════════════

    if resume_step <= 1:
        # 4. LLM Provider
        print(f"\n  {C.DIM}Before I can think, I need a mind...{C.RESET}\n")
        detect_result = engine.detect_providers()
        providers = detect_result.data["providers"]
        provider_name, provider_config = setup_provider(providers)
        C.ok(f"Provider: {provider_name}")

        api_key = (
            provider_config.get("anthropic_key")
            or provider_config.get("openai_key")
            or provider_config.get("gemini_key")
            or ""
        )
        engine.validate_provider(
            {
                "provider": provider_name,
                "api_key": api_key,
                "ollama_model": provider_config.get("ollama_model", ""),
                "lightweight_model": provider_config.get("lightweight_model", ""),
            }
        )
        engine.save_state(2)

    # ═══════════════════════════════════════════════════════════
    # Phase 3: The Birth (creature creation)
    # ═══════════════════════════════════════════════════════════

    if resume_step <= 2:
        import time

        # 5. Egg + creature name
        print()
        show_egg()
        time.sleep(0.5)
        print(f"\n  {C.DIM}Hello... I'm your Familiar.{C.RESET}")
        print(f"  {C.DIM}I don't have a form yet.{C.RESET}")
        print()

        creature_name = ask_creature_name()
        engine._state["_creature_name"] = creature_name

        # 6. Species selection
        species = ask_species()
        engine._state["_species"] = species

        # 7. Colors
        color_body = prompt_color("Body color")
        color_eyes = prompt_color("Eye color")
        color_accent = prompt_color("Accent color")
        engine._state["_colors"] = {
            "body": color_body,
            "eyes": color_eyes,
            "accent": color_accent,
        }

        # 8. Birth animation + display
        creature = run_birth_animation(
            name=creature_name,
            species=species,
            color_body=color_body,
            color_eyes=color_eyes,
            color_accent=color_accent,
            owner_name=owner_name,
        )
        engine._state["_creature_born"] = True
        engine.save_state(3)

    creature_name = engine._state.get("_creature_name", "Familiar")

    # Set identity via engine — creature name IS the agent name
    engine.validate_identity({"name": creature_name, "persona": "hospitality"})

    # ═══════════════════════════════════════════════════════════
    # Phase 4: The World (post-birth config)
    # ═══════════════════════════════════════════════════════════

    if resume_step <= 3:
        # 9. Channels
        print(f"\n  {C.DIM}Now... how shall we talk?{C.RESET}")
        channel, channel_config = setup_channel()
        C.ok(f"Channel: {channel}")
        engine.validate_channels(channel_config)
        engine._state["_preferred_channel"] = channel
        engine.save_state(4)

    preferred_channel = engine._state.get("_preferred_channel", "cli")

    if resume_step <= 4:
        # 10. Owner PIN (only if non-CLI channel selected)
        channels_state = engine._state.get("channels", {})
        selected_channels = channels_state.get("channels", [preferred_channel])
        has_remote = any(ch != "cli" for ch in selected_channels)

        if has_remote:
            print(f"\n  {C.DIM}One more thing — a way to prove you're you.{C.RESET}")
            print(
                f"  {C.DIM}Send this PIN as your first message to claim ownership.{C.RESET}\n"
            )

            default_pin = OnboardingEngine.generate_default_pin()
            owner_pin = prompt_string(
                "Choose a 4-8 digit owner PIN", default=default_pin, secret=False
            )

            pin_result = engine.validate_owner_pin({"pin": owner_pin})
            while not pin_result.success:
                for err in pin_result.errors:
                    print(f"  {C.DIM}{err}{C.RESET}")
                owner_pin = prompt_string(
                    "Choose a 4-8 digit owner PIN", default=default_pin, secret=False
                )
                pin_result = engine.validate_owner_pin({"pin": owner_pin})

            print(f"\n  {C.CYAN}╔══════════════════════════════════════╗{C.RESET}")
            print(
                f"  {C.CYAN}║  Your owner PIN: {C.BOLD}{owner_pin}{C.RESET}"
                f"{C.CYAN}{'':>{20 - len(owner_pin)}}║{C.RESET}"
            )
            print(f"  {C.CYAN}║  Send this as your first message     ║{C.RESET}")
            print(f"  {C.CYAN}║  to claim ownership of the bot.      ║{C.RESET}")
            print(f"  {C.CYAN}╚══════════════════════════════════════╝{C.RESET}")
            C.ok(f"Owner PIN set ({len(owner_pin)} digits)")
        else:
            # CLI-only — set a default PIN in case they add channels later
            default_pin = OnboardingEngine.generate_default_pin()
            engine.validate_owner_pin({"pin": default_pin})

        engine.save_state(5)

    if resume_step <= 5:
        # 11. Briefing
        print(f"\n  {C.DIM}Shall I wake you each morning?{C.RESET}")
        briefing_raw = setup_briefing()
        engine.validate_briefing(
            {
                "enabled": briefing_raw.get("briefing_enabled", False),
                "time": briefing_raw.get("briefing_time", "08:00"),
                "heartbeat": briefing_raw.get("heartbeat_enabled", False),
            }
        )
        briefing = engine._state.get("briefing", {})
        if briefing.get("briefing_enabled"):
            C.ok(f"Briefing at {briefing.get('briefing_time', '08:00')}")
        else:
            C.ok("Briefing: off (you can enable later)")
        engine.save_state(6)

    if resume_step <= 6:
        # 12. Encryption
        print(f"\n  {C.DIM}Shall I protect our conversations?{C.RESET}")
        print(
            f"  {C.DIM}Encrypt conversations, contacts, tasks, and all stored data at rest.{C.RESET}"
        )
        print(f"  {C.DIM}Data is decrypted only in memory while Familiar is running.{C.RESET}")
        print(f"  {C.DIM}If you lose the passphrase, the data is unrecoverable.{C.RESET}\n")

        encrypt_choice = prompt_choice(
            "Enable encryption at rest?",
            [
                ("yes", "Encrypt all data stores (recommended)"),
                ("no", "Skip — you can enable later"),
            ],
            default="yes",
        )

        if encrypt_choice == "yes":
            import secrets as _enc_secrets

            default_passphrase = _enc_secrets.token_urlsafe(16)
            enc_passphrase = prompt_string(
                "Encryption passphrase (or press Enter for auto-generated)",
                default=default_passphrase,
                secret=False,
            )
            engine.validate_encryption({"enabled": True, "passphrase": enc_passphrase})

            print(f"\n  {C.CYAN}╔══════════════════════════════════════════╗{C.RESET}")
            print(f"  {C.CYAN}║  Save this passphrase somewhere safe:    ║{C.RESET}")
            print(
                f"  {C.CYAN}║  {C.BOLD}{enc_passphrase[:38]}{C.RESET}"
                f"{C.CYAN}{'':>{40 - len(enc_passphrase[:38])}}║{C.RESET}"
            )
            print(f"  {C.CYAN}║  If lost, your data is unrecoverable.    ║{C.RESET}")
            print(f"  {C.CYAN}╚══════════════════════════════════════════╝{C.RESET}")
            C.ok("Encryption enabled")
        else:
            engine.validate_encryption({"enabled": False})
            C.ok("Encryption: off (tell Familiar 'encrypt all my data stores' anytime)")
        engine.save_state(7)

    # ═══════════════════════════════════════════════════════════
    # Phase 5: Activation
    # ═══════════════════════════════════════════════════════════

    # 14. Summary + confirm
    summary = engine.get_summary()
    print(f"\n\n{'─' * 50}")
    print(f"  {C.BOLD}Configuration Summary{C.RESET}")
    print(f"{'─' * 50}")
    print(f"  Provider:   {summary['provider']}")
    if summary.get("ollama_model"):
        print(f"  Main model: {summary['ollama_model']}")
    if summary.get("lightweight_model"):
        print(f"  Background: {summary['lightweight_model']}")
    print(f"  Channels:   {', '.join(summary['channels'])}")
    print(f"  Owner PIN:  {'*' * summary['pin_length']} ({summary['pin_length']} digits)")
    print(f"  Name:       {summary['name']}")
    print(f"  Encryption: {'enabled' if summary['encrypt_at_rest'] else 'off'}")
    briefing_str = (
        f"enabled at {summary['briefing_time']}" if summary["briefing_enabled"] else "off"
    )
    print(f"  Briefing:   {briefing_str}")
    if summary.get("heartbeat_enabled"):
        print("  Check-ins: every 4 hours (during waking hours)")
    print(f"{'─' * 50}\n")

    go = input("  Proceed? [Y/n]: ").strip().lower()
    if go == "n":
        print("\n  Cancelled. Run 'familiar --onboard' anytime.\n")
        return OnboardResult(success=False)

    # 15. engine.activate() — writes config.yaml + .env
    print(f"\n  {C.BOLD}Activating...{C.RESET}\n")

    def _cli_notify(msg: str):
        C.info(msg)

    result = engine.activate(notify=_cli_notify)
    if not result.success:
        for err in result.errors:
            C.fail(err)
        return OnboardResult(success=False)
    for warn in result.warnings:
        C.warn(warn)

    # 16. Save creature to disk (creature was created in-memory during birth)
    if engine._state.get("_creature_born"):
        colors = engine._state.get("_colors", {})
        from familiar.creature.state import create_creature, save_creature

        creature = create_creature(
            name=creature_name,
            species=engine._state.get("_species", "hedgehog"),
            color_body=colors.get("body", "#4A90D9"),
            color_eyes=colors.get("eyes", "#4A90D9"),
            color_accent=colors.get("accent", "#4A90D9"),
            owner_name=owner_name,
            constitution_accepted=True,
        )
        creature.preferred_channel = preferred_channel
        save_creature(creature)

    engine.clear_saved_state()

    # Re-source .env so the current process has the right vars
    for env_path in [project_dir / ".env", Path.home() / ".familiar" / ".env"]:
        if env_path.exists():
            for line in env_path.read_text().splitlines():
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, _, val = line.partition("=")
                    os.environ[key.strip()] = val.strip()
            break

    # 17. Done banner + channel info
    print(f"""

{C.GREEN}{"═" * 56}
  ✅  {creature_name} is ready!
{"═" * 56}{C.RESET}
""")

    checklist = engine.get_post_activation_checklist()
    print(f"  {C.BOLD}Next steps:{C.RESET}\n")
    for i, item in enumerate(checklist, 1):
        print(f"  {C.CYAN}{i}.{C.RESET} {C.BOLD}{item['title']}{C.RESET}")
        print(f"     {C.DIM}{item['detail']}{C.RESET}")
    print(f"\n  {C.DIM}Reconfigure anytime: python -m familiar --reconfigure{C.RESET}")
    print()

    # Show creature-specific channel info
    try:
        from familiar.creature.state import load_creature

        loaded_creature = load_creature()
        if loaded_creature:
            show_channel_info(preferred_channel, loaded_creature)
    except Exception as e:
        logger.debug("Error suppressed: %s", e)
    return OnboardResult(
        success=True,
        creature_name=creature_name,
        preferred_channel=preferred_channel,
    )


def _run_reconfigure(
    engine: OnboardingEngine,
    project_dir: Path,
    resume_step: int,
) -> OnboardResult:
    """Reconfigure path: skip creature phases, only run config steps."""

    # Load existing creature name if available
    creature_name = "Familiar"
    preferred_channel = "cli"
    try:
        from familiar.creature.state import load_creature

        existing = load_creature()
        if existing:
            creature_name = existing.name
            preferred_channel = existing.preferred_channel or "cli"
    except Exception as e:
        logger.debug("Error suppressed: %s", e)
    banner()

    # ── Provider ──
    if resume_step <= 0:
        print(f"  {C.BOLD}Step 1 of 5: LLM Provider{C.RESET}\n")
        detect_result = engine.detect_providers()
        providers = detect_result.data["providers"]
        provider_name, provider_config = setup_provider(providers)
        C.ok(f"Provider: {provider_name}")

        api_key = (
            provider_config.get("anthropic_key")
            or provider_config.get("openai_key")
            or provider_config.get("gemini_key")
            or ""
        )
        engine.validate_provider(
            {
                "provider": provider_name,
                "api_key": api_key,
                "ollama_model": provider_config.get("ollama_model", ""),
                "lightweight_model": provider_config.get("lightweight_model", ""),
            }
        )
        engine.save_state(1)

    # ── Channel ──
    if resume_step <= 1:
        print(f"\n\n  {C.BOLD}Step 2 of 5: Communication Channel{C.RESET}")
        channel, channel_config = setup_channel()
        preferred_channel = channel
        C.ok(f"Channel: {channel}")
        engine.validate_channels(channel_config)
        engine.save_state(2)

    # Set identity to existing creature name
    engine.validate_identity({"name": creature_name, "persona": "hospitality"})

    # ── Owner PIN ──
    if resume_step <= 2:
        print(f"\n\n  {C.BOLD}Step 3 of 5: Owner Verification{C.RESET}")
        print(f"  {C.DIM}This PIN proves you are the owner when connecting via Telegram.{C.RESET}")
        print(
            f"  {C.DIM}The first person to send /start must enter this PIN to claim ownership.{C.RESET}\n"
        )

        default_pin = OnboardingEngine.generate_default_pin()
        owner_pin = prompt_string(
            "Choose a 4-8 digit owner PIN", default=default_pin, secret=False
        )

        pin_result = engine.validate_owner_pin({"pin": owner_pin})
        while not pin_result.success:
            for err in pin_result.errors:
                print(f"  {C.DIM}{err}{C.RESET}")
            owner_pin = prompt_string(
                "Choose a 4-8 digit owner PIN", default=default_pin, secret=False
            )
            pin_result = engine.validate_owner_pin({"pin": owner_pin})

        print(f"\n  {C.CYAN}╔══════════════════════════════════════╗{C.RESET}")
        print(
            f"  {C.CYAN}║  Your owner PIN: {C.BOLD}{owner_pin}{C.RESET}"
            f"{C.CYAN}{'':>{20 - len(owner_pin)}}║{C.RESET}"
        )
        print(f"  {C.CYAN}║  Send this as your first message     ║{C.RESET}")
        print(f"  {C.CYAN}║  to claim ownership of the bot.      ║{C.RESET}")
        print(f"  {C.CYAN}╚══════════════════════════════════════╝{C.RESET}")
        C.ok(f"Owner PIN set ({len(owner_pin)} digits)")
        engine.save_state(3)

    # ── Briefing ──
    if resume_step <= 3:
        print(f"\n\n  {C.BOLD}Step 4 of 5: Proactive Briefing{C.RESET}")
        briefing_raw = setup_briefing()
        engine.validate_briefing(
            {
                "enabled": briefing_raw.get("briefing_enabled", False),
                "time": briefing_raw.get("briefing_time", "08:00"),
                "heartbeat": briefing_raw.get("heartbeat_enabled", False),
            }
        )
        briefing = engine._state.get("briefing", {})
        if briefing.get("briefing_enabled"):
            C.ok(f"Briefing at {briefing.get('briefing_time', '08:00')}")
        else:
            C.ok("Briefing: off (you can enable later)")
        engine.save_state(4)

    # ── Encryption ──
    if resume_step <= 4:
        print(f"\n\n  {C.BOLD}Step 5 of 5: Data Encryption{C.RESET}")
        print(
            f"  {C.DIM}Encrypt conversations, contacts, tasks, and all stored data at rest.{C.RESET}"
        )
        print(f"  {C.DIM}Data is decrypted only in memory while Familiar is running.{C.RESET}")
        print(f"  {C.DIM}If you lose the passphrase, the data is unrecoverable.{C.RESET}\n")

        encrypt_choice = prompt_choice(
            "Enable encryption at rest?",
            [
                ("yes", "Encrypt all data stores (recommended)"),
                ("no", "Skip — you can enable later"),
            ],
            default="yes",
        )

        if encrypt_choice == "yes":
            import secrets as _enc_secrets

            default_passphrase = _enc_secrets.token_urlsafe(16)
            enc_passphrase = prompt_string(
                "Encryption passphrase (or press Enter for auto-generated)",
                default=default_passphrase,
                secret=False,
            )
            engine.validate_encryption({"enabled": True, "passphrase": enc_passphrase})

            print(f"\n  {C.CYAN}╔══════════════════════════════════════════╗{C.RESET}")
            print(f"  {C.CYAN}║  Save this passphrase somewhere safe:    ║{C.RESET}")
            print(
                f"  {C.CYAN}║  {C.BOLD}{enc_passphrase[:38]}{C.RESET}"
                f"{C.CYAN}{'':>{40 - len(enc_passphrase[:38])}}║{C.RESET}"
            )
            print(f"  {C.CYAN}║  If lost, your data is unrecoverable.    ║{C.RESET}")
            print(f"  {C.CYAN}╚══════════════════════════════════════════╝{C.RESET}")
            C.ok("Encryption enabled")
        else:
            engine.validate_encryption({"enabled": False})
            C.ok("Encryption: off (tell Familiar 'encrypt all my data stores' anytime)")
        engine.save_state(5)

    # ── Summary ──
    summary = engine.get_summary()
    print(f"\n\n{'─' * 50}")
    print(f"  {C.BOLD}Configuration Summary{C.RESET}")
    print(f"{'─' * 50}")
    print(f"  Provider:   {summary['provider']}")
    if summary.get("ollama_model"):
        print(f"  Main model: {summary['ollama_model']}")
    if summary.get("lightweight_model"):
        print(f"  Background: {summary['lightweight_model']}")
    print(f"  Channels:   {', '.join(summary['channels'])}")
    print(f"  Owner PIN:  {'*' * summary['pin_length']} ({summary['pin_length']} digits)")
    print(f"  Name:       {summary['name']}")
    print(f"  Encryption: {'enabled' if summary['encrypt_at_rest'] else 'off'}")
    briefing_str = (
        f"enabled at {summary['briefing_time']}" if summary["briefing_enabled"] else "off"
    )
    print(f"  Briefing:   {briefing_str}")
    if summary.get("heartbeat_enabled"):
        print("  Check-ins: every 4 hours (during waking hours)")
    print(f"{'─' * 50}\n")

    go = input("  Proceed? [Y/n]: ").strip().lower()
    if go == "n":
        print("\n  Cancelled. Run 'familiar --reconfigure' anytime.\n")
        return OnboardResult(success=False)

    # ── Activate ──
    print(f"\n  {C.BOLD}Activating...{C.RESET}\n")

    def _cli_notify(msg: str):
        C.info(msg)

    result = engine.activate(notify=_cli_notify)
    if not result.success:
        for err in result.errors:
            C.fail(err)
        return OnboardResult(success=False)
    for warn in result.warnings:
        C.warn(warn)

    # Update creature's preferred_channel if changed
    try:
        from familiar.creature.state import load_creature, save_creature

        existing = load_creature()
        if existing:
            existing.preferred_channel = preferred_channel
            save_creature(existing)
    except Exception as e:
        logger.debug("Error suppressed: %s", e)
    engine.clear_saved_state()

    # Re-source .env
    for env_path in [project_dir / ".env", Path.home() / ".familiar" / ".env"]:
        if env_path.exists():
            for line in env_path.read_text().splitlines():
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, _, val = line.partition("=")
                    os.environ[key.strip()] = val.strip()
            break

    print(f"\n{C.GREEN}{'═' * 56}")
    print("  ✅  Configuration updated!")
    print(f"{'═' * 56}{C.RESET}\n")
    print(f"  {C.GREEN}Restart Familiar to apply changes.{C.RESET}\n")

    return OnboardResult(
        success=True,
        creature_name=creature_name,
        preferred_channel=preferred_channel,
    )


# ── Entry Point ───────────────────────────────────────────────────────


def main():
    try:
        run_onboard()
    except KeyboardInterrupt:
        print(f"\n\n  {C.YELLOW}Setup cancelled.{C.RESET}\n")
        sys.exit(0)


if __name__ == "__main__":
    main()
