"""SQLite + FTS5 + sqlite-vec DAL 层

所有数据库操作集中在此文件。sqlite-vec 调用仅在 _load_vec_extension
和 vector_search 中出现，方便未来替换。
"""

from __future__ import annotations

import math
import re
import sqlite3
import struct
from dataclasses import dataclass
from pathlib import Path

import sqlite_vec

from .resilience import safe_db_write

# CJK 字符检测（中日韩）
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]")

# SQLite 版本检测：contentless_delete 需要 3.43.0+
_SQLITE_VERSION = tuple(int(x) for x in sqlite3.sqlite_version.split("."))
_SUPPORTS_CONTENTLESS_DELETE = _SQLITE_VERSION >= (3, 43, 0)


def tokenize_for_fts5(text: str) -> str:
    """对含 CJK 字符的文本做分词，空格连接，供 FTS5 索引和查询使用。

    - 纯英文文本原样返回（零开销）
    - jieba 未安装时降级原样返回（不影响英文用户）
    """
    if not text or not _CJK_RE.search(text):
        return text
    try:
        import jieba
        return " ".join(jieba.cut(text))
    except ImportError:
        return text


@dataclass
class ChunkRow:
    """从数据库读出的 chunk 数据"""

    id: int
    doc_id: int
    content: str
    section: str | None
    parent_section: str | None
    chunk_index: int
    tags: str | None
    token_count: int | None
    summary: str | None
    source: str | None = None  # join documents.path


class Database:
    """SQLite + FTS5 + sqlite-vec 数据访问层"""

    # 常见 embedding 模型维度白名单（L1 校验用，同 CogDatabase）
    _KNOWN_DIMS = frozenset({128, 256, 384, 512, 768, 1024, 1536, 3072, 4096})

    def __init__(self, db_path: Path | str, embedding_dim: int = 768):
        import logging
        import threading
        _log = logging.getLogger(__name__)
        self._db_path = str(db_path)
        if "Config(" in self._db_path:
            raise ValueError(f"db_path 收到了 Config 对象而非路径: {self._db_path[:80]}...")
        # ── L1 事前校验：embedding_dim 合理性（#254 三层防御） ──
        if not isinstance(embedding_dim, int) or embedding_dim <= 0:
            _log.error(
                "embedding_dim 非法 (%s, type=%s)，回退到 384",
                embedding_dim, type(embedding_dim).__name__,
            )
            embedding_dim = 384
        if embedding_dim not in self._KNOWN_DIMS:
            _log.warning(
                "embedding_dim=%d 不在已知维度列表 %s，可能配置错误",
                embedding_dim, sorted(self._KNOWN_DIMS),
            )
        self._embedding_dim = embedding_dim
        self._conn: sqlite3.Connection | None = None
        self._vec_loaded = False
        self._conn_lock = threading.RLock()
        self._reconnect_count = 0
        self._max_reconnects = 3

    def connect(self) -> None:
        """打开数据库连接并初始化"""
        parent = Path(self._db_path).parent
        try:
            parent.mkdir(parents=True, exist_ok=True)
        except OSError:
            pass

        self._conn = sqlite3.connect(
            self._db_path, timeout=10.0, check_same_thread=False,
        )
        self._conn.row_factory = sqlite3.Row
        self._conn.isolation_level = "IMMEDIATE"  # 写事务立即获取锁，busy_timeout 才生效
        self._conn.execute("PRAGMA journal_mode=WAL")
        self._conn.execute("PRAGMA foreign_keys=ON")
        self._conn.execute("PRAGMA busy_timeout=15000")  # 对齐 cognitive.db
        self._conn.execute("PRAGMA synchronous=NORMAL")  # WAL 模式下安全且快

        # 连接成功，重置重连计数
        self._reconnect_count = 0

        # 首次连接时检测数据库完整性
        self._check_integrity()

        self._load_vec_extension()
        self._init_schema()

    def _check_integrity(self) -> None:
        """首次连接时快速检测数据库完整性"""
        import logging
        log = logging.getLogger(__name__)
        if self._conn is None:
            return
        try:
            result = self._conn.execute("PRAGMA integrity_check(1)").fetchone()
            if result and result[0] != "ok":
                log.error(
                    "数据库完整性检查失败: %s，建议删除 %s 并重新索引",
                    result[0], self._db_path,
                )
        except sqlite3.DatabaseError as e:
            log.error("数据库损坏无法读取: %s，建议删除 %s 并重新索引", e, self._db_path)

    def close(self) -> None:
        with self._conn_lock:
            if self._conn:
                self._conn.close()
                self._conn = None

    def _reconnect(self) -> None:
        """关闭旧连接并重建"""
        import logging
        if self._conn:
            try:
                self._conn.close()
            except Exception as e:
                logging.getLogger(__name__).warning("关闭旧连接失败: %s", e)
        self._conn = None
        self.connect()

    @property
    def conn(self) -> sqlite3.Connection:
        with self._conn_lock:
            if self._conn is None:
                self.connect()
            else:
                # 健康检查：连接可能已断开或损坏
                try:
                    self._conn.execute("SELECT 1")
                except sqlite3.OperationalError as e:
                    import logging
                    self._reconnect_count += 1
                    if self._reconnect_count > self._max_reconnects:
                        logging.getLogger(__name__).error(
                            "SQLite 重连次数已达上限 (%d)，放弃重连", self._max_reconnects,
                        )
                        self._conn = None
                    else:
                        logging.getLogger(__name__).warning(
                            "SQLite 连接已失效（OperationalError: %s），重连 %d/%d...",
                            e, self._reconnect_count, self._max_reconnects,
                        )
                        self._reconnect()
                except sqlite3.ProgrammingError as e:
                    import logging
                    self._reconnect_count += 1
                    if self._reconnect_count > self._max_reconnects:
                        logging.getLogger(__name__).error(
                            "SQLite 重连次数已达上限 (%d)，放弃重连", self._max_reconnects,
                        )
                        self._conn = None
                    else:
                        logging.getLogger(__name__).warning(
                            "SQLite 连接已损坏（ProgrammingError: %s），重连 %d/%d...",
                            e, self._reconnect_count, self._max_reconnects,
                        )
                        self._reconnect()
            if self._conn is None:
                raise RuntimeError("数据库连接失败，无法恢复")
            return self._conn

    # ── 文档操作 ──

    def upsert_document(
        self,
        path: str,
        hash: str,
        collection: str,
        doc_type: str,
        metadata: str | None = None,
    ) -> int:
        """插入或更新文档记录，返回 doc_id"""
        cur = self.conn.execute(
            """INSERT INTO documents (path, hash, collection, doc_type, metadata, indexed_at)
               VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
               ON CONFLICT(path) DO UPDATE SET
                 hash=excluded.hash,
                 collection=excluded.collection,
                 doc_type=excluded.doc_type,
                 metadata=excluded.metadata,
                 indexed_at=CURRENT_TIMESTAMP
               RETURNING id""",
            (path, hash, collection, doc_type, metadata),
        )
        row = cur.fetchone()
        self.conn.commit()
        return row[0]

    def get_document_hash(self, path: str) -> str | None:
        """获取文档的已索引 hash，用于变更检测"""
        row = self.conn.execute(
            "SELECT hash FROM documents WHERE path = ?", (path,)
        ).fetchone()
        return row[0] if row else None

    def delete_document(self, path: str) -> None:
        """删除文档及其所有 chunks（ON DELETE CASCADE）"""
        self.conn.execute("DELETE FROM documents WHERE path = ?", (path,))
        self.conn.commit()

    def list_document_paths(self, collection: str | None = None) -> list[str]:
        """返回所有已索引文档路径"""
        if collection:
            rows = self.conn.execute(
                "SELECT path FROM documents WHERE collection = ?", (collection,)
            ).fetchall()
        else:
            rows = self.conn.execute("SELECT path FROM documents").fetchall()
        return [r[0] for r in rows]

    def find_document(
        self, file_path: str, collection: str | None = None,
        watch_paths: list[str] | None = None,
    ) -> dict | None:
        """查找文档记录（三级匹配：精确→后缀→basename）

        匹配优先级：
        1. 精确匹配 path
        2. 绝对路径转相对后精确匹配（需 watch_paths）
        3. 后缀匹配（path LIKE '%/<input>'）
        4. basename 匹配（仅在无歧义时返回）

        Args:
            file_path: 查找的文件路径
            collection: 限定集合（可选）
            watch_paths: 项目 watch_paths 列表，用于绝对路径→相对路径转换（可选）
        """
        # cols/coll_clause/params_base 均为内部常量，非用户输入
        cols = "id, path, collection, indexed_at"
        coll_clause = " AND collection = ?" if collection else ""
        params_base: list = [collection] if collection else []

        def _query(where: str, params: list) -> dict | None:
            row = self.conn.execute(
                f"SELECT {cols} FROM documents WHERE {where}{coll_clause} LIMIT 1",
                params + params_base,
            ).fetchone()
            if row:
                return {"id": row[0], "path": row[1], "collection": row[2], "indexed_at": row[3]}
            return None

        # Level 1: 精确匹配
        result = _query("path = ?", [file_path])
        if result:
            return result

        # Level 2: 绝对路径 → 尝试转相对（需要 watch_paths）
        if file_path.startswith("/") and watch_paths:
            from pathlib import Path as _P
            for wp in watch_paths:
                try:
                    rel = str(_P(file_path).relative_to(_P(wp).resolve()))
                    result = _query("path = ?", [rel])
                    if result:
                        return result
                except (ValueError, OSError):
                    continue

        # Level 3: 后缀匹配（仅当输入含 "/" 时，说明用户提供了路径上下文）
        if not file_path.startswith("/") and "/" in file_path:
            result = _query("path LIKE ?", [f"%/{file_path}"])
            if result:
                return result

        # Level 4: basename 匹配（仅无歧义时）
        from pathlib import PurePosixPath as _PP
        basename = _PP(file_path).name
        if basename:
            rows = self.conn.execute(
                f"SELECT {cols} FROM documents WHERE (path LIKE ? OR path = ?){coll_clause}",
                [f"%/{basename}", basename] + params_base,
            ).fetchall()
            if len(rows) == 1:
                row = rows[0]
                return {"id": row[0], "path": row[1], "collection": row[2], "indexed_at": row[3]}

        return None

    def get_file_meta(self, doc_id: int) -> dict:
        """获取文件元数据：chunk_count + symbols 列表"""
        chunk_count = self.conn.execute(
            "SELECT COUNT(*) FROM chunks WHERE doc_id = ?", (doc_id,),
        ).fetchone()[0]
        symbols = self.get_symbols_by_doc(doc_id)
        return {"chunk_count": chunk_count, "symbols": symbols}

    # ── Chunk 操作 ──

    def insert_chunks(self, doc_id: int, chunks: list[dict]) -> None:
        """批量插入 chunks，事务内完成

        chunks: list of dict with keys:
            content, section, parent_section, chunk_index, tags,
            embedding (list[float] | None), token_count, summary
        """
        with self.conn:
            for chunk in chunks:
                embedding_blob = None
                if chunk.get("embedding"):
                    embedding_blob = _floats_to_blob(chunk["embedding"])

                tags_str = chunk.get("tags")
                if isinstance(tags_str, list):
                    tags_str = ", ".join(tags_str)

                cur = self.conn.execute(
                    """INSERT INTO chunks
                       (doc_id, content, section, parent_section, chunk_index,
                        tags, embedding, token_count, summary)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                    (
                        doc_id,
                        chunk["content"],
                        chunk.get("section"),
                        chunk.get("parent_section"),
                        chunk.get("chunk_index", 0),
                        tags_str,
                        embedding_blob,
                        chunk.get("token_count"),
                        chunk.get("summary"),
                    ),
                )
                chunk_id = cur.lastrowid

                # FTS5 同步（对 content 做 CJK 分词以支持中文 BM25）
                self.conn.execute(
                    """INSERT INTO chunks_fts(rowid, content, section, parent_section, tags)
                       VALUES (?, ?, ?, ?, ?)""",
                    (
                        chunk_id,
                        tokenize_for_fts5(chunk["content"]),
                        chunk.get("section"),
                        chunk.get("parent_section"),
                        tags_str,
                    ),
                )

                # sqlite-vec 同步
                if embedding_blob and self._vec_loaded:
                    self.conn.execute(
                        "INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)",
                        (chunk_id, embedding_blob),
                    )

    def delete_chunks_by_doc(self, doc_id: int) -> None:
        """删除文档的所有旧 chunks（重索引前调用）"""
        # 获取要删除的 chunk 数据（用于清理 FTS5 和 vec）
        rows = self.conn.execute(
            "SELECT id, content, section, parent_section, tags FROM chunks WHERE doc_id = ?",
            (doc_id,),
        ).fetchall()

        if not rows:
            return

        _fts5_delete_failed = False

        with self.conn:
            for row in rows:
                cid, content, section, parent_section, tags = row
                if _SUPPORTS_CONTENTLESS_DELETE:
                    # SQLite >= 3.43: contentless_delete=1 支持直接 DELETE
                    self.conn.execute(
                        "DELETE FROM chunks_fts WHERE rowid = ?", (cid,)
                    )
                else:
                    # SQLite < 3.43: contentless 表用 'delete' 命令删除
                    try:
                        self.conn.execute(
                            "INSERT INTO chunks_fts(chunks_fts, rowid, content, section, parent_section, tags) "
                            "VALUES('delete', ?, ?, ?, ?, ?)",
                            (cid, content or "", section or "", parent_section or "", tags or ""),
                        )
                    except sqlite3.OperationalError:
                        _fts5_delete_failed = True
                # sqlite-vec 删除
                if self._vec_loaded:
                    self.conn.execute(
                        "DELETE FROM chunks_vec WHERE rowid = ?", (cid,)
                    )

            self.conn.execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,))

            # 旧版 SQLite FTS5 逐条删除失败时，rebuild 整个 FTS5 索引
            if _fts5_delete_failed:
                try:
                    self.conn.execute(
                        "INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')"
                    )
                except sqlite3.OperationalError as e:
                    import logging
                    logging.getLogger(__name__).warning(
                        "FTS5 rebuild 失败: %s，BM25 搜索可能返回已删除的幽灵结果", e,
                    )

    def get_chunk(self, chunk_id: int) -> ChunkRow | None:
        """按 ID 获取完整 chunk（含 source 路径）"""
        row = self.conn.execute(
            """SELECT c.id, c.doc_id, c.content, c.section, c.parent_section,
                      c.chunk_index, c.tags, c.token_count, c.summary, d.path as source
               FROM chunks c
               JOIN documents d ON c.doc_id = d.id
               WHERE c.id = ?""",
            (chunk_id,),
        ).fetchone()
        if not row:
            return None
        return ChunkRow(
            id=row[0], doc_id=row[1], content=row[2], section=row[3],
            parent_section=row[4], chunk_index=row[5], tags=row[6],
            token_count=row[7], summary=row[8], source=row[9],
        )

    def get_chunk_context(self, chunk_id: int) -> dict:
        """获取 chunk 的上下文（前后 chunk 的 section）"""
        row = self.conn.execute(
            "SELECT doc_id, chunk_index FROM chunks WHERE id = ?", (chunk_id,)
        ).fetchone()
        if not row:
            return {}

        doc_id, chunk_index = row[0], row[1]
        prev_row = self.conn.execute(
            "SELECT section FROM chunks WHERE doc_id = ? AND chunk_index = ?",
            (doc_id, chunk_index - 1),
        ).fetchone()
        next_row = self.conn.execute(
            "SELECT section FROM chunks WHERE doc_id = ? AND chunk_index = ?",
            (doc_id, chunk_index + 1),
        ).fetchone()

        return {
            "prev_section": prev_row[0] if prev_row else None,
            "next_section": next_row[0] if next_row else None,
        }

    # ── 搜索操作 ──

    # FTS5 列数（content, section, parent_section, tags）
    _FTS5_COLUMN_COUNT = 4

    def fts5_search(
        self, query: str, collection: str | None, limit: int,
        column_weights: tuple[float, ...] | None = None,
    ) -> list[tuple[int, float]]:
        """BM25 全文搜索，返回 [(chunk_id, bm25_score)]

        Args:
            column_weights: FTS5 列权重 (content, section, parent_section, tags)。
                           必须恰好 4 个有限浮点数。None 时等价于全 1.0。

        注意：调用方应先对查询做 tokenize_for_fts5() 分词，
        与 insert_chunks() 中的索引分词保持一致。
        """
        # FTS5 bm25() 参数不支持 ? 占位符（是函数调用的一部分），
        # 必须用 f-string 构造。通过 float() + isfinite() 严格验证防注入。
        if column_weights:
            if len(column_weights) != self._FTS5_COLUMN_COUNT:
                raise ValueError(
                    f"column_weights 需要 {self._FTS5_COLUMN_COUNT} 个值，"
                    f"实际 {len(column_weights)}"
                )
            safe = []
            for v in column_weights:
                f = float(v)
                if not math.isfinite(f):
                    raise ValueError(f"column_weights 包含非有限值: {v}")
                safe.append(f)
            bm25_fn = f"bm25(chunks_fts, {', '.join(str(w) for w in safe)})"
        else:
            bm25_fn = "bm25(chunks_fts)"

        if collection:
            rows = self.conn.execute(
                f"""SELECT cf.rowid, {bm25_fn} as score
                   FROM chunks_fts cf
                   JOIN chunks c ON cf.rowid = c.id
                   JOIN documents d ON c.doc_id = d.id
                   WHERE chunks_fts MATCH ? AND d.collection = ?
                   ORDER BY score
                   LIMIT ?""",
                (query, collection, limit),
            ).fetchall()
        else:
            rows = self.conn.execute(
                f"""SELECT rowid, {bm25_fn} as score
                   FROM chunks_fts
                   WHERE chunks_fts MATCH ?
                   ORDER BY score
                   LIMIT ?""",
                (query, limit),
            ).fetchall()

        return [(r[0], abs(r[1])) for r in rows]

    def vector_search(
        self, embedding: list[float], collection: str | None, limit: int
    ) -> list[tuple[int, float]]:
        """sqlite-vec KNN 搜索，返回 [(chunk_id, distance)]"""
        if not self._vec_loaded:
            return []
        if limit <= 0:
            return []
        limit = min(limit, 1000)

        query_blob = _floats_to_blob(embedding)

        if collection:
            rows = self.conn.execute(
                """SELECT v.rowid, v.distance
                   FROM chunks_vec v
                   JOIN chunks c ON v.rowid = c.id
                   JOIN documents d ON c.doc_id = d.id
                   WHERE v.embedding MATCH ? AND k = ? AND d.collection = ?
                   ORDER BY v.distance""",
                (query_blob, limit * 2, collection),
            ).fetchall()
            return [(r[0], r[1]) for r in rows[:limit]]
        else:
            rows = self.conn.execute(
                """SELECT rowid, distance
                   FROM chunks_vec
                   WHERE embedding MATCH ? AND k = ?
                   ORDER BY distance""",
                (query_blob, limit),
            ).fetchall()
            return [(r[0], r[1]) for r in rows]

    # ── 统计 ──

    def get_stats(self) -> dict:
        """返回索引统计信息"""
        doc_count = self.conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
        chunk_count = self.conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
        collections = [
            r[0]
            for r in self.conn.execute(
                "SELECT DISTINCT collection FROM documents"
            ).fetchall()
        ]
        last_indexed = self.conn.execute(
            "SELECT MAX(indexed_at) FROM documents"
        ).fetchone()[0]
        doc_types = [
            r[0]
            for r in self.conn.execute(
                "SELECT DISTINCT doc_type FROM documents"
            ).fetchall()
        ]

        return {
            "doc_count": doc_count,
            "chunk_count": chunk_count,
            "collections": collections,
            "doc_types": doc_types,
            "last_indexed": last_indexed,
            "db_path": self._db_path,
            "vec_loaded": self._vec_loaded,
        }

    def get_chunks_missing_embedding(
        self, collection: str | None = None
    ) -> list[tuple[int, str]]:
        """查询缺少向量的 chunk，返回 [(chunk_id, content)]"""
        if collection:
            rows = self.conn.execute(
                """SELECT c.id, c.content
                   FROM chunks c
                   JOIN documents d ON c.doc_id = d.id
                   WHERE c.embedding IS NULL AND d.collection = ?
                   ORDER BY c.id""",
                (collection,),
            ).fetchall()
        else:
            rows = self.conn.execute(
                """SELECT id, content FROM chunks
                   WHERE embedding IS NULL ORDER BY id""",
            ).fetchall()
        return [(r[0], r[1]) for r in rows]

    def update_chunk_embedding(self, chunk_id: int, embedding: list[float]) -> None:
        """回填单条 chunk 的向量（更新 chunks 表 + 插入 chunks_vec）"""
        embedding_blob = _floats_to_blob(embedding)
        with self.conn:
            self.conn.execute(
                "UPDATE chunks SET embedding = ? WHERE id = ?",
                (embedding_blob, chunk_id),
            )
            if self._vec_loaded:
                # 先删旧的（如果有），再插入新的
                self.conn.execute(
                    "DELETE FROM chunks_vec WHERE rowid = ?", (chunk_id,)
                )
                self.conn.execute(
                    "INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)",
                    (chunk_id, embedding_blob),
                )

    def get_doc_chunks(self, doc_id: int) -> list[ChunkRow]:
        """获取文档的所有 chunks（走 idx_chunks_doc 索引）"""
        rows = self.conn.execute(
            """SELECT c.id, c.doc_id, c.content, c.section, c.parent_section,
                      c.chunk_index, c.tags, c.token_count, c.summary, d.path
               FROM chunks c
               JOIN documents d ON c.doc_id = d.id
               WHERE c.doc_id = ?
               ORDER BY c.chunk_index""",
            (doc_id,),
        ).fetchall()
        return [
            ChunkRow(
                id=r[0], doc_id=r[1], content=r[2], section=r[3],
                parent_section=r[4], chunk_index=r[5], tags=r[6],
                token_count=r[7], summary=r[8], source=r[9],
            )
            for r in rows
        ]

    def browse_collection(
        self, collection: str | None, doc_type: str | None = None,
        limit: int = 50, offset: int = 0,
    ) -> list[dict]:
        """浏览集合中的文档列表

        使用子查询先定位文档（走 idx_docs_collection_path 索引），
        再用相关子查询获取 chunk 统计，避免 JOIN 展开。
        """
        where_clauses = []
        params: list = []
        if collection:
            where_clauses.append("d.collection = ?")
            params.append(collection)
        if doc_type:
            where_clauses.append("d.doc_type = ?")
            params.append(doc_type)

        where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
        order = "d.path" if collection else "d.collection, d.path"

        rows = self.conn.execute(
            f"""SELECT d.id, d.path, d.collection, d.doc_type,
                       d.indexed_at,
                       (SELECT COUNT(*) FROM chunks WHERE doc_id = d.id) as chunk_count,
                       (SELECT COALESCE(SUM(token_count), 0) FROM chunks WHERE doc_id = d.id) as total_tokens
                FROM documents d
                {where_sql}
                ORDER BY {order}
                LIMIT ? OFFSET ?""",
            params + [limit, offset],
        ).fetchall()
        return [
            {
                "id": r[0],
                "path": r[1],
                "collection": r[2],
                "doc_type": r[3],
                "indexed_at": r[4],
                "chunk_count": r[5],
                "total_tokens": r[6],
            }
            for r in rows
        ]

    def get_collection_stats(self) -> list[dict]:
        """返回每个集合的详细统计

        使用相关子查询避免 LEFT JOIN 展开后的大结果集。
        documents 表按 collection 分组走 idx_docs_collection 索引。
        """
        rows = self.conn.execute("""
            SELECT
                d.collection,
                COUNT(*) AS doc_count,
                (SELECT COUNT(*) FROM chunks c
                 JOIN documents d2 ON c.doc_id = d2.id
                 WHERE d2.collection = d.collection) AS chunk_count,
                MAX(d.indexed_at) AS last_indexed
            FROM documents d
            GROUP BY d.collection
            ORDER BY d.collection
        """).fetchall()
        return [
            {
                "collection": r[0],
                "doc_count": r[1],
                "chunk_count": r[2],
                "last_indexed": r[3],
            }
            for r in rows
        ]

    # ── 索引运行记录 ──

    def create_index_run(self, collection: str, total_files: int) -> int | None:
        """创建索引运行记录，返回 run_id"""
        return safe_db_write(
            self.conn,
            "INSERT INTO index_runs (collection, total_files) VALUES (?, ?)",
            (collection, total_files),
        )

    def update_index_run(self, run_id: int, stats: dict) -> None:
        """更新索引运行记录"""
        import json

        status = "interrupted" if stats.get("interrupted") else "completed"
        failed_paths = json.dumps(stats.get("failed_files", []), ensure_ascii=False)
        safe_db_write(
            self.conn,
            """UPDATE index_runs
               SET indexed=?, skipped=?, failed=?, failed_paths=?,
                   status=?, completed_at=CURRENT_TIMESTAMP
               WHERE id=?""",
            (
                stats.get("indexed", 0),
                stats.get("skipped", 0),
                stats.get("failed", 0),
                failed_paths,
                status,
                run_id,
            ),
        )

    def get_last_interrupted_run(self, collection: str | None = None) -> dict | None:
        """获取最近一次中断且有失败文件的运行记录"""
        import json

        if collection:
            row = self.conn.execute(
                """SELECT id, collection, total_files, indexed, skipped, failed,
                          failed_paths, status, started_at, completed_at
                   FROM index_runs
                   WHERE status='interrupted' AND collection=?
                   ORDER BY started_at DESC LIMIT 1""",
                (collection,),
            ).fetchone()
        else:
            row = self.conn.execute(
                """SELECT id, collection, total_files, indexed, skipped, failed,
                          failed_paths, status, started_at, completed_at
                   FROM index_runs
                   WHERE status='interrupted'
                   ORDER BY started_at DESC LIMIT 1""",
            ).fetchone()
        if not row:
            return None
        failed_paths_raw = row[6]
        try:
            failed_list = json.loads(failed_paths_raw) if failed_paths_raw else []
        except (json.JSONDecodeError, TypeError):
            failed_list = []
        return {
            "id": row[0], "collection": row[1], "total_files": row[2],
            "indexed": row[3], "skipped": row[4], "failed": row[5],
            "failed_paths": failed_list, "status": row[7],
            "started_at": row[8], "completed_at": row[9],
        }

    def get_recent_runs(self, collection: str | None = None, limit: int = 5) -> list[dict]:
        """获取最近的索引运行记录"""
        if collection:
            rows = self.conn.execute(
                """SELECT id, collection, total_files, indexed, skipped, failed,
                          status, started_at, completed_at
                   FROM index_runs WHERE collection=?
                   ORDER BY started_at DESC LIMIT ?""",
                (collection, limit),
            ).fetchall()
        else:
            rows = self.conn.execute(
                """SELECT id, collection, total_files, indexed, skipped, failed,
                          status, started_at, completed_at
                   FROM index_runs ORDER BY started_at DESC LIMIT ?""",
                (limit,),
            ).fetchall()
        return [
            {
                "id": r[0], "collection": r[1], "total_files": r[2],
                "indexed": r[3], "skipped": r[4], "failed": r[5],
                "status": r[6], "started_at": r[7], "completed_at": r[8],
            }
            for r in rows
        ]

    # ── 符号操作 (Code Graph) ──

    def insert_symbols(self, doc_id: int, symbols: list[dict]) -> list[int]:
        """批量插入符号定义，返回 symbol_id 列表

        symbols: list of dict with keys:
            name, kind, line_start, line_end, qualified_name, signature, chunk_id
        """
        ids: list[int] = []
        with self.conn:
            for sym in symbols:
                cur = self.conn.execute(
                    """INSERT INTO symbols
                       (doc_id, chunk_id, name, qualified_name, kind,
                        line_start, line_end, signature)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
                    (
                        doc_id,
                        sym.get("chunk_id"),
                        sym["name"],
                        sym.get("qualified_name"),
                        sym["kind"],
                        sym.get("line_start", 0),
                        sym.get("line_end"),
                        sym.get("signature"),
                    ),
                )
                ids.append(cur.lastrowid)
        return ids

    def get_symbols_by_doc(self, doc_id: int) -> list[dict]:
        """获取文档的所有符号定义"""
        rows = self.conn.execute(
            """SELECT id, doc_id, chunk_id, name, qualified_name, kind,
                      line_start, line_end, signature, pagerank
               FROM symbols WHERE doc_id = ?
               ORDER BY line_start""",
            (doc_id,),
        ).fetchall()
        return [
            {
                "id": r[0], "doc_id": r[1], "chunk_id": r[2], "name": r[3],
                "qualified_name": r[4], "kind": r[5], "line_start": r[6],
                "line_end": r[7], "signature": r[8], "pagerank": r[9],
            }
            for r in rows
        ]

    def get_symbol_by_name(
        self, name: str, collection: str | None = None
    ) -> list[dict]:
        """按名称查找符号（可能有多个同名定义）"""
        if collection:
            rows = self.conn.execute(
                """SELECT s.id, s.doc_id, s.name, s.qualified_name, s.kind,
                          s.line_start, s.signature, s.pagerank, d.path
                   FROM symbols s
                   JOIN documents d ON s.doc_id = d.id
                   WHERE s.name = ? AND d.collection = ?""",
                (name, collection),
            ).fetchall()
        else:
            rows = self.conn.execute(
                """SELECT s.id, s.doc_id, s.name, s.qualified_name, s.kind,
                          s.line_start, s.signature, s.pagerank, d.path
                   FROM symbols s
                   JOIN documents d ON s.doc_id = d.id
                   WHERE s.name = ?""",
                (name,),
            ).fetchall()
        return [
            {
                "id": r[0], "doc_id": r[1], "name": r[2], "qualified_name": r[3],
                "kind": r[4], "line_start": r[5], "signature": r[6],
                "pagerank": r[7], "path": r[8],
            }
            for r in rows
        ]

    def delete_symbols_by_doc(self, doc_id: int) -> int:
        """删除文档的所有符号（重索引前调用），返回删除数"""
        cur = self.conn.execute("DELETE FROM symbols WHERE doc_id = ?", (doc_id,))
        self.conn.commit()
        return cur.rowcount

    # ── 引用操作 ──

    def insert_refs(self, refs_list: list[dict]) -> int:
        """批量插入引用关系，返回插入数

        refs_list: list of dict with keys:
            from_doc_id, to_doc_id, from_symbol_id, to_symbol_id,
            ref_name, kind, from_line, confidence
        """
        count = 0
        with self.conn:
            for ref in refs_list:
                self.conn.execute(
                    """INSERT INTO refs
                       (from_doc_id, to_doc_id, from_symbol_id, to_symbol_id,
                        ref_name, kind, from_line, confidence)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
                    (
                        ref["from_doc_id"],
                        ref.get("to_doc_id"),
                        ref.get("from_symbol_id"),
                        ref.get("to_symbol_id"),
                        ref.get("ref_name"),
                        ref["kind"],
                        ref.get("from_line"),
                        ref.get("confidence", 1.0),
                    ),
                )
                count += 1
        return count

    def get_refs_by_symbol(
        self, symbol_id: int, direction: str = "both"
    ) -> list[dict]:
        """获取符号的引用关系。direction: callers | callees | both"""
        results = []
        if direction in ("callees", "both"):
            rows = self.conn.execute(
                """SELECT r.id, r.to_doc_id, r.to_symbol_id, r.kind,
                          r.from_line, r.confidence, s.name as to_name, d.path as to_path
                   FROM refs r
                   LEFT JOIN symbols s ON r.to_symbol_id = s.id
                   LEFT JOIN documents d ON r.to_doc_id = d.id
                   WHERE r.from_symbol_id = ?""",
                (symbol_id,),
            ).fetchall()
            for r in rows:
                results.append({
                    "id": r[0], "doc_id": r[1], "symbol_id": r[2], "kind": r[3],
                    "line": r[4], "confidence": r[5], "name": r[6], "path": r[7],
                    "direction": "callee",
                })
        if direction in ("callers", "both"):
            rows = self.conn.execute(
                """SELECT r.id, r.from_doc_id, r.from_symbol_id, r.kind,
                          r.from_line, r.confidence, s.name as from_name, d.path as from_path
                   FROM refs r
                   LEFT JOIN symbols s ON r.from_symbol_id = s.id
                   LEFT JOIN documents d ON r.from_doc_id = d.id
                   WHERE r.to_symbol_id = ?""",
                (symbol_id,),
            ).fetchall()
            for r in rows:
                results.append({
                    "id": r[0], "doc_id": r[1], "symbol_id": r[2], "kind": r[3],
                    "line": r[4], "confidence": r[5], "name": r[6], "path": r[7],
                    "direction": "caller",
                })
        return results

    def delete_refs_by_doc(self, doc_id: int) -> int:
        """删除文档的所有出边引用（重索引前调用）"""
        cur = self.conn.execute("DELETE FROM refs WHERE from_doc_id = ?", (doc_id,))
        self.conn.commit()
        return cur.rowcount

    def get_file_graph(self, collection: str | None = None) -> list[tuple[int, int, float]]:
        """获取文件级引用图: [(from_doc_id, to_doc_id, weight), ...]"""
        import math
        if collection:
            rows = self.conn.execute(
                """SELECT r.from_doc_id, r.to_doc_id,
                          COUNT(*) as cnt, AVG(r.confidence) as avg_conf
                   FROM refs r
                   JOIN documents d ON r.from_doc_id = d.id
                   WHERE r.from_doc_id != r.to_doc_id
                     AND r.to_doc_id IS NOT NULL
                     AND d.collection = ?
                   GROUP BY r.from_doc_id, r.to_doc_id""",
                (collection,),
            ).fetchall()
        else:
            rows = self.conn.execute(
                """SELECT r.from_doc_id, r.to_doc_id,
                          COUNT(*) as cnt, AVG(r.confidence) as avg_conf
                   FROM refs r
                   WHERE r.from_doc_id != r.to_doc_id
                     AND r.to_doc_id IS NOT NULL
                   GROUP BY r.from_doc_id, r.to_doc_id""",
            ).fetchall()
        return [(r[0], r[1], math.sqrt(r[2]) * r[3]) for r in rows]

    def update_pageranks(self, ranks: dict[int, float]) -> None:
        """批量更新 symbols.pagerank（doc_id → rank）"""
        with self.conn:
            self.conn.executemany(
                "UPDATE symbols SET pagerank = ? WHERE doc_id = ?",
                [(rank, doc_id) for doc_id, rank in ranks.items()],
            )

    def get_symbol_stats(self, collection: str | None = None) -> dict:
        """符号统计: symbol_count, ref_count, languages"""
        if collection:
            sym_count = self.conn.execute(
                """SELECT COUNT(*) FROM symbols s
                   JOIN documents d ON s.doc_id = d.id
                   WHERE d.collection = ?""",
                (collection,),
            ).fetchone()[0]
            ref_count = self.conn.execute(
                """SELECT COUNT(*) FROM refs r
                   JOIN documents d ON r.from_doc_id = d.id
                   WHERE d.collection = ?""",
                (collection,),
            ).fetchone()[0]
        else:
            sym_count = self.conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
            ref_count = self.conn.execute("SELECT COUNT(*) FROM refs").fetchone()[0]
        return {"symbol_count": sym_count, "ref_count": ref_count}

    def get_all_symbol_names(self) -> set[str]:
        """返回所有已索引的符号名（函数名/类名），供 query.py 路由判断"""
        try:
            rows = self.conn.execute("SELECT DISTINCT name FROM symbols").fetchall()
            return {r[0] for r in rows}
        except Exception as e:
            logger.debug("符号名查询失败: %s", e)
            return set()

    # ── 内部方法 ──

    def _load_vec_extension(self) -> None:
        """加载 sqlite-vec 扩展"""
        try:
            self._conn.enable_load_extension(True)
            sqlite_vec.load(self._conn)
            self._conn.enable_load_extension(False)
            self._vec_loaded = True
        except Exception:
            self._vec_loaded = False

    def _init_schema(self) -> None:
        """建表 + FTS5（幂等）"""
        with self.conn:
            self.conn.executescript("""
                CREATE TABLE IF NOT EXISTS documents (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    path TEXT UNIQUE NOT NULL,
                    hash TEXT NOT NULL,
                    collection TEXT NOT NULL DEFAULT 'default',
                    doc_type TEXT NOT NULL,
                    indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                    metadata JSON
                );

                CREATE TABLE IF NOT EXISTS chunks (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
                    content TEXT NOT NULL,
                    section TEXT,
                    parent_section TEXT,
                    chunk_index INTEGER NOT NULL,
                    tags TEXT,
                    embedding BLOB,
                    token_count INTEGER,
                    summary TEXT
                );

                CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id);
                CREATE INDEX IF NOT EXISTS idx_docs_collection ON documents(collection);
                CREATE INDEX IF NOT EXISTS idx_docs_collection_path ON documents(collection, path);

                CREATE TABLE IF NOT EXISTS index_runs (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    collection TEXT NOT NULL,
                    total_files INTEGER NOT NULL DEFAULT 0,
                    indexed INTEGER NOT NULL DEFAULT 0,
                    skipped INTEGER NOT NULL DEFAULT 0,
                    failed INTEGER NOT NULL DEFAULT 0,
                    failed_paths TEXT,
                    status TEXT NOT NULL DEFAULT 'running',
                    started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                    completed_at DATETIME
                );
            """)

            # FTS5 虚拟表（不能用 IF NOT EXISTS，检查是否已存在）
            exists = self.conn.execute(
                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks_fts'"
            ).fetchone()
            if not exists:
                if _SUPPORTS_CONTENTLESS_DELETE:
                    self.conn.execute("""
                        CREATE VIRTUAL TABLE chunks_fts USING fts5(
                            content, section, parent_section, tags,
                            content='',
                            contentless_delete=1
                        )
                    """)
                else:
                    self.conn.execute("""
                        CREATE VIRTUAL TABLE chunks_fts USING fts5(
                            content, section, parent_section, tags,
                            content=''
                        )
                    """)

            # ── Code Graph 表（symbols + refs）──
            self.conn.executescript("""
                CREATE TABLE IF NOT EXISTS symbols (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
                    chunk_id INTEGER REFERENCES chunks(id) ON DELETE SET NULL,
                    name TEXT NOT NULL,
                    qualified_name TEXT,
                    kind TEXT NOT NULL,
                    line_start INTEGER NOT NULL DEFAULT 0,
                    line_end INTEGER,
                    signature TEXT,
                    pagerank REAL DEFAULT 0.0
                );
                CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
                CREATE INDEX IF NOT EXISTS idx_symbols_doc ON symbols(doc_id);
                CREATE INDEX IF NOT EXISTS idx_symbols_qualified ON symbols(qualified_name);

                CREATE TABLE IF NOT EXISTS refs (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    from_doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
                    to_doc_id INTEGER REFERENCES documents(id) ON DELETE SET NULL,
                    from_symbol_id INTEGER REFERENCES symbols(id) ON DELETE CASCADE,
                    to_symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
                    ref_name TEXT,
                    kind TEXT NOT NULL,
                    from_line INTEGER,
                    confidence REAL DEFAULT 1.0
                );
                CREATE INDEX IF NOT EXISTS idx_refs_from_doc ON refs(from_doc_id);
                CREATE INDEX IF NOT EXISTS idx_refs_to_doc ON refs(to_doc_id);
                CREATE INDEX IF NOT EXISTS idx_refs_to_symbol ON refs(to_symbol_id);
                CREATE INDEX IF NOT EXISTS idx_refs_ref_name ON refs(ref_name);
                CREATE INDEX IF NOT EXISTS idx_refs_kind ON refs(kind);
            """)

            # schema 迁移: refs.ref_name 列（v0.x → v1.x 升级）
            try:
                self.conn.execute("SELECT ref_name FROM refs LIMIT 0")
            except sqlite3.OperationalError:
                try:
                    self.conn.execute("ALTER TABLE refs ADD COLUMN ref_name TEXT")
                    self.conn.execute(
                        "CREATE INDEX IF NOT EXISTS idx_refs_ref_name ON refs(ref_name)"
                    )
                except sqlite3.OperationalError:
                    pass  # 表不存在（全新安装）或列已存在

            # sqlite-vec 虚拟表（含维度迁移检测）
            if self._vec_loaded:
                vec_exists = self.conn.execute(
                    "SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks_vec'"
                ).fetchone()
                if vec_exists:
                    # 维度迁移检测：如果 vec0 表维度与当前配置不一致，重建
                    self._migrate_vec_dim_if_needed()
                else:
                    self.conn.execute(
                        f"CREATE VIRTUAL TABLE chunks_vec USING vec0(embedding float[{self._embedding_dim}])"
                    )


    def _migrate_vec_dim_if_needed(self) -> None:
        """检测 vec0 表维度是否与当前配置匹配

        三层防御（同 db_cog.py #254 教训）：
        - L1 事前：有数据时拒绝破坏性迁移，适配已有维度
        - L2 事中：无数据重建后验证新表正确性
        - L3 事后：结构化日志（old_dim/new_dim/rows/action）
        """
        import logging
        log = logging.getLogger(__name__)

        try:
            row = self.conn.execute(
                "SELECT sql FROM sqlite_master WHERE name='chunks_vec'"
            ).fetchone()
            if not row or not row[0]:
                return

            # 从 CREATE 语句中提取维度：float[768] → 768
            sql = row[0]
            m = re.search(r'float\[(\d+)\]', sql)
            if not m:
                return

            existing_dim = int(m.group(1))
            if existing_dim == self._embedding_dim:
                return

            # ── L1 事前校验：检查已有数据量 ──
            existing_rows = 0
            try:
                existing_rows = self.conn.execute(
                    "SELECT COUNT(*) FROM chunks_vec"
                ).fetchone()[0]
            except Exception as e:
                log.debug("chunks_vec COUNT 查询失败 (视为 0 行): %s", e)

            if existing_rows > 0:
                # ── L1 拒绝：有数据时不允许破坏性迁移 ──
                log.error(
                    "chunks_vec 维度不匹配 (DB=%d, 请求=%d) 且存在 %d 条向量数据，"
                    "拒绝自动迁移以保护数据。适配为已有维度 %d。"
                    "请用 `index1 index --resume` 重新索引",
                    existing_dim, self._embedding_dim, existing_rows, existing_dim,
                )
                self._embedding_dim = existing_dim
                return

            # ── 无数据时安全重建 ──
            log.warning(
                "chunks_vec 维度变更: %d → %d（表为空，安全重建）",
                existing_dim, self._embedding_dim,
            )
            self.conn.execute("DROP TABLE chunks_vec")
            self.conn.execute(
                f"CREATE VIRTUAL TABLE chunks_vec USING vec0(embedding float[{self._embedding_dim}])"
            )
            # 清除 chunks 表中的旧 embedding（维度不匹配，不可用）
            self.conn.execute("UPDATE chunks SET embedding = NULL")
            self.conn.commit()

            # ── L2 事中验证：确认重建成功 ──
            verify = self.conn.execute(
                "SELECT sql FROM sqlite_master WHERE name='chunks_vec'"
            ).fetchone()
            if not verify or f"float[{self._embedding_dim}]" not in (verify[0] or ""):
                log.error(
                    "chunks_vec 重建后验证失败: expected float[%d], got %s",
                    self._embedding_dim, verify[0] if verify else "None",
                )
        except Exception as e:
            log.warning("vec0 维度迁移检测失败: %s", e)


def _floats_to_blob(floats: list[float]) -> bytes:
    """float 列表转 sqlite-vec 需要的 bytes"""
    return struct.pack(f"{len(floats)}f", *floats)


def _blob_to_floats(blob: bytes, dim: int) -> list[float]:
    """bytes 转 float 列表"""
    return list(struct.unpack(f"{dim}f", blob))
