"""The Memory Manager — curated memory with ADD/UPDATE/DELETE/NOOP operations.

Inspired by Memory-R1: instead of appending blindly, ``remember`` decides whether a
new fact is genuinely new (ADD), updates an existing one (UPDATE), or is a duplicate
(NOOP). ``merge`` applies this over a batch — the dedup engine behind the migration
memory-merge (which must never overwrite existing history blindly).
"""

from __future__ import annotations

import re
import uuid
from collections.abc import Callable

from chimera.memory.models import MemoryItem, MemoryKind
from chimera.memory.semantic import EmbedFn, SemanticIndex
from chimera.memory.store import MemoryBackend
from chimera.telemetry import get_logger

_log = get_logger("memory.manager")
_TOKEN = re.compile(r"[a-z0-9]+")


def _normalize(text: str) -> str:
    return re.sub(r"\s+", " ", text.strip().lower())


class MemoryManager:
    """Curates a :class:`MemoryStore`."""

    def __init__(self, store: MemoryBackend, *, embed: EmbedFn | None = None) -> None:
        self.store = store
        # Opt-in semantic recall: when an embedder is supplied, ``search`` ranks by cosine
        # similarity (bridges paraphrases keyword search can't). Absent/failing embedder ->
        # the keyword/FTS path always remains as a fallback.
        self._semantic = SemanticIndex(embed) if embed is not None else None

    def add(
        self,
        content: str,
        kind: MemoryKind = "semantic",
        *,
        key: str | None = None,
        source: str = "chimera",
        provenance: str = "clean",
    ) -> MemoryItem:
        item = MemoryItem(
            # Full uuid4 hex, not [:8]: an 8-char (32-bit) id has a ~1% birthday-collision chance by
            # 10k memories, and add() overwrites on a clash — silently destroying a distinct memory.
            id=uuid.uuid4().hex,
            kind=kind,
            content=content,
            key=key,
            source=source,
            provenance=provenance,
        )
        self.store.add(item)
        return item

    def update(self, item_id: str, content: str) -> MemoryItem:
        item = self.store.get(item_id)
        item.content = content
        self.store.add(item)
        return item

    def delete(self, item_id: str) -> None:
        self.store.remove(item_id)

    def _find_duplicate(self, content: str, key: str | None) -> MemoryItem | None:
        norm = _normalize(content)
        for item in self.store.all():
            if key is not None and item.key == key:
                return item
            if _normalize(item.content) == norm:
                return item
        return None

    def remember(
        self,
        content: str,
        kind: MemoryKind = "semantic",
        *,
        key: str | None = None,
        source: str = "chimera",
        provenance: str = "clean",
    ) -> tuple[str, MemoryItem]:
        """ADD a new fact, UPDATE an existing one (same key), or NOOP a duplicate.

        Returns the operation name and the resulting item. ``provenance="tainted"``
        marks a fact written during a run that consumed untrusted content; an UPDATE
        from a tainted run also taints the stored item (poison must not launder itself
        into a previously clean fact).
        """
        duplicate = self._find_duplicate(content, key)
        if duplicate is None:
            return "ADD", self.add(content, kind, key=key, source=source, provenance=provenance)
        if _normalize(duplicate.content) == _normalize(content):
            return "NOOP", duplicate
        updated = self.update(duplicate.id, content)
        if provenance == "tainted":
            updated.provenance = "tainted"
            self.store.add(updated)
        return "UPDATE", updated

    def merge(self, items: list[MemoryItem]) -> dict[str, int]:
        """Merge a batch, deduping against existing memory. Returns op counts."""
        counts = {"ADD": 0, "UPDATE": 0, "NOOP": 0}
        for item in items:
            # Carry provenance through the merge: a tainted imported fact must NOT launder itself
            # to clean (the same guarantee remember() makes for a direct tainted UPDATE).
            op, _ = self.remember(
                item.content, item.kind, key=item.key, source=item.source,
                provenance=item.provenance,
            )
            counts[op] += 1
        _log.debug("merged %d items: %s", len(items), counts)
        return counts

    def prune(self, max_items: int, *, dry_run: bool = False) -> int:
        """Keep the ``max_items`` highest-value memories; remove the rest. Returns the count removed.

        Value is the multi-factor model in :mod:`chimera.memory.value` (recency,
        specificity, kind, curation, reliability) — not a single cue.

        ``persona`` facts (the durable cross-session profile) are NEVER pruned — they're identity,
        not budget fodder — so the ``max_items`` budget applies only to the prunable items. With
        ``dry_run=True`` nothing is deleted; the return value is how many WOULD be removed.
        """
        from chimera.memory.value import rank

        prunable = [item for item in self.store.all() if item.kind != "persona"]
        if len(prunable) <= max_items:
            return 0
        to_remove = [item for _, item in rank(prunable)[max_items:]]
        if not dry_run:
            for item in to_remove:
                self.store.remove(item.id)
        return len(to_remove)

    def consolidate(
        self,
        summarizer: object,
        *,
        threshold: float = 0.5,
        kinds: tuple[MemoryKind, ...] = ("semantic", "episodic"),
    ) -> int:
        """Merge clusters of similar memories into one summarised fact. Returns net reduction.

        ``summarizer`` is a ``list[str] -> str`` callable (see :mod:`chimera.memory.consolidate`).
        A merge is a write — call it deliberately (e.g. from ``memory consolidate``), not on
        every turn.
        """
        from chimera.memory.consolidate import cluster

        removed = 0
        for kind in kinds:
            for group in cluster(self.store.by_kind(kind), threshold=threshold):
                if len(group) < 2:
                    continue
                summary = summarizer([item.content for item in group])  # type: ignore[operator]
                if not summary.strip():
                    continue
                # Propagate the strongest provenance of the cluster: merging a tainted member into a
                # summary must NOT launder it to clean (same guarantee remember/merge uphold).
                prov = "tainted" if any(i.provenance == "tainted" for i in group) else "clean"
                for item in group:
                    self.store.remove(item.id)
                self.add(summary, kind, source="chimera", provenance=prov)
                removed += len(group) - 1
        _log.debug("consolidated: removed %d memories", removed)
        return removed

    def nudges(self, user_texts: list[str], *, max_suggestions: int = 3) -> list[str]:
        """Suggest persona facts to save from preferences stated in recent user messages.

        Returns plain fact strings not already in memory (deduped, capped). Empty when
        there's nothing new worth saving. Storing is the user's call — this only surfaces.
        """
        from chimera.memory.nudges import detect_nudges

        known = [item.content for item in self.store.all()]
        return detect_nudges(user_texts, known, max_suggestions=max_suggestions)

    def autoconsolidate(
        self,
        summarizer: object,
        *,
        max_items: int,
        threshold: float = 0.5,
        kinds: tuple[MemoryKind, ...] = ("semantic", "episodic"),
    ) -> int:
        """Consolidate only when memory has outgrown ``max_items``. Returns net reduction.

        The budgeted, opt-in end-of-session hook: consolidation is skipped entirely while
        memory is small (no wasted model calls), and only kicks in once it grows past the
        budget. Returns 0 without calling the summariser when under budget.
        """
        if len(self.store.all()) <= max_items:
            return 0
        return self.consolidate(summarizer, threshold=threshold, kinds=kinds)

    def profile(self, *, max_items: int = 12) -> str:
        """A consolidated user-profile preamble from persona memories (highest-value first).

        Persona facts persist across sessions, so applying them as a preamble on every turn
        gives cross-session personalization without the user re-stating preferences. Empty
        string when no persona facts are stored.
        """
        from chimera.memory.value import rank

        personas = self.store.by_kind("persona")
        if not personas:
            return ""
        top = [item for _, item in rank(personas)][:max_items]
        # Surface trust provenance on recall: a fact learned from untrusted content must
        # not read as verified — the model is told, in-line, which facts to weigh less.
        facts = "\n".join(
            f"- {item.content}"
            + (" [unverified: learned from untrusted content]" if item.provenance == "tainted" else "")
            for item in top
        )
        return f"What you know about the user:\n{facts}"

    def search(
        self, query: str, *, k: int = 5, on_layer: Callable[[str], None] | None = None
    ) -> list[MemoryItem]:
        """Retrieve relevant memories.

        Semantic ranking (opt-in, when an embedder is configured) first; on any embedder
        failure or when semantic is off, fall through to full-text (if the backend supports
        it) and finally to keyword overlap. The fallback is unconditional — recall must
        never hard-fail because an embeddings endpoint is down.

        ``on_layer`` (optional) is called with the name of the layer that actually produced the
        hits — ``"semantic"`` | ``"fts"`` | ``"keyword"`` — and never when a layer returns nothing,
        so a UI can honestly show which layer contributed rather than guessing.
        """
        # A tokenless query (blank/whitespace/punctuation) has nothing to match — return [] up front
        # so every path agrees. Otherwise the semantic path would embed "" and return k arbitrary
        # items while the keyword/FTS paths correctly return nothing.
        if not _TOKEN.findall(query.lower()):
            return []
        if self._semantic is not None:
            try:
                hits = self._semantic.search(query, self.store.all(), k)
                if hits:
                    if on_layer is not None:
                        on_layer("semantic")
                    return hits
            except Exception as exc:  # noqa: BLE001 — degrade to lexical, never fail recall
                _log.warning("semantic recall failed, falling back to keyword: %s", exc)
        backend_search = getattr(self.store, "search", None)
        if callable(backend_search):  # e.g. the SQLite/FTS5 store
            result: list[MemoryItem] = backend_search(query, k=k)
            if result and on_layer is not None:
                on_layer("fts")
            return result
        terms = set(_TOKEN.findall(query.lower()))
        if not terms:
            return []
        scored: list[tuple[int, str, MemoryItem]] = []
        for item in self.store.all():
            haystack = set(_TOKEN.findall(item.content.lower()))
            score = len(terms & haystack)
            if score:
                scored.append((score, item.id, item))
        scored.sort(key=lambda entry: (-entry[0], entry[1]))
        top = [item for _, _, item in scored[:k]]
        if top and on_layer is not None:
            on_layer("keyword")
        return top
