"""索引引擎 — 扫描 → 变更检测 → 分块 → embedding → 入库

流程: walk_files → hash_diff → smart_chunk → batch_embed → insert_db
"""

from __future__ import annotations

import asyncio
import hashlib
import logging
import os
import sqlite3
from pathlib import Path

from ._compat import asyncio_timeout
from .chunker import Chunk, get_chunker
from .config import Config
from .db import Database, _SUPPORTS_CONTENTLESS_DELETE, _floats_to_blob, tokenize_for_fts5
from .embed import Embedder
from .resilience import safe_db_transaction

logger = logging.getLogger(__name__)

# 支持索引的文件扩展名
INDEXABLE_EXTENSIONS = {
    ".md", ".markdown",
    ".py",
    ".rs",
    ".js", ".ts", ".jsx", ".tsx",
    ".txt", ".text",
    ".yaml", ".yml", ".toml", ".json",
    ".cfg", ".ini", ".conf",
    ".sh", ".bash",
    ".html", ".css",
}

# 跳过的目录
SKIP_DIRS = {
    ".git", ".hg", ".svn",
    "__pycache__", ".mypy_cache", ".pytest_cache",
    "node_modules", ".venv", "venv", "env",
    ".tox", ".eggs", "*.egg-info",
    "target", "build", "dist",
    ".index1", ".claude-index1",
}

MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB

# collection 推断已迁移到 collection.py（Issue #136）
from .collection import detect_collection, PROJECT_MARKERS as _PROJECT_MARKERS


def infer_collection(paths: list[str | Path], config: Config) -> str:
    """未指定 collection 时，从索引路径推断项目名作为 collection

    委托给统一的 detect_collection()（collection.py）
    """
    cwd = Path(paths[0]).resolve() if paths else None
    return detect_collection(cwd=cwd, paths=paths, config=config)


def _file_hash(path: Path) -> str:
    """计算文件 SHA256"""
    h = hashlib.sha256()
    try:
        with open(path, "rb") as f:
            for block in iter(lambda: f.read(65536), b""):
                h.update(block)
    except OSError as e:
        logger.warning("读取文件失败 %s: %s", path, e)
        return ""
    return h.hexdigest()


def _detect_doc_type(path: Path) -> str:
    """根据扩展名判断文档类型"""
    ext = path.suffix.lower()
    mapping = {
        ".md": "markdown", ".markdown": "markdown",
        ".py": "python",
        ".rs": "rust",
        ".js": "javascript", ".ts": "javascript",
        ".jsx": "javascript", ".tsx": "javascript",
    }
    return mapping.get(ext, "text")


def _collect_files(paths: list[str | Path]) -> list[Path]:
    """收集所有可索引文件（递归目录，过滤扩展名和大小）

    使用 os.scandir 手动递归，遇到 SKIP_DIRS 直接跳过不进入子目录，
    避免 rglob 遍历 node_modules 等巨型目录。
    """
    result: list[Path] = []

    for p in paths:
        path = Path(p).resolve()

        if path.is_file():
            if _should_index(path):
                result.append(path)
            continue

        if path.is_dir():
            for f in _walk_indexable(path):
                result.append(f)

    return sorted(set(result))


def _walk_indexable(root: Path) -> list[Path]:
    """迭代收集可索引文件，跳过 SKIP_DIRS 目录不进入

    使用显式栈代替递归，避免深目录导致 RecursionError。
    """
    result: list[Path] = []
    stack: list[Path] = [root]

    while stack:
        current = stack.pop()
        try:
            entries = os.scandir(current)
        except OSError:
            continue

        with entries:
            for entry in entries:
                if entry.is_dir(follow_symlinks=False):
                    if entry.name not in SKIP_DIRS:
                        stack.append(Path(entry.path))
                elif entry.is_file(follow_symlinks=False):
                    p = Path(entry.path)
                    if _should_index(p):
                        result.append(p)
    return result


def _should_index(path: Path) -> bool:
    """判断文件是否应该被索引"""
    if path.suffix.lower() not in INDEXABLE_EXTENSIONS:
        return False
    try:
        size = path.stat().st_size
    except OSError:
        return False
    if size > MAX_FILE_SIZE:
        logger.info("跳过大文件 %s (%d MB)", path, size // (1024 * 1024))
        return False
    if size == 0:
        return False
    return True


def _in_skip_dir(file_path: Path, root: Path) -> bool:
    """检查文件是否在需要跳过的目录中"""
    try:
        rel = file_path.relative_to(root)
    except ValueError:
        return False
    return any(part in SKIP_DIRS for part in rel.parts[:-1])


def _atomic_upsert(
    conn: sqlite3.Connection,
    db: Database,
    rel_path: str,
    file_hash: str,
    collection: str,
    doc_type: str,
    chunk_dicts: list[dict],
    symbol_tags: list | None = None,
) -> int:
    """原子化入库 — 在单个事务中完成 upsert + delete + insert

    由 safe_db_transaction() 调用，conn 已在事务中。
    所有 SQL 直接用 conn.execute()，不调用 db 高层方法（避免嵌套事务）。
    """
    # 1. Upsert document → doc_id
    cur = conn.execute(
        """INSERT INTO documents (path, hash, collection, doc_type, metadata, indexed_at)
           VALUES (?, ?, ?, ?, NULL, CURRENT_TIMESTAMP)
           ON CONFLICT(path) DO UPDATE SET
             hash=excluded.hash,
             collection=excluded.collection,
             doc_type=excluded.doc_type,
             indexed_at=CURRENT_TIMESTAMP
           RETURNING id""",
        (rel_path, file_hash, collection, doc_type),
    )
    doc_id = cur.fetchone()[0]

    # 2a. Delete old symbols + refs
    # 先删 symbols → ON DELETE SET NULL 级联清理 refs.to_symbol_id
    # 再删 refs（from_doc_id = doc_id，清理本文件的出向引用）
    conn.execute("DELETE FROM symbols WHERE doc_id = ?", (doc_id,))
    conn.execute("DELETE FROM refs WHERE from_doc_id = ?", (doc_id,))

    # 2b. Delete old chunks (FTS5 + vec cleanup)
    old_rows = conn.execute(
        "SELECT id, content, section, parent_section, tags FROM chunks WHERE doc_id = ?",
        (doc_id,),
    ).fetchall()

    fts5_rebuild_needed = False
    for row in old_rows:
        cid, content, section, parent_section, tags = row
        if _SUPPORTS_CONTENTLESS_DELETE:
            conn.execute("DELETE FROM chunks_fts WHERE rowid = ?", (cid,))
        else:
            try:
                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_rebuild_needed = True
        if db._vec_loaded:
            conn.execute("DELETE FROM chunks_vec WHERE rowid = ?", (cid,))

    if old_rows:
        conn.execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,))

    if fts5_rebuild_needed:
        try:
            conn.execute("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')")
        except sqlite3.OperationalError as e:
            logger.warning("FTS5 rebuild 失败: %s", e)

    # 3. Insert new chunks + FTS5 + vec sync
    for chunk in chunk_dicts:
        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 = 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

        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,
            ),
        )

        if embedding_blob and db._vec_loaded:
            conn.execute(
                "INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)",
                (chunk_id, embedding_blob),
            )

    # 4. Insert symbols + refs from tags（可选，tree-sitter 提取的标签）
    if symbol_tags:
        try:
            # 收集 chunk 行范围（用于 symbol→chunk 映射）
            chunk_line_ranges: list[tuple[int, int, int]] = []  # (idx, line_start, line_end)
            line_offset = 0
            for i, cd in enumerate(chunk_dicts):
                content = cd.get("content", "")
                line_count = content.count("\n") + 1
                chunk_line_ranges.append((i, line_offset, line_offset + line_count - 1))
                line_offset += line_count

            inserted_chunks = conn.execute(
                "SELECT id, chunk_index FROM chunks WHERE doc_id = ? ORDER BY chunk_index",
                (doc_id,),
            ).fetchall()
            chunk_id_by_index = {r[1]: r[0] for r in inserted_chunks}

            def _find_chunk_id(tag_line: int) -> int | None:
                for idx, start, end in chunk_line_ranges:
                    if start <= tag_line <= end:
                        return chunk_id_by_index.get(idx)
                return None

            # 插入 definitions → symbols 表
            defs = [t for t in symbol_tags if t.kind == "def"]
            symbol_name_to_id: dict[str, int] = {}
            for tag in defs:
                if not tag.name:
                    continue
                chunk_id = _find_chunk_id(tag.line) if tag.line >= 0 else None
                cur = conn.execute(
                    """INSERT INTO symbols
                       (doc_id, chunk_id, name, kind, line_start)
                       VALUES (?, ?, ?, ?, ?)""",
                    (doc_id, chunk_id, tag.name, "function", tag.line),
                )
                symbol_name_to_id[tag.name] = cur.lastrowid

            # 插入 references → refs 表（to_symbol_id 暂时 NULL，后续跨文件解析填充）
            ref_tags = [t for t in symbol_tags if t.kind == "ref"]
            for tag in ref_tags:
                if not tag.name:
                    continue
                to_sym_id = symbol_name_to_id.get(tag.name)
                to_doc = doc_id if to_sym_id else None
                confidence = 0.9 if to_sym_id else 0.5
                conn.execute(
                    """INSERT INTO refs
                       (from_doc_id, to_doc_id, to_symbol_id, ref_name, kind, from_line, confidence)
                       VALUES (?, ?, ?, ?, ?, ?, ?)""",
                    (doc_id, to_doc, to_sym_id, tag.name, "call", tag.line, confidence),
                )
        except (sqlite3.IntegrityError, sqlite3.OperationalError) as e:
            logger.warning("符号/引用插入异常（不影响索引）: %s", e)

    return doc_id


async def index_files(
    paths: list[str | Path],
    config: Config,
    db: Database,
    embedder: Embedder,
    *,
    collection: str | None = None,
    force: bool = False,
    base_dir: Path | None = None,
    concurrent: int = 8,
) -> dict:
    """索引文件列表

    Args:
        paths: 文件或目录路径列表
        config: 配置
        db: 数据库实例（已连接）
        embedder: embedding 客户端
        collection: 集合名称，None 使用 config 默认值
        force: True 时跳过 hash 检测，强制重索引
        base_dir: 基准目录，用于计算相对路径
        concurrent: 并发文件数（默认 8，embedding I/O 与 chunking CPU 流水线重叠）

    Returns:
        统计信息 dict
    """
    collection = collection or infer_collection(list(paths), config)

    # L1 事前校验：tree-sitter 可用性（符号提取是代码图的前提）
    codegraph_ok = False
    try:
        from .symbols import is_codegraph_available
        codegraph_ok = is_codegraph_available()
        if not codegraph_ok:
            logger.error(
                "P0: tree-sitter 不可用 — 符号提取将跳过，代码图（symbols/refs/PageRank）将为空。"
                " 安装: pipx runpip index1 install tree-sitter tree-sitter-language-pack"
            )
    except Exception as e:
        logger.error("P0: 符号模块导入失败 — 代码图将不可用: %s", e)

    # 检查 Ollama 可用性
    ollama_ok = await embedder.check_availability()
    if not ollama_ok:
        logger.warning(
            "Ollama 不可用或模型 %s 未加载，将跳过 embedding（仅 BM25 搜索可用）",
            embedder.model,
        )

    # 收集文件
    files = _collect_files(paths)
    if not files:
        logger.info("未找到可索引文件")
        return {"total": 0, "indexed": 0, "skipped": 0, "failed": 0}

    stats: dict = {
        "total": len(files), "indexed": 0, "skipped": 0, "failed": 0,
        "failed_files": [],  # 记录失败文件路径，供排查和重试
        "interrupted": False,
    }
    total = len(files)
    completed = 0  # 并发进度计数器（asyncio 单线程，无竞态）

    # 创建索引运行记录
    run_id = db.create_index_run(collection, total)
    if run_id is None:
        logger.warning("创建 index_run 记录失败，断点续传功能将不可用")

    sem = asyncio.Semaphore(concurrent)

    async def _process_one(file_path: Path) -> tuple[str, Path]:
        """处理单个文件，返回 (status, path)"""
        nonlocal completed
        async with sem:
            try:
                # 动态超时: 基础 60s + 每 10KB 文件大小 1s（覆盖大文件 embedding 耗时）
                try:
                    file_size = file_path.stat().st_size
                except OSError:
                    file_size = 0
                timeout_sec = max(120, 60 + file_size // 10240)
                async with asyncio_timeout(timeout_sec):
                    await _index_one_file(
                        file_path, db, embedder, collection,
                        force=force, ollama_ok=ollama_ok, base_dir=base_dir,
                    )
                status = "indexed"
            except _SkipFile:
                status = "skipped"
            except TimeoutError:
                logger.warning("索引超时（%ds）%s，跳过", timeout_sec, file_path)
                status = "failed"
            except Exception as e:
                logger.warning("索引失败 %s: %s", file_path, e)
                status = "failed"

            completed += 1
            if completed == 1 or completed % 50 == 0 or completed == total:
                logger.info("进度: %d/%d 文件...", completed, total)

            return (status, file_path)

    try:
        results = await asyncio.gather(
            *[_process_one(f) for f in files],
            return_exceptions=True,
        )

        for result in results:
            # 系统异常必须传播，不能当成业务失败
            if isinstance(result, (KeyboardInterrupt, SystemExit, GeneratorExit)):
                stats["interrupted"] = True
                break
            if isinstance(result, BaseException):
                stats["failed"] += 1
                continue
            status, file_path = result
            if status == "indexed":
                stats["indexed"] += 1
            elif status == "skipped":
                stats["skipped"] += 1
            else:
                stats["failed"] += 1
                stats["failed_files"].append(str(file_path))
    except KeyboardInterrupt:
        stats["interrupted"] = True
    finally:
        # 持久化运行记录（中断时也写入）
        if run_id is not None:
            db.update_index_run(run_id, stats)

    if stats["interrupted"]:
        logger.warning(
            "索引被中断，已完成 %d/%d（成功 %d，跳过 %d，失败 %d）",
            stats["indexed"] + stats["skipped"] + stats["failed"],
            total, stats["indexed"], stats["skipped"], stats["failed"],
        )

    if stats["failed_files"]:
        logger.warning("以下文件索引失败，可重新运行索引:\n  %s",
                       "\n  ".join(stats["failed_files"]))

    # L2 事中监控：索引完成后检查 symbols 是否为空（仅对含代码文件的索引告警）
    _CODE_SUFFIXES = {".py", ".rs", ".js", ".ts", ".jsx", ".tsx", ".go", ".java", ".c", ".cpp", ".rb"}
    has_code_files = any(f.suffix.lower() in _CODE_SUFFIXES for f in files)
    if stats["indexed"] > 0 and has_code_files:
        try:
            sym_count = db.conn.execute(
                "SELECT COUNT(*) FROM symbols s JOIN documents d ON s.doc_id = d.id WHERE d.collection = ?",
                (collection,),
            ).fetchone()[0]
            if sym_count == 0:
                logger.error(
                    "P0: 索引完成但 symbols=0 — 代码图不可用（符号路由、PageRank、repo-map 全部失效）。"
                    " 修复: pipx runpip index1 install tree-sitter tree-sitter-language-pack && index1 index ./src --force"
                )
            stats["codegraph_available"] = codegraph_ok
            stats["symbols"] = sym_count
        except Exception as e:
            logger.debug("symbols 检查跳过（表可能不存在）: %s", e)

    # 跨文件引用解析 + PageRank（索引完成后批量处理）
    if stats["indexed"] > 0:
        try:
            ref_stats = resolve_cross_file_refs(db, collection)
            stats["refs_resolved"] = ref_stats.get("resolved", 0)
            stats["refs_ambiguous"] = ref_stats.get("ambiguous", 0)
        except Exception as e:
            logger.warning("跨文件引用解析异常: %s", e)

        try:
            from .graph import compute_and_store_pagerank
            pr_count = compute_and_store_pagerank(db, collection)
            stats["pagerank_updated"] = pr_count
        except Exception as e:
            logger.warning("PageRank 计算异常: %s", e)

    return stats


class _SkipFile(Exception):
    """文件无变更，跳过索引"""


async def _index_one_file(
    file_path: Path,
    db: Database,
    embedder: Embedder,
    collection: str,
    *,
    force: bool,
    ollama_ok: bool,
    base_dir: Path | None,
) -> None:
    """索引单个文件"""
    # 计算相对路径作为文档 path
    if base_dir:
        try:
            rel_path = str(file_path.relative_to(base_dir))
        except ValueError:
            rel_path = str(file_path)
    else:
        rel_path = str(file_path)

    # Step 1: 变更检测
    file_hash = _file_hash(file_path)
    if not file_hash:
        raise _SkipFile("hash 计算失败")

    if not force:
        stored_hash = db.get_document_hash(rel_path)
        if stored_hash == file_hash:
            raise _SkipFile("文件未变更")

    # Step 2: 读取文件内容
    try:
        content = file_path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        try:
            content = file_path.read_text(encoding="latin-1")
        except OSError as e:
            raise RuntimeError(f"读取失败: {e}") from e
    except OSError as e:
        raise RuntimeError(f"读取失败: {e}") from e

    if not content.strip():
        raise _SkipFile("文件为空")

    # TOCTOU 防护: 文件可能在 hash 计算与内容读取之间被修改
    rehash = _file_hash(file_path)
    if rehash and rehash != file_hash:
        raise _SkipFile("文件在索引期间被修改，下次重新处理")

    # Step 3: 分块
    chunker = get_chunker(str(file_path))
    doc_type = _detect_doc_type(file_path)
    chunks: list[Chunk] = chunker.chunk(content, str(file_path))

    if not chunks:
        raise _SkipFile("分块结果为空")

    # Step 3.5: 符号提取（可选，tree-sitter 不可用时跳过）
    symbol_tags = None
    try:
        from .symbols import extract_tags, is_codegraph_available

        if is_codegraph_available():
            symbol_tags = extract_tags(str(file_path), content, rel_path)
            if not symbol_tags:
                symbol_tags = None
    except Exception as e:
        logger.warning("符号提取失败 %s: %s", rel_path, e)

    # Step 4: 批量 embedding（允许部分成功，失败条目为 None）
    embeddings: list[list[float] | None] = []
    if ollama_ok:
        texts = [c.content for c in chunks]
        embeddings = await embedder.embed_batch(texts)

    # Step 5: 入库（原子事务：upsert doc + delete旧chunks + insert新chunks + symbols）
    chunk_dicts: list[dict] = []
    for i, chunk in enumerate(chunks):
        d = {
            "content": chunk.content,
            "section": chunk.section,
            "parent_section": chunk.parent_section,
            "chunk_index": chunk.chunk_index,
            "tags": chunk.tags,
            "token_count": chunk.token_count,
            "summary": chunk.summary,
            "embedding": embeddings[i] if i < len(embeddings) and embeddings[i] is not None else None,
        }
        chunk_dicts.append(d)

    success, _result = safe_db_transaction(
        db.conn,
        lambda conn: _atomic_upsert(
            conn, db, rel_path, file_hash, collection, doc_type, chunk_dicts,
            symbol_tags=symbol_tags,
        ),
    )
    if not success:
        raise RuntimeError("数据库写入失败（锁超时/磁盘错误）")

    vec_count = sum(1 for d in chunk_dicts if d["embedding"] is not None)
    sym_count = len(symbol_tags) if symbol_tags else 0
    if vec_count == 0:
        vec_hint = " (无向量)"
    elif vec_count < len(chunk_dicts):
        vec_hint = f" ({vec_count}/{len(chunk_dicts)} 有向量)"
    else:
        vec_hint = ""
    sym_hint = f", {sym_count} tags" if sym_count else ""
    logger.info("已索引 %s → %d chunks%s%s", rel_path, len(chunk_dicts), vec_hint, sym_hint)


def resolve_cross_file_refs(db: Database, collection: str) -> dict:
    """跨文件引用解析 — 索引完成后调用

    遍历所有 to_symbol_id=NULL 的引用，按 ref_name 匹配目标符号：
    - 唯一匹配 → confidence 0.7
    - 多个同名符号 → 选第一个，confidence 0.4
    - 无匹配 → 保持 NULL

    Returns:
        {"total": N, "resolved": N, "ambiguous": N, "unresolved": N}
    """
    stats = {"total": 0, "resolved": 0, "ambiguous": 0, "unresolved": 0}

    try:
        # 1. 查询所有未解析的引用
        unresolved = db.conn.execute(
            """SELECT r.id, r.ref_name
               FROM refs r
               JOIN documents d ON r.from_doc_id = d.id
               WHERE r.to_symbol_id IS NULL
                 AND r.ref_name IS NOT NULL
                 AND d.collection = ?""",
            (collection,),
        ).fetchall()
    except Exception as e:
        logger.warning("查询未解析引用失败: %s", e)
        return stats

    if not unresolved:
        return stats

    stats["total"] = len(unresolved)

    try:
        # 2. 构建全局符号名称索引: name → [(symbol_id, doc_id)]
        symbol_index: dict[str, list[tuple[int, int]]] = {}
        all_symbols = db.conn.execute(
            """SELECT s.id, s.doc_id, s.name
               FROM symbols s
               JOIN documents d ON s.doc_id = d.id
               WHERE d.collection = ?""",
            (collection,),
        ).fetchall()

        for sym_id, doc_id, name in all_symbols:
            symbol_index.setdefault(name, []).append((sym_id, doc_id))

        if not symbol_index:
            stats["unresolved"] = stats["total"]
            return stats

        # 3. 批量解析
        updates: list[tuple[int, int, float]] = []  # (to_symbol_id, to_doc_id, confidence, ref_id)
        for ref_id, ref_name in unresolved:
            candidates = symbol_index.get(ref_name)
            if not candidates:
                stats["unresolved"] += 1
                continue

            if len(candidates) == 1:
                sym_id, doc_id = candidates[0]
                updates.append((sym_id, doc_id, 0.7, ref_id))
                stats["resolved"] += 1
            else:
                # 多个同名符号 → 歧义，取第一个但低置信度
                sym_id, doc_id = candidates[0]
                updates.append((sym_id, doc_id, 0.4, ref_id))
                stats["ambiguous"] += 1

        # 4. 批量 UPDATE
        if updates:
            with db.conn:
                db.conn.executemany(
                    """UPDATE refs
                       SET to_symbol_id = ?, to_doc_id = ?, confidence = ?
                       WHERE id = ?""",
                    updates,
                )

        logger.info(
            "跨文件引用解析: %d 已解析, %d 歧义, %d 未匹配 (共 %d)",
            stats["resolved"], stats["ambiguous"], stats["unresolved"], stats["total"],
        )
    except Exception as e:
        logger.warning("跨文件引用解析失败: %s", e)
        stats["unresolved"] = stats["total"] - stats["resolved"] - stats["ambiguous"]

    return stats


async def reindex_file(
    file_path: str | Path,
    config: Config,
    db: Database,
    embedder: Embedder,
    *,
    collection: str | None = None,
    base_dir: Path | None = None,
) -> bool:
    """重索引单个文件（watcher 使用）

    Returns:
        True 表示成功索引，False 表示跳过或失败
    """
    file_path = Path(file_path).resolve()
    collection = collection or config.collection
    ollama_ok = embedder.available or await embedder.check_availability()

    try:
        await _index_one_file(
            file_path, db, embedder, collection,
            force=True, ollama_ok=ollama_ok, base_dir=base_dir,
        )
        return True
    except _SkipFile:
        return False
    except Exception as e:
        logger.warning("重索引失败 %s: %s", file_path, e)
        return False


async def backfill_embeddings(
    config: Config,
    db: Database,
    embedder: Embedder,
    *,
    collection: str | None = None,
    batch_size: int = 32,
) -> dict:
    """回填缺失的向量（不重新分块，只补 embedding）

    适用场景：
    - embed_batch 部分失败后补齐断层
    - Ollama 恢复后补充之前跳过的向量
    - 切换模型后重新生成向量

    Returns:
        统计信息 dict
    """
    collection = collection or config.collection

    # 检查 Ollama
    ollama_ok = await embedder.check_availability()
    if not ollama_ok:
        logger.warning("Ollama 不可用，无法回填向量: %s", embedder.unavailable_reason)
        return {"total": 0, "filled": 0, "failed": 0}

    # 查询缺向量的 chunks
    missing = db.get_chunks_missing_embedding(collection)
    if not missing:
        logger.info("所有 chunk 都有向量，无需回填")
        return {"total": 0, "filled": 0, "failed": 0}

    total = len(missing)
    logger.info("发现 %d 个缺失向量的 chunk，开始回填...", total)

    stats = {"total": total, "filled": 0, "failed": 0}

    # 分批 embed
    for i in range(0, total, batch_size):
        batch = missing[i : i + batch_size]
        chunk_ids = [cid for cid, _ in batch]
        texts = [text for _, text in batch]

        try:
            embeddings = await embedder.embed_batch(texts, batch_size=batch_size)
        except Exception as e:
            logger.warning("回填 batch %d 失败: %s", i // batch_size, e)
            stats["failed"] += len(batch)
            continue

        for j, (chunk_id, _text) in enumerate(batch):
            emb = embeddings[j] if j < len(embeddings) and embeddings[j] is not None else None
            if emb is None:
                stats["failed"] += 1
                continue
            try:
                db.update_chunk_embedding(chunk_id, emb)
                stats["filled"] += 1
            except Exception as e:
                logger.warning("回填 chunk %d 写入失败: %s", chunk_id, e)
                stats["failed"] += 1

        filled_so_far = stats["filled"] + stats["failed"]
        if filled_so_far % 100 == 0 or filled_so_far == total:
            logger.info("回填进度: %d/%d", filled_so_far, total)

    logger.info(
        "回填完成: %d 成功, %d 失败 (共 %d)",
        stats["filled"], stats["failed"], stats["total"],
    )
    return stats
