"""
cortex/hippo/store/summary_store.py — CRUD for summaries and context items.

All queries filter by owner_id for multi-tenant isolation.
Issue: #801, #802
"""

from __future__ import annotations

import logging
import os
from typing import Optional

import aiosqlite

from cortex.hippo.cache import SummaryCache
from cortex.hippo.models import (
    ContextItemRecord,
    SummaryRecord,
)

logger = logging.getLogger(__name__)


def _generate_summary_id() -> str:
    """Generate a summary ID: 'sum_' + 16 hex chars."""
    return "sum_" + os.urandom(8).hex()


class SummaryStore:
    """Data access for hippo_summaries, hippo_summary_parents,
    hippo_summary_messages, and hippo_context_items tables."""

    def __init__(
        self,
        conn: aiosqlite.Connection,
        cache_max_size: int = 256,
        cache_ttl_seconds: float = 300.0,
        vec_available: bool = False,
    ) -> None:
        self._conn = conn
        self._vec_available = vec_available
        self._cache = SummaryCache(
            max_size=cache_max_size,
            ttl_seconds=cache_ttl_seconds,
        )

    # ── Vec sync helpers ───────────────────────────────────────────────

    async def _vec_upsert(self, summary_id: str, owner_id: str, embedding: bytes) -> None:
        """Insert or replace a vector in the vec0 index."""
        if not self._vec_available or embedding is None:
            return
        try:
            # vec0 doesn't support ON CONFLICT, so delete-then-insert
            await self._conn.execute(
                "DELETE FROM hippo_vec_summaries WHERE summary_id = ?",
                (summary_id,),
            )
            await self._conn.execute(
                "INSERT INTO hippo_vec_summaries(summary_id, owner_id, embedding) VALUES (?, ?, ?)",
                (summary_id, owner_id, embedding),
            )
        except Exception as exc:
            logger.warning("vec upsert failed for %s: %s", summary_id, exc)

    async def _vec_delete(self, summary_id: str) -> None:
        """Remove a vector from the vec0 index."""
        if not self._vec_available:
            return
        try:
            await self._conn.execute(
                "DELETE FROM hippo_vec_summaries WHERE summary_id = ?",
                (summary_id,),
            )
        except Exception as exc:
            logger.warning("vec delete failed for %s: %s", summary_id, exc)

    # ── Summaries ──────────────────────────────────────────────────────

    async def create_summary(
        self,
        conversation_id: int,
        owner_id: str,
        kind: str,
        depth: int,
        content: str,
        token_count: int,
        quality_score: Optional[float] = None,
        earliest_at: Optional[str] = None,
        latest_at: Optional[str] = None,
        descendant_count: int = 0,
        descendant_token_count: int = 0,
        source_message_token_count: int = 0,
        signal_metadata: Optional[str] = None,
        file_ids: Optional[str] = None,
    ) -> SummaryRecord:
        """Create a new summary node in the DAG."""
        summary_id = _generate_summary_id()
        file_ids_value = file_ids or "[]"

        await self._conn.execute(
            """
            INSERT INTO hippo_summaries
                (summary_id, conversation_id, owner_id, kind, depth,
                 content, token_count, quality_score, earliest_at, latest_at,
                 descendant_count, descendant_token_count, source_message_token_count,
                 signal_metadata, file_ids)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                summary_id, conversation_id, owner_id, kind, depth,
                content, token_count, quality_score, earliest_at, latest_at,
                descendant_count, descendant_token_count, source_message_token_count,
                signal_metadata, file_ids_value,
            ),
        )
        await self._conn.commit()

        record = SummaryRecord(
            summary_id=summary_id,
            conversation_id=conversation_id,
            owner_id=owner_id,
            kind=kind,
            depth=depth,
            content=content,
            token_count=token_count,
            file_ids=file_ids_value,
            quality_score=quality_score,
            earliest_at=earliest_at,
            latest_at=latest_at,
            descendant_count=descendant_count,
            descendant_token_count=descendant_token_count,
            source_message_token_count=source_message_token_count,
            signal_metadata=signal_metadata,
        )
        self._cache.put(summary_id, record)
        return record

    @property
    def cache(self) -> SummaryCache:
        """Expose cache for stats/testing."""
        return self._cache

    async def get_summary(
        self, owner_id: str, summary_id: str
    ) -> Optional[SummaryRecord]:
        """Get a summary by ID (cache-through)."""
        # Check cache first
        cached = self._cache.get(summary_id)
        if cached is not None and cached.owner_id == owner_id:
            return cached

        async with self._conn.execute(
            """
            SELECT summary_id, conversation_id, owner_id, kind, depth,
                   content, token_count, file_ids, earliest_at, latest_at,
                   descendant_count, descendant_token_count, source_message_token_count,
                   quality_score, embedding, signal_metadata, created_at
            FROM hippo_summaries
            WHERE owner_id = ? AND summary_id = ?
            """,
            (owner_id, summary_id),
        ) as cur:
            row = await cur.fetchone()
            if not row:
                return None
            record = SummaryRecord(
                summary_id=row[0],
                conversation_id=row[1],
                owner_id=row[2],
                kind=row[3],
                depth=row[4],
                content=row[5],
                token_count=row[6],
                file_ids=row[7],
                earliest_at=row[8],
                latest_at=row[9],
                descendant_count=row[10],
                descendant_token_count=row[11],
                source_message_token_count=row[12],
                quality_score=row[13],
                embedding=row[14],
                signal_metadata=row[15],
                created_at=row[16],
            )
            self._cache.put(summary_id, record)
            return record

    async def get_summaries_for_conversation(
        self, conversation_id: int, owner_id: str
    ) -> list[SummaryRecord]:
        """Get all summaries for a conversation."""
        async with self._conn.execute(
            """
            SELECT summary_id, conversation_id, owner_id, kind, depth,
                   content, token_count, file_ids, earliest_at, latest_at,
                   descendant_count, descendant_token_count, source_message_token_count,
                   quality_score, embedding, signal_metadata, created_at
            FROM hippo_summaries
            WHERE conversation_id = ? AND owner_id = ?
            ORDER BY depth ASC, created_at ASC
            """,
            (conversation_id, owner_id),
        ) as cur:
            rows = await cur.fetchall()
            return [
                SummaryRecord(
                    summary_id=r[0], conversation_id=r[1], owner_id=r[2],
                    kind=r[3], depth=r[4], content=r[5], token_count=r[6],
                    file_ids=r[7], earliest_at=r[8], latest_at=r[9],
                    descendant_count=r[10], descendant_token_count=r[11],
                    source_message_token_count=r[12], quality_score=r[13],
                    embedding=r[14], signal_metadata=r[15], created_at=r[16],
                )
                for r in rows
            ]

    # ── Summary Parents (DAG edges) ───────────────────────────────────

    async def add_summary_parent(self, summary_id: str, parent_summary_id: str) -> None:
        """Add a DAG edge: child → parent."""
        await self._conn.execute(
            "INSERT OR IGNORE INTO hippo_summary_parents (summary_id, parent_summary_id) VALUES (?, ?)",
            (summary_id, parent_summary_id),
        )
        await self._conn.commit()

    async def get_children(self, parent_summary_id: str, owner_id: Optional[str] = None) -> list[str]:
        """Get child summary IDs for a parent.

        When *owner_id* is provided, only returns children belonging to that
        owner (JOIN to hippo_summaries for tenant isolation).
        """
        if owner_id is not None:
            async with self._conn.execute(
                """
                SELECT sp.summary_id FROM hippo_summary_parents sp
                JOIN hippo_summaries s ON s.summary_id = sp.summary_id
                WHERE sp.parent_summary_id = ? AND s.owner_id = ?
                """,
                (parent_summary_id, owner_id),
            ) as cur:
                rows = await cur.fetchall()
                return [r[0] for r in rows]
        async with self._conn.execute(
            "SELECT summary_id FROM hippo_summary_parents WHERE parent_summary_id = ?",
            (parent_summary_id,),
        ) as cur:
            rows = await cur.fetchall()
            return [r[0] for r in rows]

    async def get_children_records(self, parent_summary_id: str, owner_id: Optional[str] = None) -> list[SummaryRecord]:
        """Get full child summary records for a parent.

        When *owner_id* is provided, filters children by owner for tenant isolation.
        """
        owner_filter = "AND s.owner_id = ?" if owner_id is not None else ""
        params: list = [parent_summary_id]
        if owner_id is not None:
            params.append(owner_id)
        async with self._conn.execute(
            f"""
            SELECT s.summary_id, s.conversation_id, s.owner_id, s.kind, s.depth,
                   s.content, s.token_count, s.file_ids, s.earliest_at, s.latest_at,
                   s.descendant_count, s.descendant_token_count, s.source_message_token_count,
                   s.quality_score, s.embedding, s.signal_metadata, s.created_at
            FROM hippo_summary_parents sp
            JOIN hippo_summaries s ON s.summary_id = sp.summary_id
            WHERE sp.parent_summary_id = ? {owner_filter}
            ORDER BY s.created_at ASC
            """,
            params,
        ) as cur:
            rows = await cur.fetchall()
            return [self._row_to_record(r) for r in rows]

    async def get_parent_records(self, summary_id: str, owner_id: Optional[str] = None) -> list[SummaryRecord]:
        """Get parent summary records for a child.

        When *owner_id* is provided, filters parents by owner for tenant isolation.
        """
        owner_filter = "AND s.owner_id = ?" if owner_id is not None else ""
        params: list = [summary_id]
        if owner_id is not None:
            params.append(owner_id)
        async with self._conn.execute(
            f"""
            SELECT s.summary_id, s.conversation_id, s.owner_id, s.kind, s.depth,
                   s.content, s.token_count, s.file_ids, s.earliest_at, s.latest_at,
                   s.descendant_count, s.descendant_token_count, s.source_message_token_count,
                   s.quality_score, s.embedding, s.signal_metadata, s.created_at
            FROM hippo_summary_parents sp
            JOIN hippo_summaries s ON s.summary_id = sp.parent_summary_id
            WHERE sp.summary_id = ? {owner_filter}
            ORDER BY s.depth ASC
            """,
            params,
        ) as cur:
            rows = await cur.fetchall()
            return [self._row_to_record(r) for r in rows]

    async def get_summary_subtree(
        self,
        summary_id: str,
        max_depth: int = 50,
        owner_id: Optional[str] = None,
    ) -> list[dict]:
        """Get subtree of a summary using recursive CTE.

        Returns list of dicts with summary_id, depth_from_root, child_count.
        Depth is capped at max_depth to prevent runaway recursal.
        Uses UNION (not UNION ALL) to deduplicate diamond-in-DAG paths.

        When *owner_id* is provided, ownership is enforced at every stage of
        the CTE to prevent cross-tenant structure leaks:

        1. **Seed**: the starting summary is verified against
           ``hippo_summaries.owner_id`` before entering the CTE.
        2. **Recursive term**: each child node is joined to
           ``hippo_summaries`` so only rows owned by the same tenant can be
           followed, preventing traversal into other tenants' subgraphs.
        3. **child_count subquery**: restricted to the same owner so the
           count cannot expose the existence of cross-tenant children.
        """
        if owner_id is not None:
            # Fully owner-scoped CTE — ownership enforced in seed, recursive
            # term, and child_count subquery. params order:
            #   seed: summary_id, owner_id
            #   recursive: max_depth, owner_id
            #   child_count: owner_id
            params: tuple = (summary_id, owner_id, max_depth, owner_id, owner_id)
            query = """
            WITH RECURSIVE subtree(summary_id, depth_from_root) AS (
                SELECT s.summary_id, 0
                FROM hippo_summaries s
                WHERE s.summary_id = ? AND s.owner_id = ?
                UNION
                SELECT sp.summary_id, st.depth_from_root + 1
                FROM hippo_summary_parents sp
                JOIN subtree st ON st.summary_id = sp.parent_summary_id
                JOIN hippo_summaries s ON s.summary_id = sp.summary_id
                WHERE st.depth_from_root < ? AND s.owner_id = ?
            )
            SELECT st.summary_id, st.depth_from_root,
                   s.kind, s.token_count, s.content,
                   (SELECT COUNT(*) FROM hippo_summary_parents sp2
                    JOIN hippo_summaries sc ON sc.summary_id = sp2.summary_id
                    WHERE sp2.parent_summary_id = st.summary_id
                      AND sc.owner_id = ?) AS child_count
            FROM subtree st
            JOIN hippo_summaries s ON s.summary_id = st.summary_id
            ORDER BY st.depth_from_root ASC
            """
        else:
            params = (summary_id, max_depth)
            query = """
            WITH RECURSIVE subtree(summary_id, depth_from_root) AS (
                SELECT ?, 0
                UNION
                SELECT sp.summary_id, st.depth_from_root + 1
                FROM hippo_summary_parents sp
                JOIN subtree st ON st.summary_id = sp.parent_summary_id
                WHERE st.depth_from_root < ?
            )
            SELECT st.summary_id, st.depth_from_root,
                   s.kind, s.token_count, s.content,
                   (SELECT COUNT(*) FROM hippo_summary_parents
                    WHERE parent_summary_id = st.summary_id) AS child_count
            FROM subtree st
            JOIN hippo_summaries s ON s.summary_id = st.summary_id
            ORDER BY st.depth_from_root ASC
            """
        async with self._conn.execute(query, params) as cur:
            rows = await cur.fetchall()
            return [
                {
                    "summary_id": r[0],
                    "depth_from_root": r[1],
                    "kind": r[2],
                    "token_count": r[3],
                    "content": r[4],
                    "child_count": r[5],
                }
                for r in rows
            ]

    async def search_summaries(
        self,
        owner_id: str,
        pattern: str,
        mode: str = "regex",
        conversation_id: Optional[int] = None,
        limit: int = 50,
    ) -> list[SummaryRecord]:
        """Search summaries via FTS5 or LIKE."""
        if mode == "full_text":
            return await self._search_summaries_fts(owner_id, pattern, conversation_id, limit)
        return await self._search_summaries_like(owner_id, pattern, conversation_id, limit)

    async def _search_summaries_fts(
        self, owner_id: str, pattern: str,
        conversation_id: Optional[int], limit: int,
    ) -> list[SummaryRecord]:
        """FTS5-based summary search."""
        from cortex.hippo.store.fts_utils import sanitize_fts5_query

        fts_query = sanitize_fts5_query(pattern)
        if not fts_query:
            return await self._search_summaries_like(owner_id, pattern, conversation_id, limit)

        conv_filter = "AND s.conversation_id = ?" if conversation_id else ""
        params: list = [fts_query, owner_id]
        if conversation_id:
            params.append(conversation_id)
        params.append(limit)

        try:
            async with self._conn.execute(
                f"""
                SELECT s.summary_id, s.conversation_id, s.owner_id, s.kind, s.depth,
                       s.content, s.token_count, s.file_ids, s.earliest_at, s.latest_at,
                       s.descendant_count, s.descendant_token_count, s.source_message_token_count,
                       s.quality_score, s.embedding, s.signal_metadata, s.created_at
                FROM hippo_fts_summaries fts
                JOIN hippo_summaries s ON s.rowid = fts.rowid
                WHERE hippo_fts_summaries MATCH ? AND s.owner_id = ? {conv_filter}
                ORDER BY rank
                LIMIT ?
                """,
                params,
            ) as cur:
                rows = await cur.fetchall()
                return [self._row_to_record(r) for r in rows]
        except Exception:
            logger.debug("FTS5 summary search failed, falling back to LIKE")
            return await self._search_summaries_like(owner_id, pattern, conversation_id, limit)

    async def _search_summaries_like(
        self, owner_id: str, pattern: str,
        conversation_id: Optional[int], limit: int,
    ) -> list[SummaryRecord]:
        """LIKE-based summary search fallback."""
        from cortex.hippo.store.utils import escape_like_pattern

        conv_filter = "AND conversation_id = ?" if conversation_id else ""
        escaped = escape_like_pattern(pattern)
        params: list = [owner_id, f"%{escaped}%"]
        if conversation_id:
            params.append(conversation_id)
        params.append(limit)

        async with self._conn.execute(
            f"""
            SELECT summary_id, conversation_id, owner_id, kind, depth,
                   content, token_count, file_ids, earliest_at, latest_at,
                   descendant_count, descendant_token_count, source_message_token_count,
                   quality_score, embedding, signal_metadata, created_at
            FROM hippo_summaries
            WHERE owner_id = ? AND content LIKE ? ESCAPE '\\' {conv_filter}
            ORDER BY created_at DESC
            LIMIT ?
            """,
            params,
        ) as cur:
            rows = await cur.fetchall()
            return [self._row_to_record(r) for r in rows]

    async def get_summaries_with_embeddings(
        self, owner_id: str, conversation_id: Optional[int] = None
    ) -> list[SummaryRecord]:
        """Get summaries that have embeddings for semantic search."""
        conv_filter = "AND conversation_id = ?" if conversation_id else ""
        params: list = [owner_id]
        if conversation_id:
            params.append(conversation_id)

        async with self._conn.execute(
            f"""
            SELECT summary_id, conversation_id, owner_id, kind, depth,
                   content, token_count, file_ids, earliest_at, latest_at,
                   descendant_count, descendant_token_count, source_message_token_count,
                   quality_score, embedding, signal_metadata, created_at
            FROM hippo_summaries
            WHERE owner_id = ? AND embedding IS NOT NULL {conv_filter}
            """,
            params,
        ) as cur:
            rows = await cur.fetchall()
            return [self._row_to_record(r) for r in rows]

    # ── Large Files ──────────────────────────────────────────────────

    async def insert_large_file(
        self,
        file_id: str,
        conversation_id: int,
        owner_id: str,
        file_name: Optional[str] = None,
        mime_type: Optional[str] = None,
        byte_size: int = 0,
        content: Optional[str] = None,
        exploration_summary: Optional[str] = None,
    ) -> int:
        """Insert a large file record. Returns the row id."""
        async with self._conn.execute(
            """
            INSERT INTO hippo_large_files
                (file_id, conversation_id, owner_id, file_name,
                 mime_type, byte_size, content, exploration_summary)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (file_id, conversation_id, owner_id, file_name,
             mime_type, byte_size, content, exploration_summary),
        ) as cur:
            row_id = cur.lastrowid
        await self._conn.commit()
        return row_id

    async def get_large_file(self, file_id: str, owner_id: Optional[str] = None) -> Optional[dict]:
        """Get a large file record by file_id.

        When *owner_id* is provided, only returns the file if it belongs to
        that owner (tenant isolation).
        """
        try:
            owner_filter = "AND owner_id = ?" if owner_id is not None else ""
            params: list = [file_id]
            if owner_id is not None:
                params.append(owner_id)
            async with self._conn.execute(
                f"""
                SELECT id, file_id, conversation_id, owner_id, file_name,
                       mime_type, byte_size, content, exploration_summary, created_at
                FROM hippo_large_files
                WHERE file_id = ? {owner_filter}
                """,
                params,
            ) as cur:
                row = await cur.fetchone()
                if not row:
                    return None
                return {
                    "id": row[0],
                    "file_id": row[1],
                    "conversation_id": row[2],
                    "owner_id": row[3],
                    "file_name": row[4],
                    "mime_type": row[5],
                    "byte_size": row[6],
                    "content": row[7],
                    "exploration_summary": row[8],
                    "created_at": row[9],
                }
        except Exception:
            return None

    async def get_large_files_by_conversation(
        self, conversation_id: int, owner_id: Optional[str] = None
    ) -> list[dict]:
        """Get all large file records for a conversation.

        When *owner_id* is provided, filters by owner for tenant isolation.
        """
        try:
            owner_filter = "AND owner_id = ?" if owner_id is not None else ""
            params: list = [conversation_id]
            if owner_id is not None:
                params.append(owner_id)
            async with self._conn.execute(
                f"""
                SELECT id, file_id, conversation_id, owner_id, file_name,
                       mime_type, byte_size, content, exploration_summary, created_at
                FROM hippo_large_files
                WHERE conversation_id = ? {owner_filter}
                ORDER BY created_at ASC
                """,
                params,
            ) as cur:
                rows = await cur.fetchall()
                return [
                    {
                        "id": r[0],
                        "file_id": r[1],
                        "conversation_id": r[2],
                        "owner_id": r[3],
                        "file_name": r[4],
                        "mime_type": r[5],
                        "byte_size": r[6],
                        "content": r[7],
                        "exploration_summary": r[8],
                        "created_at": r[9],
                    }
                    for r in rows
                ]
        except Exception:
            return []

    @staticmethod
    def _row_to_record(r: tuple) -> SummaryRecord:
        """Convert a row tuple to SummaryRecord."""
        return SummaryRecord(
            summary_id=r[0], conversation_id=r[1], owner_id=r[2],
            kind=r[3], depth=r[4], content=r[5], token_count=r[6],
            file_ids=r[7], earliest_at=r[8], latest_at=r[9],
            descendant_count=r[10], descendant_token_count=r[11],
            source_message_token_count=r[12], quality_score=r[13],
            embedding=r[14], signal_metadata=r[15], created_at=r[16],
        )

    # ── Batch Methods (performance optimization) ─────────────────────

    async def get_records_batch(self, summary_ids: list[str], owner_id: Optional[str] = None) -> list[SummaryRecord]:
        """Fetch multiple summaries by ID in one query (cache-through).

        Returns records in arbitrary order. Missing IDs are silently skipped.
        Checks cache first, only queries DB for misses.
        When *owner_id* is provided, filters results to that owner.
        Issue: #1187
        """
        if not summary_ids:
            return []

        # Check cache first
        cached, missing = self._cache.get_batch(summary_ids)
        # Filter cached results by owner_id if specified
        if owner_id is not None:
            cached = [r for r in cached if r.owner_id == owner_id]
        if not missing:
            return cached

        placeholders = ",".join("?" * len(missing))
        owner_filter = "AND owner_id = ?" if owner_id is not None else ""
        params: list = list(missing)
        if owner_id is not None:
            params.append(owner_id)
        async with self._conn.execute(
            f"""
            SELECT summary_id, conversation_id, owner_id, kind, depth,
                   content, token_count, file_ids, earliest_at, latest_at,
                   descendant_count, descendant_token_count, source_message_token_count,
                   quality_score, embedding, signal_metadata, created_at
            FROM hippo_summaries
            WHERE summary_id IN ({placeholders}) {owner_filter}
            """,
            params,
        ) as cur:
            rows = await cur.fetchall()
            db_records = [self._row_to_record(r) for r in rows]
            self._cache.put_batch(db_records)
            return cached + db_records

    async def get_children_records_batch(
        self, parent_ids: list[str], owner_id: Optional[str] = None
    ) -> dict[str, list[SummaryRecord]]:
        """Fetch children for multiple parent summaries in one query.

        Returns {parent_id: [child_records]}. Missing parents get empty lists.
        Issue: #1187
        """
        if not parent_ids:
            return {}
        placeholders = ",".join("?" * len(parent_ids))
        owner_filter = "AND s.owner_id = ?" if owner_id is not None else ""
        params: list = list(parent_ids)
        if owner_id is not None:
            params.append(owner_id)
        async with self._conn.execute(
            f"""
            SELECT s.summary_id, s.conversation_id, s.owner_id, s.kind, s.depth,
                   s.content, s.token_count, s.file_ids, s.earliest_at, s.latest_at,
                   s.descendant_count, s.descendant_token_count, s.source_message_token_count,
                   s.quality_score, s.embedding, s.signal_metadata, s.created_at,
                   sp.parent_summary_id
            FROM hippo_summary_parents sp
            JOIN hippo_summaries s ON s.summary_id = sp.summary_id
            WHERE sp.parent_summary_id IN ({placeholders}) {owner_filter}
            ORDER BY s.created_at ASC
            """,
            params,
        ) as cur:
            rows = await cur.fetchall()

        result: dict[str, list[SummaryRecord]] = {pid: [] for pid in parent_ids}
        for r in rows:
            parent_id = r[17]  # parent_summary_id is the 18th column
            record = self._row_to_record(r[:17])
            result[parent_id].append(record)
        return result

    async def get_source_message_ids_batch(
        self, summary_ids: list[str], owner_id: Optional[str] = None
    ) -> dict[str, list[int]]:
        """Fetch source message IDs for multiple summaries in one query.

        Returns {summary_id: [message_ids]}. Missing summaries get empty lists.

        When *owner_id* is given, both summary ownership AND message ownership
        are verified via JOINs (defense-in-depth, matching the single-ID
        version's approach).
        Issue: #1187
        """
        if not summary_ids:
            return {}
        placeholders = ",".join("?" * len(summary_ids))
        if owner_id is not None:
            params: list = [owner_id, owner_id] + list(summary_ids)
            query = f"""
            SELECT sm.summary_id, sm.message_id
            FROM hippo_summary_messages sm
            JOIN hippo_summaries hs ON hs.summary_id = sm.summary_id AND hs.owner_id = ?
            JOIN hippo_messages hm ON hm.message_id = sm.message_id AND hm.owner_id = ?
            WHERE sm.summary_id IN ({placeholders})
            ORDER BY sm.message_id
            """
        else:
            params = list(summary_ids)
            query = f"""
            SELECT summary_id, message_id
            FROM hippo_summary_messages
            WHERE summary_id IN ({placeholders})
            ORDER BY message_id
            """
        async with self._conn.execute(query, params) as cur:
            rows = await cur.fetchall()

        result: dict[str, list[int]] = {sid: [] for sid in summary_ids}
        for r in rows:
            result[r[0]].append(r[1])
        return result

    # ── Summary Messages (leaf → source messages) ─────────────────────

    async def link_summary_messages(
        self, summary_id: str, message_ids: list[int]
    ) -> None:
        """Link a leaf summary to its source messages."""
        for msg_id in message_ids:
            await self._conn.execute(
                "INSERT OR IGNORE INTO hippo_summary_messages (summary_id, message_id) VALUES (?, ?)",
                (summary_id, msg_id),
            )
        await self._conn.commit()

    async def get_source_message_ids(
        self, summary_id: str, owner_id: Optional[str] = None
    ) -> list[int]:
        """Get source message IDs for a summary.

        Args:
            summary_id: The summary whose linked message IDs to retrieve.
            owner_id: When provided, verifies both the summary AND each linked
                message belong to this owner via JOIN. This prevents returning
                message IDs owned by other tenants even if the join table
                were corrupted (defense-in-depth tenant isolation).
        """
        if owner_id is not None:
            async with self._conn.execute(
                """
                SELECT sm.message_id
                FROM hippo_summary_messages sm
                JOIN hippo_summaries s ON s.summary_id = sm.summary_id
                JOIN hippo_messages m ON m.message_id = sm.message_id
                WHERE sm.summary_id = ? AND s.owner_id = ? AND m.owner_id = ?
                ORDER BY sm.message_id
                """,
                (summary_id, owner_id, owner_id),
            ) as cur:
                rows = await cur.fetchall()
                return [r[0] for r in rows]
        async with self._conn.execute(
            "SELECT message_id FROM hippo_summary_messages WHERE summary_id = ? ORDER BY message_id",
            (summary_id,),
        ) as cur:
            rows = await cur.fetchall()
            return [r[0] for r in rows]

    # ── Context Items ──────────────────────────────────────────────────

    async def get_context_items(
        self, conversation_id: int
    ) -> list[ContextItemRecord]:
        """Get all context items for a conversation, ordered by ordinal."""
        async with self._conn.execute(
            """
            SELECT conversation_id, ordinal, item_type, message_id, summary_id, created_at
            FROM hippo_context_items
            WHERE conversation_id = ?
            ORDER BY ordinal ASC
            """,
            (conversation_id,),
        ) as cur:
            rows = await cur.fetchall()
            return [
                ContextItemRecord(
                    conversation_id=r[0],
                    ordinal=r[1],
                    item_type=r[2],
                    message_id=r[3],
                    summary_id=r[4],
                    created_at=r[5],
                )
                for r in rows
            ]

    async def append_context_item(
        self,
        conversation_id: int,
        item_type: str,
        message_id: Optional[int] = None,
        summary_id: Optional[str] = None,
    ) -> int:
        """Append a context item at the end of the ordered list.

        Returns the new ordinal.
        """
        async with self._conn.execute(
            "SELECT COALESCE(MAX(ordinal), -1) + 1 FROM hippo_context_items WHERE conversation_id = ?",
            (conversation_id,),
        ) as cur:
            row = await cur.fetchone()
            ordinal = row[0]

        await self._conn.execute(
            """
            INSERT INTO hippo_context_items
                (conversation_id, ordinal, item_type, message_id, summary_id)
            VALUES (?, ?, ?, ?, ?)
            """,
            (conversation_id, ordinal, item_type, message_id, summary_id),
        )
        await self._conn.commit()
        return ordinal

    async def replace_context_items(
        self,
        conversation_id: int,
        items: list[tuple[str, Optional[int], Optional[str]]],
    ) -> None:
        """Replace all context items for a conversation.

        Each tuple: (item_type, message_id, summary_id).
        Uses executemany for bulk insert performance (#1187).
        """
        await self._conn.execute(
            "DELETE FROM hippo_context_items WHERE conversation_id = ?",
            (conversation_id,),
        )
        if items:
            await self._conn.executemany(
                """
                INSERT INTO hippo_context_items
                    (conversation_id, ordinal, item_type, message_id, summary_id)
                VALUES (?, ?, ?, ?, ?)
                """,
                [
                    (conversation_id, ordinal, item_type, msg_id, sum_id)
                    for ordinal, (item_type, msg_id, sum_id) in enumerate(items)
                ],
            )
        await self._conn.commit()

    async def get_context_item_count(self, conversation_id: int) -> int:
        """Get the count of context items."""
        async with self._conn.execute(
            "SELECT COUNT(*) FROM hippo_context_items WHERE conversation_id = ?",
            (conversation_id,),
        ) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def get_total_summary_tokens(self, conversation_id: int, owner_id: str) -> int:
        """Get total token count across all summaries in a conversation."""
        async with self._conn.execute(
            """
            SELECT COALESCE(SUM(token_count), 0)
            FROM hippo_summaries
            WHERE conversation_id = ? AND owner_id = ?
            """,
            (conversation_id, owner_id),
        ) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def get_summary_count(self, conversation_id: int, owner_id: str) -> int:
        """Get count of summaries in a conversation."""
        async with self._conn.execute(
            "SELECT COUNT(*) FROM hippo_summaries WHERE conversation_id = ? AND owner_id = ?",
            (conversation_id, owner_id),
        ) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def get_max_depth(self, conversation_id: int, owner_id: str) -> int:
        """Get maximum summary depth in a conversation."""
        async with self._conn.execute(
            """
            SELECT COALESCE(MAX(depth), 0)
            FROM hippo_summaries
            WHERE conversation_id = ? AND owner_id = ?
            """,
            (conversation_id, owner_id),
        ) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def get_depth_distribution(self, conversation_id: int, owner_id: Optional[str] = None) -> dict:
        """Return a mapping of depth → summary count for a conversation.

        When *owner_id* is provided, filters by owner for tenant isolation.
        """
        if owner_id is not None:
            async with self._conn.execute(
                "SELECT depth, COUNT(*) as cnt FROM hippo_summaries WHERE conversation_id = ? AND owner_id = ? GROUP BY depth",
                (conversation_id, owner_id),
            ) as cur:
                rows = await cur.fetchall()
        else:
            async with self._conn.execute(
                "SELECT depth, COUNT(*) as cnt FROM hippo_summaries WHERE conversation_id = ? GROUP BY depth",
                (conversation_id,),
            ) as cur:
                rows = await cur.fetchall()
        return {row[0]: row[1] for row in rows}

    # ── Phase 2: Compaction support methods ────────────────────────────

    async def get_context_token_count(self, conversation_id: int) -> int:
        """Compute total tokens referenced by context_items (messages + summaries)."""
        async with self._conn.execute(
            """
            SELECT COALESCE(SUM(
                CASE
                    WHEN ci.item_type = 'message' THEN COALESCE(m.token_count, 0)
                    WHEN ci.item_type = 'summary' THEN COALESCE(s.token_count, 0)
                    ELSE 0
                END
            ), 0)
            FROM hippo_context_items ci
            LEFT JOIN hippo_messages m ON ci.message_id = m.message_id
            LEFT JOIN hippo_summaries s ON ci.summary_id = s.summary_id
            WHERE ci.conversation_id = ?
            """,
            (conversation_id,),
        ) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def get_distinct_depths_in_context(
        self, conversation_id: int, max_ordinal_exclusive: float = float("inf"),
    ) -> list[int]:
        """Get distinct summary depths present in context_items below max_ordinal."""
        if max_ordinal_exclusive == float("inf"):
            sql = """
                SELECT DISTINCT s.depth
                FROM hippo_context_items ci
                JOIN hippo_summaries s ON ci.summary_id = s.summary_id
                WHERE ci.conversation_id = ? AND ci.item_type = 'summary'
                ORDER BY s.depth ASC
            """
            params: tuple = (conversation_id,)
        else:
            sql = """
                SELECT DISTINCT s.depth
                FROM hippo_context_items ci
                JOIN hippo_summaries s ON ci.summary_id = s.summary_id
                WHERE ci.conversation_id = ? AND ci.item_type = 'summary'
                  AND ci.ordinal < ?
                ORDER BY s.depth ASC
            """
            params = (conversation_id, int(max_ordinal_exclusive))
        async with self._conn.execute(sql, params) as cur:
            rows = await cur.fetchall()
            return [r[0] for r in rows]

    async def replace_context_range_with_summary(
        self,
        conversation_id: int,
        start_ordinal: int,
        end_ordinal: int,
        summary_id: str,
    ) -> None:
        """Atomically replace context items in [start, end] with a single summary item.

        Uses explicit transaction: DELETE range, INSERT summary, re-number ordinals.
        """
        await self._conn.execute("BEGIN IMMEDIATE")
        try:
            # Delete items in range
            await self._conn.execute(
                "DELETE FROM hippo_context_items WHERE conversation_id = ? AND ordinal BETWEEN ? AND ?",
                (conversation_id, start_ordinal, end_ordinal),
            )
            # Insert summary at start_ordinal
            await self._conn.execute(
                "INSERT INTO hippo_context_items (conversation_id, ordinal, item_type, message_id, summary_id) VALUES (?, ?, 'summary', NULL, ?)",
                (conversation_id, start_ordinal, summary_id),
            )
            # Re-number to keep ordinals contiguous (two-pass to avoid PK collisions)
            async with self._conn.execute(
                "SELECT rowid, ordinal FROM hippo_context_items WHERE conversation_id = ? ORDER BY ordinal ASC",
                (conversation_id,),
            ) as cur:
                rows = await cur.fetchall()
            for new_ord, (rowid, _) in enumerate(rows):
                await self._conn.execute(
                    "UPDATE hippo_context_items SET ordinal = ? WHERE rowid = ?",
                    (-(new_ord + 1), rowid),
                )
            for new_ord, (rowid, _) in enumerate(rows):
                await self._conn.execute(
                    "UPDATE hippo_context_items SET ordinal = ? WHERE rowid = ?",
                    (new_ord, rowid),
                )
            await self._conn.execute("COMMIT")
        except Exception:
            await self._conn.execute("ROLLBACK")
            raise

    async def add_summary_parents(self, summary_id: str, parent_ids: list[str]) -> None:
        """Link a condensed summary to its parent summaries (batch)."""
        for pid in parent_ids:
            await self._conn.execute(
                "INSERT OR IGNORE INTO hippo_summary_parents (summary_id, parent_summary_id) VALUES (?, ?)",
                (summary_id, pid),
            )
        await self._conn.commit()

    async def update_summary_embedding(
        self, summary_id: str, owner_id: str, embedding: bytes
    ) -> None:
        """Update the embedding on a summary and sync to vec index.

        Scoped by owner_id to prevent cross-tenant embedding overwrites.
        """
        await self._conn.execute(
            "UPDATE hippo_summaries SET embedding = ? WHERE summary_id = ? AND owner_id = ?",
            (embedding, summary_id, owner_id),
        )
        await self._conn.commit()
        await self._vec_upsert(summary_id, owner_id, embedding)
        await self._conn.commit()

    async def delete_summary(self, summary_id: str, owner_id: Optional[str] = None) -> bool:
        """Delete a summary and remove from vec index.

        When *owner_id* is provided the summary is only deleted if it belongs
        to that owner (tenant isolation).  Returns True when the summary was
        actually deleted.

        The ownership-checked DELETE and cascade deletes share a single
        implicit transaction (committed together by the final ``commit()``)
        so there is no TOCTOU window between the ownership check and the
        cascade.

        DAG edges referencing this summary (both ``summary_id = ?`` child
        edges and ``parent_summary_id = ?`` parent-reference edges) are
        cleaned up before the summary row is deleted (FK constraint
        requires it).  Edges in other summaries that referenced this one
        as a parent become orphans once the summary is deleted, so removing
        them is correct regardless of which tenant owns the child summary.
        """
        await self._vec_delete(summary_id)

        # Clean up all DAG edges referencing this summary BEFORE deleting
        # the summary row (FK constraints on both columns require it).
        await self._conn.execute(
            "DELETE FROM hippo_summary_parents WHERE summary_id = ? OR parent_summary_id = ?",
            (summary_id, summary_id),
        )
        await self._conn.execute(
            "DELETE FROM hippo_summary_messages WHERE summary_id = ?",
            (summary_id,),
        )
        await self._conn.execute(
            "DELETE FROM hippo_context_items WHERE summary_id = ?",
            (summary_id,),
        )

        # Delete the summary row — owner-scoped when requested.
        # This is in the same implicit transaction as the cascade deletes
        # above: if the ownership check fails, rollback undoes everything.
        if owner_id is not None:
            async with self._conn.execute(
                "DELETE FROM hippo_summaries WHERE summary_id = ? AND owner_id = ?",
                (summary_id, owner_id),
            ) as cur:
                if cur.rowcount == 0:
                    await self._conn.rollback()
                    return False
        else:
            async with self._conn.execute(
                "DELETE FROM hippo_summaries WHERE summary_id = ?",
                (summary_id,),
            ) as cur:
                if cur.rowcount == 0:
                    await self._conn.rollback()
                    return False

        await self._conn.commit()
        self._cache.invalidate(summary_id)
        return True

    async def update_summary_quality(self, summary_id: str, quality_score: float, owner_id: Optional[str] = None) -> bool:
        """Update quality_score on a summary.

        When *owner_id* is provided, only updates if the summary belongs to
        that owner (tenant isolation). Returns True if the update was applied.
        """
        if owner_id is not None:
            async with self._conn.execute(
                "UPDATE hippo_summaries SET quality_score = ? WHERE summary_id = ? AND owner_id = ?",
                (quality_score, summary_id, owner_id),
            ) as cur:
                updated = cur.rowcount > 0
        else:
            async with self._conn.execute(
                "UPDATE hippo_summaries SET quality_score = ? WHERE summary_id = ?",
                (quality_score, summary_id),
            ) as cur:
                updated = cur.rowcount > 0
        await self._conn.commit()
        if updated:
            self._cache.invalidate(summary_id)
        return updated
