"""Curated memory extraction — read a finished conversation, save what's new.

This is the whole memory-writing path: pick up the messages the curator has not
read yet, ask the model which permanent facts they contain, merge each result
against what is already stored, and move the conversation's watermark.

It runs in-process. Extraction used to sit behind an HTTP call to a separate
service on :8001 holding a second, smaller model, because that model could not
share the GPU with the chat model. The curator is the chat model now — already
resident, already warm — so the extra process bought nothing but a hop.
"""

from __future__ import annotations

import asyncio
import math
import uuid

from .store import store, MemoryItem
from .extractor import extract_memory
from ..ollama_manager import get_ollama_manager

_EXTRACT_TIMEOUT = 300.0


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


async def extract_for_conversation(conversation_id: str, project_id: str = "") -> list[dict]:
    """Extract and save facts from a conversation's unread messages.

    Returns the saved/updated items. Safe to call more than once: the watermark
    means a second run with no new messages does nothing and never reaches the
    model. Never raises — memory extraction must not take the caller down.
    """
    messages, last_id = store.pending_extraction(conversation_id)
    if not messages:
        return []

    # Only global facts and this project's are in scope: another project's facts
    # must not be shown to the curator as "already known", or as a merge target.
    existing = [m for m in store.all() if m.project_id in ("", project_id)]
    existing_sections = list({i.section for i in existing})
    existing_texts = [i.text for i in existing]

    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", -1), 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(
                messages, existing_sections, existing_texts, mgr,
                model=model, num_gpu=num_gpu,
            ),
            timeout=_EXTRACT_TIMEOUT,
        )
    except Exception:
        # Leave the watermark alone so the next sweep retries these messages.
        return []

    saved = []
    try:
        # 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}

        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,
                                     project_id=target.project_id)
                store.update(updated)
                if new_vec:
                    existing_embeds[updated.id] = new_vec
                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"], project_id=project_id)
                store.add(item)
                if new_vec:
                    existing_embeds[item.id] = new_vec
                saved.append({"id": item.id, "section": item.section, "text": item.text})
    except Exception:
        pass

    # Watermark even when nothing was saved — the model has read these messages
    # and re-reading them would just spend another call to reach the same "no".
    store.set_extracted_through(conversation_id, last_id)
    return saved
