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

"""Settings, credentials, shell, creature, and remaining API routes."""

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, make_response, redirect, render_template, request, send_file

from ..auth import require_auth, is_first_run as _is_first_run
from ..helpers import (
    get_agent, get_service_manager, get_wizard_host,
    load_briefing, save_briefing, scheduled_messages, safe_error,
    DATA_DIR, AGENT_DIR,
)

logger = logging.getLogger(__name__)

bp = Blueprint("settings", __name__)


# API Routes
# ============================================================


@bp.route("/ui-preference", methods=["POST"])
def set_ui_preference():
    """Set UI preference (v1 or v2) via cookie."""
    data = request.json or {}
    pref = data.get("ui", "v1")
    resp = jsonify({"ui": pref})
    resp.set_cookie("familiar_ui", pref, max_age=365 * 24 * 3600, samesite="Lax")
    return resp


@bp.route("/classic")
def index():
    """Legacy v1 dashboard (Jinja templates)."""

    if _is_first_run() and not request.args.get("api_key"):
        return redirect("/onboard")

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

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

        version = _pkg_version("familiar-agent")
    except Exception:
        pass
    if not version:
        try:
            version = (Path(__file__).resolve().parent.parent / "VERSION").read_text().strip()
        except Exception:
            pass
    # Resolve the dashboard API key so the JS always has it
    injected_api_key = (
        request.args.get("api_key")
        or os.environ.get("FAMILIAR_DASHBOARD_KEY")
        or getattr(require_auth, "_auto_key", "")
        or ""
    )
    resp = make_response(
        render_template(
            "dashboard.html",
            creature_name=creature_name,
            creature_species_label=creature_species_label,
            creature_color_name=creature_color_name,
            owner_name=owner_name,
            is_new_setup=is_new_setup,
            version=version,
            injected_api_key=injected_api_key,
        )
    )
    resp.headers["Cache-Control"] = "no-cache"
    return resp


@bp.route("/api/version/check")
@require_auth
def api_version_check():
    """Check PyPI for a newer version of familiar-agent."""
    try:
        from familiar.core.health import get_health_checker

        hc = get_health_checker()
        if "pypi_update" in hc._dependency_checks:
            result = hc._dependency_checks["pypi_update"]()
            return jsonify(result.details)
    except Exception:
        pass
    import familiar

    return jsonify(
        {
            "current_version": familiar.__version__,
            "latest_version": familiar.__version__,
            "update_available": False,
        }
    )


@bp.route("/api/status")
@require_auth
def api_status():
    """Get system status."""
    agent = get_agent()
    status = agent.get_status()

    # Add more details
    status["timestamp"] = datetime.now().isoformat()
    status["uptime"] = _get_uptime()

    return jsonify(status)


@bp.route("/api/image-providers")
@require_auth
def api_image_providers():
    """Get available image generation providers and their status."""
    try:
        from familiar.skills.image_gen.skill import _detect_providers
        providers = _detect_providers()
    except Exception:
        providers = []

    # Read preferred provider from config
    preferred = ""
    try:
        config_path = Path.home() / ".familiar" / "config.yaml"
        if config_path.exists():
            import yaml
            with open(config_path) as f:
                cfg = yaml.safe_load(f) or {}
            preferred = cfg.get("image_gen", {}).get("preferred_provider", "")
    except Exception:
        pass

    return jsonify({"providers": providers, "preferred": preferred})


@bp.route("/api/image-providers/preferred", methods=["POST"])
@require_auth
def api_image_providers_set_preferred():
    """Set the preferred image generation provider."""
    data = request.get_json(silent=True) or {}
    provider = data.get("provider", "auto")

    try:
        import yaml
        config_path = Path.home() / ".familiar" / "config.yaml"
        cfg = {}
        if config_path.exists():
            with open(config_path) as f:
                cfg = yaml.safe_load(f) or {}
        if "image_gen" not in cfg:
            cfg["image_gen"] = {}
        cfg["image_gen"]["preferred_provider"] = provider
        with open(config_path, "w") as f:
            yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=False)
        return jsonify({"status": "ok", "preferred": provider})
    except Exception as e:
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/scheduled-messages")
@require_auth
def apischeduled_messages():
    """Poll for scheduled messages (briefings, heartbeats, reminders).

    Returns and drains queued messages so each is delivered once.
    The frontend polls this every few seconds when the chat panel is visible.
    """
    messages = []
    while scheduled_messages:
        try:
            messages.append(scheduled_messages.popleft())
        except IndexError:
            break
    return jsonify({"messages": messages})


@bp.route("/api/latest-briefing")
@require_auth
def api_latest_briefing():
    """Get the latest persisted briefing (survives restarts)."""
    return jsonify(load_briefing())


@bp.route("/api/latest-briefing/refresh", methods=["POST"])
@require_auth
def api_latest_briefing_refresh():
    """Trigger a fresh briefing on demand. Uses structured generator, not agent.chat()."""
    try:
        result = _generate_briefing_full()
        return jsonify({"ok": True, "message": result.get("summary") or result.get("raw", "")})
    except Exception as e:
        logger.error("Briefing refresh failed: %s", e)
        return jsonify({"ok": False, "error": safe_error(e)}), 500


# ============================================================
# Calendar
# ============================================================


@bp.route("/api/calendar/events")
@require_auth
def api_calendar_events():
    """Get calendar events for the dashboard Calendar panel."""
    days = request.args.get("days", "7", type=str)
    view = request.args.get("view", "week", type=str)

    try:
        days_int = int(days)
    except (ValueError, TypeError):
        days_int = 7

    # Clamp days based on view
    if view == "day":
        days_int = max(days_int, 1)
    elif view == "month":
        days_int = max(days_int, 28)

    events: list[dict] = []
    accounts: list[dict] = []
    try:
        from familiar.skills.calendar.skill import get_events_structured

        events = get_events_structured({"date": "today", "days": days_int})
    except Exception as e:
        logger.error("Calendar events fetch failed: %s", e)

    try:
        from familiar.skills.calendar.accounts import get_all_accounts

        raw_accounts = get_all_accounts()
        # Return safe subset (no passwords/tokens)
        accounts = [
            {"type": a.get("type", ""), "label": a.get("label", ""), "id": a.get("id", "")}
            for a in raw_accounts
        ]
    except Exception as e:
        logger.warning("Calendar accounts fetch failed: %s", e)

    return jsonify({"events": events, "accounts": accounts, "view": view})


@bp.route("/api/provider-health")
@require_auth
def api_provider_health():
    """Check configured provider health — surfaces billing/credit issues."""
    import httpx

    results = {}
    _checks = {
        "anthropic": {
            "env_key": "ANTHROPIC_API_KEY",
            "url": "https://api.anthropic.com/v1/messages",
            "billing_url": "https://console.anthropic.com/settings/plans",
            "method": "post",
            "headers_fn": lambda k: {
                "x-api-key": k,
                "anthropic-version": "2023-06-01",
                "content-type": "application/json",
            },
            "body": {
                "model": "claude-haiku-4-5-20251001",
                "max_tokens": 1,
                "messages": [{"role": "user", "content": "ping"}],
            },
        },
        "openai": {
            "env_key": "OPENAI_API_KEY",
            "url": "https://api.openai.com/v1/models",
            "billing_url": "https://platform.openai.com/account/billing",
            "method": "get",
            "headers_fn": lambda k: {"Authorization": f"Bearer {k}"},
            "body": None,
        },
        "gemini": {
            "env_key": "GEMINI_API_KEY",
            "url": None,  # key appended as param
            "billing_url": "https://aistudio.google.com/billing",
            "method": "get",
            "headers_fn": lambda k: {},
            "body": None,
        },
    }

    # Load keys from agent env file
    env_path = AGENT_DIR / ".env"
    env_vars = {}
    if env_path.exists():
        for line in env_path.read_text().splitlines():
            if "=" in line and not line.strip().startswith("#"):
                k, _, v = line.partition("=")
                env_vars[k.strip()] = v.strip()

    for provider, cfg in _checks.items():
        api_key = os.environ.get(cfg["env_key"]) or env_vars.get(cfg["env_key"])
        if not api_key:
            results[provider] = {"status": "not_configured"}
            continue

        try:
            with httpx.Client(timeout=10) as client:
                if provider == "gemini":
                    url = f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}"
                    resp = client.get(url)
                else:
                    headers = cfg["headers_fn"](api_key)
                    if cfg["method"] == "post":
                        resp = client.post(cfg["url"], headers=headers, json=cfg["body"])
                    else:
                        resp = client.get(cfg["url"], headers=headers)

                body_text = resp.text.lower()
                if resp.status_code == 200 or resp.status_code == 201:
                    results[provider] = {"status": "ok"}
                elif any(
                    kw in body_text
                    for kw in [
                        "credit balance",
                        "insufficient",
                        "billing",
                        "payment required",
                        "purchase credits",
                    ]
                ):
                    results[provider] = {
                        "status": "billing_error",
                        "billing_url": cfg["billing_url"],
                        "message": (f"{provider.title()} account has insufficient credits."),
                    }
                elif resp.status_code == 401:
                    results[provider] = {
                        "status": "auth_error",
                        "message": "Invalid API key",
                    }
                else:
                    results[provider] = {"status": "ok"}
        except Exception as e:
            results[provider] = {
                "status": "connection_error",
                "message": safe_error(e)[:100],
            }

    # Check Ollama
    try:
        with httpx.Client(timeout=5) as client:
            resp = client.get("http://localhost:11434/api/tags")
            results["ollama"] = {"status": "ok" if resp.status_code == 200 else "unavailable"}
    except Exception:
        results["ollama"] = {"status": "unavailable"}

    # Build warnings list for providers with billing issues
    warnings = []
    for provider, result in results.items():
        if result.get("status") == "billing_error":
            warnings.append(
                {
                    "provider": provider,
                    "message": result["message"],
                    "billing_url": result.get("billing_url"),
                }
            )

    return jsonify(
        {
            "providers": results,
            "warnings": warnings,
            "timestamp": datetime.now().isoformat(),
        }
    )


@bp.route("/api/credentials/health")
@require_auth
def api_credentials_health():
    """Credential health summary — lightweight cached-status read."""
    from familiar.core.health import get_credential_health_summary

    summary = get_credential_health_summary()
    return jsonify(summary)


@bp.route("/api/credentials/status")
@require_auth
def api_credentials_status():
    """All credential statuses with spec metadata for dashboard cards."""
    from familiar.core.credential_health import get_credential_status
    from familiar.core.credential_registry import CREDENTIAL_REGISTRY, is_configured

    category = request.args.get("category")

    # Build running-service map for helpful hints
    running_services: dict = {}
    try:
        from familiar.services.manager import get_service_manager
        from familiar.services.specs import SERVICE_SPECS as _svc_specs

        mgr = get_service_manager()
        for svc_key, svc_spec in _svc_specs.items():
            st = mgr.get_status(svc_key)
            if st.get("status") == "running" and svc_spec.ports:
                first_port = next(iter(svc_spec.ports.keys()))
                running_services[svc_key] = {
                    "port": first_port,
                    "url": f"http://localhost:{first_port}",
                }
    except Exception:
        pass

    results = []
    for key, spec in CREDENTIAL_REGISTRY.items():
        if category and spec.category != category:
            continue
        status = get_credential_status(key)
        # Use LIVE configured check — cache may be stale from a previous session
        live_configured = is_configured(key)
        # If not currently configured, clear stale errors from cache
        if not live_configured and status.attention_needed:
            status.attention_needed = False
            status.last_error = None
            status.valid = None

        entry: dict = {
            "service_key": key,
            "display_name": spec.display_name,
            "category": spec.category,
            "credential_type": spec.credential_type.value,
            "reauth_type": spec.reauth_type.value,
            "doc_url": spec.doc_url,
            "doc_hint": spec.doc_hint,
            "sensitivity": spec.sensitivity.value,
            "configured": live_configured,
            "valid": status.valid if live_configured else None,
            "attention_needed": status.attention_needed if live_configured else False,
            "last_validated": status.last_validated if live_configured else None,
            "last_error": status.last_error if live_configured else None,
        }

        # Hint: if service is running but credential not configured, tell the user
        if not live_configured and key in running_services:
            svc_info = running_services[key]
            entry["running_hint"] = f"Service running on {svc_info['url']} — click Configure to connect"
            entry["running_url"] = svc_info["url"]

        results.append(entry)
    return jsonify({"credentials": results})


@bp.route("/api/credentials/status/<service_key>")
@require_auth
def api_credential_status_single(service_key):
    """Single credential status with spec metadata."""
    from familiar.core.credential_health import get_credential_status
    from familiar.core.credential_registry import get_credential_spec

    spec = get_credential_spec(service_key)
    if spec is None:
        return jsonify({"error": f"Unknown service: {service_key}"}), 404
    status = get_credential_status(service_key)
    return jsonify(
        {
            "service_key": service_key,
            "display_name": spec.display_name,
            "category": spec.category,
            "credential_type": spec.credential_type.value,
            "reauth_type": spec.reauth_type.value,
            "doc_url": spec.doc_url,
            "doc_hint": spec.doc_hint,
            "sensitivity": spec.sensitivity.value,
            "configured": status.configured,
            "valid": status.valid,
            "attention_needed": status.attention_needed,
            "last_validated": status.last_validated,
            "last_error": status.last_error,
        }
    )


@bp.route("/api/credentials/validate/<service_key>", methods=["POST"])
@require_auth
def api_credential_validate(service_key):
    """Trigger manual validation for a single credential."""
    from familiar.core.credential_health import ensure_credentials, invalidate_credential
    from familiar.core.credential_registry import get_credential_spec

    spec = get_credential_spec(service_key)
    if spec is None:
        return jsonify({"error": f"Unknown service: {service_key}"}), 404

    # Force re-validation by invalidating cache first
    invalidate_credential(service_key)
    status = ensure_credentials(service_key)
    return jsonify(
        {
            "service_key": service_key,
            "valid": status.valid,
            "attention_needed": status.attention_needed,
            "last_validated": status.last_validated,
            "last_error": status.last_error,
            "configured": status.configured,
        }
    )


@bp.route("/api/credentials/update/<service_key>", methods=["POST"])
@require_auth
def api_credential_update(service_key):
    """Accept new credential values, validate, save to .env, and clear attention.

    Body: {"values": {"ENV_VAR_NAME": "new_value", ...}}

    Follows the Zapier pattern: validate BEFORE saving — never store untested
    credentials.
    """
    from familiar.core.credential_health import (
        clear_attention,
        ensure_credentials,
        invalidate_credential,
    )
    from familiar.core.credential_registry import get_credential_spec, is_configured

    spec = get_credential_spec(service_key)
    if spec is None:
        return jsonify({"error": f"Unknown service: {service_key}"}), 404

    body = request.get_json(silent=True) or {}
    values = body.get("values", {})
    if not values:
        return jsonify({"error": "No values provided"}), 400

    # Only accept env vars that belong to this service
    allowed_keys = set(spec.env_vars)
    invalid_keys = [k for k in values if k not in allowed_keys]
    if invalid_keys:
        return jsonify(
            {
                "error": f"Invalid keys for {service_key}: {invalid_keys}",
                "allowed": list(allowed_keys),
            }
        ), 400

    # Step 1: Temporarily set env vars for validation
    import os as _os

    old_values = {}
    for k, v in values.items():
        old_values[k] = _os.environ.get(k)
        _os.environ[k] = v

    # Step 2: Validate with new values
    invalidate_credential(service_key)
    status = ensure_credentials(service_key)

    if not status.valid:
        # Restore old values — don't save invalid credentials
        for k, old_v in old_values.items():
            if old_v is None:
                _os.environ.pop(k, None)
            else:
                _os.environ[k] = old_v
        invalidate_credential(service_key)
        return jsonify(
            {
                "error": "Validation failed — credentials not saved",
                "valid": False,
                "last_error": status.last_error,
            }
        ), 422

    # Step 3: Persist to .env file
    try:
        env_path = Path.home() / ".familiar" / ".env"
        from familiar.channels.connect_wizard._helpers import _update_env_file

        _update_env_file(env_path, values)
    except Exception as e:
        logger.warning("Failed to persist credentials to .env: %s", e)
        # Values are already in os.environ and validated — not fatal

    # Step 4: Clear attention flag
    clear_attention(service_key)

    return jsonify(
        {
            "service_key": service_key,
            "valid": True,
            "saved": True,
            "last_validated": status.last_validated,
        }
    )


@bp.route("/api/credentials/fields/<service_key>")
@require_auth
def api_credential_fields(service_key):
    """Return the field definitions for a credential update form.

    Used by the dashboard to render the correct modal (simple_token vs
    multi_field) with field labels and masked current values.
    """
    import os as _os

    from familiar.core.credential_registry import get_credential_spec

    spec = get_credential_spec(service_key)
    if spec is None:
        return jsonify({"error": f"Unknown service: {service_key}"}), 404

    fields = []
    for env_var in spec.env_vars:
        current = _os.environ.get(env_var, "")
        masked = ""
        if current:
            if len(current) > 8:
                masked = current[:4] + "•" * (len(current) - 8) + current[-4:]
            else:
                masked = "•" * len(current)
        # Derive a human-friendly label from the env var name
        label = env_var.replace("_", " ").title()
        is_secret = any(
            kw in env_var.lower() for kw in ["key", "token", "secret", "password", "pin"]
        )
        fields.append(
            {
                "env_var": env_var,
                "label": label,
                "masked_value": masked,
                "has_value": bool(current),
                "is_secret": is_secret,
            }
        )

    return jsonify(
        {
            "service_key": service_key,
            "display_name": spec.display_name,
            "reauth_type": spec.reauth_type.value,
            "doc_url": spec.doc_url,
            "doc_hint": spec.doc_hint,
            "fields": fields,
        }
    )


@bp.route("/api/credentials/audit")
@require_auth
def api_credentials_audit():
    """Return credential audit events, optionally filtered."""
    try:
        from familiar.core.credential_audit import get_credential_events

        service_key = request.args.get("service_key")
        event_type = request.args.get("event_type")
        since = request.args.get("since")
        limit = min(int(request.args.get("limit", 100)), 500)

        events = get_credential_events(
            service_key=service_key,
            event_type=event_type,
            since=since,
            limit=limit,
        )
        return jsonify(
            {
                "count": len(events),
                "events": [e.to_dict() for e in events],
            }
        )
    except Exception as e:
        logger.exception("Failed to get credential audit events")
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/credentials/attention")
@require_auth
def api_credentials_attention():
    """Lightweight polling endpoint — returns services needing user attention."""
    try:
        from familiar.core.credential_health import get_attention_needed

        items = get_attention_needed()
        return jsonify(
            {
                "count": len(items),
                "services": [
                    {
                        "service_key": s.service_key,
                        "last_error": s.last_error,
                    }
                    for s in items
                ],
            }
        )
    except Exception as e:
        logger.exception("Failed to get credential attention list")
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/credentials/dismiss/<service_key>", methods=["POST"])
@require_auth
def api_credential_dismiss(service_key):
    """Clear the attention_needed flag for a credential (session dismiss)."""
    try:
        from familiar.core.credential_health import clear_attention

        clear_attention(service_key)
        return jsonify({"status": "dismissed", "service_key": service_key})
    except Exception as e:
        logger.exception("Failed to dismiss credential attention for %s", service_key)
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/credentials/troubleshoot/<service_key>")
@require_auth
def api_credential_troubleshoot(service_key):
    """Search Memory Tree for troubleshooting knowledge about a credential.

    Returns install memories, markdown knowledge topics, and prior audit
    events that show successful resolution for the given service.
    """
    results: dict = {"service_key": service_key, "install_memories": [], "knowledge": [], "prior_fixes": []}

    # 1. Search install memories
    try:
        from familiar.skills.host_setup.knowledge import search_install_knowledge

        matches = search_install_knowledge(service_key)
        results["install_memories"] = [
            {
                "topic": m.get("topic", ""),
                "method": m.get("install_method", ""),
                "platform": m.get("platform", ""),
                "notes": m.get("notes", ""),
                "created": m.get("created_at", ""),
            }
            for m in matches[:5]
        ]
    except Exception:
        pass

    # 2. Search markdown knowledge topics
    try:
        from familiar.core.memory_tree import MemoryTree

        mt = MemoryTree()
        topics = mt.search_markdown(service_key, limit=5)
        results["knowledge"] = [
            {"topic": t.get("topic", ""), "namespace": t.get("namespace", ""), "slug": t.get("slug", "")}
            for t in topics
        ]
    except Exception:
        pass

    # 3. Search audit log for successful resolution (validated/refreshed after failure)
    try:
        from familiar.core.credential_health import _load_statuses, _STATUS_FILE
        import json

        audit_path = _STATUS_FILE.parent / "credential_audit.jsonl"
        if audit_path.exists():
            lines = audit_path.read_text(encoding="utf-8").strip().split("\n")
            fixes = []
            for line in reversed(lines[-200:]):
                try:
                    ev = json.loads(line)
                    if ev.get("service_key") == service_key and ev.get("event_type") in ("validated", "refreshed", "updated"):
                        fixes.append({
                            "event_type": ev["event_type"],
                            "timestamp": ev.get("timestamp", ""),
                            "details": ev.get("details", ""),
                            "source": ev.get("source", ""),
                        })
                        if len(fixes) >= 3:
                            break
                except (json.JSONDecodeError, KeyError):
                    continue
            results["prior_fixes"] = fixes
    except Exception:
        pass

    return jsonify(results)


@bp.route("/api/agent/restart", methods=["POST"])
@require_auth
def api_agent_restart():
    """Gracefully restart the agent process via os.execv."""
    import sys
    import threading

    reason = (request.json or {}).get("reason", "manual restart via dashboard")
    logger.info("Agent restart requested: %s", reason)

    def _do_restart():
        import time

        time.sleep(0.5)  # Let the response flush
        # Spawn a trampoline: wait 2s for the parent socket to close,
        # then exec the real process. close_fds=True avoids inheriting
        # the bound socket; start_new_session=True detaches from this group.
        trampoline = "import time, os, sys; time.sleep(2); os.execv(sys.argv[1], sys.argv[1:])"
        subprocess.Popen(
            [sys.executable, "-c", trampoline, sys.executable] + sys.argv,
            close_fds=True,
            start_new_session=True,
        )
        os._exit(0)  # Exit this process; socket released, trampoline waits then binds

    threading.Thread(target=_do_restart, daemon=True).start()
    return jsonify({"status": "restarting", "reason": reason})


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

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

    try:
        from familiar.core.resilience import get_all_circuit_status

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

    # Routing context: active provider + fallback chain + rate limits
    try:
        agent = get_agent()
        result["routing"]["active_provider"] = agent.provider.name
        result["routing"]["active_provider_key"] = getattr(agent.provider, "provider_key", "")
        result["routing"]["active_model"] = getattr(agent.provider, "model_name", "")
        if hasattr(agent, "providers") and agent.providers.model_router:
            router = agent.providers.model_router
            result["routing"]["fallback_chain"] = list(getattr(router, "_fallback_chain", []))
            router_models = router.get_model_names()
            # Merge in all Ollama models (catalog + installed)
            try:
                from familiar.channels.connect_wizard._llm import OLLAMA_MODEL_CATALOG

                catalog_set = {f"ollama/{tag}" for tag, *_ in OLLAMA_MODEL_CATALOG}
            except ImportError:
                catalog_set = set()
            try:
                import httpx as _httpx

                resp = _httpx.get("http://localhost:11434/api/tags", timeout=3)
                installed_set = {
                    f"ollama/{m.get('name', '')}"
                    for m in resp.json().get("models", [])
                    if m.get("name")
                }
            except Exception:
                installed_set = set()
            all_models = set(router_models) | catalog_set | installed_set
            result["routing"]["available_models"] = sorted(all_models)
        if hasattr(agent, "providers"):
            tracker = agent.providers.capacity_tracker
            result["rate_limits"] = tracker.get_status()
    except Exception:
        logger.debug("Routing info unavailable", exc_info=True)

    return jsonify(result)


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

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

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

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

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


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

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

    data = request.json or {}

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

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

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

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

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

    save_creature(creature)

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

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

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


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

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

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

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

    return jsonify({"sprite_html": sprite_html})


@bp.route("/api/creature/recolor")
@require_auth
def api_creature_recolor():
    """Return a recolored sprite PNG. Maps body/eyes/accent onto base sprite pixels."""
    import colorsys
    import io
    from collections import Counter

    from PIL import Image

    species = request.args.get("species", "frog").lower()
    stage = request.args.get("stage", "baby").lower()
    color_body = request.args.get("color_body", "")
    color_eyes = request.args.get("color_eyes", "")
    color_accent = request.args.get("color_accent", "")

    sprite_dir = Path(__file__).parent / "frontend" / "static" / "sprites"
    sprite_path = sprite_dir / f"{species}-{stage}.png"
    if not sprite_path.exists():
        abort(404)

    img = Image.open(sprite_path).convert("RGBA")

    if not color_body and not color_eyes and not color_accent:
        buf = io.BytesIO()
        img.save(buf, format="PNG")
        buf.seek(0)
        return Response(buf.getvalue(), mimetype="image/png")

    pixel_colors = Counter()
    for y in range(img.height):
        for x in range(img.width):
            px = img.getpixel((x, y))
            if px[3] > 0:
                pixel_colors[px[:3]] += 1

    sorted_colors = pixel_colors.most_common()
    total_px = sum(c for _, c in sorted_colors)
    body_hues, roles = [], {}
    cumulative = 0
    for c, count in sorted_colors:
        h, s, v = colorsys.rgb_to_hsv(c[0] / 255, c[1] / 255, c[2] / 255)
        pct = count / total_px
        cumulative += pct
        if v < 0.28:
            roles[c] = "shadow"
        elif cumulative <= 0.55:
            roles[c] = "body"
            body_hues.append(h)
        elif pct < 0.02 and body_hues:
            avg_body = sum(body_hues) / len(body_hues)
            hue_diff = min(abs(h - avg_body), 1 - abs(h - avg_body))
            roles[c] = "eyes" if hue_diff > 0.15 and s > 0.3 else "accent"
        else:
            roles[c] = "accent"

    def hex_to_rgb(h):
        h = h.lstrip("#")
        return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))

    def shift_color(orig, target):
        _, _, ov = colorsys.rgb_to_hsv(orig[0] / 255, orig[1] / 255, orig[2] / 255)
        th, ts, _ = colorsys.rgb_to_hsv(target[0] / 255, target[1] / 255, target[2] / 255)
        nr, ng, nb = colorsys.hsv_to_rgb(th, min(ts * 1.1, 1.0), ov)
        return (int(nr * 255), int(ng * 255), int(nb * 255))

    tb = hex_to_rgb(color_body) if color_body else None
    te = hex_to_rgb(color_eyes) if color_eyes else None
    ta = hex_to_rgb(color_accent) if color_accent else None

    color_map = {}
    for c, role in roles.items():
        if role == "body" and tb:
            color_map[c] = shift_color(c, tb)
        elif role == "eyes" and te:
            color_map[c] = shift_color(c, te)
        elif role == "accent" and ta:
            color_map[c] = shift_color(c, ta)
        elif role == "shadow" and tb:
            color_map[c] = shift_color(c, tb)

    pixels = img.load()
    for y in range(img.height):
        for x in range(img.width):
            r, g, b, a = pixels[x, y]
            if a > 0 and (r, g, b) in color_map:
                nr, ng, nb = color_map[(r, g, b)]
                pixels[x, y] = (nr, ng, nb, a)

    buf = io.BytesIO()
    img.save(buf, format="PNG")
    buf.seek(0)
    resp = Response(buf.getvalue(), mimetype="image/png")
    resp.headers["Cache-Control"] = "public, max-age=300"
    return resp



# ============================================================
# Host Shell Terminal
# ============================================================

_shell_proc = None
_shell_lock = threading.Lock()
_shell_enabled_override: bool | None = None  # None = use env var


def _shell_enabled() -> bool:
    if _shell_enabled_override is not None:
        return _shell_enabled_override
    return os.environ.get("FAMILIAR_SHELL_ENABLED", "").lower() in ("1", "true", "yes")


# Patterns that should never be executed via the shell terminal
_DANGEROUS_PATTERNS = [
    r"\brm\s+-[^\s]*r[^\s]*f\s+/\s*$",  # rm -rf /
    r"\brm\s+-[^\s]*f[^\s]*r\s+/\s*$",  # rm -fr /
    r"\brm\s+-rf\s+~",  # rm -rf ~
    r"\bmkfs\b",
    r"\bdd\s+if=",
    r">\s*/dev/sd[a-z]",
    r":\(\)\s*\{\s*:\|:\s*&\s*\}\s*;",  # fork bomb
    r"\bcurl\b.*\|\s*\b(sh|bash|zsh)\b",  # curl|sh
    r"\bwget\b.*\|\s*\b(sh|bash|zsh)\b",  # wget|sh
    r"\bsudo\b",
    r"\bchmod\s+777\b",
    r"\bpython[23]?\s+-c\b",
    r"\beval\b",
    r"\bnc\s+-[^\s]*e\b",  # nc -e reverse shell
    r"\bbase64\s+-d\b.*\|\s*\b(sh|bash)\b",  # base64 -d|sh
    r"/dev/tcp/",
    r"\biptables\b",
    r"\bsystemctl\b",
    r">\s*/etc/",
]


@bp.route("/api/shell/enabled")
@require_auth
def api_shell_enabled():
    """Check if the host shell terminal is enabled."""
    return jsonify({"enabled": _shell_enabled()})


@bp.route("/api/shell/enabled", methods=["POST"])
@require_auth
def api_shell_toggle():
    """Toggle the host shell terminal on or off."""
    global _shell_enabled_override

    data = request.json or {}
    enabled = data.get("enabled")
    if not isinstance(enabled, bool):
        return jsonify({"error": '"enabled" must be a boolean'}), 400

    _shell_enabled_override = enabled

    # Persist to ~/.familiar/.env so the setting survives restarts
    env_path = AGENT_DIR / ".env"
    try:
        from familiar.channels.connect_wizard import _update_env_file

        _update_env_file(env_path, {"FAMILIAR_SHELL_ENABLED": "true" if enabled else "false"})
    except Exception:
        logger.debug("Could not persist shell toggle to .env", exc_info=True)

    return jsonify({"enabled": enabled})


@bp.route("/api/settings/pii")
@require_auth
def api_pii_status():
    """Check if PII/PHI detection is enabled."""
    agent = get_agent()
    enabled = getattr(agent.config.agent, "enable_pii_detection", False)
    return jsonify({"enabled": enabled})


@bp.route("/api/settings/pii", methods=["POST"])
@require_auth
def api_pii_toggle():
    """Toggle PII/PHI detection on or off. Manual control only."""
    data = request.json or {}
    enabled = data.get("enabled")
    if not isinstance(enabled, bool):
        return jsonify({"error": '"enabled" must be a boolean'}), 400

    agent = get_agent()

    # Update running agent config
    agent.config.agent.enable_pii_detection = enabled

    # Update guardrails live
    from familiar.core.guardrails import PIIConfig, PIIDetector

    if enabled:
        if not agent.guardrails._pii_detector:
            agent.guardrails._pii_detector = PIIDetector(PIIConfig())
            agent.guardrails.pii_config = agent.guardrails._pii_detector.config
    else:
        agent.guardrails._pii_detector = None
        agent.guardrails.pii_config = None

    # Persist to config.yaml
    config_path = DATA_DIR.parent / 'config.yaml'
    try:
        import yaml

        config_data = {}
        if config_path.exists():
            with open(config_path) as f:
                config_data = yaml.safe_load(f) or {}
        config_data.setdefault("agent", {})["enable_pii_detection"] = enabled
        with open(config_path, "w") as f:
            yaml.safe_dump(config_data, f, default_flow_style=False)
    except Exception:
        logger.debug("Could not persist PII toggle to config.yaml", exc_info=True)

    logger.info(f"PII detection {'enabled' if enabled else 'disabled'} via Settings toggle")
    return jsonify({"enabled": enabled})


@bp.route("/api/settings/budget")
@require_auth
def api_budget_status():
    """Return current budget settings and today's spend."""
    config_path = DATA_DIR.parent / 'config.yaml'
    budget_enabled = True  # default: off (no limit)
    daily_budget_amount = 10.0
    try:
        import yaml

        if config_path.exists():
            with open(config_path) as f:
                cfg = yaml.safe_load(f) or {}
            budget_enabled = cfg.get("agent", {}).get("budget_enabled", False)
            daily_budget_amount = cfg.get("agent", {}).get("daily_budget_amount", 10.0)
    except Exception:
        pass

    spent_today = 0.0
    agent = get_agent()
    if agent and hasattr(agent, "sessions"):
        session = agent.sessions.get_or_create_session("dashboard", "web")
        session._check_budget_reset()
        spent_today = round(session.spent_today, 4)

    return jsonify(
        {
            "enabled": budget_enabled,
            "daily_budget": daily_budget_amount,
            "spent_today": spent_today,
        }
    )


@bp.route("/api/settings/budget", methods=["POST"])
@require_auth
def api_budget_update():
    """Enable/disable daily budget limit and set amount."""
    data = request.json or {}
    enabled = data.get("enabled")
    if not isinstance(enabled, bool):
        return jsonify({"error": '"enabled" must be a boolean'}), 400

    daily_budget_amount = data.get("daily_budget")
    if daily_budget_amount is not None:
        if not isinstance(daily_budget_amount, (int, float)) or daily_budget_amount < 0:
            return jsonify({"error": '"daily_budget" must be a non-negative number'}), 400
        daily_budget_amount = float(daily_budget_amount)
    else:
        # Read existing value from config
        config_path = DATA_DIR.parent / 'config.yaml'
        try:
            import yaml

            if config_path.exists():
                with open(config_path) as f:
                    cfg = yaml.safe_load(f) or {}
                daily_budget_amount = cfg.get("agent", {}).get("daily_budget_amount", 10.0)
            else:
                daily_budget_amount = 10.0
        except Exception:
            daily_budget_amount = 10.0

    # Apply to live session
    agent = get_agent()
    if agent and hasattr(agent, "sessions"):
        session = agent.sessions.get_or_create_session("dashboard", "web")
        if enabled:
            session.daily_budget = daily_budget_amount
        else:
            session.daily_budget = 999999.0  # effectively unlimited
        agent.sessions.save_session(session)

    # Persist to config.yaml
    config_path = DATA_DIR.parent / 'config.yaml'
    try:
        import yaml

        config_data = {}
        if config_path.exists():
            with open(config_path) as f:
                config_data = yaml.safe_load(f) or {}
        config_data.setdefault("agent", {})["budget_enabled"] = enabled
        config_data["agent"]["daily_budget_amount"] = daily_budget_amount
        with open(config_path, "w") as f:
            yaml.safe_dump(config_data, f, default_flow_style=False)
    except Exception:
        logger.debug("Could not persist budget settings to config.yaml", exc_info=True)

    logger.info(
        f"Budget {'enabled ($%.2f/day)' % daily_budget_amount if enabled else 'disabled (unlimited)'} via Settings"
    )
    return jsonify({"enabled": enabled, "daily_budget": daily_budget_amount})


@bp.route("/api/settings/briefing")
@require_auth
def api_briefing_settings_get():
    """Get current briefing schedule settings."""
    try:
        from familiar.skills.proactive.skill import _load_config as _load_proactive_config

        cfg = _load_proactive_config()
        return jsonify(
            {
                "enabled": cfg.get("briefing_enabled", False),
                "time": cfg.get("briefing_time", "08:00"),
            }
        )
    except Exception as e:
        logger.warning("Briefing settings get failed: %s", e)
        return jsonify({"enabled": False, "time": "08:00"})


@bp.route("/api/settings/briefing", methods=["POST"])
@require_auth
def api_briefing_settings_post():
    """Save briefing schedule settings."""
    data = request.json or {}
    enabled = data.get("enabled", False)
    time_str = data.get("time", "08:00").strip()

    from datetime import datetime as _dt

    try:
        _dt.strptime(time_str, "%H:%M")
    except ValueError:
        return jsonify({"error": "Invalid time — use HH:MM format"}), 400

    try:
        from familiar.skills.proactive.skill import set_briefing_time

        result = set_briefing_time({"time": time_str, "enabled": enabled})
        return jsonify({"ok": True, "message": result, "enabled": enabled, "time": time_str})
    except Exception as e:
        logger.error("briefing settings save failed: %s", e)
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/settings/news-topics")
@require_auth
def api_news_topics_get():
    """Get saved news topics."""
    try:
        from familiar.skills.media.news import load_topics

        return jsonify({"topics": load_topics()})
    except Exception as e:
        logger.debug("news topics get failed: %s", e)
        return jsonify({"topics": []})


@bp.route("/api/settings/news-topics", methods=["POST"])
@require_auth
def api_news_topics_post():
    """Replace the news topics list (up to 5)."""
    data = request.json or {}
    topics = data.get("topics", [])
    if not isinstance(topics, list):
        return jsonify({"error": "topics must be an array"}), 400
    topics = [str(t).strip().lower() for t in topics if str(t).strip()][:5]
    try:
        from familiar.skills.media.news import save_topics

        save_topics(topics)
        return jsonify({"topics": topics})
    except Exception as e:
        logger.error("news topics save failed: %s", e)
        return jsonify({"error": safe_error(e)}), 500


@bp.route("/api/shell/stream", methods=["POST"])
# rate limiting handled at app level
@require_auth
def api_shell_stream():
    """Stream shell command output via SSE."""
    global _shell_proc

    if not _shell_enabled():
        return jsonify(
            {"error": "Shell terminal is disabled. Set FAMILIAR_SHELL_ENABLED=true"}
        ), 403

    data = request.json or {}
    command = data.get("command", "").strip()

    if not command:
        return jsonify({"error": "No command provided"}), 400

    # Allowlist: specific Familiar-initiated commands that require sudo
    # but are known-safe (package installs for server bootstrap)
    _SAFE_SUDO_COMMANDS = [
        r"^sudo\s+(apt-get|apt|dnf|yum|pacman|zypper|apk|brew)\s+install\s+-y\s+podman$",
        r"^sudo\s+(apt-get|apt|dnf|yum|pacman|zypper|apk|brew)\s+install\s+-y\s+podman\s*\|",
        r"^sudo\s+apt-get\s+install\s+-y\s+podman\s+\|\|\s+sudo\s+dnf\s+install\s+-y\s+podman$",
        r"^sudo\s+usermod\s+-aG\s+docker\s+\w+$",
    ]
    is_allowlisted = any(re.search(p, command) for p in _SAFE_SUDO_COMMANDS)

    # Defense-in-depth: block catastrophically destructive patterns
    if not is_allowlisted:
        for pattern in _DANGEROUS_PATTERNS:
            if re.search(pattern, command):
                logger.warning(f"Shell blocked dangerous command: {command!r}")
                return jsonify({"error": "Command blocked for safety"}), 400

    # Sudo password passthrough — only for allowlisted commands.
    # The password is piped to sudo -S via stdin (never logged, never stored).
    sudo_password = data.get("sudo_password", "")
    use_sudo_stdin = bool(sudo_password and is_allowlisted and "sudo" in command)
    if use_sudo_stdin:
        # Rewrite sudo → sudo -S so it reads the password from stdin
        command = command.replace("sudo ", "sudo -S ")

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

    msg_queue = queue.Queue()

    def _run_shell():
        global _shell_proc
        try:
            proc = subprocess.Popen(
                command,
                shell=True,
                stdin=subprocess.PIPE if use_sudo_stdin else None,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
                cwd=str(Path.home()),
            )
            with _shell_lock:
                _shell_proc = proc

            # Feed the sudo password via stdin, then close it
            if use_sudo_stdin and proc.stdin:
                try:
                    proc.stdin.write(sudo_password + "\n")
                    proc.stdin.flush()
                    proc.stdin.close()
                except OSError:
                    pass

            for line in proc.stdout:
                # Never echo the password back to the UI
                if sudo_password and sudo_password in line:
                    continue
                msg_queue.put({"type": "output", "content": line})

            proc.wait()
            msg_queue.put({"type": "exit", "code": proc.returncode})
        except Exception as e:
            logger.exception("Shell stream error")
            msg_queue.put({"type": "error", "error": safe_error(e)})
        finally:
            with _shell_lock:
                _shell_proc = None
            msg_queue.put(None)  # Sentinel

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

    def generate():
        while True:
            try:
                item = msg_queue.get(timeout=300)  # 5 min idle timeout
            except queue.Empty:
                yield f"data: {json.dumps({'type': 'error', 'error': 'Timeout'})}\n\n"
                yield f"data: {json.dumps({'type': 'done'})}\n\n"
                break
            if item is None:
                yield f"data: {json.dumps({'type': 'done'})}\n\n"
                break
            yield f"data: {json.dumps(item)}\n\n"

    resp = Response(generate(), mimetype="text/event-stream")
    resp.headers["Cache-Control"] = "no-cache"
    resp.headers["X-Accel-Buffering"] = "no"
    return resp


@bp.route("/api/shell/kill", methods=["POST"])
@require_auth
def api_shell_kill():
    """Terminate the running shell process."""
    global _shell_proc

    if not _shell_enabled():
        return jsonify({"error": "Shell terminal is disabled"}), 403

    with _shell_lock:
        if _shell_proc and _shell_proc.poll() is None:
            _shell_proc.terminate()
            return jsonify({"success": True, "message": "Process terminated"})
    return jsonify({"success": False, "message": "No running process"})


@bp.route("/api/memory")
@require_auth
def api_memory():
    """Get all memories."""
    agent = get_agent()
    if not agent.memory:
        return jsonify({"memories": []})

    memories = []
    for key, entry in agent.memory.recall_all().items():
        memories.append(
            {
                "key": entry.key,
                "value": entry.value,
                "category": entry.category,
                "importance": entry.importance,
                "updated_at": entry.updated_at,
            }
        )

    # Sort by importance
    memories.sort(key=lambda x: x["importance"], reverse=True)

    return jsonify({"memories": memories})


@bp.route("/api/memory", methods=["POST"])
@require_auth
def api_memory_add():
    """Add a memory."""
    data = request.json or {}
    key = data.get("key", "")
    value = data.get("value", "")
    category = data.get("category", "fact")

    if not key or not value:
        return jsonify({"error": "Key and value required"}), 400

    agent = get_agent()
    if agent.memory:
        agent.memory.remember(key, value, category=category)
        return jsonify({"success": True})

    return jsonify({"error": "Memory not enabled"}), 400


@bp.route("/api/memory/<key>", methods=["DELETE"])
@require_auth
def api_memory_delete(key):
    """Delete a memory."""
    agent = get_agent()
    if agent.memory:
        agent.memory.forget(key)
        return jsonify({"success": True})
    return jsonify({"error": "Memory not enabled"}), 400




# ============================================================


