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

"""Misc routes — extracted from memory.py."""

from __future__ import annotations

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

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

from ..auth import require_auth
from ..helpers import get_agent, get_gateway, get_service_manager, get_marketplace_catalog, DATA_DIR

logger = logging.getLogger(__name__)

bp = Blueprint("misc", __name__)


@bp.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": []})


@bp.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 _load_tasks, add_task

        result = add_task(data)
        # Return the newest task for ID access
        tasks = _load_tasks()
        newest = tasks[-1] if tasks else {}
        return jsonify({"result": result, "task": newest})
    except Exception:
        return jsonify({"error": "Failed to add task"}), 500


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


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


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

    nodes = []
    for node_id, info in get_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})


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


@bp.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 (
            get_nonprofit_alerts,
            resolve_overview_cards,
        )

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

        # Add smart alerts for nonprofit workspace
        if jc.key == "nonprofit_director":
            try:
                ws["alerts"] = get_nonprofit_alerts()
            except Exception:
                ws["alerts"] = []

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


@bp.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.warning("Workspace section error: %s", e)
        return jsonify({"error": str(e)}), 500


@bp.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.warning("Workspace action error: %s", e)
        return jsonify({"result": str(e), "success": False}), 500


# ============================================================
# Workspace Data CRUD (generic JSON file records)
# ============================================================

# Allowed data files and their keys — prevents arbitrary file access
_WS_DATA_REGISTRY = {
    "studio": {"file": "studio.json", "keys": {"pieces", "commissions", "shows", "materials", "clients"}},
    "kitchen": {"file": "kitchen.json", "keys": {"recipes", "menus", "vendors"}},
    "security": {"file": "security.json", "keys": {"incidents"}},
    "osint": {"file": "osint.json", "keys": {"investigations"}},
    "network": {"file": "network.json", "keys": {"hosts"}},
}


def _ws_load_data(source: str) -> tuple:
    """Load a workspace JSON data file. Returns (data_dict, path)."""
    import json as _json

    from familiar.core.paths import DATA_DIR

    reg = _WS_DATA_REGISTRY.get(source)
    if not reg:
        return {}, None
    path = DATA_DIR / reg["file"]
    if not path.exists():
        return {}, path
    try:
        with open(path, encoding="utf-8") as f:
            return _json.load(f), path
    except (ValueError, IOError):
        return {}, path


def _ws_save_data(source: str, data: dict) -> bool:
    """Save workspace JSON data file (atomic: write to temp, then rename)."""
    import json as _json
    import tempfile

    from familiar.core.paths import DATA_DIR

    reg = _WS_DATA_REGISTRY.get(source)
    if not reg:
        return False
    path = DATA_DIR / reg["file"]
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            _json.dump(data, f, indent=2, default=str)
        os.replace(tmp, path)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise
    return True


@bp.route("/api/workspace/data/<source>/<key>", methods=["POST"])
@require_auth
def api_workspace_data_create(source, key):
    """Create a record in a workspace data file."""
    import uuid

    reg = _WS_DATA_REGISTRY.get(source)
    if not reg or key not in reg["keys"]:
        return jsonify({"error": "Invalid source/key"}), 400
    payload = request.get_json(silent=True) or {}
    data, _ = _ws_load_data(source)
    records = data.get(key, [])
    record = {**payload, "id": str(uuid.uuid4())[:8]}
    records.append(record)
    data[key] = records
    _ws_save_data(source, data)
    return jsonify({"record": record, "id": record["id"]}), 201


@bp.route("/api/workspace/data/<source>/<key>/<record_id>", methods=["PUT"])
@require_auth
def api_workspace_data_update(source, key, record_id):
    """Update a record in a workspace data file."""
    reg = _WS_DATA_REGISTRY.get(source)
    if not reg or key not in reg["keys"]:
        return jsonify({"error": "Invalid source/key"}), 400
    payload = request.get_json(silent=True) or {}
    data, _ = _ws_load_data(source)
    records = data.get(key, [])
    for i, r in enumerate(records):
        if r.get("id") == record_id:
            records[i] = {**r, **payload, "id": record_id}
            data[key] = records
            _ws_save_data(source, data)
            return jsonify({"record": records[i]})
    return jsonify({"error": "Record not found"}), 404


@bp.route("/api/workspace/data/<source>/<key>/<record_id>", methods=["DELETE"])
@require_auth
def api_workspace_data_delete(source, key, record_id):
    """Delete a record from a workspace data file."""
    reg = _WS_DATA_REGISTRY.get(source)
    if not reg or key not in reg["keys"]:
        return jsonify({"error": "Invalid source/key"}), 400
    data, _ = _ws_load_data(source)
    records = data.get(key, [])
    before = len(records)
    records = [r for r in records if r.get("id") != record_id]
    if len(records) == before:
        return jsonify({"error": "Record not found"}), 404
    data[key] = records
    _ws_save_data(source, data)
    return jsonify({"status": "ok"})


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


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


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


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


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


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


@bp.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 = []
    catalog_tags: set[str] = set()
    for tag, display_name, size, description, _url in OLLAMA_MODEL_CATALOG:
        installed = tag in installed_names or f"{tag}:latest" in installed_names
        # Active match: exact or base name match (e.g. qwen2.5:7b matches qwen2.5:14b)
        is_active = (
            tag == active_model
            or f"{tag}:latest" == active_model
            or (
                active_model
                and tag.split(":")[0] == active_model.split(":")[0]
                and active_model in installed_names
            )
        )
        models.append(
            {
                "tag": tag,
                "display_name": display_name,
                "size": size,
                "description": description,
                "installed": installed,
                "active": is_active and tag == active_model,
            }
        )
        catalog_tags.add(tag)
        catalog_tags.add(f"{tag}:latest")

    # Add installed models not in the catalog
    for name in sorted(installed_names):
        if name not in catalog_tags:
            base = name.split(":")[0]
            suffix = name.split(":")[-1] if ":" in name else ""
            display = f"{base} ({suffix})" if suffix and suffix != "latest" else base
            models.append(
                {
                    "tag": name,
                    "display_name": display.title(),
                    "size": "",
                    "description": "Locally installed model",
                    "installed": True,
                    "active": name == active_model,
                }
            )

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


@bp.route("/api/models/ollama/loaded")
@require_auth
def api_models_ollama_loaded():
    """Return currently loaded Ollama models with memory usage."""
    try:
        import httpx

        resp = httpx.get("http://localhost:11434/api/ps", timeout=5)
        ps_data = resp.json()
    except Exception:
        return jsonify({"loaded": [], "total_ram_bytes": 0})

    loaded = []
    total_ram = 0
    for m in ps_data.get("models", []):
        size = m.get("size", 0)
        vram = m.get("size_vram", 0)
        ram = size - vram  # what's in system RAM vs GPU VRAM
        total_ram += size
        loaded.append(
            {
                "name": m.get("name", ""),
                "size_bytes": size,
                "size_mb": round(size / 1024 / 1024),
                "vram_bytes": vram,
                "ram_bytes": ram,
                "context_length": m.get("context_length", 0),
                "expires_at": m.get("expires_at", ""),
                "family": m.get("details", {}).get("family", ""),
                "parameter_size": m.get("details", {}).get("parameter_size", ""),
                "quantization": m.get("details", {}).get("quantization_level", ""),
            }
        )

    return jsonify(
        {
            "loaded": loaded,
            "total_ram_bytes": total_ram,
            "total_ram_mb": round(total_ram / 1024 / 1024),
            "model_count": len(loaded),
        }
    )


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


@bp.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()
        # Update the config BEFORE switching so the provider picks up the new model
        agent.config.llm.ollama_model = model
        agent.switch_provider("ollama")

        # Persist to config file so it survives restarts
        try:
            config_path = CONFIG_FILE
            if config_path.exists():
                import yaml

                cfg_data = yaml.safe_load(config_path.read_text()) or {}
                cfg_data.setdefault("llm", {})["ollama_model"] = model
                config_path.write_text(yaml.dump(cfg_data, default_flow_style=False))
        except Exception as e:
            logger.debug("Failed to persist ollama_model to config: %s", e)

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


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


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


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


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


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


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

    # Validate package structure before install
    try:
        from familiar.marketplace.validation import validate_package

        errors = validate_package(data)
        if errors:
            return jsonify({"success": False, "error": f"Validation failed: {'; '.join(errors)}"}), 400
    except ImportError:
        pass  # validation module optional

    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.warning("Marketplace install error: %s", e)
        return jsonify({"error": str(e)}), 500


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


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


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


@bp.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})
# ============================================================
# Corpus / Training Dashboard
# ============================================================


@bp.route("/api/corpus/status")
@require_auth
def api_corpus_status():
    """Overall corpus status: consent, latest run, entry count."""
    from familiar.skills.training.corpus import (
        CURRENT_CONSENT_VERSION,
        check_corpus_consent,
        get_corpus_status,
        load_consent_registry,
    )

    status = get_corpus_status()
    registry = load_consent_registry()
    user_id = "default"
    rec = registry.get(user_id, {})
    status["consent"] = {
        "active": check_corpus_consent(user_id),
        "consent_version": rec.get("consent_version", ""),
        "granted_at": rec.get("granted_at", ""),
        "chain_block_hash": rec.get("chain_block_hash", ""),
        "revoked": rec.get("revoked", False),
    }
    status["consent_version"] = CURRENT_CONSENT_VERSION
    return jsonify(status)


@bp.route("/api/corpus/consent", methods=["POST"])
@require_auth
def api_corpus_consent():
    """Grant or revoke corpus consent."""
    from familiar.skills.training.corpus import grant_corpus_consent, revoke_corpus_consent

    data = request.get_json(silent=True) or {}
    action = data.get("action", "grant")
    user_id = data.get("user_id", "default")
    if action == "grant":
        block_hash = grant_corpus_consent(user_id)
        return jsonify({"ok": True, "chain_block_hash": block_hash})
    elif action == "revoke":
        revoke_corpus_consent(user_id)
        return jsonify({"ok": True})
    else:
        return jsonify({"ok": False, "error": f"Unknown action: {action}"}), 400


@bp.route("/api/corpus/memories")
@require_auth
def api_corpus_memories():
    """List markdown memory files with training eligibility metadata."""
    from familiar.skills.training.corpus import list_memories

    memories = list_memories()
    return jsonify({"memories": memories, "total": len(memories)})


@bp.route("/api/corpus/memories/toggle", methods=["POST"])
@require_auth
def api_corpus_memories_toggle():
    """Toggle training_eligible on a memory file."""
    from familiar.skills.training.corpus import toggle_memory_eligible

    data = request.get_json(silent=True) or {}
    filename = data.get("filename", "")
    eligible = bool(data.get("eligible", False))
    if not filename:
        return jsonify({"ok": False, "error": "filename required"}), 400
    found = toggle_memory_eligible(filename, eligible)
    if found:
        return jsonify({"ok": True, "filename": filename, "eligible": eligible})
    return jsonify({"ok": False, "error": f"File not found: {filename}"}), 404


@bp.route("/api/corpus/entries")
@require_auth
def api_corpus_entries():
    """Return paginated corpus entries."""
    from familiar.skills.training.corpus import list_entries

    try:
        limit = int(request.args.get("limit", 20))
        offset = int(request.args.get("offset", 0))
    except (TypeError, ValueError):
        limit, offset = 20, 0
    limit = min(limit, 50)
    return jsonify(list_entries(limit=limit, offset=offset))


@bp.route("/api/corpus/entries/<entry_id>", methods=["DELETE"])
@require_auth
def api_corpus_entry_delete(entry_id: str):
    """Delete a specific corpus entry by id."""
    from familiar.skills.training.corpus import delete_entry

    found = delete_entry(entry_id)
    if found:
        return jsonify({"ok": True})
    return jsonify({"ok": False, "error": "Entry not found"}), 404


@bp.route("/api/corpus/run", methods=["POST"])
@require_auth
def api_corpus_run():
    """Trigger a corpus pipeline run."""
    from familiar.skills.training.corpus import (
        CorpusPipeline,
        check_corpus_consent,
        record_corpus_run,
    )

    if not check_corpus_consent("default"):
        return jsonify({"ok": False, "error": "No active corpus consent. Grant consent first."})
    try:
        pipeline = CorpusPipeline()
        summary = pipeline.run()
        record_corpus_run(summary)
        return jsonify({"ok": True, "summary": summary})
    except Exception as e:
        logger.exception("Corpus pipeline run failed")
        return jsonify({"ok": False, "error": str(e)}), 500


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


@bp.route("/api/social/accounts")
# rate limiting handled at app level
@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})


@bp.route("/api/social/config")
# rate limiting handled at app level
@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})


@bp.route("/api/social/feed")
# rate limiting handled at app level
@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": ""})


@bp.route("/api/social/notifications")
# rate limiting handled at app level
@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})


@bp.route("/api/social/post", methods=["POST"])
# rate limiting handled at app level
@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})


@bp.route("/api/social/poll")
# rate limiting handled at app level
@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})


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


@bp.route("/api/onboard/server-recommendations")
@require_auth  # was _require_localhost — moved to onboard blueprint
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": []})


@bp.route("/api/server/full-setup", methods=["POST"])
@require_auth
def api_server_full_setup():
    """One-button server setup: detect runtime → install → provision → start.

    No LLM involved. Reads the user's job class, maps it to services,
    and does everything from installing Podman to starting containers.

    Body (optional):
        job_class: Override job class (default: read from creature state)
        services: Explicit list of service keys (overrides job class mapping)
    """
    mgr = get_service_manager()
    data = request.get_json(silent=True) or {}

    # Determine which services to set up
    service_keys = data.get("services")
    if not service_keys:
        try:
            engine = _get_onboard_engine()
            result = engine.get_server_recommendations()
            service_keys = (
                [r["key"] for r in result.data.get("recommendations", [])] if result.success else []
            )
        except Exception:
            service_keys = []

    if not service_keys:
        return jsonify({"error": "No services to set up. Set a job class first."}), 400

    # Run the full setup pipeline
    try:
        result = mgr.full_server_setup(service_keys)
        return jsonify(result)
    except Exception as exc:
        return jsonify({"error": str(exc)}), 500


@bp.route("/api/server/bootstrap", methods=["POST"])
@require_auth
def api_server_bootstrap():
    """Bootstrap container runtime from scratch.

    If no container runtime (Podman/Docker) is detected, installs Podman via
    the system package manager. Optionally chains to auto-provision afterwards.

    Query params:
        auto_provision: If "true", runs auto-provision after bootstrap succeeds.
    """
    mgr = get_service_manager()

    # Bootstrap the runtime
    result = mgr.bootstrap_runtime()
    if not result.get("success"):
        return jsonify(result), 500

    # Optionally chain to auto-provision
    if request.args.get("auto_provision", "").lower() == "true" and not result.get(
        "already_installed"
    ):
        # Runtime just installed — re-init manager runtime cache
        mgr.invalidate_runtime()

    return jsonify(result)


@bp.route("/api/server/auto-provision", methods=["POST"])
@require_auth
def api_server_auto_provision():
    """Auto-provision all recommended services for the current job class.

    Iterates through the job class service map, skips already-running services,
    and provisions + starts each one. Returns per-service results.
    """
    mgr = get_service_manager()

    # Auto-bootstrap container runtime if missing
    from familiar.services.manager import ContainerRuntime

    if mgr.detect_runtime() == ContainerRuntime.NONE:
        bootstrap = mgr.bootstrap_runtime()
        if not bootstrap.get("success"):
            return jsonify(
                {
                    "error": "No container runtime available and auto-install failed: "
                    + bootstrap.get("error", "unknown"),
                    "bootstrap_attempted": True,
                }
            ), 500

    try:
        engine = _get_onboard_engine()
        result = engine.get_server_recommendations()
        recs = [r["key"] for r in result.data.get("recommendations", [])] if result.success else []
    except Exception:
        recs = []

    if not recs:
        return jsonify({"error": "No services recommended for current job class."}), 400

    # Check current state
    results = []
    to_provision = []

    for key in recs:
        st = mgr.get_status(key)
        state = st.get("status", "unknown")
        if state == "running":
            results.append({"service": key, "status": "already_running", "ok": True})
        else:
            to_provision.append(key)

    # Provision and start in order (respecting depends_on)
    from familiar.services.specs import SERVICE_SPECS

    for key in to_provision:
        spec = SERVICE_SPECS.get(key)
        if not spec:
            results.append({"service": key, "status": "unknown_spec", "ok": False})
            continue
        if spec.service_type.value == "native":
            # Native services (cockpit) — just check if systemd unit exists
            if spec.systemctl_unit:
                results.append(
                    {
                        "service": key,
                        "status": "native_skipped",
                        "ok": True,
                        "hint": f"Install via: sudo dnf install {key} or sudo apt install {key}",
                    }
                )
            else:
                results.append({"service": key, "status": "native_skipped", "ok": True})
            continue
        try:
            prov = mgr.provision(key)
            if prov.get("error"):
                results.append(
                    {
                        "service": key,
                        "status": "provision_failed",
                        "error": prov["error"],
                        "ok": False,
                    }
                )
                continue
            start = mgr.start(key)
            if start.get("error"):
                results.append(
                    {"service": key, "status": "start_failed", "error": start["error"], "ok": False}
                )
            else:
                entry = {"service": key, "status": "started", "ok": True}
                if spec.setup_notes:
                    entry["setup_notes"] = spec.setup_notes
                results.append(entry)
        except Exception as e:
            results.append({"service": key, "status": "error", "error": str(e), "ok": False})

    ok_count = sum(1 for r in results if r["ok"])
    return jsonify(
        {
            "results": results,
            "total": len(recs),
            "ok": ok_count,
            "failed": len(results) - ok_count,
            "job_class": result.data.get("job_class", "") if result.success else "",
        }
    )


# ============================================================
# Smart Intake API
# ============================================================


@bp.route("/api/intake/process", methods=["POST"])
@require_auth
def api_intake_process():
    """Process a file through smart intake pipeline."""
    from familiar.skills.intake.skill import smart_intake

    data = request.get_json(silent=True) or {}
    if not data.get("filepath"):
        return jsonify({"error": "filepath required"}), 400
    result = smart_intake(data)
    return jsonify({"result": result})


@bp.route("/api/intake/history")
@require_auth
def api_intake_history():
    """Get intake history entries."""
    from familiar.skills.intake.history import get_intake_history

    history = get_intake_history()
    limit = request.args.get("limit", 50, type=int)
    content_type = request.args.get("content_type")
    entries = history.list_entries(limit=limit, content_type=content_type)
    return jsonify({"entries": entries, "count": len(entries)})


@bp.route("/api/intake/stats")
@require_auth
def api_intake_stats():
    """Get intake statistics."""
    from familiar.skills.intake.history import get_intake_history

    history = get_intake_history()
    return jsonify(history.get_stats())


@bp.route("/api/intake/batch", methods=["POST"])
@require_auth
def api_intake_batch():
    """Process multiple files through intake."""
    from familiar.skills.intake.skill import batch_intake

    data = request.get_json(silent=True) or {}
    result = batch_intake(data)
    return jsonify({"result": result})


@bp.route("/api/intake/upload", methods=["POST"])
@require_auth
def api_intake_upload():
    """Upload a file and process through intake pipeline."""
    if "file" not in request.files:
        return jsonify({"error": "No file uploaded"}), 400

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

    # Validate file extension
    from familiar.skills.intake.skill import _ALL_SUPPORTED

    ext = Path(file.filename).suffix.lower()
    if ext not in _ALL_SUPPORTED:
        return jsonify(
            {
                "error": f"Unsupported file type: {ext}",
                "supported": "Images (JPG/PNG/TIFF), PDF, DOCX, XLSX, CSV, TXT, MP3, WAV, M4A",
            }
        ), 400

    # Check file size (50MB limit)
    file.seek(0, 2)
    size = file.tell()
    file.seek(0)
    if size > 50 * 1024 * 1024:
        return jsonify({"error": "File too large (50MB limit)"}), 413

    # Save to temp intake directory
    intake_dir = Path.home() / ".familiar" / "data" / "intake_uploads"
    intake_dir.mkdir(parents=True, exist_ok=True)

    import secrets as _secrets

    safe_name = f"{_secrets.token_hex(4)}_{file.filename}"
    save_path = intake_dir / safe_name
    file.save(str(save_path))

    # Process through intake
    from familiar.skills.intake.skill import smart_intake

    dry_run = request.form.get("dry_run", "false").lower() == "true"
    result = smart_intake({"filepath": str(save_path), "dry_run": dry_run})

    # Clean up uploaded file after processing (data is now in stores)
    if not dry_run:
        try:
            save_path.unlink()
        except OSError:
            pass

    return jsonify({"result": result, "filepath": str(save_path)})


# ============================================================
# Scribe endpoints
# ============================================================


@bp.route("/api/scribe/inbox")
@require_auth
def api_scribe_inbox():
    """Get scribe inbox (recent intake entries)."""
    from familiar.skills.intake.history import get_intake_history

    history = get_intake_history()
    limit = request.args.get("limit", 50, type=int)
    entries = history.list_entries(limit=limit)
    return jsonify({"entries": entries, "count": len(entries)})


@bp.route("/api/scribe/inbox/process", methods=["POST"])
@require_auth
def api_scribe_inbox_process():
    """Process a file through scribe intake."""
    from familiar.skills.intake.skill import smart_intake

    data = request.get_json(silent=True) or {}
    if not data.get("filepath"):
        return jsonify({"error": "filepath required"}), 400
    result = smart_intake(data)
    return jsonify({"result": result})


@bp.route("/api/scribe/review")
@require_auth
def api_scribe_review():
    """Get review queue items."""
    from familiar.skills.intake.review import get_review_queue

    queue = get_review_queue()
    limit = request.args.get("limit", 50, type=int)
    pending = queue.list_pending(limit=limit)
    stats = queue.get_stats()
    return jsonify({"items": pending, "stats": stats})


@bp.route("/api/scribe/review/<item_id>/approve", methods=["POST"])
@require_auth
def api_scribe_review_approve(item_id):
    """Approve a review item."""
    from familiar.skills.intake.review import get_review_queue, process_review_decision

    data = request.get_json(silent=True) or {}
    result = process_review_decision(
        item_id=item_id,
        action="approve",
        corrected_type=data.get("corrected_type"),
        corrected_fields=data.get("corrected_fields"),
    )
    return jsonify({"result": result})


@bp.route("/api/scribe/review/<item_id>/reject", methods=["POST"])
@require_auth
def api_scribe_review_reject(item_id):
    """Reject a review item."""
    from familiar.skills.intake.review import get_review_queue, process_review_decision

    data = request.get_json(silent=True) or {}
    result = process_review_decision(
        item_id=item_id,
        action="reject",
        reason=data.get("reason", ""),
    )
    return jsonify({"result": result})


@bp.route("/api/scribe/search")
@require_auth
def api_scribe_search():
    """Search intake history."""
    from familiar.skills.intake.history import get_intake_history

    history = get_intake_history()
    content_type = request.args.get("content_type")
    limit = request.args.get("limit", 50, type=int)
    entries = history.list_entries(limit=limit, content_type=content_type)
    return jsonify({"entries": entries, "count": len(entries)})


@bp.route("/api/scribe/stats")
@require_auth
def api_scribe_stats():
    """Get scribe statistics (intake + review)."""
    from familiar.skills.intake.history import get_intake_history
    from familiar.skills.intake.review import get_review_queue

    history = get_intake_history()
    queue = get_review_queue()
    return jsonify(
        {
            "intake": history.get_stats(),
            "review": queue.get_stats(),
        }
    )


@bp.route("/api/scribe/watches", methods=["GET"])
@require_auth
def api_scribe_watches_list():
    """List watch folders."""
    from familiar.skills.intake.watcher import get_folder_watcher

    watcher = get_folder_watcher()
    return jsonify({"watches": watcher.list_watches()})


@bp.route("/api/scribe/watches", methods=["POST"])
@require_auth
def api_scribe_watches_add():
    """Add a watch folder."""
    from familiar.skills.intake.watcher import get_folder_watcher

    data = request.get_json(silent=True) or {}
    if not data.get("directory"):
        return jsonify({"error": "directory required"}), 400
    watcher = get_folder_watcher()
    try:
        watch_id = watcher.add_watch(
            directory=data["directory"],
            recursive=data.get("recursive", False),
            file_types=data.get("file_types"),
            label=data.get("label", ""),
        )
        return jsonify({"watch_id": watch_id})
    except ValueError as e:
        return jsonify({"error": str(e)}), 400


@bp.route("/api/scribe/watches/<watch_id>", methods=["DELETE"])
@require_auth
def api_scribe_watches_delete(watch_id):
    """Remove a watch folder."""
    from familiar.skills.intake.watcher import get_folder_watcher

    watcher = get_folder_watcher()
    if watcher.remove_watch(watch_id):
        return jsonify({"removed": True})
    return jsonify({"error": "Watch not found"}), 404


@bp.route("/api/scribe/clips")
@require_auth
def api_scribe_clips():
    """List web clips."""
    from familiar.skills.intake.clipper import get_web_clipper

    clipper = get_web_clipper()
    limit = request.args.get("limit", 50, type=int)
    return jsonify({"clips": clipper.list_clips(limit=limit)})


@bp.route("/api/scribe/templates")
@require_auth
def api_scribe_templates():
    """List extraction templates."""
    from familiar.skills.intake.templates import get_template_store

    store = get_template_store()
    limit = request.args.get("limit", 50, type=int)
    return jsonify({"templates": store.list_templates(limit=limit)})


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


_first_run_mode = False


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

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

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

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

        enforce_token_permissions()
    except Exception:
        pass

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

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

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

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

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

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

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

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

    import webbrowser

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

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

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

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

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


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