# 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 threading
from datetime import datetime
from functools import wraps
from pathlib import Path

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

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 origin:
            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


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

        _agent = Agent()
    return _agent


# ============================================================
# 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
    message = data.get("message", "")

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

    agent = get_agent()
    response = agent.chat(message, user_id="dashboard", channel="web")

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


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

    # 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,
    )
    _add(
        "ollama",
        "Ollama (local)",
        "llm",
        "Ollama" in current_provider,
        current_provider if "Ollama" in current_provider else None,
    )

    # ── 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 ImportError:
        pass
    _add("browser", "Browser (Playwright)", "integrations", has_browser, cmd="/connect browser")

    has_voice = False
    try:
        import faster_whisper  # noqa: F401

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

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

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

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