"""SQLite Index-Management · FTS5 + Entitäten + Relationen + Vektoren. [B§4.7]

Zentrale Datenbank für alle Memory-Indizes.
Alles ableitbar aus den Markdown Source-of-Truth Dateien.

Thread-Safety:
SQLite im WAL-Modus unterstützt gleichzeitige Reads, aber nur einen
Writer. Bei Multi-Channel-Betrieb (z.B. Telegram + CLI + Cron) können
gleichzeitige Writes zu "database is locked" Fehlern führen.

Lösung: Alle Write-Operationen werden über ein threading.RLock
serialisiert. Reads benötigen kein Lock (WAL erlaubt concurrent reads).
RLock statt Lock, da manche Write-Methoden intern andere Write-Methoden
aufrufen (z.B. delete_entity → DELETE relations + DELETE entities).
"""

from __future__ import annotations

import json
import sqlite3
import struct
import threading
from datetime import datetime
from pathlib import Path
from typing import Any

from jarvis.models import Chunk, Entity, MemoryTier, Relation


def _serialize_vector(vec: list[float]) -> bytes:
    """Serialisiert einen Float-Vektor zu Bytes (für sqlite-vec Kompatibilität)."""
    return struct.pack(f"{len(vec)}f", *vec)


def _deserialize_vector(data: bytes) -> list[float]:
    """Deserialisiert Bytes zurück zu Float-Vektor."""
    n = len(data) // 4
    return list(struct.unpack(f"{n}f", data))


class MemoryIndex:
    """SQLite-basierter Index für das Memory-System.

    Schema:
    - chunks: Alle Text-Chunks mit Metadaten
    - chunks_fts: FTS5 Volltextsuche (BM25)
    - embeddings: Vektor-Embeddings (content_hash → vector)
    - entities: Wissens-Graph Entitäten
    - relations: Beziehungen zwischen Entitäten
    """

    def __init__(self, db_path: str | Path) -> None:
        """Initialisiert den Memory-Index mit SQLite-Datenbank."""
        self._db_path = Path(db_path)
        self._conn: sqlite3.Connection | None = None
        self._write_lock = threading.RLock()

    @property
    def db_path(self) -> Path:
        """Gibt den Pfad zur SQLite-Datenbank zurück."""
        return self._db_path

    @property
    def conn(self) -> sqlite3.Connection:
        """Lazy connection. Erstellt DB beim ersten Zugriff.

        Thread-safe: Connection-Erstellung ist über das Write-Lock geschützt,
        da _init_schema() Schreiboperationen ausführt.
        """
        if self._conn is None:
            with self._write_lock:
                # Double-check nach Lock-Erwerb (ein anderer Thread
                # könnte die Connection inzwischen erstellt haben)
                if self._conn is None:
                    self._db_path.parent.mkdir(parents=True, exist_ok=True)
                    self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
                    self._conn.row_factory = sqlite3.Row
                    self._conn.execute("PRAGMA journal_mode=WAL")
                    self._conn.execute("PRAGMA busy_timeout=5000")
                    self._conn.execute("PRAGMA foreign_keys=ON")
                    self._init_schema()
        return self._conn

    def _init_schema(self) -> None:
        """Erstellt alle Tabellen und Indizes."""
        c = self.conn
        c.executescript(
            """
            -- Chunks (alle Memory-Tiers)
            CREATE TABLE IF NOT EXISTS chunks (
                id TEXT PRIMARY KEY,
                text TEXT NOT NULL,
                source_path TEXT NOT NULL,
                line_start INTEGER DEFAULT 0,
                line_end INTEGER DEFAULT 0,
                content_hash TEXT NOT NULL,
                memory_tier TEXT NOT NULL,
                timestamp REAL,
                token_count INTEGER DEFAULT 0,
                entities_json TEXT DEFAULT '[]',
                created_at REAL NOT NULL
            );

            CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source_path);
            CREATE INDEX IF NOT EXISTS idx_chunks_tier ON chunks(memory_tier);
            CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(content_hash);
            CREATE INDEX IF NOT EXISTS idx_chunks_created_at ON chunks(created_at);
            CREATE INDEX IF NOT EXISTS idx_chunks_tier_created ON chunks(memory_tier, created_at);

            -- FTS5 Volltextsuche (BM25)
            CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
                text,
                content='chunks',
                content_rowid='rowid',
                tokenize='unicode61 remove_diacritics 2'
            );

            -- Trigger für FTS5 Sync
            CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
                INSERT INTO chunks_fts(rowid, text) VALUES (new.rowid, new.text);
            END;

            CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
                INSERT INTO chunks_fts(chunks_fts, rowid, text)
                VALUES('delete', old.rowid, old.text);
            END;

            CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
                INSERT INTO chunks_fts(chunks_fts, rowid, text)
                VALUES('delete', old.rowid, old.text);
                INSERT INTO chunks_fts(rowid, text)
                VALUES (new.rowid, new.text);
            END;

            -- Embeddings (content_hash → Vektor)
            CREATE TABLE IF NOT EXISTS embeddings (
                content_hash TEXT PRIMARY KEY,
                vector BLOB NOT NULL,
                model_name TEXT NOT NULL,
                dimensions INTEGER NOT NULL,
                created_at REAL NOT NULL
            );

            -- Entitäten (Wissens-Graph)
            CREATE TABLE IF NOT EXISTS entities (
                id TEXT PRIMARY KEY,
                type TEXT NOT NULL,
                name TEXT NOT NULL,
                attributes_json TEXT DEFAULT '{}',
                source_file TEXT DEFAULT '',
                created_at REAL NOT NULL,
                updated_at REAL NOT NULL,
                confidence REAL DEFAULT 1.0
            );

            CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
            CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
            CREATE INDEX IF NOT EXISTS idx_entities_source ON entities(source_file);

            -- Relationen
            CREATE TABLE IF NOT EXISTS relations (
                id TEXT PRIMARY KEY,
                source_entity TEXT NOT NULL REFERENCES entities(id),
                relation_type TEXT NOT NULL,
                target_entity TEXT NOT NULL REFERENCES entities(id),
                attributes_json TEXT DEFAULT '{}',
                source_file TEXT DEFAULT '',
                created_at REAL NOT NULL,
                confidence REAL DEFAULT 1.0
            );

            CREATE INDEX IF NOT EXISTS idx_relations_type ON relations(relation_type);
            CREATE INDEX IF NOT EXISTS idx_relations_source ON relations(source_entity);
            CREATE INDEX IF NOT EXISTS idx_relations_target ON relations(target_entity);
        """
        )

    # ── Chunk Operations ─────────────────────────────────────────

    def upsert_chunk(self, chunk: Chunk) -> None:
        """Fügt einen Chunk ein oder aktualisiert ihn und committet.

        Thread-safe: Geschützt über Write-Lock.
        """
        with self._write_lock:
            self._upsert_chunk_impl(chunk)
            self.conn.commit()

    def _upsert_chunk_impl(self, chunk: Chunk) -> None:
        """Internal: insert/update ohne Lock und ohne Commit (für Batch-Nutzung)."""
        now = datetime.now().timestamp()
        ts = chunk.timestamp.timestamp() if chunk.timestamp else None

        self.conn.execute(
            """
            INSERT INTO chunks (id, text, source_path, line_start, line_end,
                              content_hash, memory_tier, timestamp, token_count,
                              entities_json, created_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(id) DO UPDATE SET
                text=excluded.text,
                source_path=excluded.source_path,
                line_start=excluded.line_start,
                line_end=excluded.line_end,
                content_hash=excluded.content_hash,
                memory_tier=excluded.memory_tier,
                timestamp=excluded.timestamp,
                token_count=excluded.token_count,
                entities_json=excluded.entities_json
            """,
            (
                chunk.id,
                chunk.text,
                chunk.source_path,
                chunk.line_start,
                chunk.line_end,
                chunk.content_hash,
                chunk.memory_tier.value,
                ts,
                chunk.token_count,
                json.dumps(chunk.entities),
                now,
            ),
        )

    def upsert_chunks(self, chunks: list[Chunk]) -> int:
        """Batch-Insert von Chunks. Returns Anzahl eingefügt.

        Thread-safe: Geschützt über Write-Lock.
        """
        now = datetime.now().timestamp()
        rows = []
        for c in chunks:
            ts = c.timestamp.timestamp() if c.timestamp else None
            rows.append(
                (
                    c.id,
                    c.text,
                    c.source_path,
                    c.line_start,
                    c.line_end,
                    c.content_hash,
                    c.memory_tier.value,
                    ts,
                    c.token_count,
                    json.dumps(c.entities),
                    now,
                )
            )

        with self._write_lock:
            self.conn.executemany(
                """
                INSERT INTO chunks (id, text, source_path, line_start, line_end,
                                  content_hash, memory_tier, timestamp, token_count,
                                  entities_json, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(id) DO UPDATE SET
                    text=excluded.text,
                    content_hash=excluded.content_hash,
                    memory_tier=excluded.memory_tier,
                    timestamp=excluded.timestamp,
                    token_count=excluded.token_count,
                    entities_json=excluded.entities_json
                """,
                rows,
            )
            self.conn.commit()
        return len(rows)

    def delete_chunks_by_source(self, source_path: str) -> int:
        """Löscht alle Chunks einer Quelldatei. Returns Anzahl gelöscht."""
        with self._write_lock:
            cursor = self.conn.execute("DELETE FROM chunks WHERE source_path = ?", (source_path,))
            self.conn.commit()
            return cursor.rowcount

    def get_chunk_by_id(self, chunk_id: str) -> Chunk | None:
        """Lädt einen Chunk anhand seiner ID."""
        row = self.conn.execute("SELECT * FROM chunks WHERE id = ?", (chunk_id,)).fetchone()
        if row is None:
            return None
        return self._row_to_chunk(row)

    def get_chunks_by_ids(self, chunk_ids: list[str]) -> dict[str, Chunk]:
        """Lädt mehrere Chunks anhand ihrer IDs in einem Batch.

        Nutzt SELECT ... WHERE id IN (...) statt einzelner Queries (N+1 vermeiden).
        Beachtet SQLite's Parameter-Limit von 999 und splittet bei Bedarf.

        Args:
            chunk_ids: Liste von Chunk-IDs.

        Returns:
            Dict mapping chunk_id → Chunk. Fehlende IDs werden ausgelassen.
        """
        if not chunk_ids:
            return {}

        result: dict[str, Chunk] = {}
        # SQLite hat ein Standard-Limit von 999 Parametern pro Query
        batch_size = 999

        for i in range(0, len(chunk_ids), batch_size):
            batch = chunk_ids[i : i + batch_size]
            placeholders = ",".join("?" for _ in batch)
            rows = self.conn.execute(
                f"SELECT * FROM chunks WHERE id IN ({placeholders})",
                batch,
            ).fetchall()
            for row in rows:
                result[row["id"]] = self._row_to_chunk(row)

        return result

    def get_chunks_by_source(self, source_path: str) -> list[Chunk]:
        """Alle Chunks einer Quelldatei."""
        rows = self.conn.execute(
            "SELECT * FROM chunks WHERE source_path = ? ORDER BY line_start",
            (source_path,),
        ).fetchall()
        return [self._row_to_chunk(r) for r in rows]

    def get_chunk_ids_by_hash(self, content_hash: str) -> list[str]:
        """Chunk-IDs für einen Content-Hash (für inkrementelle Hash-Map-Updates)."""
        rows = self.conn.execute(
            "SELECT id FROM chunks WHERE content_hash = ?",
            (content_hash,),
        ).fetchall()
        return [r["id"] for r in rows]

    def get_all_content_hashes(self) -> set[str]:
        """Alle content_hashes im Index (für Embedding-Cache)."""
        rows = self.conn.execute("SELECT DISTINCT content_hash FROM chunks").fetchall()
        return {r["content_hash"] for r in rows}

    def count_chunks(self, tier: MemoryTier | None = None) -> int:
        """Zählt Chunks, optional gefiltert nach Tier."""
        if tier:
            row = self.conn.execute(
                "SELECT COUNT(*) as cnt FROM chunks WHERE memory_tier = ?",
                (tier.value,),
            ).fetchone()
        else:
            row = self.conn.execute("SELECT COUNT(*) as cnt FROM chunks").fetchone()
        return row["cnt"] if row else 0

    # ── BM25 Search ──────────────────────────────────────────────

    def search_bm25(self, query: str, top_k: int = 10) -> list[tuple[str, float]]:
        """FTS5 BM25 Suche.

        Args:
            query: Suchbegriffe.
            top_k: Maximale Ergebnisse.

        Returns:
            Liste von (chunk_id, bm25_score) Tupeln.
            Score ist negativ (FTS5 Konvention), wird hier positiv gemacht.
        """
        if not query.strip():
            return []

        # FTS5 Query: Wörter mit OR verbinden, Prefix-Match für deutsche Komposita
        # Sanitize: strip FTS5 operators/special chars to prevent query injection
        import re

        _fts_clean = re.compile(r'["\(\)\*\:\^]')
        words = [_fts_clean.sub("", w) for w in query.strip().split()]
        words = [w for w in words if w and w.upper() not in ("AND", "OR", "NOT", "NEAR")]
        if not words:
            return []
        # Jedes Wort bekommt Prefix-Match (*) für bessere Treffer bei Komposita
        fts_query = " OR ".join(f'"{w}"*' for w in words)

        try:
            rows = self.conn.execute(
                """
                SELECT c.id, rank
                FROM chunks_fts fts
                JOIN chunks c ON c.rowid = fts.rowid
                WHERE chunks_fts MATCH ?
                ORDER BY rank
                LIMIT ?
                """,
                (fts_query, top_k),
            ).fetchall()
        except sqlite3.OperationalError:
            # Fallback bei ungültiger FTS-Query
            return []

        # FTS5 rank ist negativ (kleiner = besser), wir machen ihn positiv
        return [(r["id"], -r["rank"]) for r in rows]

    # ── Embedding Operations ─────────────────────────────────────

    def store_embedding(
        self,
        content_hash: str,
        vector: list[float],
        model_name: str = "qwen3-embedding:0.6b",
    ) -> None:
        """Speichert ein Embedding. Thread-safe."""
        now = datetime.now().timestamp()
        with self._write_lock:
            self.conn.execute(
                """
                INSERT INTO embeddings (content_hash, vector, model_name, dimensions, created_at)
                VALUES (?, ?, ?, ?, ?)
                ON CONFLICT(content_hash) DO UPDATE SET
                    vector=excluded.vector,
                    model_name=excluded.model_name,
                    dimensions=excluded.dimensions
                """,
                (content_hash, _serialize_vector(vector), model_name, len(vector), now),
            )
            self.conn.commit()

    def get_embedding(self, content_hash: str) -> list[float] | None:
        """Lädt ein Embedding anhand des Content-Hash."""
        row = self.conn.execute(
            "SELECT vector FROM embeddings WHERE content_hash = ?",
            (content_hash,),
        ).fetchone()
        if row is None:
            return None
        return _deserialize_vector(row["vector"])

    def get_all_embeddings(self) -> dict[str, list[float]]:
        """Lädt alle Embeddings. Returns {content_hash: vector}."""
        rows = self.conn.execute("SELECT content_hash, vector FROM embeddings").fetchall()
        return {r["content_hash"]: _deserialize_vector(r["vector"]) for r in rows}

    def get_embeddings_by_hashes(
        self,
        content_hashes: set[str],
    ) -> dict[str, list[float]]:
        """Lädt nur Embeddings für die angegebenen Hashes (statt alle).

        Deutlich RAM-effizienter als get_all_embeddings() wenn nur wenige
        Hashes geprüft werden müssen.
        """
        if not content_hashes:
            return {}
        result: dict[str, list[float]] = {}
        # SQLite hat ein SQLITE_MAX_VARIABLE_NUMBER Limit (default 999).
        # Batch in Gruppen von 900 um sicher zu bleiben.
        hash_list = list(content_hashes)
        for i in range(0, len(hash_list), 900):
            batch = hash_list[i : i + 900]
            placeholders = ",".join("?" * len(batch))
            rows = self.conn.execute(
                f"SELECT content_hash, vector FROM embeddings "
                f"WHERE content_hash IN ({placeholders})",
                batch,
            ).fetchall()
            for r in rows:
                result[r["content_hash"]] = _deserialize_vector(r["vector"])
        return result

    def get_embedding_hashes(self) -> set[str]:
        """Gibt alle content_hashes zurück die ein Embedding haben (ohne Vektoren zu laden)."""
        rows = self.conn.execute("SELECT content_hash FROM embeddings").fetchall()
        return {r["content_hash"] for r in rows}

    def count_embeddings(self) -> int:
        """Anzahl gespeicherter Embeddings."""
        row = self.conn.execute("SELECT COUNT(*) as cnt FROM embeddings").fetchone()
        return row["cnt"] if row else 0

    # ── Entity Operations ────────────────────────────────────────

    def upsert_entity(self, entity: Entity) -> None:
        """Fügt eine Entität ein oder aktualisiert sie. Thread-safe."""
        with self._write_lock:
            self.conn.execute(
                """
                INSERT INTO entities (id, type, name, attributes_json, source_file,
                                    created_at, updated_at, confidence)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(id) DO UPDATE SET
                    type=excluded.type,
                    name=excluded.name,
                    attributes_json=excluded.attributes_json,
                    source_file=excluded.source_file,
                    updated_at=excluded.updated_at,
                    confidence=excluded.confidence
                """,
                (
                    entity.id,
                    entity.type,
                    entity.name,
                    json.dumps(entity.attributes, default=str),
                    entity.source_file,
                    entity.created_at.timestamp(),
                    entity.updated_at.timestamp(),
                    entity.confidence,
                ),
            )
            self.conn.commit()

    def get_entity_by_id(self, entity_id: str) -> Entity | None:
        """Lädt eine Entität."""
        row = self.conn.execute("SELECT * FROM entities WHERE id = ?", (entity_id,)).fetchone()
        if row is None:
            return None
        return self._row_to_entity(row)

    def search_entities(
        self,
        name: str | None = None,
        entity_type: str | None = None,
    ) -> list[Entity]:
        """Sucht Entitäten nach Name und/oder Typ."""
        conditions: list[str] = []
        params: list[Any] = []

        if name:
            # Escape LIKE wildcards to prevent injection
            escaped = name.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
            conditions.append("name LIKE ? ESCAPE '\\'")
            params.append(f"%{escaped}%")
        if entity_type:
            conditions.append("type = ?")
            params.append(entity_type)

        where = " AND ".join(conditions) if conditions else "1=1"
        rows = self.conn.execute(
            f"SELECT * FROM entities WHERE {where} ORDER BY name", params
        ).fetchall()
        return [self._row_to_entity(r) for r in rows]

    def delete_entity(self, entity_id: str) -> bool:
        """Löscht eine Entität und ihre Relationen. Thread-safe."""
        with self._write_lock:
            self.conn.execute(
                "DELETE FROM relations WHERE source_entity = ? OR target_entity = ?",
                (entity_id, entity_id),
            )
            cursor = self.conn.execute("DELETE FROM entities WHERE id = ?", (entity_id,))
            self.conn.commit()
            return cursor.rowcount > 0

    def count_entities(self) -> int:
        """Zählt die Anzahl der Entitäten im Index."""
        row = self.conn.execute("SELECT COUNT(*) as cnt FROM entities").fetchone()
        return row["cnt"] if row else 0

    # ── Relation Operations ──────────────────────────────────────

    def upsert_relation(self, relation: Relation) -> None:
        """Fügt eine Relation ein oder aktualisiert sie. Thread-safe."""
        with self._write_lock:
            self.conn.execute(
                """
                INSERT INTO relations (id, source_entity, relation_type, target_entity,
                                     attributes_json, source_file, created_at, confidence)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(id) DO UPDATE SET
                    relation_type=excluded.relation_type,
                    attributes_json=excluded.attributes_json,
                    confidence=excluded.confidence
                """,
                (
                    relation.id,
                    relation.source_entity,
                    relation.relation_type,
                    relation.target_entity,
                    json.dumps(relation.attributes, default=str),
                    relation.source_file,
                    relation.created_at.timestamp(),
                    relation.confidence,
                ),
            )
            self.conn.commit()

    def get_relations_for_entity(
        self,
        entity_id: str,
        relation_type: str | None = None,
    ) -> list[Relation]:
        """Alle Relationen einer Entität (als Quelle oder Ziel)."""
        if relation_type:
            rows = self.conn.execute(
                """SELECT * FROM relations
                   WHERE (source_entity = ? OR target_entity = ?)
                   AND relation_type = ?""",
                (entity_id, entity_id, relation_type),
            ).fetchall()
        else:
            rows = self.conn.execute(
                "SELECT * FROM relations WHERE source_entity = ? OR target_entity = ?",
                (entity_id, entity_id),
            ).fetchall()
        return [self._row_to_relation(r) for r in rows]

    def graph_traverse(
        self,
        entity_id: str,
        max_depth: int = 2,
    ) -> list[Entity]:
        """Traversiert den Wissens-Graph ab einer Entität.

        Verwendet eine iterative BFS-Suche (statt rekursiver CTE) um
        Zyklen im Graphen sicher zu handhaben und exponentielle Blowups
        bei dicht vernetzten Entitäten zu vermeiden.

        Args:
            entity_id: Start-Entität.
            max_depth: Maximale Tiefe.

        Returns:
            Alle erreichbaren Entitäten (ohne Start-Entität).
        """
        visited: set[str] = {entity_id}
        frontier: set[str] = {entity_id}

        for _ in range(max_depth):
            if not frontier:
                break
            # Alle Nachbarn der aktuellen Frontier in einem Query laden
            frontier_list = list(frontier)
            next_frontier: set[str] = set()
            for i in range(0, len(frontier_list), 450):
                batch = frontier_list[i : i + 450]
                placeholders = ",".join("?" * len(batch))
                rows = self.conn.execute(
                    f"SELECT source_entity, target_entity FROM relations "
                    f"WHERE source_entity IN ({placeholders}) "
                    f"OR target_entity IN ({placeholders})",
                    batch + batch,
                ).fetchall()
                for r in rows:
                    for neighbor in (r["source_entity"], r["target_entity"]):
                        if neighbor not in visited:
                            visited.add(neighbor)
                            next_frontier.add(neighbor)
            frontier = next_frontier

        # Start-Entität entfernen
        visited.discard(entity_id)
        if not visited:
            return []

        # Entitäten laden
        result_list = list(visited)
        entities: list[Entity] = []
        for i in range(0, len(result_list), 900):
            batch = result_list[i : i + 900]
            placeholders = ",".join("?" * len(batch))
            rows = self.conn.execute(
                f"SELECT * FROM entities WHERE id IN ({placeholders})",
                batch,
            ).fetchall()
            entities.extend(self._row_to_entity(r) for r in rows)
        return entities

    def count_relations(self) -> int:
        """Zählt die Anzahl der Relationen im Index."""
        row = self.conn.execute("SELECT COUNT(*) as cnt FROM relations").fetchone()
        return row["cnt"] if row else 0

    def get_chunks_with_entity_overlap(
        self,
        entity_ids: set[str],
    ) -> list[tuple[str, list[str]]]:
        """Find chunks whose entities_json contains any of the given entity IDs.

        Uses SQL LIKE filtering to avoid loading all chunks into Python.

        Args:
            entity_ids: Set of entity IDs to search for.

        Returns:
            List of (chunk_id, entity_list) tuples for chunks that have
            at least one entity in *entity_ids*.
        """
        if not entity_ids:
            return []

        # Build a WHERE clause using LIKE for each entity ID.
        # entities_json is a JSON array of strings, e.g. '["e1","e2"]'.
        # We use LIKE with the quoted entity ID to filter at the DB level.
        conditions = []
        params: list[str] = []
        for eid in entity_ids:
            # Escape LIKE wildcards in entity IDs
            escaped = eid.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
            conditions.append("entities_json LIKE ? ESCAPE '\\'")
            params.append(f"%{escaped}%")

        where_clause = " OR ".join(conditions)
        query = (
            f"SELECT id, entities_json FROM chunks WHERE entities_json != '[]' AND ({where_clause})"
        )
        rows = self.conn.execute(query, params).fetchall()

        results: list[tuple[str, list[str]]] = []
        for row in rows:
            chunk_entities = json.loads(row["entities_json"]) if row["entities_json"] else []
            results.append((row["id"], chunk_entities))
        return results

    # ── Maintenance ──────────────────────────────────────────────

    def rebuild_fts(self) -> None:
        """Baut den FTS5 Index komplett neu auf. Thread-safe."""
        with self._write_lock:
            self.conn.execute("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')")
            self.conn.commit()

    def vacuum(self) -> None:
        """Komprimiert die Datenbank. Thread-safe."""
        with self._write_lock:
            self.conn.execute("VACUUM")

    def stats(self) -> dict[str, int]:
        """Statistiken über den Index."""
        return {
            "chunks": self.count_chunks(),
            "embeddings": self.count_embeddings(),
            "entities": self.count_entities(),
            "relations": self.count_relations(),
        }

    def close(self) -> None:
        """Schließt die Datenbankverbindung. Thread-safe."""
        with self._write_lock:
            if self._conn:
                self._conn.close()
                self._conn = None

    # ── Row Conversion ───────────────────────────────────────────

    @staticmethod
    def _row_to_chunk(row: sqlite3.Row) -> Chunk:
        """Konvertiert eine SQLite-Row in ein Chunk-Objekt."""
        ts = datetime.fromtimestamp(row["timestamp"]) if row["timestamp"] is not None else None
        entities = json.loads(row["entities_json"]) if row["entities_json"] else []
        return Chunk(
            id=row["id"],
            text=row["text"],
            source_path=row["source_path"],
            line_start=row["line_start"],
            line_end=row["line_end"],
            content_hash=row["content_hash"],
            memory_tier=MemoryTier(row["memory_tier"]),
            timestamp=ts,
            token_count=row["token_count"],
            entities=entities,
        )

    @staticmethod
    def _row_to_entity(row: sqlite3.Row) -> Entity:
        """Konvertiert eine SQLite-Row in ein Entity-Objekt."""
        attrs = json.loads(row["attributes_json"]) if row["attributes_json"] else {}
        return Entity(
            id=row["id"],
            type=row["type"],
            name=row["name"],
            attributes=attrs,
            source_file=row["source_file"],
            created_at=datetime.fromtimestamp(row["created_at"]),
            updated_at=datetime.fromtimestamp(row["updated_at"]),
            confidence=row["confidence"],
        )

    @staticmethod
    def _row_to_relation(row: sqlite3.Row) -> Relation:
        """Konvertiert eine SQLite-Row in ein Relation-Objekt."""
        attrs = json.loads(row["attributes_json"]) if row["attributes_json"] else {}
        return Relation(
            id=row["id"],
            source_entity=row["source_entity"],
            relation_type=row["relation_type"],
            target_entity=row["target_entity"],
            attributes=attrs,
            source_file=row["source_file"],
            created_at=datetime.fromtimestamp(row["created_at"]),
            confidence=row["confidence"],
        )
