# 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 queue
import re
import subprocess
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, safe_error

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": safe_error(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": safe_error(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": safe_error(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": safe_error(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": safe_error(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": safe_error(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": safe_error(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": safe_error(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": safe_error(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": safe_error(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": safe_error(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"


