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

"""
Onboarding Engine — UI-agnostic backend for all onboarding UIs.

Every UI (CLI wizard, web dashboard, Textual TUI, tkinter installer)
calls into this single engine.  No duplicated business logic.

Usage::

    engine = OnboardingEngine()
    result = engine.detect_providers()
    result = engine.validate_provider({"provider": "anthropic", "api_key": "sk-..."})
    result = engine.validate_channels({"channels": ["telegram"], "telegram_token": "123:ABC"})
    result = engine.validate_owner_pin({"pin": "1234"})
    result = engine.validate_identity({"name": "Jarvis", "persona": "hospitality"})
    result = engine.validate_briefing({"enabled": True, "time": "08:00", "heartbeat": True})
    result = engine.validate_encryption({"enabled": True, "passphrase": "hunter2"})
    summary = engine.get_summary()
    result = engine.activate(notify=print)
"""

import hashlib
import logging
import os
import re
import secrets
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Callable, Dict, List, Optional

try:
    import fcntl
except ImportError:
    fcntl = None  # type: ignore[assignment]  # Windows / Android

logger = logging.getLogger(__name__)

# ── Constants (single source of truth for all UIs) ────────────────────

PROVIDERS = [
    {
        "key": "anthropic",
        "label": "Anthropic Claude",
        "env_key": "ANTHROPIC_API_KEY",
        "default_model": "claude-sonnet-4-6",
        "model_field": "anthropic_model",
        "url": "https://console.anthropic.com/",
    },
    {
        "key": "openai",
        "label": "OpenAI GPT",
        "env_key": "OPENAI_API_KEY",
        "default_model": "gpt-4o",
        "model_field": "openai_model",
        "url": "https://platform.openai.com/api-keys",
    },
    {
        "key": "gemini",
        "label": "Google Gemini",
        "env_key": "GEMINI_API_KEY",
        "default_model": "gemini-2.0-flash",
        "model_field": "gemini_model",
        "url": "https://aistudio.google.com/apikey",
    },
    {
        "key": "ollama",
        "label": "Ollama (local)",
        "env_key": None,
        "default_model": "llama3.2",
        "model_field": "ollama_model",
        "url": "https://ollama.ai/",
    },
]

CHANNELS = [
    {
        "key": "telegram",
        "label": "Telegram",
        "token_env": "TELEGRAM_BOT_TOKEN",
        "needs_token": True,
    },
    {"key": "discord", "label": "Discord", "token_env": "DISCORD_BOT_TOKEN", "needs_token": True},
    {
        "key": "matrix",
        "label": "Matrix / Element",
        "token_env": "MATRIX_ACCESS_TOKEN",
        "needs_token": True,
    },
    {
        "key": "whatsapp",
        "label": "WhatsApp",
        "token_env": "WHATSAPP_BRIDGE_PORT",
        "needs_token": False,
    },
    {"key": "signal", "label": "Signal", "token_env": "SIGNAL_PHONE", "needs_token": True},
    {
        "key": "imessage",
        "label": "iMessage",
        "token_env": None,
        "needs_token": False,
        "platform": "darwin",
    },
    {
        "key": "teams",
        "label": "Microsoft Teams",
        "token_env": "TEAMS_APP_ID",
        "needs_token": True,
    },
    {"key": "cli", "label": "Terminal / CLI", "token_env": None, "needs_token": False},
]

PERSONAS = {
    "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",
}

PERSONA_LABELS = {
    "hospitality": "Warm & professional (default hospitality consciousness)",
    "efficient": "Efficient & concise (just the facts)",
    "friendly": "Casual & friendly (conversational)",
    "formal": "Formal & thorough (detailed responses)",
}


# ── Result dataclass ──────────────────────────────────────────────────


@dataclass
class StepResult:
    """Outcome of an engine validation or action step."""

    success: bool
    errors: list = field(default_factory=list)
    warnings: list = field(default_factory=list)
    data: dict = field(default_factory=dict)


# ── Engine ────────────────────────────────────────────────────────────


class OnboardingEngine:
    """UI-agnostic backend for onboarding flows."""

    def __init__(self, project_dir: Optional[Path] = None):
        self.project_dir = project_dir or Path.cwd()
        # When project_dir is explicitly set (e.g. tests), only write .env
        # there — never write to ~/.familiar/.env from test runs.
        self._write_home_env = project_dir is None
        self._state: Dict = {}

    # ── Detection ─────────────────────────────────────────────────

    def detect_providers(self) -> StepResult:
        """Auto-detect available LLM providers from env vars and system."""
        detected = {}

        # Cloud providers (check env vars)
        for provider in PROVIDERS:
            env_key = provider["env_key"]
            if env_key:
                key = os.environ.get(env_key, "")
                if key:
                    detected[provider["key"]] = {
                        "key": key,
                        "label": f"{provider['label']} (API key detected)",
                    }

        # Ollama (check subprocess)
        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()
                ]
                label = (
                    f"Ollama ({len(models)} model{'s' if len(models) != 1 else ''} installed)"
                    if models
                    else "Ollama (installed, no models yet)"
                )
                detected["ollama"] = {"models": models, "label": label}
        except (FileNotFoundError, subprocess.TimeoutExpired):
            pass

        # RAM detection
        ram_gb = self._get_ram_gb()

        return StepResult(
            success=True,
            data={"providers": detected, "ram_gb": ram_gb},
        )

    # ── Validation steps ──────────────────────────────────────────

    def validate_provider(self, data: dict) -> StepResult:
        """Validate LLM provider selection and API key.

        Args:
            data: {"provider": str, "api_key": str (optional for ollama),
                   "ollama_model": str (optional), "lightweight_model": str (optional)}
        """
        provider = data.get("provider", "").strip()
        api_key = data.get("api_key", "").strip()
        errors = []

        # Find provider spec
        provider_spec = next((p for p in PROVIDERS if p["key"] == provider), None)
        if not provider_spec:
            return StepResult(success=False, errors=[f"Unknown provider: {provider}"])

        # API key required for cloud providers
        if provider_spec["env_key"] and not api_key:
            # Check env var as fallback
            env_val = os.environ.get(provider_spec["env_key"], "")
            if env_val:
                api_key = env_val
            else:
                errors.append(f"API key required for {provider_spec['label']}")

        if errors:
            return StepResult(success=False, errors=errors)

        # Store validated state
        state = {
            "provider": provider,
            "model": data.get("model", provider_spec["default_model"]),
            "model_field": provider_spec["model_field"],
        }
        if api_key:
            state["api_key"] = api_key
            state["env_key"] = provider_spec["env_key"]
        if data.get("ollama_model"):
            state["ollama_model"] = data["ollama_model"]
        if data.get("lightweight_model"):
            state["lightweight_model"] = data["lightweight_model"]

        self._state["provider"] = state
        return StepResult(success=True, data=state)

    def validate_channels(self, data: dict) -> StepResult:
        """Validate channel selection and tokens.

        Args:
            data: {"channels": list[str], "telegram_token": str, "discord_token": str,
                   "matrix_homeserver": str, "matrix_user": str,
                   "matrix_password": str, "matrix_access_token": str,
                   "owner_matrix_id": str}
        """
        channels = data.get("channels", [])
        errors = []
        warnings = []

        if not channels:
            errors.append("At least one channel must be selected")
            return StepResult(success=False, errors=errors)

        # Validate each channel exists
        valid_keys = {c["key"] for c in CHANNELS}
        for ch in channels:
            if ch not in valid_keys:
                errors.append(f"Unknown channel: {ch}")

        if errors:
            return StepResult(success=False, errors=errors)

        # Validate tokens for channels that need them
        if "telegram" in channels:
            token = data.get("telegram_token", "").strip()
            if not token:
                errors.append("Telegram bot token is required")
            elif ":" not in token:
                warnings.append("Telegram token usually contains a ':' — double check it")

        if "discord" in channels:
            token = data.get("discord_token", "").strip()
            if not token:
                errors.append("Discord bot token is required")

        if "matrix" in channels:
            if not data.get("matrix_homeserver", "").strip():
                errors.append("Matrix homeserver URL is required")
            if not data.get("matrix_user", "").strip():
                errors.append("Matrix user ID is required")
            has_auth = (
                data.get("matrix_password", "").strip()
                or data.get("matrix_access_token", "").strip()
            )
            if not has_auth:
                errors.append("Matrix password or access token is required")

        if "signal" in channels:
            phone = data.get("signal_phone", "").strip()
            if not phone:
                errors.append("Signal phone number is required")
            elif not phone.startswith("+"):
                warnings.append(
                    "Signal phone number should include country code (e.g. +14155551234)"
                )

        if "whatsapp" in channels:
            port = data.get("whatsapp_port", "3001").strip()
            if port and not port.isdigit():
                errors.append("WhatsApp bridge port must be a number")
            nodejs_warning = self._check_nodejs_available()
            if nodejs_warning:
                warnings.append(nodejs_warning)

        if "imessage" in channels:
            if sys.platform != "darwin":
                errors.append("iMessage is only available on macOS")

        if "teams" in channels:
            app_id = data.get("teams_app_id", "").strip()
            app_password = data.get("teams_app_password", "").strip()
            if not app_id:
                errors.append("Teams App ID is required (from Azure Bot Services)")
            if not app_password:
                errors.append("Teams App Password is required")

        if errors:
            return StepResult(success=False, errors=errors, warnings=warnings)

        # Store validated state
        state = {
            "channel": channels[0],  # primary
            "channels": channels,
        }
        # Copy token fields
        for key in (
            "telegram_token",
            "discord_token",
            "matrix_homeserver",
            "matrix_user",
            "matrix_password",
            "matrix_access_token",
            "owner_matrix_id",
            "signal_phone",
            "whatsapp_port",
            "teams_app_id",
            "teams_app_password",
            "teams_tenant_id",
        ):
            val = data.get(key, "").strip()
            if val:
                state[key] = val

        self._state["channels"] = state
        return StepResult(success=True, data=state, warnings=warnings)

    def validate_owner_pin(self, data: dict) -> StepResult:
        """Validate and hash owner PIN.

        Args:
            data: {"pin": str}  — 4-8 digit PIN
        """
        pin = data.get("pin", "").strip()
        errors = []

        if not pin:
            errors.append("Owner PIN is required")
        elif not pin.isdigit():
            errors.append("PIN must contain only digits")
        elif len(pin) < 4 or len(pin) > 8:
            errors.append("PIN must be 4-8 digits")

        if errors:
            return StepResult(success=False, errors=errors)

        # Hash with PBKDF2 (salted, 100K iterations)
        salt = secrets.token_hex(16)
        pin_hash = hashlib.pbkdf2_hmac("sha256", pin.encode(), salt.encode(), 100_000).hex()
        hash_str = f"{salt}:{pin_hash}"

        self._state["owner_pin"] = {"hash": hash_str, "length": len(pin)}
        return StepResult(
            success=True,
            data={"hash": hash_str, "length": len(pin)},
        )

    def validate_identity(self, data: dict) -> StepResult:
        """Validate assistant name and persona.

        Args:
            data: {"name": str, "persona": str}
        """
        name = data.get("name", "").strip()
        persona_key = data.get("persona", "hospitality").strip()
        errors = []

        if not name:
            name = "Familiar"

        if persona_key not in PERSONAS:
            errors.append(f"Unknown persona: {persona_key}")
            return StepResult(success=False, errors=errors)

        state = {"name": name, "persona": PERSONAS[persona_key], "persona_key": persona_key}
        self._state["identity"] = state
        return StepResult(success=True, data=state)

    def validate_briefing(self, data: dict) -> StepResult:
        """Validate briefing configuration.

        Args:
            data: {"enabled": bool, "time": str (HH:MM), "heartbeat": bool}
        """
        enabled = data.get("enabled", False)
        errors = []

        state = {"briefing_enabled": enabled}

        if enabled:
            time_str = data.get("time", "08:00").strip()
            if not re.match(r"^([01]?\d|2[0-3]):[0-5]\d$", time_str):
                errors.append(
                    f"'{time_str}' is not a valid time. Use HH:MM format (e.g. 08:00, 14:30)"
                )
                return StepResult(success=False, errors=errors)
            state["briefing_time"] = time_str
            state["heartbeat_enabled"] = data.get("heartbeat", False)
        else:
            state["briefing_time"] = "08:00"
            state["heartbeat_enabled"] = False

        self._state["briefing"] = state
        return StepResult(success=True, data=state)

    def validate_encryption(self, data: dict) -> StepResult:
        """Validate encryption configuration.

        Args:
            data: {"enabled": bool, "passphrase": str}
        """
        enabled = data.get("enabled", False)

        state = {"encrypt_at_rest": enabled}

        if enabled:
            passphrase = data.get("passphrase", "").strip()
            if not passphrase:
                # Auto-generate
                passphrase = secrets.token_urlsafe(16)
            state["passphrase"] = passphrase

        self._state["encryption"] = state
        return StepResult(success=True, data=state)

    # ── Summary ───────────────────────────────────────────────────

    def get_summary(self) -> dict:
        """Return a summary of all validated configuration."""
        s = self._state
        provider_state = s.get("provider", {})
        channel_state = s.get("channels", {})
        identity_state = s.get("identity", {})
        briefing_state = s.get("briefing", {})
        encryption_state = s.get("encryption", {})
        pin_state = s.get("owner_pin", {})

        return {
            "provider": provider_state.get("provider", ""),
            "model": provider_state.get("model", ""),
            "ollama_model": provider_state.get("ollama_model", ""),
            "lightweight_model": provider_state.get("lightweight_model", ""),
            "channels": channel_state.get("channels", []),
            "primary_channel": channel_state.get("channel", ""),
            "name": identity_state.get("name", "Familiar"),
            "persona_key": identity_state.get("persona_key", "hospitality"),
            "persona": identity_state.get("persona", ""),
            "pin_length": pin_state.get("length", 0),
            "briefing_enabled": briefing_state.get("briefing_enabled", False),
            "briefing_time": briefing_state.get("briefing_time", "08:00"),
            "heartbeat_enabled": briefing_state.get("heartbeat_enabled", False),
            "encrypt_at_rest": encryption_state.get("encrypt_at_rest", False),
        }

    # ── Post-activation checklist ────────────────────────────────

    def get_post_activation_checklist(self) -> list:
        """Return context-aware next-steps after activation.

        Each item: {"title": str, "detail": str, "priority": int}.
        Sorted by priority (lower = more important).
        """
        summary = self.get_summary()
        channels = summary.get("channels", [])
        primary = summary.get("primary_channel", channels[0] if channels else "cli")
        name = summary.get("name", "Familiar")
        items = []

        # Launch command
        if primary == "telegram":
            cmd = "python -m familiar --telegram"
        elif primary == "discord":
            cmd = "python -m familiar --discord"
        else:
            cmd = "python -m familiar"
        items.append(
            {
                "title": f"Start {name}",
                "detail": f"Run: {cmd}",
                "priority": 0,
            }
        )

        # Channel-specific first steps
        if "telegram" in channels:
            items.append(
                {
                    "title": "Message your Telegram bot",
                    "detail": "Open Telegram and send /start to your bot",
                    "priority": 1,
                }
            )
        if "discord" in channels:
            items.append(
                {
                    "title": "Invite your Discord bot",
                    "detail": "Use the OAuth2 URL from the Discord Developer Portal to add the bot to a server",
                    "priority": 1,
                }
            )
        if "matrix" in channels:
            items.append(
                {
                    "title": "Invite your Matrix bot",
                    "detail": "Invite the bot user to a room in Element or your Matrix client",
                    "priority": 1,
                }
            )

        # Google Calendar
        items.append(
            {
                "title": "Connect Google Calendar",
                "detail": "Run: python -m familiar --setup-google",
                "priority": 5,
            }
        )

        # Discover integrations
        items.append(
            {
                "title": "Discover integrations",
                "detail": "Send /connect to your bot to set up email, calendar, LLM providers, and more",
                "priority": 6,
            }
        )

        # Web dashboard
        items.append(
            {
                "title": "Web dashboard",
                "detail": "Visit http://localhost:5000 for a browser-based control panel",
                "priority": 7,
            }
        )

        items.sort(key=lambda x: x["priority"])
        return items

    # ── Activation ────────────────────────────────────────────────

    def activate(
        self,
        notify: Optional[Callable[[str], None]] = None,
    ) -> StepResult:
        """Write config files, install deps, init encryption keys.

        Args:
            notify: Optional callback for progress messages.
        """
        errors = []
        warnings = []

        def _notify(msg: str):
            if notify:
                notify(msg)
            logger.info(msg)

        # Validate all required steps have been completed
        required = ["provider", "channels", "identity"]
        for step in required:
            if step not in self._state:
                errors.append(f"Step '{step}' has not been completed")
        if errors:
            return StepResult(success=False, errors=errors)

        provider_state = self._state["provider"]
        channel_state = self._state["channels"]
        identity_state = self._state["identity"]
        briefing_state = self._state.get("briefing", {"briefing_enabled": False})
        encryption_state = self._state.get("encryption", {"encrypt_at_rest": False})
        pin_state = self._state.get("owner_pin", {})

        # ── Ensure directories ──
        _notify("Creating directories...")
        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.yaml ──
        _notify("Writing configuration...")
        self._write_config(provider_state, channel_state, identity_state, briefing_state)

        # ── Write .env ──
        _notify("Writing secrets...")
        self._write_env(provider_state, channel_state, encryption_state, pin_state)

        # ── Install dependencies ──
        _notify("Installing dependencies...")
        dep_result = self._install_deps(provider_state, channel_state, _notify)
        if dep_result.warnings:
            warnings.extend(dep_result.warnings)

        # ── Init encryption keys & mesh config ──
        _notify("Initializing encryption...")
        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()
            _notify("Encryption keys generated")
        except ImportError:
            pass  # Running outside full install

        _notify("Activation complete!")
        return StepResult(success=True, warnings=warnings)

    # ── Generate default PIN ──────────────────────────────────────

    @staticmethod
    def generate_default_pin() -> str:
        """Generate a random 4-digit PIN for display as a suggestion."""
        return f"{secrets.randbelow(10000):04d}"

    # ── Test message ──────────────────────────────────────────────

    def send_test_message(
        self,
        notify: Optional[Callable[[str], None]] = None,
    ) -> StepResult:
        """Send a test message using stored state. Returns StepResult."""
        channel_state = self._state.get("channels", {})
        identity_state = self._state.get("identity", {})
        briefing_state = self._state.get("briefing", {})

        channels = channel_state.get("channels", [])
        if not channels:
            return StepResult(success=False, errors=["No channels configured"])

        name = identity_state.get("name", "Familiar")
        if briefing_state.get("briefing_enabled"):
            btime = briefing_state.get("briefing_time", "08:00")
            msg = (
                f"Hi, I'm {name}. I'm set up and ready.\n\n"
                f"I'll send you a morning briefing at {btime} 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..."'
            )

        primary = channels[0]

        if primary == "cli":
            if notify:
                notify(f"{name}: {msg}")
            return StepResult(success=True, data={"channel": "cli"})

        if primary == "telegram" and channel_state.get("telegram_token"):
            return self._send_telegram_test(channel_state["telegram_token"], msg, notify)

        if primary == "discord" and channel_state.get("discord_token"):
            return self._send_discord_test(channel_state["discord_token"], msg, notify)

        if primary == "matrix" and channel_state.get("matrix_homeserver"):
            return self._send_matrix_test(channel_state, msg, notify)

        # Other channels — deferred
        if notify:
            notify(f"{primary.title()} test message will be sent when the bot starts.")
        return StepResult(
            success=True,
            warnings=[f"Test message deferred for {primary}"],
        )

    def _send_telegram_test(
        self,
        token: str,
        message: str,
        notify: Optional[Callable] = None,
    ) -> StepResult:
        """Send a test message via Telegram. Waits for /start."""
        try:
            import json
            import time
            import urllib.request

            if notify:
                notify("Waiting for you to send /start to your bot...")

            offset = 0
            chat_id = None
            for attempt in range(60):
                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
                time.sleep(1)

            if not chat_id:
                return StepResult(
                    success=False,
                    warnings=["Didn't receive /start. Test later by sending /start to your bot."],
                )

            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"):
                    if notify:
                        notify("Test message sent! Check your Telegram.")
                    self._save_chat_id(chat_id)
                    return StepResult(success=True, data={"chat_id": chat_id})
                else:
                    return StepResult(
                        success=False,
                        warnings=[f"Telegram API error: {result}"],
                    )
        except Exception as e:
            return StepResult(success=False, warnings=[f"Test message failed: {e}"])

    def _send_discord_test(
        self,
        token: str,
        message: str,
        notify: Optional[Callable] = None,
    ) -> StepResult:
        """Send a test message to the bot's first text channel."""
        try:
            import json
            import urllib.request

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

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

            if not guilds:
                return StepResult(
                    success=False,
                    warnings=["Bot is not in any servers. Invite it first."],
                )

            guild_id = guilds[0]["id"]
            req = urllib.request.Request(
                f"https://discord.com/api/v10/guilds/{guild_id}/channels",
                headers=headers,
            )
            with urllib.request.urlopen(req, timeout=10) as resp:
                channels = json.loads(resp.read())

            text_channel = next((c for c in channels if c.get("type") == 0), None)
            if not text_channel:
                return StepResult(
                    success=False,
                    warnings=["No text channels found in the server."],
                )

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

            if notify:
                notify(f"Test message sent to #{text_channel['name']}!")
            return StepResult(success=True)
        except Exception as e:
            return StepResult(
                success=False,
                warnings=[f"Discord test message failed: {e}"],
            )

    def _send_matrix_test(
        self,
        channel_config: dict,
        message: str,
        notify: Optional[Callable] = None,
    ) -> StepResult:
        """Send a test message to a Matrix room."""
        try:
            import json
            import time
            import urllib.request

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

            if not access_token:
                user = channel_config.get("matrix_user", "")
                password = channel_config.get("matrix_password", "")
                if not password:
                    return StepResult(
                        success=False,
                        warnings=["Matrix test requires a password or access token."],
                    )
                login_payload = json.dumps(
                    {
                        "type": "m.login.password",
                        "identifier": {"type": "m.id.user", "user": user},
                        "password": password,
                    }
                ).encode()
                req = urllib.request.Request(
                    f"{homeserver}/_matrix/client/v3/login",
                    data=login_payload,
                    headers={"Content-Type": "application/json"},
                )
                with urllib.request.urlopen(req, timeout=10) as resp:
                    login_data = json.loads(resp.read())
                access_token = login_data.get("access_token", "")
                if not access_token:
                    return StepResult(
                        success=False,
                        warnings=["Matrix login failed."],
                    )

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

            if not rooms:
                return StepResult(
                    success=False,
                    warnings=["Bot has not joined any rooms."],
                )

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

            if notify:
                notify("Test message sent! Check your Matrix client.")
            return StepResult(success=True)
        except Exception as e:
            return StepResult(
                success=False,
                warnings=[f"Matrix test message failed: {e}"],
            )

    def _save_chat_id(self, chat_id: int):
        """Save the Telegram chat ID for proactive messaging."""
        os.environ["OWNER_TELEGRAM_ID"] = str(chat_id)

        # Write to project_dir .env
        env_path = self.project_dir / ".env"
        if env_path.exists():
            self._update_single_env_key(env_path, "OWNER_TELEGRAM_ID", str(chat_id))

        # Write to ~/.familiar/.env only in production mode
        if self._write_home_env:
            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))

            home_env = Path.home() / ".familiar" / ".env"
            if home_env.exists() and home_env != env_path:
                self._update_single_env_key(home_env, "OWNER_TELEGRAM_ID", str(chat_id))

            for cfg_path in [
                self.project_dir / "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"
                            f"  telegram_allowed_users:\n    - {chat_id}\n"
                            f"  telegram_enabled:",
                        )
                        cfg_path.write_text(existing)

    @staticmethod
    def _update_single_env_key(env_path: Path, key: str, value: str):
        """Update or append a single key=value in a .env file."""
        with open(env_path, "r+") as f:
            if fcntl is not None:
                fcntl.flock(f, fcntl.LOCK_EX)
            try:
                lines = f.read().splitlines()
                found = False
                for i, line in enumerate(lines):
                    if line.startswith(f"{key}="):
                        lines[i] = f"{key}={value}"
                        found = True
                        break
                if not found:
                    lines.append(f"{key}={value}")
                f.seek(0)
                f.write("\n".join(lines) + "\n")
                f.truncate()
            finally:
                if fcntl is not None:
                    fcntl.flock(f, fcntl.LOCK_UN)

    # ── Ollama bundle helper ─────────────────────────────────────

    def get_ollama_bundles(self, ram_gb: Optional[float] = None) -> dict:
        """Return RAM-based Ollama model bundle recommendations.

        Args:
            ram_gb: Override RAM detection (for testing / TUI display).

        Returns:
            Dict with ram_gb, recommended bundle, and available models list.
        """
        if ram_gb is None:
            ram_gb = self._get_ram_gb()

        is_pi = ram_gb < 4

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

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

        return {
            "ram_gb": ram_gb,
            "recommended": recommended,
            "available_models": available_models,
        }

    # ── Config completeness check ────────────────────────────────

    @staticmethod
    def validate_config_completeness(
        config_path: Optional[Path] = None,
        env_path: Optional[Path] = None,
    ) -> StepResult:
        """Check that config.yaml and .env are complete enough to start.

        Returns StepResult with warnings for each issue found.
        """
        import yaml

        if config_path is None:
            config_path = Path.home() / ".familiar" / "config.yaml"
        if env_path is None:
            env_path = Path.home() / ".familiar" / ".env"

        warnings: List[str] = []

        # 1. config.yaml parseable
        if not config_path.exists():
            return StepResult(
                success=False,
                errors=["config.yaml not found"],
            )

        try:
            cfg = yaml.safe_load(config_path.read_text()) or {}
        except Exception as e:
            return StepResult(
                success=False,
                errors=[f"config.yaml is not valid YAML: {e}"],
            )

        # 2. default_provider is set
        llm = cfg.get("llm", {}) or {}
        provider = llm.get("default_provider", "")
        if not provider or provider == "none":
            warnings.append("No LLM provider configured (llm.default_provider)")

        # 3. At least one channel enabled
        channels_cfg = cfg.get("channels", {}) or {}
        has_channel = any(
            channels_cfg.get(f"{ch}_enabled", False)
            for ch in (
                "telegram",
                "discord",
                "matrix",
                "signal",
                "whatsapp",
                "imessage",
                "teams",
                "cli",
            )
        )
        if not has_channel:
            warnings.append("No communication channels enabled")

        # 4. Required secrets for enabled channels
        env_vars: Dict[str, str] = {}
        if env_path.exists():
            for line in env_path.read_text().splitlines():
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    k, _, v = line.partition("=")
                    env_vars[k.strip()] = v.strip()

        secret_checks = {
            "telegram": "TELEGRAM_BOT_TOKEN",
            "discord": "DISCORD_BOT_TOKEN",
            "matrix": "MATRIX_HOMESERVER",
            "signal": "SIGNAL_PHONE",
            "teams": "TEAMS_APP_ID",
        }
        for ch, env_key in secret_checks.items():
            if channels_cfg.get(f"{ch}_enabled") and not env_vars.get(env_key):
                warnings.append(f"{ch.title()} is enabled but {env_key} is missing from .env")

        if warnings:
            return StepResult(success=False, warnings=warnings)
        return StepResult(success=True)

    # ── Wizard state persistence ─────────────────────────────────

    _STATE_FILE = Path.home() / ".familiar" / ".onboard_state.json"

    def save_state(self, current_step: int = 0):
        """Persist wizard state to disk for resume."""
        import json

        payload = {
            "timestamp": datetime.now().isoformat(),
            "step": current_step,
            "state": self._state,
        }
        self._STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
        self._STATE_FILE.write_text(json.dumps(payload, default=str))

    def load_saved_state(self) -> Optional[dict]:
        """Load saved wizard state. Returns None if missing, stale (>24h), or corrupt."""
        import json

        if not self._STATE_FILE.exists():
            return None
        try:
            data = json.loads(self._STATE_FILE.read_text())
            ts = datetime.fromisoformat(data["timestamp"])
            if (datetime.now() - ts).total_seconds() > 86400:
                self.clear_saved_state()
                return None
            return data
        except (json.JSONDecodeError, KeyError, ValueError):
            self.clear_saved_state()
            return None

    def clear_saved_state(self):
        """Delete the saved state file."""
        try:
            self._STATE_FILE.unlink(missing_ok=True)
        except OSError:
            pass

    # ── Private helpers ───────────────────────────────────────────

    def _write_config(
        self,
        provider: dict,
        channels: dict,
        identity: dict,
        briefing: dict,
    ):
        """Write config.yaml to project dir and ~/.familiar/."""
        provider_name = provider["provider"]
        model_field = provider.get("model_field", "ollama_model")
        model = provider.get("model", "")
        if provider_name == "ollama":
            model = provider.get("ollama_model", provider.get("model", "llama3.2"))

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

        yaml_content = f"""# Familiar Configuration
# Generated by onboarding wizard — {datetime.now().strftime("%Y-%m-%d %H:%M")}
#
# NOTE: Environment variables in .env override values in this file.
# To change a setting, update BOTH files or remove the .env variable.

agent:
  name: "{identity.get("name", "Familiar")}"
  persona: "{identity.get("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_name}
  {model_field}: {model}

channels:
  telegram_enabled: {str("telegram" in selected_channels).lower()}
  discord_enabled: {str("discord" in selected_channels).lower()}
  matrix_enabled: {str("matrix" in selected_channels).lower()}
  signal_enabled: {str("signal" in selected_channels).lower()}
  whatsapp_enabled: {str("whatsapp" in selected_channels).lower()}
  imessage_enabled: {str("imessage" in selected_channels).lower()}
  teams_enabled: {str("teams" 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"
"""
        # Write to project dir
        config_path = self.project_dir / "config.yaml"
        config_path.write_text(yaml_content)

        # Copy to ~/.familiar/ — skip when project_dir was explicitly set
        if self._write_home_env:
            home_config = Path.home() / ".familiar" / "config.yaml"
            home_config.parent.mkdir(parents=True, exist_ok=True)
            if home_config != config_path:
                shutil.copy2(config_path, home_config)

    def _write_env(
        self,
        provider: dict,
        channels: dict,
        encryption: dict,
        pin: dict,
    ):
        """Write or merge .env file with secrets."""
        new_vars: Dict[str, str] = {}
        new_vars["DEFAULT_PROVIDER"] = provider["provider"]

        # Provider key
        if provider.get("api_key") and provider.get("env_key"):
            new_vars[provider["env_key"]] = provider["api_key"]

        # Ollama model
        if provider.get("ollama_model"):
            new_vars["OLLAMA_MODEL"] = provider["ollama_model"]
        if provider.get("lightweight_model"):
            new_vars["FAMILIAR_LIGHTWEIGHT_MODEL"] = provider["lightweight_model"]

        # Encryption
        if encryption.get("encrypt_at_rest"):
            new_vars["ENCRYPTION_ENABLED"] = "true"
            if encryption.get("passphrase"):
                new_vars["ENCRYPTION_PASSPHRASE"] = encryption["passphrase"]

        # Channel tokens
        if channels.get("telegram_token"):
            new_vars["TELEGRAM_BOT_TOKEN"] = channels["telegram_token"]
        if channels.get("discord_token"):
            new_vars["DISCORD_BOT_TOKEN"] = channels["discord_token"]
        if channels.get("matrix_homeserver"):
            new_vars["MATRIX_HOMESERVER"] = channels["matrix_homeserver"]
        if channels.get("matrix_user"):
            new_vars["MATRIX_USER"] = channels["matrix_user"]
        if channels.get("matrix_password"):
            new_vars["MATRIX_PASSWORD"] = channels["matrix_password"]
        if channels.get("matrix_access_token"):
            new_vars["MATRIX_ACCESS_TOKEN"] = channels["matrix_access_token"]
        if channels.get("owner_matrix_id"):
            new_vars["OWNER_MATRIX_ID"] = channels["owner_matrix_id"]
        if channels.get("signal_phone"):
            new_vars["SIGNAL_PHONE"] = channels["signal_phone"]
        if "whatsapp" in channels.get("channels", []):
            new_vars["WHATSAPP_BRIDGE_PORT"] = channels.get("whatsapp_port", "3001")
        if channels.get("teams_app_id"):
            new_vars["TEAMS_APP_ID"] = channels["teams_app_id"]
        if channels.get("teams_app_password"):
            new_vars["TEAMS_APP_PASSWORD"] = channels["teams_app_password"]
        if channels.get("teams_tenant_id"):
            new_vars["TEAMS_TENANT_ID"] = channels["teams_tenant_id"]
        if channels.get("channels"):
            # Merge with previously-enabled channels so a partial wizard
            # run doesn't drop channels that were already configured.
            target_env = self.project_dir / ".env"
            prev_channels: List[str] = []
            if target_env.exists():
                for _line in target_env.read_text().splitlines():
                    _line = _line.strip()
                    if _line.startswith("ENABLED_CHANNELS="):
                        prev_channels = [
                            c.strip() for c in _line.split("=", 1)[1].split(",") if c.strip()
                        ]
                        break
            merged = list(
                dict.fromkeys(
                    [c.lower() for c in channels["channels"]] + [c.lower() for c in prev_channels]
                )
            )
            new_vars["ENABLED_CHANNELS"] = ",".join(merged)

        # Owner PIN
        if pin.get("hash"):
            new_vars["OWNER_PIN_HASH"] = pin["hash"]

        # Write to project dir .env (merge)
        self._merge_env(self.project_dir / ".env", new_vars, channels)

        # Write to ~/.familiar/.env (merge) — skip when project_dir was
        # explicitly set (tests, CI) to prevent test data leaking into
        # the user's real configuration.
        if self._write_home_env:
            home_env = Path.home() / ".familiar" / ".env"
            home_env.parent.mkdir(parents=True, exist_ok=True)
            if home_env != self.project_dir / ".env":
                self._merge_env(home_env, new_vars, channels)

    @staticmethod
    def _merge_env(env_path: Path, new_vars: dict, channels: dict):
        """Merge new vars into an existing .env file (with file locking)."""
        # Ensure file exists for open(r+) mode
        if not env_path.exists():
            env_path.touch()

        # Back up existing .env before any modifications
        if env_path.stat().st_size > 0:
            backup_dir = env_path.parent / "backups"
            backup_dir.mkdir(parents=True, exist_ok=True)
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            backup_path = backup_dir / f".env.{timestamp}"
            shutil.copy2(env_path, backup_path)
            try:
                backup_path.chmod(0o600)
            except OSError:
                pass
            logger.info(f"Backed up .env to {backup_path}")

        with open(env_path, "r+") as f:
            if fcntl is not None:
                fcntl.flock(f, fcntl.LOCK_EX)
            try:
                existing_vars: Dict[str, str] = {}
                for line in f.read().splitlines():
                    line = line.strip()
                    if line and not line.startswith("#") and "=" in line:
                        key, _, val = line.partition("=")
                        existing_vars[key.strip()] = val.strip()

                # Only remove channel tokens if the channel was previously
                # in ENABLED_CHANNELS and is now being explicitly deselected.
                # Never strip tokens that aren't also present in the existing
                # ENABLED_CHANNELS — this prevents accidental credential loss
                # when activate() is called with partial state.
                selected = channels.get("channels", [])
                prev_enabled = [
                    c.strip()
                    for c in existing_vars.get("ENABLED_CHANNELS", "").split(",")
                    if c.strip()
                ]
                if selected and prev_enabled:
                    _CHANNEL_VARS = {
                        "telegram": ["TELEGRAM_BOT_TOKEN", "OWNER_TELEGRAM_ID"],
                        "discord": ["DISCORD_BOT_TOKEN"],
                        "signal": ["SIGNAL_PHONE", "OWNER_SIGNAL_ID"],
                        "whatsapp": [
                            "WHATSAPP_BRIDGE_HOST",
                            "WHATSAPP_BRIDGE_PORT",
                            "OWNER_WHATSAPP_ID",
                        ],
                        "teams": [
                            "TEAMS_APP_ID",
                            "TEAMS_APP_PASSWORD",
                            "TEAMS_TENANT_ID",
                        ],
                        "matrix": [
                            "MATRIX_HOMESERVER",
                            "MATRIX_USER",
                            "MATRIX_PASSWORD",
                            "MATRIX_ACCESS_TOKEN",
                            "OWNER_MATRIX_ID",
                        ],
                    }
                    for ch, var_keys in _CHANNEL_VARS.items():
                        # Only remove if the channel WAS enabled and is NOW
                        # explicitly excluded from the new selection.
                        if ch in prev_enabled and ch not in selected:
                            for k in var_keys:
                                existing_vars.pop(k, None)

                # Remove legacy duplicate
                existing_vars.pop("LLM_DEFAULT_PROVIDER", None)

                # Protect sensitive keys: never overwrite a real credential
                # with a shorter/placeholder value from a partial wizard run.
                _SENSITIVE_KEYS = {
                    "TELEGRAM_BOT_TOKEN",
                    "DISCORD_BOT_TOKEN",
                    "MATRIX_ACCESS_TOKEN",
                    "MATRIX_PASSWORD",
                    "ANTHROPIC_API_KEY",
                    "OPENAI_API_KEY",
                    "GEMINI_API_KEY",
                    "OWNER_PIN_HASH",
                    "OWNER_TELEGRAM_ID",
                    "OWNER_DISCORD_ID",
                    "OWNER_SIGNAL_ID",
                    "OWNER_MATRIX_ID",
                    "EMAIL_PASSWORD",
                    "ENCRYPTION_PASSPHRASE",
                    "TWILIO_AUTH_TOKEN",
                    "TEAMS_APP_PASSWORD",
                    "SIGNAL_PHONE",
                }
                skip_keys: set = set()
                for key, new_val in new_vars.items():
                    old_val = existing_vars.get(key, "")
                    if (
                        key in _SENSITIVE_KEYS
                        and old_val
                        and new_val != old_val
                        and len(new_val) < len(old_val)
                    ):
                        logger.warning(
                            f"Refusing to overwrite {key} "
                            f"(existing {len(old_val)} chars → "
                            f"new {len(new_val)} chars)"
                        )
                        skip_keys.add(key)
                for k in skip_keys:
                    new_vars.pop(k)
                existing_vars.update(new_vars)

                # Only clean conditional vars if they were explicitly
                # toggled off (present in new_vars as disabled), not merely
                # absent from the current wizard run.
                if new_vars.get("ENCRYPTION_ENABLED") == "false":
                    existing_vars.pop("ENCRYPTION_PASSPHRASE", None)
                    existing_vars.pop("ENCRYPTION_ENABLED", None)

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

                content = "\n".join(env_lines) + "\n"
                f.seek(0)
                f.write(content)
                f.truncate()
            finally:
                if fcntl is not None:
                    fcntl.flock(f, fcntl.LOCK_UN)
        try:
            env_path.chmod(0o600)
        except OSError:
            pass  # Windows

    def _install_deps(
        self,
        provider: dict,
        channels: dict,
        notify: Callable[[str], None],
    ) -> StepResult:
        """Install required pip packages."""
        warnings = []

        core_deps = ["pyyaml", "pydantic", "httpx", "cryptography"]

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

        # Provider-specific
        provider_deps: List[str] = []
        provider_name = provider.get("provider", "")
        if provider_name in ("anthropic",) or provider.get("api_key"):
            if provider.get("env_key") == "ANTHROPIC_API_KEY":
                provider_deps.append("anthropic>=0.40.0")
        if provider_name in ("openai", "ollama"):
            provider_deps.append("openai>=1.0.0")
        if provider_name == "gemini":
            provider_deps.append("google-generativeai>=0.8.0")

        # Skill essentials
        skill_deps = [
            "beautifulsoup4",
            "aiosmtpd",
            "python-docx",
            "openpyxl",
            "Pillow",
            "markdown",
        ]

        all_required = core_deps + channel_deps + provider_deps + skill_deps

        try:
            result = subprocess.run(
                [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q"]
                + all_required,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                timeout=300,
            )
            if result.returncode == 0:
                notify(f"Core dependencies installed ({len(all_required)} packages)")
            else:
                err = result.stderr.decode(errors="replace").strip()
                warnings.append(f"Some packages failed: {err[-400:]}")
                notify("Some packages failed to install")
        except subprocess.TimeoutExpired:
            warnings.append("pip install timed out (300s)")
            notify("Installation timed out")
        except Exception as e:
            warnings.append(f"pip install error: {e}")
            notify(f"Installation error: {e}")

        return StepResult(success=True, warnings=warnings)

    @staticmethod
    def _check_nodejs_available() -> Optional[str]:
        """Check if Node.js is installed and >= v18.

        Returns a warning string if Node.js is missing or too old, else None.
        """
        try:
            result = subprocess.run(
                ["node", "--version"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                version_str = result.stdout.strip().lstrip("v")
                major = int(version_str.split(".")[0])
                if major < 18:
                    return (
                        f"WhatsApp bridge requires Node.js v18+, "
                        f"found v{version_str}. "
                        f"Update from https://nodejs.org/"
                    )
                return None
        except FileNotFoundError:
            return "WhatsApp bridge requires Node.js v18+. Install from https://nodejs.org/"
        except (subprocess.TimeoutExpired, ValueError, IndexError):
            pass
        return None

    @staticmethod
    def _get_ram_gb() -> float:
        """Detect system RAM in GB."""
        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
