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

A friendly GUI installer that walks users through setup.
Cross-platform: Windows, macOS, Linux

Build to executable:
    Windows: pyinstaller --onefile --windowed --icon=familiar.ico --name="Familiar Setup" installer/wizard.py
    macOS:   pyinstaller --onefile --windowed --icon=familiar.icns --name="Familiar Setup" installer/wizard.py
    Linux:   pyinstaller --onefile --windowed --name="familiar-setup" installer/wizard.py
"""

import json
import os
import platform
import shutil
import subprocess
import sys
import threading
import tkinter as tk
import urllib.error
import urllib.request
import webbrowser
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

# ============================================================================
# Configuration
# ============================================================================

APP_NAME = "Familiar"
APP_VERSION = "1.10.0"
GITHUB_REPO = "familiar-ai/familiar"
MIN_PYTHON_VERSION = (3, 10)

# Default paths by OS
if platform.system() == "Windows":
    DEFAULT_INSTALL_DIR = Path(os.environ.get("LOCALAPPDATA", "C:\\")) / "Familiar"
    DEFAULT_DATA_DIR = DEFAULT_INSTALL_DIR / "data"
    PYTHON_CMD = "python"
elif platform.system() == "Darwin":  # macOS
    DEFAULT_INSTALL_DIR = Path.home() / "Applications" / "Familiar"
    DEFAULT_DATA_DIR = Path.home() / "Library" / "Application Support" / "Familiar"
    PYTHON_CMD = "python3"
else:  # Linux
    DEFAULT_INSTALL_DIR = Path.home() / ".local" / "share" / "familiar"
    DEFAULT_DATA_DIR = Path.home() / ".familiar"
    PYTHON_CMD = "python3"


# ============================================================================
# Styles
# ============================================================================

COLORS = {
    "bg": "#1a1a2e",
    "fg": "#eaeaea",
    "accent": "#7b68ee",  # Medium slate blue (familiar purple)
    "accent_hover": "#9683ec",
    "success": "#4ade80",
    "warning": "#fbbf24",
    "error": "#f87171",
    "input_bg": "#2d2d44",
    "input_border": "#4a4a6a",
}


def configure_styles():
    """Configure ttk styles for modern look."""
    style = ttk.Style()

    # Try to use a modern theme
    available_themes = style.theme_names()
    if "clam" in available_themes:
        style.theme_use("clam")

    # Configure colors
    style.configure(
        ".",
        background=COLORS["bg"],
        foreground=COLORS["fg"],
        font=("Segoe UI", 11) if platform.system() == "Windows" else ("SF Pro Display", 11),
    )

    style.configure("TFrame", background=COLORS["bg"])
    style.configure("TLabel", background=COLORS["bg"], foreground=COLORS["fg"])
    style.configure(
        "TButton",
        background=COLORS["accent"],
        foreground="white",
        padding=(20, 10),
        font=("Segoe UI Semibold", 11)
        if platform.system() == "Windows"
        else ("SF Pro Display", 11, "bold"),
    )
    style.map(
        "TButton",
        background=[("active", COLORS["accent_hover"]), ("disabled", "#555")],
        foreground=[("disabled", "#888")],
    )

    style.configure(
        "TEntry", fieldbackground=COLORS["input_bg"], foreground=COLORS["fg"], padding=10
    )

    style.configure("TCheckbutton", background=COLORS["bg"], foreground=COLORS["fg"])

    style.configure(
        "Header.TLabel",
        font=("Segoe UI", 24, "bold")
        if platform.system() == "Windows"
        else ("SF Pro Display", 24, "bold"),
        foreground=COLORS["accent"],
    )

    style.configure(
        "Subheader.TLabel",
        font=("Segoe UI", 12) if platform.system() == "Windows" else ("SF Pro Display", 12),
        foreground="#aaa",
    )

    # Progress bar
    style.configure(
        "Accent.Horizontal.TProgressbar",
        troughcolor=COLORS["input_bg"],
        background=COLORS["accent"],
    )


# ============================================================================
# Wizard Pages
# ============================================================================


class WizardPage(ttk.Frame):
    """Base class for wizard pages."""

    def __init__(self, parent, wizard):
        super().__init__(parent)
        self.wizard = wizard
        self.configure(padding=40)

    def on_enter(self):
        """Called when page becomes active."""
        pass

    def on_leave(self) -> bool:
        """Called when leaving page. Return False to prevent navigation."""
        return True

    def validate(self) -> bool:
        """Validate page data. Return False to prevent Next."""
        return True


class WelcomePage(WizardPage):
    """Welcome / introduction page."""

    def __init__(self, parent, wizard):
        super().__init__(parent, wizard)

        # Logo placeholder (would be actual image in production)
        logo_frame = ttk.Frame(self)
        logo_frame.pack(pady=(0, 20))

        logo_text = tk.Label(
            logo_frame,
            text="🦔",
            font=("Segoe UI Emoji", 64)
            if platform.system() == "Windows"
            else ("Apple Color Emoji", 64),
            bg=COLORS["bg"],
        )
        logo_text.pack()

        # Title
        title = ttk.Label(self, text=f"Welcome to {APP_NAME}", style="Header.TLabel")
        title.pack(pady=(0, 10))

        # Subtitle
        subtitle = ttk.Label(
            self,
            text="Your personal AI assistant that actually does things",
            style="Subheader.TLabel",
        )
        subtitle.pack(pady=(0, 30))

        # Features
        features = [
            "🔒  Enterprise-grade security with progressive trust",
            "💬  Connect via Telegram, Discord, WhatsApp & more",
            "🧠  Persistent memory across conversations",
            "⚡  17 built-in skills, extensible with custom skills",
            "🏥  HIPAA, SOC2, PCI-DSS compliance ready",
        ]

        for feature in features:
            feat_label = ttk.Label(self, text=feature, font=("Segoe UI", 11))
            feat_label.pack(anchor="w", pady=3)

        # Version info
        version_label = ttk.Label(self, text=f"Version {APP_VERSION}", foreground="#666")
        version_label.pack(side="bottom", pady=(20, 0))


class LicensePage(WizardPage):
    """License agreement page."""

    def __init__(self, parent, wizard):
        super().__init__(parent, wizard)

        title = ttk.Label(self, text="License Agreement", style="Header.TLabel")
        title.pack(pady=(0, 20))

        # License text
        license_frame = ttk.Frame(self)
        license_frame.pack(fill="both", expand=True, pady=(0, 20))

        self.license_text = tk.Text(
            license_frame,
            wrap="word",
            bg=COLORS["input_bg"],
            fg=COLORS["fg"],
            font=("Consolas", 10) if platform.system() == "Windows" else ("Monaco", 10),
            padx=15,
            pady=15,
            height=15,
        )
        self.license_text.pack(fill="both", expand=True, side="left")

        scrollbar = ttk.Scrollbar(license_frame, command=self.license_text.yview)
        scrollbar.pack(side="right", fill="y")
        self.license_text.config(yscrollcommand=scrollbar.set)

        # Insert license text
        license_content = """MIT License

Copyright (c) 2026 Familiar Contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

SECURITY NOTICE

Familiar is an AI agent with system access capabilities. By installing and using
this software, you acknowledge that:

1. You are responsible for configuring appropriate security settings
2. API keys and credentials should be kept secure
3. The software should be run with minimal necessary privileges
4. Regular security updates should be applied

For regulated industries (healthcare, finance), please consult the compliance
documentation before deployment.
"""
        self.license_text.insert("1.0", license_content)
        self.license_text.config(state="disabled")

        # Agreement checkbox
        self.agree_var = tk.BooleanVar(value=False)
        agree_check = ttk.Checkbutton(
            self,
            text="I accept the license agreement",
            variable=self.agree_var,
            command=self._on_agree_change,
        )
        agree_check.pack(anchor="w")

    def _on_agree_change(self):
        self.wizard.update_navigation()

    def validate(self) -> bool:
        if not self.agree_var.get():
            messagebox.showwarning(
                "License Agreement", "Please accept the license agreement to continue."
            )
            return False
        return True


class InstallLocationPage(WizardPage):
    """Installation location selection."""

    def __init__(self, parent, wizard):
        super().__init__(parent, wizard)

        title = ttk.Label(self, text="Installation Location", style="Header.TLabel")
        title.pack(pady=(0, 10))

        subtitle = ttk.Label(
            self, text="Choose where to install Familiar", style="Subheader.TLabel"
        )
        subtitle.pack(pady=(0, 30))

        # Install directory
        install_frame = ttk.Frame(self)
        install_frame.pack(fill="x", pady=(0, 20))

        ttk.Label(install_frame, text="Install Directory:").pack(anchor="w")

        path_frame = ttk.Frame(install_frame)
        path_frame.pack(fill="x", pady=(5, 0))

        self.install_path = tk.StringVar(value=str(DEFAULT_INSTALL_DIR))
        install_entry = ttk.Entry(path_frame, textvariable=self.install_path, width=50)
        install_entry.pack(side="left", fill="x", expand=True)

        browse_btn = ttk.Button(
            path_frame, text="Browse...", command=lambda: self._browse("install")
        )
        browse_btn.pack(side="right", padx=(10, 0))

        # Data directory
        data_frame = ttk.Frame(self)
        data_frame.pack(fill="x", pady=(0, 20))

        ttk.Label(data_frame, text="Data Directory:").pack(anchor="w")

        data_path_frame = ttk.Frame(data_frame)
        data_path_frame.pack(fill="x", pady=(5, 0))

        self.data_path = tk.StringVar(value=str(DEFAULT_DATA_DIR))
        data_entry = ttk.Entry(data_path_frame, textvariable=self.data_path, width=50)
        data_entry.pack(side="left", fill="x", expand=True)

        data_browse_btn = ttk.Button(
            data_path_frame, text="Browse...", command=lambda: self._browse("data")
        )
        data_browse_btn.pack(side="right", padx=(10, 0))

        # Space required
        space_label = ttk.Label(self, text="Space required: ~500 MB", foreground="#888")
        space_label.pack(anchor="w", pady=(20, 0))

        # Store in wizard config
        self.wizard.setup_config["install_dir"] = self.install_path.get()
        self.wizard.setup_config["data_dir"] = self.data_path.get()

    def _browse(self, target: str):
        path = filedialog.askdirectory()
        if path:
            if target == "install":
                self.install_path.set(path)
            else:
                self.data_path.set(path)

    def on_leave(self) -> bool:
        self.wizard.setup_config["install_dir"] = self.install_path.get()
        self.wizard.setup_config["data_dir"] = self.data_path.get()
        return True


class APIKeyPage(WizardPage):
    """API key configuration page."""

    def __init__(self, parent, wizard):
        super().__init__(parent, wizard)

        title = ttk.Label(self, text="AI Provider Setup", style="Header.TLabel")
        title.pack(pady=(0, 10))

        subtitle = ttk.Label(
            self,
            text="Configure your AI provider (at least one required)",
            style="Subheader.TLabel",
        )
        subtitle.pack(pady=(0, 30))

        # Provider selection
        self.provider_var = tk.StringVar(value="anthropic")

        providers_frame = ttk.Frame(self)
        providers_frame.pack(fill="x", pady=(0, 20))

        # Anthropic
        anthropic_frame = ttk.Frame(providers_frame)
        anthropic_frame.pack(fill="x", pady=5)

        ttk.Radiobutton(
            anthropic_frame,
            text="Anthropic Claude (Recommended)",
            variable=self.provider_var,
            value="anthropic",
        ).pack(side="left")

        get_key_btn = ttk.Button(
            anthropic_frame,
            text="Get API Key →",
            command=lambda: webbrowser.open("https://console.anthropic.com/"),
        )
        get_key_btn.pack(side="right")

        self.anthropic_key = tk.StringVar()
        anthropic_entry = ttk.Entry(
            providers_frame, textvariable=self.anthropic_key, width=60, show="•"
        )
        anthropic_entry.pack(fill="x", pady=(5, 15))

        # OpenAI
        openai_frame = ttk.Frame(providers_frame)
        openai_frame.pack(fill="x", pady=5)

        ttk.Radiobutton(
            openai_frame, text="OpenAI GPT", variable=self.provider_var, value="openai"
        ).pack(side="left")

        get_openai_btn = ttk.Button(
            openai_frame,
            text="Get API Key →",
            command=lambda: webbrowser.open("https://platform.openai.com/api-keys"),
        )
        get_openai_btn.pack(side="right")

        self.openai_key = tk.StringVar()
        openai_entry = ttk.Entry(providers_frame, textvariable=self.openai_key, width=60, show="•")
        openai_entry.pack(fill="x", pady=(5, 15))

        # Gemini
        gemini_frame = ttk.Frame(providers_frame)
        gemini_frame.pack(fill="x", pady=5)

        ttk.Radiobutton(
            gemini_frame, text="Google Gemini", variable=self.provider_var, value="gemini"
        ).pack(side="left")

        get_gemini_btn = ttk.Button(
            gemini_frame,
            text="Get API Key →",
            command=lambda: webbrowser.open("https://aistudio.google.com/apikey"),
        )
        get_gemini_btn.pack(side="right")

        self.gemini_key = tk.StringVar()
        gemini_entry = ttk.Entry(providers_frame, textvariable=self.gemini_key, width=60, show="•")
        gemini_entry.pack(fill="x", pady=(5, 15))

        # Local LLM option
        ttk.Radiobutton(
            providers_frame,
            text="Local LLM (Ollama) - No API key needed",
            variable=self.provider_var,
            value="ollama",
        ).pack(anchor="w", pady=5)

        # Note
        note_label = ttk.Label(
            self,
            text="💡 Your API key is stored locally and encrypted. It never leaves your machine.",
            foreground=COLORS["success"],
        )
        note_label.pack(pady=(20, 0))

    def validate(self) -> bool:
        provider = self.provider_var.get()

        if provider == "anthropic" and not self.anthropic_key.get().strip():
            messagebox.showwarning("API Key Required", "Please enter your Anthropic API key.")
            return False
        elif provider == "openai" and not self.openai_key.get().strip():
            messagebox.showwarning("API Key Required", "Please enter your OpenAI API key.")
            return False
        elif provider == "gemini" and not self.gemini_key.get().strip():
            messagebox.showwarning("API Key Required", "Please enter your Google Gemini API key.")
            return False

        # Store in config
        self.wizard.setup_config["provider"] = provider
        self.wizard.setup_config["anthropic_key"] = self.anthropic_key.get().strip()
        self.wizard.setup_config["openai_key"] = self.openai_key.get().strip()
        self.wizard.setup_config["gemini_key"] = self.gemini_key.get().strip()

        return True


class ChannelsPage(WizardPage):
    """Communication channels setup."""

    def __init__(self, parent, wizard):
        super().__init__(parent, wizard)

        title = ttk.Label(self, text="Communication Channels", style="Header.TLabel")
        title.pack(pady=(0, 10))

        subtitle = ttk.Label(
            self, text="Choose how you want to interact with Familiar", style="Subheader.TLabel"
        )
        subtitle.pack(pady=(0, 30))

        # Channel options
        channels_frame = ttk.Frame(self)
        channels_frame.pack(fill="both", expand=True)

        self.channels = {}

        channel_options = [
            ("cli", "💻 Command Line (CLI)", "Always available", True),
            ("telegram", "📱 Telegram", "Requires bot token", False),
            ("discord", "🎮 Discord", "Requires bot token", False),
            ("webui", "🌐 Web Interface", "Local browser access", False),
        ]

        for channel_id, name, note, default in channel_options:
            frame = ttk.Frame(channels_frame)
            frame.pack(fill="x", pady=8)

            var = tk.BooleanVar(value=default)
            self.channels[channel_id] = {"enabled": var, "token": tk.StringVar()}

            check = ttk.Checkbutton(frame, text=name, variable=var)
            check.pack(side="left")

            note_label = ttk.Label(frame, text=f"  ({note})", foreground="#888")
            note_label.pack(side="left")

            # Token entry for channels that need it
            if channel_id in ["telegram", "discord"]:
                token_frame = ttk.Frame(channels_frame)
                token_frame.pack(fill="x", pady=(0, 10), padx=(30, 0))

                ttk.Label(token_frame, text="Bot Token:").pack(side="left")

                token_entry = ttk.Entry(
                    token_frame, textvariable=self.channels[channel_id]["token"], width=50, show="•"
                )
                token_entry.pack(side="left", padx=(10, 0))

                if channel_id == "telegram":
                    help_btn = ttk.Button(
                        token_frame,
                        text="?",
                        width=3,
                        command=lambda: webbrowser.open("https://core.telegram.org/bots#botfather"),
                    )
                    help_btn.pack(side="left", padx=(5, 0))

        # Note
        note = ttk.Label(
            self,
            text="You can configure additional channels later in the settings.",
            foreground="#888",
        )
        note.pack(pady=(20, 0))

    def on_leave(self) -> bool:
        self.wizard.setup_config["channels"] = {
            channel_id: {"enabled": data["enabled"].get(), "token": data["token"].get()}
            for channel_id, data in self.channels.items()
        }
        return True


class InstallPage(WizardPage):
    """Installation progress page."""

    def __init__(self, parent, wizard):
        super().__init__(parent, wizard)

        title = ttk.Label(self, text="Installing Familiar", style="Header.TLabel")
        title.pack(pady=(0, 30))

        # Progress bar
        self.progress = ttk.Progressbar(
            self, style="Accent.Horizontal.TProgressbar", length=400, mode="determinate"
        )
        self.progress.pack(pady=(0, 20))

        # Status label
        self.status_var = tk.StringVar(value="Preparing installation...")
        self.status_label = ttk.Label(self, textvariable=self.status_var)
        self.status_label.pack(pady=(0, 20))

        # Log output
        log_frame = ttk.Frame(self)
        log_frame.pack(fill="both", expand=True)

        self.log_text = tk.Text(
            log_frame,
            wrap="word",
            bg=COLORS["input_bg"],
            fg=COLORS["fg"],
            font=("Consolas", 9) if platform.system() == "Windows" else ("Monaco", 9),
            height=12,
            state="disabled",
        )
        self.log_text.pack(fill="both", expand=True, side="left")

        scrollbar = ttk.Scrollbar(log_frame, command=self.log_text.yview)
        scrollbar.pack(side="right", fill="y")
        self.log_text.config(yscrollcommand=scrollbar.set)

        self.install_complete = False
        self._install_in_progress = False

    def log(self, message: str):
        """Add message to log. Must be called via self.after() from background threads."""
        self.log_text.configure(state="normal")
        self.log_text.insert("end", message + "\n")
        self.log_text.see("end")
        self.log_text.configure(state="disabled")
        # Do NOT call update_idletasks() here — this method is invoked from
        # background threads via self.after(0, ...) and tkinter is not thread-safe.
        # The after() dispatch already ensures we run on the main thread.

    def set_progress(self, value: int, status: str = None):
        """Update progress bar and status. Must be called via self.after() from background threads."""
        self.progress["value"] = value
        if status:
            self.status_var.set(status)
        # Do NOT call update_idletasks() — called from background thread via after()

    def on_enter(self):
        """Start installation when page is shown."""
        self.wizard.set_buttons_enabled(False)
        self._install_in_progress = True
        threading.Thread(target=self._run_installation, daemon=True).start()

    def _run_installation(self):
        """Run the installation process."""
        try:
            config = self.wizard.setup_config  # noqa: F841 - passed to sub-methods via self.wizard.setup_config
            steps = [
                (5, "Creating directories...", self._create_directories),
                (15, "Checking Python...", self._check_python),
                (25, "Creating virtual environment...", self._create_venv),
                (40, "Installing dependencies...", self._install_deps),
                (60, "Downloading Familiar...", self._download_familiar),
                (75, "Configuring...", self._configure),
                (85, "Generating encryption key...", self._generate_keys),
                (90, "Creating shortcuts...", self._create_shortcuts),
                (95, "Verifying installation...", self._verify_installation),
                (100, "Installation complete!", None),
            ]

            for progress, status, func in steps:
                self.after(0, lambda p=progress, s=status: self.set_progress(p, s))
                self.after(0, lambda s=status: self.log(f"→ {s}"))

                if func:
                    try:
                        func()
                    except Exception as e:
                        self.after(0, lambda e=e: self.log(f"  ✗ Error: {e}"))
                        raise
                    self.after(0, lambda: self.log("  ✓ Done"))

            self.install_complete = True
            self._install_in_progress = False
            self.after(0, lambda: self.log("\n🎉 Familiar has been installed successfully!"))
            self.after(0, lambda: self.wizard.set_buttons_enabled(True))

        except Exception as e:
            _err = str(e)  # capture before except block exits (Python deletes `e` at block end)
            self._install_in_progress = False
            self.after(0, lambda msg=_err: self.log(f"\n❌ Installation failed: {msg}"))
            self.after(0, lambda: self.status_var.set("Installation failed"))
            self.after(0, lambda msg=_err: messagebox.showerror("Installation Failed", msg))

    def _create_directories(self):
        """Create installation directories."""
        config = self.wizard.setup_config
        install_dir = Path(config["install_dir"])
        data_dir = Path(config["data_dir"])

        for d in [install_dir, data_dir]:
            d.mkdir(parents=True, exist_ok=True)

        # Create subdirectories
        for subdir in ["sessions", "memory", "history", "audit", "backups", "skills"]:
            (data_dir / subdir).mkdir(exist_ok=True)

    def _check_python(self):
        """Check Python version."""
        if sys.version_info < MIN_PYTHON_VERSION:
            raise RuntimeError(
                f"Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}+ required, "
                f"found {sys.version_info[0]}.{sys.version_info[1]}"
            )

    def _create_venv(self):
        """Create virtual environment."""
        import venv

        config = self.wizard.setup_config
        venv_dir = Path(config["install_dir"]) / "venv"
        venv.create(venv_dir, with_pip=True)

    def _install_deps(self):
        """Install Python dependencies."""
        config = self.wizard.setup_config
        venv_dir = Path(config["install_dir"]) / "venv"

        if platform.system() == "Windows":
            pip = venv_dir / "Scripts" / "pip.exe"
        else:
            pip = venv_dir / "bin" / "pip"

        # Core dependencies
        deps = [
            "anthropic>=0.40.0",
            "openai>=1.50.0",
            "cryptography>=41.0.0",
            "pyyaml>=6.0",
            "python-dotenv>=1.0.0",
            "httpx>=0.27.0",
            "aiohttp>=3.9.0",
        ]

        def _run_pip(args):
            """Run pip and stream output to the install log."""
            result = subprocess.run(
                [str(pip)] + args,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
            )
            for line in (result.stdout or "").strip().splitlines():
                self.after(0, lambda ln=line: self.log(f"    {ln}"))
            if result.returncode != 0:
                raise RuntimeError(
                    f"pip {' '.join(args[:2])} failed (exit {result.returncode}). "
                    f"See log for details."
                )

        _run_pip(["install", "--upgrade", "pip", "wheel"])
        _run_pip(["install"] + deps)

    def _download_familiar(self):
        """Install Familiar source into the install directory.

        Strategy (in priority order):
        1. If running from a PyInstaller bundle, the source is bundled alongside
           the executable in a 'familiar' subdirectory — copy it directly.
        2. Otherwise download the latest release archive from GitHub and extract it.
        """
        config = self.wizard.setup_config
        install_dir = Path(config["install_dir"])
        familiar_dst = install_dir / "familiar"

        # -- Strategy 1: bundled source (PyInstaller _MEIPASS) --
        bundle_dir = getattr(sys, "_MEIPASS", None)
        if bundle_dir:
            bundle_src = Path(bundle_dir) / "familiar"
            if bundle_src.exists():
                if familiar_dst.exists():
                    shutil.rmtree(familiar_dst)
                shutil.copytree(bundle_src, familiar_dst)
                self.after(0, lambda: self.log("  Installed from bundle"))
                return

        # -- Strategy 2: also check next to the wizard script itself --
        script_src = Path(__file__).parent.parent / "familiar"
        if script_src.exists() and (script_src / "__init__.py").exists():
            if familiar_dst.exists():
                shutil.rmtree(familiar_dst)
            shutil.copytree(
                script_src,
                familiar_dst,
                ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".git"),
            )
            self.after(0, lambda: self.log("  Installed from source directory"))
            return

        # -- Strategy 3: download release archive from GitHub --
        import tempfile
        import zipfile

        api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
        try:
            self.after(0, lambda: self.log("  Fetching latest release info..."))
            with urllib.request.urlopen(api_url, timeout=30) as resp:
                release = json.loads(resp.read())

            # Find the source zip asset
            zip_url = release.get("zipball_url")
            tag = release.get("tag_name", "unknown")
            self.after(0, lambda t=tag: self.log(f"  Downloading release {t}..."))

            with urllib.request.urlopen(zip_url, timeout=120) as resp:
                zip_data = resp.read()

        except urllib.error.URLError as e:
            raise RuntimeError(
                f"Could not download Familiar from GitHub: {e}\n"
                "Check your internet connection or install manually."
            )

        with tempfile.TemporaryDirectory() as tmpdir:
            tmp_path = Path(tmpdir)
            zip_path = tmp_path / "familiar.zip"
            zip_path.write_bytes(zip_data)

            with zipfile.ZipFile(zip_path) as zf:
                zf.extractall(tmp_path)

            # GitHub zips have a top-level directory like 'familiar-ai-familiar-abc1234'
            extracted = [d for d in tmp_path.iterdir() if d.is_dir() and d.name != "__MACOSX"]
            if not extracted:
                raise RuntimeError("Downloaded archive appears empty")

            src_root = extracted[0]
            familiar_src = src_root / "familiar"
            if not familiar_src.exists():
                raise RuntimeError(
                    f"Could not find 'familiar' package in downloaded archive. "
                    f"Contents: {list(src_root.iterdir())}"
                )

            if familiar_dst.exists():
                shutil.rmtree(familiar_dst)
            shutil.copytree(
                familiar_src, familiar_dst, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
            )

        self.after(0, lambda: self.log("  Download and extraction complete"))

    def _configure(self):
        """Create configuration files."""
        config = self.wizard.setup_config
        data_dir = Path(config["data_dir"])

        # Create config.yaml
        yaml_config = f"""# Familiar Configuration
# Generated by Setup Wizard

agent:
  name: Familiar
  default_provider: {config["provider"]}
  security_mode: production

security:
  encrypt_sessions: true
  encrypt_memory: true
  encrypt_history: true
  encryption_key_env: FAMILIAR_ENCRYPTION_KEY

guardrails:
  rate_limit:
    requests_per_minute: 30
    requests_per_hour: 300
  cost:
    max_per_request: 1.00
    max_per_day: 20.00

channels:
"""
        for channel_id, channel_config in config.get("channels", {}).items():
            if channel_config.get("enabled"):
                yaml_config += f"  {channel_id}:\n    enabled: true\n"

        config_path = data_dir / "config.yaml"
        config_path.write_text(yaml_config)

    def _generate_keys(self):
        """Generate encryption key and write .env file.

        IMPORTANT: cryptography is installed into the *venv*, not the host
        Python that runs this wizard. We therefore generate the Fernet key by
        running a one-liner through the venv's Python executable, avoiding an
        ImportError on the host.
        """
        import secrets

        config = self.wizard.setup_config
        data_dir = Path(config["data_dir"])
        install_dir = Path(config["install_dir"])

        # Locate venv Python
        if platform.system() == "Windows":
            venv_python = install_dir / "venv" / "Scripts" / "python.exe"
        else:
            venv_python = install_dir / "venv" / "bin" / "python"

        # Generate Fernet key via venv Python (cryptography is installed there)
        encryption_key = None
        if venv_python.exists():
            try:
                result = subprocess.run(
                    [
                        str(venv_python),
                        "-c",
                        "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())",
                    ],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True,
                    timeout=15,
                )
                if result.returncode == 0:
                    encryption_key = result.stdout.strip()
            except Exception as e:
                self.after(
                    0, lambda e=e: self.log(f"  Warning: venv key gen failed ({e}), using fallback")
                )

        if not encryption_key:
            # Fallback: generate a URL-safe base64-encoded 32-byte key manually
            # (identical format to Fernet.generate_key() — base64url, 32 bytes)
            import base64

            encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
            self.after(
                0,
                lambda: self.log(
                    "  Used fallback key generation (cryptography not available on host)"
                ),
            )

        webhook_secret = secrets.token_hex(32)

        env_content = f"""# Familiar Environment Variables
# Generated by Setup Wizard - KEEP THIS FILE SECURE

# Encryption (auto-generated - BACK THIS UP!)
FAMILIAR_ENCRYPTION_KEY={encryption_key}
FAMILIAR_WEBHOOK_SECRET={webhook_secret}

# LLM Provider Keys
"""
        if config.get("anthropic_key"):
            env_content += f"ANTHROPIC_API_KEY={config['anthropic_key']}\n"
        if config.get("openai_key"):
            env_content += f"OPENAI_API_KEY={config['openai_key']}\n"
        if config.get("gemini_key"):
            env_content += f"GEMINI_API_KEY={config['gemini_key']}\n"

        env_content += "\n# Channel Tokens\n"
        for channel_id, channel_config in config.get("channels", {}).items():
            token = channel_config.get("token", "")
            if token:
                env_content += f"{channel_id.upper()}_BOT_TOKEN={token}\n"

        env_path = data_dir / ".env"
        env_path.write_text(env_content)

        # Restrict permissions on Unix so only the owner can read API keys
        if platform.system() != "Windows":
            os.chmod(env_path, 0o600)

    def _verify_installation(self):
        """Run the integration test suite to confirm the agent stack is healthy.

        Runs pytest against tests/test_integration.py using the venv Python.
        Does NOT require API keys, Ollama, or any running services — all
        providers are mocked. Warns but does not abort installation on failure
        so users on unusual setups are not blocked.
        """
        config = self.wizard.setup_config
        install_dir = Path(config["install_dir"])

        # Locate the Python interpreter — prefer the venv we just created
        venv_python = install_dir / ".venv" / "bin" / "python3"
        if not venv_python.exists():
            venv_python = install_dir / "venv" / "bin" / "python3"
        if not venv_python.exists():
            venv_python = Path(sys.executable)

        tests_dir = install_dir / "tests"
        test_file = tests_dir / "test_integration.py"

        if not test_file.exists():
            self.after(
                0,
                lambda: self.log(
                    "  ⚠  tests/test_integration.py not found — skipping verification"
                ),
            )
            return

        # Ensure pytest is available in the venv
        try:
            result = subprocess.run(
                [str(venv_python), "-m", "pytest", "--version"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                cwd=str(install_dir),
                timeout=15,
            )
            if result.returncode != 0:
                subprocess.run(
                    [
                        str(venv_python),
                        "-m",
                        "pip",
                        "install",
                        "pytest",
                        "--break-system-packages",
                        "-q",
                    ],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    timeout=60,
                )
        except (subprocess.SubprocessError, OSError):
            self.after(0, lambda: self.log("  ⚠  Could not check pytest — skipping verification"))
            return

        self.after(0, lambda: self.log("  Running integration tests..."))

        try:
            result = subprocess.run(
                [
                    str(venv_python),
                    "-m",
                    "pytest",
                    str(test_file),
                    "--tb=short",
                    "-q",
                    "--no-header",
                ],
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                cwd=str(install_dir),
                timeout=120,
                text=True,
            )

            # Surface last few lines of output to the wizard log
            output_lines = result.stdout.strip().splitlines()
            summary = output_lines[-1] if output_lines else ""

            if result.returncode == 0:
                self.after(0, lambda s=summary: self.log(f"  ✅ {s}"))
            else:
                # Warn but don't abort — installation files are correct;
                # the failure may be an env-specific issue (missing optional dep, etc.)
                self.after(
                    0,
                    lambda s=summary: self.log(
                        f"  ⚠  Some tests failed: {s}\n"
                        f"     Run './run_tests.sh -v' after installation for details."
                    ),
                )

        except subprocess.TimeoutExpired:
            self.after(
                0,
                lambda: self.log(
                    "  ⚠  Verification timed out (>120s) — installation may still be OK.\n"
                    "     Run './run_tests.sh' manually to confirm."
                ),
            )
        except (subprocess.SubprocessError, OSError) as e:
            self.after(0, lambda e=e: self.log(f"  ⚠  Could not run tests: {e}"))

    def _create_shortcuts(self):
        """Create desktop shortcuts / start menu entries."""
        config = self.wizard.setup_config
        install_dir = Path(config["install_dir"])

        if platform.system() == "Windows":
            # Create batch file launcher
            launcher = install_dir / "Start Familiar.bat"
            launcher.write_text(f"""@echo off
cd /d "{install_dir}"
call venv\\Scripts\\activate
python -m familiar
pause
""")

            # Could also create .lnk shortcut with pywin32

        elif platform.system() == "Darwin":
            # Create macOS app launcher script
            launcher = install_dir / "start-familiar.command"
            launcher.write_text(f"""#!/bin/bash
cd "{install_dir}"
source venv/bin/activate
python -m familiar
""")
            os.chmod(launcher, 0o755)

        else:
            # Create Linux .desktop file
            desktop_dir = Path.home() / ".local" / "share" / "applications"
            desktop_dir.mkdir(parents=True, exist_ok=True)

            desktop_file = desktop_dir / "familiar.desktop"
            desktop_file.write_text(f"""[Desktop Entry]
Name=Familiar
Comment=Personal AI Assistant
Exec={install_dir}/venv/bin/python -m familiar
Terminal=true
Type=Application
Categories=Utility;
""")


class CompletePage(WizardPage):
    """Installation complete page."""

    def __init__(self, parent, wizard):
        super().__init__(parent, wizard)

        # Success icon
        icon = tk.Label(
            self,
            text="✅",
            font=("Segoe UI Emoji", 48)
            if platform.system() == "Windows"
            else ("Apple Color Emoji", 48),
            bg=COLORS["bg"],
        )
        icon.pack(pady=(0, 20))

        title = ttk.Label(self, text="Installation Complete!", style="Header.TLabel")
        title.pack(pady=(0, 10))

        subtitle = ttk.Label(
            self, text="Familiar has been installed successfully", style="Subheader.TLabel"
        )
        subtitle.pack(pady=(0, 30))

        # Next steps
        steps_frame = ttk.Frame(self)
        steps_frame.pack(fill="x", pady=(0, 30))

        ttk.Label(steps_frame, text="Next Steps:", font=("Segoe UI Semibold", 12)).pack(anchor="w")

        steps = [
            "1. Start Familiar from the Start Menu or Applications folder",
            "2. Configure your preferred messaging channels",
            "3. Start chatting with your personal AI assistant!",
        ]

        for step in steps:
            ttk.Label(steps_frame, text=step).pack(anchor="w", pady=3)

        # Options
        self.start_now = tk.BooleanVar(value=True)
        start_check = ttk.Checkbutton(self, text="Start Familiar now", variable=self.start_now)
        start_check.pack(anchor="w")

        self.open_docs = tk.BooleanVar(value=False)
        docs_check = ttk.Checkbutton(
            self, text="Open documentation in browser", variable=self.open_docs
        )
        docs_check.pack(anchor="w")

        # Links
        links_frame = ttk.Frame(self)
        links_frame.pack(fill="x", pady=(30, 0))

        doc_link = ttk.Button(
            links_frame,
            text="📖 Documentation",
            command=lambda: webbrowser.open("https://docs.familiar.ai"),
        )
        doc_link.pack(side="left", padx=(0, 10))

        discord_link = ttk.Button(
            links_frame,
            text="💬 Discord Community",
            command=lambda: webbrowser.open("https://discord.gg/familiar"),
        )
        discord_link.pack(side="left")

    def on_leave(self) -> bool:
        """Handle finish actions."""
        config = self.wizard.setup_config

        if self.open_docs.get():
            webbrowser.open("https://docs.familiar.ai")

        if self.start_now.get():
            install_dir = Path(config["install_dir"])

            if platform.system() == "Windows":
                subprocess.Popen(
                    [str(install_dir / "venv" / "Scripts" / "python.exe"), "-m", "familiar"],
                    cwd=str(install_dir),
                )
            else:
                subprocess.Popen(
                    [str(install_dir / "venv" / "bin" / "python"), "-m", "familiar"],
                    cwd=str(install_dir),
                )

        return True


# ============================================================================
# Wizard Controller
# ============================================================================


class SetupWizard(tk.Tk):
    """Main wizard window."""

    def __init__(self):
        super().__init__()

        self.title(f"{APP_NAME} Setup")
        self.geometry("700x600")
        self.resizable(False, False)
        self.configure(bg=COLORS["bg"])

        # Center window
        self.update_idletasks()
        x = (self.winfo_screenwidth() - 700) // 2
        y = (self.winfo_screenheight() - 600) // 2
        self.geometry(f"+{x}+{y}")

        # Configuration storage — NOT self.config (that's tk.Misc.configure alias)
        self.setup_config = {}

        # Configure styles
        configure_styles()

        # Create pages
        self.pages = [
            WelcomePage,
            LicensePage,
            InstallLocationPage,
            APIKeyPage,
            ChannelsPage,
            InstallPage,
            CompletePage,
        ]
        self.current_page_index = 0
        self.page_instances = {}

        # Main container
        self.main_frame = ttk.Frame(self)
        self.main_frame.pack(fill="both", expand=True)

        # Page container
        self.page_container = ttk.Frame(self.main_frame)
        self.page_container.pack(fill="both", expand=True)

        # Navigation buttons
        self.nav_frame = ttk.Frame(self.main_frame)
        self.nav_frame.pack(fill="x", padx=40, pady=20)

        self.cancel_btn = ttk.Button(self.nav_frame, text="Cancel", command=self.cancel)
        self.cancel_btn.pack(side="left")

        self.next_btn = ttk.Button(self.nav_frame, text="Next →", command=self.next_page)
        self.next_btn.pack(side="right")

        self.back_btn = ttk.Button(self.nav_frame, text="← Back", command=self.prev_page)
        self.back_btn.pack(side="right", padx=(0, 10))

        # Show first page
        self.show_page(0)

        # Handle window close
        self.protocol("WM_DELETE_WINDOW", self.cancel)

    def show_page(self, index: int):
        """Show a specific page."""
        # Hide current page
        for widget in self.page_container.winfo_children():
            widget.pack_forget()

        # Get or create page instance
        if index not in self.page_instances:
            page_class = self.pages[index]
            self.page_instances[index] = page_class(self.page_container, self)

        page = self.page_instances[index]
        page.pack(fill="both", expand=True)
        page.on_enter()

        self.current_page_index = index
        self.update_navigation()

    def update_navigation(self):
        """Update navigation button states."""
        index = self.current_page_index

        # Back button
        self.back_btn.config(state="normal" if index > 0 else "disabled")

        # Next/Finish button
        if index == len(self.pages) - 1:
            self.next_btn.config(text="Finish")
        elif index == len(self.pages) - 2:  # Install page
            self.next_btn.config(text="Next →")
        else:
            self.next_btn.config(text="Next →")

        # License page - disable next until accepted
        # Use dynamic lookup so page reordering doesn't silently break this
        try:
            license_index = self.pages.index(LicensePage)
        except ValueError:
            license_index = -1
        if index == license_index:
            license_page = self.page_instances.get(license_index)
            if license_page and hasattr(license_page, "agree_var"):
                self.next_btn.configure(
                    state="normal" if license_page.agree_var.get() else "disabled"
                )

    def set_buttons_enabled(self, enabled: bool):
        """Enable or disable navigation buttons."""
        state = "normal" if enabled else "disabled"
        self.back_btn.config(state=state)
        self.next_btn.config(state=state)
        self.cancel_btn.config(state=state)

    def next_page(self):
        """Go to next page."""
        current = self.page_instances.get(self.current_page_index)

        if current:
            if not current.validate():
                return
            if not current.on_leave():
                return

        if self.current_page_index < len(self.pages) - 1:
            self.show_page(self.current_page_index + 1)
        else:
            self.finish()

    def prev_page(self):
        """Go to previous page."""
        if self.current_page_index > 0:
            self.show_page(self.current_page_index - 1)

    def cancel(self):
        """Cancel installation."""
        # Check if an install is running — showing a dialog from a background
        # thread context is unsafe and can deadlock on macOS.
        install_page = self.page_instances.get(self.current_page_index)
        if install_page and getattr(install_page, "_install_in_progress", False):
            messagebox.showwarning(
                "Installation In Progress",
                "Installation is currently running. Please wait for it to complete or fail before closing.",
            )
            return
        if messagebox.askyesno("Cancel Setup", "Are you sure you want to cancel the installation?"):
            self.destroy()

    def finish(self):
        """Complete the wizard."""
        current = self.page_instances.get(self.current_page_index)
        if current:
            current.on_leave()
        self.destroy()


# ============================================================================
# Entry Point
# ============================================================================


def main():
    """Main entry point."""
    # Check if running with admin/root when needed
    if platform.system() != "Windows" and os.geteuid() == 0:
        print("Warning: Running as root is not recommended.")

    wizard = SetupWizard()
    wizard.mainloop()


if __name__ == "__main__":
    main()
