"""PersistentHandler: SQLite + VectorStore backend.

Implements HandlerProtocol using:
- SQLite for relational data
- VectorStore ABC for embedding search (SQLite, Qdrant, etc.)
- AIClient for embedding generation and LLM operations

Data is persisted in SQLite by default (~/.trustmem/trustmem.db).
Set DATABASE_URL to ":memory:" for in-memory mode (tests).
"""
from __future__ import annotations

import hashlib
import logging
import os
import threading
import uuid
from datetime import datetime, timezone
from typing import Any

logger = logging.getLogger(__name__)

from trustmem.constants import (
    DEFAULT_MEMORY_TYPE,
    DEFAULT_SEARCH_MODE,
    DEFAULT_SOURCE_TRUST,
    MAX_CONTENT_LENGTH,
    MEMORY_TYPES,
    RRF_K,
    SEARCH_MODES,
    SOURCE_TRUST_HIERARCHY,
    SOURCE_TRUST_LEVELS,
)
from trustmem.storage.sqlite_store import SQLiteStore
from trustmem.ztm.constants import (
    DEFAULT_CONFIDENCE,
)
from trustmem.extraction.entities import (
    build_entity_edge,
    build_entity_node,
    compute_edge_id,
    compute_entity_id,
    determine_entity_confidence,
    normalize_predicate,
)


def _default_db_path() -> str:
    """Return default SQLite database path."""
    home = os.path.expanduser("~")
    return os.path.join(home, ".trustmem", "trustmem.db")


class PersistentHandler:
    """Handler using SQLite + VectorStore + AIClient.

    All data is persisted in SQLite. VectorStore handles embeddings
    for similarity search. AIClient generates embeddings and runs
    LLM operations.
    """

    def __init__(
        self,
        database_url: str = "",
        vectorstore: Any = None,
        ai_client: Any = None,
        graphstore: Any = None,
        read_audit: bool = False,
    ) -> None:
        if ai_client is None:
            raise TypeError(
                "ai_client is required. "
                "Use create_ai_client() to create one."
            )
        self._database_url = database_url
        self._ai = ai_client

        # Initialize SQLite store
        db_path = database_url if database_url else _default_db_path()
        self._db = SQLiteStore(db_path)

        # VectorStore: fall back to SQLiteVectorStore wrapping self._db
        if vectorstore is not None:
            self._vs = vectorstore
        else:
            from trustmem.vectorstore.sqlite import SQLiteVectorStore

            self._vs = SQLiteVectorStore(self._db)

        # GraphStore: fall back to SQLiteGraphStore wrapping self._db
        if graphstore is not None:
            self._gs = graphstore
        else:
            from trustmem.graphstore.sqlite import SQLiteGraphStore

            self._gs = SQLiteGraphStore(self._db)

        # Read Audit (opt-in)
        self._read_audit = read_audit

        # In-memory embedding cache (not persisted — rebuilt on demand)
        # LRU-style: evict oldest entries when exceeding max size.
        self._embedding_cache: dict[str, list[float]] = {}
        self._embedding_cache_max_size = 10_000
        self._embedding_cache_lock = threading.Lock()

    def _cache_embedding(self, memory_id: str, vec: list[float]) -> None:
        """Store embedding in cache, evicting oldest entries if over limit.

        Thread-safe: uses ``_embedding_cache_lock`` to guard concurrent access
        (e.g. async contamination propagation via daemon threads).
        """
        with self._embedding_cache_lock:
            if len(self._embedding_cache) >= self._embedding_cache_max_size:
                # Evict ~10% oldest entries (dict preserves insertion order in 3.7+)
                evict_count = self._embedding_cache_max_size // 10
                keys = list(self._embedding_cache.keys())[:evict_count]
                for k in keys:
                    del self._embedding_cache[k]
            self._embedding_cache[memory_id] = vec

    def _record_access(
        self,
        event_type: str,
        user_id: str | None,
        memory_ids: list[str],
        result_count: int,
        accessor: str = "system",
        query: str | None = None,
        filters: dict | None = None,
    ) -> None:
        """Record a read access event (if read_audit is enabled)."""
        if not self._read_audit:
            return
        try:
            self._db.put_access_log({
                "access_id": str(uuid.uuid4()),
                "event_type": event_type,
                "user_id": user_id,
                "accessor": accessor,
                "query": query,
                "filters": filters or {},
                "memory_ids": memory_ids,
                "result_count": result_count,
                "created_at": datetime.now(timezone.utc).isoformat(),
            })
        except Exception as e:
            logger.warning("Failed to record access log: %s", e)

    @property
    def _provenance(self) -> list[dict]:
        """Backward-compatible access to provenance records."""
        return self._db.all_provenance()

    # ============================================================
    # Always Verify (read-time hash check)
    # ============================================================

    def _verify_confidence(self, mem: dict) -> tuple[str, bool]:
        """Always Verify: read-time content hash check.

        Recomputes the content hash and compares it against the stored hash.
        If tampered, confidence is demoted to ``"low"`` in the returned value
        (the database is **not** modified).

        Returns:
            (effective_confidence, hash_verified)
        """
        stored_conf = mem.get("confidence", DEFAULT_CONFIDENCE)
        uid = mem.get("user_id") or ""
        hash_input = f"{mem['content']}|{uid}|{mem['created_at']}"
        computed = "mem_" + hashlib.sha256(
            hash_input.encode()
        ).hexdigest()[:40]

        if computed != mem.get("content_hash", ""):
            return "low", False

        return stored_conf, True

    # ============================================================
    # Core CRUD
    # ============================================================

    def add_memory(
        self,
        content: str,
        tags: list[str] | None = None,
        metadata: dict | None = None,
        _precomputed_embedding: list[float] | None = None,
        _confidence: str | None = None,
        _source_trust: str | None = None,
        expires_at: str | None = None,
        user_id: str | None = None,
        memory_type: str = DEFAULT_MEMORY_TYPE,
    ) -> dict:
        """Add a memory with confidence-based trust."""

        if not isinstance(content, str) or not content.strip():
            raise ValueError("content must be a non-empty string")
        if len(content) > MAX_CONTENT_LENGTH:
            raise ValueError(
                f"content exceeds maximum length ({len(content)} > {MAX_CONTENT_LENGTH})"
            )
        tags = tags or []
        metadata = metadata or {}
        confidence = _confidence if _confidence is not None else DEFAULT_CONFIDENCE
        source_trust = _source_trust if _source_trust in SOURCE_TRUST_LEVELS else DEFAULT_SOURCE_TRUST
        if _source_trust is not None and _source_trust not in SOURCE_TRUST_LEVELS:
            logger.warning(
                "Unknown source_trust '%s', defaulting to '%s'.",
                _source_trust, DEFAULT_SOURCE_TRUST,
            )

        memory_id = str(uuid.uuid4())
        now = datetime.now(timezone.utc)
        now_iso = now.isoformat()

        # Content hash (use user_id, empty string for None)
        uid = user_id or ""
        content_hash = "mem_" + hashlib.sha256(
            f"{content}|{uid}|{now_iso}".encode()
        ).hexdigest()[:40]

        # Validate memory_type
        if memory_type not in MEMORY_TYPES:
            logger.warning(
                "Unknown memory_type '%s', defaulting to '%s'.",
                memory_type, DEFAULT_MEMORY_TYPE,
            )
            memory_type = DEFAULT_MEMORY_TYPE

        memory = {
            "memory_id": memory_id,
            "content_hash": content_hash,
            "content": content,
            "tags": tags,
            "metadata": metadata,
            "source_trust": source_trust,
            "confidence": confidence,
            "memory_type": memory_type,
            "user_id": user_id,
            "created_at": now_iso,
            "updated_at": now_iso,
            "last_evaluated_at": now_iso,
            "expires_at": expires_at,
        }

        # Compute embedding (reuse precomputed if available)
        vec = _precomputed_embedding
        embedding_status = "success"
        if vec is None:
            try:
                vec = self._ai.embed(content)
            except Exception as e:
                logger.warning("Embedding failed for memory %s: %s", memory_id, e)
                embedding_status = "failed"
        if vec is not None:
            self._cache_embedding(memory_id, vec)

        # Atomic SQLite writes: memory + audit trail
        try:
            self._db.begin_transaction()

            self._db.put_memory(memory)

            self._db.put_audit_trail({
                "audit_id": str(uuid.uuid4()),
                "memory_id": memory_id,
                "event_type": "memory_created",
                "old_confidence": None,
                "new_confidence": confidence,
                "details": {"source_trust": source_trust},
                "created_at": now_iso,
            })

            self._db.commit_transaction()
        except Exception:
            self._db.rollback_transaction()
            raise

        # Upsert to vectorstore (outside transaction — external store)
        if vec is not None:
            try:
                vs_payload: dict[str, Any] = {
                    "content": content,
                    "memory_id": memory_id,
                    "user_id": user_id,
                    "memory_type": memory_type,
                    "created_at": now_iso,
                }
                # Store user metadata as meta.* prefixed keys for pre-filtering
                if metadata:
                    for mk, mv in metadata.items():
                        if isinstance(mv, (str, int, float, bool)):
                            vs_payload[f"meta.{mk}"] = mv
                self._vs.upsert(
                    id=memory_id,
                    vector=vec,
                    payload=vs_payload,
                )
            except Exception as e:
                logger.warning("VectorStore upsert failed for %s: %s", memory_id, e)
                embedding_status = "partial"

        return {
            "memory_id": memory_id,
            "content_hash": content_hash,
            "confidence": confidence,
            "source_trust": source_trust,
            "embedding_status": embedding_status,
        }

    def _rrf_merge(
        self,
        vector_results: list[dict],
        keyword_results: list[dict],
        k: int = RRF_K,
    ) -> list[str]:
        """Reciprocal Rank Fusion merge of vector and keyword results.

        Returns a list of memory_ids sorted by RRF score descending.
        """
        scores: dict[str, float] = {}
        for rank, item in enumerate(vector_results):
            mid = item["id"]
            scores[mid] = scores.get(mid, 0) + 1.0 / (k + rank + 1)
        for rank, item in enumerate(keyword_results):
            mid = item["memory_id"]
            scores[mid] = scores.get(mid, 0) + 1.0 / (k + rank + 1)
        sorted_ids = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return [mid for mid, _ in sorted_ids]

    def search_memory(
        self,
        query: str,
        limit: int = 10,
        min_source_trust: str | None = None,
        filters: dict[str, Any] | None = None,
        accessor: str = "system",
        exclude_expired: bool = False,
        user_id: str | None = None,
        memory_type: str | None = None,
        search_mode: str = DEFAULT_SEARCH_MODE,
    ) -> dict:
        """Search memories using vector, keyword, or hybrid mode.

        When user_id is provided, returns user's memories + org-shared (user_id IS NULL).
        When user_id is None, returns only org-shared memories (user_id IS NULL).
        """
        if search_mode not in SEARCH_MODES:
            logger.warning(
                "Invalid search_mode %r, falling back to %r",
                search_mode, DEFAULT_SEARCH_MODE,
            )
            search_mode = DEFAULT_SEARCH_MODE

        results = []
        now = datetime.now(timezone.utc)
        min_trust_rank = (
            SOURCE_TRUST_HIERARCHY.get(min_source_trust, 0)
            if min_source_trust else 0
        )

        try:
            fetch_limit = limit * 3 if user_id is not None else limit

            # --- Vector search ---
            vs_results: list[dict] = []
            query_vec = None
            if search_mode in ("vector", "hybrid"):
                query_vec = self._ai.embed(query)
                vs_filters: dict[str, Any] = {}
                if memory_type is not None:
                    vs_filters["memory_type"] = memory_type
                if filters:
                    for fk, fv in filters.items():
                        vs_filters[f"meta.{fk}"] = fv
                vs_results = self._vs.search(
                    query_vector=query_vec,
                    limit=fetch_limit,
                    filters=vs_filters,
                )

            # --- Keyword search ---
            kw_results: list[dict] = []
            if search_mode in ("keyword", "hybrid"):
                kw_results = self._db.search_tokens(
                    query=query,
                    user_id=user_id,
                    memory_type=memory_type,
                    limit=fetch_limit,
                )

            # --- Determine candidate ordering ---
            if search_mode == "hybrid" and vs_results and kw_results:
                ordered_ids = self._rrf_merge(vs_results, kw_results)
            elif search_mode == "hybrid" and kw_results:
                ordered_ids = [r["memory_id"] for r in kw_results]
            elif search_mode == "keyword":
                ordered_ids = [r["memory_id"] for r in kw_results]
            else:
                # vector-only or hybrid with no kw results
                ordered_ids = [r["id"] for r in vs_results]

            # Build score lookup from vector results
            vs_score_map = {r["id"]: r.get("score", 0.0) for r in vs_results}

            # Batch-fetch memories from SQLite
            mem_map = self._db.get_memories_by_ids(ordered_ids)

            for mid in ordered_ids:
                mem = mem_map.get(mid)
                if not mem:
                    continue

                # Scope filter (post-fetch)
                mem_user = mem.get("user_id")

                if user_id is not None:
                    if mem_user is not None and mem_user != user_id:
                        continue
                else:
                    if mem_user is not None:
                        continue

                # Source trust filter
                mem_trust = mem.get("source_trust", DEFAULT_SOURCE_TRUST)
                if SOURCE_TRUST_HIERARCHY.get(mem_trust, 0) < min_trust_rank:
                    continue

                # Metadata filter (post-fetch, needed for keyword/hybrid results)
                if filters:
                    mem_meta = mem.get("metadata", {})
                    if not all(mem_meta.get(fk) == fv for fk, fv in filters.items()):
                        continue

                score = vs_score_map.get(mid, 0.0)

                # Calculate freshness (negative days since last_evaluated_at)
                freshness = None
                last_eval = mem.get("last_evaluated_at")
                if last_eval:
                    try:
                        last_dt = datetime.fromisoformat(last_eval)
                        if last_dt.tzinfo is None:
                            last_dt = last_dt.replace(tzinfo=timezone.utc)
                        freshness = -(now - last_dt).days
                    except (ValueError, TypeError):
                        pass

                # Always Verify: read-time hash check
                stored_conf = mem.get("confidence", DEFAULT_CONFIDENCE)
                effective_conf, hash_verified = self._verify_confidence(mem)

                entry = {
                    "memory_id": mid,
                    "content": mem["content"],
                    "similarity": round(score, 4),
                    "confidence": effective_conf,
                    "stored_confidence": stored_conf,
                    "hash_verified": hash_verified,
                    "source_trust": mem_trust,
                    "tags": mem.get("tags", []),
                    "created_at": mem["created_at"],
                    "last_evaluated_at": last_eval,
                    "freshness": freshness,
                }

                # TTL check
                expired = False
                expires_at_val = mem.get("expires_at")
                if expires_at_val:
                    try:
                        exp_dt = datetime.fromisoformat(expires_at_val)
                        if exp_dt.tzinfo is None:
                            exp_dt = exp_dt.replace(tzinfo=timezone.utc)
                        if now > exp_dt:
                            expired = True
                    except (ValueError, TypeError):
                        pass

                if exclude_expired and expired:
                    continue

                entry["expired"] = expired
                entry["expires_at"] = expires_at_val

                results.append(entry)
        except (ConnectionError, OSError) as e:
            logger.error("VectorStore connection error: %s", e)
            return {"results": [], "count": 0, "error": "search_unavailable"}
        except Exception as e:
            logger.warning("Search failed: %s", e)
            return {"results": [], "count": 0, "error": "search_failed"}

        # For vector/hybrid with vector results, rank by trust/confidence/similarity
        # For keyword-only, preserve RRF/FTS rank order
        if search_mode != "keyword":
            _conf_rank = {"high": 2, "medium": 1, "low": 0}
            results.sort(key=lambda r: (
                not r.get("expired", False),
                SOURCE_TRUST_HIERARCHY.get(r.get("source_trust", "derived"), 0),
                _conf_rank.get(r.get("confidence", "medium"), 1),
                r["similarity"],
            ), reverse=True)
        results = results[:limit]

        # Record read access
        self._record_access(
            event_type="search",
            user_id=user_id,
            memory_ids=[r["memory_id"] for r in results],
            result_count=len(results),
            accessor=accessor,
            query=query,
            filters=filters,
        )

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

    def search_memory_raw(
        self,
        query: str,
        limit: int = 10,
        filters: dict[str, Any] | None = None,
        accessor: str = "system",
        exclude_expired: bool = False,
        user_id: str | None = None,
        memory_type: str | None = None,
    ) -> dict:
        """Pure cosine similarity search without TrustMem ranking.

        Same scoping as search_memory: user_id provided returns user + org-shared.
        """
        results = []
        now = datetime.now(timezone.utc)
        try:
            query_vec = self._ai.embed(query)
            fetch_limit = limit * 3 if user_id is not None else limit
            vs_filters: dict[str, Any] = {}
            if memory_type is not None:
                vs_filters["memory_type"] = memory_type
            if filters:
                for fk, fv in filters.items():
                    vs_filters[f"meta.{fk}"] = fv
            vs_results = self._vs.search(
                query_vector=query_vec,
                limit=fetch_limit,
                filters=vs_filters,
            )
            candidate_ids = [r["id"] for r in vs_results]
            mem_map = self._db.get_memories_by_ids(candidate_ids)

            for r in vs_results:
                mid = r["id"]
                mem = mem_map.get(mid)
                if not mem:
                    continue

                mem_user = mem.get("user_id")

                if user_id is not None:
                    if mem_user is not None and mem_user != user_id:
                        continue
                else:
                    if mem_user is not None:
                        continue

                score = r.get("score", 0.0)
                entry: dict[str, Any] = {
                    "memory_id": mid,
                    "content": mem["content"],
                    "similarity": round(score, 4),
                    "confidence": mem.get("confidence", DEFAULT_CONFIDENCE),
                    "source_trust": mem.get("source_trust", DEFAULT_SOURCE_TRUST),
                    "metadata": mem.get("metadata", {}),
                    "tags": mem.get("tags", []),
                    "created_at": mem["created_at"],
                }

                expired = False
                expires_at_val = mem.get("expires_at")
                if expires_at_val:
                    try:
                        exp_dt = datetime.fromisoformat(expires_at_val)
                        if exp_dt.tzinfo is None:
                            exp_dt = exp_dt.replace(tzinfo=timezone.utc)
                        if now > exp_dt:
                            expired = True
                    except (ValueError, TypeError):
                        pass

                if exclude_expired and expired:
                    continue

                entry["expired"] = expired
                entry["expires_at"] = expires_at_val

                results.append(entry)
        except (ConnectionError, OSError) as e:
            logger.error("VectorStore connection error: %s", e)
            return {"results": [], "count": 0, "error": "search_unavailable"}
        except Exception as e:
            logger.warning("VectorStore search failed: %s", e)
            return {"results": [], "count": 0, "error": "search_failed"}

        results.sort(key=lambda r: r["similarity"], reverse=True)
        results = results[:limit]

        self._record_access(
            event_type="search_raw",
            user_id=user_id,
            memory_ids=[r["memory_id"] for r in results],
            result_count=len(results),
            accessor=accessor,
            query=query,
            filters=filters,
        )

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

    def get_memory(self, memory_id: str, accessor: str = "system") -> dict:
        """Get a single memory by ID.

        Raises ValueError if not found.
        """
        mem = self._db.get_memory(memory_id)
        if mem is None:
            raise ValueError(f"Memory not found: {memory_id}")

        # Always Verify: read-time hash check
        stored_conf = mem.get("confidence", DEFAULT_CONFIDENCE)
        effective_conf, hash_verified = self._verify_confidence(mem)
        mem["stored_confidence"] = stored_conf
        mem["confidence"] = effective_conf
        mem["hash_verified"] = hash_verified

        self._record_access(
            event_type="get",
            user_id=mem.get("user_id"),
            memory_ids=[memory_id],
            result_count=1,
            accessor=accessor,
        )

        return mem

    def list_memories(
        self,
        limit: int = 50,
        offset: int = 0,
        accessor: str = "system",
        user_id: str | None = None,
        memory_type: str | None = None,
    ) -> dict:
        """List memories with multi-tenant scope filter."""
        page = self._db.list_memories_multi(
            user_id,
            scope_filter=True, limit=limit, offset=offset,
            memory_type=memory_type,
        )

        # Always Verify: read-time hash check for each memory
        for mem in page:
            stored_conf = mem.get("confidence", DEFAULT_CONFIDENCE)
            effective_conf, hash_verified = self._verify_confidence(mem)
            mem["stored_confidence"] = stored_conf
            mem["confidence"] = effective_conf
            mem["hash_verified"] = hash_verified

        # Record read access
        self._record_access(
            event_type="get_all",
            user_id=user_id,
            memory_ids=[m["memory_id"] for m in page],
            result_count=len(page),
            accessor=accessor,
        )

        return {"memories": page, "count": len(page)}

    def list_all_memories(
        self,
        user_id: str | None = None,
        limit: int = 100,
        offset: int = 0,
        memory_type: str | None = None,
    ) -> dict:
        """List all memories without scope filter (admin)."""
        page = self._db.list_memories_multi(
            user_id,
            scope_filter=False, limit=limit, offset=offset,
            memory_type=memory_type,
        )
        for mem in page:
            stored_conf = mem.get("confidence", "medium")
            effective_conf, hash_verified = self._verify_confidence(mem)
            mem["stored_confidence"] = stored_conf
            mem["confidence"] = effective_conf
            mem["hash_verified"] = hash_verified
        return {"memories": page, "count": len(page)}

    def delete_memory(
        self,
        memory_id: str,
    ) -> bool:
        """Delete a memory.

        Returns True if deleted. Raises KeyError if not found.
        """
        mem = self._db.get_memory(memory_id)
        if mem is None:
            raise KeyError(f"Memory not found: {memory_id}")
        self._db.delete_memory(memory_id)
        with self._embedding_cache_lock:
            self._embedding_cache.pop(memory_id, None)
        try:
            self._vs.delete(memory_id)
        except Exception as e:
            logger.warning("VectorStore delete failed for %s: %s", memory_id, e)
        return True

    # ── Selective Forgetting ────────────────────────────────────

    def forget_entity(
        self,
        entity: str,
        user_id: str | None = None,
        dry_run: bool = False,
        reason: str = "manual",
    ) -> dict:
        """Delete all memories and entities related to the given entity.

        Search strategy:
          1. entity_nodes with matching name → source_memory_id
          2. memories.content text match (LIKE)

        Deletes: memories (CASCADE → provenance, audit_trail, access_log),
                 entity_nodes (hard delete), entity_edges (hard delete),
                 VectorStore entries.

        IMPORTANT: Unlike search (which merges user + org-shared),
        forget uses strict matching — user_id specified means ONLY that
        user's data is deleted. org-shared (user_id=NULL) is never
        touched unless visibility="organization" is used (user_id=None).

        Args:
            entity: Entity name to forget.
            user_id: Restrict to this user. None = org-shared only.
            dry_run: If True, return impact without deleting.
            reason: Audit log reason (e.g. "gdpr_erasure by admin-yamada").

        Returns:
            Summary dict with counts of deleted items.
        """
        # 1. Find related memories (strict match, no merge with NULL)
        memories = self._db.find_memories_by_entity(entity, user_id, strict=True)

        # 2. Find related entity nodes/edges (strict match)
        nodes = self._db.find_entity_nodes_by_name(entity, user_id)
        edges = self._db.find_entity_edges_by_name(entity, user_id)

        if dry_run:
            return {
                "dry_run": True,
                "deleted_memories": len(memories),
                "deleted_entity_nodes": len(nodes),
                "deleted_entity_edges": len(edges),
                "deleted_vectors": len(memories),
            }

        # 3. Delete memories (SQLite CASCADE handles provenance/audit/access_log)
        deleted_vectors = 0
        for mem in memories:
            mid = mem["memory_id"]
            self._db.delete_memory(mid)
            with self._embedding_cache_lock:
                self._embedding_cache.pop(mid, None)
            try:
                self._vs.delete(mid)
                deleted_vectors += 1
            except Exception as e:
                logger.warning("VectorStore delete failed for %s: %s", mid, e)

        # 4. Hard-delete entity edges BEFORE nodes (edges FK CASCADE from nodes)
        deleted_edges = self._db.hard_delete_entity_edges(entity, user_id)
        deleted_nodes = self._db.hard_delete_entity_nodes(entity, user_id)

        # 5. Record forget audit event (no personal data in details)
        self.record_audit_trail(
            memory_id=f"FORGET:{uuid.uuid4()}",
            event_type="forget_entity",
            triggered_by=reason,
            detail={
                "deleted_memories": len(memories),
                "deleted_entity_nodes": deleted_nodes,
                "deleted_entity_edges": deleted_edges,
                "deleted_vectors": deleted_vectors,
            },
        )

        return {
            "dry_run": False,
            "deleted_memories": len(memories),
            "deleted_entity_nodes": deleted_nodes,
            "deleted_entity_edges": deleted_edges,
            "deleted_vectors": deleted_vectors,
        }

    # ── Version Management (update_raw) ──────────────────────────

    def update_memory(
        self,
        memory_id: str,
        content: str,
        *,
        embedding: list[float] | None = None,
        tags: list[str] | None = None,
        metadata: dict | None = None,
        reason: str = "",
    ) -> dict:
        """Create a new version of an existing memory.

        Human-only admin operation. Bypasses ZTM pipeline.
        New memory gets confidence=high, source_trust=verified.
        Old memory is downgraded to confidence=low.
        A derived_from provenance edge links new → old.
        """
        # 1. Validate old memory exists
        old_mem = self._db.get_memory(memory_id)
        if old_mem is None:
            raise ValueError(f"Memory not found: {memory_id}")

        if not isinstance(content, str) or not content.strip():
            raise ValueError("content must be a non-empty string")
        if len(content) > MAX_CONTENT_LENGTH:
            raise ValueError(
                f"content exceeds maximum length ({len(content)} > {MAX_CONTENT_LENGTH})"
            )

        now_iso = datetime.now(timezone.utc).isoformat()
        old_confidence = old_mem.get("confidence", "medium")

        # 2. Create new memory (same path as add_raw)
        #    Inherit user_id from old memory
        new_result = self.add_memory(
            content=content,
            tags=tags,
            metadata=metadata,
            _precomputed_embedding=embedding,
            _confidence="high",
            _source_trust="verified",
            user_id=old_mem.get("user_id"),
        )
        new_memory_id = new_result["memory_id"]

        # 3. Downgrade old memory confidence → low
        self._db.update_memory(memory_id, {
            "confidence": "low",
            "updated_at": now_iso,
            "last_evaluated_at": now_iso,
        })

        # 4. Record derived_from provenance
        self._db.put_provenance({
            "provenance_id": str(uuid.uuid4()),
            "child_memory_id": new_memory_id,
            "parent_memory_id": memory_id,
            "user_id": old_mem.get("user_id"),
            "relationship": "derived_from",
            "created_at": now_iso,
        })

        # 5. Audit trail — old memory (superseded)
        self.record_audit_trail(
            memory_id=memory_id,
            event_type="superseded",
            triggered_by="update_raw",
            detail={
                "new_memory_id": new_memory_id,
                "reason": reason,
            },
            old_confidence=old_confidence,
            new_confidence="low",
        )

        # 6. Audit trail — new memory (update)
        self.record_audit_trail(
            memory_id=new_memory_id,
            event_type="update",
            triggered_by="update_raw",
            detail={
                "old_memory_id": memory_id,
                "reason": reason,
            },
            old_confidence=None,
            new_confidence="high",
        )

        return {
            "old_memory_id": memory_id,
            "new_memory_id": new_memory_id,
            "old_confidence": "low",
            "new_confidence": "high",
            "new_source_trust": "verified",
        }

    def get_memory_stats(self, user_id: str | None = None) -> dict:
        """Get memory statistics using SQL aggregation."""

        stats = self._db.get_memory_stats_sql(user_id=user_id)
        stats["user_id"] = user_id
        return stats

    def record_audit_trail(
        self,
        memory_id: str,
        event_type: str,
        triggered_by: str = "system",
        detail: dict | None = None,
        old_confidence: str | None = None,
        new_confidence: str | None = None,
    ) -> dict:
        """Record an audit trail event."""
        entry = {
            "audit_id": str(uuid.uuid4()),
            "memory_id": memory_id,
            "event_type": event_type,
            "old_confidence": old_confidence,
            "new_confidence": new_confidence,
            "triggered_by": triggered_by,
            "details": detail or {},
            "created_at": datetime.now(timezone.utc).isoformat(),
        }
        self._db.put_audit_trail(entry)
        return entry

    def backfill_content_hash(self, batch_size: int = 500) -> dict:
        """Backfill content_hash for memories that lack one (batch iteration)."""
        all_ids = self._db.all_memory_ids()
        total = len(all_ids)
        updated = 0
        for i in range(0, total, batch_size):
            batch_ids = all_ids[i:i + batch_size]
            batch_mems = self._db.get_memories_by_ids(batch_ids)
            for mid, mem in batch_mems.items():
                if not mem.get("content_hash"):
                    uid = mem.get("user_id") or ""
                    hash_input = f"{mem['content']}|{uid}|{mem['created_at']}"
                    content_hash = "mem_" + hashlib.sha256(hash_input.encode()).hexdigest()[:40]
                    self._db.update_memory(mid, {"content_hash": content_hash})
                    updated += 1
        return {"updated": updated, "total": total}

    def get_audit_trail(self, memory_id: str, limit: int = 50, offset: int = 0) -> list:
        return self._db.get_audit_trail(memory_id, limit, offset)

    def get_contradictions(self, memory_id: str, limit: int = 20, offset: int = 0) -> list:
        """Get contradiction relationships for a memory."""
        return self._db.get_contradictions(memory_id, limit, offset)

    def get_provenance(self, memory_id: str, limit: int = 20, offset: int = 0) -> list:
        """Get provenance links (derived_from etc) for a memory."""
        return self._db.get_provenance_links(memory_id, limit, offset)

    def verify_audit_chain(self) -> dict:
        """Verify the integrity of the entire audit trail hash chain."""
        return self._db.verify_audit_chain()

    def cleanup_audit_trail(self, before: str, dry_run: bool = False) -> dict:
        """Count audit trail records older than *before* (ISO 8601).

        Deletion is disabled to preserve hash chain integrity.
        Only dry_run=True is supported.

        Args:
            before: ISO 8601 date/datetime string.
            dry_run: Must be True. False raises ValueError.

        Returns:
            {"deleted": int, "before": str, "dry_run": bool}
        """
        count = self._db.count_audit_trail_before(before)
        if dry_run:
            return {"deleted": count, "before": before, "dry_run": True}
        raise ValueError(
            "Cannot delete audit trail records: hash chain integrity would be broken. "
            "Use dry_run=True to count records without deleting."
        )

    def cleanup_expired(self, grace_days: int = 0, dry_run: bool = False) -> dict:
        """Delete expired memories (TTL + grace period).

        Args:
            grace_days: Additional days after expiry before deletion.
            dry_run: If True, count only without deleting.
        """
        from datetime import timedelta
        cutoff = (datetime.now(timezone.utc) - timedelta(days=grace_days)).isoformat()
        expired_ids = self._db.cleanup_expired(cutoff)
        count = len(expired_ids)

        if dry_run:
            return {"count": count, "dry_run": True}

        for mid in expired_ids:
            try:
                self._db.delete_memory(mid)
                self._vs.delete(mid)
                self.record_audit_trail(
                    mid, "memory_deleted_expired", "cleanup_expired",
                    {"expires_at_cutoff": cutoff, "grace_days": grace_days},
                )
            except Exception as e:
                logger.warning("Failed to cleanup expired memory %s: %s", mid, e)

        return {"deleted": count}

    # ============================================================
    # Read Audit (Access Log)
    # ============================================================

    def get_access_log(
        self,
        *,
        memory_id: str | None = None,
        accessor: str | None = None,
        since: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> list[dict]:
        """Query access log records."""
        return self._db.get_access_log(
            memory_id=memory_id,
            accessor=accessor,
            since=since,
            limit=limit,
            offset=offset,
        )

    def verify_access_chain(self) -> dict:
        """Verify the integrity of the access log hash chain."""
        return self._db.verify_access_chain()

    def purge_access_log(self, before: str) -> dict:
        """Purge access log records older than *before* (ISO 8601).

        Physically deletes records and rewrites the chain boundary
        with a PURGE marker.
        """
        return self._db.purge_access_log(before)

    def verify_memory(self, memory_id: str, auto_quarantine: bool = True) -> dict:
        """Verify memory integrity via content hash check."""
        mem = self._db.get_memory(memory_id)
        if not mem:
            return {"memory_id": memory_id, "valid": False, "error": "Memory not found"}

        uid = mem.get("user_id") or ""
        hash_input = f"{mem['content']}|{uid}|{mem['created_at']}"
        computed = "mem_" + hashlib.sha256(hash_input.encode()).hexdigest()[:40]
        stored = mem.get("content_hash", "")
        is_valid = computed == stored

        result = {
            "memory_id": memory_id,
            "valid": is_valid,
            "stored_hash": stored,
            "computed_hash": computed,
        }

        if not is_valid and auto_quarantine:
            now_iso = datetime.now(timezone.utc).isoformat()
            old_confidence = mem.get("confidence", DEFAULT_CONFIDENCE)
            self._db.update_memory(memory_id, {
                "confidence": "low",
                "updated_at": now_iso,
            })
            self.record_audit_trail(
                memory_id, "integrity_violation",
                "verify_memory", {"stored_hash": stored, "computed_hash": computed},
                old_confidence=old_confidence, new_confidence="low",
            )
            result["auto_quarantined"] = True

        return result

    def verify_all_memories(self, batch_size: int = 500) -> dict:
        """Verify all memories in batches."""
        all_ids = self._db.all_memory_ids()
        total = len(all_ids)
        valid_count = 0
        invalid_results: list[dict] = []

        for i in range(0, total, batch_size):
            batch = all_ids[i:i + batch_size]
            for mid in batch:
                r = self.verify_memory(mid)
                if r["valid"]:
                    valid_count += 1
                else:
                    invalid_results.append(r)

        return {
            "total": total,
            "valid": valid_count,
            "invalid": len(invalid_results),
            "invalid_results": invalid_results,
        }

    # ============================================================
    # Auto Extraction (Phase 8)
    # ============================================================

    def remember(
        self,
        messages: list[dict],
        user_id: str | None = None,
        session_id: str | None = None,
        tags: list[str] | None = None,
        metadata: dict | None = None,
        custom_extraction_prompt: str | None = None,
        auto_resolve_conflicts: bool = True,
        dry_run: bool = False,
        source_trust: str = DEFAULT_SOURCE_TRUST,
        expires_at: str | None = None,
    ) -> dict:
        """Extract memories from conversation messages using LLM pipeline.

        Uses ExtractionPipeline for LLM-based fact extraction and conflict
        resolution. Falls back gracefully when AI client is a stub.
        """
        # Early return for empty/invalid messages (avoid unnecessary LLM calls)
        valid_messages = [
            m for m in (messages or [])
            if isinstance(m, dict) and m.get("content", "").strip()
        ]
        if not valid_messages:
            result = {
                "extracted": [], "entities": [], "relations": [],
                "stats": {
                    "total_extracted": 0, "added": 0, "updated": 0, "skipped": 0,
                    "entities_added": 0, "entities_updated": 0, "relations_added": 0,
                },
            }
            return result

        from trustmem.extraction.pipeline import ExtractionPipeline

        # 1. Run extraction pipeline (Stages 1-3)
        pipeline = ExtractionPipeline(
            ai_client=self._ai,
            vectorstore=self._vs,
        )
        pipeline_result = pipeline.run(
            messages=valid_messages,
            user_id=user_id,
            custom_extraction_prompt=custom_extraction_prompt,
            auto_resolve_conflicts=auto_resolve_conflicts,
        )

        facts = pipeline_result.get("facts", [])
        extraction_failed = pipeline_result.get("extraction_failed", False)

        # 2. Commit facts
        add_tags = list(tags) if tags else ["auto_extracted", "remember"]
        add_metadata = dict(metadata) if metadata else {
            "extraction_model": "llm",
            "user_id": user_id,
            "session_id": session_id,
        }

        extracted, stats = self._commit_facts(
            facts=facts,
            tags=add_tags,
            metadata=add_metadata,
            approval_note="remember_auto_update",
            dry_run=dry_run,
            source_trust=source_trust,
            expires_at=expires_at,
            user_id=user_id,
        )

        # 3. Entity/Relation commit (Phase 8.5)
        entity_results, relation_results = self._commit_entities(
            pipeline_result=pipeline_result,
            user_id=user_id,
            dry_run=dry_run,
        )
        stats["entities_added"] = len([e for e in entity_results if e.get("is_new", True)])
        stats["entities_updated"] = len([e for e in entity_results if not e.get("is_new", True)])
        stats["relations_added"] = len(relation_results)

        result = {
            "extracted": extracted,
            "entities": entity_results,
            "relations": relation_results,
            "stats": stats,
        }
        if extraction_failed:
            result["extraction_failed"] = True
        return result

    def _add_fact_memory(
        self,
        content: str,
        *,
        add_tags: list[str],
        add_metadata: dict,
        confidence: str,
        source_trust: str = DEFAULT_SOURCE_TRUST,
        expires_at: str | None = None,
        user_id: str | None = None,
    ) -> dict:
        """Store a single fact and return add_memory result."""
        precomputed_vec = None
        try:
            precomputed_vec = self._ai.embed(content)
        except Exception as e:
            logger.warning("Embedding failed for fact: %s", e)

        result = self.add_memory(
            content=content,
            tags=list(add_tags),
            metadata=dict(add_metadata),
            _precomputed_embedding=precomputed_vec,
            _confidence=confidence,
            _source_trust=source_trust,
            expires_at=expires_at,
            user_id=user_id,
            memory_type="semantic",
        )
        return result

    def _handle_contradict(
        self,
        fact: dict,
        content: str,
        conflict_with: str,
        *,
        add_tags: list[str],
        add_metadata: dict,
        source_trust: str = DEFAULT_SOURCE_TRUST,
        expires_at: str | None = None,
        user_id: str | None = None,
    ) -> dict:
        """Handle CONTRADICT action: store fact + record contradicts edge.

        Uses winner/loser_confidence from Stage 3 LLM, but source_trust
        hierarchy can override the LLM's winner decision:
        - If new.source_trust < existing.source_trust → force winner="existing"
        - If new.source_trust > existing.source_trust → force winner="new"
        - If equal → use LLM's judgment
        """
        winner = fact.get("winner", "unknown")
        loser_confidence = fact.get("loser_confidence", "medium")
        fact_confidence = fact.get("confidence", DEFAULT_CONFIDENCE)

        # Source trust hierarchy override
        existing_mem = self._db.get_memory(conflict_with)
        existing_trust = (
            existing_mem.get("source_trust", DEFAULT_SOURCE_TRUST)
            if existing_mem else DEFAULT_SOURCE_TRUST
        )
        new_rank = SOURCE_TRUST_HIERARCHY.get(source_trust, 0)
        existing_rank = SOURCE_TRUST_HIERARCHY.get(existing_trust, 0)

        source_trust_override = False
        if new_rank < existing_rank:
            # Lower trust source cannot override higher trust source
            if winner != "existing":
                winner = "existing"
                source_trust_override = True
                logger.info(
                    "Source trust override: forcing winner='existing' "
                    "(new=%s < existing=%s)", source_trust, existing_trust,
                )
        elif new_rank > existing_rank:
            # Higher trust source always wins
            if winner != "new":
                winner = "new"
                source_trust_override = True
                logger.info(
                    "Source trust override: forcing winner='new' "
                    "(new=%s > existing=%s)", source_trust, existing_trust,
                )

        # Determine new memory's confidence based on winner
        if winner == "existing":
            new_confidence = loser_confidence
        else:
            new_confidence = fact_confidence

        result = self._add_fact_memory(
            content,
            add_tags=add_tags, add_metadata=add_metadata,
            confidence=new_confidence, source_trust=source_trust,
            expires_at=expires_at,
            user_id=user_id,
        )

        # Record contradicts provenance edge
        now_iso = datetime.now(timezone.utc).isoformat()
        self._db.put_provenance({
            "provenance_id": str(uuid.uuid4()),
            "child_memory_id": result["memory_id"],
            "parent_memory_id": conflict_with,
            "user_id": user_id,
            "relationship": "contradicts",
            "created_at": now_iso,
        })

        # Build shared audit detail
        reason = fact.get("reason", "")
        original_winner = fact.get("winner", "unknown") if source_trust_override else None
        base_detail = {
            "opponent_memory_id": None,  # filled per-side
            "winner": winner,
            "role": None,  # filled per-side
            "reason": reason,
            "source_trust_override": source_trust_override,
            "original_winner": original_winner,
            "new_source_trust": source_trust if source_trust_override else None,
            "existing_source_trust": existing_trust if source_trust_override else None,
        }

        old_conf = (
            existing_mem.get("confidence", DEFAULT_CONFIDENCE)
            if existing_mem else DEFAULT_CONFIDENCE
        )

        if winner == "new":
            # Winner = new memory
            self.record_audit_trail(
                memory_id=result["memory_id"],
                event_type="contradict",
                triggered_by="contradict_resolution",
                detail={**base_detail, "role": "winner",
                        "opponent_memory_id": conflict_with},
                old_confidence=None, new_confidence=new_confidence,
            )
            # Loser = existing memory (downgrade confidence)
            if existing_mem:
                self._db.update_memory(conflict_with, {
                    "confidence": loser_confidence,
                    "last_evaluated_at": now_iso,
                    "updated_at": now_iso,
                })
                self.record_audit_trail(
                    memory_id=conflict_with,
                    event_type="contradict",
                    triggered_by="contradict_resolution",
                    detail={**base_detail, "role": "loser",
                            "opponent_memory_id": result["memory_id"]},
                    old_confidence=old_conf, new_confidence=loser_confidence,
                )

        elif winner == "existing":
            # Winner = existing memory
            self._db.update_memory(conflict_with, {
                "last_evaluated_at": now_iso,
            })
            self.record_audit_trail(
                memory_id=conflict_with,
                event_type="contradict",
                triggered_by="contradict_resolution",
                detail={**base_detail, "role": "winner",
                        "opponent_memory_id": result["memory_id"]},
                old_confidence=old_conf, new_confidence=old_conf,
            )
            # Loser = new memory
            self.record_audit_trail(
                memory_id=result["memory_id"],
                event_type="contradict",
                triggered_by="contradict_resolution",
                detail={**base_detail, "role": "loser",
                        "opponent_memory_id": conflict_with},
                old_confidence=None, new_confidence=loser_confidence,
            )

        else:  # unknown
            self.record_audit_trail(
                memory_id=result["memory_id"],
                event_type="contradict",
                triggered_by="contradict_resolution",
                detail={**base_detail, "role": "unknown",
                        "opponent_memory_id": conflict_with},
                old_confidence=None, new_confidence=new_confidence,
            )
            self.record_audit_trail(
                memory_id=conflict_with,
                event_type="contradict",
                triggered_by="contradict_resolution",
                detail={**base_detail, "role": "unknown",
                        "opponent_memory_id": result["memory_id"]},
                old_confidence=old_conf, new_confidence=old_conf,
            )

        return {
            "memory_id": result["memory_id"],
            "content": content,
            "event": "CONTRADICT",
            "confidence": new_confidence,
            "source_trust": source_trust,
            "conflict_with": conflict_with,
            "winner": winner,
            "source_trust_override": source_trust_override,
            "reason": fact.get("reason", ""),
        }

    def _handle_add(
        self,
        fact: dict,
        content: str,
        *,
        add_tags: list[str],
        add_metadata: dict,
        dry_run: bool,
        conflict_with: str | None,
        source_trust: str = DEFAULT_SOURCE_TRUST,
        expires_at: str | None = None,
        user_id: str | None = None,
    ) -> dict:
        """Handle ADD action (also dry-run fallback for CONTRADICT)."""
        confidence = fact.get("confidence", DEFAULT_CONFIDENCE)

        if dry_run:
            event_name = "CONTRADICT" if fact.get("action") == "CONTRADICT" else "ADD"
            return {
                "memory_id": None,
                "content": content,
                "event": event_name,
                "confidence": confidence,
                "source_trust": source_trust,
                "conflict_with": conflict_with,
                "reason": fact.get("reason", ""),
            }

        result = self._add_fact_memory(
            content,
            add_tags=add_tags, add_metadata=add_metadata,
            confidence=confidence, source_trust=source_trust,
            expires_at=expires_at,
            user_id=user_id,
        )
        return {
            "memory_id": result["memory_id"],
            "content": content,
            "event": "ADD",
            "confidence": result.get("confidence", confidence),
            "source_trust": source_trust,
            "conflict_with": conflict_with,
            "reason": fact.get("reason", ""),
        }

    def _commit_facts(
        self,
        facts: list[dict],
        *,
        tags: list[str] | None = None,
        metadata: dict | None = None,
        approval_note: str = "auto_update",
        dry_run: bool = False,
        source_trust: str = DEFAULT_SOURCE_TRUST,
        expires_at: str | None = None,
        user_id: str | None = None,
    ) -> tuple[list[dict], dict]:
        """Commit extracted facts to storage.

        Called by remember() after LLM extraction.
        Returns (extracted_list, stats_dict).
        """
        extracted: list[dict] = []
        stats = {"total_extracted": 0, "added": 0, "skipped": 0, "contradicted": 0}
        add_tags = tags or ["auto_extracted"]
        add_metadata = metadata or {}

        for fact in facts:
            action = fact.get("action", "ADD")
            content = fact.get("content", "")
            conflict_with = fact.get("conflict_with")

            # NOOP
            if action == "NOOP":
                stats["total_extracted"] += 1
                stats["skipped"] += 1
                extracted.append({
                    "memory_id": None,
                    "content": content,
                    "event": "NOOP",
                    "confidence": None,
                    "conflict_with": conflict_with,
                    "reason": fact.get("reason", ""),
                })
                continue

            # CONTRADICT
            if action == "CONTRADICT" and not conflict_with:
                logger.warning(
                    "LLM returned CONTRADICT but conflict_with is None; falling through to ADD"
                )
            if action == "CONTRADICT" and conflict_with and not dry_run:
                record = self._handle_contradict(
                    fact, content, conflict_with,
                    add_tags=add_tags, add_metadata=add_metadata,
                    source_trust=source_trust,
                    expires_at=expires_at,
                    user_id=user_id,
                )
                stats["total_extracted"] += 1
                stats["contradicted"] += 1
                extracted.append(record)
                continue

            # ADD (default fallback)
            record = self._handle_add(
                fact, content,
                add_tags=add_tags, add_metadata=add_metadata,
                dry_run=dry_run, conflict_with=conflict_with,
                source_trust=source_trust,
                expires_at=expires_at,
                user_id=user_id,
            )
            stats["total_extracted"] += 1
            stats["added"] += 1
            extracted.append(record)

        return extracted, stats

    def _commit_entities(
        self,
        pipeline_result: dict,
        user_id: str | None = None,
        *,
        source_memory_id: str | None = None,
        dry_run: bool = False,
    ) -> tuple[list[dict], list[dict]]:
        """Commit extracted entities and relations to the graph store.

        Returns (entity_results, relation_results).
        """
        entity_results: list[dict] = []
        relation_results: list[dict] = []
        entities = pipeline_result.get("entities", [])
        relations = pipeline_result.get("relations", [])

        if dry_run or not entities:
            return entity_results, relation_results

        entity_map: dict[str, str] = {}
        entity_canonical: dict[str, str] = {}
        for ent in entities:
            ent_result = self.add_entity(
                name=ent["name"],
                entity_type=ent.get("type", "other"),
                source_memory_id=source_memory_id,
                user_id=user_id,
            )
            key = ent["name"].lower()
            entity_map[key] = ent_result["entity_id"]
            entity_canonical[key] = ent["name"]
            entity_results.append(ent_result)

        for rel in relations:
            subj_key = rel.get("subject", "").lower()
            obj_key = rel.get("object", "").lower()
            if subj_key in entity_map and obj_key in entity_map:
                rel_result = self.add_entity_edge(
                    subject_name=entity_canonical[subj_key],
                    subject_type="other",
                    predicate=rel.get("predicate", ""),
                    object_name=entity_canonical[obj_key],
                    object_type="other",
                    source_memory_id=source_memory_id,
                    user_id=user_id,
                )
                relation_results.append(rel_result)

        return entity_results, relation_results

    # ============================================================
    # Knowledge Graph (Phase 8.5)
    # ============================================================

    def add_entity(
        self,
        name: str,
        entity_type: str = "other",
        source_memory_id: str | None = None,
        metadata: dict | None = None,
        user_id: str | None = None,
    ) -> dict:

        entity_id = compute_entity_id(name, entity_type, user_id)
        existing = self._gs.get_node(entity_id)

        if existing and existing.get("is_active", True):
            existing["mention_count"] = existing.get("mention_count", 1) + 1
            existing["confidence"] = determine_entity_confidence(existing["mention_count"])
            existing["updated_at"] = datetime.now(timezone.utc).isoformat()
            self._gs.put_node(existing)
            return {**existing, "is_new": False}

        node = build_entity_node(
            name=name, entity_type=entity_type,
            user_id=user_id,
            source_memory_id=source_memory_id,
            metadata=metadata,
        )
        self._gs.put_node(node)
        return {**node, "is_new": True}

    def add_entity_edge(
        self,
        subject_name: str,
        subject_type: str = "other",
        predicate: str = "",
        object_name: str = "",
        object_type: str = "other",
        source_memory_id: str | None = None,
        metadata: dict | None = None,
        user_id: str | None = None,
    ) -> dict:

        subj = self.add_entity(
            name=subject_name, entity_type=subject_type,
            source_memory_id=source_memory_id,
            user_id=user_id,
        )
        obj = self.add_entity(
            name=object_name, entity_type=object_type,
            source_memory_id=source_memory_id,
            user_id=user_id,
        )

        normalized_pred = normalize_predicate(predicate)
        edge_id = compute_edge_id(subj["entity_id"], normalized_pred, obj["entity_id"])
        existing = self._gs.get_edge(edge_id)

        if existing and existing.get("is_active", True):
            existing["mention_count"] = existing.get("mention_count", 1) + 1
            existing["confidence"] = determine_entity_confidence(existing["mention_count"])
            existing["updated_at"] = datetime.now(timezone.utc).isoformat()
            self._gs.put_edge(existing)
            return {**existing, "is_new": False}

        edge = build_entity_edge(
            subject_id=subj["entity_id"], subject_name=subject_name,
            predicate=predicate, object_id=obj["entity_id"],
            object_name=object_name,
            user_id=user_id,
            source_memory_id=source_memory_id,
            metadata=metadata,
        )
        self._gs.put_edge(edge)
        return {**edge, "is_new": True}

    def search_entity_relations(
        self,
        entity_name: str,
        predicate: str | None = None,
        direction: str = "both",
        limit: int = 20,
        partial_match: bool = False,
        user_id: str | None = None,
    ) -> list:

        return self._gs.search_edges(
            user_id=user_id,
            entity_name=entity_name,
            predicate=normalize_predicate(predicate) if predicate else None,
            direction=direction,
            limit=limit,
            partial_match=partial_match,
        )

    def get_entity_graph(
        self,
        entity_name: str,
        hops: int = 2,
        user_id: str | None = None,
    ) -> dict:

        hops = max(1, min(hops, 3))
        nodes, edges = self._gs.traverse(
            user_id=user_id,
            start_name=entity_name,
            max_hops=hops,
        )
        return {
            "center": entity_name,
            "hops": hops,
            "nodes": nodes,
            "edges": edges,
            "node_count": len(nodes),
            "edge_count": len(edges),
        }

    def delete_entity(self, entity_id: str) -> bool:
        return self._gs.delete_node(entity_id)

    def delete_entity_edge(self, edge_id: str) -> bool:
        return self._gs.delete_edge(edge_id)

    def list_entity_edges(self, user_id: str | None = None, limit: int = 100) -> list[dict]:
        """List all active edges for an org+user scope."""
        return self._gs.list_edges(user_id=user_id, limit=limit)

    def get_entity_stats(self, user_id: str | None = None) -> dict:

        return self._gs.get_stats(user_id)

    # ============================================================
    # Store management
    # ============================================================

    def reset_stores(self) -> None:
        """Clear all stores."""
        self._db.clear_all()
        with self._embedding_cache_lock:
            self._embedding_cache.clear()
