"""Dedicated memory curator service — run alongside Synapse on port 8001.

Endpoints:
  GET  /            health check
  GET  /memories    list all memory items (optional ?section= filter)
  POST /memories    direct write — no LLM, saves immediately
  PATCH /memories/{id}  update a memory item
  DELETE /memories/{id} delete a memory item
  POST /memories/extract  LLM-curated: evaluate a conversation exchange and
                          optionally save a new permanent fact
"""

from __future__ import annotations

import asyncio
import math
import uuid
from typing import Any, Dict, List, Optional

from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from pydantic import BaseModel

from .store import store, MemoryItem
from .extractor import extract_memory
from ..ollama_manager import get_ollama_manager
from ..nexus_config import ALLOWED_HOSTS, ALLOWED_ORIGINS

app = FastAPI(title="Nexus Memory Service", version="1.0")

# Same unauthenticated-local-only posture as the main backend: reject foreign
# Host headers (anti DNS-rebind) and scope CORS to known local origins rather
# than "*". See nexus_config.ALLOWED_HOSTS / ALLOWED_ORIGINS for env overrides.
if "*" not in ALLOWED_HOSTS:
    app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.on_event("startup")
async def _warm_curator():
    """Preload the curator model (in RAM, num_gpu=0 by default) so the first
    extraction isn't a cold load that blows the timeout. Runs in the background
    so it never delays startup. keep_alive then holds it warm between messages."""
    async def _bg():
        try:
            mgr = get_ollama_manager()
            # Ollama is started by the Synapse backend (a separate process), so
            # at our startup it usually isn't reachable yet. Wait for it before
            # warming instead of failing with "All connection attempts failed" —
            # which leaves the curator cold and makes the first extraction slow.
            for _ in range(60):  # up to ~2 min
                if await asyncio.to_thread(mgr.is_running):
                    break
                await asyncio.sleep(2)
            else:
                return
            settings = store.get_settings()
            model = settings.get("memory_model") or await mgr.select_best_model()
            num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
            await mgr.warm(model, num_gpu=num_gpu)
        except Exception:
            pass
    asyncio.create_task(_bg())


@app.get("/")
async def health():
    return {"status": "ok", "count": len(store.all())}


@app.get("/memories")
async def list_memories(section: Optional[str] = None):
    items = store.all()
    if section:
        items = [i for i in items if i.section.lower() == section.lower()]
    return {"items": [{"id": i.id, "section": i.section, "text": i.text, "tags": i.tags} for i in items]}


@app.post("/memories")
async def add_memory(payload: Dict[str, Any] = Body(...)):
    text = (payload.get("text") or "").strip()
    if not text:
        raise HTTPException(status_code=400, detail="Missing 'text'")
    item = MemoryItem(
        id=str(uuid.uuid4()),
        section=(payload.get("section") or "General").strip(),
        text=text,
        tags=payload.get("tags", []),
    )
    store.add(item)
    return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}


@app.patch("/memories/{item_id}")
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
    existing = store.get(item_id)
    if not existing:
        raise HTTPException(status_code=404, detail="Not found")
    updated = MemoryItem(
        id=item_id,
        section=(payload.get("section") or existing.section).strip(),
        text=(payload.get("text") or existing.text).strip(),
        tags=payload.get("tags", existing.tags),
    )
    store.update(updated)
    return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}


@app.delete("/memories/{item_id}")
async def delete_memory(item_id: str):
    if not store.get(item_id):
        raise HTTPException(status_code=404, detail="Not found")
    store.delete(item_id)
    return {"status": "deleted"}


def _cosine(a: list, b: list) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb) if na and nb else 0.0


class ExtractRequest(BaseModel):
    user_message: str
    assistant_response: str


@app.post("/memories/extract")
async def extract_and_save(req: ExtractRequest):
    """LLM-curated extraction — asks Mistral to evaluate the exchange against all
    existing memory items and save only new, permanent personal facts."""
    existing = store.all()
    existing_sections = list({i.section for i in existing})
    existing_texts = [i.text for i in existing]

    # Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
    # which model does extraction, and whether it runs on CPU/RAM or the GPU.
    settings = store.get_settings()
    mgr = get_ollama_manager()
    model = settings.get("memory_model") or await mgr.select_best_model()
    num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
    try:
        merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
    except (TypeError, ValueError):
        merge_threshold = 0.88

    try:
        results = await asyncio.wait_for(
            extract_memory(
                req.user_message,
                req.assistant_response,
                existing_sections,
                existing_texts,
                mgr,
                model=model,
                num_gpu=num_gpu,
            ),
            timeout=300.0,
        )

        # Embed existing facts once so each new fact can be matched against them.
        # A near-duplicate UPDATES the matched fact in place (edit with new info)
        # rather than appending a copy. Best effort: if embeddings are down we
        # fall back to plain append. Merge disabled unless 0 < threshold < 1.
        existing_embeds: dict = {}
        if results and 0 < merge_threshold < 1:
            vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
            existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}

        saved = []
        for result in results:
            new_vec = await mgr.embed(result["text"]) if existing_embeds else None
            match_id, best = None, 0.0
            if new_vec:
                for eid, ev in existing_embeds.items():
                    sim = _cosine(new_vec, ev)
                    if sim > best:
                        best, match_id = sim, eid
                if best < merge_threshold:
                    match_id = None

            target = store.get(match_id) if match_id else None
            if target:
                # Near-duplicate of an existing fact — overwrite with the newer
                # statement, keeping the original id/section/position.
                updated = MemoryItem(id=target.id, section=target.section,
                                     text=result["text"], tags=target.tags)
                store.update(updated)
                if new_vec:
                    existing_embeds[updated.id] = new_vec  # keep cache fresh for later facts in this batch
                saved.append({"id": updated.id, "section": updated.section,
                              "text": updated.text, "updated": True})
            else:
                item = MemoryItem(id=str(uuid.uuid4()),
                                  section=result["section"], text=result["text"])
                store.add(item)
                if new_vec:
                    existing_embeds[item.id] = new_vec
                saved.append({"id": item.id, "section": item.section, "text": item.text})
        if saved:
            first = {k: saved[0][k] for k in ("id", "section", "text")}
            return {"saved": True, "items": saved, **first}
    except asyncio.TimeoutError:
        pass
    except Exception:
        pass
    return {"saved": False}
