# 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 Web Dashboard

A web-based control panel for managing the agent.

Features:
- Chat interface
- View/manage memory
- Monitor connected nodes
- Configure skills
- View tasks and deadlines
- Email triage overview
- System status

Run:
    python -m familiar.dashboard
    # Opens at http://localhost:5000

Security:
    Set FAMILIAR_DASHBOARD_KEY environment variable to enable authentication.
    Set FAMILIAR_ALLOWED_ORIGINS for CORS (comma-separated list).
"""

import json
import logging
import os
import queue
import re
import subprocess
import threading
from datetime import datetime
from functools import wraps
from pathlib import Path

from flask import Flask, Response, abort, jsonify, redirect, render_template, request, send_file
from flask_cors import CORS

from familiar.channels.connect_wizard._browse import (
    SEARCHABLE_SERVICES,
    _extract_video_id,
    browse_all,
    is_video_url,
    search_service,
)

logger = logging.getLogger(__name__)

# Data paths - use centralized paths
try:
    from familiar.core.paths import DATA_DIR
except ImportError:
    DATA_DIR = Path.home() / ".familiar" / "data"

TEMPLATES_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"

app = Flask(__name__, template_folder=str(TEMPLATES_DIR), static_folder=str(STATIC_DIR))

# Configure CORS with restricted origins
_allowed_origins = os.environ.get(
    "FAMILIAR_ALLOWED_ORIGINS", "http://localhost:5000,http://127.0.0.1:5000"
)
_origins_list = [o.strip() for o in _allowed_origins.split(",") if o.strip()]
CORS(app, origins=_origins_list)


# ============================================================
# CSRF Protection
# ============================================================


@app.before_request
def csrf_protect():
    """Validate Origin/Referer on state-changing requests."""
    if request.method in ("POST", "PUT", "DELETE", "PATCH"):
        origin = request.headers.get("Origin") or request.headers.get("Referer", "")
        if not origin:
            # Reject requests with no Origin/Referer — prevents CSRF from
            # scripts that strip these headers.
            logger.warning(f"CSRF check failed: no Origin/Referer from {request.remote_addr}")
            abort(403, description="Origin header required")

        from urllib.parse import urlparse

        parsed = urlparse(origin)
        origin_host = f"{parsed.scheme}://{parsed.netloc}"
        if origin_host not in _origins_list:
            logger.warning(
                f"CSRF check failed: origin={origin_host} "
                f"not in allowed={_origins_list} from {request.remote_addr}"
            )
            abort(403, description="Origin not allowed")


# ============================================================
# Authentication
# ============================================================


def require_auth(f):
    """
    Require API key authentication for dashboard endpoints.

    API key can be provided via:
    - X-API-Key header
    - api_key query parameter

    If FAMILIAR_DASHBOARD_KEY is not set, authentication is disabled
    (with a warning logged on first request).
    """

    @wraps(f)
    def decorated(*args, **kwargs):
        expected_key = os.environ.get("FAMILIAR_DASHBOARD_KEY")

        if not expected_key:
            # Auto-generate a key on first run (Jupyter-style)
            if not getattr(require_auth, "_auto_key", None):
                import secrets as _secrets

                require_auth._auto_key = _secrets.token_urlsafe(32)
                logger.warning("FAMILIAR_DASHBOARD_KEY not set. Auto-generated session key.")
                print(f"\n{'=' * 60}")
                print("⚠️  Dashboard auth key (auto-generated):")
                print(f"   {require_auth._auto_key}")
                print("   Add to requests as X-API-Key header or ?api_key= param")
                print("   Set FAMILIAR_DASHBOARD_KEY env var to make persistent")
                print(f"{'=' * 60}\n")
            expected_key = require_auth._auto_key

        # Check for API key in header or query param
        provided_key = request.headers.get("X-API-Key") or request.args.get("api_key")

        if not provided_key:
            logger.warning(f"Unauthenticated request to {request.path} from {request.remote_addr}")
            abort(401, description="API key required. Provide via X-API-Key header.")

        # Constant-time comparison to prevent timing attacks
        import hmac

        if not hmac.compare_digest(provided_key, expected_key):
            logger.warning(f"Invalid API key from {request.remote_addr}")
            abort(401, description="Invalid API key")

        return f(*args, **kwargs)

    return decorated


# Optional: Rate limiting (requires flask-limiter)
try:
    from flask_limiter import Limiter
    from flask_limiter.util import get_remote_address

    limiter = Limiter(
        get_remote_address, app=app, default_limits=["200 per minute"], storage_uri="memory://"
    )
    _has_limiter = True
    logger.info("Rate limiting enabled")
except ImportError:
    _has_limiter = False

    # Create a no-op decorator
    class _NoOpLimiter:
        def limit(self, *args, **kwargs):
            def decorator(f):
                return f

            return decorator

    limiter = _NoOpLimiter()
    logger.debug("flask-limiter not installed - rate limiting disabled")

# Global references (set by run_dashboard)
_agent = None
_gateway = None
_wizard_host = None
_wizard_lock = threading.Lock()


def get_agent():
    global _agent
    if _agent is None:
        from familiar.core.agent import Agent

        _agent = Agent()
    return _agent


def get_wizard_host():
    """Get or create the connect-wizard adapter for the dashboard."""
    global _wizard_host
    with _wizard_lock:
        if _wizard_host is None:
            from familiar.channels.dashboard_wizard import DashboardWizardHost

            _wizard_host = DashboardWizardHost(get_agent())
    return _wizard_host


# ============================================================
# API Routes
# ============================================================


@app.route("/")
def index():
    """Main dashboard page. Redirects to onboarding on first run."""
    config_path = Path.home() / ".familiar" / "config.yaml"
    if not config_path.exists():
        return redirect("/onboard")
    return render_template("dashboard.html")


@app.route("/api/status")
@require_auth
def api_status():
    """Get system status."""
    agent = get_agent()
    status = agent.get_status()

    # Add more details
    status["timestamp"] = datetime.now().isoformat()
    status["uptime"] = _get_uptime()

    return jsonify(status)


@app.route("/api/chat", methods=["POST"])
@limiter.limit("20 per minute")  # Stricter limit for expensive LLM calls
@require_auth
def api_chat():
    """Send a message to the agent."""
    data = request.json or {}
    message = data.get("message", "")

    if not message:
        return jsonify({"error": "No message provided"}), 400

    # Route /connect commands to the wizard
    stripped = message.strip()
    if stripped.lower().startswith("/connect"):
        args = stripped.split()[1:]  # drop the "/connect" prefix
        host = get_wizard_host()
        messages = host.start_wizard(args)
        return jsonify({"wizard_messages": messages, "timestamp": datetime.now().isoformat()})

    # If wizard is active, intercept text input
    host = get_wizard_host()
    if host.is_active():
        messages = host.send_text(message)
        return jsonify({"wizard_messages": messages, "timestamp": datetime.now().isoformat()})

    agent = get_agent()
    result = agent.chat_with_results(message, user_id="dashboard", channel="web")

    return jsonify({"response": result.text, "timestamp": datetime.now().isoformat()})


@app.route("/api/voice/stt", methods=["POST"])
@limiter.limit("10 per minute")
@require_auth
def api_voice_stt():
    """Transcribe uploaded audio and send transcript to agent chat."""
    import tempfile as _tempfile

    if "audio" not in request.files:
        return jsonify({"error": "No audio file provided"}), 400

    audio_file = request.files["audio"]
    tmp_path = None
    try:
        fd, tmp_path = _tempfile.mkstemp(suffix=".webm")
        os.close(fd)
        audio_file.save(tmp_path)

        from familiar.skills.voice.skill import _transcribe_with_whisper

        transcript = _transcribe_with_whisper(tmp_path)
        text = transcript.text.strip()
        if not text:
            return jsonify({"error": "No speech detected"}), 400

        agent = get_agent()
        result = agent.chat_with_results(text, user_id="dashboard", channel="web")

        return jsonify(
            {
                "transcript": text,
                "response": result.text,
                "timestamp": datetime.now().isoformat(),
            }
        )
    except Exception as e:
        logger.exception("Voice STT error")
        return jsonify({"error": str(e)}), 500
    finally:
        if tmp_path:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass


@app.route("/api/voice/tts", methods=["POST"])
@limiter.limit("20 per minute")
@require_auth
def api_voice_tts():
    """Generate speech audio from text."""
    import tempfile as _tempfile

    data = request.json or {}
    text = data.get("text", "").strip()
    if not text:
        return jsonify({"error": "No text provided"}), 400

    # Try Piper first (local, high-quality WAV)
    try:
        from familiar.skills.voice.document_reader import speak_with_piper

        fd, tmp_path = _tempfile.mkstemp(suffix=".wav")
        os.close(fd)
        success, result = speak_with_piper(text, output_path=tmp_path, play=False)
        if success:
            return send_file(tmp_path, mimetype="audio/wav")
    except Exception:
        logger.debug("Piper TTS unavailable, trying fallbacks", exc_info=True)

    # Fallback: gTTS (MP3)
    try:
        from gtts import gTTS

        fd, tmp_path = _tempfile.mkstemp(suffix=".mp3")
        os.close(fd)
        gTTS(text=text, lang="en").save(tmp_path)
        return send_file(tmp_path, mimetype="audio/mpeg")
    except Exception:
        logger.debug("gTTS unavailable, trying edge-tts", exc_info=True)

    # Fallback: edge-tts (MP3)
    try:
        import asyncio

        import edge_tts

        fd, tmp_path = _tempfile.mkstemp(suffix=".mp3")
        os.close(fd)
        asyncio.run(edge_tts.Communicate(text, "en-US-AriaNeural").save(tmp_path))
        return send_file(tmp_path, mimetype="audio/mpeg")
    except Exception:
        logger.debug("edge-tts unavailable", exc_info=True)

    return jsonify({"error": "No TTS engine available"}), 503


@app.route("/api/terminal/stream", methods=["POST"])
@limiter.limit("20 per minute")
@require_auth
def api_terminal_stream():
    """Stream a chat response via SSE for the terminal panel."""
    from familiar.core.providers import StreamEventType

    data = request.json or {}
    message = data.get("message", "")

    if not message:
        return jsonify({"error": "No message provided"}), 400

    # Route /connect commands to the wizard (non-streaming)
    stripped = message.strip()
    if stripped.lower().startswith("/connect"):
        args = stripped.split()[1:]
        host = get_wizard_host()
        messages = host.start_wizard(args)
        text = "\n".join(m.get("text", str(m)) for m in messages)

        def _wizard_gen():
            yield f"data: {json.dumps({'type': 'text', 'content': text})}\n\n"
            yield f"data: {json.dumps({'type': 'done'})}\n\n"

        resp = Response(_wizard_gen(), mimetype="text/event-stream")
        resp.headers["Cache-Control"] = "no-cache"
        resp.headers["X-Accel-Buffering"] = "no"
        return resp

    # If wizard is active, route text input through it
    host = get_wizard_host()
    if host.is_active():
        messages = host.send_text(message)
        text = "\n".join(m.get("text", str(m)) for m in messages)

        def _wizard_input_gen():
            yield f"data: {json.dumps({'type': 'text', 'content': text})}\n\n"
            yield f"data: {json.dumps({'type': 'done'})}\n\n"

        resp = Response(_wizard_input_gen(), mimetype="text/event-stream")
        resp.headers["Cache-Control"] = "no-cache"
        resp.headers["X-Accel-Buffering"] = "no"
        return resp

    agent = get_agent()
    msg_queue = queue.Queue()

    def _run_stream():
        try:
            for event in agent.chat_stream(message, user_id="terminal", channel="terminal"):
                if event.type == StreamEventType.TEXT:
                    msg_queue.put({"type": "text", "content": event.content})
                elif event.type == StreamEventType.TOOL_START:
                    msg_queue.put({"type": "tool_start", "tool_name": event.tool_name or ""})
                elif event.type == StreamEventType.TOOL_END:
                    msg_queue.put({"type": "tool_end", "tool_name": event.tool_name or ""})
                elif event.type == StreamEventType.ERROR:
                    msg_queue.put({"type": "error", "error": event.error or "Unknown error"})
                elif event.type == StreamEventType.DONE:
                    pass  # Handled after loop
        except Exception as e:
            logger.exception("Terminal stream error")
            msg_queue.put({"type": "error", "error": str(e)})
        finally:
            msg_queue.put(None)  # Sentinel

    t = threading.Thread(target=_run_stream, daemon=True)
    t.start()

    def generate():
        while True:
            try:
                item = msg_queue.get(timeout=120)
            except queue.Empty:
                yield f"data: {json.dumps({'type': 'error', 'error': 'Timeout'})}\n\n"
                yield f"data: {json.dumps({'type': 'done'})}\n\n"
                break
            if item is None:
                yield f"data: {json.dumps({'type': 'done'})}\n\n"
                break
            yield f"data: {json.dumps(item)}\n\n"

    resp = Response(generate(), mimetype="text/event-stream")
    resp.headers["Cache-Control"] = "no-cache"
    resp.headers["X-Accel-Buffering"] = "no"
    return resp


# ============================================================
# Host Shell Terminal
# ============================================================

_shell_proc = None
_shell_lock = threading.Lock()
_shell_enabled_override: bool | None = None  # None = use env var


def _shell_enabled() -> bool:
    if _shell_enabled_override is not None:
        return _shell_enabled_override
    return os.environ.get("FAMILIAR_SHELL_ENABLED", "").lower() in ("1", "true", "yes")


# Patterns that should never be executed via the shell terminal
_DANGEROUS_PATTERNS = [
    r"\brm\s+-[^\s]*r[^\s]*f\s+/\s*$",  # rm -rf /
    r"\brm\s+-[^\s]*f[^\s]*r\s+/\s*$",  # rm -fr /
    r"\bmkfs\b",
    r"\bdd\s+if=",
    r">\s*/dev/sd[a-z]",
    r":\(\)\s*\{\s*:\|:\s*&\s*\}\s*;",  # fork bomb
]


@app.route("/api/shell/enabled")
@require_auth
def api_shell_enabled():
    """Check if the host shell terminal is enabled."""
    return jsonify({"enabled": _shell_enabled()})


@app.route("/api/shell/enabled", methods=["POST"])
@require_auth
def api_shell_toggle():
    """Toggle the host shell terminal on or off."""
    global _shell_enabled_override

    data = request.json or {}
    enabled = data.get("enabled")
    if not isinstance(enabled, bool):
        return jsonify({"error": '"enabled" must be a boolean'}), 400

    _shell_enabled_override = enabled

    # Persist to ~/.familiar/.env so the setting survives restarts
    env_path = Path.home() / ".familiar" / ".env"
    try:
        from familiar.channels.connect_wizard import _update_env_file

        _update_env_file(env_path, {"FAMILIAR_SHELL_ENABLED": "true" if enabled else "false"})
    except Exception:
        logger.debug("Could not persist shell toggle to .env", exc_info=True)

    return jsonify({"enabled": enabled})


@app.route("/api/shell/stream", methods=["POST"])
@limiter.limit("10 per minute")
@require_auth
def api_shell_stream():
    """Stream shell command output via SSE."""
    global _shell_proc

    if not _shell_enabled():
        return jsonify(
            {"error": "Shell terminal is disabled. Set FAMILIAR_SHELL_ENABLED=true"}
        ), 403

    data = request.json or {}
    command = data.get("command", "").strip()

    if not command:
        return jsonify({"error": "No command provided"}), 400

    # Defense-in-depth: block catastrophically destructive patterns
    for pattern in _DANGEROUS_PATTERNS:
        if re.search(pattern, command):
            logger.warning(f"Shell blocked dangerous command: {command!r}")
            return jsonify({"error": "Command blocked for safety"}), 400

    msg_queue = queue.Queue()

    def _run_shell():
        global _shell_proc
        try:
            proc = subprocess.Popen(
                command,
                shell=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
                cwd=str(Path.home()),
            )
            with _shell_lock:
                _shell_proc = proc

            for line in proc.stdout:
                msg_queue.put({"type": "output", "content": line})

            proc.wait()
            msg_queue.put({"type": "exit", "code": proc.returncode})
        except Exception as e:
            logger.exception("Shell stream error")
            msg_queue.put({"type": "error", "error": str(e)})
        finally:
            with _shell_lock:
                _shell_proc = None
            msg_queue.put(None)  # Sentinel

    t = threading.Thread(target=_run_shell, daemon=True)
    t.start()

    def generate():
        while True:
            try:
                item = msg_queue.get(timeout=300)  # 5 min idle timeout
            except queue.Empty:
                yield f"data: {json.dumps({'type': 'error', 'error': 'Timeout'})}\n\n"
                yield f"data: {json.dumps({'type': 'done'})}\n\n"
                break
            if item is None:
                yield f"data: {json.dumps({'type': 'done'})}\n\n"
                break
            yield f"data: {json.dumps(item)}\n\n"

    resp = Response(generate(), mimetype="text/event-stream")
    resp.headers["Cache-Control"] = "no-cache"
    resp.headers["X-Accel-Buffering"] = "no"
    return resp


@app.route("/api/shell/kill", methods=["POST"])
@require_auth
def api_shell_kill():
    """Terminate the running shell process."""
    global _shell_proc

    if not _shell_enabled():
        return jsonify({"error": "Shell terminal is disabled"}), 403

    with _shell_lock:
        if _shell_proc and _shell_proc.poll() is None:
            _shell_proc.terminate()
            return jsonify({"success": True, "message": "Process terminated"})
    return jsonify({"success": False, "message": "No running process"})


@app.route("/api/memory")
@require_auth
def api_memory():
    """Get all memories."""
    agent = get_agent()
    if not agent.memory:
        return jsonify({"memories": []})

    memories = []
    for key, entry in agent.memory.recall_all().items():
        memories.append(
            {
                "key": entry.key,
                "value": entry.value,
                "category": entry.category,
                "importance": entry.importance,
                "updated_at": entry.updated_at,
            }
        )

    # Sort by importance
    memories.sort(key=lambda x: x["importance"], reverse=True)

    return jsonify({"memories": memories})


@app.route("/api/memory", methods=["POST"])
@require_auth
def api_memory_add():
    """Add a memory."""
    data = request.json or {}
    key = data.get("key", "")
    value = data.get("value", "")
    category = data.get("category", "fact")

    if not key or not value:
        return jsonify({"error": "Key and value required"}), 400

    agent = get_agent()
    if agent.memory:
        agent.memory.remember(key, value, category=category)
        return jsonify({"success": True})

    return jsonify({"error": "Memory not enabled"}), 400


@app.route("/api/memory/<key>", methods=["DELETE"])
@require_auth
def api_memory_delete(key):
    """Delete a memory."""
    agent = get_agent()
    if agent.memory:
        agent.memory.forget(key)
        return jsonify({"success": True})
    return jsonify({"error": "Memory not enabled"}), 400


@app.route("/api/tasks")
@require_auth
def api_tasks():
    """Get all tasks."""
    tasks_file = DATA_DIR / "tasks.json"
    if not tasks_file.exists():
        return jsonify({"tasks": []})

    try:
        tasks = json.loads(tasks_file.read_text())
        return jsonify({"tasks": tasks})
    except (json.JSONDecodeError, IOError, OSError):
        return jsonify({"tasks": []})


@app.route("/api/tasks", methods=["POST"])
@require_auth
def api_task_add():
    """Add a task."""
    data = request.json or {}

    # Use the tasks skill
    try:
        import sys

        sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "tasks"))
        from skill import add_task

        result = add_task(data)
        return jsonify({"result": result})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/tasks/<task_id>/complete", methods=["POST"])
@require_auth
def api_task_complete(task_id):
    """Complete a task."""
    try:
        import sys

        sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "tasks"))
        from skill import complete_task

        result = complete_task({"id": task_id})
        return jsonify({"result": result})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/skills")
@require_auth
def api_skills():
    """Get loaded skills."""
    agent = get_agent()
    skills = agent.skills.list_skills()
    return jsonify({"skills": skills})


@app.route("/api/skills/<name>/toggle", methods=["POST"])
@require_auth
def api_skill_toggle(name):
    """Enable/disable a skill."""
    agent = get_agent()
    skill = agent.skills.get_skill(name)

    if not skill:
        return jsonify({"error": "Skill not found"}), 404

    if skill.enabled:
        agent.skills.disable_skill(name)
        return jsonify({"enabled": False})
    else:
        agent.skills.enable_skill(name)
        return jsonify({"enabled": True})


@app.route("/api/tools")
@require_auth
def api_tools():
    """Get available tools."""
    agent = get_agent()
    tools = []

    for tool in agent.tools.get_all():
        tools.append(
            {"name": tool.name, "description": tool.description, "category": tool.category}
        )

    return jsonify({"tools": tools})


@app.route("/api/nodes")
@require_auth
def api_nodes():
    """Get connected mesh nodes."""
    if not _gateway:
        return jsonify({"nodes": [], "gateway_running": False})

    nodes = []
    for node_id, info in _gateway.nodes.items():
        nodes.append(
            {
                "id": info.node_id,
                "name": info.name,
                "type": info.type,
                "connected_at": info.connected_at,
                "last_seen": info.last_seen,
                "tools": info.tools,
            }
        )

    return jsonify({"nodes": nodes, "gateway_running": True})


@app.route("/api/email/summary")
@require_auth
def api_email_summary():
    """Get email triage summary."""
    try:
        import sys

        sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "triage"))
        from skill import get_category_summary

        result = get_category_summary({"days_back": 7})
        return jsonify({"summary": result})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/email/triage")
@require_auth
def api_email_triage():
    """Get full email triage."""
    try:
        import sys

        sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "triage"))
        from skill import triage_inbox

        result = triage_inbox(
            {
                "limit": int(request.args.get("limit", 25)),
                "unread_only": request.args.get("unread", "true").lower() == "true",
            }
        )
        return jsonify({"triage": result})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/briefing")
@require_auth
def api_briefing():
    """Get daily briefing."""
    try:
        import sys

        sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "proactive"))
        from skill import generate_briefing

        result = generate_briefing({})
        return jsonify({"briefing": result})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/connect/status")
@require_auth
def api_connect_status():
    """Get connection status of all supported services."""
    agent = get_agent()
    current_provider = agent.provider.name if agent else "unknown"

    services = []

    def _add(name, display_name, category, connected, detail=None, cmd=None):
        services.append(
            {
                "name": name,
                "display_name": display_name,
                "category": category,
                "connected": connected,
                "detail": detail,
                "connect_command": cmd or f"/connect {name}",
            }
        )

    # ── LLM Providers ────────────────────────────────────────
    anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
    _add(
        "anthropic",
        "Anthropic (Claude)",
        "llm",
        bool(anthropic_key),
        f"...{anthropic_key[-4:]}" if len(anthropic_key) >= 4 else None,
    )
    openai_key = os.environ.get("OPENAI_API_KEY", "")
    _add(
        "openai",
        "OpenAI (GPT)",
        "llm",
        bool(openai_key),
        f"...{openai_key[-4:]}" if len(openai_key) >= 4 else None,
    )
    gemini_key = os.environ.get("GEMINI_API_KEY", "")
    _add(
        "gemini",
        "Gemini (Google AI)",
        "llm",
        bool(gemini_key),
        f"...{gemini_key[-4:]}" if len(gemini_key) >= 4 else None,
    )
    # Ollama — reachable if the API responds on localhost
    has_ollama = False
    ollama_detail = None
    try:
        import socket as _sock

        with _sock.create_connection(("127.0.0.1", 11434), timeout=2.0):
            has_ollama = True
        ollama_detail = current_provider if "Ollama" in current_provider else "Running"
    except (OSError, TimeoutError):
        pass
    _add("ollama", "Ollama (local)", "llm", has_ollama, ollama_detail)

    # ── Integrations ─────────────────────────────────────────
    data_dir = DATA_DIR if DATA_DIR.exists() else Path.home() / ".familiar" / "data"

    has_google = (data_dir / "google_credentials.json").exists()
    _add("google", "Google Workspace", "integrations", has_google, cmd="/connect google")

    has_gmail = (data_dir / "google_token_gmail.json").exists()
    _add("gmail", "Gmail API", "integrations", has_gmail, cmd="/connect google auth gmail")

    has_calendar_token = (data_dir / "google_token.json").exists()
    has_caldav = bool(os.environ.get("CALDAV_URL"))
    has_calendar = has_calendar_token or has_caldav
    _add(
        "calendar",
        "Calendar",
        "integrations",
        has_calendar,
        "CalDAV" if has_caldav else ("Google OAuth" if has_calendar_token else None),
        "/connect calendar",
    )

    has_drive = (data_dir / "google_token_drive.json").exists()
    _add("drive", "Google Drive", "integrations", has_drive, cmd="/connect google auth drive")

    try:
        from familiar.skills.email.accounts import get_all_accounts

        email_accounts = get_all_accounts()
        has_email = bool(email_accounts)
        email_detail = ", ".join(a["address"] for a in email_accounts) if email_accounts else None
    except Exception:
        has_email = bool(os.environ.get("EMAIL_ADDRESS"))
        email_detail = os.environ.get("EMAIL_ADDRESS") if has_email else None
    _add("email", "Email IMAP/SMTP", "integrations", has_email, email_detail, "/connect email")

    has_twilio = bool(os.environ.get("TWILIO_ACCOUNT_SID"))
    _add("sms", "SMS (Twilio)", "integrations", has_twilio, cmd="/connect sms")

    has_browser = False
    try:
        import playwright  # noqa: F401

        has_browser = True
    except Exception:
        pass
    _add("browser", "Browser (Playwright)", "integrations", has_browser, cmd="/connect browser")

    has_voice = False
    try:
        import faster_whisper  # noqa: F401

        has_voice = True
    except Exception:
        try:
            import whisper  # noqa: F401

            has_voice = True
        except Exception:
            pass
    _add("voice", "Voice (Whisper)", "integrations", has_voice, cmd="/connect voice")

    _add("websearch", "WebSearch (DuckDuckGo)", "integrations", True, "Built-in")

    _add(
        "github",
        "GitHub (issues & PRs)",
        "integrations",
        bool(os.environ.get("GITHUB_TOKEN")),
        cmd="/connect github",
    )
    _add(
        "slack",
        "Slack (Socket Mode)",
        "integrations",
        bool(os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_APP_TOKEN")),
        cmd="/connect slack",
    )

    # ── Self-Hosted ──────────────────────────────────────────
    for env_name, svc_name, display in [
        ("JELLYFIN_URL", "jellyfin", "Jellyfin (media)"),
        ("GITEA_URL", "gitea", "Gitea (code)"),
        ("HOMEASSISTANT_URL", "homeassistant", "Home Assistant"),
        ("JOPLIN_TOKEN", "joplin", "Joplin (notes)"),
        ("PIHOLE_URL", "pihole", "Pi-hole / AdGuard"),
        ("NEXTCLOUD_URL", "nextcloud", "Nextcloud"),
        ("EMAIL_SERVER_DOMAIN", "email_server", "Email Server"),
    ]:
        val = os.environ.get(env_name, "")
        _add(svc_name, display, "selfhosted", bool(val), val[:40] if val else None)

    # Proton Bridge — TCP check
    import socket

    try:
        with socket.create_connection(("127.0.0.1", 1143), timeout=2.0):
            has_proton = True
    except (OSError, TimeoutError):
        has_proton = False
    _add("proton", "Proton Bridge", "selfhosted", has_proton, cmd="/connect proton")

    # ── Security ─────────────────────────────────────────────
    has_vaultwarden = bool(os.environ.get("VAULTWARDEN_URL"))
    _add(
        "vaultwarden",
        "Vaultwarden (passwords)",
        "security",
        has_vaultwarden,
        cmd="/connect security",
    )

    key_dir = Path.home() / ".familiar" / "keys"
    has_encryption = any(key_dir.glob("*.key")) if key_dir.exists() else False
    _add("encryption", "Encryption (at rest)", "security", has_encryption, cmd="/connect security")

    categories = ["llm", "integrations", "selfhosted", "security"]

    return jsonify(
        {
            "services": services,
            "categories": categories,
            "active_provider": current_provider,
        }
    )


@app.route("/api/connect/browse/browser/navigate", methods=["POST"])
@limiter.limit("10 per minute")
@require_auth
def api_browser_navigate():
    """Navigate the browser to a URL and return a page preview."""
    data = request.json or {}
    url = data.get("url", "").strip()
    if not url:
        return jsonify({"success": False, "error": "No URL provided"}), 400

    try:
        from familiar.skills.browser.skill import get_page_content, navigate

        nav_result = navigate({"url": url})
        if nav_result.startswith("❌"):
            return jsonify({"success": False, "error": nav_result})

        # Check for video URL before fetching page content
        video_detected = is_video_url(url)
        video_id = _extract_video_id(url) if video_detected else ""

        content_result = get_page_content({})
        if content_result.startswith("❌"):
            # Navigation succeeded but content fetch failed — return what we have
            result = {
                "success": True,
                "title": nav_result.split("\n")[0].replace("✓ Navigated to: ", ""),
                "url": url,
                "content": "",
            }
            if video_detected:
                result["is_video"] = True
                result["video_id"] = video_id
            return jsonify(result)

        # Parse: "📄 Title\nURL: ...\n\nContent..."
        lines = content_result.split("\n", 3)
        title = lines[0].lstrip("📄 ").strip() if lines else ""
        page_url = lines[1].replace("URL: ", "").strip() if len(lines) > 1 else url
        content = lines[3].strip() if len(lines) > 3 else ""
        content = content[:2000]

        result = {
            "success": True,
            "title": title,
            "url": page_url,
            "content": content,
        }
        if video_detected:
            result["is_video"] = True
            result["video_id"] = video_id
        return jsonify(result)
    except Exception as e:
        logger.exception("Browser navigate error")
        return jsonify({"success": False, "error": str(e)}), 500


@app.route("/api/connect/browse/browser/video/embed", methods=["POST"])
@require_auth
def api_browser_video_embed():
    """Get privacy-friendly embed HTML for a video URL."""
    url = (request.json or {}).get("url", "").strip()
    if not url:
        return jsonify({"success": False, "error": "No URL provided"}), 400
    try:
        video_id = _extract_video_id(url)
        if video_id and is_video_url(url):
            from urllib.parse import urlparse

            host = (urlparse(url).hostname or "").lstrip("www.").lstrip("m.")
            if host in ("youtube.com", "youtu.be"):
                # youtube-nocookie.com = YouTube's privacy-enhanced mode
                # (no cookies, no tracking, always available)
                html = (
                    f'<iframe width="640" height="360"'
                    f' src="https://www.youtube-nocookie.com/embed/{video_id}"'
                    f' frameborder="0"'
                    f' allow="accelerometer; autoplay; encrypted-media;'
                    f' gyroscope; picture-in-picture"'
                    f" allowfullscreen></iframe>"
                )
                return jsonify({"success": True, "html": html})
        # Fallback: try PrivacyPlayer for non-YouTube
        import asyncio

        from familiar.skills.video.player import PrivacyPlayer

        class _Cfg:
            invidious_instance = "https://yewtu.be"

        player = PrivacyPlayer(_Cfg())

        async def _get():
            try:
                return await player.get_embed_html(url, width=640, height=360, autoplay=False)
            finally:
                await player.close()

        html = asyncio.run(_get())
        return jsonify({"success": True, "html": html})
    except Exception as e:
        logger.exception("Video embed error")
        return jsonify({"success": False, "error": str(e)}), 500


@app.route("/api/connect/browse/browser/video/download", methods=["POST"])
@require_auth
def api_browser_video_download():
    """Download a video using yt-dlp."""
    data = request.json or {}
    url = data.get("url", "").strip()
    quality = data.get("quality", "720p")
    if not url:
        return jsonify({"success": False, "error": "No URL provided"}), 400
    try:
        import asyncio

        from familiar.skills.video import VideoSkill

        config = {"video": {"default_quality": quality}}
        skill = VideoSkill(None, config)

        async def _download():
            await skill.start()
            try:
                meta = await skill.download(url, quality=quality, transcribe=False)
                return {
                    "success": True,
                    "title": meta.title,
                    "path": meta.local_path or "",
                    "size": f"{(meta.file_size_bytes or 0) / 1048576:.1f} MB",
                    "duration": meta.duration_formatted,
                }
            finally:
                await skill.stop()

        result = asyncio.run(_download())
        return jsonify(result)
    except Exception as e:
        logger.exception("Video download error")
        return jsonify({"success": False, "error": str(e)}), 500


def _parse_search_results(response: str) -> dict:
    """Extract structured JSON from an agent response containing a SEARCH_RESULTS_JSON: marker.

    The agent's tool returns human-readable text followed by a
    ``SEARCH_RESULTS_JSON:`` line and a JSON object.  This helper finds
    the marker and parses the JSON using brace-depth counting so that
    any trailing text the agent appends after the JSON is ignored.

    Falls back to ``{"items": [], "summary": <truncated response>}``
    when no marker is present (agent responded conversationally).
    """
    marker = "SEARCH_RESULTS_JSON:"
    idx = response.find(marker)
    if idx == -1:
        return {"items": [], "summary": response[:200]}

    json_start = response.find("{", idx + len(marker))
    if json_start == -1:
        return {"items": [], "summary": response[:200]}

    # Brace-depth counting to find the matching closing brace
    depth = 0
    in_string = False
    escape = False
    for i in range(json_start, len(response)):
        ch = response[i]
        if escape:
            escape = False
            continue
        if ch == "\\":
            if in_string:
                escape = True
            continue
        if ch == '"':
            in_string = not in_string
            continue
        if in_string:
            continue
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                try:
                    return json.loads(response[json_start : i + 1])
                except (json.JSONDecodeError, ValueError):
                    break

    return {"items": [], "summary": response[:200]}


@app.route("/api/connect/browse/video/search", methods=["POST"])
@limiter.limit("10 per minute")
@require_auth
def api_video_search_online():
    """Search videos online, routed through the agent for intelligent results."""
    query = (request.json or {}).get("query", "").strip()
    if not query:
        return jsonify({"items": [], "error": "No query provided"}), 400

    try:
        agent = get_agent()
        prompt = f"Search for videos: {query}"
        agent_result = agent.chat_with_results(prompt, user_id="dashboard", channel="web")
        result = _parse_search_results(agent_result.text)
    except Exception:
        logger.exception("Agent video search failed, falling back to direct search")
        from familiar.channels.connect_wizard._browse import search_video_online

        result = search_video_online(query)

    if result.get("error"):
        return jsonify(result), 500
    return jsonify(result)


@app.route("/api/connect/browse/search", methods=["POST"])
@limiter.limit("10 per minute")
@require_auth
def api_connect_browse_search():
    """Search a connected service, routed through the agent for intelligent results."""
    data = request.json or {}
    service = data.get("service", "").strip().lower()
    query = data.get("query", "").strip()
    if not service or not query:
        return jsonify({"items": [], "error": "Missing service or query"}), 400
    if service not in SEARCHABLE_SERVICES:
        return jsonify({"items": [], "error": f"Unknown service: {service}"}), 400

    try:
        agent = get_agent()
        prompt = f"Search {service} for: {query}"
        agent_result = agent.chat_with_results(prompt, user_id="dashboard", channel="web")
        result = _parse_search_results(agent_result.text)
    except Exception:
        logger.exception("Agent search failed, falling back to direct search")
        result = search_service(service, query)

    if result.get("error"):
        return jsonify(result), 400 if "Unknown service" in result["error"] else 500
    return jsonify(result)


@app.route("/api/connect/browse")
@require_auth
def api_connect_browse():
    """Browse data from connected services."""
    return jsonify({"services": browse_all()})


# ============================================================
# Connect Wizard Routes
# ============================================================


@app.route("/api/connect/wizard/start", methods=["POST"])
@require_auth
def api_wizard_start():
    """Start (or restart) the connect wizard."""
    data = request.json or {}
    args = data.get("args", [])
    host = get_wizard_host()
    messages = host.start_wizard(args)
    return jsonify({"wizard_messages": messages})


@app.route("/api/connect/wizard/select", methods=["POST"])
@require_auth
def api_wizard_select():
    """Handle a wizard button click."""
    data = request.json or {}
    key = data.get("key", "")
    if not key:
        return jsonify({"error": "No key provided"}), 400
    host = get_wizard_host()
    messages = host.select_menu(key)
    return jsonify({"wizard_messages": messages})


@app.route("/api/connect/wizard/tab", methods=["POST"])
@require_auth
def api_wizard_tab():
    """Switch to a wizard tab."""
    data = request.json or {}
    tab = data.get("tab", "")
    if not tab:
        return jsonify({"error": "No tab provided"}), 400
    host = get_wizard_host()
    messages = host.switch_tab(tab)
    return jsonify({"wizard_messages": messages})


@app.route("/api/connect/wizard/input", methods=["POST"])
@require_auth
def api_wizard_input():
    """Send text input to the wizard (e.g. API key, URL)."""
    data = request.json or {}
    text = data.get("text", "")
    if not text:
        return jsonify({"error": "No text provided"}), 400
    host = get_wizard_host()
    messages = host.send_text(text)
    return jsonify({"wizard_messages": messages})


@app.route("/api/connect/wizard/state")
@require_auth
def api_wizard_state():
    """Check whether a wizard session is active."""
    host = get_wizard_host()
    return jsonify({"active": host.is_active()})


@app.route("/api/config")
@require_auth
def api_config():
    """Get current configuration."""
    agent = get_agent()
    config = {
        "agent_name": agent.config.agent.name,
        "provider": agent.provider.name,
        "memory_enabled": agent.config.agent.memory_enabled,
        "skills_enabled": agent.config.agent.skills_enabled,
        "scheduler_enabled": agent.config.agent.scheduler_enabled,
    }
    return jsonify(config)


@app.route("/api/config", methods=["POST"])
@require_auth
def api_config_update():
    """Update configuration."""
    data = request.json or {}
    # For now just switch provider
    if "provider" in data:
        agent = get_agent()
        result = agent.switch_provider(data["provider"])
        return jsonify({"result": result})

    return jsonify({"error": "No valid config option"}), 400


@app.route("/api/config/routing")
@require_auth
def api_config_routing():
    """Get current model routing configuration."""
    agent = get_agent()
    return jsonify(agent.get_routing_config())


@app.route("/api/config/routing", methods=["POST"])
@require_auth
def api_config_routing_update():
    """Update model routing mode and manual route assignments."""
    data = request.json or {}
    mode = data.get("mode", "auto")
    routes = data.get("routes")
    agent = get_agent()
    try:
        result = agent.set_routing_config(mode, routes)
        return jsonify(result)
    except ValueError as e:
        return jsonify({"error": str(e)}), 400


@app.route("/api/history")
@require_auth
def api_history():
    """Get conversation history."""
    agent = get_agent()
    history = agent.history.get_history("dashboard", "web", limit=50)
    return jsonify({"history": history})


@app.route("/api/history", methods=["DELETE"])
@require_auth
def api_history_clear():
    """Clear conversation history."""
    agent = get_agent()
    agent.clear_history("dashboard", "web")
    return jsonify({"success": True})


# ============================================================
# Onboarding Routes (no auth — first-run, localhost only)
# ============================================================

# Session-scoped engine instance for the onboarding flow
_onboard_engine = None
_onboard_lock = threading.Lock()
_activation_lock = threading.Lock()


def _get_onboard_engine():
    """Get or create the onboarding engine for this session."""
    global _onboard_engine
    with _onboard_lock:
        if _onboard_engine is None:
            from familiar.onboard_engine import OnboardingEngine

            _onboard_engine = OnboardingEngine()
    return _onboard_engine


def _require_localhost(f):
    """Only allow onboard routes from localhost.

    Set the ``FAMILIAR_ONBOARD_LAN`` environment variable to ``1`` or
    ``true`` to allow non-localhost IPs to access ``/onboard`` routes,
    enabling setup from another device on the same network.
    """

    @wraps(f)
    def decorated(*args, **kwargs):
        lan_allowed = os.environ.get("FAMILIAR_ONBOARD_LAN", "").lower() in ("1", "true")
        if not lan_allowed:
            remote = request.remote_addr
            if remote not in ("127.0.0.1", "::1", "localhost"):
                abort(403, description="Onboarding is only available from localhost")
        return f(*args, **kwargs)

    return decorated


@app.route("/onboard")
@_require_localhost
def onboard_page():
    """Serve the onboarding wizard UI."""
    return render_template("onboard.html")


@app.route("/api/onboard/detect")
@_require_localhost
def api_onboard_detect():
    """Detect available LLM providers."""
    engine = _get_onboard_engine()
    result = engine.detect_providers()
    return jsonify(result.data)


@app.route("/api/onboard/validate/<step>", methods=["POST"])
@_require_localhost
def api_onboard_validate(step):
    """Validate a single onboarding step."""
    engine = _get_onboard_engine()
    data = request.json or {}

    validators = {
        "provider": engine.validate_provider,
        "channels": engine.validate_channels,
        "owner_pin": engine.validate_owner_pin,
        "identity": engine.validate_identity,
        "briefing": engine.validate_briefing,
        "encryption": engine.validate_encryption,
    }

    validator = validators.get(step)
    if not validator:
        return jsonify({"success": False, "errors": [f"Unknown step: {step}"]}), 400

    result = validator(data)
    return jsonify(
        {
            "success": result.success,
            "errors": result.errors,
            "warnings": result.warnings,
            "data": result.data,
        }
    )


@app.route("/api/onboard/ollama-bundles")
@_require_localhost
def api_onboard_ollama_bundles():
    """Get RAM-based Ollama model bundle recommendations."""
    engine = _get_onboard_engine()
    bundles = engine.get_ollama_bundles()
    return jsonify(bundles)


@app.route("/api/onboard/test-message", methods=["POST"])
@_require_localhost
def api_onboard_test_message():
    """Send a test message after activation."""
    engine = _get_onboard_engine()
    result = engine.send_test_message()
    return jsonify(
        {
            "success": result.success,
            "warnings": result.warnings,
            "errors": result.errors,
        }
    )


@app.route("/api/onboard/checklist")
@_require_localhost
def api_onboard_checklist():
    """Get post-activation next-steps checklist."""
    engine = _get_onboard_engine()
    return jsonify(engine.get_post_activation_checklist())


@app.route("/api/onboard/save-state", methods=["POST"])
@_require_localhost
def api_onboard_save_state():
    """Persist wizard state server-side for cross-browser resume."""
    engine = _get_onboard_engine()
    data = request.json or {}
    step = data.get("step", 0)
    if data.get("state"):
        engine._state = data["state"]
    engine.save_state(step)
    return jsonify({"success": True})


@app.route("/api/onboard/load-state")
@_require_localhost
def api_onboard_load_state():
    """Load saved wizard state from server."""
    engine = _get_onboard_engine()
    saved = engine.load_saved_state()
    if saved:
        return jsonify(
            {"found": True, "step": saved.get("step", 0), "state": saved.get("state", {})}
        )
    return jsonify({"found": False})


@app.route("/api/onboard/clear-state", methods=["POST"])
@_require_localhost
def api_onboard_clear_state():
    """Clear saved wizard state on the server."""
    engine = _get_onboard_engine()
    engine.clear_saved_state()
    return jsonify({"success": True})


@app.route("/api/onboard/activate", methods=["POST"])
@_require_localhost
def api_onboard_activate():
    """Activate the configuration (write config, install deps)."""
    engine = _get_onboard_engine()
    result = engine.activate()
    return jsonify(
        {
            "success": result.success,
            "errors": result.errors,
            "warnings": result.warnings,
        }
    )


@app.route("/api/onboard/activate/stream")
@_require_localhost
def api_onboard_activate_stream():
    """SSE stream of activation progress events."""
    if not _activation_lock.acquire(blocking=False):
        return jsonify({"error": "Activation already in progress"}), 409

    engine = _get_onboard_engine()
    msg_queue = queue.Queue()

    def _notify(msg):
        msg_queue.put(msg)

    def _run_activation():
        try:
            result = engine.activate(notify=_notify)
            msg_queue.put(("__DONE__", result.success, result.errors))
        finally:
            _activation_lock.release()

    # Run activation in background thread
    t = threading.Thread(target=_run_activation, daemon=True)
    t.start()

    def generate():
        progress = 10
        step_increment = 12  # ~8 steps, 12% each gets to ~100%
        while True:
            try:
                item = msg_queue.get(timeout=60)
            except queue.Empty:
                yield f"data: {json.dumps({'message': 'Waiting...', 'progress': progress})}\n\n"
                continue

            if isinstance(item, tuple) and item[0] == "__DONE__":
                _, success, errors = item
                payload = {
                    "done": True,
                    "success": success,
                    "progress": 100,
                    "message": "Complete!" if success else "; ".join(errors),
                    "type": "ok" if success else "error",
                }
                yield f"data: {json.dumps(payload)}\n\n"
                break
            else:
                progress = min(progress + step_increment, 95)
                payload = {"message": str(item), "progress": progress, "type": "ok"}
                yield f"data: {json.dumps(payload)}\n\n"

    resp = Response(generate(), mimetype="text/event-stream")
    resp.headers["Cache-Control"] = "no-cache"
    resp.headers["X-Accel-Buffering"] = "no"
    return resp


# ============================================================
# Helpers
# ============================================================


def _get_uptime():
    """Get system uptime."""
    try:
        with open("/proc/uptime", "r") as f:
            uptime_seconds = float(f.readline().split()[0])
            days = int(uptime_seconds // 86400)
            hours = int((uptime_seconds % 86400) // 3600)
            minutes = int((uptime_seconds % 3600) // 60)

            if days > 0:
                return f"{days}d {hours}h {minutes}m"
            elif hours > 0:
                return f"{hours}h {minutes}m"
            else:
                return f"{minutes}m"
    except (IOError, OSError, ValueError, IndexError):
        return "unknown"


# ============================================================
# Main
# ============================================================


def run_dashboard(agent=None, gateway=None, host="127.0.0.1", port=5000, debug=False):
    """Run the web dashboard."""
    global _agent, _gateway
    _agent = agent
    _gateway = gateway

    # Create template and static dirs if needed
    TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
    STATIC_DIR.mkdir(parents=True, exist_ok=True)

    print(f"\n🌐 Dashboard running at http://{host}:{port}")
    app.run(host=host, port=port, debug=debug, threaded=True)


if __name__ == "__main__":
    run_dashboard(debug=False)
