# 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,
    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 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


_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


# ============================================================
# 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")

    # Load creature state for server-side template rendering
    creature_name = "Familiar"
    creature_species_label = ""
    creature_color_name = ""
    owner_name = ""
    is_new_setup = False
    try:
        from familiar.creature.palette import hex_to_palette_name
        from familiar.creature.species import SPECIES_INFO
        from familiar.creature.state import load_creature as _load_creature

        creature = _load_creature()
        if creature and creature.name:
            creature_name = creature.name
            info = SPECIES_INFO.get(creature.species)
            creature_species_label = info.label if info else creature.species
            owner_name = creature.owner_name
            creature_color_name = hex_to_palette_name(creature.color_body)
            # First launch: creature exists but hasn't completed dashboard tutorial.
            # Check a server-side flag (not just API keys, since users may have
            # keys set but still be going through onboarding for the first time).
            tutorial_flag = Path.home() / ".familiar" / "data" / ".dashboard_tutorial_done"
            if not tutorial_flag.exists():
                is_new_setup = True
    except Exception as e:
        logger.debug("Error suppressed: %s", e)
    # Version from pyproject.toml (via importlib.metadata)
    version = ""
    try:
        from importlib.metadata import version as _pkg_version

        version = _pkg_version("familiar-agent")
    except Exception as e:
        logger.debug("Error suppressed: %s", e)
    resp = make_response(
        render_template(
            "dashboard.html",
            creature_name=creature_name,
            creature_species_label=creature_species_label,
            creature_color_name=creature_color_name,
            owner_name=owner_name,
            is_new_setup=is_new_setup,
            version=version,
        )
    )
    resp.headers["Cache-Control"] = "no-cache"
    return resp


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


# ============================================================
# Activity Monitor Endpoints
# ============================================================


@app.route("/api/activity/metrics")
@require_auth
def api_activity_metrics():
    """Get tool and LLM performance metrics with alerts."""
    try:
        from familiar.core.metrics import get_metrics_collector

        mc = get_metrics_collector()

        # Tool metrics sorted by call count
        tools = []
        for tm in sorted(
            mc.get_all_tool_metrics().values(), key=lambda t: t.total_calls, reverse=True
        ):
            tools.append({
                "tool_name": tm.tool_name,
                "total_calls": tm.total_calls,
                "success_rate": round(tm.success_rate, 4),
                "avg_duration_ms": round(tm.avg_duration_ms, 1),
                "p95_duration_ms": round(tm.p95_duration_ms, 1),
                "timeout_count": tm.timeout_count,
                "last_error": tm.last_error,
            })

        # LLM provider metrics sorted by call count
        llm_providers = []
        for lm in sorted(
            mc.get_all_llm_metrics().values(), key=lambda m: m.total_calls, reverse=True
        ):
            llm_providers.append({
                "provider": lm.provider,
                "model": lm.model,
                "total_calls": lm.total_calls,
                "success_rate": round(lm.success_rate, 4),
                "avg_duration_ms": round(lm.avg_duration_ms, 1),
                "p95_duration_ms": round(lm.p95_duration_ms, 1),
                "total_prompt_tokens": lm.total_prompt_tokens,
                "total_completion_tokens": lm.total_completion_tokens,
                "total_cost_usd": round(lm.total_cost_usd, 6),
                "rate_limit_errors": lm.rate_limit_errors,
                "last_error": lm.last_error,
            })

        # Alerts
        alerts = [a.to_dict() for a in mc.get_alerts()]

        # Rollup KPIs
        tool_summary = mc.get_tool_summary()
        llm_summary = mc.get_llm_summary()

        return jsonify({
            "tools": tools,
            "llm_providers": llm_providers,
            "alerts": alerts,
            "kpi": {
                "total_tool_calls": tool_summary.get("total_calls", 0),
                "tool_success_rate": round(tool_summary.get("overall_success_rate", 1.0), 4),
                "total_tokens": llm_summary.get("total_tokens", 0),
                "total_cost_usd": round(llm_summary.get("total_cost_usd", 0.0), 6),
            },
        })
    except Exception:
        logger.debug("Activity metrics unavailable", exc_info=True)
        return jsonify({
            "tools": [], "llm_providers": [], "alerts": [],
            "kpi": {"total_tool_calls": 0, "tool_success_rate": 1.0,
                    "total_tokens": 0, "total_cost_usd": 0.0},
        })


@app.route("/api/activity/errors")
@require_auth
def api_activity_errors():
    """Get recent error events from analytics store."""
    try:
        from familiar.core.analytics import EventType, get_analytics_store

        store = get_analytics_store()
        events = store.query(event_type=EventType.ERROR, limit=50)
        errors = []
        for e in events:
            errors.append({
                "timestamp": e.timestamp.isoformat() if e.timestamp else "",
                "skill": e.skill or "",
                "channel": e.channel or "",
                "model": e.model or "",
                "error_message": e.error_message or "",
                "latency_ms": e.latency_ms,
            })
        return jsonify({"errors": errors})
    except Exception:
        logger.debug("Activity errors unavailable", exc_info=True)
        return jsonify({"errors": []})


@app.route("/api/activity/health")
@require_auth
def api_activity_health():
    """Get system health: resources, dependencies, circuit breakers."""
    result = {"status": "unknown", "uptime_seconds": 0,
              "dependencies": [], "resources": {}, "circuits": {},
              "routing": {}}
    try:
        from familiar.core.health import get_health_checker

        hc = get_health_checker()
        health = hc.check_health(include_resources=True)
        hdict = health.to_dict()
        result["status"] = hdict.get("status", "unknown")
        result["uptime_seconds"] = hdict.get("uptime_seconds", 0)
        result["resources"] = hdict.get("resources", {})
        result["dependencies"] = hdict.get("dependencies", [])
    except Exception:
        logger.debug("Health status unavailable", exc_info=True)

    try:
        from familiar.core.resilience import get_all_circuit_status

        result["circuits"] = get_all_circuit_status()
    except Exception:
        logger.debug("Circuit breaker status unavailable", exc_info=True)

    # Routing context: active provider + fallback chain + rate limits
    try:
        agent = get_agent()
        result["routing"]["active_provider"] = agent.provider.name
        if hasattr(agent, "providers") and agent.providers.model_router:
            router = agent.providers.model_router
            result["routing"]["fallback_chain"] = list(
                getattr(router, "_fallback_chain", [])
            )
            result["routing"]["available_models"] = list(router._models.keys())
        if hasattr(agent, "providers"):
            tracker = agent.providers.capacity_tracker
            result["rate_limits"] = tracker.get_status()
    except Exception:
        logger.debug("Routing info unavailable", exc_info=True)

    return jsonify(result)


@app.route("/api/creature")
@require_auth
def api_creature():
    """Get creature state and rendered sprite for the dashboard."""
    from familiar.creature.palette import build_color_map, hex_to_palette_name
    from familiar.creature.renderer_html import render_frame_html
    from familiar.creature.species import SPECIES_INFO
    from familiar.creature.sprites import get_sprite_frames
    from familiar.creature.state import load_creature

    creature = load_creature()
    if creature is None:
        return jsonify({"exists": False})

    colors = build_color_map(creature.color_body, creature.color_eyes, creature.color_accent)
    frames = get_sprite_frames(creature.species, creature.evolution_stage, "idle")
    frame = frames[0] if frames else []
    sprite_html = render_frame_html(frame, colors)

    species_info = SPECIES_INFO.get(creature.species)
    label = species_info.label if species_info else creature.species
    color_name = hex_to_palette_name(creature.color_body)

    return jsonify(
        {
            "exists": True,
            "name": creature.name,
            "species": label,
            "species_key": creature.species,
            "color_name": color_name,
            "color_body": creature.color_body,
            "color_eyes": creature.color_eyes,
            "color_accent": creature.color_accent,
            "color_voice": creature.color_voice,
            "stage": creature.evolution_stage,
            "interactions": creature.total_interactions,
            "born": creature.born_at[:10] if creature.born_at else "",
            "sprite_html": sprite_html,
            "owner_name": creature.owner_name,
        }
    )


@app.route("/api/creature", methods=["POST"])
@require_auth
def api_creature_update():
    """Update creature name, species, or colors."""
    from familiar.creature.palette import (
        build_color_map,
        hex_to_palette_name,
        resolve_color,
        rgb_to_hex,
    )
    from familiar.creature.renderer_html import render_frame_html
    from familiar.creature.species import SPECIES_INFO, SPECIES_ORDER, get_species_info
    from familiar.creature.sprites import get_sprite_frames
    from familiar.creature.state import load_creature, save_creature

    creature = load_creature()
    if creature is None:
        return jsonify({"error": "No creature exists"}), 404

    data = request.json or {}

    # Validate and apply name
    if "name" in data:
        name = str(data["name"]).strip()
        if not name or len(name) > 32:
            return jsonify({"error": "Name must be 1-32 characters"}), 400
        creature.name = name

    # Validate and apply species
    if "species" in data:
        sp = str(data["species"]).strip().lower()
        if get_species_info(sp) is None:
            return jsonify({"error": f"Unknown species: {sp}"}), 400
        creature.species = sp

    # Validate and apply evolution stage
    if "evolution_stage" in data:
        stage = str(data["evolution_stage"]).strip().lower()
        if stage not in ("baby", "juvenile", "adult"):
            return jsonify({"error": "Stage must be baby, juvenile, or adult"}), 400
        creature.evolution_stage = stage

    # Validate and apply total_interactions (for reset)
    if "total_interactions" in data:
        try:
            count = int(data["total_interactions"])
            if count < 0:
                raise ValueError
            creature.total_interactions = count
        except (ValueError, TypeError):
            return jsonify({"error": "Invalid interaction count"}), 400

    # Validate and apply colors
    for field in ("color_body", "color_eyes", "color_accent", "color_voice"):
        if field in data:
            raw = str(data[field]).strip()
            rgb = resolve_color(raw)
            if rgb is None:
                return jsonify({"error": f"Invalid color for {field}: {raw}"}), 400
            setattr(creature, field, rgb_to_hex(*rgb))

    save_creature(creature)

    # Return refreshed state
    colors = build_color_map(creature.color_body, creature.color_eyes, creature.color_accent)
    frames = get_sprite_frames(creature.species, creature.evolution_stage, "idle")
    frame = frames[0] if frames else []
    sprite_html = render_frame_html(frame, colors)

    species_info = SPECIES_INFO.get(creature.species)
    label = species_info.label if species_info else creature.species
    color_name = hex_to_palette_name(creature.color_body)

    return jsonify(
        {
            "exists": True,
            "name": creature.name,
            "species": label,
            "species_key": creature.species,
            "color_name": color_name,
            "color_body": creature.color_body,
            "color_eyes": creature.color_eyes,
            "color_accent": creature.color_accent,
            "color_voice": creature.color_voice,
            "stage": creature.evolution_stage,
            "interactions": creature.total_interactions,
            "born": creature.born_at[:10] if creature.born_at else "",
            "sprite_html": sprite_html,
            "owner_name": creature.owner_name,
        }
    )


@app.route("/api/creature/preview")
@require_auth
def api_creature_preview():
    """Render a sprite preview without saving. For live preview in settings."""
    from familiar.creature.palette import build_color_map
    from familiar.creature.renderer_html import render_frame_html
    from familiar.creature.species import get_species_info
    from familiar.creature.sprites import get_sprite_frames
    from familiar.creature.state import load_creature

    creature = load_creature()
    stage = request.args.get("stage", creature.evolution_stage if creature else "baby")
    if stage not in ("baby", "juvenile", "adult"):
        stage = "baby"

    species = request.args.get("species", creature.species if creature else "fox")
    if get_species_info(species) is None:
        species = "fox"
    color_body = request.args.get("color_body", creature.color_body if creature else "#4A90D9")
    color_eyes = request.args.get("color_eyes", creature.color_eyes if creature else "#FFD700")
    color_accent = request.args.get(
        "color_accent", creature.color_accent if creature else "#FF6B6B"
    )

    colors = build_color_map(color_body, color_eyes, color_accent)
    frames = get_sprite_frames(species, stage, "idle")
    frame = frames[0] if frames else []
    sprite_html = render_frame_html(frame, colors)

    return jsonify({"sprite_html": sprite_html})


@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", "")
    files = data.get("files", [])

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

    # Prepend file context so the agent sees attached file paths
    if files:
        lines = ["[Attached files — use read_document or appropriate filereader tool to process]"]
        for f in files:
            fname = f.get("filename", "unknown")
            fpath = f.get("path", "")
            fsize = f.get("size", 0)
            if fsize >= 1024 * 1024:
                size_str = f"{fsize / (1024 * 1024):.1f} MB"
            elif fsize >= 1024:
                size_str = f"{fsize / 1024:.1f} KB"
            else:
                size_str = f"{fsize} B"
            lines.append(f"  - {fname} ({size_str}): {fpath}")
        file_context = "\n".join(lines) + "\n\n"
        message = file_context + (message or "Please analyze the attached file(s).")

    # 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()})

    # Route /start to the tarot onboarding spread
    if stripped.lower().startswith("/start"):
        host = get_wizard_host()
        messages = host.start()
        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:
        logger.exception("Voice STT error")
        return jsonify({"error": "Internal server error"}), 500
    finally:
        if tmp_path:
            try:
                os.unlink(tmp_path)
            except OSError as e:
                logger.debug("I/O error suppressed: %s", e)


@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

    # Route /start to tarot onboarding (non-streaming)
    if stripped.lower().startswith("/start"):
        host = get_wizard_host()
        messages = host.start()
        text = "\n".join(m.get("text", str(m)) for m in messages)

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

        resp = Response(_start_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"\brm\s+-rf\s+~",  # rm -rf ~
    r"\bmkfs\b",
    r"\bdd\s+if=",
    r">\s*/dev/sd[a-z]",
    r":\(\)\s*\{\s*:\|:\s*&\s*\}\s*;",  # fork bomb
    r"\bcurl\b.*\|\s*\b(sh|bash|zsh)\b",  # curl|sh
    r"\bwget\b.*\|\s*\b(sh|bash|zsh)\b",  # wget|sh
    r"\bsudo\b",
    r"\bchmod\s+777\b",
    r"\bpython[23]?\s+-c\b",
    r"\beval\b",
    r"\bnc\s+-[^\s]*e\b",  # nc -e reverse shell
    r"\bbase64\s+-d\b.*\|\s*\b(sh|bash)\b",  # base64 -d|sh
    r"/dev/tcp/",
    r"\biptables\b",
    r"\bsystemctl\b",
    r">\s*/etc/",
]


@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

    logger.info("Shell command: %s", command[:200])

    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:
        return jsonify({"error": "Failed to add task"}), 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:
        return jsonify({"error": "Failed to complete task"}), 500


@app.route("/api/skills")
@require_auth
def api_skills():
    """Get loaded skills with prerequisite status."""
    agent = get_agent()
    from familiar.core.skills import check_skill_prerequisites

    prereq_failures = check_skill_prerequisites()
    skills = []
    for s in agent.skills.list_skills():
        name = s["name"]
        failures = prereq_failures.get(name, [])
        if s["enabled"] and not failures:
            status = "connected"
        elif failures:
            status = "setup_required"
        else:
            status = "optional"
        skills.append(
            {
                **s,
                "configured": s["enabled"] and not failures,
                "status": status,
                "tools_count": len(s.get("tools", [])),
                "setup_command": f"/connect {name}",
                "prerequisites": failures,
            }
        )
    return jsonify({"skills": skills})


@app.route("/api/skills/<name>/detail")
@require_auth
def api_skill_detail(name):
    """Get full SKILL.md content for a skill."""
    agent = get_agent()
    skill = agent.skills.get_skill(name)
    if not skill:
        return jsonify({"error": "Skill not found"}), 404

    from familiar.core.skills import check_skill_prerequisites

    prereq_failures = check_skill_prerequisites()
    failures = prereq_failures.get(name, [])
    configured = skill.enabled and not failures

    return jsonify(
        {
            "name": name,
            "description": skill.description,
            "summary": skill.summary,
            "tools": [t.name for t in skill.tools],
            "configured": configured,
            "setup_command": f"/connect {name}",
            "prerequisites": failures,
            "enabled": skill.enabled,
        }
    )


@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/connect/test/<service>", methods=["POST"])
@require_auth
def api_connect_test(service):
    """Test connection for a configured service."""
    from familiar.channels.connect_wizard import _apis, _selfhosted

    test_fns = {
        "hubspot": _apis.wizard_hubspot_test,
        "todoist": _apis.wizard_todoist_test,
        "stripe": _apis.wizard_stripe_test,
        "square": _apis.wizard_square_test,
        "mailerlite": _apis.wizard_mailerlite_test,
        "shippo": _apis.wizard_shippo_test,
        "calcom": _apis.wizard_calcom_test,
        "brave_search": _apis.wizard_brave_search_test,
        "spoonacular": _apis.wizard_spoonacular_test,
        "reddit": _apis.wizard_reddit_test,
        "etsy": _apis.wizard_etsy_test,
        "clockify": _apis.wizard_clockify_test,
        "quickbooks": _apis.wizard_quickbooks_test,
        "mealie": _selfhosted.wizard_mealie_test,
    }
    test_fn = test_fns.get(service)
    if not test_fn:
        return jsonify({"error": f"No test available for '{service}'"}), 404

    from familiar.channels.connect_wizard._helpers import _test_http_service

    # Use a lightweight approach: check env vars and call _test_http_service directly
    svc_tests = {
        "hubspot": lambda: _test_http_service(
            "https://api.hubapi.com/crm/v3/objects/contacts",
            headers={"Authorization": f"Bearer {os.environ.get('HUBSPOT_ACCESS_TOKEN', '')}"},
            params={"limit": "1"},
        ),
        "todoist": lambda: _test_http_service(
            "https://api.todoist.com/rest/v2/projects",
            headers={"Authorization": f"Bearer {os.environ.get('TODOIST_API_TOKEN', '')}"},
        ),
        "stripe": lambda: _test_http_service(
            "https://api.stripe.com/v1/balance",
            headers={"Authorization": f"Bearer {os.environ.get('STRIPE_SECRET_KEY', '')}"},
        ),
        "square": lambda: _test_http_service(
            "https://connect.squareup.com/v2/locations",
            headers={"Authorization": f"Bearer {os.environ.get('SQUARE_ACCESS_TOKEN', '')}"},
        ),
        "mailerlite": lambda: _test_http_service(
            "https://connect.mailerlite.com/api/subscribers",
            headers={"Authorization": f"Bearer {os.environ.get('MAILERLITE_API_KEY', '')}"},
            params={"limit": "1"},
        ),
        "shippo": lambda: _test_http_service(
            "https://api.goshippo.com/carriers",
            headers={"Authorization": f"ShippoToken {os.environ.get('SHIPPO_API_KEY', '')}"},
        ),
        "calcom": lambda: _test_http_service(
            "https://api.cal.com/v2/me",
            headers={"Authorization": f"Bearer {os.environ.get('CALCOM_API_KEY', '')}"},
        ),
        "brave_search": lambda: _test_http_service(
            "https://api.search.brave.com/res/v1/web/search",
            headers={"X-Subscription-Token": os.environ.get("BRAVE_SEARCH_API_KEY", "")},
            params={"q": "test", "count": "1"},
        ),
        "spoonacular": lambda: _test_http_service(
            "https://api.spoonacular.com/recipes/random",
            params={"number": "1", "apiKey": os.environ.get("SPOONACULAR_API_KEY", "")},
        ),
        "clockify": lambda: _test_http_service(
            "https://api.clockify.me/api/v1/user",
            headers={"X-Api-Key": os.environ.get("CLOCKIFY_API_KEY", "")},
        ),
        "mealie": lambda: _test_http_service(
            f"{os.environ.get('MEALIE_URL', '')}/api/recipes",
            headers={"Authorization": f"Bearer {os.environ.get('MEALIE_TOKEN', '')}"},
            params={"page": "1", "perPage": "1"},
        ),
        "quickbooks": lambda: _test_http_service(
            "https://quickbooks.api.intuit.com/v3/company/{rid}/companyinfo/{rid}".format(
                rid=os.environ.get("QUICKBOOKS_REALM_ID", "")
            ),
            headers={
                "Authorization": f"Bearer {os.environ.get('QUICKBOOKS_ACCESS_TOKEN', '')}",
                "Accept": "application/json",
            },
        ),
        "etsy": lambda: _test_http_service(
            "https://openapi.etsy.com/v3/application/shops/{sid}".format(
                sid=os.environ.get("ETSY_SHOP_ID", "")
            ),
            headers={"x-api-key": os.environ.get("ETSY_API_KEY", "")},
        ),
        "reddit": lambda: _test_reddit(),
    }

    def _test_reddit():
        """Reddit needs OAuth — simplified check."""
        import httpx

        cid = os.environ.get("REDDIT_CLIENT_ID", "")
        csecret = os.environ.get("REDDIT_CLIENT_SECRET", "")
        if not cid or not csecret:
            return False, "Reddit credentials not configured"
        try:
            with httpx.Client(timeout=10) as client:
                resp = client.post(
                    "https://www.reddit.com/api/v1/access_token",
                    auth=(cid, csecret),
                    data={"grant_type": "client_credentials"},
                    headers={"User-Agent": "Familiar/1.0"},
                )
                if resp.status_code == 200 and "access_token" in resp.json():
                    return True, "OK (OAuth token acquired)"
                return False, f"HTTP {resp.status_code}"
        except Exception as e:
            return False, str(e)

    sync_test = svc_tests.get(service)
    if not sync_test:
        return jsonify({"error": f"No sync test for '{service}'"}), 404

    try:
        ok, msg = sync_test()
        return jsonify({"success": ok, "message": msg})
    except Exception as e:
        return jsonify({"success": False, "message": str(e)})


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


# ============================================================
# Mesh Network Endpoints (v2.7.6)
# ============================================================


@app.route("/api/mesh/status")
@require_auth
def api_mesh_status():
    """Get full mesh status: gateway, transports, counts, config."""
    if not _gateway:
        return jsonify({"running": False, "error": "Gateway not initialized"})

    try:
        config = _gateway.config
        status = {
            "running": _gateway._running,
            "node_id": config.get("node_id", ""),
            "node_name": config.get("node_name", ""),
            "host": _gateway.host,
            "port": _gateway.port,
            "node_count": len(_gateway.nodes),
            "peer_count": len(_gateway.peer_gateways),
            "transports": {
                "websocket": {
                    "running": _gateway._running,
                    "address": f"ws://{_gateway.host}:{_gateway.port}",
                },
                "mdns": {
                    "running": _gateway.discovery.is_running if _gateway.discovery else False,
                    "available": True,
                    "peer_count": _gateway.discovery.peer_store.count()
                    if _gateway.discovery
                    else 0,
                },
                "tor": _gateway.tor_transport.get_status()
                if _gateway.tor_transport
                else {
                    "running": False,
                    "available": False,
                    "onion_address": "",
                },
            },
            "config": {
                "discovery_enabled": config.get("discovery_enabled", False),
                "tor_enabled": config.get("tor_enabled", False),
                "max_peers": config.get("max_peers", 16),
                "auto_connect_trusted": config.get("auto_connect_trusted", True),
            },
        }

        # Add Tor availability even if transport not initialized
        if not _gateway.tor_transport:
            try:
                from familiar.core.mesh.tor_transport import TorTransport

                status["transports"]["tor"]["available"] = TorTransport.is_available()
            except ImportError:
                status["transports"]["tor"]["available"] = {
                    "available": False,
                    "missing": ["tor_transport module"],
                }

        return jsonify(status)
    except Exception as e:
        return jsonify({"running": False, "error": str(e)})


@app.route("/api/mesh/peers")
@require_auth
def api_mesh_peers():
    """Get all discovered peers from PeerStore."""
    if not _gateway:
        return jsonify({"peers": []})

    try:
        peers = []
        if _gateway.discovery and _gateway.discovery.peer_store:
            from dataclasses import asdict

            for peer in _gateway.discovery.peer_store.get_all():
                p = asdict(peer)
                # Add trust info if available
                if _gateway.trust_manager:
                    rec = _gateway.trust_manager.get_trust_record(peer.node_id)
                    p["trust_level"] = rec.trust_level if rec else "unknown"
                else:
                    p["trust_level"] = "unknown"
                peers.append(p)
        return jsonify({"peers": peers})
    except Exception as e:
        return jsonify({"peers": [], "error": str(e)})


@app.route("/api/mesh/key")
@require_auth
def api_mesh_key():
    """Get mesh secret key."""
    if not _gateway:
        return jsonify({"error": "Gateway not initialized"}), 404

    return jsonify({"key": _gateway.config.get("secret_key", "")})


@app.route("/api/mesh/trust")
@require_auth
def api_mesh_trust():
    """Get all trust records, verification codes, permissions, crypto status."""
    if not _gateway or not _gateway.trust_manager:
        return jsonify({
            "available": False,
            "has_crypto": False,
            "our_fingerprint": "",
            "records": [],
        })

    try:
        tm = _gateway.trust_manager
        records = []
        for rec in tm.get_all_records():
            r = {
                "node_id": rec.node_id,
                "node_name": rec.node_name,
                "identity_fingerprint": rec.identity_fingerprint,
                "trust_level": rec.trust_level,
                "trusted_at": rec.trusted_at or "",
                "blocked_at": rec.blocked_at or "",
                "verification_code": tm.get_verification_code(rec.node_id) or "",
                "has_session": tm.has_session(rec.node_id),
                "permissions": rec.permissions.to_dict() if rec.permissions else {},
            }
            records.append(r)

        return jsonify({
            "available": True,
            "has_crypto": tm.has_crypto,
            "our_fingerprint": tm.get_our_fingerprint(),
            "records": records,
        })
    except Exception as e:
        return jsonify({"available": False, "error": str(e)})


@app.route("/api/mesh/trust/<node_id>/approve", methods=["POST"])
@require_auth
def api_mesh_trust_approve(node_id):
    """Approve a pending peer."""
    if not _gateway or not _gateway.trust_manager:
        return jsonify({"error": "Trust manager not available"}), 404

    try:
        ok = _gateway.trust_manager.approve(node_id)
        return jsonify({"success": ok, "node_id": node_id})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/mesh/trust/<node_id>/block", methods=["POST"])
@require_auth
def api_mesh_trust_block(node_id):
    """Block a peer."""
    if not _gateway or not _gateway.trust_manager:
        return jsonify({"error": "Trust manager not available"}), 404

    try:
        ok = _gateway.trust_manager.block(node_id)
        return jsonify({"success": ok, "node_id": node_id})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/mesh/trust/<node_id>/permissions", methods=["POST"])
@require_auth
def api_mesh_trust_permissions(node_id):
    """Update permission flags for a peer."""
    if not _gateway or not _gateway.trust_manager:
        return jsonify({"error": "Trust manager not available"}), 404

    try:
        data = request.get_json() or {}
        permission = data.get("permission", "")
        granted = data.get("granted", False)

        if not permission:
            return jsonify({"error": "permission field required"}), 400

        if granted:
            ok = _gateway.trust_manager.grant_permission(node_id, permission)
        else:
            ok = _gateway.trust_manager.revoke_permission(node_id, permission)

        return jsonify({"success": ok, "node_id": node_id, "permission": permission})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/mesh/memory")
@require_auth
def api_mesh_memory():
    """Get memory bridge status with full topic listings."""
    if not _gateway or not _gateway.memory_bridge:
        return jsonify({"initialized": False})

    try:
        bridge = _gateway.memory_bridge
        status = {"initialized": True, **bridge.get_status()}

        # Enrich with markdown topic listings and word counts
        md_store = getattr(bridge, "_markdown_store", None)
        shared_store = getattr(bridge, "shared_store", None)

        # Your Knowledge — all local markdown topics with word counts
        markdown_topics = []
        if md_store and hasattr(md_store, "list_topics"):
            for t in md_store.list_topics(limit=100):
                topic_info = {
                    "topic": t["topic"],
                    "tags": t.get("tags", []),
                    "revision": t.get("revision", 1),
                    "updated": t.get("updated", ""),
                    "created": t.get("created", ""),
                }
                # Get word count
                try:
                    body = md_store.read_topic(t["topic"])
                    topic_info["word_count"] = len(body.split()) if body else 0
                except Exception:
                    topic_info["word_count"] = 0
                markdown_topics.append(topic_info)
        status["markdown_topics"] = markdown_topics
        status["total_words"] = sum(t["word_count"] for t in markdown_topics)
        status["total_topics"] = len(markdown_topics)

        # Shared with Peers — topics marked shareable
        shared_topics = []
        if shared_store:
            for key in shared_store.get_shareable_keys():
                meta = shared_store.get(key)
                if meta:
                    shared_topics.append({
                        "key": meta.key,
                        "scope": meta.share_scope,
                        "shared_at": meta.shared_at or "",
                    })
        status["shared_topics"] = shared_topics

        # Received from Peers — topics pushed to us
        received_topics = []
        if shared_store:
            for key in shared_store.get_received_keys():
                meta = shared_store.get(key)
                if meta:
                    received_topics.append({
                        "key": meta.key,
                        "origin_name": meta.mesh_origin_name or "",
                        "origin_id": meta.mesh_origin or "",
                        "origin_genesis": meta.mesh_origin_genesis_hash or "",
                        "received_at": meta.received_at or "",
                    })
        status["received_topics"] = received_topics

        return jsonify(status)
    except Exception as e:
        return jsonify({"initialized": False, "error": str(e)})


@app.route("/api/mesh/models")
@require_auth
def api_mesh_models():
    """Get model bridge status."""
    if not _gateway or not _gateway.model_bridge:
        return jsonify({"initialized": False})

    try:
        return jsonify({"initialized": True, **_gateway.model_bridge.get_status()})
    except Exception as e:
        return jsonify({"initialized": False, "error": str(e)})


@app.route("/api/mesh/usage")
@require_auth
def api_mesh_usage():
    """Get usage/Dross economy summary."""
    if not _gateway or not _gateway._usage_tracker:
        return jsonify({"initialized": False})

    try:
        return jsonify({"initialized": True, **_gateway._usage_tracker.get_usage_summary()})
    except Exception as e:
        return jsonify({"initialized": False, "error": str(e)})


@app.route("/api/mesh/tor/enable", methods=["POST"])
@require_auth
def api_mesh_tor_enable():
    """Start Tor hidden service."""
    if not _gateway:
        return jsonify({"error": "Gateway not initialized"}), 404

    try:
        import asyncio

        from familiar.core.mesh.tor_transport import TorTransport

        avail = TorTransport.is_available()
        if not avail["available"]:
            return jsonify({
                "error": "Tor not available",
                "missing": avail["missing"],
            }), 400

        if _gateway.tor_transport and _gateway.tor_transport.is_running:
            return jsonify({
                "success": True,
                "onion_address": _gateway.tor_transport.onion_address,
                "message": "Already running",
            })

        config = _gateway.config
        transport = TorTransport(
            control_port=config.get("tor_control_port", 9051),
            socks_port=config.get("tor_socks_port", 9050),
            control_password=config.get("tor_control_password", ""),
        )

        loop = asyncio.new_event_loop()
        started = loop.run_until_complete(transport.start(gateway_port=_gateway.port))
        loop.close()

        if started:
            _gateway.tor_transport = transport
            config.set("tor_enabled", True)
            return jsonify({
                "success": True,
                "onion_address": transport.onion_address,
            })
        else:
            return jsonify({"error": "Failed to start Tor hidden service"}), 500
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/mesh/tor/disable", methods=["POST"])
@require_auth
def api_mesh_tor_disable():
    """Stop Tor hidden service."""
    if not _gateway:
        return jsonify({"error": "Gateway not initialized"}), 404

    try:
        if _gateway.tor_transport:
            import asyncio

            loop = asyncio.new_event_loop()
            loop.run_until_complete(_gateway.tor_transport.stop())
            loop.close()
            _gateway.tor_transport = None

        _gateway.config.set("tor_enabled", False)
        return jsonify({"success": True})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/mesh/discovery/enable", methods=["POST"])
@require_auth
def api_mesh_discovery_enable():
    """Start mDNS discovery."""
    if not _gateway:
        return jsonify({"error": "Gateway not initialized"}), 404

    try:
        if _gateway.discovery and _gateway.discovery.is_running:
            return jsonify({"success": True, "message": "Already running"})

        if not _gateway.discovery:
            from familiar.core.mesh.discovery import MeshDiscovery

            skill_names = []
            try:
                from familiar.core.tools import get_tool_registry

                skill_names = [t.get("name", "") for t in get_tool_registry().get_schemas()]
            except Exception:
                pass

            config = _gateway.config
            _gateway.discovery = MeshDiscovery(
                node_id=config.get("node_id"),
                node_name=config.get("node_name", "familiar"),
                node_type=config.get("node_type", "gateway"),
                port=_gateway.port,
                skills=skill_names,
                fingerprint=_gateway.trust_manager.get_our_fingerprint()
                if _gateway.trust_manager
                else "",
                mesh_dir=None,
                max_peers=config.get("max_peers", 16),
            )

        _gateway.discovery.start()
        _gateway.config.set("discovery_enabled", True)
        return jsonify({"success": True})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/mesh/discovery/disable", methods=["POST"])
@require_auth
def api_mesh_discovery_disable():
    """Stop mDNS discovery."""
    if not _gateway:
        return jsonify({"error": "Gateway not initialized"}), 404

    try:
        if _gateway.discovery:
            _gateway.discovery.stop()

        _gateway.config.set("discovery_enabled", False)
        return jsonify({"success": True})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@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:
        return jsonify({"error": "Failed to generate email summary"}), 500


@app.route("/api/email/triage")
@require_auth
def api_email_triage():
    """Get full email triage as structured JSON for the interactive dashboard."""
    try:
        import threading

        from familiar.skills.triage.skill import CATEGORIES, triage_inbox

        # Run triage — side-effect: stores ordered_emails on thread-local
        text_result = triage_inbox(
            {
                "limit": int(request.args.get("limit", 25)),
                "unread_only": request.args.get("unread", "true").lower() == "true",
            }
        )

        # Retrieve structured data from thread-local (set by triage_inbox)
        triage_ctx = getattr(threading.current_thread(), "_familiar_triage_ctx", None)
        if triage_ctx and triage_ctx.get("emails"):
            emails = triage_ctx["emails"]
            # Group by category
            categories = {}
            for em in emails:
                cat = em.get("category", "Other")
                categories.setdefault(cat, []).append(em)
            return jsonify(
                {
                    "emails": emails,
                    "categories": {cat: categories.get(cat, []) for cat in CATEGORIES},
                    "total": triage_ctx.get("total", len(emails)),
                    "triage": text_result,  # Keep text fallback
                }
            )

        # Fallback: text-only response
        return jsonify({"triage": text_result})
    except Exception as e:
        logger.debug("Email triage error: %s", e)
        return jsonify({"error": "Failed to triage emails"}), 500


@app.route("/api/email/read/<msg_id>")
@require_auth
def api_email_read(msg_id):
    """Read a specific email by Gmail message ID or IMAP ID."""
    try:
        from familiar.skills.email.skill import (
            _gmail_read_email_by_id,
            _imap_read_email_by_id,
        )

        id_source = request.args.get("source", "gmail")
        if id_source == "gmail":
            result = _gmail_read_email_by_id(msg_id)
        else:
            result = _imap_read_email_by_id(msg_id)
        return jsonify({"content": result})
    except Exception as e:
        logger.debug("Email read error: %s", e)
        return jsonify({"error": f"Failed to read email: {e}"}), 500


@app.route("/api/email/reply", methods=["POST"])
@require_auth
def api_email_reply():
    """Send a reply to an email via Gmail API or SMTP."""
    try:
        from familiar.skills.email.skill import send_email

        payload = request.get_json() or {}
        # Build send_email payload with reply context
        send_data = {
            "to": payload.get("to", ""),
            "subject": payload.get("subject", ""),
            "body": payload.get("body", ""),
        }
        # Include thread_id for Gmail threading
        if payload.get("thread_id"):
            send_data["thread_id"] = payload["thread_id"]
        if payload.get("in_reply_to"):
            send_data["in_reply_to"] = payload["in_reply_to"]

        result = send_email(send_data)
        return jsonify({"result": result})
    except Exception as e:
        logger.debug("Email reply error: %s", e)
        return jsonify({"error": "Failed to send reply"}), 500


@app.route("/api/briefing")
@require_auth
def api_briefing():
    """Get daily briefing with structured sections + LLM synthesis."""
    try:
        import sys

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

        raw_data = generate_briefing_data()
        raw_text = generate_briefing({})

        # Try LLM synthesis for executive summary
        summary = ""
        try:
            agent = get_agent()
            if agent:
                result = agent.chat_with_results(
                    "Summarize this briefing in 2-3 concise sentences highlighting the most "
                    "important items. Be direct and actionable. Just the summary, no headers "
                    f"or bullet points:\n\n{raw_text}",
                    user_id="dashboard-briefing",
                    channel="web",
                    include_history=False,
                )
                summary = result.text
        except Exception as e:
            logger.debug("LLM briefing synthesis failed: %s", e)

        return jsonify(
            {
                "summary": summary,
                "sections": raw_data["sections"],
                "timestamp": raw_data["timestamp"],
                "raw": raw_text,
            }
        )
    except Exception:
        return jsonify({"error": "Failed to generate briefing"}), 500


@app.route("/api/briefing/action", methods=["POST"])
@require_auth
def api_briefing_action():
    """Handle calendar event quick actions (running late, cancel)."""
    data = request.json or {}
    action = data.get("action")
    event_summary = data.get("event_summary", "")
    attendees = data.get("attendees", [])
    event_time = data.get("event_time", "")

    if not action or not attendees:
        return jsonify({"error": "Missing action or attendees"}), 400

    prompt = f'I need to tell the attendees of my "{event_summary}" meeting at {event_time} '
    if action == "running_late":
        prompt += "that I'm running late. Draft and send a brief, polite email to: "
    elif action == "cancel":
        prompt += "that I need to cancel. Draft and send a brief, polite email to: "
    else:
        return jsonify({"error": f"Unknown action: {action}"}), 400
    prompt += ", ".join(attendees)

    try:
        agent = get_agent()
        if not agent:
            return jsonify({"error": "Agent not available"}), 503
        result = agent.chat_with_results(prompt, user_id="dashboard", channel="web")
        return jsonify({"response": result.text})
    except Exception as e:
        logger.debug("Briefing action error: %s", e)
        return jsonify({"error": "Failed to process action"}), 500


@app.route("/api/connect/discover", methods=["POST"])
@require_auth
def api_connect_discover():
    """Scan LAN for self-hosted services."""
    from familiar.channels.connect_wizard._discovery import discover_lan_services

    services = (request.json or {}).get("services") if request.is_json else None
    results = discover_lan_services(services=services)
    return jsonify(results)


@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) as e:
        logger.debug("I/O error suppressed: %s", e)
    _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 as e:
        logger.debug("Error suppressed: %s", e)
    _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 as e:
            logger.debug("Error suppressed: %s", e)
    _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:
        logger.exception("Browser navigate error")
        return jsonify({"success": False, "error": "Navigation failed"}), 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:
        logger.exception("Video embed error")
        return jsonify({"success": False, "error": "Video embed failed"}), 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:
        logger.exception("Video download error")
        return jsonify({"success": False, "error": "Video download failed"}), 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 — direct DuckDuckGo search with agent fallback."""
    query = (request.json or {}).get("query", "").strip()
    if not query:
        return jsonify({"items": [], "error": "No query provided"}), 400
    platforms = (request.json or {}).get("platforms") or None

    # Direct search first (fast, reliable)
    from familiar.channels.connect_wizard._browse import search_video_online

    result = search_video_online(query, platforms=platforms)

    # Fall back to agent if direct search returned nothing
    if not result.get("items") and not result.get("error"):
        try:
            agent = get_agent()
            prompt = f"Search for videos: {query}"
            agent_result = agent.chat_with_results(prompt, user_id="dashboard", channel="web")
            agent_parsed = _parse_search_results(agent_result.text)
            if agent_parsed.get("items"):
                result = agent_parsed
        except Exception:
            logger.debug("Agent video search fallback also failed")

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


# ── Media Video Aliases ──────────────────────────────────────────────
# Thin aliases so the Media panel's Video tab can call cleaner paths.


@app.route("/api/media/video/search", methods=["POST"])
@require_auth
def api_media_video_search():
    return api_video_search_online()


@app.route("/api/media/video/embed", methods=["POST"])
@require_auth
def api_media_video_embed():
    return api_browser_video_embed()


@app.route("/api/media/video/download", methods=["POST"])
@require_auth
def api_media_video_download():
    return api_browser_video_download()


@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

    # Direct search first (fast, reliable)
    result = search_service(service, query)

    # Fall back to agent if direct search returned nothing
    if not result.get("items") and not result.get("error"):
        try:
            agent = get_agent()
            prompt = f"Search {service} for: {query}"
            agent_result = agent.chat_with_results(prompt, user_id="dashboard", channel="web")
            agent_parsed = _parse_search_results(agent_result.text)
            if agent_parsed.get("items"):
                result = agent_parsed
        except Exception:
            logger.debug("Agent search fallback also failed")

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


@app.route("/api/connect/browse")
@limiter.limit("10 per minute")
@require_auth
def api_connect_browse():
    """Browse data from connected services."""
    try:
        return jsonify({"services": browse_all()})
    except Exception:
        logger.exception("Browse all services failed")
        return jsonify({"services": {}})


# ============================================================
# 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:
        return jsonify({"error": "Invalid routing configuration"}), 400


@app.route("/api/config/job-class")
@require_auth
def api_job_class():
    """Get available job classes and the currently active one."""
    from familiar.creature.state import load_creature
    from familiar.jobs import list_job_classes

    creature = load_creature()
    active = creature.job_class if creature else ""
    classes = [
        {"key": jc.key, "name": jc.name, "icon": jc.icon, "description": jc.description}
        for jc in list_job_classes()
    ]
    return jsonify({"active": active, "classes": classes})


@app.route("/api/config/job-class", methods=["POST"])
@require_auth
def api_job_class_update():
    """Switch the active job class."""
    data = request.json or {}
    key = data.get("job_class", "")
    agent = get_agent()
    result = agent.switch_job_class(key)
    return jsonify({"result": result, "active": key})


# ============================================================
# Workspace Panel
# ============================================================


@app.route("/api/workspace")
@require_auth
def api_workspace():
    """Get workspace config + computed overview metrics for the active job class."""
    try:
        from familiar.jobs import get_active_job_class

        jc = get_active_job_class()
        if not jc or not jc.workspace:
            return jsonify({"workspace": None})

        from familiar.dashboard.workspace import resolve_overview_cards

        ws = dict(jc.workspace)
        overview = ws.get("overview", {})
        if overview:
            ws["overview"]["computed_cards"] = resolve_overview_cards(overview)

        return jsonify({"workspace": ws})
    except Exception as e:
        logger.debug("Workspace load error: %s", e)
        return jsonify({"error": str(e)}), 500


@app.route("/api/workspace/section/<key>")
@require_auth
def api_workspace_section(key):
    """Get data for a specific workspace section."""
    try:
        from familiar.jobs import get_active_job_class

        jc = get_active_job_class()
        if not jc or not jc.workspace:
            return jsonify({"error": "No active workspace"}), 404

        sections = jc.workspace.get("sections", [])
        section_config = next((s for s in sections if s.get("key") == key), None)
        if not section_config:
            return jsonify({"error": f"Section '{key}' not found"}), 404

        from familiar.dashboard.workspace import get_section_data

        sort = request.args.get("sort")
        sort_dir = request.args.get("dir", "asc")
        page = int(request.args.get("page", 1))
        limit = int(request.args.get("limit", 50))

        data = get_section_data(section_config, sort, sort_dir, page, limit)
        return jsonify(data)
    except Exception as e:
        logger.debug("Workspace section error: %s", e)
        return jsonify({"error": str(e)}), 500


@app.route("/api/workspace/action", methods=["POST"])
@require_auth
def api_workspace_action():
    """Execute a workspace action (tool call or agent chat)."""
    try:
        payload = request.get_json() or {}
        action_config = {
            "mode": payload.get("mode", "agent"),
            "tool": payload.get("tool", ""),
            "tool_args": payload.get("tool_args", {}),
            "prompt": payload.get("prompt", ""),
            "prompt_template": payload.get("prompt_template", ""),
        }
        row_data = payload.get("row_data", {})

        from familiar.dashboard.workspace import execute_action

        agent = get_agent()
        result = execute_action(action_config, row_data, agent)
        return jsonify(result)
    except Exception as e:
        logger.debug("Workspace action error: %s", e)
        return jsonify({"result": str(e), "success": False}), 500


# ============================================================
# Media Panel (Radio & Podcasts)
# ============================================================


@app.route("/api/media/radio/search")
@require_auth
def api_media_radio_search():
    """Search radio stations."""
    try:
        from familiar.skills.media.radio import search_stations

        name = request.args.get("name", "")
        tag = request.args.get("tag", "")
        country = request.args.get("country", "")
        limit = int(request.args.get("limit", 25))
        stations = search_stations(name=name, tag=tag, country=country, limit=limit)
        return jsonify({"stations": stations})
    except Exception as e:
        logger.debug("Radio search error: %s", e)
        return jsonify({"stations": [], "error": str(e)}), 502


@app.route("/api/media/radio/genres")
@require_auth
def api_media_radio_genres():
    """Get popular radio genres."""
    try:
        from familiar.skills.media.radio import get_genres

        limit = int(request.args.get("limit", 50))
        genres = get_genres(limit=limit)
        return jsonify({"genres": genres})
    except Exception as e:
        logger.debug("Radio genres error: %s", e)
        return jsonify({"genres": [], "error": str(e)}), 502


@app.route("/api/media/radio/popular")
@require_auth
def api_media_radio_popular():
    """Get popular radio stations."""
    try:
        from familiar.skills.media.radio import get_popular

        limit = int(request.args.get("limit", 25))
        stations = get_popular(limit=limit)
        return jsonify({"stations": stations})
    except Exception as e:
        logger.debug("Radio popular error: %s", e)
        return jsonify({"stations": [], "error": str(e)}), 502


@app.route("/api/media/podcasts/search")
@require_auth
def api_media_podcasts_search():
    """Search podcasts via iTunes."""
    try:
        from familiar.skills.media.podcasts import search_podcasts

        term = request.args.get("term", "")
        limit = int(request.args.get("limit", 25))
        podcasts = search_podcasts(term=term, limit=limit)
        return jsonify({"podcasts": podcasts})
    except Exception as e:
        logger.debug("Podcast search error: %s", e)
        return jsonify({"podcasts": [], "error": str(e)}), 502


@app.route("/api/media/podcasts/episodes")
@require_auth
def api_media_podcasts_episodes():
    """Get podcast episodes from RSS feed."""
    try:
        from familiar.skills.media.podcasts import get_episodes

        feed_url = request.args.get("feed_url", "")
        limit = int(request.args.get("limit", 20))
        episodes = get_episodes(feed_url=feed_url, limit=limit)
        return jsonify({"episodes": episodes})
    except Exception as e:
        logger.debug("Podcast episodes error: %s", e)
        return jsonify({"episodes": [], "error": str(e)}), 502


@app.route("/api/media/favorites")
@require_auth
def api_media_favorites():
    """Get saved media favorites."""
    try:
        from familiar.skills.media.skill import get_favorites

        return jsonify({"favorites": get_favorites()})
    except Exception as e:
        logger.debug("Media favorites error: %s", e)
        return jsonify({"favorites": [], "error": str(e)}), 502


@app.route("/api/media/favorites/add", methods=["POST"])
@require_auth
def api_media_favorites_add():
    """Add a media item to favorites."""
    try:
        from familiar.skills.media.skill import add_favorite

        item = request.get_json() or {}
        added = add_favorite(item)
        return jsonify({"success": added})
    except Exception as e:
        logger.debug("Media favorite add error: %s", e)
        return jsonify({"success": False, "error": str(e)}), 500


@app.route("/api/media/favorites/remove", methods=["POST"])
@require_auth
def api_media_favorites_remove():
    """Remove a media item from favorites."""
    try:
        from familiar.skills.media.skill import remove_favorite

        item = request.get_json() or {}
        removed = remove_favorite(item)
        return jsonify({"success": removed})
    except Exception as e:
        logger.debug("Media favorite remove error: %s", e)
        return jsonify({"success": False, "error": str(e)}), 500


@app.route("/api/media/history")
@require_auth
def api_media_history():
    """Get media play history."""
    try:
        from familiar.skills.media.skill import get_history

        return jsonify({"history": get_history()})
    except Exception as e:
        logger.debug("Media history error: %s", e)
        return jsonify({"history": [], "error": str(e)}), 502


# ── Music Vault Endpoints ────────────────────────────────────


MUSIC_EXTS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac", ".wma"}
MUSIC_MIME = {
    ".mp3": "audio/mpeg",
    ".wav": "audio/wav",
    ".ogg": "audio/ogg",
    ".flac": "audio/flac",
    ".m4a": "audio/mp4",
    ".aac": "audio/aac",
    ".wma": "audio/x-ms-wma",
}


@app.route("/api/media/music/upload", methods=["POST"])
@require_auth
def api_music_upload():
    """Upload a music file to the local music vault."""
    import uuid

    from familiar.skills.media.skill import _get_music_dir, add_to_music_library

    MAX_SIZE = 50 * 1024 * 1024  # 50 MB

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

    file = request.files["file"]
    if not file.filename:
        return jsonify({"error": "No filename", "success": False}), 400

    ext = Path(file.filename).suffix.lower()
    if ext not in MUSIC_EXTS:
        return jsonify({"error": f"File type {ext} not supported", "success": False}), 400

    music_dir = _get_music_dir()
    track_id = str(uuid.uuid4())[:12]
    safe_name = re.sub(r"[^\w\-.]", "_", Path(file.filename).stem)[:50]
    dest = music_dir / f"{track_id}_{safe_name}{ext}"
    file.save(str(dest))

    size = dest.stat().st_size
    if size > MAX_SIZE:
        dest.unlink()
        return jsonify({"error": "File exceeds 50 MB limit", "success": False}), 400

    track = {
        "id": track_id,
        "filename": file.filename,
        "path": str(dest),
        "size": size,
        "ext": ext,
        "added_at": datetime.now().isoformat(),
    }
    add_to_music_library(track)

    return jsonify({"success": True, "track_id": track_id, "filename": file.filename,
                     "path": str(dest), "size": size})


@app.route("/api/media/music/library")
@require_auth
def api_music_library():
    """List the music library, optionally filtering by search term."""
    from familiar.skills.media.skill import get_music_library

    search = request.args.get("search", "").strip() or None
    tracks = get_music_library(search=search)
    return jsonify({"tracks": tracks})


@app.route("/api/media/music/stream/<track_id>")
@require_auth
def api_music_stream(track_id):
    """Stream a music file by track ID."""
    if not re.match(r"^[a-f0-9\-]{8,36}$", track_id):
        return jsonify({"error": "Invalid track ID"}), 400

    from familiar.skills.media.skill import get_music_library

    tracks = get_music_library()
    track = next((t for t in tracks if t.get("id") == track_id), None)
    if not track:
        return jsonify({"error": "Track not found"}), 404

    file_path = Path(track["path"])
    if not file_path.is_file():
        return jsonify({"error": "File not found on disk"}), 404

    mime = MUSIC_MIME.get(track.get("ext", ""), "application/octet-stream")
    return send_file(str(file_path), mimetype=mime)


@app.route("/api/media/music/<track_id>", methods=["DELETE"])
@require_auth
def api_music_delete(track_id):
    """Delete a music track from the library and disk."""
    if not re.match(r"^[a-f0-9\-]{8,36}$", track_id):
        return jsonify({"error": "Invalid track ID"}), 400

    from familiar.skills.media.skill import remove_from_music_library

    removed = remove_from_music_library(track_id)
    if not removed:
        return jsonify({"error": "Track not found"}), 404
    return jsonify({"success": True})


# ── Gallery Vault Endpoints ───────────────────────────────────


@app.route("/api/gallery")
@require_auth
def api_gallery_list():
    """List all artworks in the gallery vault."""
    try:
        from familiar.skills.gallery.provenance import _load_gallery_data

        artworks = _load_gallery_data()
        items = []
        for a in artworks:
            items.append({
                "artwork_id": a["artwork_id"],
                "title": a["title"],
                "artist": a["artist"],
                "description": a.get("description", ""),
                "medium": a.get("medium", ""),
                "dimensions": a.get("dimensions", ""),
                "license": a.get("license", "All Rights Reserved"),
                "tags": a.get("tags", []),
                "watermark_types": a.get("watermark_types", []),
                "block_hash": a.get("block_hash", ""),
                "block_index": a.get("block_index", -1),
                "registered_at": a.get("registered_at", ""),
                "has_thumbnail": bool(a.get("thumbnail_path")),
            })
        return jsonify({"artworks": items, "count": len(items)})
    except Exception as e:
        logger.debug("Gallery list error: %s", e)
        return jsonify({"artworks": [], "count": 0, "error": str(e)}), 502


@app.route("/api/gallery/<artwork_id>")
@require_auth
def api_gallery_detail(artwork_id):
    """Get full artwork details including chain block info."""
    try:
        from familiar.skills.gallery.provenance import (
            _load_gallery_data,
            get_chain_blocks_for_artwork,
        )

        artworks = _load_gallery_data()
        artwork = None
        for a in artworks:
            if a["artwork_id"] == artwork_id:
                artwork = a
                break

        if artwork is None:
            return jsonify({"error": "Artwork not found"}), 404

        blocks = get_chain_blocks_for_artwork(artwork_id)
        artwork["chain_blocks"] = blocks
        return jsonify(artwork)
    except Exception as e:
        logger.debug("Gallery detail error: %s", e)
        return jsonify({"error": str(e)}), 502


@app.route("/api/gallery/upload", methods=["POST"])
@require_auth
def api_gallery_upload():
    """Upload an image to the gallery originals directory."""
    try:
        import uuid

        from familiar.skills.gallery.provenance import _get_gallery_dir
        from familiar.skills.gallery.watermark import generate_thumbnail

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

        file = request.files["file"]
        if not file.filename:
            return jsonify({"error": "No filename"}), 400

        gallery_dir = _get_gallery_dir()
        temp_id = str(uuid.uuid4())[:12]
        ext = Path(file.filename).suffix or ".png"
        dest = gallery_dir / "originals" / f"{temp_id}{ext}"
        file.save(str(dest))

        # Generate thumbnail
        thumb_path = str(gallery_dir / "thumbnails" / f"{temp_id}.png")
        generate_thumbnail(str(dest), output_path=thumb_path)

        return jsonify({
            "success": True,
            "temp_id": temp_id,
            "filename": file.filename,
            "path": str(dest),
            "thumbnail_path": thumb_path,
        })
    except Exception as e:
        logger.debug("Gallery upload error: %s", e)
        return jsonify({"error": str(e), "success": False}), 500


@app.route("/api/gallery/watermark", methods=["POST"])
@require_auth
def api_gallery_watermark():
    """Apply watermark(s) to an uploaded artwork."""
    try:
        from familiar.skills.gallery.watermark import (
            apply_invisible_watermark,
            apply_visible_watermark,
            compute_image_hash,
        )

        data = request.get_json(force=True)
        image_path = data.get("image_path", "")
        if not image_path or not Path(image_path).exists():
            return jsonify({"error": "Image file not found"}), 400

        wm_type = data.get("type", "visible")
        text = data.get("text", "")
        output_path = data.get("output_path", "")

        if wm_type == "invisible":
            embed_data = data.get("data", text or "watermark")
            result_path = apply_invisible_watermark(
                image_path, embed_data, output_path=output_path
            )
        else:
            if not text:
                return jsonify({"error": "text is required for visible watermarks"}), 400
            result_path = apply_visible_watermark(
                image_path, text,
                position=data.get("position", "bottom-right"),
                opacity=data.get("opacity", 0.3),
                output_path=output_path,
            )

        return jsonify({
            "success": True,
            "output_path": result_path,
            "type": wm_type,
            "hash": compute_image_hash(result_path),
        })
    except Exception as e:
        logger.debug("Gallery watermark error: %s", e)
        return jsonify({"error": str(e), "success": False}), 500


@app.route("/api/gallery/register", methods=["POST"])
@require_auth
def api_gallery_register():
    """Register + chain-notarize an artwork."""
    try:
        from familiar.skills.gallery.provenance import register_artwork

        data = request.get_json(force=True)
        image_path = data.get("image_path", "")
        if not image_path or not Path(image_path).exists():
            return jsonify({"error": "Image file not found"}), 400

        artwork = register_artwork(
            image_path=image_path,
            title=data.get("title", "Untitled"),
            artist=data.get("artist", "Unknown"),
            description=data.get("description", ""),
            medium=data.get("medium", ""),
            dimensions=data.get("dimensions", ""),
            license_type=data.get("license", "All Rights Reserved"),
            tags=data.get("tags", []),
            watermark_types=data.get("watermark_types", ["visible", "invisible", "certificate"]),
        )

        return jsonify({
            "success": True,
            "artwork_id": artwork["artwork_id"],
            "title": artwork["title"],
            "artist": artwork["artist"],
            "block_hash": artwork.get("block_hash", ""),
            "watermark_types": artwork["watermark_types"],
            "certificate_path": artwork.get("certificate_path", ""),
        })
    except Exception as e:
        logger.debug("Gallery register error: %s", e)
        return jsonify({"error": str(e), "success": False}), 500


@app.route("/api/gallery/verify/<artwork_id>")
@require_auth
def api_gallery_verify(artwork_id):
    """Verify chain integrity for an artwork."""
    try:
        from familiar.skills.gallery.provenance import verify_artwork

        result = verify_artwork(artwork_id)
        return jsonify(result)
    except Exception as e:
        logger.debug("Gallery verify error: %s", e)
        return jsonify({"error": str(e), "verified": False}), 502


@app.route("/api/gallery/certificate/<artwork_id>")
@require_auth
def api_gallery_certificate(artwork_id):
    """Download provenance certificate markdown."""
    try:
        from familiar.skills.gallery.provenance import _load_gallery_data

        artworks = _load_gallery_data()
        artwork = None
        for a in artworks:
            if a["artwork_id"] == artwork_id:
                artwork = a
                break

        if artwork is None:
            return jsonify({"error": "Artwork not found"}), 404

        cert_path = artwork.get("certificate_path", "")
        if not cert_path or not Path(cert_path).exists():
            return jsonify({"error": "Certificate not found"}), 404

        return send_file(
            cert_path,
            as_attachment=True,
            download_name=f"certificate_{artwork_id}.md",
            mimetype="text/markdown",
        )
    except Exception as e:
        logger.debug("Gallery certificate error: %s", e)
        return jsonify({"error": str(e)}), 502


@app.route("/api/gallery/image/<artwork_id>")
@require_auth
def api_gallery_image(artwork_id):
    """Serve artwork image file. Use ?type=original or ?type=watermarked."""
    try:
        from familiar.skills.gallery.provenance import _load_gallery_data

        artworks = _load_gallery_data()
        artwork = None
        for a in artworks:
            if a["artwork_id"] == artwork_id:
                artwork = a
                break

        if artwork is None:
            return jsonify({"error": "Artwork not found"}), 404

        img_type = request.args.get("type", "watermarked")
        if img_type == "original":
            img_path = artwork.get("original_path", "")
        else:
            img_path = artwork.get("watermarked_path", "")

        if not img_path or not Path(img_path).exists():
            return jsonify({"error": "Image file not found"}), 404

        ext = Path(img_path).suffix.lower()
        mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
                    ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp"}
        mimetype = mime_map.get(ext, "image/png")
        return send_file(img_path, mimetype=mimetype)
    except Exception as e:
        logger.debug("Gallery image error: %s", e)
        return jsonify({"error": str(e)}), 502


@app.route("/api/gallery/thumbnail/<artwork_id>")
@require_auth
def api_gallery_thumbnail(artwork_id):
    """Serve artwork thumbnail."""
    try:
        from familiar.skills.gallery.provenance import _load_gallery_data

        artworks = _load_gallery_data()
        artwork = None
        for a in artworks:
            if a["artwork_id"] == artwork_id:
                artwork = a
                break

        if artwork is None:
            return jsonify({"error": "Artwork not found"}), 404

        thumb_path = artwork.get("thumbnail_path", "")
        if not thumb_path or not Path(thumb_path).exists():
            return jsonify({"error": "Thumbnail not found"}), 404

        return send_file(thumb_path, mimetype="image/png")
    except Exception as e:
        logger.debug("Gallery thumbnail error: %s", e)
        return jsonify({"error": str(e)}), 502


@app.route("/api/upload", methods=["POST"])
@require_auth
def api_upload():
    """Upload a file for chat attachment."""
    import uuid

    ALLOWED_EXTS = {
        ".pdf", ".docx", ".doc", ".xlsx", ".xls", ".csv", ".tsv",
        ".txt", ".md", ".json", ".xml", ".html", ".htm", ".rtf",
        ".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".gif", ".webp",
    }
    IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".gif", ".webp"}
    MAX_SIZE = 25 * 1024 * 1024  # 25 MB

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

    file = request.files["file"]
    if not file.filename:
        return jsonify({"error": "No filename", "success": False}), 400

    ext = Path(file.filename).suffix.lower()
    if ext not in ALLOWED_EXTS:
        return jsonify({"error": f"File type {ext} not supported", "success": False}), 400

    uploads_dir = DATA_DIR / "uploads"
    uploads_dir.mkdir(parents=True, exist_ok=True)

    file_id = str(uuid.uuid4())[:12]
    safe_name = re.sub(r"[^\w\-.]", "_", Path(file.filename).stem)[:50]
    dest = uploads_dir / f"{file_id}_{safe_name}{ext}"
    file.save(str(dest))

    size = dest.stat().st_size
    if size > MAX_SIZE:
        dest.unlink()
        return jsonify({"error": "File exceeds 25 MB limit", "success": False}), 400

    return jsonify({
        "success": True,
        "file_id": file_id,
        "filename": file.filename,
        "path": str(dest),
        "size": size,
        "is_image": ext in IMAGE_EXTS,
    })


@app.route("/api/upload/preview/<file_id>")
@require_auth
def api_upload_preview(file_id):
    """Serve an uploaded image file for thumbnail preview."""
    import glob as _glob
    import mimetypes

    # Sanitize file_id to prevent path traversal
    if not re.match(r"^[a-f0-9\-]{8,36}$", file_id):
        return jsonify({"error": "Invalid file ID"}), 400

    uploads_dir = DATA_DIR / "uploads"
    matches = _glob.glob(str(uploads_dir / f"{file_id}_*"))
    if not matches:
        return jsonify({"error": "File not found"}), 404

    fpath = Path(matches[0])
    mime = mimetypes.guess_type(str(fpath))[0] or "application/octet-stream"
    if not mime.startswith("image/"):
        return jsonify({"error": "Not an image"}), 400

    return send_file(str(fpath), mimetype=mime)


@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("/api/tutorial/complete", methods=["POST"])
@require_auth
def api_tutorial_complete():
    """Mark the dashboard tutorial as completed (server-side flag)."""
    flag = Path.home() / ".familiar" / "data" / ".dashboard_tutorial_done"
    flag.parent.mkdir(parents=True, exist_ok=True)
    flag.write_text("1")
    return jsonify({"ok": True})


@app.route("/api/tutorial/reset", methods=["POST"])
@require_auth
def api_tutorial_reset():
    """Reset the dashboard tutorial so it runs again on next load."""
    flag = Path.home() / ".familiar" / "data" / ".dashboard_tutorial_done"
    try:
        flag.unlink(missing_ok=True)
    except OSError as e:
        logger.debug("I/O error suppressed: %s", e)
    return jsonify({"ok": True, "message": "Tutorial reset. Reload the dashboard to start it."})


@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,
        "profile": engine.validate_profile,
        "credentials": engine.validate_credentials,
        "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


# ============================================================
# Model Library, Tarot Spread & Downloads
# ============================================================


@app.route("/api/models/ollama")
@require_auth
def api_models_ollama():
    """Return Ollama model catalog with install/active status."""
    import socket

    from familiar.channels.connect_wizard._llm import OLLAMA_MODEL_CATALOG

    # Check Ollama is running
    ollama_running = False
    try:
        with socket.create_connection(("127.0.0.1", 11434), timeout=2.0):
            ollama_running = True
    except (OSError, TimeoutError) as e:
        logger.debug("I/O error suppressed: %s", e)
    installed_names: set[str] = set()
    if ollama_running:
        try:
            import httpx

            resp = httpx.get("http://localhost:11434/api/tags", timeout=5)
            data = resp.json()
            for m in data.get("models", []):
                name = m.get("name", "")
                installed_names.add(name)
                if ":" not in name:
                    installed_names.add(f"{name}:latest")
        except Exception:
            logger.debug("Failed to query Ollama /api/tags", exc_info=True)

    # Detect active model
    active_model = ""
    try:
        agent = get_agent()
        llm_cfg = getattr(getattr(agent, "config", None), "llm", None)
        active_model = getattr(llm_cfg, "ollama_model", "") if llm_cfg else ""
    except Exception as e:
        logger.debug("Error suppressed: %s", e)
    models = []
    for tag, display_name, size, description, _url in OLLAMA_MODEL_CATALOG:
        installed = tag in installed_names or f"{tag}:latest" in installed_names
        models.append(
            {
                "tag": tag,
                "display_name": display_name,
                "size": size,
                "description": description,
                "installed": installed,
                "active": tag == active_model,
            }
        )

    return jsonify(
        {
            "ollama_running": ollama_running,
            "models": models,
            "active_model": active_model,
        }
    )


@app.route("/api/models/ollama/pull", methods=["POST"])
@require_auth
def api_models_ollama_pull():
    """Stream an Ollama model download via SSE."""
    from familiar.channels.connect_wizard._llm import OLLAMA_MODEL_CATALOG

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

    valid_tags = {tag for tag, *_ in OLLAMA_MODEL_CATALOG}
    if model not in valid_tags:
        return jsonify({"error": f"Unknown model: {model}"}), 400

    msg_queue = queue.Queue()

    def _pull():
        try:
            proc = subprocess.Popen(
                ["ollama", "pull", model],
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
            )
            for line in proc.stdout:
                msg_queue.put({"message": line.strip(), "done": False, "success": False})
            proc.wait()
            msg_queue.put(
                {
                    "message": "Complete" if proc.returncode == 0 else "Failed",
                    "done": True,
                    "success": proc.returncode == 0,
                }
            )
        except FileNotFoundError:
            msg_queue.put({"message": "ollama not found in PATH", "done": True, "success": False})
        except Exception as e:
            msg_queue.put({"message": str(e), "done": True, "success": False})
        finally:
            msg_queue.put(None)

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

    def generate():
        while True:
            try:
                item = msg_queue.get(timeout=600)
            except queue.Empty:
                yield f"data: {json.dumps({'message': 'Timeout', 'done': True, 'success': False})}\n\n"
                break
            if item is None:
                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/models/ollama/activate", methods=["POST"])
@require_auth
def api_models_ollama_activate():
    """Switch the active Ollama model."""
    data = request.json or {}
    model = data.get("model", "").strip()
    if not model:
        return jsonify({"error": "No model specified"}), 400

    try:
        agent = get_agent()
        agent.switch_provider("ollama", model=model)
        return jsonify({"success": True, "active_model": model})
    except Exception as e:
        logger.exception("Failed to activate Ollama model")
        return jsonify({"error": str(e)}), 500


@app.route("/api/models/ollama/remove", methods=["POST"])
@require_auth
def api_models_ollama_remove():
    """Delete an installed Ollama model."""
    data = request.json or {}
    model = data.get("model", "").strip()
    if not model:
        return jsonify({"error": "No model specified"}), 400

    try:
        result = subprocess.run(
            ["ollama", "rm", model],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode == 0:
            return jsonify({"success": True})
        return jsonify({"error": result.stderr.strip() or "Failed to remove model"}), 500
    except FileNotFoundError:
        return jsonify({"error": "ollama not found in PATH"}), 500
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/tarot/spread")
@require_auth
def api_tarot_spread():
    """Return the tarot spread with live service status."""
    from familiar.channels.connect_wizard._helpers import SERVICE_DISPLAY
    from familiar.channels.connect_wizard._tarot import (
        ARCANA_ORDER,
        TAROT_ARCANA,
        _card_reveal,
    )
    from familiar.creature.state import load_creature

    # Get live service status
    host = get_wizard_host()
    service_status = host._get_service_status()

    # Load creature species
    species = "fox"
    try:
        creature = load_creature()
        if creature:
            species = creature.species
    except Exception as e:
        logger.debug("Error suppressed: %s", e)
    cards = []
    for key in ARCANA_ORDER:
        arcana = TAROT_ARCANA[key]
        services = arcana["services"]
        configured = sum(1 for s in services if service_status.get(s, False))
        total = len(services)
        pending = total - configured

        svc_list = []
        for svc in services:
            svc_list.append(
                {
                    "name": svc,
                    "display": SERVICE_DISPLAY.get(svc, svc),
                    "connected": service_status.get(svc, False),
                }
            )

        cards.append(
            {
                "key": key,
                "name": arcana["name"],
                "icon": arcana["icon"],
                "numeral": arcana["numeral"],
                "configured": configured,
                "total": total,
                "pending": pending,
                "description": arcana["description"],
                "services": svc_list,
                "species_flavor": _card_reveal(species, key),
            }
        )

    return jsonify({"cards": cards, "species": species})


@app.route("/api/downloads/tome")
@require_auth
def api_downloads_tome():
    """Download the user guide as PDF (or HTML fallback)."""
    try:
        from familiar.dashboard.guide import generate_guide_pdf

        pdf_path = generate_guide_pdf()
        return send_file(
            str(pdf_path),
            as_attachment=True,
            download_name="familiar_user_guide.pdf",
            mimetype="application/pdf",
        )
    except ImportError:
        # WeasyPrint not installed — serve HTML fallback
        from familiar.dashboard.guide import get_guide_html

        return Response(get_guide_html(), mimetype="text/html")
    except Exception:
        logger.exception("Failed to generate guide PDF")
        # Fallback to HTML
        try:
            from familiar.dashboard.guide import get_guide_html

            return Response(get_guide_html(), mimetype="text/html")
        except Exception:
            return jsonify({"error": "Guide generation failed"}), 500


@app.route("/api/downloads/bundle")
@require_auth
def api_downloads_bundle():
    """Download a config-only backup archive."""
    import shutil
    import tempfile

    try:
        from familiar.core.backup import BackupManager, BackupType

        manager = BackupManager()
        manifest = manager.create_backup(backup_type=BackupType.CONFIG_ONLY)

        # The backup file is in the manager's backup dir
        backup_path = manager.config.path / f"{manifest.backup_id}.tar.gz"
        if not backup_path.exists():
            return jsonify({"error": "Backup file not found"}), 500

        # Copy to temp file for serving (so we don't hold a lock)
        fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz")
        os.close(fd)
        shutil.copy2(str(backup_path), tmp_path)

        return send_file(
            tmp_path,
            as_attachment=True,
            download_name=f"familiar_config_{manifest.backup_id}.tar.gz",
            mimetype="application/gzip",
        )
    except Exception:
        logger.exception("Failed to create config backup")
        return jsonify({"error": "Backup creation failed"}), 500


# ============================================================
# Marketplace
# ============================================================


@app.route("/api/marketplace/catalog/browse")
@require_auth
def api_marketplace_catalog_browse():
    """Browse/search the unified marketplace catalog."""
    catalog = _get_marketplace_catalog()
    entity_type = request.args.get("entity_type")
    search = request.args.get("search", "").strip()
    limit = int(request.args.get("limit", 50))
    offset = int(request.args.get("offset", 0))

    entries = catalog.browse(
        entity_type=entity_type,
        limit=limit,
        offset=offset,
    )
    # Apply search filter on name/description/tags
    if search:
        lower = search.lower()
        entries = [
            e for e in entries
            if lower in e.name.lower()
            or lower in (e.description or "").lower()
            or any(lower in t.lower() for t in (e.tags or []))
        ]
    total = len(catalog.browse(entity_type=entity_type, limit=9999))
    results = []
    for e in entries:
        results.append({
            "id": e.id,
            "entity_type": e.entity_type,
            "name": e.name,
            "description": e.description,
            "version": e.version,
            "author": e.author,
            "source": e.source,
            "tags": e.tags or [],
            "entity_data": e.entity_data or {},
        })
    return jsonify({"entries": results, "total": total})


@app.route("/api/marketplace/catalog/stats")
@require_auth
def api_marketplace_catalog_stats():
    """Return counts per entity type."""
    catalog = _get_marketplace_catalog()
    return jsonify({"stats": catalog.stats()})


@app.route("/api/marketplace/catalog/entry/<entity_type>/<entry_id>")
@require_auth
def api_marketplace_catalog_entry(entity_type, entry_id):
    """Get a single catalog entry with full detail."""
    catalog = _get_marketplace_catalog()
    entry = catalog.get_entry(entity_type, entry_id)
    if not entry:
        return jsonify({"error": "Entry not found"}), 404
    return jsonify({
        "id": entry.id,
        "entity_type": entry.entity_type,
        "name": entry.name,
        "description": entry.description,
        "version": entry.version,
        "author": entry.author,
        "source": entry.source,
        "tags": entry.tags or [],
        "entity_data": entry.entity_data or {},
    })


@app.route("/api/marketplace/catalog/install", methods=["POST"])
@require_auth
def api_marketplace_catalog_install():
    """Validate and install a JSON package."""
    data = request.json or {}
    pkg_type = data.get("type", "")

    try:
        if pkg_type in ("job_class", "bundle", "familiar"):
            from familiar.jobs._marketplace import install_package

            result = install_package(data)
        elif pkg_type == "skill":
            from familiar.skills._marketplace import install_skill_package

            result = install_skill_package(data)
        elif pkg_type == "species":
            catalog = _get_marketplace_catalog()
            from familiar.marketplace.catalog import CatalogEntry

            entry = CatalogEntry(
                id=data.get("metadata", {}).get("id", "custom"),
                entity_type="species",
                name=data.get("metadata", {}).get("name", "Unknown"),
                description=data.get("metadata", {}).get("description", ""),
                version=data.get("metadata", {}).get("version", "1.0.0"),
                author=data.get("metadata", {}).get("author", ""),
                source="custom",
                tags=data.get("metadata", {}).get("tags", []),
                entity_data=data,
            )
            catalog.add(entry)
            result = f"Added species '{entry.name}' to catalog"
        else:
            return jsonify({"error": f"Unknown package type: {pkg_type}"}), 400

        if result and ("failed" in result.lower() or "error" in result.lower()):
            return jsonify({"error": result}), 400
        return jsonify({"result": result})
    except Exception as e:
        logger.debug("Marketplace install error: %s", e)
        return jsonify({"error": str(e)}), 500


@app.route("/api/marketplace/plugins")
@require_auth
def api_marketplace_plugins():
    """List available plugins with optional category/search filter."""
    from familiar.skills.marketplace.skill import get_marketplace

    mp = get_marketplace()
    category = request.args.get("category")
    search = request.args.get("search", "").strip()
    plugins = mp.list_plugins(category=category, search=search or None)

    # Collect unique categories
    all_plugins = mp.list_plugins()
    categories = sorted({p.get("category", "") for p in all_plugins if p.get("category")})

    return jsonify({"plugins": plugins, "categories": categories})


@app.route("/api/marketplace/plugins/<plugin_id>/install", methods=["POST"])
@require_auth
def api_marketplace_plugin_install(plugin_id):
    """Install a plugin by ID."""
    from familiar.skills.marketplace.skill import get_marketplace

    mp = get_marketplace()
    result = mp.install(plugin_id)
    return jsonify({"result": result})


@app.route("/api/marketplace/plugins/<plugin_id>/uninstall", methods=["POST"])
@require_auth
def api_marketplace_plugin_uninstall(plugin_id):
    """Uninstall a plugin by ID."""
    from familiar.skills.marketplace.skill import get_marketplace

    mp = get_marketplace()
    result = mp.uninstall(plugin_id)
    return jsonify({"result": result})


@app.route("/api/marketplace/plugins/<plugin_id>/update", methods=["POST"])
@require_auth
def api_marketplace_plugin_update(plugin_id):
    """Update a plugin by ID."""
    from familiar.skills.marketplace.skill import get_marketplace

    mp = get_marketplace()
    result = mp.update(plugin_id)
    return jsonify({"result": result})


# ============================================================
# Server
# ============================================================


@app.route("/api/server/runtime")
@require_auth
def api_server_runtime():
    """Get detected container runtime info."""
    mgr = _get_service_manager()
    return jsonify(mgr.get_runtime_info())


@app.route("/api/server/status")
@require_auth
def api_server_status():
    """Get status of all managed services."""
    mgr = _get_service_manager()
    return jsonify(mgr.get_all_statuses())


@app.route("/api/server/provision/<service>", methods=["POST"])
@require_auth
def api_server_provision(service):
    """Provision (pull + create) a service."""
    mgr = _get_service_manager()
    result = mgr.provision(service)
    return jsonify(result)


@app.route("/api/server/start/<service>", methods=["POST"])
@require_auth
def api_server_start(service):
    """Start a provisioned service."""
    mgr = _get_service_manager()
    result = mgr.start(service)
    return jsonify(result)


@app.route("/api/server/stop/<service>", methods=["POST"])
@require_auth
def api_server_stop(service):
    """Stop a running service."""
    mgr = _get_service_manager()
    result = mgr.stop(service)
    return jsonify(result)


@app.route("/api/server/restart/<service>", methods=["POST"])
@require_auth
def api_server_restart(service):
    """Restart a service."""
    mgr = _get_service_manager()
    result = mgr.restart(service)
    return jsonify(result)


@app.route("/api/server/remove/<service>", methods=["POST"])
@require_auth
def api_server_remove(service):
    """Remove a service container, optionally with data."""
    mgr = _get_service_manager()
    remove_data = False
    if request.is_json:
        remove_data = request.json.get("remove_data", False)
    result = mgr.remove(service, remove_data=remove_data)
    return jsonify(result)


@app.route("/api/server/logs/<service>")
@require_auth
def api_server_logs(service):
    """Get recent logs for a service."""
    mgr = _get_service_manager()
    tail = request.args.get("tail", 100, type=int)
    result = mgr.get_logs(service, tail=tail)
    return jsonify(result)


# ============================================================
# Phase 8: Peer Messaging API
# ============================================================


@app.route("/api/messages/contacts")
@require_auth
def api_messages_contacts():
    """Get conversations with online status and unread counts."""
    bridge = _get_message_bridge()
    if not bridge:
        return jsonify({"contacts": [], "error": "Messaging not available"})
    return jsonify({"contacts": bridge.get_contacts()})


@app.route("/api/messages/unread")
@require_auth
def api_messages_unread():
    """Get total unread message count (for nav badge)."""
    bridge = _get_message_bridge()
    if not bridge:
        return jsonify({"unread": 0})
    return jsonify({"unread": bridge.get_unread_count()})


@app.route("/api/messages/poll")
@require_auth
def api_messages_poll():
    """Combined poll: contacts + unread + typing + peers + mesh_state."""
    bridge = _get_message_bridge()
    if not bridge:
        # Build mesh_state even without bridge
        agent = get_agent()
        gw = getattr(agent, "_gateway", None) if agent else None
        active = gw is not None
        peer_count = len(gw.peer_gateways) if gw and hasattr(gw, "peer_gateways") else 0
        return jsonify({
            "contacts": [], "unread": 0, "typing": [], "peers": [],
            "mesh_state": {
                "active": active, "peer_count": peer_count,
                "trusted_count": 0, "messageable_count": 0,
            },
        })
    # Build mesh_state from trust manager
    tm = bridge._trust_manager
    trusted_count = 0
    messageable_count = 0
    if tm:
        for rec in tm._trust_store.get_all():
            if rec.trust_level == "trusted":
                trusted_count += 1
                if rec.permissions.send_messages:
                    messageable_count += 1
    gw = bridge._gateway
    peer_count = len(gw.peer_gateways) if gw and hasattr(gw, "peer_gateways") else 0
    peers = bridge.get_messageable_peers()
    return jsonify({
        "contacts": bridge.get_contacts(),
        "unread": bridge.get_unread_count(),
        "typing": bridge.get_typing_peers(),
        "peers": peers,
        "mesh_state": {
            "active": True,
            "peer_count": peer_count,
            "trusted_count": trusted_count,
            "messageable_count": messageable_count,
        },
    })


@app.route("/api/messages/<peer_id>")
@require_auth
def api_messages_history(peer_id):
    """Get message history for a conversation."""
    bridge = _get_message_bridge()
    if not bridge:
        return jsonify({"messages": []})
    limit = request.args.get("limit", 50, type=int)
    before = request.args.get("before", None, type=str)
    messages = bridge.get_messages(peer_id, limit=limit, before=before)
    return jsonify({"messages": messages})


@app.route("/api/messages/<peer_id>/send", methods=["POST"])
@require_auth
def api_messages_send(peer_id):
    """Send a message to a peer."""
    bridge = _get_message_bridge()
    if not bridge:
        return jsonify({"error": "Messaging not available"}), 503
    data = request.json or {}
    body = data.get("body", "").strip()
    if not body:
        return jsonify({"error": "Empty message"}), 400
    reply_to = data.get("reply_to")
    result = bridge.send_message(peer_id, body, reply_to=reply_to)
    if result.get("status") == "error":
        return jsonify(result), 403
    return jsonify(result)


@app.route("/api/messages/<peer_id>/read", methods=["POST"])
@require_auth
def api_messages_mark_read(peer_id):
    """Mark a conversation as read."""
    bridge = _get_message_bridge()
    if not bridge:
        return jsonify({"ok": False})
    bridge.mark_as_read(peer_id)
    return jsonify({"ok": True})


@app.route("/api/messages/<peer_id>/typing", methods=["POST"])
@require_auth
def api_messages_typing(peer_id):
    """Send typing indicator to a peer."""
    bridge = _get_message_bridge()
    if not bridge:
        return jsonify({"ok": False})
    bridge.send_typing(peer_id)
    return jsonify({"ok": True})


@app.route("/api/messages/peers")
@require_auth
def api_messages_peers():
    """Get trusted peers available for new conversations."""
    bridge = _get_message_bridge()
    if not bridge:
        return jsonify({"peers": []})
    return jsonify({"peers": bridge.get_messageable_peers()})


# ============================================================
# Social Dashboard
# ============================================================

_SOCIAL_TIMEOUT = 15


def _strip_html(text: str) -> str:
    """Strip HTML tags from text (double-pass to catch entity-encoded tags)."""
    import html as _html_mod

    text = re.sub(r"<[^>]+>", "", text)
    text = _html_mod.unescape(text)
    text = re.sub(r"<[^>]+>", "", text)
    return text.strip()


def _social_fetch_bluesky(endpoint: str, params: dict | None = None) -> dict | None:
    """Fetch from Bluesky XRPC API using skill-layer auth."""
    try:
        from familiar.skills.bluesky.skill import _base_url, _configured, _headers

        if not _configured():
            return None
        import httpx

        resp = httpx.get(
            f"{_base_url()}/xrpc/{endpoint}",
            headers=_headers(),
            params=params,
            timeout=_SOCIAL_TIMEOUT,
        )
        if resp.status_code != 200:
            logger.warning("Bluesky %s returned %s", endpoint, resp.status_code)
            return None
        return resp.json()
    except Exception as e:
        logger.error("Bluesky fetch error (%s): %s", endpoint, e)
        return None


def _social_fetch_mastodon(endpoint: str, params: dict | None = None) -> dict | None:
    """Fetch from Mastodon REST API using env var Bearer token."""
    try:
        from familiar.skills.mastodon.skill import _base_url, _configured, _headers

        if not _configured():
            return None
        import httpx

        resp = httpx.get(
            f"{_base_url()}{endpoint}",
            headers=_headers(),
            params=params,
            timeout=_SOCIAL_TIMEOUT,
        )
        if resp.status_code != 200:
            logger.warning("Mastodon %s returned %s", endpoint, resp.status_code)
            return None
        return resp.json()
    except Exception as e:
        logger.error("Mastodon fetch error (%s): %s", endpoint, e)
        return None


@app.route("/api/social/accounts")
@limiter.limit("30 per minute")
@require_auth
def api_social_accounts():
    """Return which social platforms are configured."""
    bluesky = False
    mastodon = False
    try:
        from familiar.skills.bluesky.skill import _configured as bs_conf

        bluesky = bs_conf()
    except Exception:
        pass
    try:
        from familiar.skills.mastodon.skill import _configured as ma_conf

        mastodon = ma_conf()
    except Exception:
        pass
    return jsonify({"bluesky": bluesky, "mastodon": mastodon})


@app.route("/api/social/config")
@limiter.limit("30 per minute")
@require_auth
def api_social_config():
    """Return display names, handles, avatars for configured accounts."""
    accounts = {}
    try:
        from familiar.skills.bluesky.skill import _configured as bs_conf

        if bs_conf():
            from familiar.skills.bluesky.skill import _get_session

            session = _get_session()
            if session:
                handle = session.get("handle", "")
                profile = _social_fetch_bluesky(
                    "app.bsky.actor.getProfile", {"actor": handle}
                )
                accounts["bluesky"] = {
                    "handle": handle,
                    "display_name": (profile or {}).get("displayName", handle),
                    "avatar": (profile or {}).get("avatar", ""),
                }
    except Exception:
        pass
    try:
        from familiar.skills.mastodon.skill import _configured as ma_conf

        if ma_conf():
            profile = _social_fetch_mastodon("/api/v1/accounts/verify_credentials")
            if profile:
                accounts["mastodon"] = {
                    "handle": f"@{profile.get('acct', '?')}",
                    "display_name": profile.get("display_name", ""),
                    "avatar": profile.get("avatar", ""),
                }
    except Exception:
        pass
    return jsonify({"accounts": accounts})


@app.route("/api/social/feed")
@limiter.limit("30 per minute")
@require_auth
def api_social_feed():
    """Combined timeline from configured platforms, sorted by timestamp."""
    platform_filter = request.args.get("platform", "")
    cursor = request.args.get("cursor", "")
    posts = []

    # Bluesky feed
    if platform_filter in ("", "bluesky"):
        params = {"limit": "30"}
        if cursor and platform_filter == "bluesky":
            params["cursor"] = cursor
        data = _social_fetch_bluesky("app.bsky.feed.getTimeline", params)
        if data:
            for item in data.get("feed", []):
                post = item.get("post", {})
                author = post.get("author", {})
                record = post.get("record", {})
                posts.append({
                    "platform": "bluesky",
                    "author": author.get("handle", "?"),
                    "display_name": author.get("displayName", ""),
                    "avatar": author.get("avatar", ""),
                    "text": record.get("text", ""),
                    "timestamp": record.get("createdAt", ""),
                    "likes": post.get("likeCount", 0),
                    "reposts": post.get("repostCount", 0),
                    "replies": post.get("replyCount", 0),
                    "uri": post.get("uri", ""),
                })

    # Mastodon feed
    if platform_filter in ("", "mastodon"):
        params = {"limit": "30"}
        if cursor and platform_filter == "mastodon":
            params["max_id"] = cursor
        data = _social_fetch_mastodon("/api/v1/timelines/home", params)
        if data and isinstance(data, list):
            for status in data:
                acct = status.get("account", {})
                posts.append({
                    "platform": "mastodon",
                    "author": f"@{acct.get('acct', '?')}",
                    "display_name": acct.get("display_name", ""),
                    "avatar": acct.get("avatar", ""),
                    "text": _strip_html(status.get("content", "")),
                    "timestamp": status.get("created_at", ""),
                    "likes": status.get("favourites_count", 0),
                    "reposts": status.get("reblogs_count", 0),
                    "replies": status.get("replies_count", 0),
                    "uri": status.get("url", status.get("uri", "")),
                })

    # Sort combined by timestamp descending
    posts.sort(key=lambda p: p.get("timestamp", ""), reverse=True)

    return jsonify({"posts": posts, "cursor": ""})


@app.route("/api/social/notifications")
@limiter.limit("30 per minute")
@require_auth
def api_social_notifications():
    """Merged notifications from configured platforms."""
    notifications = []

    # Bluesky notifications
    data = _social_fetch_bluesky(
        "app.bsky.notification.listNotifications", {"limit": "30"}
    )
    if data:
        for n in data.get("notifications", []):
            author = n.get("author", {})
            record = n.get("record", {})
            text = record.get("text", "")
            notifications.append({
                "platform": "bluesky",
                "type": n.get("reason", "unknown"),
                "author": author.get("handle", "?"),
                "avatar": author.get("avatar", ""),
                "text": text[:200] if text else "",
                "timestamp": n.get("indexedAt", ""),
                "is_read": n.get("isRead", False),
            })

    # Mastodon notifications
    data = _social_fetch_mastodon("/api/v1/notifications", {"limit": "30"})
    if data and isinstance(data, list):
        for n in data:
            acct = n.get("account", {})
            status = n.get("status", {})
            text = _strip_html(status.get("content", "")) if status else ""
            notifications.append({
                "platform": "mastodon",
                "type": n.get("type", "unknown"),
                "author": f"@{acct.get('acct', '?')}",
                "avatar": acct.get("avatar", ""),
                "text": text[:200] if text else "",
                "timestamp": n.get("created_at", ""),
                "is_read": False,
            })

    notifications.sort(key=lambda n: n.get("timestamp", ""), reverse=True)
    return jsonify({"notifications": notifications})


@app.route("/api/social/post", methods=["POST"])
@limiter.limit("30 per minute")
@require_auth
def api_social_post():
    """Create a post on selected platforms."""
    data = request.json or {}
    text = data.get("text", "").strip()
    platforms = data.get("platforms", [])

    if not text:
        return jsonify({"error": "Post text is required"}), 400
    if not platforms:
        return jsonify({"error": "Select at least one platform"}), 400

    results = {}

    if "bluesky" in platforms:
        try:
            from familiar.skills.bluesky.skill import bluesky_post

            results["bluesky"] = bluesky_post({"text": text})
        except Exception as e:
            results["bluesky"] = f"Error: {e}"

    if "mastodon" in platforms:
        try:
            from familiar.skills.mastodon.skill import mastodon_post

            results["mastodon"] = mastodon_post({"text": text})
        except Exception as e:
            results["mastodon"] = f"Error: {e}"

    return jsonify({"results": results})


@app.route("/api/social/poll")
@limiter.limit("30 per minute")
@require_auth
def api_social_poll():
    """Lightweight poll — returns unread notification count only."""
    count = 0

    data = _social_fetch_bluesky(
        "app.bsky.notification.getUnreadCount"
    )
    if data:
        count += data.get("count", 0)

    data = _social_fetch_mastodon("/api/v1/notifications", {"limit": "1"})
    if data and isinstance(data, list):
        # Mastodon doesn't have an unread-count endpoint natively;
        # approximate by checking if first notification is recent
        count += len(data)

    return jsonify({"unread_count": count})


@app.route("/api/mesh/config/name", methods=["POST"])
@require_auth
def api_mesh_config_name():
    """Set the node display name."""
    data = request.json or {}
    name = data.get("name", "").strip()
    if not name or len(name) < 2 or len(name) > 32:
        return jsonify({"error": "Name must be 2-32 characters"}), 400
    try:
        agent = get_agent()
        gw = getattr(agent, "_gateway", None)
        if not gw:
            return jsonify({"error": "Mesh gateway not active"}), 503
        if hasattr(gw, "config") and gw.config:
            gw.config.set("node_name", name)
        if hasattr(gw, "node_name"):
            gw.node_name = name
        # Update message bridge node_name too
        if hasattr(gw, "message_bridge") and gw.message_bridge:
            gw.message_bridge.node_name = name
        return jsonify({"success": True, "name": name})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/onboard/server-recommendations")
@require_auth
def api_onboard_server_recommendations():
    """Get service recommendations based on job class."""
    try:
        engine = _get_onboard_engine()
        result = engine.get_server_recommendations()
        return jsonify(result.data if result.success else {"recommendations": []})
    except Exception:
        return jsonify({"recommendations": []})


# ============================================================
# 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)
