"""
Octopoda Cloud API Server
========================
FastAPI-based REST API for external developers to interact with Octopoda.
Runs on port 8741 (separate from the Flask dashboard on 7842).

Auto-generated docs at /docs (Swagger UI).
"""

import time
import os
import asyncio
import threading
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor

# Thread pool for blocking runtime calls (embeddings, SQLite writes)
# 16 workers handles concurrent requests from many users without starving
_executor = ThreadPoolExecutor(max_workers=16)

from fastapi import FastAPI, Depends, HTTPException, Header, Request, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from typing import Any, Optional, List


# ---------------------------------------------------------------------------
# Lightweight in-process rate limiter (no external dependency)
# ---------------------------------------------------------------------------

# Per-plan rate limits (requests per minute)
_PLAN_RATE_LIMITS = {
    "free": 10_000,       # ~166/sec — enough for 50+ concurrent agents
    "pro": 60_000,        # ~1000/sec — production workloads
    "enterprise": 300_000, # ~5000/sec — heavy enterprise usage
}
_DEFAULT_RPM = int(os.environ.get("SYNRIX_RATE_LIMIT_RPM", "10000"))


class _RateLimiter:
    """Token-bucket rate limiter keyed by tenant ID (not IP)."""

    def __init__(self):
        self._buckets: dict = {}  # tenant_id -> [tokens, last_refill, rpm]
        self._lock = threading.Lock()

    def _refill(self, bucket: list):
        now = time.monotonic()
        elapsed = now - bucket[1]
        rpm = bucket[2]
        bucket[0] = min(rpm, bucket[0] + elapsed * (rpm / 60.0))
        bucket[1] = now

    def allow(self, tenant_id: str, plan: str = "free", rpm_override: int = 0) -> bool:
        rpm = rpm_override if rpm_override > 0 else _PLAN_RATE_LIMITS.get(plan, _DEFAULT_RPM)
        with self._lock:
            if tenant_id not in self._buckets:
                self._buckets[tenant_id] = [rpm, time.monotonic(), rpm]
            bucket = self._buckets[tenant_id]
            bucket[2] = rpm  # update if plan changed
            self._refill(bucket)
            if bucket[0] >= 1.0:
                bucket[0] -= 1.0
                return True
            return False

    def get_remaining(self, tenant_id: str) -> int:
        with self._lock:
            if tenant_id not in self._buckets:
                return _DEFAULT_RPM
            bucket = self._buckets[tenant_id]
            self._refill(bucket)
            return int(bucket[0])


_rate_limiter = _RateLimiter()

# Separate stricter rate limiter for auth endpoints (prevent brute-force)
_AUTH_RPM = 5  # 5 attempts per minute per IP (prevents mass account creation)
_auth_rate_limiter = _RateLimiter()

from synrix_runtime.api.cloud_models import (
    RegisterAgentRequest, RememberRequest, BatchRememberRequest,
    SnapshotRequest, RestoreRequest,
    SharedWriteRequest, TaskCreateRequest, TaskActionRequest, DecisionLogRequest,
    RawWriteRequest, HealthResponse, MemoryResponse, RecallResponse, SearchResponse,
    SnapshotResponse, RestoreResponse, AgentResponse, BatchMemoryResponse, ErrorResponse,
    ProcessConversationRequest, GetContextRequest,
)
from synrix_runtime.api.auth import APIKeyManager
from synrix_runtime.log import get_logger

logger = get_logger("api")

# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------

app = FastAPI(
    title="Octopoda Agent Memory API",
    version="2.0.0",
    description="Persistent Memory Kernel for AI Agents. Sub-millisecond crash recovery, shared memory bus, full audit trail.",
    docs_url="/docs",
    redoc_url="/redoc",
)

# CORS: restrict to localhost by default; set SYNRIX_CORS_ORIGINS to override
_cors_origins = os.environ.get("SYNRIX_CORS_ORIGINS", "").strip()
if _cors_origins:
    _allowed_origins = [o.strip() for o in _cors_origins.split(",") if o.strip()]
    _origin_regex = None
else:
    _allowed_origins = [
        "http://localhost:7842", "http://127.0.0.1:7842",
        "http://localhost:8741", "http://127.0.0.1:8741",
        "http://localhost:3000", "http://localhost:5173",
        "https://octopodas.com", "https://www.octopodas.com",
    ]
    # Allow Lovable preview domains (id-prefixed subdomains only) and octopodas.com subdomains
    _origin_regex = r"https://[a-z0-9-]+\.(lovable\.app|lovableproject\.com|octopodas\.com)"

app.add_middleware(
    CORSMiddleware,
    allow_origins=_allowed_origins,
    allow_origin_regex=_origin_regex,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
    allow_credentials=True,
)


@app.on_event("startup")
async def _prewarm_models():
    """Pre-load embedding model so first request isn't slow (~11s cold start)."""
    def _load():
        try:
            from synrix.embeddings import EmbeddingModel
            model = EmbeddingModel.get()
            if model:
                # Warm the model with a dummy encode
                model.encode("warmup")
                import logging
                logging.getLogger("synrix.runtime").info("Embedding model pre-warmed")
        except Exception as e:
            import logging
            logging.getLogger("synrix.runtime").warning("Model pre-warm failed: %s", e)
    await asyncio.get_event_loop().run_in_executor(_executor, _load)


@app.on_event("startup")
async def _preload_agents():
    """Pre-load all registered agents in background so server starts immediately.

    Without this, agents 'disappear' from the dashboard until their next API call.
    Runs in a background thread so the server accepts requests right away.
    """
    def _load():
        try:
            from synrix_runtime.api.tenant import TenantManager
            tm = TenantManager.get_instance()
            tenants = tm.list_tenants()
            total = 0
            for tenant in tenants:
                tid = tenant["tenant_id"]
                try:
                    agents = tm.get_tenant_agents(tid)
                    for agent in agents:
                        aid = agent.get("agent_id")
                        state = agent.get("state")
                        # Only reload agents that were running (not deregistered)
                        if aid and state in ("running", None):
                            try:
                                runtime = tm.get_runtime(tid, aid)
                                cache_key = f"{tid}:{aid}"
                                _agent_runtimes[cache_key] = runtime
                                # Attach tenant LLM settings
                                tenant_settings = _get_tenant_settings(tid)
                                if tenant_settings:
                                    runtime._llm_config = tenant_settings
                                total += 1
                            except Exception as e:
                                logger.debug("Skip agent %s/%s: %s", tid[:8], aid, e)
                except Exception as e:
                    logger.debug("Skip tenant %s: %s", tid[:8], e)
            logger.info("Pre-loaded %d agents across %d tenants", total, len(tenants))
        except Exception as e:
            logger.warning("Agent pre-load failed: %s", e)
    # Run in background thread — don't block server startup
    import threading
    t = threading.Thread(target=_load, name="agent-preload", daemon=True)
    t.start()


def _wal_checkpoint_all():
    """Checkpoint WAL for all tenant databases — reusable by periodic + shutdown."""
    try:
        from synrix_runtime.api.tenant import TenantManager
        import sqlite3
        tm = TenantManager.get_instance()
        count = 0
        for tenant in tm.list_tenants():
            try:
                tid = tenant["tenant_id"]
                db_path = tm._tenant_db_path(tid)
                if os.path.exists(db_path):
                    conn = sqlite3.connect(db_path)
                    conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
                    conn.close()
                    count += 1
            except Exception as e:
                logger.debug("WAL checkpoint skip %s: %s", tenant.get("tenant_id", "?")[:8], e)
        logger.info("WAL checkpoint complete for %d tenants", count)
    except Exception as e:
        logger.warning("WAL checkpoint failed: %s", e)


def _periodic_wal_checkpoint():
    """Run WAL checkpoint every 30 minutes to prevent unbounded WAL growth."""
    import time as _time
    while True:
        _time.sleep(1800)  # 30 minutes
        try:
            logger.info("Periodic WAL checkpoint starting...")
            _wal_checkpoint_all()
        except Exception as e:
            logger.warning("Periodic WAL checkpoint error: %s", e)


@app.on_event("startup")
async def _start_wal_checkpoint_thread():
    """Start background thread for periodic WAL checkpoints."""
    import threading
    t = threading.Thread(target=_periodic_wal_checkpoint, name="wal-checkpoint", daemon=True)
    t.start()
    logger.info("Periodic WAL checkpoint thread started (every 30 min)")


def _periodic_ttl_cleanup():
    """Background thread: clean expired TTL memories every 60 seconds."""
    while True:
        time.sleep(60)
        try:
            for cache_key, runtime in list(_agent_runtimes.items()):
                try:
                    # Check if tenant has TTL auto-cleanup enabled
                    tenant_id = cache_key.split(":")[0] if ":" in cache_key else cache_key
                    settings = _tenant_settings.get(tenant_id, {})
                    if not settings.get("ttl_auto_cleanup", True):
                        continue  # Tenant disabled auto-cleanup
                    result = runtime.cleanup_expired()
                    if result.get("deleted", 0) > 0:
                        logger.info("TTL cleanup: deleted %d expired memories for %s",
                                   result["deleted"], result.get("agent_id", cache_key))
                except Exception:
                    pass
        except Exception:
            pass


@app.on_event("startup")
async def _start_ttl_cleanup_thread():
    """Start background thread for periodic TTL cleanup."""
    import threading
    t = threading.Thread(target=_periodic_ttl_cleanup, name="ttl-cleanup", daemon=True)
    t.start()
    logger.info("TTL cleanup thread started (every 60s)")


@app.on_event("shutdown")
async def _graceful_shutdown():
    """Flush pending writes, checkpoint WAL, and let executor drain before exit."""
    logger.info("Shutting down — flushing pending work...")
    _executor.shutdown(wait=True, cancel_futures=False)
    # Final WAL checkpoint with TRUNCATE to clean up WAL files
    try:
        from synrix_runtime.api.tenant import TenantManager
        import sqlite3
        tm = TenantManager.get_instance()
        for tenant in tm.list_tenants():
            try:
                tid = tenant["tenant_id"]
                db_path = tm._tenant_db_path(tid)
                if os.path.exists(db_path):
                    conn = sqlite3.connect(db_path)
                    conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
                    conn.close()
            except Exception as e:
                logger.debug("WAL checkpoint skip %s: %s", tenant.get("tenant_id", "?")[:8], e)
        logger.info("Final WAL checkpoint (TRUNCATE) complete")
    except Exception as e:
        logger.warning("WAL checkpoint failed: %s", e)
    logger.info("Shutdown complete")


@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
    """Enforce per-tenant rate limiting on all endpoints except /health and auth."""
    path = request.url.path
    if path == "/health":
        return await call_next(request)

    # Rate limit auth endpoints by IP (prevent brute-force)
    if path in ("/v1/auth/login", "/v1/auth/signup", "/v1/auth/verify",
                 "/v1/auth/reset-password", "/v1/auth/forgot-password", "/v1/auth/resend-code"):
        client_ip = request.client.host if request.client else "unknown"
        if not _auth_rate_limiter.allow(f"auth:{client_ip}", rpm_override=_AUTH_RPM):
            from fastapi.responses import JSONResponse
            return JSONResponse(
                status_code=429,
                content={"detail": "Too many attempts. Try again later.", "retry_after_seconds": 60},
            )
        return await call_next(request)

    # Extract tenant from auth header for rate limiting
    tenant_id = "anonymous"
    plan = "free"
    auth_header = request.headers.get("authorization", "") or request.headers.get("x-api-key", "")
    if auth_header.startswith("Bearer "):
        api_key = auth_header[7:]
        try:
            from synrix_runtime.api.tenant import TenantManager
            tm = TenantManager.get_instance()
            tenant = tm.verify_api_key(api_key)
            if tenant:
                tenant_id = tenant.get("tenant_id", "anonymous")
                plan = tenant.get("plan", "free")
        except Exception:
            pass

    if not _rate_limiter.allow(tenant_id, plan):
        from fastapi.responses import JSONResponse
        remaining = _rate_limiter.get_remaining(tenant_id)
        rpm = _PLAN_RATE_LIMITS.get(plan, _DEFAULT_RPM)
        return JSONResponse(
            status_code=429,
            content={
                "detail": "Rate limit exceeded.",
                "limit": rpm,
                "plan": plan,
                "retry_after_seconds": 1,
            },
            headers={"Retry-After": "1", "X-RateLimit-Limit": str(rpm), "X-RateLimit-Remaining": str(remaining)},
        )
    return await call_next(request)


# Global state (initialized by start_cloud_server())
_boot_time = time.time()
_daemon = None
_auth_manager = None
_config = None


def init_cloud_server(daemon, config):
    """Initialize the cloud server with daemon and config references."""
    global _daemon, _auth_manager, _config, _boot_time
    _daemon = daemon
    _config = config
    _boot_time = time.time()
    _auth_manager = APIKeyManager(
        backend=daemon.backend if daemon else None,
        master_key=config.api_key if config else "",
    )


# ---------------------------------------------------------------------------
# Auth dependency
# ---------------------------------------------------------------------------

async def verify_auth(authorization: Optional[str] = Header(None)):
    """Verify API key. Returns tenant info dict or None."""
    auth_disabled = os.environ.get("SYNRIX_AUTH_DISABLED", "").strip() == "1"
    if auth_disabled:
        # Only allow in local development — refuse if running on a public port
        bind_host = os.environ.get("SYNRIX_API_HOST", "127.0.0.1")
        if bind_host not in ("127.0.0.1", "localhost", "::1"):
            logger.error("SYNRIX_AUTH_DISABLED=1 is NOT allowed when binding to %s — blocking request", bind_host)
            raise HTTPException(status_code=403, detail="Auth bypass not allowed on public interfaces")
        else:
            return {"tenant_id": "dev", "plan": "pro", "max_agents": 100, "max_memories_per_agent": 100000}

    # Try multi-tenant auth first
    try:
        from synrix_runtime.api.tenant import TenantManager
        tm = TenantManager.get_instance()
        if authorization:
            tenant_info = tm.verify_api_key(authorization)
            if tenant_info:
                # Check email verification
                if not tenant_info.get("verified", 0):
                    raise HTTPException(
                        status_code=403,
                        detail="Email not verified. Check your inbox for a verification code, "
                               "or request a new one at POST /v1/auth/resend-code"
                    )
                return tenant_info
    except HTTPException:
        raise
    except Exception:
        pass

    # Fallback to legacy auth
    if _auth_manager:
        if not authorization:
            raise HTTPException(
                status_code=401,
                detail="API key required. Pass Authorization: Bearer sk-octopoda-... "
                       "Sign up at POST /v1/auth/signup",
            )
        key_info = _auth_manager.verify_key(authorization)
        if not key_info:
            raise HTTPException(status_code=401, detail="Invalid API key")
        return {"tenant_id": key_info.tenant_id, "plan": "legacy", "max_agents": 100, "max_memories_per_agent": 100000}
    raise HTTPException(status_code=401, detail="Authentication required. Pass Authorization: Bearer sk-octopoda-...")


# ---------------------------------------------------------------------------
# Helper: get or create AgentRuntime (tenant-isolated)
# ---------------------------------------------------------------------------

_agent_runtimes: OrderedDict = OrderedDict()
_MAX_CACHED_RUNTIMES = 1000


def _get_tenant_id(auth) -> str:
    """Extract tenant_id from auth info. Raises 401 if not authenticated."""
    if auth and isinstance(auth, dict):
        tid = auth.get("tenant_id")
        if tid:
            return tid
    raise HTTPException(status_code=401, detail="Authentication required")


def _get_runtime(agent_id: str, auth=None):
    """Get or create a tenant-isolated AgentRuntime."""
    tenant_id = _get_tenant_id(auth)
    cache_key = f"{tenant_id}:{agent_id}"

    if cache_key in _agent_runtimes:
        _agent_runtimes.move_to_end(cache_key)
        return _agent_runtimes[cache_key]

    # Use TenantManager for isolated runtime
    try:
        from synrix_runtime.api.tenant import TenantManager, TenantLimitError
        tm = TenantManager.get_instance()
        runtime = tm.get_runtime(tenant_id, agent_id)
    except TenantLimitError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=503, detail=f"Failed to initialize tenant runtime: {e}")

    # Attach per-tenant LLM config so fact extractor uses the right provider
    tenant_settings = _get_tenant_settings(tenant_id)
    if tenant_settings:
        # Check platform free tier limit
        if tenant_settings.get("llm_provider") == "platform":
            used = tenant_settings.get("platform_extractions_used", 0)
            if used >= _PLATFORM_FREE_LIMIT:
                # Exceeded free tier — downgrade to embedding-only
                tenant_settings["llm_provider"] = "none"
                _save_tenant_settings(tenant_id, tenant_settings)
                logger.info("Tenant %s exceeded platform free tier (%d/%d), downgraded to embedding-only",
                           tenant_id, used, _PLATFORM_FREE_LIMIT)
        runtime._llm_config = tenant_settings

    # Evict oldest if at capacity
    while len(_agent_runtimes) >= _MAX_CACHED_RUNTIMES:
        oldest_key, oldest_rt = _agent_runtimes.popitem(last=False)
        logger.info("Evicted stale runtime: %s", oldest_key)

    _agent_runtimes[cache_key] = runtime
    return runtime


# ---------------------------------------------------------------------------
# Auth: Signup / Login
# ---------------------------------------------------------------------------

from pydantic import BaseModel as _PydanticBase

class SignupRequest(_PydanticBase):
    email: str
    password: str
    first_name: str
    last_name: str
    company: str = ""
    use_case: str = ""

class LoginRequest(_PydanticBase):
    email: str
    password: str

class VerifyEmailRequest(_PydanticBase):
    email: str
    code: str

class ResendCodeRequest(_PydanticBase):
    email: str


import re as _re

_EMAIL_RE = _re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
_AGENT_ID_RE = _re.compile(r"^[a-zA-Z0-9_\-\.]{1,128}$")

# Disposable email domains — block mass account creation
_DISPOSABLE_DOMAINS = {
    "tempmail.com", "guerrillamail.com", "guerrillamail.net", "guerrillamail.org",
    "mailinator.com", "throwaway.email", "temp-mail.org", "fakeinbox.com",
    "sharklasers.com", "guerrillamailblock.com", "grr.la", "dispostable.com",
    "yopmail.com", "yopmail.fr", "trashmail.com", "trashmail.me", "trashmail.net",
    "10minutemail.com", "10minute.email", "minutemail.com", "tempail.com",
    "mohmal.com", "burnermail.io", "maildrop.cc", "mailnesia.com",
    "mailcatch.com", "tmail.ws", "harakirimail.com", "getairmail.com",
    "meltmail.com", "throwam.com", "getnada.com", "emailondeck.com",
    "33mail.com", "mailexpire.com", "tempinbox.com", "discard.email",
    "discardmail.com", "mailbox92.biz", "spamgourmet.com", "tempr.email",
    "mytemp.email", "mt2015.com", "emailfake.com", "crazymailing.com",
    "mailsac.com", "inboxkitten.com", "tempmailo.com", "emailnator.com",
}

def _check_disposable_email(email: str):
    domain = email.lower().split("@")[-1]
    if domain in _DISPOSABLE_DOMAINS:
        raise HTTPException(status_code=422, detail="Disposable email addresses are not allowed. Please use a real email.")

def _validate_name(name: str, field: str):
    if not name or not name.strip():
        raise HTTPException(status_code=422, detail=f"{field} is required")
    if len(name.strip()) > 100:
        raise HTTPException(status_code=422, detail=f"{field} too long (max 100 characters)")
    if len(name.strip()) < 1:
        raise HTTPException(status_code=422, detail=f"{field} is required")


# ---------------------------------------------------------------------------
# Email verification: 6-digit codes with 10-minute expiry
# ---------------------------------------------------------------------------
import secrets as _secrets

_verification_codes: dict = {}  # email -> {"code": str, "expires": float, "attempts": int}
_VERIFY_CODE_TTL = 600  # 10 minutes
_MAX_VERIFY_ATTEMPTS = 5  # lock out after 5 wrong guesses

def _generate_verification_code(email: str) -> str:
    """Generate a 6-digit verification code for an email."""
    code = str(_secrets.randbelow(900000) + 100000)  # 100000-999999
    _verification_codes[email] = {"code": code, "expires": time.time() + _VERIFY_CODE_TTL, "attempts": 0}
    return code

def _verify_code(email: str, code: str) -> bool:
    """Check if a verification code is valid and not expired. Max 5 attempts."""
    entry = _verification_codes.get(email)
    if not entry:
        return False
    if time.time() > entry["expires"]:
        _verification_codes.pop(email, None)
        return False
    if entry.get("attempts", 0) >= _MAX_VERIFY_ATTEMPTS:
        _verification_codes.pop(email, None)  # burn the code — must request a new one
        return False
    if entry["code"] != code:
        entry["attempts"] = entry.get("attempts", 0) + 1
        return False
    _verification_codes.pop(email, None)
    return True


# ---------------------------------------------------------------------------
# Resend email integration
# ---------------------------------------------------------------------------
_RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
_RESEND_FROM = os.environ.get("RESEND_FROM_EMAIL", "Octopoda <noreply@octopodas.com>")

def _send_verification_email(email: str, first_name: str, code: str):
    """Send a verification code email via Resend."""
    if not _RESEND_API_KEY:
        logger.warning("RESEND_API_KEY not set — skipping verification email to %s (code: %s)", email, code)
        return

    try:
        import requests as _req
        resp = _req.post(
            "https://api.resend.com/emails",
            headers={"Authorization": f"Bearer {_RESEND_API_KEY}", "Content-Type": "application/json"},
            json={
                "from": _RESEND_FROM,
                "to": [email],
                "subject": f"Your Octopoda verification code: {code}",
                "html": f"""
                <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
                    <div style="text-align: center; margin-bottom: 32px;">
                        <h1 style="color: #1a1a2e; font-size: 24px; margin: 0;">🐙 Octopoda</h1>
                        <p style="color: #666; font-size: 14px; margin: 4px 0 0;">Agent Memory Infrastructure</p>
                    </div>
                    <div style="background: #f8f9fa; border-radius: 12px; padding: 32px; text-align: center;">
                        <p style="color: #333; font-size: 16px; margin: 0 0 8px;">
                            Hey{(' ' + first_name) if first_name else ''}, welcome to Octopoda!
                        </p>
                        <p style="color: #666; font-size: 14px; margin: 0 0 24px;">
                            Enter this code to verify your email:
                        </p>
                        <div style="background: #1a1a2e; color: #fff; font-size: 32px; letter-spacing: 8px; padding: 16px 24px; border-radius: 8px; display: inline-block; font-family: monospace;">
                            {code}
                        </div>
                        <p style="color: #999; font-size: 12px; margin: 24px 0 0;">
                            This code expires in 10 minutes.
                        </p>
                    </div>
                    <p style="color: #999; font-size: 12px; text-align: center; margin: 24px 0 0;">
                        If you didn't sign up for Octopoda, ignore this email.
                    </p>
                </div>
                """,
            },
            timeout=10,
        )
        if resp.status_code not in (200, 201):
            logger.error("Resend email failed: %s %s", resp.status_code, resp.text)
    except Exception as e:
        logger.error("Failed to send verification email: %s", e)


def _send_password_reset_email(email: str, first_name: str, code: str):
    """Send a password reset code email via Resend."""
    if not _RESEND_API_KEY:
        logger.warning("RESEND_API_KEY not set — skipping reset email to %s (code: %s)", email, code)
        return

    try:
        import requests as _req
        resp = _req.post(
            "https://api.resend.com/emails",
            headers={"Authorization": f"Bearer {_RESEND_API_KEY}", "Content-Type": "application/json"},
            json={
                "from": _RESEND_FROM,
                "to": [email],
                "subject": f"Reset your Octopoda password: {code}",
                "html": f"""
                <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
                    <div style="text-align: center; margin-bottom: 32px;">
                        <h1 style="color: #1a1a2e; font-size: 24px; margin: 0;">🐙 Octopoda</h1>
                        <p style="color: #666; font-size: 14px; margin: 4px 0 0;">Agent Memory Infrastructure</p>
                    </div>
                    <div style="background: #f8f9fa; border-radius: 12px; padding: 32px; text-align: center;">
                        <p style="color: #333; font-size: 16px; margin: 0 0 8px;">
                            Hey{(' ' + first_name) if first_name else ''}, we received a password reset request.
                        </p>
                        <p style="color: #666; font-size: 14px; margin: 0 0 24px;">
                            Enter this code to reset your password:
                        </p>
                        <div style="background: #1a1a2e; color: #fff; font-size: 32px; letter-spacing: 8px; padding: 16px 24px; border-radius: 8px; display: inline-block; font-family: monospace;">
                            {code}
                        </div>
                        <p style="color: #999; font-size: 12px; margin: 24px 0 0;">
                            This code expires in 10 minutes.
                        </p>
                    </div>
                    <p style="color: #999; font-size: 12px; text-align: center; margin: 24px 0 0;">
                        If you didn't request a password reset, ignore this email. Your password won't change.
                    </p>
                </div>
                """,
            },
            timeout=10,
        )
        if resp.status_code not in (200, 201):
            logger.error("Resend reset email failed: %s %s", resp.status_code, resp.text)
    except Exception as e:
        logger.error("Failed to send reset email: %s", e)
_KEY_RE = _re.compile(r"^[a-zA-Z0-9_\-\.:/]{1,512}$")


def _validate_email(email: str):
    if not email or len(email) > 254 or not _EMAIL_RE.match(email):
        raise HTTPException(status_code=422, detail="Invalid email address")

def _validate_password(password: str):
    if not password or len(password) < 8:
        raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
    if len(password) > 128:
        raise HTTPException(status_code=422, detail="Password too long (max 128 characters)")

def _validate_agent_id(agent_id: str):
    if not _AGENT_ID_RE.match(agent_id):
        raise HTTPException(
            status_code=422,
            detail="Invalid agent_id. Use letters, numbers, hyphens, underscores, dots (max 128 chars)",
        )

def _validate_key(key: str):
    if not _KEY_RE.match(key):
        raise HTTPException(
            status_code=422,
            detail="Invalid key. Use letters, numbers, hyphens, underscores, dots, colons, slashes (max 512 chars)",
        )


@app.post("/v1/auth/signup")
async def signup(req: SignupRequest):
    """Create a new account. Returns tenant_id + API key (inactive until email verified)."""
    _validate_email(req.email)
    _check_disposable_email(req.email)
    _validate_password(req.password)
    _validate_name(req.first_name, "First name")
    _validate_name(req.last_name, "Last name")
    if req.use_case and req.use_case not in ("ai_agent", "chatbot", "rag_pipeline", "research", "other", ""):
        raise HTTPException(status_code=422, detail="Invalid use_case")

    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    result = tm.create_tenant(
        req.email, req.password,
        first_name=req.first_name.strip(),
        last_name=req.last_name.strip(),
        company=req.company.strip() if req.company else "",
        use_case=req.use_case.strip() if req.use_case else "",
    )
    if not result.get("success"):
        raise HTTPException(status_code=409, detail=result.get("error", "Signup failed"))

    # Send verification email
    code = _generate_verification_code(req.email.lower())
    _send_verification_email(req.email.lower(), req.first_name.strip(), code)

    result["email_verified"] = False
    result["message"] = "Check your email for a 6-digit verification code."
    return result


@app.post("/v1/auth/verify")
async def verify_email(req: VerifyEmailRequest):
    """Verify email with 6-digit code sent during signup."""
    _validate_email(req.email)
    if not req.code or len(req.code) != 6 or not req.code.isdigit():
        raise HTTPException(status_code=422, detail="Code must be 6 digits")

    if not _verify_code(req.email.lower(), req.code):
        raise HTTPException(status_code=400, detail="Invalid or expired code. Request a new one.")

    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    tm.set_verified(req.email.lower(), True)
    return {"verified": True, "email": req.email.lower()}


@app.post("/v1/auth/resend-code")
async def resend_verification_code(req: ResendCodeRequest):
    """Resend verification code to email."""
    _validate_email(req.email)
    _check_disposable_email(req.email)

    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    tenant = tm.get_tenant_by_email(req.email.lower())
    if not tenant:
        # Don't reveal if account exists
        return {"sent": True, "message": "If an account exists, a code has been sent."}

    if tenant.get("verified"):
        return {"sent": False, "message": "Email already verified."}

    code = _generate_verification_code(req.email.lower())
    _send_verification_email(req.email.lower(), tenant.get("first_name", ""), code)
    return {"sent": True, "message": "Verification code sent."}


@app.post("/v1/auth/login")
async def login(req: LoginRequest):
    """Login with email + password. Returns tenant info + API key."""
    _validate_email(req.email)
    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    tenant = tm.authenticate(req.email, req.password)
    if not tenant:
        raise HTTPException(status_code=401, detail="Invalid email or password")
    api_key = tm.create_session_key(tenant["tenant_id"])
    return {
        "tenant_id": tenant["tenant_id"],
        "email": tenant["email"],
        "plan": tenant["plan"],
        "api_key": api_key,
    }


@app.post("/v1/auth/api-key")
async def regenerate_key(auth=Depends(verify_auth)):
    """Generate a new API key (deactivates old ones)."""
    tenant_id = _get_tenant_id(auth)
    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    new_key = tm.regenerate_api_key(tenant_id)
    if not new_key:
        raise HTTPException(status_code=500, detail="Failed to regenerate key")
    return {"api_key": new_key, "warning": "Save this key — it will not be shown again."}


@app.get("/v1/auth/me")
async def get_me(auth=Depends(verify_auth)):
    """Get current account info."""
    tenant_id = _get_tenant_id(auth)
    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    tenant = tm.get_tenant(tenant_id)
    if not tenant:
        return {"tenant_id": tenant_id, "plan": "dev"}
    return {
        "tenant_id": tenant["tenant_id"],
        "email": tenant["email"],
        "first_name": tenant.get("first_name", ""),
        "last_name": tenant.get("last_name", ""),
        "company": tenant.get("company", ""),
        "use_case": tenant.get("use_case", ""),
        "plan": tenant["plan"],
        "max_agents": tenant["max_agents"],
        "max_memories_per_agent": tenant["max_memories_per_agent"],
        "email_verified": bool(tenant.get("verified", 0)),
    }


class ChangePasswordRequest(_PydanticBase):
    old_password: str
    new_password: str

class ForgotPasswordRequest(_PydanticBase):
    email: str

class ResetPasswordRequest(_PydanticBase):
    email: str
    code: str
    new_password: str


@app.post("/v1/auth/change-password")
async def change_password(req: ChangePasswordRequest, auth=Depends(verify_auth)):
    """Change account password (requires current password)."""
    tenant_id = _get_tenant_id(auth)
    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    result = tm.change_password(tenant_id, req.old_password, req.new_password)
    if not result.get("success"):
        raise HTTPException(status_code=400, detail=result.get("error", "Password change failed"))
    return {"success": True}


@app.post("/v1/auth/forgot-password")
async def forgot_password(req: ForgotPasswordRequest):
    """Send a password reset code to email."""
    _validate_email(req.email)

    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    tenant = tm.get_tenant_by_email(req.email.lower())

    # Always return success (don't reveal if account exists)
    if not tenant:
        return {"sent": True, "message": "If an account exists, a reset code has been sent."}

    code = _generate_verification_code(f"reset:{req.email.lower()}")
    _send_password_reset_email(req.email.lower(), tenant.get("first_name", ""), code)
    return {"sent": True, "message": "If an account exists, a reset code has been sent."}


@app.post("/v1/auth/reset-password")
async def reset_password(req: ResetPasswordRequest):
    """Reset password using code from forgot-password email."""
    _validate_email(req.email)
    _validate_password(req.new_password)

    if not req.code or len(req.code) != 6 or not req.code.isdigit():
        raise HTTPException(status_code=422, detail="Code must be 6 digits")

    if not _verify_code(f"reset:{req.email.lower()}", req.code):
        raise HTTPException(status_code=400, detail="Invalid or expired code. Request a new one.")

    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    result = tm.reset_password(req.email.lower(), req.new_password)
    if not result.get("success"):
        raise HTTPException(status_code=400, detail=result.get("error", "Password reset failed"))
    return {"success": True, "message": "Password reset successfully. You can now log in."}


# ---------------------------------------------------------------------------
# GDPR: Data export & account deletion
# ---------------------------------------------------------------------------

@app.get("/v1/auth/export")
async def export_data(auth=Depends(verify_auth)):
    """Download all your data as JSON (GDPR Article 20)."""
    tenant_id = _get_tenant_id(auth)
    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    return tm.export_tenant_data(tenant_id)


@app.delete("/v1/auth/account")
async def delete_account(auth=Depends(verify_auth)):
    """Permanently delete your account and all data (GDPR Article 17)."""
    tenant_id = _get_tenant_id(auth)
    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    result = tm.delete_tenant(tenant_id)
    if not result.get("deleted"):
        raise HTTPException(status_code=404, detail=result.get("error", "Deletion failed"))
    # Clear from runtime cache
    cache_keys = [k for k in _agent_runtimes if k.startswith(f"{tenant_id}:")]
    for k in cache_keys:
        _agent_runtimes.pop(k, None)
    return result


# ---------------------------------------------------------------------------
# Usage stats
# ---------------------------------------------------------------------------

@app.get("/v1/usage")
async def usage_stats(auth=Depends(verify_auth)):
    """Get usage statistics — agents, memories, plan limits."""
    tenant_id = _get_tenant_id(auth)
    from synrix_runtime.api.tenant import TenantManager
    tm = TenantManager.get_instance()
    return tm.get_tenant_usage(tenant_id)


# ---------------------------------------------------------------------------
# Health & System
# ---------------------------------------------------------------------------

@app.api_route("/health", methods=["GET", "HEAD"], response_model=HealthResponse)
async def health():
    backend_type = "unknown"
    if _daemon and hasattr(_daemon, 'backend'):
        backend_type = getattr(_daemon.backend, 'backend_type', 'unknown')
    return HealthResponse(
        status="ok",
        version="2.0.0",
        backend=backend_type,
        uptime_seconds=time.time() - _boot_time,
    )


@app.get("/v1/status")
async def system_status(auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    agents = _get_agents_from_backend(backend)
    active = [a for a in agents if a.get("state") != "deregistered"]
    return {
        "status": "running",
        "uptime_seconds": round(time.time() - _boot_time, 1),
        "version": "2.0.6",
        "total_agents": len(agents),
        "active_agents": len(active),
        "agents": active,
    }


# ---------------------------------------------------------------------------
# Agent Management (with pagination)
# ---------------------------------------------------------------------------

@app.post("/v1/agents", response_model=AgentResponse)
async def register_agent(req: RegisterAgentRequest, auth=Depends(verify_auth)):
    _validate_agent_id(req.agent_id)
    try:
        runtime = _get_runtime(req.agent_id, auth)
        return AgentResponse(
            agent_id=req.agent_id,
            agent_type=req.agent_type,
            status="running",
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/v1/agents")
async def list_agents(
    offset: int = Query(default=0, ge=0),
    limit: int = Query(default=50, ge=1, le=500),
    auth=Depends(verify_auth),
):
    tenant_id = _get_tenant_id(auth)
    try:
        from synrix_runtime.api.tenant import TenantManager
        tm = TenantManager.get_instance()
        all_agents = tm.get_tenant_agents(tenant_id)
    except Exception:
        all_agents = []
    total = len(all_agents)
    page = all_agents[offset:offset + limit]
    return {"agents": page, "count": len(page), "total": total, "offset": offset, "limit": limit}


@app.get("/v1/agents/{agent_id}")
async def get_agent(agent_id: str, auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    if not backend:
        raise HTTPException(status_code=503, detail="Backend not available")
    state_result = backend.read(f"runtime:agents:{agent_id}:state")
    state = None
    if state_result:
        data = state_result.get("data", {})
        val = data.get("value", data)
        state = val.get("value") if isinstance(val, dict) else val
    if state:
        try:
            from synrix_runtime.monitoring.metrics import MetricsCollector
            mc = MetricsCollector(backend)
            metrics = mc.get_agent_metrics(agent_id)
            return {
                "agent_id": agent_id,
                "state": state,
                "metrics": {
                    "total_operations": metrics.total_operations,
                    "total_writes": metrics.total_writes,
                    "total_reads": metrics.total_reads,
                    "avg_write_latency_us": metrics.avg_write_latency_us,
                    "avg_read_latency_us": metrics.avg_read_latency_us,
                    "crash_count": metrics.crash_count,
                    "performance_score": metrics.performance_score,
                    "uptime_seconds": metrics.uptime_seconds,
                },
            }
        except Exception:
            return {"agent_id": agent_id, "state": state}
    raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")


@app.delete("/v1/agents/{agent_id}")
async def deregister_agent(agent_id: str, auth=Depends(verify_auth)):
    tenant_id = _get_tenant_id(auth)
    cache_key = f"{tenant_id}:{agent_id}"
    if cache_key in _agent_runtimes:
        _agent_runtimes[cache_key].shutdown()
        del _agent_runtimes[cache_key]
    backend = _get_tenant_backend(auth)
    if backend:
        backend.write(f"runtime:agents:{agent_id}:state", {"value": "deregistered"})
        backend.write(f"runtime:agents:{agent_id}:last_active", {"value": time.time()})
    return {"agent_id": agent_id, "deregistered": True}


# ---------------------------------------------------------------------------
# Memory Operations
# ---------------------------------------------------------------------------

@app.post("/v1/agents/{agent_id}/remember", response_model=MemoryResponse)
async def remember(agent_id: str, req: RememberRequest, auth=Depends(verify_auth)):
    _validate_agent_id(agent_id)
    _validate_key(req.key)
    # License enforcement: check memory limit
    try:
        from synrix.licensing import check_memory_limit, record_memory_written, MemoryLimitError
        check_memory_limit(agent_id)
    except MemoryLimitError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except Exception:
        pass

    runtime = _get_runtime(agent_id, auth)
    tenant_id = _get_tenant_id(auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(
        _executor, lambda: runtime.remember(req.key, req.value, tags=req.tags)
    )

    # Track platform free tier usage
    _increment_platform_usage(tenant_id)

    # Check if free tier is exhausted — add warning to response
    tier_warning = None
    tenant_settings = _get_tenant_settings(tenant_id)
    if tenant_settings.get("llm_provider") == "none":
        used = tenant_settings.get("platform_extractions_used", 0)
        if used >= _PLATFORM_FREE_LIMIT:
            tier_warning = (
                "Free AI extractions exhausted (100/100). "
                "AI-powered extraction is disabled — memories are stored but without intelligent fact extraction. "
                "Add your own API key at octopodas.com/dashboard/settings to restore full features."
            )

    # Track the write
    try:
        from synrix.licensing import record_memory_written
        record_memory_written(agent_id)
    except Exception:
        pass

    # Track latency & errors for anomaly detection
    _track_latency_and_errors(agent_id, result.latency_us, result.success, runtime)

    return MemoryResponse(
        node_id=result.node_id,
        key=req.key,
        latency_us=result.latency_us,
        timestamp=result.timestamp,
        success=result.success,
        loop_warning=result.loop_warning,
        warning=tier_warning,
    )


@app.post("/v1/agents/{agent_id}/flush")
async def flush_enrichment(agent_id: str, auth=Depends(verify_auth)):
    """Wait for all pending background enrichment (embeddings, facts, NER) to complete.

    Call after writes to ensure memories are searchable via semantic search.
    Returns counts of completed/failed/timed-out enrichment tasks.
    """
    _validate_agent_id(agent_id)
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.flush(timeout=60.0))
    return result


@app.post("/v1/agents/{agent_id}/remember/batch", response_model=BatchMemoryResponse)
async def remember_batch(agent_id: str, req: BatchRememberRequest, auth=Depends(verify_auth)):
    runtime = _get_runtime(agent_id, auth)
    results = []
    for item in req.items:
        # License enforcement: check memory limit per item
        try:
            from synrix.licensing import check_memory_limit, record_memory_written, MemoryLimitError
            check_memory_limit(agent_id)
        except MemoryLimitError as e:
            raise HTTPException(status_code=403, detail=str(e))
        except Exception:
            pass

        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(_executor, lambda k=item.key, v=item.value, t=item.tags: runtime.remember(k, v, tags=t))

        try:
            record_memory_written(agent_id)
        except Exception:
            pass

        results.append({
            "key": item.key,
            "node_id": result.node_id,
            "latency_us": result.latency_us,
            "success": result.success,
        })
    return BatchMemoryResponse(agent_id=agent_id, results=results, count=len(results))


@app.get("/v1/agents/{agent_id}/recall/{key:path}", response_model=RecallResponse)
async def recall(agent_id: str, key: str, auth=Depends(verify_auth)):
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.recall(key))
    return RecallResponse(
        value=result.value,
        key=key,
        latency_us=result.latency_us,
        found=result.found,
    )


@app.get("/v1/agents/{agent_id}/search", response_model=SearchResponse)
async def search(
    agent_id: str,
    prefix: str = "",
    limit: int = Query(default=50, ge=1, le=1000),
    auth=Depends(verify_auth),
):
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.search(prefix, limit=limit))
    return SearchResponse(
        items=result.items,
        count=result.count,
        latency_us=result.latency_us,
    )


@app.get("/v1/agents/{agent_id}/similar")
async def semantic_search(
    agent_id: str,
    q: str = Query(..., description="Natural language search query"),
    limit: int = Query(default=10, ge=1, le=100),
    auth=Depends(verify_auth),
):
    """Semantic search — find memories by meaning, not just exact keys."""
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.recall_similar(q, limit=limit))
    return {
        "agent_id": agent_id,
        "query": q,
        "items": result.items,
        "count": result.count,
        "latency_us": result.latency_us,
    }


# ---------------------------------------------------------------------------
# Debug: Embedding inspection (temporary)
# ---------------------------------------------------------------------------

@app.get("/v1/agents/{agent_id}/debug-embeddings")
async def debug_embeddings(agent_id: str, limit: int = Query(default=20), auth=Depends(verify_auth)):
    """Debug: check what embeddings exist for an agent's memories."""
    runtime = _get_runtime(agent_id, auth)
    backend = runtime.backend
    raw_client = backend.client if hasattr(backend, 'client') else backend
    collection = backend.collection if hasattr(backend, 'collection') else 'default'
    prefix = f"agents:{agent_id}:%"

    def _check():
        with raw_client._conn() as conn:
            # Count total nodes for this agent
            total = conn.execute(
                "SELECT COUNT(*) FROM nodes WHERE name LIKE ? AND (valid_until IS NULL OR valid_until = 0)",
                (prefix,),
            ).fetchone()[0]

            # Count nodes with embeddings
            with_emb = conn.execute(
                "SELECT COUNT(*) FROM nodes WHERE collection = ? AND embedding IS NOT NULL AND name LIKE ? AND (valid_until IS NULL OR valid_until = 0)",
                (collection, prefix),
            ).fetchone()[0]

            # Count nodes with WRONG collection
            wrong_coll = conn.execute(
                "SELECT COUNT(*) FROM nodes WHERE collection != ? AND name LIKE ? AND (valid_until IS NULL OR valid_until = 0)",
                (collection, prefix),
            ).fetchone()[0]

            # Check what collections exist for this agent
            collections = conn.execute(
                "SELECT DISTINCT collection, COUNT(*) as cnt FROM nodes WHERE name LIKE ? GROUP BY collection",
                (prefix,),
            ).fetchall()

            # Sample some nodes
            sample = conn.execute(
                "SELECT name, collection, embedding IS NOT NULL as has_emb, length(embedding) as emb_len FROM nodes WHERE name LIKE ? AND (valid_until IS NULL OR valid_until = 0) ORDER BY name LIMIT ?",
                (prefix, limit),
            ).fetchall()

            # Check fact_embeddings
            fact_count = conn.execute(
                "SELECT COUNT(*) FROM fact_embeddings WHERE collection = ? AND node_name LIKE ?",
                (collection, prefix),
            ).fetchone()[0]

        return {
            "agent_id": agent_id,
            "expected_collection": collection,
            "total_nodes": total,
            "nodes_with_embedding": with_emb,
            "nodes_wrong_collection": wrong_coll,
            "collections": [{"collection": r[0], "count": r[1]} for r in collections],
            "fact_embeddings": fact_count,
            "sample": [
                {"name": r[0], "collection": r[1], "has_emb": bool(r[2]), "emb_len": r[3]}
                for r in sample
            ],
        }

    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(_executor, _check)


# ---------------------------------------------------------------------------
# Conversation Processing (high-level API)
# ---------------------------------------------------------------------------

@app.post("/v1/agents/{agent_id}/process-conversation")
async def process_conversation(agent_id: str, req: ProcessConversationRequest, auth=Depends(verify_auth)):
    """Process a conversation and automatically extract + store memories.

    Extracts preferences, facts, and decisions from the messages,
    stores them as individual memories with semantic embeddings.
    This is the recommended way to add memory to your agents —
    just pass the conversation, Octopoda handles the rest.
    """
    _validate_agent_id(agent_id)
    runtime = _get_runtime(agent_id, auth)
    tenant_id = _get_tenant_id(auth)
    loop = asyncio.get_event_loop()
    t0 = time.time()

    # Build conversation text from messages
    conv_lines = []
    for msg in req.messages:
        role = msg.get("role", "unknown")
        content = msg.get("content", "")
        conv_lines.append(f"{role}: {content}")
    conversation_text = "\n".join(conv_lines)

    # Store the full conversation as a timestamped key
    ts = int(time.time())
    conv_key = f"{req.namespace}:turn_{ts}"
    conv_result = await loop.run_in_executor(
        _executor, lambda: runtime.remember(conv_key, conversation_text, tags=["conversation"])
    )
    _increment_platform_usage(tenant_id)

    stored_memories = [{"key": conv_key, "type": "conversation", "node_id": conv_result.node_id}]

    # Extract structured information from the conversation
    # The fact extractor handles decomposition into atomic facts
    # which are stored as embeddings for high-quality semantic search
    user_messages = [m.get("content", "") for m in req.messages if m.get("role") == "user"]
    assistant_messages = [m.get("content", "") for m in req.messages if m.get("role") == "assistant"]

    # Extract and store preferences (what the user wants/likes/dislikes)
    if req.extract_preferences and user_messages:
        pref_text = " ".join(user_messages)
        pref_key = f"{req.namespace}:preferences_{ts}"
        pref_result = await loop.run_in_executor(
            _executor, lambda: runtime.remember(pref_key, pref_text, tags=["preference", "user"])
        )
        _increment_platform_usage(tenant_id)
        stored_memories.append({"key": pref_key, "type": "preferences", "node_id": pref_result.node_id})

    # Extract and store decisions/action items from assistant responses
    if req.extract_decisions and assistant_messages:
        decision_text = " ".join(assistant_messages)
        dec_key = f"{req.namespace}:decisions_{ts}"
        dec_result = await loop.run_in_executor(
            _executor, lambda: runtime.remember(dec_key, decision_text, tags=["decision", "action"])
        )
        _increment_platform_usage(tenant_id)
        stored_memories.append({"key": dec_key, "type": "decisions", "node_id": dec_result.node_id})

    elapsed_ms = (time.time() - t0) * 1000

    # Track latency & errors for anomaly detection
    _track_latency_and_errors(agent_id, elapsed_ms * 1000, True, runtime)

    # Check if free tier is exhausted
    tier_warning = None
    tenant_settings = _get_tenant_settings(tenant_id)
    if tenant_settings.get("llm_provider") == "none":
        used = tenant_settings.get("platform_extractions_used", 0)
        if used >= _PLATFORM_FREE_LIMIT:
            tier_warning = (
                "Free AI extractions exhausted (100/100). "
                "AI-powered extraction is disabled — memories are stored but without intelligent fact extraction. "
                "Add your own API key at octopodas.com/dashboard/settings to restore full features."
            )

    response = {
        "agent_id": agent_id,
        "memories_stored": len(stored_memories),
        "memories": stored_memories,
        "message_count": len(req.messages),
        "latency_ms": round(elapsed_ms, 1),
    }
    if tier_warning:
        response["warning"] = tier_warning
    return response


@app.post("/v1/agents/{agent_id}/context")
async def get_context(agent_id: str, req: GetContextRequest, auth=Depends(verify_auth)):
    """Get relevant context for a query from the agent's memory.

    Searches the agent's memories semantically and returns the most
    relevant context. Use this before your agent generates a response
    to give it access to everything it has learned.
    """
    _validate_agent_id(agent_id)
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    t0 = time.time()

    # Semantic search across all memories
    result = await loop.run_in_executor(
        _executor, lambda: runtime.recall_similar(req.query, limit=req.limit)
    )

    elapsed_ms = (time.time() - t0) * 1000

    now = time.time()

    if req.format == "text":
        # Format as a readable context block for LLM consumption
        context_parts = []
        for item in result.items:
            value = item.get("value", "")
            score = item.get("score", 0)
            # Filter out expired TTL entries
            if isinstance(value, dict) and "__expires_at" in value:
                if value["__expires_at"] < now:
                    continue
                value = value.get("value", str(value))
            elif isinstance(value, dict):
                value = value.get("value", str(value))
            if score > 0.5:  # Only include relevant results
                context_parts.append(str(value))

        context_text = "\n---\n".join(context_parts) if context_parts else ""
        return {
            "agent_id": agent_id,
            "query": req.query,
            "context": context_text,
            "memory_count": len(context_parts),
            "latency_ms": round(elapsed_ms, 1),
        }
    else:
        # Filter out expired TTL entries
        filtered = []
        for item in result.items:
            val = item.get("value", "")
            if isinstance(val, dict) and "__expires_at" in val:
                if val["__expires_at"] < now:
                    continue
            filtered.append(item)
        return {
            "agent_id": agent_id,
            "query": req.query,
            "memories": filtered,
            "memory_count": len(filtered),
            "latency_ms": round(elapsed_ms, 1),
        }


@app.get("/v1/agents/{agent_id}/history/{key:path}")
async def memory_history(agent_id: str, key: str, auth=Depends(verify_auth)):
    """Get all versions of a memory over time."""
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.recall_history(key))
    return {
        "agent_id": agent_id,
        "key": result.key,
        "current_version": result.current_version,
        "versions": result.versions,
        "latency_us": result.latency_us,
    }


@app.get("/v1/agents/{agent_id}/related/{entity}")
async def related_entities(agent_id: str, entity: str, auth=Depends(verify_auth)):
    """Query the knowledge graph for entity relationships."""
    runtime = _get_runtime(agent_id, auth)
    result = runtime.related(entity)
    return {
        "agent_id": agent_id,
        "entity": result.entity,
        "entity_type": result.entity_type,
        "found": result.found,
        "relationships": result.relationships,
        "latency_us": result.latency_us,
    }


@app.get("/v1/agents/{agent_id}/memory")
async def list_memory(
    agent_id: str,
    offset: int = Query(default=0, ge=0),
    limit: int = Query(default=50, ge=1, le=500),
    auth=Depends(verify_auth),
):
    tenant_id = _get_tenant_id(auth)
    try:
        from synrix_runtime.api.tenant import TenantManager
        backend = TenantManager.get_instance().get_backend(tenant_id)
    except Exception:
        backend = None
    if backend:
        start = time.perf_counter_ns()
        results = backend.query_prefix(f"agents:{agent_id}:", limit=offset + limit)
        latency_us = (time.perf_counter_ns() - start) / 1000
        items = []
        for item in results:
            key = item.get("key", "")
            data = item.get("data", {})
            items.append({"key": key, "value": data.get("value", data)})
        page = items[offset:offset + limit]
        # Record read metric
        try:
            from synrix_runtime.monitoring.metrics import MetricsCollector
            mc = MetricsCollector(backend)
            mc.record_read(agent_id, f"memory:list", latency_us, len(page) > 0)
        except Exception:
            pass
        return {"agent_id": agent_id, "items": page, "count": len(page), "total": len(items), "offset": offset}
    return {"agent_id": agent_id, "items": [], "count": 0, "total": 0, "offset": offset}


# ---------------------------------------------------------------------------
# TTL / Auto-Expire
# ---------------------------------------------------------------------------

@app.post("/v1/agents/{agent_id}/remember/ttl")
async def remember_with_ttl(agent_id: str, req: dict, auth=Depends(verify_auth)):
    """Store a memory that auto-expires after ttl_seconds."""
    key = req.get("key")
    value = req.get("value")
    ttl_seconds = req.get("ttl_seconds", 3600)
    tags = req.get("tags")
    if not key or value is None:
        raise HTTPException(status_code=422, detail="key and value required")
    if ttl_seconds < 1 or ttl_seconds > 31536000:  # max 1 year
        raise HTTPException(status_code=422, detail="ttl_seconds must be 1-31536000")
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.remember_with_ttl(key, value, ttl_seconds, tags=tags))
    return {
        "node_id": result.node_id,
        "key": key,
        "ttl_seconds": ttl_seconds,
        "expires_at": time.time() + ttl_seconds,
        "latency_us": result.latency_us,
        "success": result.success,
    }

@app.post("/v1/agents/{agent_id}/cleanup")
async def cleanup_expired(agent_id: str, auth=Depends(verify_auth)):
    """Remove all expired TTL memories for this agent."""
    runtime = _get_runtime(agent_id, auth)
    result = runtime.cleanup_expired()
    return result


# ---------------------------------------------------------------------------
# Memory Importance Scoring
# ---------------------------------------------------------------------------

@app.post("/v1/agents/{agent_id}/remember/important")
async def remember_important(agent_id: str, req: dict, auth=Depends(verify_auth)):
    """Store a memory with importance level (critical/normal/low)."""
    key = req.get("key")
    value = req.get("value")
    importance = req.get("importance", "normal")
    tags = req.get("tags")
    if not key or value is None:
        raise HTTPException(status_code=422, detail="key and value required")
    if importance not in ("critical", "normal", "low"):
        raise HTTPException(status_code=422, detail="importance must be critical, normal, or low")
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.remember_important(key, value, importance=importance, tags=tags))
    return {
        "node_id": result.node_id,
        "key": key,
        "importance": importance,
        "latency_us": result.latency_us,
        "success": result.success,
    }


# ---------------------------------------------------------------------------
# Conflict Detection
# ---------------------------------------------------------------------------

@app.post("/v1/agents/{agent_id}/conflicts")
async def detect_conflicts(agent_id: str, req: dict, auth=Depends(verify_auth)):
    """Check if a new value conflicts with existing memories."""
    key = req.get("key", "")
    value = req.get("value")
    threshold = req.get("threshold", 0.7)
    if value is None:
        raise HTTPException(status_code=422, detail="value required")
    runtime = _get_runtime(agent_id, auth)
    result = runtime.detect_conflicts(key, value, threshold=threshold)
    return result

@app.post("/v1/agents/{agent_id}/remember/safe")
async def remember_safe(agent_id: str, req: dict, auth=Depends(verify_auth)):
    """Write a memory and return any detected conflicts."""
    key = req.get("key")
    value = req.get("value")
    tags = req.get("tags")
    tenant_id = _get_tenant_id(auth)
    settings = _get_tenant_settings(tenant_id)

    # Use tenant's conflict sensitivity if set, otherwise use request threshold or default
    threshold = req.get("conflict_threshold", settings.get("conflict_sensitivity", 0.85))

    if not key or value is None:
        raise HTTPException(status_code=422, detail="key and value required")
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()

    # If conflict detection is disabled, just do a normal write
    if not settings.get("conflict_detection", True):
        result = await loop.run_in_executor(_executor, lambda: runtime.remember(key, value, tags=tags))
        return {
            "write": {
                "node_id": result.node_id,
                "key": result.key,
                "latency_us": result.latency_us,
                "success": result.success,
            },
            "conflicts": {"has_conflicts": False, "conflicts": [], "new_key": key, "checked_against": 0},
        }

    result = await loop.run_in_executor(_executor, lambda: runtime.remember_safe(key, value, tags=tags, conflict_threshold=threshold))
    return result


# ---------------------------------------------------------------------------
# Usage Analytics
# ---------------------------------------------------------------------------

@app.get("/v1/agents/{agent_id}/analytics")
async def agent_analytics(agent_id: str, auth=Depends(verify_auth)):
    """Get detailed usage analytics for an agent."""
    runtime = _get_runtime(agent_id, auth)
    return runtime.usage_analytics()


# ---------------------------------------------------------------------------
# Webhooks
# ---------------------------------------------------------------------------

# In-memory webhook registry (per-tenant)
_webhook_registry: dict = {}  # tenant_id -> list of webhook configs

@app.post("/v1/webhooks")
async def register_webhook(req: dict, auth=Depends(verify_auth)):
    """Register a webhook URL to receive event notifications.

    Events: agent.crash, agent.recovery, memory.limit, memory.conflict
    """
    url = req.get("url")
    events = req.get("events", ["agent.crash", "agent.recovery"])
    if not url:
        raise HTTPException(status_code=422, detail="url required")
    # SSRF protection: only allow HTTPS URLs to public hosts
    from urllib.parse import urlparse
    parsed = urlparse(url)
    if parsed.scheme not in ("https",):
        raise HTTPException(status_code=422, detail="Only HTTPS webhook URLs are allowed")
    hostname = parsed.hostname or ""
    if hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1") or hostname.startswith("10.") \
       or hostname.startswith("172.") or hostname.startswith("192.168.") or hostname.startswith("169.254."):
        raise HTTPException(status_code=422, detail="Webhook URLs must point to public hosts")
    tenant_id = _get_tenant_id(auth)
    if tenant_id not in _webhook_registry:
        _webhook_registry[tenant_id] = []
    webhook_id = f"wh_{int(time.time()*1000)}"
    _webhook_registry[tenant_id].append({
        "id": webhook_id,
        "url": url,
        "events": events,
        "created_at": time.time(),
        "active": True,
    })
    return {"id": webhook_id, "url": url, "events": events, "active": True}

@app.get("/v1/webhooks")
async def list_webhooks(auth=Depends(verify_auth)):
    """List all registered webhooks."""
    tenant_id = _get_tenant_id(auth)
    hooks = _webhook_registry.get(tenant_id, [])
    return {"webhooks": [h for h in hooks if h["active"]], "count": len(hooks)}

@app.delete("/v1/webhooks/{webhook_id}")
async def delete_webhook(webhook_id: str, auth=Depends(verify_auth)):
    """Delete a webhook."""
    tenant_id = _get_tenant_id(auth)
    hooks = _webhook_registry.get(tenant_id, [])
    for h in hooks:
        if h["id"] == webhook_id:
            h["active"] = False
            return {"deleted": True, "id": webhook_id}
    raise HTTPException(status_code=404, detail="Webhook not found")


def _fire_webhooks(tenant_id: str, event: str, payload: dict):
    """Fire webhooks for a given event (runs in background thread)."""
    hooks = _webhook_registry.get(tenant_id, [])
    for h in hooks:
        if h["active"] and event in h["events"]:
            def _send(url, data):
                try:
                    import urllib.request, json as _j
                    req = urllib.request.Request(
                        url, data=_j.dumps(data).encode(),
                        headers={"Content-Type": "application/json"},
                    )
                    urllib.request.urlopen(req, timeout=10)
                except Exception as e:
                    logger.warning("Webhook delivery failed to %s: %s", url, e)
            threading.Thread(
                target=_send, args=(h["url"], {"event": event, **payload}),
                daemon=True,
            ).start()


# ---------------------------------------------------------------------------
# Snapshots
# ---------------------------------------------------------------------------

@app.post("/v1/agents/{agent_id}/snapshot", response_model=SnapshotResponse)
async def snapshot(agent_id: str, req: SnapshotRequest, auth=Depends(verify_auth)):
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    try:
        result = await asyncio.wait_for(
            loop.run_in_executor(_executor, lambda: runtime.snapshot(req.label)),
            timeout=90.0,
        )
    except asyncio.TimeoutError:
        raise HTTPException(status_code=504, detail="Snapshot timed out — try again when enrichment load is lower")
    return SnapshotResponse(
        label=result.label,
        keys_captured=result.keys_captured,
        latency_us=result.latency_us,
    )


@app.post("/v1/agents/{agent_id}/restore", response_model=RestoreResponse)
async def restore(agent_id: str, req: RestoreRequest, auth=Depends(verify_auth)):
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    try:
        result = await asyncio.wait_for(
            loop.run_in_executor(_executor, lambda: runtime.restore(req.label)),
            timeout=90.0,
        )
    except asyncio.TimeoutError:
        raise HTTPException(status_code=504, detail="Restore timed out — try again when enrichment load is lower")
    return RestoreResponse(
        label=result.label,
        keys_restored=result.keys_restored,
        recovery_time_us=result.recovery_time_us,
    )


# ---------------------------------------------------------------------------
# Shared Memory (with pagination)
# ---------------------------------------------------------------------------

@app.post("/v1/shared/{space}")
async def shared_write(space: str, req: SharedWriteRequest, auth=Depends(verify_auth)):
    runtime = _get_runtime(req.author_agent_id, auth)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.share(req.key, req.value, space=space))
    return {
        "node_id": result.node_id,
        "key": req.key,
        "space": space,
        "latency_us": result.latency_us,
        "success": result.success,
    }


def _get_tenant_backend(auth):
    """Get the tenant-isolated backend for the current request."""
    tenant_id = _get_tenant_id(auth)
    try:
        from synrix_runtime.api.tenant import TenantManager
        return TenantManager.get_instance().get_backend(tenant_id)
    except Exception:
        return None


def _get_agents_from_backend(backend) -> list:
    """Query agents directly from a tenant backend (for SSE and listings)."""
    if not backend:
        return []
    try:
        results = backend.query_prefix("runtime:agents:", limit=500)
        agents = {}
        for r in results:
            key = r.get("key", "")
            parts = key.split(":")
            if len(parts) >= 3:
                aid = parts[2]
                if aid == "system":
                    continue
                if aid not in agents:
                    agents[aid] = {"agent_id": aid}
                if len(parts) > 3:
                    data = r.get("data", {})
                    value = data.get("value", data)
                    if isinstance(value, dict) and "value" in value:
                        value = value["value"]
                    agents[aid][parts[3]] = value
        return [a for a in agents.values() if a.get("state") != "deregistered"]
    except Exception:
        return []


@app.get("/v1/shared/{space}/detail")
async def shared_space_detail(space: str, auth=Depends(verify_auth)):
    """Get space items + changelog (used by Shared Memory dashboard tab)."""
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.api.shared_memory import SharedMemoryBus
        bus = SharedMemoryBus(backend)
        items = bus.get_all(space)
        changelog = bus.get_changelog(space, limit=20)
        return {"space": space, "items": items, "changelog": changelog}
    except Exception:
        return {"space": space, "items": [], "changelog": []}


@app.get("/v1/shared/{space}/{key:path}")
async def shared_read(space: str, key: str, auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    if backend:
        result = backend.read(f"shared:{space}:{key}")
        if result:
            data = result.get("data", {})
            return {"key": key, "space": space, "value": data.get("value", data), "found": True}
    return {"key": key, "space": space, "value": None, "found": False}


@app.get("/v1/shared/{space}")
async def shared_list(
    space: str,
    offset: int = Query(default=0, ge=0),
    limit: int = Query(default=50, ge=1, le=500),
    auth=Depends(verify_auth),
):
    backend = _get_tenant_backend(auth)
    if backend:
        results = backend.query_prefix(f"shared:{space}:", limit=offset + limit + 200)
        items = []
        for item in results:
            key = item.get("key", "").replace(f"shared:{space}:", "")
            if ":changelog:" not in key:
                data = item.get("data", {})
                items.append({"key": key, "value": data.get("value", data)})
        page = items[offset:offset + limit]
        return {"space": space, "items": page, "count": len(page), "total": len(items), "offset": offset}
    return {"space": space, "items": [], "count": 0, "total": 0, "offset": offset}


@app.get("/v1/shared")
async def shared_spaces(auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    if backend:
        try:
            from synrix_runtime.api.shared_memory import SharedMemoryBus
            bus = SharedMemoryBus(backend)
            return {"spaces": bus.list_spaces()}
        except Exception:
            pass
    return {"spaces": []}


# ---------------------------------------------------------------------------
# Audit
# ---------------------------------------------------------------------------

@app.get("/v1/agents/{agent_id}/audit")
async def agent_audit(
    agent_id: str,
    limit: int = Query(default=50, ge=1, le=500),
    auth=Depends(verify_auth),
):
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.audit import AuditSystem
        audit = AuditSystem(backend)
        events = audit.replay(agent_id)
        return {"agent_id": agent_id, "events": events[:limit], "count": len(events)}
    except Exception:
        return {"agent_id": agent_id, "events": [], "count": 0}


@app.post("/v1/agents/{agent_id}/decision")
async def log_decision(agent_id: str, req: DecisionLogRequest, auth=Depends(verify_auth)):
    runtime = _get_runtime(agent_id, auth)
    loop = asyncio.get_event_loop()
    await loop.run_in_executor(_executor, lambda: runtime.log_decision(req.decision, req.reasoning, req.context))
    return {"agent_id": agent_id, "logged": True}


# ---------------------------------------------------------------------------
# Recovery
# ---------------------------------------------------------------------------

@app.post("/v1/agents/{agent_id}/recover")
async def recover_agent(agent_id: str, auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    if not backend:
        raise HTTPException(status_code=503, detail="Backend not available")
    loop = asyncio.get_event_loop()

    def _do_recovery():
        from synrix_runtime.core.recovery import RecoveryOrchestrator
        from dataclasses import asdict
        orch = RecoveryOrchestrator(backend)
        result = orch.full_recovery(agent_id)
        return asdict(result)

    try:
        return await asyncio.wait_for(
            loop.run_in_executor(_executor, _do_recovery),
            timeout=90.0,
        )
    except asyncio.TimeoutError:
        raise HTTPException(status_code=504, detail="Recovery timed out — try again when enrichment load is lower")
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Recovery failed: {e}")


@app.get("/v1/recovery/history")
async def recovery_history(auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.core.recovery import RecoveryOrchestrator
        orch = RecoveryOrchestrator(backend)
        return {
            "history": orch.get_all_recovery_history(),
            "stats": orch.get_recovery_stats(),
        }
    except Exception:
        return {"history": [], "stats": {}}


# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------

@app.get("/v1/agents/{agent_id}/metrics")
async def agent_metrics(agent_id: str, auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.metrics import MetricsCollector
        mc = MetricsCollector(backend)
        m = mc.get_agent_metrics(agent_id)
        return {
            "agent_id": agent_id,
            "total_operations": m.total_operations,
            "total_writes": m.total_writes,
            "total_reads": m.total_reads,
            "total_queries": m.total_queries,
            "avg_write_latency_us": m.avg_write_latency_us,
            "avg_read_latency_us": m.avg_read_latency_us,
            "crash_count": m.crash_count,
            "recovery_count": m.recovery_count,
            "performance_score": m.performance_score,
            "uptime_seconds": m.uptime_seconds,
            "error_rate": m.error_rate,
            "memory_node_count": m.memory_node_count,
        }
    except Exception:
        return {"agent_id": agent_id, "error": "Metrics not available"}


@app.get("/v1/metrics/system")
async def system_metrics(auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.metrics import MetricsCollector
        mc = MetricsCollector(backend)
        m = mc.get_system_metrics()
        return {
            "total_agents": m.total_agents,
            "active_agents": m.active_agents,
            "total_operations": m.total_operations,
            "system_uptime_seconds": m.system_uptime_seconds,
            "mean_recovery_time_us": m.mean_recovery_time_us,
            "total_crashes": m.total_crashes,
            "total_recoveries": m.total_recoveries,
        }
    except Exception:
        return {"error": "Metrics not available"}


# ---------------------------------------------------------------------------
# Webhook Ingest — any language, any framework
# ---------------------------------------------------------------------------

class IngestEvent(_PydanticBase):
    agent_id: str
    event_type: str = "memory"  # memory, conversation, task, custom
    key: Optional[str] = None
    value: Any = None
    tags: Optional[list] = None
    metadata: Optional[dict] = None
    timestamp: Optional[float] = None


class BatchIngestRequest(_PydanticBase):
    events: List[IngestEvent]


@app.post("/v1/ingest")
async def ingest_event(event: IngestEvent, auth=Depends(verify_auth)):
    """
    Universal ingest endpoint — send events from any agent, any language.

    Works with Node.js, Go, Rust, Ruby, Java, or any HTTP client.

    Example (curl):
        curl -X POST https://api.octopoda.dev/v1/ingest \\
          -H "Authorization: Bearer sk-octopoda-..." \\
          -H "Content-Type: application/json" \\
          -d '{"agent_id": "my_agent", "key": "user_name", "value": "Alice"}'

    Example (Node.js):
        await fetch("https://api.octopoda.dev/v1/ingest", {
            method: "POST",
            headers: { "Authorization": "Bearer sk-octopoda-...", "Content-Type": "application/json" },
            body: JSON.stringify({ agent_id: "my_agent", key: "user_name", value: "Alice" })
        });
    """
    _validate_agent_id(event.agent_id)
    if event.key:
        _validate_key(event.key)

    runtime = _get_runtime(event.agent_id, auth)

    key = event.key or f"ingest:{event.event_type}:{int((event.timestamp or time.time()) * 1000)}"
    value = event.value or ""

    # Store the event as a memory
    if isinstance(value, dict):
        if event.metadata:
            value["_metadata"] = event.metadata
        if event.event_type != "memory":
            value["_event_type"] = event.event_type
    elif event.metadata or event.event_type != "memory":
        value = {
            "value": value,
            "_event_type": event.event_type,
            "_metadata": event.metadata or {},
        }

    tags = event.tags or []
    if event.event_type not in tags:
        tags.append(event.event_type)

    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(_executor, lambda: runtime.remember(key, value, tags=tags))

    return {
        "success": result.success,
        "agent_id": event.agent_id,
        "key": key,
        "node_id": result.node_id,
        "latency_us": result.latency_us,
    }


@app.post("/v1/ingest/batch")
async def ingest_batch(req: BatchIngestRequest, auth=Depends(verify_auth)):
    """
    Batch ingest — send multiple events in one request.

    Example:
        curl -X POST https://api.octopoda.dev/v1/ingest/batch \\
          -H "Authorization: Bearer sk-octopoda-..." \\
          -d '{"events": [
            {"agent_id": "bot", "key": "name", "value": "Alice"},
            {"agent_id": "bot", "key": "role", "value": "Engineer"}
          ]}'
    """
    if len(req.events) > 100:
        raise HTTPException(status_code=400, detail="Maximum 100 events per batch")

    results = []
    for event in req.events:
        _validate_agent_id(event.agent_id)
        if event.key:
            _validate_key(event.key)

        runtime = _get_runtime(event.agent_id, auth)
        key = event.key or f"ingest:{event.event_type}:{int((event.timestamp or time.time()) * 1000)}"
        value = event.value or ""

        tags = event.tags or []
        if event.event_type not in tags:
            tags.append(event.event_type)

        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(_executor, lambda r=runtime, k=key, v=value, t=tags: r.remember(k, v, tags=t))
        results.append({
            "agent_id": event.agent_id,
            "key": key,
            "success": result.success,
            "node_id": result.node_id,
        })

    return {
        "processed": len(results),
        "results": results,
    }


# ---------------------------------------------------------------------------
# Dashboard API: SSE Streaming (real-time updates for Loveable frontend)
# ---------------------------------------------------------------------------

def _sse_event_generator(backend):
    """Generate SSE events every second for the React dashboard."""
    import json as _json
    last_event_ts = time.time()

    while True:
        events = []
        try:
            # Agent update (with enriched metrics) — tenant-isolated
            try:
                agents = _get_agents_from_backend(backend)
                try:
                    from synrix_runtime.monitoring.metrics import MetricsCollector
                    collector = MetricsCollector(backend)
                    for a in agents:
                        agent_id = a.get("agent_id", "")
                        try:
                            m = collector.get_agent_metrics(agent_id)
                            a["performance_score"] = m.performance_score
                            a["total_operations"] = m.total_operations
                            a["avg_write_latency_us"] = m.avg_write_latency_us
                            a["avg_read_latency_us"] = m.avg_read_latency_us
                            a["memory_node_count"] = m.memory_node_count
                            a["crash_count"] = m.crash_count
                            a["uptime_seconds"] = m.uptime_seconds
                            a["error_rate"] = m.error_rate
                        except Exception:
                            pass
                        a["status"] = a.get("state", "unknown")
                except Exception:
                    for a in agents:
                        a["status"] = a.get("state", "unknown")
                events.append(("agent_update", {"agents": agents, "timestamp": time.time()}))
            except Exception:
                events.append(("agent_update", {"agents": [], "timestamp": time.time()}))

            # System metrics — tenant-isolated
            try:
                from synrix_runtime.monitoring.metrics import MetricsCollector
                collector = MetricsCollector(backend)
                system = collector.get_system_metrics()
                events.append(("metrics_update", {
                    "total_agents": system.total_agents,
                    "active_agents": system.active_agents,
                    "total_operations": system.total_operations,
                    "mean_recovery_time_us": system.mean_recovery_time_us,
                    "total_crashes": system.total_crashes,
                    "total_recoveries": system.total_recoveries,
                    "uptime_seconds": system.system_uptime_seconds,
                    "timestamp": time.time(),
                }))
            except Exception:
                pass

            # Anomalies
            try:
                from synrix_runtime.monitoring.anomaly import AnomalyDetector
                detector = AnomalyDetector(backend)
                anomalies = detector.get_all_anomalies()
                if anomalies:
                    events.append(("anomaly_alert", {"anomalies": anomalies[:5], "timestamp": time.time()}))
            except Exception:
                pass

            # Recent recoveries
            try:
                from synrix_runtime.core.recovery import RecoveryOrchestrator
                orchestrator = RecoveryOrchestrator(backend)
                recoveries = orchestrator.get_all_recovery_history()
                recent = [r for r in recoveries if isinstance(r, dict) and r.get("timestamp", 0) > last_event_ts - 10]
                if recent:
                    events.append(("recovery_event", {"recoveries": recent, "timestamp": time.time()}))
            except Exception:
                pass

            # Heartbeat
            events.append(("system_heartbeat", {"alive": True, "timestamp": time.time()}))

            last_event_ts = time.time()

            for event_type, data in events:
                yield f"event: {event_type}\ndata: {_json.dumps(data)}\n\n"

        except GeneratorExit:
            break
        except Exception as e:
            yield f"event: error\ndata: {_json.dumps({'error': str(e)})}\n\n"

        time.sleep(1)


@app.get("/v1/stream/events")
async def sse_stream(auth=Depends(verify_auth)):
    """
    Server-Sent Events stream for real-time dashboard updates.

    Events emitted every ~1 second:
    - agent_update: all agents with health scores, metrics, state
    - metrics_update: system-wide metrics
    - anomaly_alert: active anomalies (crash loops, latency spikes)
    - recovery_event: recent crash recoveries
    - system_heartbeat: keepalive ping

    Usage (JavaScript):
        const es = new EventSource('/v1/stream/events', {
            headers: { 'Authorization': 'Bearer sk-octopoda-...' }
        });
        es.addEventListener('agent_update', (e) => {
            const data = JSON.parse(e.data);
            updateAgentList(data.agents);
        });
    """
    backend = _get_tenant_backend(auth)
    return StreamingResponse(
        _sse_event_generator(backend),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )


# ---------------------------------------------------------------------------
# Dashboard API: Anomaly Detection
# ---------------------------------------------------------------------------

@app.get("/v1/anomalies")
async def get_anomalies(auth=Depends(verify_auth)):
    """Get all active anomalies across agents (crash loops, latency spikes, idle agents)."""
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.anomaly import AnomalyDetector
        detector = AnomalyDetector(backend)
        return {"anomalies": detector.get_all_anomalies()}
    except Exception:
        return {"anomalies": []}


# ---------------------------------------------------------------------------
# Dashboard API: Metrics Time-Series (for Chart.js graphs)
# ---------------------------------------------------------------------------

@app.get("/v1/agents/{agent_id}/metrics/timeseries")
async def agent_metrics_timeseries(
    agent_id: str,
    minutes: int = Query(default=60, ge=1, le=1440),
    type: str = Query(default="write", description="Metric type: write, read, query"),
    auth=Depends(verify_auth),
):
    """
    Get time-series metrics for Chart.js performance graphs.

    Returns data points over the last N minutes for the specified metric type.
    Used by the Performance tab to render latency/throughput charts.
    """
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.metrics import MetricsCollector
        collector = MetricsCollector(backend)
        series = collector.get_time_series(agent_id, type, minutes)
        return {"agent_id": agent_id, "type": type, "minutes": minutes, "series": series}
    except Exception:
        return {"agent_id": agent_id, "type": type, "minutes": minutes, "series": []}


@app.get("/v1/metrics/timeseries")
async def system_metrics_timeseries(
    agent_id: str = Query(default="", description="Agent ID (empty for system-wide)"),
    minutes: int = Query(default=60, ge=1, le=1440),
    type: str = Query(default="write", description="Metric type: write, read, query"),
    auth=Depends(verify_auth),
):
    """System-wide or per-agent time-series metrics."""
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.metrics import MetricsCollector
        collector = MetricsCollector(backend)
        series = collector.get_time_series(agent_id, type, minutes)
        return {"agent_id": agent_id, "type": type, "minutes": minutes, "series": series}
    except Exception:
        return {"agent_id": agent_id, "type": type, "minutes": minutes, "series": []}


# ---------------------------------------------------------------------------
# Dashboard API: Global Audit Timeline + Explain Decision
# ---------------------------------------------------------------------------

@app.get("/v1/audit/timeline")
async def audit_timeline(
    limit: int = Query(default=50, ge=1, le=500),
    auth=Depends(verify_auth),
):
    """
    Global audit timeline — all events across all agents, newest first.

    Used by the Audit tab to show a chronological view of everything
    happening across the system.
    """
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.audit import AuditSystem
        audit = AuditSystem(backend)
        return {"events": audit.get_global_timeline(limit), "limit": limit}
    except Exception:
        return {"events": [], "limit": limit}


@app.get("/v1/audit/explain/{agent_id}/{timestamp}")
async def audit_explain(agent_id: str, timestamp: float, auth=Depends(verify_auth)):
    """
    Explain a decision — show what the agent knew at that exact moment.

    Returns the full causal chain: what was queried, what was decided,
    what was written, and the memory snapshot at decision time.
    """
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.audit import AuditSystem
        audit = AuditSystem(backend)
        return audit.explain_decision(agent_id, timestamp)
    except Exception:
        return {"agent_id": agent_id, "timestamp": timestamp, "explanation": None}


@app.get("/v1/agents/{agent_id}/audit/replay")
async def agent_audit_replay(
    agent_id: str,
    from_ts: Optional[float] = Query(default=None, alias="from", description="Start timestamp"),
    to_ts: Optional[float] = Query(default=None, alias="to", description="End timestamp"),
    auth=Depends(verify_auth),
):
    """Replay agent audit events within a time range."""
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.audit import AuditSystem
        audit = AuditSystem(backend)
        events = audit.replay(agent_id, from_ts=from_ts, to_ts=to_ts)
        return {"agent_id": agent_id, "events": events, "count": len(events)}
    except Exception:
        return {"agent_id": agent_id, "events": [], "count": 0}


# ---------------------------------------------------------------------------
# Dashboard API: Performance Breakdown
# ---------------------------------------------------------------------------

@app.get("/v1/agents/{agent_id}/performance")
async def agent_performance(agent_id: str, auth=Depends(verify_auth)):
    """
    Detailed performance breakdown for an agent.

    Returns per-operation-type latency stats, used by the Performance tab
    for detailed charts and analysis.
    """
    backend = _get_tenant_backend(auth)
    try:
        from synrix_runtime.monitoring.metrics import MetricsCollector
        collector = MetricsCollector(backend)
        m = collector.get_agent_metrics(agent_id)
        breakdown = collector.get_performance_breakdown(agent_id)
        return {
            "agent_id": agent_id,
            "metrics": {
                "total_operations": m.total_operations,
                "total_writes": m.total_writes,
                "total_reads": m.total_reads,
                "total_queries": m.total_queries,
                "avg_write_latency_us": m.avg_write_latency_us,
                "avg_read_latency_us": m.avg_read_latency_us,
                "avg_query_latency_us": m.avg_query_latency_us,
                "crash_count": m.crash_count,
                "recovery_count": m.recovery_count,
                "performance_score": m.performance_score,
                "uptime_seconds": m.uptime_seconds,
                "error_rate": m.error_rate,
                "memory_node_count": m.memory_node_count,
                "operations_per_minute": m.operations_per_minute,
            },
            "breakdown": breakdown,
        }
    except Exception:
        return {"agent_id": agent_id, "metrics": {}, "breakdown": {}}


# ---------------------------------------------------------------------------
# Raw operations
# ---------------------------------------------------------------------------

@app.post("/v1/raw/write")
async def raw_write(req: RawWriteRequest, auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    if backend:
        start = time.perf_counter_ns()
        node_id = backend.write(req.key, req.value, req.metadata)
        latency_us = (time.perf_counter_ns() - start) / 1000
        return {"node_id": node_id, "key": req.key, "latency_us": round(latency_us, 1)}
    raise HTTPException(status_code=503, detail="Backend not available")


@app.get("/v1/raw/read/{key:path}")
async def raw_read(key: str, auth=Depends(verify_auth)):
    backend = _get_tenant_backend(auth)
    if backend:
        start = time.perf_counter_ns()
        result = backend.read(key)
        latency_us = (time.perf_counter_ns() - start) / 1000
        if result:
            return {"key": key, "data": result.get("data", {}), "latency_us": round(latency_us, 1), "found": True}
        return {"key": key, "data": None, "latency_us": round(latency_us, 1), "found": False}
    raise HTTPException(status_code=503, detail="Backend not available")


@app.get("/v1/raw/query")
async def raw_query(
    prefix: str = "",
    limit: int = Query(default=100, ge=1, le=1000),
    auth=Depends(verify_auth),
):
    backend = _get_tenant_backend(auth)
    if backend:
        start = time.perf_counter_ns()
        results = backend.query_prefix(prefix, limit=limit)
        latency_us = (time.perf_counter_ns() - start) / 1000
        return {"items": results, "count": len(results), "latency_us": round(latency_us, 1)}
    raise HTTPException(status_code=503, detail="Backend not available")


# ---------------------------------------------------------------------------
# License Info
# ---------------------------------------------------------------------------

@app.get("/v1/license")
async def license_info(auth=Depends(verify_auth)):
    """Check current license tier, agent count, and limits."""
    backend = _get_tenant_backend(auth)
    agents_list = _get_agents_from_backend(backend)
    tenant_id = _get_tenant_id(auth)
    plan = auth.get("plan", "free") if auth else "free"
    max_agents = auth.get("max_agents", 100) if auth else 100
    max_mem = auth.get("max_memories_per_agent", 100000) if auth else 100000
    return {
        "tier": plan,
        "max_agents": max_agents,
        "max_memories_per_agent": max_mem,
        "current_agents": len(agents_list),
        "agents": [
            {"agent_id": a.get("agent_id", ""), "state": a.get("state", "unknown")}
            for a in agents_list
        ],
    }


# ---------------------------------------------------------------------------
# API Key Management (admin only)
# ---------------------------------------------------------------------------

@app.post("/v1/admin/keys")
async def create_api_key(auth=Depends(verify_auth)):
    """Create a new API key for the authenticated tenant (own account only)."""
    tenant_id = _get_tenant_id(auth)
    if _auth_manager:
        raw_key = _auth_manager.create_key(tenant_id=tenant_id)
        return {"api_key": raw_key, "tenant_id": tenant_id, "warning": "Save this key - it won't be shown again"}
    raise HTTPException(status_code=503, detail="Auth not configured")


# ---------------------------------------------------------------------------
# Per-Tenant LLM Settings
# ---------------------------------------------------------------------------
# In-memory tenant settings (persisted to tenant DB on write)
_tenant_settings: dict = {}  # tenant_id -> {llm_provider, openai_api_key, ...}

# Platform free tier: 100 LLM extractions per tenant before downgrade to embedding-only
_PLATFORM_FREE_LIMIT = int(os.environ.get("OCTOPODA_PLATFORM_FREE_LIMIT", "100"))

# ---------------------------------------------------------------------------
# Latency & Error Anomaly Detection (in-memory, per-agent)
# ---------------------------------------------------------------------------
# Tracks recent latencies and errors per agent to detect spikes and high error rates.
# Zero config — runs automatically on every API call.

_latency_tracker: dict = {}  # agent_id -> [{"latency_us": float, "time": float}, ...]
_error_tracker: dict = {}    # agent_id -> [{"time": float, "success": bool}, ...]
_latency_tracker_lock = threading.Lock()

_LATENCY_WINDOW = 300       # 5 minutes
_LATENCY_SPIKE_FACTOR = 5   # alert if recent mean > 5x baseline mean
_LATENCY_MIN_SAMPLES = 10   # need at least 10 samples to establish baseline
_ERROR_WINDOW = 300          # 5 minutes
_ERROR_RATE_THRESHOLD = 0.20 # alert if >20% of calls fail in window
_ERROR_MIN_SAMPLES = 5       # need at least 5 calls to trigger


def _track_latency_and_errors(agent_id: str, latency_us: float, success: bool, runtime):
    """Track latency and error rate, write alerts if anomalous."""
    now = time.time()
    cutoff = now - _LATENCY_WINDOW

    with _latency_tracker_lock:
        # Track latency
        if agent_id not in _latency_tracker:
            _latency_tracker[agent_id] = []
        entries = _latency_tracker[agent_id]
        entries.append({"latency_us": latency_us, "time": now})
        # Prune old entries
        _latency_tracker[agent_id] = [e for e in entries if e["time"] >= cutoff]
        recent_latencies = _latency_tracker[agent_id]

        # Track errors
        if agent_id not in _error_tracker:
            _error_tracker[agent_id] = []
        err_entries = _error_tracker[agent_id]
        err_entries.append({"time": now, "success": success})
        _error_tracker[agent_id] = [e for e in err_entries if e["time"] >= cutoff]
        recent_errors = _error_tracker[agent_id]

    # Check latency spike
    if len(recent_latencies) >= _LATENCY_MIN_SAMPLES:
        values = [e["latency_us"] for e in recent_latencies]
        # Use first half as baseline, second half as recent
        mid = len(values) // 2
        if mid >= 3:
            baseline_mean = sum(values[:mid]) / mid
            recent_mean = sum(values[mid:]) / (len(values) - mid)
            if baseline_mean > 0 and recent_mean > baseline_mean * _LATENCY_SPIKE_FACTOR:
                try:
                    alert_key = f"alerts:{agent_id}:latency_spike:{int(now)}"
                    alert_data = {
                        "agent_id": agent_id,
                        "type": "latency_spike",
                        "severity": "warning",
                        "detail": f"Avg latency {recent_mean:.0f}us is {recent_mean/baseline_mean:.1f}x above baseline {baseline_mean:.0f}us",
                        "current_value": recent_mean,
                        "threshold": baseline_mean * _LATENCY_SPIKE_FACTOR,
                        "timestamp": now,
                    }
                    runtime.remember(alert_key, alert_data)
                except Exception:
                    pass

    # Check error rate
    if len(recent_errors) >= _ERROR_MIN_SAMPLES:
        failures = sum(1 for e in recent_errors if not e["success"])
        error_rate = failures / len(recent_errors)
        if error_rate >= _ERROR_RATE_THRESHOLD:
            try:
                alert_key = f"alerts:{agent_id}:high_error_rate:{int(now)}"
                alert_data = {
                    "agent_id": agent_id,
                    "type": "high_error_rate",
                    "severity": "critical",
                    "detail": f"Error rate {error_rate:.0%} ({failures}/{len(recent_errors)} calls failed in last 5 min)",
                    "current_value": error_rate,
                    "threshold": _ERROR_RATE_THRESHOLD,
                    "timestamp": now,
                }
                runtime.remember(alert_key, alert_data)
            except Exception:
                pass

# ---------------------------------------------------------------------------
# API key encryption at rest (AES-128 via Fernet)
# ---------------------------------------------------------------------------
_SENSITIVE_KEYS = ("openai_api_key", "anthropic_api_key")

def _get_fernet():
    """Get Fernet cipher for encrypting tenant API keys at rest."""
    try:
        from cryptography.fernet import Fernet
        import base64, hashlib
        # Derive from server secret (env var or auto-generated file)
        secret = os.environ.get("OCTOPODA_ENCRYPTION_KEY", "")
        if not secret:
            key_path = os.path.join(os.path.expanduser("~"), ".synrix", ".encryption_key")
            if os.path.exists(key_path):
                with open(key_path, "r") as f:
                    secret = f.read().strip()
            else:
                import secrets as _secrets
                secret = _secrets.token_urlsafe(48)
                os.makedirs(os.path.dirname(key_path), exist_ok=True)
                with open(key_path, "w") as f:
                    f.write(secret)
                os.chmod(key_path, 0o600)
        # Fernet needs a 32-byte URL-safe base64-encoded key
        derived = hashlib.sha256(secret.encode()).digest()
        fernet_key = base64.urlsafe_b64encode(derived)
        return Fernet(fernet_key)
    except ImportError:
        return None  # cryptography not installed — fall back to plaintext

_fernet_cipher = _get_fernet()

def _encrypt_settings(settings: dict) -> dict:
    """Encrypt sensitive API keys before storing to DB."""
    if not _fernet_cipher:
        return settings
    out = dict(settings)
    for key in _SENSITIVE_KEYS:
        val = out.get(key)
        if val and not val.startswith("enc:"):
            out[key] = "enc:" + _fernet_cipher.encrypt(val.encode()).decode()
    return out

def _decrypt_settings(settings: dict) -> dict:
    """Decrypt sensitive API keys after loading from DB."""
    if not _fernet_cipher:
        return settings
    out = dict(settings)
    for key in _SENSITIVE_KEYS:
        val = out.get(key)
        if val and val.startswith("enc:"):
            try:
                out[key] = _fernet_cipher.decrypt(val[4:].encode()).decode()
            except Exception:
                out[key] = ""  # corrupted — clear it
    return out


def _get_tenant_settings(tenant_id: str) -> dict:
    """Get tenant LLM settings from cache or DB.

    New tenants default to 'platform' provider (free tier with 100 LLM extractions).
    """
    if tenant_id in _tenant_settings:
        return _tenant_settings[tenant_id]
    # Try to load from tenant DB
    try:
        from synrix_runtime.api.tenant import TenantManager
        tm = TenantManager.get_instance()
        backend = tm.get_backend(tenant_id)
        result = backend.read("__tenant_settings__")
        if result and "data" in result:
            settings = result["data"].get("value", {})
            # Unwrap nested "value" keys caused by earlier double-wrap bug
            while isinstance(settings, dict) and "value" in settings and "llm_provider" not in settings:
                settings = settings["value"]
            if isinstance(settings, dict) and "llm_provider" in settings:
                # Clean: strip leftover nested "value" key to stop DB bloat
                clean = {k: v for k, v in settings.items() if k != "value"}
                # Decrypt any encrypted API keys
                clean = _decrypt_settings(clean)
                _tenant_settings[tenant_id] = clean
                # Re-save clean+encrypted version to fix the DB entry
                try:
                    backend.write("__tenant_settings__", _encrypt_settings(clean), metadata={"type": "settings"})
                except Exception:
                    pass
                return clean
    except Exception:
        pass
    # New tenant — default to platform free tier
    defaults = {"llm_provider": "platform", "platform_extractions_used": 0}
    _tenant_settings[tenant_id] = defaults
    return defaults


_platform_usage_lock = threading.Lock()

def _check_and_increment_platform_usage(tenant_id: str) -> bool:
    """Atomically check and increment platform free tier counter.
    Returns True if extraction is allowed, False if limit exceeded."""
    with _platform_usage_lock:
        settings = _get_tenant_settings(tenant_id)
        if settings.get("llm_provider") != "platform":
            return True  # not on platform tier, no limit
        used = settings.get("platform_extractions_used", 0)
        if used >= _PLATFORM_FREE_LIMIT:
            # Exceeded — downgrade to embedding-only
            settings["llm_provider"] = "none"
            _save_tenant_settings(tenant_id, settings)
            return False
        settings["platform_extractions_used"] = used + 1
        _save_tenant_settings(tenant_id, settings)
        return True

def _increment_platform_usage(tenant_id: str):
    """Backward-compatible wrapper."""
    _check_and_increment_platform_usage(tenant_id)


def _save_tenant_settings(tenant_id: str, settings: dict):
    """Persist tenant settings to DB (API keys encrypted at rest)."""
    _tenant_settings[tenant_id] = settings  # in-memory cache holds plaintext
    try:
        from synrix_runtime.api.tenant import TenantManager
        tm = TenantManager.get_instance()
        backend = tm.get_backend(tenant_id)
        backend.write("__tenant_settings__", _encrypt_settings(settings), metadata={"type": "settings"})
    except Exception:
        pass


@app.get("/v1/settings")
async def get_settings(auth=Depends(verify_auth)):
    """Get current LLM and feature settings for your account."""
    tenant_id = _get_tenant_id(auth)
    settings = _get_tenant_settings(tenant_id)
    # Never return full API keys — mask them
    safe = dict(settings)
    for key in ("openai_api_key", "anthropic_api_key"):
        if key in safe and safe[key]:
            safe[key] = safe[key][:8] + "..." + safe[key][-4:]
    provider = safe.get("llm_provider", "platform")
    result = {
        "llm_provider": provider,
        "openai_api_key": safe.get("openai_api_key", ""),
        "openai_model": safe.get("openai_model", "gpt-4o-mini"),
        "openai_base_url": safe.get("openai_base_url", "https://api.openai.com/v1"),
        "anthropic_api_key": safe.get("anthropic_api_key", ""),
        "anthropic_model": safe.get("anthropic_model", "claude-haiku-4-5-20251001"),
        "ollama_model": safe.get("ollama_model", "llama3.2"),
    }
    # Show platform free tier usage if applicable
    if provider == "platform":
        used = settings.get("platform_extractions_used", 0)
        result["platform_extractions_used"] = used
        result["platform_extractions_limit"] = _PLATFORM_FREE_LIMIT
        result["platform_extractions_remaining"] = max(0, _PLATFORM_FREE_LIMIT - used)

    # Memory feature settings
    result["ttl_auto_cleanup"] = settings.get("ttl_auto_cleanup", True)
    result["conflict_detection"] = settings.get("conflict_detection", True)
    result["conflict_sensitivity"] = settings.get("conflict_sensitivity", 0.85)

    return result


@app.put("/v1/settings")
async def update_settings(req: dict, auth=Depends(verify_auth)):
    """Update LLM provider and API keys for your account.

    Supported fields:
        llm_provider: "ollama" | "openai" | "anthropic" | "none"
        openai_api_key: your OpenAI API key (or any OpenAI-compatible provider key)
        openai_model: model name (default: gpt-4o-mini)
        openai_base_url: API base URL (default: https://api.openai.com/v1)
            — Use this for Groq, Together, Mistral, or any OpenAI-compatible API
        anthropic_api_key: your Anthropic API key
        anthropic_model: model name (default: claude-haiku-4-5-20251001)
        ollama_model: Ollama model name (default: llama3.2)
    """
    tenant_id = _get_tenant_id(auth)
    settings = _get_tenant_settings(tenant_id)

    allowed_fields = {
        "llm_provider", "openai_api_key", "openai_model", "openai_base_url",
        "anthropic_api_key", "anthropic_model", "ollama_model",
        "ttl_auto_cleanup", "conflict_detection", "conflict_sensitivity",
    }
    allowed_providers = {"openai", "anthropic", "none"}

    for key, value in req.items():
        if key in allowed_fields:
            if key == "llm_provider" and value not in allowed_providers:
                raise HTTPException(
                    status_code=422,
                    detail=f"Invalid provider '{value}'. Must be one of: {', '.join(allowed_providers)}"
                )
            # SSRF protection: validate openai_base_url
            if key == "openai_base_url" and value:
                from urllib.parse import urlparse
                parsed = urlparse(str(value))
                if parsed.scheme not in ("https", "http"):
                    raise HTTPException(status_code=422, detail="base_url must be http(s)")
                hostname = (parsed.hostname or "").lower()
                if hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1", "") \
                   or hostname.startswith("10.") or hostname.startswith("172.") \
                   or hostname.startswith("192.168.") or hostname.startswith("169.254."):
                    raise HTTPException(status_code=422, detail="base_url must point to a public host")
            settings[key] = value

    _save_tenant_settings(tenant_id, settings)

    # Evict cached runtimes for this tenant so they pick up the new LLM config
    keys_to_evict = [k for k in _agent_runtimes if k.startswith(f"{tenant_id}:")]
    for k in keys_to_evict:
        _agent_runtimes.pop(k, None)

    return {"updated": True, "llm_provider": settings.get("llm_provider", "platform")}
