#!/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
=========================

Install → "my AI texted me" in under 5 minutes.

Flow:
    1. Detect available LLM providers
    2. User picks a channel (Telegram/Discord/WhatsApp/Signal/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 os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple

# ── 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:
            pass
        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


# ── Provider Detection ────────────────────────────────────────────────


def detect_providers() -> dict:
    """Detect available LLM providers. Returns dict of provider → info."""
    providers = {}

    # Anthropic
    key = os.environ.get("ANTHROPIC_API_KEY", "")
    if key:
        providers["anthropic"] = {"key": key, "label": "Claude (API key detected)"}

    # OpenAI
    key = os.environ.get("OPENAI_API_KEY", "")
    if key:
        providers["openai"] = {"key": key, "label": "GPT (API key detected)"}

    # Ollama
    try:
        result = subprocess.run(["ollama", "list"], capture_output=True, timeout=5, text=True)
        if result.returncode == 0:
            models = [
                line.split()[0] for line in result.stdout.strip().split("\n")[1:] if line.strip()
            ]
            providers["ollama"] = {
                "models": models,
                "label": f"Ollama ({len(models)} model{'s' if len(models) != 1 else ''} installed)"
                if models
                else "Ollama (installed, no models yet)",
            }
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass

    return providers


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

    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 == "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):
            pass

        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):
                        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 ────────────────────────────────────
        # Always offer model choices, even if some are installed.
        # Three paths: Bundle (recommended), Custom multi-select, Skip.
        ollama_info = providers.get("ollama", {})
        existing_models = ollama_info.get("models", [])
        ram_gb = _get_ram_gb()

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

        # Determine recommended bundle based on hardware
        is_pi = ram_gb < 4
        has_embedding = any("nomic" in m or "embed" in m for m in existing_models)

        if is_pi:
            bundle_label = "Pi Bundle"
            bundle_models = ["qwen2.5:0.5b", "nomic-embed-text"]
            bundle_desc = "qwen2.5:0.5b + nomic-embed-text — ~540MB total"
        elif ram_gb < 8:
            bundle_label = "Standard Bundle"
            bundle_models = ["llama3.2", "qwen2.5:0.5b", "nomic-embed-text"]
            bundle_desc = "llama3.2 (main) + qwen2.5:0.5b (background) + nomic-embed-text — ~2.9GB"
        else:
            bundle_label = "Full Bundle"
            bundle_models = ["qwen2.5:7b", "qwen2.5:0.5b", "nomic-embed-text"]
            bundle_desc = (
                "qwen2.5:7b (main) + qwen2.5:0.5b (background) + nomic-embed-text — ~5.5GB"
            )

        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}")
                    try:
                        subprocess.run(["ollama", "pull", model], check=True, timeout=600)
                        C.ok(f"{model} ready")
                    except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                        C.warn(f"Failed to download {model}. Run manually: ollama pull {model}")

            # 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 = [
                ("smollm2:135m", "SmolLM2 135M — 80MB, tiny"),
                ("qwen2.5:0.5b", "Qwen 0.5B — 400MB, Pi-friendly"),
                ("qwen2.5:1.5b", "Qwen 1.5B — 1GB, good balance"),
                ("llama3.2", "Llama 3.2 3B — 2GB, general purpose"),
                ("qwen2.5:3b", "Qwen 3B — 2GB, good balance"),
                ("mistral", "Mistral 7B — 4GB, strong reasoning"),
                ("qwen2.5:7b", "Qwen 7B — 5GB, best local quality"),
                ("llama3.1:8b", "Llama 3.1 8B — 5GB, latest Meta"),
                ("gemma2:9b", "Gemma 2 9B — 6GB, Google"),
                ("nomic-embed-text", "Nomic Embed — 137MB, semantic search"),
            ]

            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}")
                    try:
                        subprocess.run(["ollama", "pull", model], check=True, timeout=600)
                        C.ok(f"{model} ready")
                    except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                        C.warn(f"Failed. Run manually: ollama pull {model}")

            # 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}")
                try:
                    subprocess.run(["ollama", "pull", "nomic-embed-text"], check=True, timeout=300)
                    C.ok("nomic-embed-text ready — semantic search enabled")
                except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                    C.warn("Download failed. Run manually: ollama pull nomic-embed-text")

    return choice, config


def _get_ram_gb() -> float:
    try:
        with open("/proc/meminfo") as f:
            for line in f:
                if line.startswith("MemTotal"):
                    return int(line.split()[1]) / 1024 / 1024
    except (FileNotFoundError, ValueError):
        pass
    try:
        result = subprocess.run(
            ["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=3
        )
        return int(result.stdout.strip()) / 1024 / 1024 / 1024
    except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
        return 8.0  # Assume reasonable default


# ── 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"),
        ("whatsapp", "💬 WhatsApp"),
        ("signal", "🔒 Signal"),
        ("matrix", "🔗 Matrix / Element (decentralized, E2EE)"),
        ("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 == "whatsapp":
            print(f"""
  {C.BOLD}WhatsApp Setup:{C.RESET}

    WhatsApp requires the Node.js bridge (whatsapp-web.js).
    After onboarding, Familiar will start the bridge and show a QR code
    to scan with your phone.
""")
            config["whatsapp_enabled"] = True

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

    Signal requires signal-cli. After onboarding, run:
      signal-cli link -n "Familiar"
    Then scan the QR code with your Signal app.
""")
            config["signal_enabled"] = True

        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: pip install matrix-nio[e2e]
""")
            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)", required=False
            )
            if owner:
                config["owner_matrix_id"] = owner

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

    persona_map = {
        "hospitality": "a thoughtful AI assistant grounded in hospitality consciousness — warm, anticipatory, and service-first",
        "efficient": "an efficient AI assistant — concise, direct, focused on getting things done",
        "friendly": "a friendly AI assistant — casual, warm, conversational",
        "formal": "a thorough AI assistant — detailed, well-structured, professional",
    }

    return {"name": name, "persona": persona_map[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")
            # Validate HH:MM format
            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


# ── Write Config ──────────────────────────────────────────────────────


def write_config(
    provider_config: dict,
    channel: str,
    channel_config: dict,
    identity: dict,
    briefing: dict,
    project_dir: Path,
):
    """Write config.yaml and .env."""

    # ── config.yaml ──
    config_path = project_dir / "config.yaml"
    # Only write if doesn't exist or user confirms overwrite
    if config_path.exists():
        overwrite = input("\n  config.yaml exists. Overwrite? [y/N]: ").strip().lower()
        if overwrite != "y":
            C.info("Keeping existing config.yaml — updating .env only")
            _write_env(provider_config, channel_config, project_dir)
            return

    provider = provider_config["provider"]
    model = ""
    if provider == "anthropic":
        model = "claude-sonnet-4-20250514"
    elif provider == "openai":
        model = "gpt-4o"
    elif provider == "ollama":
        model = provider_config.get("ollama_model", "llama3.2")

    # Build model config line based on provider
    model_field = "ollama_model"
    if provider == "anthropic":
        model_field = "anthropic_model"
    elif provider == "openai":
        model_field = "openai_model"

    selected_channels = channel_config.get("channels", [channel])

    yaml_content = f"""# Familiar Configuration
# Generated by onboarding wizard — {datetime.now().strftime("%Y-%m-%d %H:%M")}

agent:
  name: "{identity["name"]}"
  persona: "{identity["persona"]}"
  memory_enabled: true
  skills_enabled: true
  scheduler_enabled: true
  heartbeat_interval_minutes: {240 if briefing.get("heartbeat_enabled") else 0}
  security_mode: balanced

llm:
  default_provider: {provider}
  {model_field}: {model}

channels:
  telegram_enabled: {str("telegram" in selected_channels).lower()}
  discord_enabled: {str("discord" in selected_channels).lower()}
  whatsapp_enabled: {str("whatsapp" in selected_channels).lower()}
  signal_enabled: {str("signal" in selected_channels).lower()}
  matrix_enabled: {str("matrix" in selected_channels).lower()}
  cli_enabled: {str("cli" in selected_channels).lower()}

proactive:
  briefing_enabled: {str(briefing.get("briefing_enabled", False)).lower()}
  briefing_time: "{briefing.get("briefing_time", "08:00")}"
  heartbeat_enabled: {str(briefing.get("heartbeat_enabled", False)).lower()}
  quiet_hours_start: "22:00"
  quiet_hours_end: "07:00"
"""
    config_path.write_text(yaml_content)
    C.ok(f"Config written: {config_path}")

    _write_env(provider_config, channel_config, project_dir)


def _write_env(provider_config: dict, channel_config: dict, project_dir: Path):
    """Write or merge .env file with secrets."""
    new_vars = {}
    new_vars["LLM_DEFAULT_PROVIDER"] = provider_config["provider"]
    new_vars["DEFAULT_PROVIDER"] = provider_config["provider"]

    if provider_config.get("anthropic_key"):
        new_vars["ANTHROPIC_API_KEY"] = provider_config["anthropic_key"]
    if provider_config.get("openai_key"):
        new_vars["OPENAI_API_KEY"] = provider_config["openai_key"]
    if provider_config.get("ollama_model"):
        new_vars["OLLAMA_MODEL"] = provider_config["ollama_model"]
    if provider_config.get("lightweight_model"):
        new_vars["FAMILIAR_LIGHTWEIGHT_MODEL"] = provider_config["lightweight_model"]
    if provider_config.get("ENCRYPTION_ENABLED"):
        new_vars["ENCRYPTION_ENABLED"] = provider_config["ENCRYPTION_ENABLED"]
    if provider_config.get("ENCRYPTION_PASSPHRASE"):
        new_vars["ENCRYPTION_PASSPHRASE"] = provider_config["ENCRYPTION_PASSPHRASE"]
    if channel_config.get("telegram_token"):
        new_vars["TELEGRAM_BOT_TOKEN"] = channel_config["telegram_token"]
    if channel_config.get("discord_token"):
        new_vars["DISCORD_BOT_TOKEN"] = channel_config["discord_token"]
    if channel_config.get("whatsapp_enabled"):
        new_vars["WHATSAPP_ENABLED"] = "true"
    if channel_config.get("signal_enabled"):
        new_vars["SIGNAL_ENABLED"] = "true"
    if channel_config.get("matrix_homeserver"):
        new_vars["MATRIX_HOMESERVER"] = channel_config["matrix_homeserver"]
    if channel_config.get("matrix_user"):
        new_vars["MATRIX_USER"] = channel_config["matrix_user"]
    if channel_config.get("matrix_password"):
        new_vars["MATRIX_PASSWORD"] = channel_config["matrix_password"]
    if channel_config.get("matrix_access_token"):
        new_vars["MATRIX_ACCESS_TOKEN"] = channel_config["matrix_access_token"]
    if channel_config.get("owner_matrix_id"):
        new_vars["OWNER_MATRIX_ID"] = channel_config["owner_matrix_id"]
    if channel_config.get("channels"):
        new_vars["ENABLED_CHANNELS"] = ",".join(channel_config["channels"])
    if provider_config.get("owner_pin_hash"):
        new_vars["OWNER_PIN_HASH"] = provider_config["owner_pin_hash"]

    # Merge with existing .env (don't erase keys set via /connect)
    env_path = project_dir / ".env"
    existing_vars = {}
    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("=")
                existing_vars[key.strip()] = val.strip()

    # Clean out tokens for channels NOT selected in this run
    # This prevents stale tokens from auto-activating channels
    selected_channels = channel_config.get("channels", [])
    if selected_channels:
        if "telegram" not in selected_channels:
            existing_vars.pop("TELEGRAM_BOT_TOKEN", None)
            existing_vars.pop("OWNER_TELEGRAM_ID", None)
        if "discord" not in selected_channels:
            existing_vars.pop("DISCORD_BOT_TOKEN", None)

    # New values overwrite existing for the same keys
    existing_vars.update(new_vars)

    env_lines = [f"# Familiar — generated {datetime.now().strftime('%Y-%m-%d %H:%M')}"]
    for key, val in existing_vars.items():
        env_lines.append(f"{key}={val}")

    env_path.write_text("\n".join(env_lines) + "\n")
    env_path.chmod(0o600)
    C.ok(f"Secrets written: {env_path} (chmod 600)")

    # Also write to ~/.familiar/.env
    home_env = Path.home() / ".familiar" / ".env"
    home_env.parent.mkdir(parents=True, exist_ok=True)
    if home_env != env_path:
        # Merge home .env too
        home_vars = {}
        if home_env.exists():
            for line in home_env.read_text().splitlines():
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, _, val = line.partition("=")
                    home_vars[key.strip()] = val.strip()
        home_vars.update(new_vars)

        home_lines = [f"# Familiar — generated {datetime.now().strftime('%Y-%m-%d %H:%M')}"]
        for key, val in home_vars.items():
            home_lines.append(f"{key}={val}")
        home_env.write_text("\n".join(home_lines) + "\n")
        home_env.chmod(0o600)


# ── 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"):
        print(f"\n  {C.DIM}Discord test message will be sent when the bot starts.")
        print(f"  Send /start in your server to receive it.{C.RESET}")
        return True

    # WhatsApp / Signal — deferred until bridge starts
    print(f"\n  {C.DIM}{channel.title()} test message will be sent after bridge setup.{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:
                pass

            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 _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):
    """Run the complete onboarding flow."""

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

    banner()

    # ── Step 1: Provider Detection ──
    print(f"  {C.BOLD}Step 1 of 7: LLM Provider{C.RESET}\n")
    providers = detect_providers()
    provider_name, provider_config = setup_provider(providers)
    C.ok(f"Provider: {provider_name}")

    # ── Step 2: Channel ──
    print(f"\n\n  {C.BOLD}Step 2 of 7: Communication Channel{C.RESET}")
    channel, channel_config = setup_channel()
    C.ok(f"Channel: {channel}")

    # ── Step 3: Owner Security ──
    print(f"\n\n  {C.BOLD}Step 3 of 7: 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"
    )

    import secrets

    default_pin = f"{secrets.randbelow(10000):04d}"
    owner_pin = prompt_string("Choose a 4-8 digit owner PIN", default=default_pin, secret=False)

    # Validate: digits only, 4-8 chars
    while not owner_pin.isdigit() or len(owner_pin) < 4 or len(owner_pin) > 8:
        print(f"  {C.DIM}PIN must be 4-8 digits{C.RESET}")
        owner_pin = prompt_string("Choose a 4-8 digit owner PIN", default=default_pin, secret=False)

    # Hash the PIN for storage with PBKDF2 (salted, 100K iterations)
    import hashlib
    import secrets as _secrets

    pin_salt = _secrets.token_hex(16)
    owner_pin_hash = hashlib.pbkdf2_hmac(
        "sha256", owner_pin.encode(), pin_salt.encode(), 100_000
    ).hex()
    provider_config["owner_pin_hash"] = f"{pin_salt}:{owner_pin_hash}"

    print(f"\n  {C.CYAN}╔══════════════════════════════════════╗{C.RESET}")
    print(
        f"  {C.CYAN}║  Your owner PIN: {C.BOLD}{owner_pin}{C.RESET}{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)")

    # ── Step 4: Identity ──
    print(f"\n\n  {C.BOLD}Step 4 of 7: Name Your Assistant{C.RESET}")
    identity = setup_identity()
    C.ok(f"Name: {identity['name']}")

    # ── Step 5: Proactive Briefing ──
    print(f"\n\n  {C.BOLD}Step 5 of 7: Proactive Briefing{C.RESET}")
    briefing = setup_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)")

    # ── Step 6: Encryption at Rest ──
    print(f"\n\n  {C.BOLD}Step 6 of 7: 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"),
        ],
    )

    encrypt_config = {"encrypt_at_rest": False}
    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,
        )
        encrypt_config["encrypt_at_rest"] = True
        encrypt_config["encryption_passphrase"] = enc_passphrase
        provider_config["ENCRYPTION_ENABLED"] = "true"
        provider_config["ENCRYPTION_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}{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:
        C.ok("Encryption: off (tell Familiar 'encrypt all my data stores' anytime)")

    # ── Summary ──
    print(f"\n\n{'─' * 50}")
    print(f"  {C.BOLD}Configuration Summary{C.RESET}")
    print(f"{'─' * 50}")
    print(f"  Provider:   {provider_name}")
    if provider_config.get("ollama_model"):
        print(f"  Main model: {provider_config['ollama_model']}")
    if provider_config.get("lightweight_model"):
        print(f"  Background: {provider_config['lightweight_model']}")
    selected_channels = channel_config.get("channels", [channel])
    print(f"  Channels:   {', '.join(selected_channels)}")
    print(f"  Owner PIN:  {'*' * len(owner_pin)} ({len(owner_pin)} digits)")
    print(f"  Name:       {identity['name']}")
    print(f"  Encryption: {'enabled' if encrypt_config.get('encrypt_at_rest') else 'off'}")
    print(
        f"  Briefing:   {'enabled at ' + briefing.get('briefing_time', '08:00') if briefing.get('briefing_enabled') else 'off'}"
    )
    if briefing.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 False

    # ── Step 6: Write Config + Test ──
    print(f"\n  {C.BOLD}Step 7 of 7: Activating{C.RESET}\n")

    # ── Install dependencies ──
    print(f"  {C.CYAN}Installing dependencies...{C.RESET}")

    # Core: always required
    core_deps = [
        "pyyaml",
        "pydantic",
        "httpx",
        "cryptography",
    ]

    # Channel-specific
    selected_channels = channel_config.get("channels", [channel])
    channel_deps = []
    if "telegram" in selected_channels:
        channel_deps.append("python-telegram-bot>=21.0")
    if "discord" in selected_channels:
        channel_deps.append("discord.py>=2.3.0")

    # Provider-specific
    provider_deps = []
    if provider_name == "anthropic" or provider_config.get("anthropic_key"):
        provider_deps.append("anthropic>=0.40.0")
    if provider_name == "openai" or provider_config.get("openai_key"):
        provider_deps.append("openai>=1.0.0")
    if provider_name == "ollama":
        provider_deps.append("openai>=1.0.0")  # Ollama uses OpenAI-compatible client

    # Skill essentials (commonly used skills that people expect to work)
    skill_deps = [
        "beautifulsoup4",  # websearch, browser
        "aiosmtpd",  # email_server
        "python-docx",  # documents
        "openpyxl",  # documents (Excel)
        "Pillow",  # image handling
        "markdown",  # knowledge, documents
    ]

    # Optional but recommended
    optional_deps = {
        "google-auth-oauthlib google-auth-httplib2 google-api-python-client": "Google Workspace",
        "openai-whisper": "Voice transcription",
        "pyttsx3": "Text-to-speech",
        "playwright": "Web browser automation",
        "flask flask-socketio flask-cors": "Web dashboard",
    }

    all_required = core_deps + channel_deps + provider_deps + skill_deps

    # Install required deps
    result = subprocess.run(
        ["pip", "install", "--break-system-packages", "-q"] + all_required,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        timeout=300,
    )
    if result.returncode == 0:
        C.ok(f"Core dependencies installed ({len(all_required)} packages)")
    else:
        err = result.stderr.decode(errors="replace").strip()
        C.warn(
            f"Some packages failed. Run manually: pip install {' '.join(all_required)} --break-system-packages"
        )
        if err:
            C.warn(f"pip error: {err[-400:]}")

    # Ask about optional deps
    print(f"\n  {C.BOLD}Optional packages:{C.RESET}")
    for pkgs, desc in optional_deps.items():
        # Check if already installed
        first_pkg = pkgs.split()[0].replace("-", "_").lower()
        try:
            __import__(first_pkg.split("_")[0] if "google" not in first_pkg else "google")
            C.ok(f"{desc} — already installed")
            continue
        except ImportError:
            pass

        install = input(f"  Install {desc}? ({pkgs}) [y/N]: ").strip().lower()
        if install == "y":
            result = subprocess.run(
                ["pip", "install", "--break-system-packages", "-q"] + pkgs.split(),
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                timeout=300,
            )
            if result.returncode == 0:
                C.ok(f"{desc} installed")
            else:
                err = result.stderr.decode(errors="replace").strip()
                C.warn(f"Failed. Run manually: pip install {pkgs} --break-system-packages")
                if err:
                    C.warn(f"pip error: {err[-300:]}")

    print()

    # Ensure .familiar directory
    familiar_dir = Path.home() / ".familiar"
    familiar_dir.mkdir(parents=True, exist_ok=True)
    (familiar_dir / "data").mkdir(exist_ok=True)
    (familiar_dir / "skills").mkdir(exist_ok=True)

    # Write config to project dir AND ~/.familiar/
    write_config(provider_config, channel, channel_config, identity, briefing, project_dir)

    # Copy config.yaml to ~/.familiar/ so the agent can find it
    home_config = Path.home() / ".familiar" / "config.yaml"
    project_config = project_dir / "config.yaml"
    if project_config.exists() and home_config != project_config:
        shutil.copy2(project_config, home_config)

    # Initialize encryption keys and mesh config
    try:
        from familiar.core.config import CONFIG_FILE, save_default_config

        if not CONFIG_FILE.exists():
            save_default_config()
        from familiar.core.mesh import MeshConfig

        mesh = MeshConfig()
        mesh.save()
        C.ok("Encryption keys generated")
    except ImportError:
        pass  # Fine — running outside full install

    # Send test message (Instant Win — Article 1)
    print()
    send_test_message(channel, channel_config, identity, briefing)

    # ── Done ──
    print(f"""

{C.GREEN}{"═" * 56}
  ✅  {identity["name"]} is ready!
{"═" * 56}{C.RESET}
""")

    if channel == "telegram":
        print("  Start with:  python -m familiar --telegram")
    elif channel == "discord":
        print("  Start with:  python -m familiar --discord")
    else:
        print("  Start with:  python -m familiar")

    print("  Or use:      ./run.sh")
    print("  Reconfigure: python -m familiar --onboard")
    print()

    auto = input("  Launch now? [Y/n]: ").strip().lower()
    if auto == "n":
        print(f"\n  {C.DIM}Run the command above when you're ready.{C.RESET}\n")
        return True

    print(f"\n  Starting {identity['name']}...\n")

    # 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

    # Build the launch command
    launch_args = [sys.executable, "-m", "familiar"]
    if channel == "telegram":
        launch_args.append("--telegram")
    elif channel == "discord":
        launch_args.append("--discord")

    # exec replaces this process with the agent
    os.execvp(sys.executable, launch_args)

    return True


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