"""cortex/storage/queries/claim_queries.py — Claim/episode/embedding/FTS storage mixin."""

from __future__ import annotations

from typing import Any, Dict, List, Optional

import json

# ---------------------------------------------------------------------------
# Pagination guard (L2 audit fix)
# The HTTP layer also caps per_page, but storage enforces its own ceiling so
# that direct storage calls cannot bypass pagination limits.
# ---------------------------------------------------------------------------
MAX_PAGE_SIZE = 200
"""Hard upper bound on per_page for all paginated storage queries."""
from cortex.storage._helpers import (
    _now, _pack_floats, _unpack_floats, _cosine_similarity,
    _row_to_claim, _row_to_episode, _row_to_provenance,
    _row_to_edge, _row_to_feedback, _row_to_entity_alias,
)
from cortex.validation import validate_claim_fields
from cortex.types import (
    ClaimProvenance, EpisodeEvent, FTSResult, MemoryEdge,
    MemoryFeedback, SemanticClaim, VectorResult,
)


class ClaimQueryMixin:
    """Mixin providing claimquery methods."""

    async def log_episode(
        self,
        session_id: str,
        actor_id: str,
        kind: str,
        raw_text: str,
        owner_id: str = "default",
        **kwargs,
    ) -> EpisodeEvent:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            ts = kwargs.get("ts", now)
            tool_trace_ref = kwargs.get("tool_trace_ref")
            source_uri = kwargs.get("source_uri")
            metadata_json = kwargs.get("metadata_json")

            # session_id here is the session_record.id (PK), not session_id column
            async with conn.execute(
                """
                INSERT INTO episode_event
                    (owner_id, session_id, actor_id, ts, kind,
                     raw_text, tool_trace_ref, source_uri, metadata_json, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                RETURNING *
                """,
                (owner_id, session_id, actor_id, ts, kind,
                 raw_text, tool_trace_ref, source_uri, metadata_json, now),
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()

            # Sync to FTS
            event = _row_to_episode(row)
            await conn.execute(
                "INSERT INTO fts_episodes(event_id, content) VALUES (?, ?)",
                (event.id, raw_text or ""),
            )
            await conn.commit()
            return event


    async def create_claim(
        self,
        subject: str,
        predicate: str,
        object_value: str,
        owner_id: str = "default",
        **kwargs,
    ) -> SemanticClaim:
        """Insert a new semantic claim triple, update FTS5, and return the created SemanticClaim."""
        # Validate and sanitize all string inputs
        subject, predicate, object_value = validate_claim_fields(
            subject, predicate, object_value
        )

        conn = await self._get_conn()
        now = _now()

        cols = {
            "owner_id": owner_id,
            "subject": subject,
            "predicate": predicate,
            "object_value": object_value,
            "memory_type": kwargs.get("memory_type", "session"),
            "subject_entity_id": kwargs.get("subject_entity_id"),
            "object_entity_id": kwargs.get("object_entity_id"),
            "datatype": kwargs.get("datatype", "text"),
            # Default confidence matches CortexConfig.session_confidence_default.
            # The reconciliation engine passes the config-driven value explicitly;
            # this fallback is for direct storage calls that bypass reconciliation.
            "confidence": kwargs.get("confidence", 0.5),
            "sensitivity": kwargs.get("sensitivity", "normal"),
            "category": kwargs.get("category", "misc"),
            # v8 envelope fields: intrinsic_type and human_family (#355/#357)
            # Only included when caller provides them (None → column stays NULL).
            **({
                "intrinsic_type": kwargs["intrinsic_type"],
            } if "intrinsic_type" in kwargs else {}),
            **({
                "human_family": kwargs["human_family"],
            } if "human_family" in kwargs else {}),
            "scope": kwargs.get("scope", "user"),
            "valid_from": kwargs.get("valid_from"),
            "valid_to": kwargs.get("valid_to"),
            "status": kwargs.get("status", "active"),
            # LoCoMo fields (v10, #516)
            **({
                "memory_class": kwargs["memory_class"],
            } if "memory_class" in kwargs else {}),
            **({
                "event_date": kwargs["event_date"],
            } if "event_date" in kwargs else {}),
            **({
                "sequence_order": kwargs["sequence_order"],
            } if "sequence_order" in kwargs else {}),
            **({
                "salience": kwargs["salience"],
            } if "salience" in kwargs else {}),
            # Causal linking (#518)
            **({
                "cause": kwargs["cause"],
            } if "cause" in kwargs else {}),
            **({
                "effect": kwargs["effect"],
            } if "effect" in kwargs else {}),
            # State transitions (#519)
            **({
                "state_before": kwargs["state_before"],
            } if "state_before" in kwargs else {}),
            **({
                "state_after": kwargs["state_after"],
            } if "state_after" in kwargs else {}),
            # Temporal fields (#573, v12)
            **({
                "temporal": kwargs["temporal"],
            } if "temporal" in kwargs else {}),
            **({
                "temporal_marker": kwargs["temporal_marker"],
            } if "temporal_marker" in kwargs else {}),
            "source_session": kwargs.get("source_session"),
            "created_at": now,
            "updated_at": now,
        }

        col_names = ", ".join(cols.keys())
        placeholders = ", ".join("?" for _ in cols)
        values = list(cols.values())

        async with self._write_lock:
            async with conn.execute(
                f"INSERT INTO semantic_claim ({col_names}) VALUES ({placeholders}) RETURNING *",
                values,
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()

            claim = _row_to_claim(row)

            # Sync to FTS
            fts_content = f"{subject} {predicate} {object_value}"
            await conn.execute(
                "INSERT INTO fts_claims(claim_id, content) VALUES (?, ?)",
                (claim.id, fts_content),
            )
            await conn.commit()
        return claim


    async def get_claim(self, claim_id: str, owner_id: Optional[str] = None) -> Optional[SemanticClaim]:
        """Return a SemanticClaim by primary-key id, or None if not found."""
        conn = await self._get_conn()
        if owner_id is None:
            async with conn.execute(
                "SELECT * FROM semantic_claim WHERE id = ?", (claim_id,)
            ) as cur:
                row = await cur.fetchone()
        else:
            async with conn.execute(
                "SELECT * FROM semantic_claim WHERE id = ? AND owner_id = ?",
                (claim_id, owner_id),
            ) as cur:
                row = await cur.fetchone()
        return _row_to_claim(row) if row else None

    async def get_claims_sensitivity_batch(
        self, claim_ids: List[str],
    ) -> Dict[str, str]:
        """Return {claim_id: sensitivity} for the given IDs in one query."""
        if not claim_ids:
            return {}
        conn = await self._get_conn()
        placeholders = ",".join("?" * len(claim_ids))
        async with conn.execute(
            f"SELECT id, sensitivity FROM semantic_claim WHERE id IN ({placeholders})",
            claim_ids,
        ) as cur:
            rows = await cur.fetchall()
        return {row["id"]: row["sensitivity"] for row in rows}


    async def update_claim(
        self,
        claim_id: str,
        owner_id: Optional[str] = None,
        **kwargs,
    ) -> SemanticClaim:
        """Patch allowed columns on a claim by id and return the updated SemanticClaim."""
        _allowed = {
            "memory_type", "confidence", "sensitivity", "scope",
            "valid_from", "valid_to", "status", "superseded_by",
            "last_accessed", "access_count", "subject_entity_id",
            "object_entity_id", "object_value", "category",
        }
        updates = {k: v for k, v in kwargs.items() if k in _allowed}
        if not updates:
            raise ValueError(f"No valid columns. Allowed: {_allowed}")

        updates["updated_at"] = _now()
        conn = await self._get_conn()
        set_clause = ", ".join(f"{col} = ?" for col in updates)

        async with self._write_lock:
            if owner_id is None:
                values = list(updates.values()) + [claim_id]
                await conn.execute(
                    f"UPDATE semantic_claim SET {set_clause} WHERE id = ?",
                    values,
                )
            else:
                values = list(updates.values()) + [claim_id, owner_id]
                await conn.execute(
                    f"UPDATE semantic_claim SET {set_clause} WHERE id = ? AND owner_id = ?",
                    values,
                )
            await conn.commit()
            claim = await self.get_claim(claim_id, owner_id=owner_id)

            # Sync FTS: re-build content from current claim state
            if claim is not None and "object_value" in updates:
                fts_content = f"{claim.subject} {claim.predicate} {claim.object_value}"
                await conn.execute(
                    "INSERT OR REPLACE INTO fts_claims(claim_id, content) VALUES (?, ?)",
                    (claim_id, fts_content),
                )
                await conn.commit()

        return claim  # type: ignore[return-value]


    async def get_claims_by_subject(
        self, subject: str, owner_id: str = "default"
    ) -> List[SemanticClaim]:
        """Return all claims whose subject matches a string within an owner scope."""
        conn = await self._get_conn()
        async with conn.execute(
            """
            SELECT * FROM semantic_claim
            WHERE owner_id = ? AND subject = ? AND status = 'active'
            ORDER BY confidence DESC
            """,
            (owner_id, subject),
        ) as cur:
            rows = await cur.fetchall()
        return [_row_to_claim(r) for r in rows]


    async def get_claims_by_entity(
        self,
        entity_id: str,
        owner_id: Optional[str] = None,
    ) -> List[SemanticClaim]:
        """Return active claims linked to a resolved entity_id.

        Notes:
            - API-facing call sites must pass ``owner_id`` to enforce tenant
              isolation.
            - ``owner_id=None`` is retained for internal-only compatibility.
        """
        conn = await self._get_conn()
        if owner_id is None:
            async with conn.execute(
                """
                SELECT * FROM semantic_claim
                WHERE subject_entity_id = ? AND status = 'active'
                ORDER BY confidence DESC
                """,
                (entity_id,),
            ) as cur:
                rows = await cur.fetchall()
        else:
            async with conn.execute(
                """
                SELECT * FROM semantic_claim
                WHERE owner_id = ? AND subject_entity_id = ? AND status = 'active'
                ORDER BY confidence DESC
                """,
                (owner_id, entity_id),
            ) as cur:
                rows = await cur.fetchall()
        return [_row_to_claim(r) for r in rows]


    async def get_claims_by_status(
        self, status: str, owner_id: str = "default"
    ) -> List[SemanticClaim]:
        """Return all claims with a given status for an owner."""
        conn = await self._get_conn()
        async with conn.execute(
            """
            SELECT * FROM semantic_claim
            WHERE owner_id = ? AND status = ?
            ORDER BY updated_at DESC
            """,
            (owner_id, status),
        ) as cur:
            rows = await cur.fetchall()
        return [_row_to_claim(r) for r in rows]


    async def add_provenance(
        self, claim_id: str, episode_event_id: str, **kwargs
    ) -> ClaimProvenance:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            async with conn.execute(
                """
                INSERT INTO claim_provenance
                    (claim_id, episode_event_id, span_start, span_end,
                     extraction_method, extractor_model_id,
                     extraction_prompt_ver, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                RETURNING *
                """,
                (
                    claim_id,
                    episode_event_id,
                    kwargs.get("span_start"),
                    kwargs.get("span_end"),
                    kwargs.get("extraction_method"),
                    kwargs.get("extractor_model_id"),
                    kwargs.get("extraction_prompt_ver"),
                    now,
                ),
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()
            return _row_to_provenance(row)

    async def get_provenance(self, claim_id: str) -> list:
        """Return all provenance records for a given claim."""
        conn = await self._get_conn()
        async with conn.execute(
            "SELECT * FROM claim_provenance WHERE claim_id = ? ORDER BY created_at ASC",
            (claim_id,),
        ) as cur:
            rows = await cur.fetchall()
        return [_row_to_provenance(r) for r in rows]


    async def store_embedding(
        self,
        item_type: str,
        item_id: str,
        embedding: List[float],
        model_id: str,
    ) -> None:
        import math
        import logging
        _log = logging.getLogger(__name__)

        # Validate embedding vector before storage
        if not embedding:
            _log.warning("store_embedding: empty embedding for %s/%s, skipping", item_type, item_id)
            return
        if any(math.isnan(x) or math.isinf(x) for x in embedding):
            _log.warning("store_embedding: NaN/Inf in embedding for %s/%s, skipping", item_type, item_id)
            return
        if all(x == 0.0 for x in embedding):
            _log.warning("store_embedding: zero-vector embedding for %s/%s, skipping", item_type, item_id)
            return

        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            blob = _pack_floats(embedding)

            # Upsert: delete old, insert new
            await conn.execute(
                "DELETE FROM embedding_index WHERE item_type = ? AND item_id = ? AND model_id = ?",
                (item_type, item_id, model_id),
            )
            await conn.execute(
                """
                INSERT INTO embedding_index (item_type, item_id, embedding, model_id, created_at)
                VALUES (?, ?, ?, ?, ?)
                """,
                (item_type, item_id, blob, model_id, now),
            )

            # Also insert into vec_index (sqlite-vec KNN virtual table) if available.
            # The vec0 virtual table uses rowid-based upsert, so we delete first.
            if self._vec_loaded:
                try:
                    await conn.execute(
                        "DELETE FROM vec_index WHERE item_type = ? AND item_id = ?",
                        (item_type, item_id),
                    )
                    await conn.execute(
                        "INSERT INTO vec_index (item_type, item_id, embedding) VALUES (?, ?, ?)",
                        (item_type, item_id, blob),
                    )
                except Exception:
                    import logging as _logging
                    _logging.getLogger(__name__).debug(
                        "vec_index insert failed for %s/%s — brute-force will be used",
                        item_type, item_id,
                    )

            await conn.commit()


    async def vector_search(
        self,
        query_embedding: List[float],
        item_type: Optional[str] = None,
        top_k: int = 50,
        owner_id: str = "default",
    ) -> List[VectorResult]:
        conn = await self._get_conn()

        if self._vec_loaded:
            return await self._vec_search_sqlite_vec(
                conn, query_embedding, item_type, top_k, owner_id
            )
        return await self._vec_search_brute_force(
            conn, query_embedding, item_type, top_k, owner_id
        )


    async def _vec_search_sqlite_vec(
        self,
        conn: aiosqlite.Connection,
        query_embedding: List[float],
        item_type: Optional[str],
        top_k: int,
        owner_id: str,
    ) -> List[VectorResult]:
        """sqlite-vec powered KNN search via vec_index virtual table."""
        dim = len(query_embedding)
        blob = _pack_floats(query_embedding)

        try:
            if item_type:
                async with conn.execute(
                    """
                    SELECT v.item_type, v.item_id, v.distance
                    FROM vec_index v
                    WHERE v.item_type = ?
                      AND v.embedding MATCH ?
                      AND k = ?
                    ORDER BY v.distance
                    """,
                    (item_type, blob, top_k),
                ) as cur:
                    rows = await cur.fetchall()
            else:
                async with conn.execute(
                    """
                    SELECT v.item_type, v.item_id, v.distance
                    FROM vec_index v
                    WHERE v.embedding MATCH ?
                      AND k = ?
                    ORDER BY v.distance
                    """,
                    (blob, top_k),
                ) as cur:
                    rows = await cur.fetchall()


            # Enforce owner isolation for claim embeddings. sqlite-vec does not
            # support arbitrary joins in the KNN query itself, so we post-filter.
            if item_type == "claim" or item_type is None:
                claim_ids = [r["item_id"] for r in rows if r["item_type"] == "claim"]
                if claim_ids:
                    placeholders = ",".join("?" for _ in claim_ids)
                    async with conn.execute(
                        f"SELECT id FROM semantic_claim WHERE owner_id = ? AND id IN ({placeholders})",
                        [owner_id, *claim_ids],
                    ) as cur:
                        allowed = {row["id"] for row in await cur.fetchall()}
                    rows = [
                        r for r in rows
                        if r["item_type"] != "claim" or r["item_id"] in allowed
                    ]

            results = [
                VectorResult(
                    item_type=r["item_type"],
                    item_id=r["item_id"],
                    score=1.0 - r["distance"],  # convert L2 distance to similarity
                )
                for r in rows
            ]

            # If vec_index returned 0 results, the index may be empty (data
            # only lives in embedding_index).  Fall back to brute-force so
            # retrieval still works while the index catches up.
            if not results:
                import logging as _logging
                _logging.getLogger(__name__).info(
                    "vec_index KNN returned 0 results; falling back to brute-force"
                )
                return await self._vec_search_brute_force(
                    conn, query_embedding, item_type, top_k, owner_id
                )

            return results
        except Exception:
            # vec_index may not be created yet; cache failure so subsequent
            # calls skip the attempt entirely instead of re-throwing each time.
            self._vec_loaded = False
            import logging as _logging
            _logging.getLogger(__name__).info("vec_index unavailable, falling back to brute-force search")
            return await self._vec_search_brute_force(
                conn, query_embedding, item_type, top_k, owner_id
            )


    #: Maximum number of embeddings to scan in brute-force mode before
    #: falling back to FTS-only search to prevent OOM on large datasets.
    MAX_BRUTE_FORCE_EMBEDDINGS: int = 10_000

    async def _vec_search_brute_force(
        self,
        conn: aiosqlite.Connection,
        query_embedding: List[float],
        item_type: Optional[str],
        top_k: int,
        owner_id: Optional[str] = None,
    ) -> List[VectorResult]:
        """Brute-force cosine similarity over embedding_index table.

        If the number of stored embeddings exceeds MAX_BRUTE_FORCE_EMBEDDINGS,
        logs a warning and returns an empty list so the caller can fall back
        to FTS-only search instead of loading everything into memory.

        Backward compatibility: ``owner_id`` is optional for legacy internal
        call sites/tests that invoked this helper before owner-aware filtering
        was added. When omitted, claim rows are not owner-filtered.
        """
        import logging
        _log = logging.getLogger(__name__)

        # Check count first to avoid OOM
        count_sql = "SELECT COUNT(*) FROM embedding_index"
        count_params: list = []
        if item_type:
            count_sql += " WHERE item_type = ?"
            count_params.append(item_type)
            if item_type == "claim" and owner_id is not None:
                count_sql += " AND item_id IN (SELECT id FROM semantic_claim WHERE owner_id = ?)"
                count_params.append(owner_id)
        else:
            if owner_id is not None:
                count_sql += " WHERE item_type = 'claim' AND item_id IN (SELECT id FROM semantic_claim WHERE owner_id = ?)"
                count_params.append(owner_id)
            else:
                count_sql += " WHERE item_type = 'claim'"
        async with conn.execute(count_sql, count_params) as cur:
            row = await cur.fetchone()
        total = row[0] if row else 0

        if total > self.MAX_BRUTE_FORCE_EMBEDDINGS:
            _log.warning(
                "Brute-force vector search: %d embeddings exceed cap of %d. "
                "Returning empty results — caller should fall back to FTS.",
                total,
                self.MAX_BRUTE_FORCE_EMBEDDINGS,
            )
            return []

        if item_type:
            if item_type == "claim" and owner_id is not None:
                async with conn.execute(
                    """
                    SELECT ei.item_type, ei.item_id, ei.embedding, ei.model_id
                    FROM embedding_index ei
                    JOIN semantic_claim c ON c.id = ei.item_id
                    WHERE ei.item_type = ? AND c.owner_id = ?
                    """,
                    (item_type, owner_id),
                ) as cur:
                    rows = await cur.fetchall()
            else:
                async with conn.execute(
                    "SELECT item_type, item_id, embedding, model_id FROM embedding_index WHERE item_type = ?",
                    (item_type,),
                ) as cur:
                    rows = await cur.fetchall()
        else:
            if owner_id is not None:
                async with conn.execute(
                    """
                    SELECT ei.item_type, ei.item_id, ei.embedding, ei.model_id
                    FROM embedding_index ei
                    JOIN semantic_claim c ON c.id = ei.item_id
                    WHERE ei.item_type = 'claim' AND c.owner_id = ?
                    """,
                    (owner_id,),
                ) as cur:
                    rows = await cur.fetchall()
            else:
                async with conn.execute(
                    """
                    SELECT item_type, item_id, embedding, model_id
                    FROM embedding_index
                    WHERE item_type = 'claim'
                    """
                ) as cur:
                    rows = await cur.fetchall()

        scored: List[VectorResult] = []
        for row in rows:
            blob = row["embedding"]
            if not blob:
                continue
            vec = _unpack_floats(bytes(blob))
            if len(vec) != len(query_embedding):
                continue
            sim = _cosine_similarity(query_embedding, vec)
            scored.append(
                VectorResult(
                    item_type=row["item_type"],
                    item_id=row["item_id"],
                    score=sim,
                    model_id=row["model_id"],
                )
            )

        scored.sort(key=lambda r: r.score, reverse=True)
        return scored[:top_k]

    # ------------------------------------------------------------------ #
    # FTS operations
    # ------------------------------------------------------------------ #

    @staticmethod
    def _sanitize_fts_query(query: str) -> str:
        """Escape FTS5 special characters for safe MATCH.

        FTS5 MATCH accepts operators (AND, OR, NOT, NEAR, *, ^) that could
        alter query semantics or cause syntax errors (#219).  We strip
        non-word/non-space characters so only plain tokens remain.
        """
        import re
        # Remove FTS5 operators and special chars that break MATCH syntax
        sanitized = re.sub(r'[^\w\s]', ' ', query)
        # Collapse whitespace and strip
        sanitized = ' '.join(sanitized.split())
        if not sanitized:
            return '""'
        return sanitized

    @staticmethod
    def _sanitize_fts_query_strict(query: str) -> str:
        """Sanitize user-facing FTS5 query as a quoted literal phrase (#219).

        Wraps each token in double quotes so FTS5 operators embedded in
        user input (AND, OR, NOT, NEAR, *, ^) are treated as literals.
        """
        import re
        sanitized = re.sub(r'[^\w\s]', ' ', query)
        tokens = sanitized.split()
        if not tokens:
            return '""'
        # Quote each token individually so multi-word queries still match
        # documents containing all tokens (implicit AND), while preventing
        # operator injection.
        return ' '.join(f'"{t}"' for t in tokens)


    async def fts_search(
        self,
        query: str,
        table: str = "claims",
        top_k: int = 50,
        owner_id: str = "default",
    ) -> List[FTSResult]:
        conn = await self._get_conn()
        query = self._sanitize_fts_query(query)

        if table == "claims":
            async with conn.execute(
                """
                SELECT f.claim_id AS item_id,
                       f.content,
                       bm25(fts_claims) AS rank
                FROM fts_claims f
                JOIN semantic_claim c ON c.id = f.claim_id
                WHERE fts_claims MATCH ?
                  AND c.owner_id = ?
                ORDER BY rank
                LIMIT ?
                """,
                (query, owner_id, top_k),
            ) as cur:
                rows = await cur.fetchall()
        elif table == "episodes":
            async with conn.execute(
                """
                SELECT f.event_id AS item_id,
                       f.content,
                       bm25(fts_episodes) AS rank
                FROM fts_episodes f
                JOIN episode_event e ON e.id = f.event_id
                WHERE fts_episodes MATCH ?
                  AND e.owner_id = ?
                ORDER BY rank
                LIMIT ?
                """,
                (query, owner_id, top_k),
            ) as cur:
                rows = await cur.fetchall()
        else:
            raise ValueError(f"Unknown FTS table: {table!r}. Use 'claims' or 'episodes'.")

        return [
            FTSResult(item_id=r["item_id"], content=r["content"], rank=r["rank"])
            for r in rows
        ]


    async def create_edge(
        self,
        source_type: str,
        source_id: str,
        target_type: str,
        target_id: str,
        edge_type: str,
        owner_id: str = "default",
        **kwargs,
    ) -> MemoryEdge:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            confidence = kwargs.get("confidence", 1.0)
            metadata_json = kwargs.get("metadata_json")

            async with conn.execute(
                """
                INSERT INTO memory_edge
                    (owner_id, source_type, source_id,
                     target_type, target_id, edge_type,
                     confidence, metadata_json, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                RETURNING *
                """,
                (owner_id, source_type, source_id,
                 target_type, target_id, edge_type,
                 confidence, metadata_json, now),
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()
            return _row_to_edge(row)


    async def get_neighbors(
        self,
        item_type: str,
        item_id: str,
        edge_types: Optional[List[str]] = None,
        max_depth: int = 1,
    ) -> List[MemoryEdge]:
        conn = await self._get_conn()

        if edge_types:
            placeholders = ", ".join("?" for _ in edge_types)
            async with conn.execute(
                f"""
                SELECT * FROM memory_edge
                WHERE source_type = ? AND source_id = ?
                  AND edge_type IN ({placeholders})
                """,
                [item_type, item_id] + edge_types,
            ) as cur:
                rows = await cur.fetchall()
        else:
            async with conn.execute(
                """
                SELECT * FROM memory_edge
                WHERE source_type = ? AND source_id = ?
                """,
                (item_type, item_id),
            ) as cur:
                rows = await cur.fetchall()

        return [_row_to_edge(r) for r in rows]


    async def create_feedback(
        self,
        target_type: str,
        target_id: str,
        feedback_type: str,
        owner_id: str = "default",
        **kwargs,
    ) -> MemoryFeedback:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            async with conn.execute(
                """
                INSERT INTO memory_feedback
                    (owner_id, target_type, target_id, feedback_type,
                     feedback_value, source, status, created_at)
                VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)
                RETURNING *
                """,
                (
                    owner_id, target_type, target_id, feedback_type,
                    kwargs.get("feedback_value"),
                    kwargs.get("source", "operator"),
                    now,
                ),
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()
            return _row_to_feedback(row)


    async def get_pending_feedback(
        self, owner_id: str = "default"
    ) -> List[MemoryFeedback]:
        conn = await self._get_conn()
        async with conn.execute(
            """
            SELECT * FROM memory_feedback
            WHERE owner_id = ? AND status = 'pending'
            ORDER BY created_at ASC
            """,
            (owner_id,),
        ) as cur:
            rows = await cur.fetchall()
        return [_row_to_feedback(r) for r in rows]


    async def apply_feedback(self, feedback_id: str) -> MemoryFeedback:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            await conn.execute(
                """
                UPDATE memory_feedback
                   SET status = 'applied', applied_at = ?
                 WHERE id = ?
                """,
                (now, feedback_id),
            )
            await conn.commit()
            async with conn.execute(
                "SELECT * FROM memory_feedback WHERE id = ?", (feedback_id,)
            ) as cur:
                row = await cur.fetchone()
            assert row is not None
            return _row_to_feedback(row)


    async def log_retrieval(self, **kwargs) -> None:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            cols = {
                "owner_id": kwargs.get("owner_id", "default"),
                "session_id": kwargs.get("session_id"),
                "query_text": kwargs.get("query_text", ""),
                "mode": kwargs.get("mode", "hybrid"),
                "token_budget": kwargs.get("token_budget"),
                "tokens_used": kwargs.get("tokens_used"),
                "candidates_json": kwargs.get("candidates_json"),
                "rerank_model": kwargs.get("rerank_model"),
                "selected_json": kwargs.get("selected_json"),
                "final_context_hash": kwargs.get("final_context_hash"),
                "latency_ms": kwargs.get("latency_ms"),
                "created_at": now,
            }
            col_names = ", ".join(cols.keys())
            placeholders = ", ".join("?" for _ in cols)
            await conn.execute(
                f"INSERT INTO retrieval_log ({col_names}) VALUES ({placeholders})",
                list(cols.values()),
            )
            await conn.commit()


    async def log_drift(self, **kwargs) -> None:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            cols = {
                "cornerstone_id": kwargs["cornerstone_id"],
                "drift_score": kwargs["drift_score"],
                "semantic_sim": kwargs.get("semantic_sim"),
                "length_ratio": kwargs.get("length_ratio"),
                "phrase_preservation": kwargs.get("phrase_preservation"),
                "action_taken": kwargs.get("action_taken", "none"),
                "current_text": kwargs.get("current_text"),
                "golden_text": kwargs.get("golden_text"),
                "checked_at": kwargs.get("checked_at", now),
            }
            col_names = ", ".join(cols.keys())
            placeholders = ", ".join("?" for _ in cols)
            await conn.execute(
                f"INSERT INTO drift_log ({col_names}) VALUES ({placeholders})",
                list(cols.values()),
            )
            await conn.commit()


    async def log_deprecation(self, **kwargs) -> None:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            cols = {
                "claim_id": kwargs["claim_id"],
                "session_id": kwargs.get("session_id"),
                "action": kwargs["action"],
                "reason": kwargs.get("reason"),
                "classifier": kwargs.get("classifier"),
                "created_at": now,
            }
            col_names = ", ".join(cols.keys())
            placeholders = ", ".join("?" for _ in cols)
            await conn.execute(
                f"INSERT INTO deprecation_log ({col_names}) VALUES ({placeholders})",
                list(cols.values()),
            )
            await conn.commit()


    async def get_config(self, key: str) -> Optional[str]:
        conn = await self._get_conn()
        async with conn.execute(
            "SELECT value FROM cortex_config WHERE key = ?", (key,)
        ) as cur:
            row = await cur.fetchone()
        return row[0] if row else None


    async def set_config(self, key: str, value: str) -> None:
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            await conn.execute(
                """
                INSERT INTO cortex_config (key, value, updated_at)
                VALUES (?, ?, ?)
                ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
                """,
                (key, value, now),
            )
            await conn.commit()


    async def delete_claim(
        self, claim_id: str, owner_id: str, hard: bool = False
    ) -> None:
        """Delete or archive a semantic claim."""
        conn = await self._get_conn()
        async with self._write_lock:
            if hard:
                await conn.execute(
                    "DELETE FROM semantic_claim WHERE id = ? AND owner_id = ?",
                    (claim_id, owner_id),
                )
            else:
                await conn.execute(
                    """
                    UPDATE semantic_claim
                    SET status = 'archived'
                    WHERE id = ? AND owner_id = ?
                    """,
                    (claim_id, owner_id),
                )
            # Sync FTS: remove entry on hard delete or soft delete (archived claims
            # should not appear in search results)
            await conn.execute(
                "DELETE FROM fts_claims WHERE claim_id = ?",
                (claim_id,),
            )
            await conn.commit()


    async def ping(self) -> bool:
        """Verify DB connection with a lightweight SELECT 1."""
        import logging
        _logger = logging.getLogger(__name__)
        try:
            conn = await self._get_conn()
            async with conn.execute("SELECT 1") as cur:
                await cur.fetchone()
            return True
        except Exception as e:
            _logger.warning("ping: DB connection check failed: %s", e)
            return False


    async def log_changelog(
        self,
        claim_id: str,
        version: int,
        content_snapshot: str,
        action: str,
        reason: Optional[str],
        changed_by: str,
        diff_summary: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Insert an immutable audit entry into claim_changelog."""
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            async with conn.execute(
                """
                INSERT INTO claim_changelog
                    (claim_id, version, content_snapshot, action, reason,
                     changed_by, diff_summary, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                RETURNING *
                """,
                (claim_id, version, content_snapshot, action, reason,
                 changed_by, diff_summary, now),
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()
            return dict(row)


    async def get_changelog(self, claim_id: str) -> List[Dict[str, Any]]:
        """Return all changelog entries for *claim_id*, ordered by version ascending."""
        conn = await self._get_conn()
        async with conn.execute(
            """
            SELECT * FROM claim_changelog
            WHERE claim_id = ?
            ORDER BY version ASC
            """,
            (claim_id,),
        ) as cur:
            rows = await cur.fetchall()
        return [dict(r) for r in rows]


    async def browse_memories(
        self,
        owner_id: str = "default",
        page: int = 1,
        per_page: int = 50,
        time_range: str = "all",
        status: Optional[str] = None,
        category: Optional[str] = None,
        entity_name: Optional[str] = None,
        q: Optional[str] = None,
        sort_by: str = "updated_at",
        sort_order: str = "desc",
    ) -> Dict[str, Any]:
        """Browse and filter semantic claims with pagination.

        Returns a dict with keys: items (list of dicts), total, page, per_page,
        pages, category_counts.
        """
        # Clamp per_page at the storage layer (L2 audit fix).
        # The HTTP layer also caps this, but storage must enforce its own ceiling
        # so that direct storage callers cannot bypass the limit.
        per_page = min(int(per_page), MAX_PAGE_SIZE)
        conn = await self._get_conn()

        # Validate sort params to prevent SQL injection
        _valid_sort_by = {"updated_at", "created_at", "confidence", "access_count"}
        _valid_sort_order = {"desc", "asc"}
        if sort_by not in _valid_sort_by:
            sort_by = "updated_at"
        if sort_order not in _valid_sort_order:
            sort_order = "desc"

        # Build WHERE clause
        conditions: List[str] = ["sc.owner_id = ?"]
        params: List[Any] = [owner_id]

        # Status filter
        if status and status != "all":
            conditions.append("sc.status = ?")
            params.append(status)

        # Category filter
        if category:
            conditions.append("sc.category = ?")
            params.append(category)

        # Time range filter
        if time_range == "1d":
            conditions.append("sc.updated_at >= datetime('now', '-1 day')")
        elif time_range == "7d":
            conditions.append("sc.updated_at >= datetime('now', '-7 days')")
        elif time_range == "30d":
            conditions.append("sc.updated_at >= datetime('now', '-30 days')")

        # Entity name filter (join entity aliases)
        if entity_name:
            # Escape LIKE wildcards in user input (#220)
            safe_name = entity_name.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
            conditions.append(
                "sc.subject_entity_id IN ("
                "  SELECT entity_id FROM entity_alias"
                "  WHERE alias LIKE ? ESCAPE '\\' AND owner_id = ?"
                ")"
            )
            params.extend([f"%{safe_name}%", owner_id])

        # Keyword search via FTS
        fts_ids: Optional[List[str]] = None
        if q:
            try:
                sanitized_q = self._sanitize_fts_query_strict(q)
                async with conn.execute(
                    "SELECT claim_id FROM fts_claims WHERE fts_claims MATCH ?",
                    (sanitized_q,),
                ) as cur:
                    fts_rows = await cur.fetchall()
                fts_ids = [r[0] for r in fts_rows]
                if fts_ids:
                    placeholders = ",".join("?" for _ in fts_ids)
                    conditions.append(f"sc.id IN ({placeholders})")
                    params.extend(fts_ids)
                else:
                    # No FTS match — return empty result quickly
                    return {
                        "items": [], "total": 0, "page": page,
                        "per_page": per_page, "pages": 0,
                        "category_counts": {},
                    }
            except Exception:
                # FTS not available — fall back to LIKE search
                # Escape LIKE wildcards in user input (#220)
                safe_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
                conditions.append(
                    "(sc.subject LIKE ? ESCAPE '\\'"
                    " OR sc.predicate LIKE ? ESCAPE '\\'"
                    " OR sc.object_value LIKE ? ESCAPE '\\')"
                )
                like_q = f"%{safe_q}%"
                params.extend([like_q, like_q, like_q])

        where_clause = " AND ".join(conditions)

        # Count total
        count_sql = f"SELECT COUNT(*) FROM semantic_claim sc WHERE {where_clause}"
        async with conn.execute(count_sql, params) as cur:
            row = await cur.fetchone()
        total = row[0] if row else 0

        # Fetch page
        offset = (page - 1) * per_page
        select_sql = (
            f"SELECT sc.* FROM semantic_claim sc "
            f"WHERE {where_clause} "
            f"ORDER BY sc.{sort_by} {sort_order.upper()} "
            f"LIMIT ? OFFSET ?"
        )
        async with conn.execute(select_sql, params + [per_page, offset]) as cur:
            rows = await cur.fetchall()

        items = [_row_to_claim(r) for r in rows]
        items_out = []
        for c in items:
            # Build a human-readable content field from the SPO triple
            obj_preview = (c.object_value[:200] if c.object_value else "")
            content = f"{c.subject} {c.predicate} {obj_preview}".strip()
            items_out.append({
                "id": c.id,
                "content": content,
                "subject": c.subject,
                "predicate": c.predicate,
                "object_value": obj_preview,
                "category": getattr(c, "category", "misc"),
                "confidence": c.confidence,
                "sensitivity": c.sensitivity,
                "status": c.status,
                "created_at": c.created_at,
                "updated_at": c.updated_at,
                "access_count": c.access_count,
            })

        # Category counts (across owner, regardless of page filters)
        async with conn.execute(
            "SELECT category, COUNT(*) FROM semantic_claim WHERE owner_id = ? GROUP BY category",
            (owner_id,),
        ) as cur:
            cat_rows = await cur.fetchall()
        category_counts = {r[0]: r[1] for r in cat_rows}

        pages = max(1, (total + per_page - 1) // per_page) if total > 0 else 0

        return {
            "items": items_out,
            "total": total,
            "page": page,
            "per_page": per_page,
            "pages": pages,
            "category_counts": category_counts,
        }


    async def get_category_counts(self, owner_id: str = "default") -> List[Dict[str, Any]]:
        """Return per-category claim counts for the given owner."""
        conn = await self._get_conn()
        async with conn.execute(
            """
            SELECT category, COUNT(*) as cnt
            FROM semantic_claim
            WHERE owner_id = ?
            GROUP BY category
            ORDER BY cnt DESC
            """,
            (owner_id,),
        ) as cur:
            rows = await cur.fetchall()
        return [{"name": r[0], "count": r[1]} for r in rows]


    async def log_request(
        self,
        owner_id: str,
        request_type: str,
        entities: Optional[Any] = None,
        has_results: Optional[bool] = None,
        latency_ms: Optional[int] = None,
        payload: Optional[Any] = None,
        response_summary: Optional[str] = None,
        status: str = "success",
        session_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Insert a structured request log entry."""
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()

            entities_json: Optional[str] = None
            if entities is not None:
                entities_json = json.dumps(entities) if not isinstance(entities, str) else entities

            request_payload: Optional[str] = None
            if payload is not None:
                request_payload = json.dumps(payload) if not isinstance(payload, str) else payload

            has_results_int: Optional[int] = None
            if has_results is not None:
                has_results_int = 1 if has_results else 0

            async with conn.execute(
                """
                INSERT INTO request_log
                    (owner_id, request_type, entities_json, has_results, latency_ms,
                     request_payload, response_summary, status, session_id, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                RETURNING *
                """,
                (owner_id, request_type, entities_json, has_results_int, latency_ms,
                 request_payload, response_summary, status, session_id, now),
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()
            return dict(row)


    async def get_request_log(
        self,
        owner_id: str,
        request_type: Optional[str] = None,
        limit: int = 50,
        offset: int = 0,
    ) -> List[Dict[str, Any]]:
        """Return paginated request log entries for an owner."""
        conn = await self._get_conn()
        if request_type is not None:
            async with conn.execute(
                """
                SELECT * FROM request_log
                WHERE owner_id = ? AND request_type = ?
                ORDER BY created_at DESC
                LIMIT ? OFFSET ?
                """,
                (owner_id, request_type, limit, offset),
            ) as cur:
                rows = await cur.fetchall()
        else:
            async with conn.execute(
                """
                SELECT * FROM request_log
                WHERE owner_id = ?
                ORDER BY created_at DESC
                LIMIT ? OFFSET ?
                """,
                (owner_id, limit, offset),
            ) as cur:
                rows = await cur.fetchall()
        return [dict(r) for r in rows]


    async def count_claims(self, owner_id: str = "default") -> int:
        conn = await self._get_conn()
        async with conn.execute(
            "SELECT COUNT(*) FROM semantic_claim WHERE owner_id = ?", (owner_id,)
        ) as cur:
            row = await cur.fetchone()
        return row[0] if row else 0

