# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License - see LICENSE file for details

"""
MemoryTree — unified memory persistence for Familiar.

Canonical memory storage with SQLite FTS5 indexing. Memories are stored
as entries in a SQLite database with full-text search, soft deletion via
temporal validity windows, and optional on-disk markdown file backing.

Usage:
    from familiar.core.memory_tree import get_memory_tree

    tree = get_memory_tree()
    entry = tree.store(
        title="User prefers dark roast coffee",
        body="Mentioned during morning briefing that they only drink dark roast.",
        tier="long",
        tags=["preference", "food"],
        source="conversation:abc123",
    )
    results = tree.search("coffee preference")
"""

import atexit
import base64
import hashlib
import logging
import re
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)


class MemoryTier(str, Enum):
    """Memory tiers — maps to cognitive science tiered memory model."""

    WORKING = "working"  # Always in system prompt (curated facts)
    SHORT = "short"  # Session conversation buffer
    LONG = "long"  # Consolidated knowledge, auto-retrieved
    ARCHIVAL = "archival"  # Raw history, explicit search only


class MemoryStatus(str, Enum):
    """Lifecycle status for a memory entry."""

    ACTIVE = "active"
    INVALIDATED = "invalidated"  # Soft-deleted via temporal validity


class RelationType(str, Enum):
    """Typed relationships between memories."""

    RELATED = "related"
    SUPERSEDES = "supersedes"  # New version replaces old
    CONTRADICTS = "contradicts"  # Conflicting information
    DERIVED_FROM = "derived_from"  # Extracted/summarized from source
    CITES = "cites"  # References another memory
    PARENT = "parent"  # Hierarchical parent
    CHILD = "child"  # Hierarchical child


# Slug sanitization
_SLUG_RE = re.compile(r"[^\w\-]", re.ASCII)


def _utcnow() -> str:
    return datetime.now(timezone.utc).isoformat()


def _make_slug(title: str) -> str:
    """Convert a title to a filesystem-safe slug."""
    slug = title.lower().strip()
    slug = slug.replace(" ", "-")
    slug = _SLUG_RE.sub("", slug)
    slug = re.sub(r"-{2,}", "-", slug).strip("-")
    return slug[:120] or "untitled"


def _content_hash(title: str, body: str) -> str:
    """SHA-256 hash of title+body for deduplication."""
    return hashlib.sha256(f"{title}\n{body}".encode("utf-8")).hexdigest()


@dataclass
class MemoryEntry:
    """A single memory in the tree."""

    memory_id: str
    tier: str
    title: str
    body: str
    content_hash: str
    source: str = ""
    confidence: float = 1.0
    importance: float = 0.5
    created_at: str = ""
    updated_at: str = ""
    valid_from: str = ""
    valid_to: Optional[str] = None  # None = currently valid
    status: str = "active"
    user_id: str = "default"
    file_path: Optional[str] = None  # Path to backing .md file
    tags: list[str] = field(default_factory=list)
    meta: dict[str, str] = field(default_factory=dict)

    def is_valid(self) -> bool:
        """Check if this memory is currently valid."""
        if self.status == MemoryStatus.INVALIDATED:
            return False
        if self.valid_to is not None:
            return self.valid_to > _utcnow()
        return True

    def to_dict(self) -> dict:
        return asdict(self)


# ── Schema ──

_SCHEMA = """
CREATE TABLE IF NOT EXISTS memories (
    memory_id    TEXT PRIMARY KEY,
    tier         TEXT NOT NULL DEFAULT 'long',
    title        TEXT NOT NULL,
    body         TEXT NOT NULL DEFAULT '',
    content_hash TEXT NOT NULL,
    source       TEXT DEFAULT '',
    confidence   REAL DEFAULT 1.0,
    importance   REAL DEFAULT 0.5,
    created_at   TEXT NOT NULL,
    updated_at   TEXT NOT NULL,
    valid_from   TEXT NOT NULL,
    valid_to     TEXT,
    status       TEXT NOT NULL DEFAULT 'active',
    user_id      TEXT NOT NULL DEFAULT 'default',
    file_path    TEXT
);

CREATE INDEX IF NOT EXISTS idx_mem_tier ON memories(tier);
CREATE INDEX IF NOT EXISTS idx_mem_user ON memories(user_id);
CREATE INDEX IF NOT EXISTS idx_mem_status ON memories(status);
CREATE INDEX IF NOT EXISTS idx_mem_hash ON memories(content_hash);
CREATE INDEX IF NOT EXISTS idx_mem_importance ON memories(importance DESC);

CREATE TABLE IF NOT EXISTS memory_tags (
    memory_id TEXT NOT NULL,
    tag       TEXT NOT NULL,
    PRIMARY KEY (memory_id, tag),
    FOREIGN KEY (memory_id) REFERENCES memories(memory_id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_tags_tag ON memory_tags(tag);

CREATE TABLE IF NOT EXISTS memory_meta (
    memory_id TEXT NOT NULL,
    key       TEXT NOT NULL,
    value     TEXT NOT NULL DEFAULT '',
    PRIMARY KEY (memory_id, key),
    FOREIGN KEY (memory_id) REFERENCES memories(memory_id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS memory_edges (
    from_id       TEXT NOT NULL,
    to_id         TEXT NOT NULL,
    relation_type TEXT NOT NULL DEFAULT 'related',
    weight        REAL DEFAULT 1.0,
    valid_from    TEXT,
    valid_to      TEXT,
    created_at    TEXT NOT NULL,
    PRIMARY KEY (from_id, to_id, relation_type),
    FOREIGN KEY (from_id) REFERENCES memories(memory_id) ON DELETE CASCADE,
    FOREIGN KEY (to_id) REFERENCES memories(memory_id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_edges_to ON memory_edges(to_id);
CREATE INDEX IF NOT EXISTS idx_edges_type ON memory_edges(relation_type);
"""

# FTS5 — created separately because CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS
# in all SQLite builds; we catch the "already exists" error instead.
_FTS_SCHEMA = """
CREATE VIRTUAL TABLE memory_fts USING fts5(
    title,
    body,
    tags_text,
    content=memories,
    content_rowid=rowid,
    tokenize='porter unicode61'
);
"""

# Triggers to keep FTS in sync with the content table.
_FTS_TRIGGERS = """
CREATE TRIGGER IF NOT EXISTS memory_fts_insert AFTER INSERT ON memories BEGIN
    INSERT INTO memory_fts(rowid, title, body, tags_text)
    VALUES (new.rowid, new.title, new.body, '');
END;

CREATE TRIGGER IF NOT EXISTS memory_fts_delete AFTER DELETE ON memories BEGIN
    INSERT INTO memory_fts(memory_fts, rowid, title, body, tags_text)
    VALUES ('delete', old.rowid, old.title, old.body, '');
END;

CREATE TRIGGER IF NOT EXISTS memory_fts_update AFTER UPDATE ON memories BEGIN
    INSERT INTO memory_fts(memory_fts, rowid, title, body, tags_text)
    VALUES ('delete', old.rowid, old.title, old.body, '');
    INSERT INTO memory_fts(rowid, title, body, tags_text)
    VALUES (new.rowid, new.title, new.body, '');
END;
"""


class MemoryTree:
    """Unified memory persistence with FTS5 full-text search.

    Stores memories in SQLite with:
    - Four tiers (working, short, long, archival)
    - FTS5 full-text search on title + body + tags
    - Tag-based organization
    - Arbitrary key-value metadata
    - Soft deletion via temporal validity (valid_to)
    - Content-hash deduplication
    - Thread-safe via reentrant lock
    """

    def __init__(self, db_path: Optional[Path] = None, encrypt: bool = False):
        if db_path is None:
            from familiar.core.paths import DATA_DIR

            db_path = DATA_DIR / "memory_tree.db"
        self._db_path = db_path
        self._db_path.parent.mkdir(parents=True, exist_ok=True)
        self._encrypt = encrypt
        self._lock = threading.RLock()
        self._storage = None  # lazy-init EncryptedStorage
        self._decrypt_on_open()
        self._init_db()
        if self._encrypt:
            atexit.register(self.shutdown)

    # ── Encryption at Rest (Sprint 7) ──

    def _get_encrypted_storage(self):
        """Lazy-init the EncryptedStorage singleton."""
        if self._storage is None:
            try:
                from familiar.core.encryption import EncryptedStorage

                self._storage = EncryptedStorage()
            except Exception:
                self._storage = None
        return self._storage

    def _encrypted_path(self) -> Path:
        """Path to the encrypted copy of the DB."""
        return self._db_path.with_suffix(".db.enc")

    def _decrypt_on_open(self):
        """If an encrypted DB exists and plaintext doesn't, decrypt it."""
        enc_path = self._encrypted_path()
        if enc_path.exists() and not self._db_path.exists():
            storage = self._get_encrypted_storage()
            if storage and storage.is_available:
                try:
                    encrypted = enc_path.read_text()
                    b64 = storage.decrypt(encrypted)
                    db_bytes = base64.b64decode(b64)
                    self._db_path.write_bytes(db_bytes)
                    enc_path.unlink()
                    logger.info("Decrypted memory tree DB on startup")
                except Exception as e:
                    logger.warning("Failed to decrypt memory tree DB: %s", e)

    def shutdown(self):
        """Encrypt the DB file at rest and remove the plaintext copy.

        Called via atexit when encrypt=True, or manually before
        long shutdown periods.
        """
        if not self._encrypt:
            return
        storage = self._get_encrypted_storage()
        if not storage or not storage.is_available:
            logger.debug("Encryption not available — skipping DB encryption")
            return
        with self._lock:
            if not self._db_path.exists():
                return
            try:
                enc_path = self._encrypted_path()
                # Base64-encode first — SQLite DBs are binary, not valid UTF-8
                db_bytes = self._db_path.read_bytes()
                b64 = base64.b64encode(db_bytes).decode("ascii")
                encrypted = storage.encrypt(b64)
                enc_path.write_text(encrypted)
                enc_path.chmod(0o600)
                self._db_path.unlink()
                logger.info("Encrypted memory tree DB at shutdown")
            except Exception as e:
                logger.warning("Failed to encrypt memory tree DB: %s", e)

    def _init_db(self):
        with self._lock:
            conn = sqlite3.connect(str(self._db_path))
            conn.executescript(_SCHEMA)
            # Create FTS5 virtual table (may already exist)
            try:
                conn.executescript(_FTS_SCHEMA)
            except sqlite3.OperationalError:
                pass  # Already exists
            # Create sync triggers
            conn.executescript(_FTS_TRIGGERS)
            conn.close()

    def _connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(str(self._db_path))
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA foreign_keys=ON")
        return conn

    # ── Store ──

    def store(
        self,
        title: str,
        body: str = "",
        tier: str = "long",
        tags: Optional[list[str]] = None,
        meta: Optional[dict[str, str]] = None,
        source: str = "",
        confidence: float = 1.0,
        importance: float = 0.5,
        user_id: str = "default",
        file_path: Optional[str] = None,
        memory_id: Optional[str] = None,
    ) -> MemoryEntry:
        """Store a new memory entry.

        Args:
            title: Short descriptive title (indexed for search)
            body: Full content (indexed for search)
            tier: Memory tier (working/short/long/archival)
            tags: List of tag strings
            meta: Arbitrary key-value metadata
            source: Provenance string (e.g. "conversation:abc123")
            confidence: 0.0-1.0 extraction confidence
            importance: 0.0-1.0 importance score
            user_id: Owner user identifier
            file_path: Optional path to backing .md file
            memory_id: Optional explicit ID (auto-generated if omitted)

        Returns:
            The stored MemoryEntry
        """
        if not title.strip():
            raise ValueError("Memory title cannot be empty")

        # Validate tier
        try:
            MemoryTier(tier)
        except ValueError:
            raise ValueError(
                f"Invalid tier '{tier}'. Must be one of: {', '.join(t.value for t in MemoryTier)}"
            )

        tags = tags or []
        meta = meta or {}
        now = _utcnow()
        mid = memory_id or str(uuid.uuid4())
        chash = _content_hash(title, body)
        tags_text = " ".join(tags)

        entry = MemoryEntry(
            memory_id=mid,
            tier=tier,
            title=title.strip(),
            body=body,
            content_hash=chash,
            source=source,
            confidence=confidence,
            importance=importance,
            created_at=now,
            updated_at=now,
            valid_from=now,
            valid_to=None,
            status=MemoryStatus.ACTIVE,
            user_id=user_id,
            file_path=file_path,
            tags=list(tags),
            meta=dict(meta),
        )

        with self._lock:
            conn = self._connect()
            try:
                conn.execute(
                    """INSERT INTO memories
                    (memory_id, tier, title, body, content_hash, source,
                     confidence, importance, created_at, updated_at,
                     valid_from, valid_to, status, user_id, file_path)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                    (
                        mid,
                        tier,
                        entry.title,
                        body,
                        chash,
                        source,
                        confidence,
                        importance,
                        now,
                        now,
                        now,
                        None,
                        MemoryStatus.ACTIVE,
                        user_id,
                        file_path,
                    ),
                )
                # Update FTS tags_text (trigger inserted empty string)
                if tags_text:
                    rowid = conn.execute(
                        "SELECT rowid FROM memories WHERE memory_id = ?", (mid,)
                    ).fetchone()[0]
                    conn.execute(
                        """INSERT INTO memory_fts(memory_fts, rowid, title, body, tags_text)
                        VALUES ('delete', ?, ?, ?, '')""",
                        (rowid, entry.title, body),
                    )
                    conn.execute(
                        """INSERT INTO memory_fts(rowid, title, body, tags_text)
                        VALUES (?, ?, ?, ?)""",
                        (rowid, entry.title, body, tags_text),
                    )
                # Insert tags
                for tag in tags:
                    conn.execute(
                        "INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)",
                        (mid, tag.strip().lower()),
                    )
                # Insert metadata
                for k, v in meta.items():
                    conn.execute(
                        "INSERT OR REPLACE INTO memory_meta (memory_id, key, value) "
                        "VALUES (?, ?, ?)",
                        (mid, k, str(v)),
                    )
                conn.commit()
            except Exception:
                conn.rollback()
                raise
            finally:
                conn.close()

        logger.debug("Stored memory %s: %s [%s]", mid, title[:50], tier)
        return entry

    # ── Retrieve ──

    def get(self, memory_id: str) -> Optional[MemoryEntry]:
        """Get a memory by ID, including tags and metadata."""
        with self._lock:
            conn = self._connect()
            try:
                row = conn.execute(
                    "SELECT * FROM memories WHERE memory_id = ?", (memory_id,)
                ).fetchone()
                if not row:
                    return None
                return self._row_to_entry(conn, row)
            finally:
                conn.close()

    def get_by_hash(self, chash: str) -> Optional[MemoryEntry]:
        """Find an active memory by content hash (for dedup)."""
        with self._lock:
            conn = self._connect()
            try:
                row = conn.execute(
                    "SELECT * FROM memories WHERE content_hash = ? AND status = 'active' LIMIT 1",
                    (chash,),
                ).fetchone()
                if not row:
                    return None
                return self._row_to_entry(conn, row)
            finally:
                conn.close()

    def list_by_tier(
        self,
        tier: str,
        user_id: Optional[str] = None,
        include_expired: bool = False,
        limit: int = 100,
    ) -> list[MemoryEntry]:
        """List memories in a specific tier."""
        with self._lock:
            conn = self._connect()
            try:
                sql = "SELECT * FROM memories WHERE tier = ?"
                params: list = [tier]
                if user_id:
                    sql += " AND user_id = ?"
                    params.append(user_id)
                if not include_expired:
                    sql += " AND status = 'active'"
                sql += " ORDER BY importance DESC, updated_at DESC LIMIT ?"
                params.append(limit)
                rows = conn.execute(sql, params).fetchall()
                return [self._row_to_entry(conn, r) for r in rows]
            finally:
                conn.close()

    def list_by_tag(
        self,
        tag: str,
        tier: Optional[str] = None,
        include_expired: bool = False,
        limit: int = 50,
    ) -> list[MemoryEntry]:
        """List memories with a specific tag."""
        with self._lock:
            conn = self._connect()
            try:
                sql = (
                    "SELECT m.* FROM memories m "
                    "JOIN memory_tags t ON m.memory_id = t.memory_id "
                    "WHERE t.tag = ?"
                )
                params: list = [tag.strip().lower()]
                if tier:
                    sql += " AND m.tier = ?"
                    params.append(tier)
                if not include_expired:
                    sql += " AND m.status = 'active'"
                sql += " ORDER BY m.importance DESC, m.updated_at DESC LIMIT ?"
                params.append(limit)
                rows = conn.execute(sql, params).fetchall()
                return [self._row_to_entry(conn, r) for r in rows]
            finally:
                conn.close()

    # ── Search (FTS5) ──

    def search(
        self,
        query: str,
        tier: Optional[str] = None,
        tags: Optional[list[str]] = None,
        user_id: Optional[str] = None,
        include_expired: bool = False,
        limit: int = 20,
    ) -> list[MemoryEntry]:
        """Full-text search across memory title, body, and tags.

        Uses FTS5 with BM25 ranking. Falls back to LIKE search if
        the FTS query syntax is invalid.

        Args:
            query: Search query (FTS5 syntax supported)
            tier: Filter to specific tier
            tags: Filter to entries with ALL of these tags
            user_id: Filter to specific user
            include_expired: Include invalidated/expired entries
            limit: Maximum results

        Returns:
            List of matching MemoryEntry objects, ranked by relevance
        """
        if not query.strip():
            if tags:
                # Tag-filtered listing without FTS
                with self._lock:
                    conn = self._connect()
                    try:
                        return self._like_search(
                            conn,
                            "",
                            tier,
                            tags,
                            user_id,
                            include_expired,
                            limit,
                        )
                    finally:
                        conn.close()
            return self.list_by_tier(
                tier or "long",
                user_id=user_id,
                include_expired=include_expired,
                limit=limit,
            )

        with self._lock:
            conn = self._connect()
            try:
                results = self._fts_search(
                    conn,
                    query,
                    tier,
                    tags,
                    user_id,
                    include_expired,
                    limit,
                )
                if results is not None:
                    # Apply graph-aware reranking if edges exist
                    if self._pagerank_cache or self._has_edges(conn):
                        results = self._apply_graph_reranking(results)
                    return results
                # Fallback to LIKE search
                return self._like_search(
                    conn,
                    query,
                    tier,
                    tags,
                    user_id,
                    include_expired,
                    limit,
                )
            finally:
                conn.close()

    def _fts_search(
        self,
        conn,
        query,
        tier,
        tags,
        user_id,
        include_expired,
        limit,
    ) -> Optional[list[MemoryEntry]]:
        """Try FTS5 search. Returns None if query syntax is invalid."""
        try:
            sql = (
                "SELECT m.*, rank FROM memories m "
                "JOIN memory_fts ON m.rowid = memory_fts.rowid "
                "WHERE memory_fts MATCH ?"
            )
            params: list = [query]
            if tier:
                sql += " AND m.tier = ?"
                params.append(tier)
            if user_id:
                sql += " AND m.user_id = ?"
                params.append(user_id)
            if not include_expired:
                sql += " AND m.status = 'active'"
            if tags:
                for tag in tags:
                    sql += " AND m.memory_id IN (SELECT memory_id FROM memory_tags WHERE tag = ?)"
                    params.append(tag.strip().lower())
            sql += " ORDER BY rank LIMIT ?"
            params.append(limit)
            rows = conn.execute(sql, params).fetchall()
            return [self._row_to_entry(conn, r) for r in rows]
        except sqlite3.OperationalError:
            return None

    def _like_search(
        self,
        conn,
        query,
        tier,
        tags,
        user_id,
        include_expired,
        limit,
    ) -> list[MemoryEntry]:
        """Fallback substring search when FTS query is invalid."""
        if query:
            like = f"%{query}%"
            sql = "SELECT * FROM memories WHERE (title LIKE ? OR body LIKE ?)"
            params: list = [like, like]
        else:
            sql = "SELECT * FROM memories WHERE 1=1"
            params: list = []
        if tier:
            sql += " AND tier = ?"
            params.append(tier)
        if user_id:
            sql += " AND user_id = ?"
            params.append(user_id)
        if not include_expired:
            sql += " AND status = 'active'"
        if tags:
            for tag in tags:
                sql += " AND memory_id IN (SELECT memory_id FROM memory_tags WHERE tag = ?)"
                params.append(tag.strip().lower())
        sql += " ORDER BY importance DESC, updated_at DESC LIMIT ?"
        params.append(limit)
        rows = conn.execute(sql, params).fetchall()
        return [self._row_to_entry(conn, r) for r in rows]

    # ── Update ──

    def update(
        self,
        memory_id: str,
        title: Optional[str] = None,
        body: Optional[str] = None,
        importance: Optional[float] = None,
        confidence: Optional[float] = None,
        tier: Optional[str] = None,
        tags: Optional[list[str]] = None,
        meta: Optional[dict[str, str]] = None,
    ) -> Optional[MemoryEntry]:
        """Update a memory entry. Only supplied fields are changed.

        Returns the updated entry, or None if not found.
        """
        with self._lock:
            conn = self._connect()
            try:
                row = conn.execute(
                    "SELECT * FROM memories WHERE memory_id = ?", (memory_id,)
                ).fetchone()
                if not row:
                    return None

                now = _utcnow()
                updates = {"updated_at": now}
                if title is not None:
                    updates["title"] = title.strip()
                if body is not None:
                    updates["body"] = body
                if importance is not None:
                    updates["importance"] = importance
                if confidence is not None:
                    updates["confidence"] = confidence
                if tier is not None:
                    MemoryTier(tier)  # Validate
                    updates["tier"] = tier

                # Recompute hash if content changed
                new_title = updates.get("title", row["title"])
                new_body = updates.get("body", row["body"])
                updates["content_hash"] = _content_hash(new_title, new_body)

                set_clause = ", ".join(f"{k} = ?" for k in updates)
                params = list(updates.values()) + [memory_id]
                conn.execute(
                    f"UPDATE memories SET {set_clause} WHERE memory_id = ?",
                    params,
                )

                # Update tags if provided
                if tags is not None:
                    conn.execute(
                        "DELETE FROM memory_tags WHERE memory_id = ?",
                        (memory_id,),
                    )
                    for tag in tags:
                        conn.execute(
                            "INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)",
                            (memory_id, tag.strip().lower()),
                        )

                # Update metadata if provided
                if meta is not None:
                    conn.execute(
                        "DELETE FROM memory_meta WHERE memory_id = ?",
                        (memory_id,),
                    )
                    for k, v in meta.items():
                        conn.execute(
                            "INSERT OR REPLACE INTO memory_meta "
                            "(memory_id, key, value) VALUES (?, ?, ?)",
                            (memory_id, k, str(v)),
                        )

                conn.commit()
                return self._row_to_entry(
                    conn,
                    conn.execute(
                        "SELECT * FROM memories WHERE memory_id = ?",
                        (memory_id,),
                    ).fetchone(),
                )
            except Exception:
                conn.rollback()
                raise
            finally:
                conn.close()

    # ── Soft Delete ──

    def soft_delete(self, memory_id: str) -> bool:
        """Soft-delete a memory by setting valid_to and status.

        Never hard-deletes — the record stays for audit trail
        and temporal queries.

        Returns True if the memory was found and invalidated.
        """
        with self._lock:
            conn = self._connect()
            try:
                now = _utcnow()
                result = conn.execute(
                    "UPDATE memories SET valid_to = ?, status = ?, updated_at = ? "
                    "WHERE memory_id = ? AND status = 'active'",
                    (now, MemoryStatus.INVALIDATED, now, memory_id),
                )
                conn.commit()
                if result.rowcount > 0:
                    logger.debug("Soft-deleted memory %s", memory_id)
                    return True
                return False
            finally:
                conn.close()

    def hard_delete(self, memory_id: str) -> bool:
        """Permanently delete a memory. Use sparingly — prefer soft_delete.

        Returns True if the memory was found and deleted.
        """
        with self._lock:
            conn = self._connect()
            try:
                result = conn.execute("DELETE FROM memories WHERE memory_id = ?", (memory_id,))
                conn.commit()
                return result.rowcount > 0
            finally:
                conn.close()

    # ── Stats ──

    def stats(self, user_id: Optional[str] = None) -> dict:
        """Get memory statistics."""
        with self._lock:
            conn = self._connect()
            try:
                where = ""
                params: list = []
                if user_id:
                    where = " WHERE user_id = ?"
                    params = [user_id]

                total = conn.execute(f"SELECT COUNT(*) FROM memories{where}", params).fetchone()[0]

                active = conn.execute(
                    f"SELECT COUNT(*) FROM memories{where}"
                    + (" AND" if where else " WHERE")
                    + " status = 'active'",
                    params,
                ).fetchone()[0]

                by_tier = {}
                for tier in MemoryTier:
                    count = conn.execute(
                        f"SELECT COUNT(*) FROM memories{where}"
                        + (" AND" if where else " WHERE")
                        + " tier = ? AND status = 'active'",
                        params + [tier.value],
                    ).fetchone()[0]
                    by_tier[tier.value] = count

                tag_count = conn.execute("SELECT COUNT(DISTINCT tag) FROM memory_tags").fetchone()[
                    0
                ]

                return {
                    "total": total,
                    "active": active,
                    "invalidated": total - active,
                    "by_tier": by_tier,
                    "unique_tags": tag_count,
                }
            finally:
                conn.close()

    def all_tags(self, limit: int = 100) -> list[dict]:
        """Get all tags with their usage counts."""
        with self._lock:
            conn = self._connect()
            try:
                rows = conn.execute(
                    "SELECT tag, COUNT(*) as cnt FROM memory_tags "
                    "GROUP BY tag ORDER BY cnt DESC LIMIT ?",
                    (limit,),
                ).fetchall()
                return [{"tag": r["tag"], "count": r["cnt"]} for r in rows]
            finally:
                conn.close()

    # ── Deduplication ──

    def find_duplicate(self, title: str, body: str) -> Optional[MemoryEntry]:
        """Check if an active memory with the same content hash exists."""
        chash = _content_hash(title, body)
        return self.get_by_hash(chash)

    # ── Graph (Sprint 1) ──

    def link(
        self,
        from_id: str,
        to_id: str,
        relation_type: str = "related",
        weight: float = 1.0,
    ) -> bool:
        """Create a typed edge between two memories.

        Returns True if the edge was created, False if it already exists.
        """
        try:
            RelationType(relation_type)
        except ValueError:
            raise ValueError(
                f"Invalid relation_type '{relation_type}'. Must be one of: "
                f"{', '.join(r.value for r in RelationType)}"
            )

        with self._lock:
            conn = self._connect()
            try:
                conn.execute(
                    "INSERT OR IGNORE INTO memory_edges "
                    "(from_id, to_id, relation_type, weight, created_at) "
                    "VALUES (?, ?, ?, ?, ?)",
                    (from_id, to_id, relation_type, weight, _utcnow()),
                )
                conn.commit()
                changed = conn.total_changes > 0
                if changed:
                    self.invalidate_pagerank_cache()
                return changed
            finally:
                conn.close()

    def unlink(
        self,
        from_id: str,
        to_id: str,
        relation_type: Optional[str] = None,
    ) -> bool:
        """Remove edges between two memories.

        If relation_type is None, removes all edges between the pair.
        Returns True if any edges were removed.
        """
        with self._lock:
            conn = self._connect()
            try:
                if relation_type:
                    result = conn.execute(
                        "DELETE FROM memory_edges "
                        "WHERE from_id = ? AND to_id = ? AND relation_type = ?",
                        (from_id, to_id, relation_type),
                    )
                else:
                    result = conn.execute(
                        "DELETE FROM memory_edges WHERE from_id = ? AND to_id = ?",
                        (from_id, to_id),
                    )
                conn.commit()
                return result.rowcount > 0
            finally:
                conn.close()

    def get_neighbors(
        self,
        memory_id: str,
        relation_type: Optional[str] = None,
        direction: str = "both",
        include_expired: bool = False,
    ) -> list[dict]:
        """Get memories connected to this one via edges.

        Args:
            memory_id: The memory to find neighbors for
            relation_type: Filter by relation type
            direction: "outgoing", "incoming", or "both"
            include_expired: Include invalidated memories

        Returns:
            List of dicts with keys: memory (MemoryEntry), relation_type,
            weight, direction ("out"/"in")
        """
        neighbors = []
        with self._lock:
            conn = self._connect()
            try:
                if direction in ("outgoing", "both"):
                    sql = (
                        "SELECT e.*, m.* FROM memory_edges e "
                        "JOIN memories m ON e.to_id = m.memory_id "
                        "WHERE e.from_id = ?"
                    )
                    params: list = [memory_id]
                    if relation_type:
                        sql += " AND e.relation_type = ?"
                        params.append(relation_type)
                    if not include_expired:
                        sql += " AND m.status = 'active'"
                    for row in conn.execute(sql, params).fetchall():
                        neighbors.append(
                            {
                                "memory": self._row_to_entry(conn, row),
                                "relation_type": row["relation_type"],
                                "weight": row["weight"],
                                "direction": "out",
                            }
                        )

                if direction in ("incoming", "both"):
                    sql = (
                        "SELECT e.*, m.* FROM memory_edges e "
                        "JOIN memories m ON e.from_id = m.memory_id "
                        "WHERE e.to_id = ?"
                    )
                    params = [memory_id]
                    if relation_type:
                        sql += " AND e.relation_type = ?"
                        params.append(relation_type)
                    if not include_expired:
                        sql += " AND m.status = 'active'"
                    for row in conn.execute(sql, params).fetchall():
                        neighbors.append(
                            {
                                "memory": self._row_to_entry(conn, row),
                                "relation_type": row["relation_type"],
                                "weight": row["weight"],
                                "direction": "in",
                            }
                        )
            finally:
                conn.close()
        return neighbors

    def get_subgraph(
        self,
        memory_id: str,
        depth: int = 2,
        include_expired: bool = False,
    ) -> dict:
        """BFS traversal to build a subgraph around a memory.

        Returns:
            Dict with "nodes" (list of MemoryEntry) and
            "edges" (list of {from_id, to_id, relation_type, weight})
        """
        visited: set[str] = set()
        nodes: list[MemoryEntry] = []
        edge_set: set[tuple] = set()
        edges: list[dict] = []
        queue = [memory_id]

        root = self.get(memory_id)
        if root:
            nodes.append(root)
            visited.add(memory_id)

        for _level in range(depth):
            next_queue: list[str] = []
            for mid in queue:
                for neighbor in self.get_neighbors(mid, include_expired=include_expired):
                    n_id = neighbor["memory"].memory_id
                    edge_from = mid if neighbor["direction"] == "out" else n_id
                    edge_to = n_id if neighbor["direction"] == "out" else mid
                    edge_key = (edge_from, edge_to, neighbor["relation_type"])
                    if edge_key not in edge_set:
                        edge_set.add(edge_key)
                        edges.append(
                            {
                                "from_id": edge_from,
                                "to_id": edge_to,
                                "relation_type": neighbor["relation_type"],
                                "weight": neighbor["weight"],
                            }
                        )
                    if n_id not in visited:
                        visited.add(n_id)
                        nodes.append(neighbor["memory"])
                        next_queue.append(n_id)
            queue = next_queue

        return {"nodes": nodes, "edges": edges}

    def get_edges(self, memory_id: str) -> list[dict]:
        """Get all raw edges for a memory (both directions)."""
        with self._lock:
            conn = self._connect()
            try:
                rows = conn.execute(
                    "SELECT * FROM memory_edges WHERE from_id = ? OR to_id = ?",
                    (memory_id, memory_id),
                ).fetchall()
                return [
                    {
                        "from_id": r["from_id"],
                        "to_id": r["to_id"],
                        "relation_type": r["relation_type"],
                        "weight": r["weight"],
                        "created_at": r["created_at"],
                    }
                    for r in rows
                ]
            finally:
                conn.close()

    # ── PageRank (graph-aware search ranking) ──

    _pagerank_cache: Optional[dict[str, float]] = None
    _pagerank_cache_time: float = 0
    _PAGERANK_CACHE_TTL = 300  # 5 minutes

    def _compute_pagerank(
        self, damping: float = 0.85, iterations: int = 20
    ) -> dict[str, float]:
        """Compute PageRank scores from memory_edges graph.

        Uses iterative power method:
          PR(v) = (1-d)/N + d * sum(PR(u)/out_degree(u) for u → v)

        Caches results for 5 minutes. Invalidated on edge changes.

        Returns dict mapping memory_id → PageRank score (0.0 to 1.0 normalized).
        """
        import time as _time

        # Check cache
        if (
            self._pagerank_cache is not None
            and (_time.time() - self._pagerank_cache_time) < self._PAGERANK_CACHE_TTL
        ):
            return self._pagerank_cache

        conn = self._connect()
        try:
            edges = conn.execute(
                "SELECT from_id, to_id, weight FROM memory_edges"
            ).fetchall()

            if not edges:
                self._pagerank_cache = {}
                self._pagerank_cache_time = _time.time()
                return {}

            # Build adjacency: node → list of (target, weight)
            out_edges: dict[str, list[tuple[str, float]]] = {}
            all_nodes: set[str] = set()
            for row in edges:
                src, dst, w = row["from_id"], row["to_id"], row["weight"] or 1.0
                all_nodes.add(src)
                all_nodes.add(dst)
                out_edges.setdefault(src, []).append((dst, w))

            n = len(all_nodes)
            if n == 0:
                self._pagerank_cache = {}
                self._pagerank_cache_time = _time.time()
                return {}

            # Initialize scores
            scores: dict[str, float] = {node: 1.0 / n for node in all_nodes}

            # Iterative computation
            for _ in range(iterations):
                new_scores: dict[str, float] = {}
                for node in all_nodes:
                    incoming_sum = 0.0
                    # Find all nodes that point TO this node
                    for src, targets in out_edges.items():
                        for dst, w in targets:
                            if dst == node:
                                out_degree = sum(tw for _, tw in out_edges.get(src, []))
                                if out_degree > 0:
                                    incoming_sum += scores[src] * w / out_degree
                    new_scores[node] = (1.0 - damping) / n + damping * incoming_sum
                scores = new_scores

            # Normalize to 0-1 range
            max_score = max(scores.values()) if scores else 1.0
            if max_score > 0:
                scores = {k: v / max_score for k, v in scores.items()}

            self._pagerank_cache = scores
            self._pagerank_cache_time = _time.time()
            return scores
        finally:
            conn.close()

    def _has_edges(self, conn) -> bool:
        """Quick check if any edges exist (avoids full PageRank compute when empty)."""
        try:
            row = conn.execute("SELECT COUNT(*) FROM memory_edges LIMIT 1").fetchone()
            return row[0] > 0
        except Exception:
            return False

    def invalidate_pagerank_cache(self):
        """Invalidate the PageRank cache (call after adding/removing edges)."""
        self._pagerank_cache = None

    def _apply_graph_reranking(
        self,
        results: list["MemoryEntry"],
        alpha: float = 0.7,
    ) -> list["MemoryEntry"]:
        """Rerank search results by blending FTS BM25 score with PageRank.

        score = alpha * normalized_bm25 + (1 - alpha) * pagerank

        Args:
            results: FTS search results (already ranked by BM25)
            alpha: Weight for BM25 vs PageRank (0.7 = 70% text relevance, 30% graph importance)

        Returns:
            Results re-sorted by blended score.
        """
        if not results:
            return results

        pagerank = self._compute_pagerank()
        if not pagerank:
            return results  # No graph data — keep FTS ranking

        # Assign BM25 rank scores (FTS results are already sorted best-first)
        # Normalize: first result = 1.0, last = 0.0
        n = len(results)
        for i, entry in enumerate(results):
            bm25_normalized = 1.0 - (i / max(n - 1, 1))
            pr = pagerank.get(entry.memory_id, 0.0)
            entry._graph_score = alpha * bm25_normalized + (1 - alpha) * pr  # type: ignore[attr-defined]

        # Re-sort by blended score (highest first)
        results.sort(key=lambda e: getattr(e, "_graph_score", 0), reverse=True)
        return results

    # ── Temporal (Sprint 2) ──

    def invalidate(
        self,
        memory_id: str,
        reason: str = "",
        replacement_id: Optional[str] = None,
    ) -> bool:
        """Invalidate a memory with optional reason and replacement.

        If replacement_id is provided, creates a SUPERSEDES edge
        from the replacement to the invalidated memory.

        Returns True if the memory was invalidated.
        """
        success = self.soft_delete(memory_id)
        if success and reason:
            with self._lock:
                conn = self._connect()
                try:
                    conn.execute(
                        "INSERT OR REPLACE INTO memory_meta "
                        "(memory_id, key, value) VALUES (?, ?, ?)",
                        (memory_id, "invalidation_reason", reason),
                    )
                    conn.commit()
                finally:
                    conn.close()
        if success and replacement_id:
            self.link(
                replacement_id,
                memory_id,
                RelationType.SUPERSEDES.value,
                weight=1.0,
            )
        return success

    def update_fact(
        self,
        old_id: str,
        new_title: Optional[str] = None,
        new_body: Optional[str] = None,
        reason: str = "updated",
        **kwargs,
    ) -> Optional[MemoryEntry]:
        """Update a fact by invalidating the old version and creating a new one.

        Creates a SUPERSEDES edge from new → old. Preserves tags, meta,
        tier, and source from the original unless overridden.

        Returns the new entry, or None if old_id not found.
        """
        old = self.get(old_id)
        if not old:
            return None

        # Build new entry from old + overrides
        title = new_title if new_title is not None else old.title
        body = new_body if new_body is not None else old.body
        tags = kwargs.get("tags", old.tags)
        meta = kwargs.get("meta", old.meta)
        tier = kwargs.get("tier", old.tier)
        source = kwargs.get("source", old.source)
        importance = kwargs.get("importance", old.importance)

        new_entry = self.store(
            title=title,
            body=body,
            tier=tier,
            tags=tags,
            meta=meta,
            source=source,
            importance=importance,
            user_id=old.user_id,
        )

        self.invalidate(old_id, reason=reason, replacement_id=new_entry.memory_id)
        return new_entry

    def get_history(self, memory_id: str) -> list[MemoryEntry]:
        """Get the version chain for a memory via SUPERSEDES edges.

        Follows the SUPERSEDES chain backward (older versions) and
        forward (newer versions), returning all in chronological order.
        """
        chain: dict[str, MemoryEntry] = {}

        # Follow backward: who does this memory supersede?
        def _follow_back(mid: str):
            if mid in chain:
                return
            entry = self.get(mid)
            if not entry:
                return
            chain[mid] = entry
            for n in self.get_neighbors(
                mid,
                relation_type=RelationType.SUPERSEDES,
                direction="outgoing",
                include_expired=True,
            ):
                _follow_back(n["memory"].memory_id)

        # Follow forward: who supersedes this memory?
        def _follow_forward(mid: str):
            if mid in chain:
                return
            entry = self.get(mid)
            if not entry:
                return
            chain[mid] = entry
            for n in self.get_neighbors(
                mid,
                relation_type=RelationType.SUPERSEDES,
                direction="incoming",
                include_expired=True,
            ):
                _follow_forward(n["memory"].memory_id)

        _follow_back(memory_id)
        _follow_forward(memory_id)

        # Sort by created_at
        return sorted(chain.values(), key=lambda e: e.created_at)

    def get_timeline(
        self,
        start: str,
        end: str,
        user_id: Optional[str] = None,
        tier: Optional[str] = None,
        limit: int = 100,
    ) -> list[MemoryEntry]:
        """Get memories valid during a time range.

        Returns memories whose validity window overlaps [start, end].
        A memory is valid in the range if:
        - valid_from <= end AND (valid_to IS NULL OR valid_to >= start)
        """
        with self._lock:
            conn = self._connect()
            try:
                sql = (
                    "SELECT * FROM memories "
                    "WHERE valid_from <= ? AND (valid_to IS NULL OR valid_to >= ?)"
                )
                params: list = [end, start]
                if user_id:
                    sql += " AND user_id = ?"
                    params.append(user_id)
                if tier:
                    sql += " AND tier = ?"
                    params.append(tier)
                sql += " ORDER BY valid_from ASC LIMIT ?"
                params.append(limit)
                rows = conn.execute(sql, params).fetchall()
                return [self._row_to_entry(conn, r) for r in rows]
            finally:
                conn.close()

    # ── Internal helpers ──

    def _row_to_entry(self, conn, row) -> MemoryEntry:
        """Convert a SQLite Row to a MemoryEntry with tags and meta."""
        mid = row["memory_id"]

        tags = [
            r["tag"]
            for r in conn.execute(
                "SELECT tag FROM memory_tags WHERE memory_id = ?", (mid,)
            ).fetchall()
        ]

        meta = {
            r["key"]: r["value"]
            for r in conn.execute(
                "SELECT key, value FROM memory_meta WHERE memory_id = ?", (mid,)
            ).fetchall()
        }

        return MemoryEntry(
            memory_id=mid,
            tier=row["tier"],
            title=row["title"],
            body=row["body"],
            content_hash=row["content_hash"],
            source=row["source"] or "",
            confidence=row["confidence"],
            importance=row["importance"],
            created_at=row["created_at"],
            updated_at=row["updated_at"],
            valid_from=row["valid_from"],
            valid_to=row["valid_to"],
            status=row["status"],
            user_id=row["user_id"],
            file_path=row["file_path"],
            tags=tags,
            meta=meta,
        )


# ── Singleton ──

_memory_tree: Optional[MemoryTree] = None
_memory_tree_lock = threading.Lock()


def get_memory_tree(
    db_path: Optional[Path] = None,
    encrypt: bool = False,
) -> MemoryTree:
    """Get or create the singleton MemoryTree instance."""
    global _memory_tree
    if _memory_tree is None:
        with _memory_tree_lock:
            if _memory_tree is None:
                _memory_tree = MemoryTree(db_path=db_path, encrypt=encrypt)
    return _memory_tree
