# 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 collections
import json
import logging
import os
import queue
import re
import subprocess
import threading
import time
from datetime import datetime
from functools import wraps
from pathlib import Path

from flask import (
    Flask,
    Response,
    abort,
    jsonify,
    make_response,
    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 AGENT_DIR, CONFIG_FILE, DATA_DIR
except ImportError:
    AGENT_DIR = Path.home() / ".familiar"
    CONFIG_FILE = AGENT_DIR / "config.yaml"
    DATA_DIR = AGENT_DIR / "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))
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024  # 16 MB request body limit

# Register blueprints (routes extracted from this file into blueprints/*.py)
from familiar.dashboard.blueprints import register_all_blueprints

register_all_blueprints(app)

# ── Svelte v2 UI (default at /) ───────────────────────────────────────────────
_V2_BUILD_DIR = Path(__file__).parent / "frontend" / "build"
if _V2_BUILD_DIR.is_dir():
    from flask import send_from_directory as _send_v2

    def _serve_svelte(path: str = "index.html"):
        """Serve the Svelte v2 UI from frontend/build/.

        For index.html requests, inject the API key via a script tag
        so the Svelte app can authenticate with Flask endpoints.
        """
        full = _V2_BUILD_DIR / path
        if full.is_file() and path != "index.html":
            return _send_v2(str(_V2_BUILD_DIR), path)

        # Serve index.html with injected API key
        index_path = _V2_BUILD_DIR / "index.html"
        if not index_path.exists():
            return "v2 build not found", 404

        html = index_path.read_text()

        # Get the API key (same logic as require_auth)
        api_key = os.environ.get("FAMILIAR_DASHBOARD_KEY", "")
        if not api_key:
            api_key = getattr(require_auth, "_auto_key", "") or ""

        # Inject API key script before </head>
        inject = (
            f'<script>window.__FAMILIAR_API_KEY__="{api_key}";</script>'
        )
        html = html.replace("</head>", f"{inject}</head>", 1)

        return Response(html, mimetype="text/html")

    # Paths that should NOT be caught by the Svelte catch-all
    _RESERVED_PREFIXES = ("api/", "classic", "onboard", "static/", "v2/")

    # v2 is the default UI at /
    @app.route("/")
    @app.route("/<path:path>")
    def serve_v2_root(path: str = "index.html"):
        # First-run: always redirect to onboard wizard
        if _first_run_mode and path == "index.html":
            return redirect("/onboard")
        # Don't intercept API, classic, onboard, or static routes
        if path.startswith(_RESERVED_PREFIXES):
            abort(404)
        return _serve_svelte(path)

    # Keep /v2 as redirect for old bookmarks
    @app.route("/v2/")
    @app.route("/v2/<path:path>")
    def serve_v2(path: str = ""):
        return redirect(f"/{path}" if path else "/")


# 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.after_request
def log_errors_to_analytics(response):
    """Record any 5xx response to analytics for the Activity Errors panel."""
    if response.status_code >= 500:
        try:
            from familiar.core.analytics import get_analytics_store

            get_analytics_store().record_tool_call(
                user_id="system",
                channel="dashboard",
                skill="api",
                action=request.path,
                latency_ms=0,
                success=False,
                error_message=f"{response.status_code} {request.method} {request.path}",
                metadata={"method": request.method, "status": response.status_code},
            )
        except Exception:
            pass
    return response


@app.after_request
def set_security_headers(response):
    """Add Content-Security-Policy and other security headers."""
    # CSP: allow self + CDN fonts/icons used by dashboard
    csp = (
        "default-src 'self'; "
        "script-src 'self' 'unsafe-inline'; "
        "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; "
        "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; "
        "img-src 'self' data: blob:; "
        "connect-src 'self'; "
        "media-src 'self' blob:; "
        "worker-src 'self'; "
        "frame-src 'none'"
    )
    response.headers["Content-Security-Policy"] = csp
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    return response


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


# ============================================================
# Global Error Capture — record ALL errors to analytics store
# ============================================================


@app.errorhandler(500)
def handle_500(error):
    """Capture 500 errors into the analytics store so Activity Errors shows them."""
    try:
        from familiar.core.analytics import get_analytics_store

        store = get_analytics_store()
        store.record_tool_call(
            user_id="system",
            channel="dashboard",
            skill="api",
            action=request.path,
            latency_ms=0,
            success=False,
            error_message=f"500 INTERNAL SERVER ERROR: {error}",
            metadata={"method": request.method, "remote_addr": request.remote_addr},
        )
    except Exception:
        pass  # Don't let error logging cause more errors
    return (
        jsonify({"error": "Internal server error", "path": request.path}),
        500,
    )


@app.errorhandler(404)
def handle_404(error):
    """Return JSON for API 404s instead of HTML."""
    if request.path.startswith("/api/"):
        return jsonify({"error": "Not found", "path": request.path}), 404
    # Non-API 404s fall through to Svelte catch-all
    return error


# ============================================================
# 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):
        # First-run bypass — no auth needed during onboarding setup
        if _first_run_mode:
            return f(*args, **kwargs)

        expected_key = os.environ.get("FAMILIAR_DASHBOARD_KEY")

        if not expected_key:
            # Persist auto-generated key via keyring so restarts don't break sessions
            if not getattr(require_auth, "_auto_key", None):
                import secrets as _secrets

                try:
                    from familiar.core.credential_store import keyring_get, keyring_set

                    stored = keyring_get("dashboard", "api_key")
                    if stored:
                        require_auth._auto_key = stored
                        logger.info("Loaded dashboard auth key from keyring")
                    else:
                        require_auth._auto_key = _secrets.token_urlsafe(32)
                        keyring_set("dashboard", "api_key", require_auth._auto_key)
                        logger.info("Generated and persisted dashboard auth key in keyring")
                except Exception:
                    # Fallback to plain file if keyring fails
                    _key_path = Path.home() / ".familiar" / "data" / "dashboard_key"
                    if _key_path.exists():
                        require_auth._auto_key = _key_path.read_text().strip()
                    else:
                        require_auth._auto_key = _secrets.token_urlsafe(32)
                        _key_path.parent.mkdir(parents=True, exist_ok=True)
                        _key_path.write_text(require_auth._auto_key)
                        _key_path.chmod(0o600)
                print(f"\n{'=' * 60}")
                print("  Dashboard auth key:")
                print(f"   {require_auth._auto_key}")
                print("   Add to requests as X-API-Key header")
                print("   Set FAMILIAR_DASHBOARD_KEY env var to override")
                print(f"{'=' * 60}\n")
            expected_key = require_auth._auto_key

        # Check for API key in header only (query params leak to logs/history)
        provided_key = request.headers.get("X-API-Key")

        if not provided_key and request.args.get("api_key"):
            logger.warning(
                "API key in query param is deprecated (leaks to logs/history). "
                "Use X-API-Key header instead. Remote: %s", request.remote_addr
            )
            provided_key = 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()

# Scheduled message queue — briefings, heartbeats, reminders pushed here
_scheduled_messages = collections.deque(maxlen=50)

# Persistent latest briefing — survives page refreshes, shows until replaced
_BRIEFING_FILE = DATA_DIR / "latest_briefing.json"


def _save_briefing(message: str, source: str = "scheduled", sections: dict = None):
    """Persist the latest briefing to disk so it survives restarts.

    When sections is provided (from _generate_briefing_full), the full
    structured data is saved so /api/briefing can serve it without
    regenerating.  Plain-text saves (from scheduler) omit sections.
    """
    data = {
        "message": message,
        "source": source,
        "timestamp": datetime.now().isoformat(),
    }
    if sections is not None:
        data["sections"] = sections
    try:
        _BRIEFING_FILE.parent.mkdir(parents=True, exist_ok=True)
        _BRIEFING_FILE.write_text(json.dumps(data, indent=2, default=str))
    except OSError as e:
        logger.warning("Failed to save briefing: %s", e)


def _load_briefing() -> dict:
    """Load the latest briefing from disk."""
    if _BRIEFING_FILE.exists():
        try:
            return json.loads(_BRIEFING_FILE.read_text())
        except (json.JSONDecodeError, OSError):
            pass
    return {}


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

        _agent = Agent()
    return _agent


_marketplace_catalog = None
_service_manager = None
_service_lock = threading.Lock()


def _get_service_manager():
    """Get or create the service manager singleton."""
    global _service_manager
    with _service_lock:
        if _service_manager is None:
            from familiar.services.manager import ServiceManager

            _service_manager = ServiceManager()
    return _service_manager


def _get_marketplace_catalog():
    global _marketplace_catalog
    if _marketplace_catalog is None:
        from familiar.marketplace.catalog import MarketplaceCatalog

        _marketplace_catalog = MarketplaceCatalog(auto_seed=True)
    return _marketplace_catalog


def _get_message_bridge():
    """Get the message bridge from the agent's gateway, or None."""
    try:
        agent = get_agent()
        gw = getattr(agent, "_gateway", None)
        if gw and hasattr(gw, "message_bridge") and gw.message_bridge:
            return gw.message_bridge
    except Exception:
        pass
    return None


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


# ============================================================
_first_run_mode = False


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

    # Load ~/.familiar/.env so API keys persist across restarts
    _env_path = AGENT_DIR / ".env"
    if _env_path.exists():
        try:
            from dotenv import load_dotenv

            load_dotenv(str(_env_path), override=False)
        except ImportError:
            # Manual fallback if python-dotenv isn't installed
            for _line in _env_path.read_text().splitlines():
                _line = _line.strip()
                if _line and not _line.startswith("#") and "=" in _line:
                    _k, _v = _line.split("=", 1)
                    _k, _v = _k.strip(), _v.strip().strip("'\"")
                    if _k and _k not in os.environ:
                        os.environ[_k] = _v

    # Enforce secure permissions on credential files
    try:
        from familiar.core.paths import enforce_token_permissions

        enforce_token_permissions()
    except Exception:
        pass

    # Pre-authorize the dashboard user as OWNER so they get full capabilities
    # (web search, email, files, etc.) from the very first message.
    if agent and hasattr(agent, "sessions"):
        from familiar.core.security import TRUST_CAPABILITIES, TrustLevel

        session = agent.sessions.get_or_create_session("dashboard", "web")
        if session.trust_level != TrustLevel.OWNER:
            session.set_trust_level(TrustLevel.OWNER)
            session._explicit_grants.update(
                c.value for c in TRUST_CAPABILITIES.get(TrustLevel.OWNER, set())
            )
            session.user_role = "admin"
            logger.info("Dashboard user promoted to OWNER trust")
        # Apply budget settings from config.yaml (or generous default if not set).
        # budget_enabled=False (default) → unlimited; True → user-specified amount.
        _budget_enabled = False
        _budget_amount = 10.0
        try:
            import yaml as _yaml

            _cfg_path = CONFIG_FILE
            if _cfg_path.exists():
                with open(_cfg_path) as _f:
                    _cfg = _yaml.safe_load(_f) or {}
                _budget_enabled = _cfg.get("agent", {}).get("budget_enabled", False)
                _budget_amount = _cfg.get("agent", {}).get("daily_budget_amount", 10.0)
        except Exception:
            pass
        session.daily_budget = _budget_amount if _budget_enabled else 999999.0
        agent.sessions.save_session(session)

    # Wire scheduler delivery callback so briefings show in the dashboard
    if agent and hasattr(agent, "scheduler") and agent.scheduler:

        def _deliver_to_dashboard(message, channel, chat_id):
            _scheduled_messages.append(
                {
                    "timestamp": datetime.now().isoformat(),
                    "message": message,
                    "channel": channel or "internal",
                }
            )
            # NOTE: Do NOT save scheduler chat messages as briefings.
            # The structured briefing panel uses _generate_briefing_full()
            # which calls the proactive skill for calendar/email/weather data.
            # Scheduler agent.chat() produces unstructured LLM text that
            # overwrites the good structured cache.
            logger.info("Scheduled message queued for dashboard (%d chars)", len(message or ""))

        agent.scheduler.set_delivery_callback(_deliver_to_dashboard)
        logger.info("Dashboard delivery callback registered")

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

    config_path = CONFIG_FILE
    display_host = "127.0.0.1" if host == "0.0.0.0" else host
    base_url = f"http://{display_host}:{port}"

    import webbrowser

    if first_run:
        # First run — no auth needed, clean URL
        url = base_url
        print("\n  Open in your browser to get started:")
        print(f"  {url}\n")
    else:
        # Normal run — resolve or generate a persistent dashboard key
        key = os.environ.get("FAMILIAR_DASHBOARD_KEY")
        if not key:
            # Try to load from .env
            env_path = AGENT_DIR / ".env"
            if env_path.exists():
                for line in env_path.read_text().splitlines():
                    if line.startswith("FAMILIAR_DASHBOARD_KEY="):
                        key = line.split("=", 1)[1].strip().strip("'\"")
                        break
        if not key:
            # Generate and persist so the key survives restarts
            import secrets as _secrets

            key = _secrets.token_urlsafe(32)
            env_path = AGENT_DIR / ".env"
            try:
                env_path.parent.mkdir(parents=True, exist_ok=True)
                with open(env_path, "a") as f:
                    f.write(f"\nFAMILIAR_DASHBOARD_KEY={key}\n")
            except OSError:
                pass
        os.environ["FAMILIAR_DASHBOARD_KEY"] = key
        require_auth._auto_key = key
        url = f"{base_url}?api_key={key}"
        print(f"\n  Dashboard: {url}\n")

    # Auto-open browser after a short delay to let Flask bind
    import threading as _thr

    _thr.Timer(1.5, webbrowser.open, args=[url]).start()

    app.run(host=host, port=port, debug=debug, threaded=True)


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

