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

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

from __future__ import annotations

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

from ._compat import asyncio_timeout
from .chunker import Chunk, get_chunker
from .config import Config
from .db import Database
from .embed import OllamaEmbedder

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


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


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

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

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

    # 检查 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 = {"total": len(files), "indexed": 0, "skipped": 0, "failed": 0}
    total = len(files)

    for idx, file_path in enumerate(files, 1):
        # 每 50 个文件或第 1 个文件输出进度
        if idx == 1 or idx % 50 == 0 or idx == total:
            logger.info("进度: %d/%d 文件...", idx, total)
        try:
            # 单文件超时 120s，防止 Ollama 卡死拖垮整个管线
            async with asyncio_timeout(120):
                await _index_one_file(
                    file_path, db, embedder, collection,
                    force=force, ollama_ok=ollama_ok, base_dir=base_dir,
                )
            stats["indexed"] += 1
        except _SkipFile:
            stats["skipped"] += 1
        except TimeoutError:
            logger.warning("索引超时（120s）%s，跳过", file_path)
            stats["failed"] += 1
        except Exception as e:
            logger.warning("索引失败 %s: %s", file_path, e)
            stats["failed"] += 1

    return stats


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


async def _index_one_file(
    file_path: Path,
    db: Database,
    embedder: OllamaEmbedder,
    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("文件为空")

    # 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 4: 批量 embedding
    embeddings: list[list[float]] = []
    if ollama_ok:
        texts = [c.content for c in chunks]
        try:
            embeddings = await embedder.embed_batch(texts)
        except Exception as e:
            logger.warning("embedding 失败 %s: %s，跳过向量", file_path, e)

    # Step 5: 入库（事务：先删旧 chunks → upsert doc → 插新 chunks）
    doc_id = db.upsert_document(
        path=rel_path,
        hash=file_hash,
        collection=collection,
        doc_type=doc_type,
    )

    # 删除旧 chunks（重索引场景）
    db.delete_chunks_by_doc(doc_id)

    # 构造 chunk dicts
    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) else None,
        }
        chunk_dicts.append(d)

    db.insert_chunks(doc_id, chunk_dicts)

    logger.info(
        "已索引 %s → %d chunks%s",
        rel_path,
        len(chunk_dicts),
        " (无向量)" if not embeddings else "",
    )


async def reindex_file(
    file_path: str | Path,
    config: Config,
    db: Database,
    embedder: OllamaEmbedder,
    *,
    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
