# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# Licensed under the MIT License

"""Mesh network + peer messaging routes — /api/mesh/*, /api/messages/*."""

from __future__ import annotations

from datetime import datetime
from pathlib import Path
import json
import logging
import os
import threading

from flask import Blueprint, Response, jsonify, request, send_file

from ..auth import require_auth
from ..helpers import get_agent, get_service_manager, get_wizard_host, get_gateway, get_message_bridge, save_briefing, load_briefing, scheduled_messages, DATA_DIR, safe_error
from ..helpers import get_gateway, get_message_bridge

logger = logging.getLogger(__name__)

bp = Blueprint("mesh", __name__)



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


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

    try:
        config = get_gateway().config
        status = {
            "running": get_gateway()._running,
            "node_id": config.get("node_id", ""),
            "node_name": config.get("node_name", ""),
            "host": get_gateway().host,
            "port": get_gateway().port,
            "node_count": len(get_gateway().nodes),
            "peer_count": len(get_gateway().peer_gateways),
            "transports": {
                "websocket": {
                    "running": get_gateway()._running,
                    "address": f"ws://{get_gateway().host}:{get_gateway().port}",
                },
                "mdns": {
                    "running": get_gateway().discovery.is_running if get_gateway().discovery else False,
                    "available": True,
                    "peer_count": get_gateway().discovery.peer_store.count()
                    if get_gateway().discovery
                    else 0,
                },
                "tor": get_gateway().tor_transport.get_status()
                if get_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 get_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": safe_error(e)})


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

    try:
        peers = []
        if get_gateway().discovery and get_gateway().discovery.peer_store:
            from dataclasses import asdict

            for peer in get_gateway().discovery.peer_store.get_all():
                p = asdict(peer)
                # Add trust info if available
                if get_gateway().trust_manager:
                    rec = get_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": safe_error(e)})


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

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


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

    try:
        tm = get_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": safe_error(e)})


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

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


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

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


@bp.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 get_gateway() or not get_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 = get_gateway().trust_manager.grant_permission(node_id, permission)
        else:
            ok = get_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": safe_error(e)}), 500


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

    try:
        bridge = get_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": safe_error(e)})


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

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


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

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


@bp.route("/api/mesh/tor/enable", methods=["POST"])
@require_auth
def api_mesh_tor_enable():
    """Start Tor hidden service."""
    if not get_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 get_gateway().tor_transport and get_gateway().tor_transport.is_running:
            return jsonify(
                {
                    "success": True,
                    "onion_address": get_gateway().tor_transport.onion_address,
                    "message": "Already running",
                }
            )

        config = get_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=get_gateway().port))
        loop.close()

        if started:
            get_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": safe_error(e)}), 500


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

    try:
        if get_gateway().tor_transport:
            import asyncio

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

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


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

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

        if not get_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 = get_gateway().config
            get_gateway().discovery = MeshDiscovery(
                node_id=config.get("node_id"),
                node_name=config.get("node_name", "familiar"),
                node_type=config.get("node_type", "gateway"),
                port=get_gateway().port,
                skills=skill_names,
                fingerprint=get_gateway().trust_manager.get_our_fingerprint()
                if get_gateway().trust_manager
                else "",
                mesh_dir=None,
                max_peers=config.get("max_peers", 16),
            )

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


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

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

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


# ============================================================
# MESH — RENDEZVOUS (Phase 9)
# ============================================================


def _get_mesh_config_standalone():
    """Get MeshConfig even without a running gateway — for pre-flight configuration."""
    try:
        agent = get_agent()
        gw = getattr(agent, "get_gateway()", None)
        if gw and hasattr(gw, "config"):
            return gw.config, gw
    except Exception:
        pass
    # Fallback: load config directly from disk
    try:
        from familiar.core.mesh.gateway import MeshConfig

        return MeshConfig(), None
    except Exception:
        return None, None


@bp.route("/api/mesh/rendezvous/status")
@require_auth
def api_mesh_rendezvous_status():
    """Get rendezvous client status."""
    try:
        cfg, gw = _get_mesh_config_standalone()
        if not cfg:
            return jsonify({"enabled": False, "running": False, "servers": []})

        enabled = cfg.get("rendezvous_enabled", False)
        servers = cfg.get("rendezvous_servers", [])
        running = False
        if gw and gw.discovery:
            running = getattr(gw.discovery, "_rendezvous_running", False)

        return jsonify(
            {
                "enabled": enabled,
                "running": running,
                "servers": servers,
                "token_set": bool(cfg.get("rendezvous_token", "")),
            }
        )
    except Exception as e:
        return jsonify({"enabled": False, "error": safe_error(e)})


@bp.route("/api/mesh/rendezvous/servers", methods=["POST"])
@require_auth
def api_mesh_rendezvous_add_server():
    """Add a rendezvous server URL."""
    data = request.json or {}
    url = data.get("url", "").strip().rstrip("/")
    if not url:
        return jsonify({"error": "URL is required"}), 400

    try:
        cfg, gw = _get_mesh_config_standalone()
        if not cfg:
            return jsonify({"error": "Cannot load mesh config"}), 500

        servers = cfg.get("rendezvous_servers", [])
        if url not in servers:
            servers.append(url)
            cfg.set("rendezvous_servers", servers)

        return jsonify({"success": True, "servers": servers})
    except Exception as e:
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/mesh/rendezvous/servers", methods=["DELETE"])
@require_auth
def api_mesh_rendezvous_remove_server():
    """Remove a rendezvous server URL."""
    data = request.json or {}
    url = data.get("url", "").strip().rstrip("/")
    if not url:
        return jsonify({"error": "URL is required"}), 400

    try:
        cfg, gw = _get_mesh_config_standalone()
        if not cfg:
            return jsonify({"error": "Cannot load mesh config"}), 500

        servers = cfg.get("rendezvous_servers", [])
        servers = [s for s in servers if s != url]
        cfg.set("rendezvous_servers", servers)

        return jsonify({"success": True, "servers": servers})
    except Exception as e:
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/mesh/rendezvous/enable", methods=["POST"])
@require_auth
def api_mesh_rendezvous_enable():
    """Enable or disable rendezvous discovery."""
    data = request.json or {}
    enable = data.get("enable", True)

    try:
        cfg, gw = _get_mesh_config_standalone()
        if not cfg:
            return jsonify({"error": "Cannot load mesh config"}), 500

        cfg.set("rendezvous_enabled", bool(enable))

        # If gateway is live, start/stop rendezvous immediately
        if gw and gw.discovery:
            if enable:
                servers = cfg.get("rendezvous_servers", [])
                token = cfg.get("rendezvous_token", "")
                gw.discovery.rendezvous_servers = servers
                gw.discovery.rendezvous_token = token
                gw.discovery.start_rendezvous()
            else:
                gw.discovery.stop_rendezvous()

        return jsonify({"success": True, "enabled": bool(enable)})
    except Exception as e:
        return jsonify({"error": safe_error(e)}), 500


# Global ref for the local rendezvous server thread
_local_rdv_thread = None


@bp.route("/api/mesh/rendezvous/local/start", methods=["POST"])
@require_auth
def api_mesh_rendezvous_local_start():
    """Start a local rendezvous server on this machine."""
    global _local_rdv_thread
    if _local_rdv_thread and _local_rdv_thread.is_alive():
        return jsonify({"success": True, "already_running": True, "port": 18790})

    try:
        from familiar.core.mesh.rendezvous import start_local_server

        data = request.json or {}
        port = int(data.get("port", 18790))
        token = data.get("token", "")
        _local_rdv_thread = start_local_server(port=port, auth_token=token)
        return jsonify({"success": True, "port": port})
    except Exception as e:
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/mesh/rendezvous/local/status")
@require_auth
def api_mesh_rendezvous_local_status():
    """Check if local rendezvous server is running."""
    running = _local_rdv_thread is not None and _local_rdv_thread.is_alive()
    return jsonify({"running": running})


def api_email_weekly_digest():
    """Generate weekly email digest for review."""
    try:
        from familiar.skills.triage.skill import get_weekly_digest

        days = int(request.args.get("days", 7))
        result = get_weekly_digest({"days_back": days})
        return jsonify(result)
    except Exception as e:
        return jsonify({"error": safe_error(e)}), 500


# ── FileStore API ──

_file_store_instance = None
_file_store_init_lock = threading.Lock()


def _get_file_store():
    global _file_store_instance
    if _file_store_instance is None:
        with _file_store_init_lock:
            if _file_store_instance is None:
                from familiar.core.files import get_file_store

                _file_store_instance = get_file_store()
    return _file_store_instance


@bp.route("/api/files/upload", methods=["POST"])
@require_auth
def api_files_upload():
    """Upload a file and attach it to a record."""
    if "file" not in request.files:
        return jsonify({"error": "No file provided"}), 400

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

    record_type = request.form.get("record_type", "general")
    record_id = request.form.get("record_id", "unlinked")
    job_class = request.form.get("job_class", "general")
    description = request.form.get("description", "")

    try:
        data = f.read()
        store = _get_file_store()
        record = store.store(
            data=data,
            filename=f.filename,
            job_class=job_class,
            record_type=record_type,
            record_id=record_id,
            description=description,
        )
        return jsonify({"status": "ok", "file": record.to_dict()}), 201
    except ValueError as e:
        return jsonify({"error": safe_error(e)}), 400
    except Exception as e:
        logger.error("File upload error: %s", e)
        return jsonify({"error": "Upload failed: " + safe_error(e)}), 500


@bp.route("/api/files/<file_id>")
@require_auth
def api_files_get(file_id):
    """Serve a stored file."""
    store = _get_file_store()
    path = store.get_path(file_id)
    if not path:
        return jsonify({"error": "File not found"}), 404
    record = store.get(file_id)
    return send_file(
        path,
        mimetype=record.mime_type if record else None,
        as_attachment=True,
        download_name=record.filename if record else None,
    )


@bp.route("/api/files")
@require_auth
def api_files_list():
    """List files, optionally filtered by record or job class."""
    store = _get_file_store()
    record_type = request.args.get("record_type")
    record_id = request.args.get("record_id")
    job_class = request.args.get("job_class")
    query = request.args.get("q")

    if record_type and record_id:
        files = store.list_for_record(record_type, record_id, job_class)
    elif job_class:
        files = store.list_for_job_class(job_class)
    elif query:
        files = store.search(query=query, job_class=job_class)
    else:
        files = store.search(limit=100)

    return jsonify({"files": [f.to_dict() for f in files]})


@bp.route("/api/files/<file_id>", methods=["DELETE"])
@require_auth
def api_files_delete(file_id):
    """Delete a stored file."""
    store = _get_file_store()
    if store.delete(file_id):
        return jsonify({"status": "ok"})
    return jsonify({"error": "File not found"}), 404


@bp.route("/api/files/stats")
@require_auth
def api_files_stats():
    """Get file storage statistics."""
    store = _get_file_store()
    return jsonify(store.stats())


# ── Macro API ──


@bp.route("/api/macros")
@require_auth
def api_macros_list():
    """List available macros, optionally filtered by job class."""
    from familiar.core.macros import get_macro_registry

    reg = get_macro_registry()
    job_class = request.args.get("job_class")
    if job_class:
        macros = reg.list_for_job_class(job_class)
    else:
        macros = reg.list_all()
    return jsonify({"macros": [m.to_dict() for m in macros]})


@bp.route("/api/macros/<macro_name>", methods=["POST"])
@require_auth
def api_macros_execute(macro_name):
    """Execute a macro by name."""
    from familiar.core.macros import get_macro_registry

    reg = get_macro_registry()
    params = request.get_json(silent=True) or {}
    result = reg.execute(macro_name, params, context={"macro_name": macro_name})
    status_code = 200 if result.success else 400
    return jsonify(result.to_dict()), status_code


_briefing_cache: dict = {}  # In-memory structured briefing cache
_briefing_cache_lock = threading.Lock()


def _generate_briefing_full() -> dict:
    """Generate a full structured briefing with LLM synthesis. Heavy — call sparingly."""
    from familiar.skills.proactive.skill import generate_briefing, generate_briefing_data

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

    summary = ""
    try:
        job_class = raw_data.get("job_class", "")
        role_hint = f" The user's role is {job_class.replace('_', ' ')}." if job_class else ""

        # Truncate raw_text to ~1500 chars to keep prompt small for CPU inference
        _raw_trimmed = raw_text[:1500] if len(raw_text) > 1500 else raw_text

        synthesis_prompt = (
            "You are generating the opening card of a daily briefing dashboard.{role_hint}\n\n"
            "Read the briefing data below and write a sharp 2-4 sentence executive summary that:\n"
            "- Calls out the single most urgent item that needs action today\n"
            "- Mentions any hard deadlines or time-sensitive events\n"
            "- Flags anything that could go wrong if ignored\n"
            "- Skips fluff — every sentence should be actionable\n\n"
            "No headers, no bullet points, no greeting. Just the summary:\n\n"
            f"{_raw_trimmed}"
        ).format(role_hint=role_hint)

        # Direct Ollama call — no tools, no agent overhead.
        # chat_with_results() sends full tool schemas which bloats context
        # and causes APITimeoutError on CPU-only machines.
        import urllib.error
        import urllib.request

        # Use agent config as single source of truth for model selection.
        # The agent already resolves config.yaml + env var precedence.
        _ollama_url = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
        _ollama_model = "qwen2.5:0.5b"  # safe default
        try:
            agent = get_agent()
            if agent and hasattr(agent, "config") and agent.config.llm.ollama_model:
                _ollama_model = agent.config.llm.ollama_model
                _ollama_url = agent.config.llm.ollama_base_url
        except Exception:
            pass

        _payload = json.dumps(
            {
                "model": _ollama_model,
                "messages": [
                    {"role": "system", "content": "You are a concise briefing assistant."},
                    {"role": "user", "content": synthesis_prompt},
                ],
                "stream": False,
                "options": {"num_predict": 256},
            }
        ).encode()

        _req = urllib.request.Request(
            f"{_ollama_url}/api/chat",
            data=_payload,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(_req, timeout=30) as _resp:
            _body = json.loads(_resp.read())
            summary = _body.get("message", {}).get("content", "")

        # Record the call in analytics
        try:
            from familiar.core.analytics import get_analytics_store

            store = get_analytics_store()
            store.record_message(
                user_id="dashboard-briefing",
                channel="web",
                tokens_in=len(synthesis_prompt) // 4,
                tokens_out=len(summary) // 4,
                cost_usd=0.0,
                latency_ms=0,
                model=_ollama_model,
                skill="briefing",
            )
        except Exception:
            pass
    except Exception as e:
        logger.warning("LLM briefing synthesis failed: %s", e)

    result = {
        "summary": summary,
        "sections": raw_data["sections"],
        "timestamp": raw_data["timestamp"],
        "raw": raw_text,
    }

    with _briefing_cache_lock:
        _briefing_cache.update(result)

    # Also persist to disk — include sections so cache survives restarts
    save_briefing(summary or raw_text, source="briefing_panel", sections=raw_data["sections"])

    return result


@bp.route("/api/briefing")
@require_auth
def api_briefing():
    """Get daily briefing — serves from cache if available, generates only on first call."""
    # Serve from memory cache if fresh (< 30 min old)
    with _briefing_cache_lock:
        if _briefing_cache and _briefing_cache.get("timestamp"):
            try:
                cached_ts = datetime.fromisoformat(_briefing_cache["timestamp"])
                # Compare naive-to-naive (both local) to avoid timezone mismatch
                age_min = (datetime.now() - cached_ts.replace(tzinfo=None)).total_seconds() / 60
                if age_min < 30:
                    return jsonify(_briefing_cache)
            except Exception:
                pass  # Parse error — regenerate

    # Serve from disk if available AND has structured sections (survives restarts).
    # Plain-text-only briefings (from scheduler agent.chat) are treated as stale —
    # they lack calendar, weather, and email data that the dashboard needs.
    disk = load_briefing()
    if disk and disk.get("sections"):
        # Disk has structured data — check freshness (2 hours)
        _disk_fresh = False
        try:
            _disk_ts = datetime.fromisoformat(disk["timestamp"])
            _disk_age = (datetime.now() - _disk_ts.replace(tzinfo=None)).total_seconds() / 60
            _disk_fresh = _disk_age < 120
        except Exception:
            pass
        if _disk_fresh:
            with _briefing_cache_lock:
                _briefing_cache.update(disk)
            return jsonify(disk)

    # Generate fresh structured briefing (first call, expired, or plain-text only on disk)
    try:
        return jsonify(_generate_briefing_full())
    except Exception as e:
        logger.exception("Briefing generation failed: %s", e)
        # Last resort: serve plain-text disk briefing if structured gen fails
        if disk and disk.get("message"):
            fallback = {
                "summary": disk["message"],
                "sections": {},
                "timestamp": disk.get("timestamp", ""),
                "raw": disk["message"],
            }
            return jsonify(fallback)
        return jsonify({"error": "Failed to generate briefing"}), 500


@bp.route("/api/briefing/refresh", methods=["POST"])
@require_auth
def api_briefing_refresh():
    """Force-regenerate the structured briefing, bypassing cache."""
    try:
        with _briefing_cache_lock:
            _briefing_cache.clear()
        _generate_briefing_full()
        return jsonify({"ok": True})
    except Exception as e:
        logger.error("Briefing refresh failed: %s", e)
        return jsonify({"ok": False, "error": safe_error(e)}), 500


@bp.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.warning("Briefing action error: %s", e)
        return jsonify({"error": "Failed to process action"}), 500


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


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

    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"),
    ]:
        val = os.environ.get(env_name, "")
        _add(svc_name, display, "selfhosted", bool(val), val[:40] if val else None)

    # Stalwart Mail — TCP check on admin port or env var
    import socket

    _has_stalwart = bool(os.environ.get("EMAIL_SERVER_DOMAIN"))
    if not _has_stalwart:
        try:
            with socket.create_connection(("127.0.0.1", 8010), timeout=2.0):
                _has_stalwart = True
        except (OSError, TimeoutError):
            pass
    _stalwart_url = os.environ.get("EMAIL_SERVER_DOMAIN", "localhost:8010")
    _add(
        "email_server",
        "Stalwart Mail",
        "selfhosted",
        _has_stalwart,
        _stalwart_url if _has_stalwart else None,
    )

    # Proton Bridge — TCP check
    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 = AGENT_DIR / "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,
        }
    )


@bp.route("/api/connect/browse/browser/navigate", methods=["POST"])
# @limiter.limit (rate limiting handled by app-level middleware)
@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


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


@bp.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]}


@bp.route("/api/connect/browse/video/search", methods=["POST"])
# @limiter.limit (rate limiting handled by app-level middleware)
@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)

    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.


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


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


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


@bp.route("/api/connect/browse/search", methods=["POST"])
# @limiter.limit (rate limiting handled by app-level middleware)
@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 — deterministic, no LLM needed
    result = search_service(service, query)

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


@bp.route("/api/connect/browse")
# @limiter.limit (rate limiting handled by app-level middleware)
@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": {}})



# ── Peer Messaging ──────────────────────────────────────────


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


@bp.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()})


@bp.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()})


@bp.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, "get_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.get_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,
            },
        }
    )


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


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


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


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


@bp.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()})

