"""Indexer 模块测试"""

from __future__ import annotations

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest

from index1.config import Config
from index1.db import Database
from index1.embed import OllamaEmbedder
from index1.indexer import (
    INDEXABLE_EXTENSIONS,
    SKIP_DIRS,
    _collect_files,
    _detect_doc_type,
    _file_hash,
    _in_skip_dir,
    _should_index,
    index_files,
    infer_collection,
)


@pytest.fixture
def tmp_project(tmp_path):
    """创建临时项目目录结构"""
    # 可索引文件
    (tmp_path / "README.md").write_text("# Hello\n\nWorld", encoding="utf-8")
    (tmp_path / "main.py").write_text("def hello():\n    pass\n", encoding="utf-8")
    (tmp_path / "lib.rs").write_text("fn main() {}\n", encoding="utf-8")
    (tmp_path / "config.yaml").write_text("key: value\n", encoding="utf-8")

    # 跳过的目录
    (tmp_path / ".git").mkdir()
    (tmp_path / ".git" / "config").write_text("git config", encoding="utf-8")
    (tmp_path / "__pycache__").mkdir()
    (tmp_path / "__pycache__" / "main.cpython-312.pyc").write_bytes(b"bytecode")

    # 不可索引文件
    (tmp_path / "image.png").write_bytes(b"\x89PNG")
    (tmp_path / "empty.py").write_text("", encoding="utf-8")

    # 子目录
    sub = tmp_path / "src"
    sub.mkdir()
    (sub / "app.py").write_text("class App:\n    pass\n", encoding="utf-8")

    return tmp_path


@pytest.fixture
def config(tmp_path):
    return Config(db_path=str(tmp_path / "test.db"))


@pytest.fixture
def db(config):
    d = Database(config.db_path_resolved, config.embedding_dim)
    d.connect()
    yield d
    d.close()


@pytest.fixture
def mock_embedder():
    embedder = AsyncMock(spec=OllamaEmbedder)
    embedder.model = "nomic-embed-text"
    embedder.dim = 768
    embedder.check_availability = AsyncMock(return_value=False)
    embedder.available = False
    embedder.close = AsyncMock()
    return embedder


class TestFileHash:
    def test_hash_file(self, tmp_path):
        f = tmp_path / "test.txt"
        f.write_text("hello", encoding="utf-8")
        h = _file_hash(f)
        assert len(h) == 64  # SHA256 hex

    def test_hash_nonexistent(self, tmp_path):
        h = _file_hash(tmp_path / "nonexistent.txt")
        assert h == ""

    def test_hash_deterministic(self, tmp_path):
        f = tmp_path / "test.txt"
        f.write_text("hello", encoding="utf-8")
        assert _file_hash(f) == _file_hash(f)


class TestDetectDocType:
    def test_markdown(self):
        assert _detect_doc_type(Path("README.md")) == "markdown"

    def test_python(self):
        assert _detect_doc_type(Path("app.py")) == "python"

    def test_rust(self):
        assert _detect_doc_type(Path("lib.rs")) == "rust"

    def test_javascript(self):
        assert _detect_doc_type(Path("app.js")) == "javascript"
        assert _detect_doc_type(Path("app.tsx")) == "javascript"

    def test_unknown(self):
        assert _detect_doc_type(Path("config.yaml")) == "text"


class TestShouldIndex:
    def test_valid_file(self, tmp_path):
        f = tmp_path / "test.py"
        f.write_text("code", encoding="utf-8")
        assert _should_index(f) is True

    def test_unsupported_ext(self, tmp_path):
        f = tmp_path / "image.png"
        f.write_bytes(b"\x89PNG")
        assert _should_index(f) is False

    def test_empty_file(self, tmp_path):
        f = tmp_path / "empty.py"
        f.write_text("", encoding="utf-8")
        assert _should_index(f) is False


class TestCollectFiles:
    def test_collect_directory(self, tmp_project):
        files = _collect_files([tmp_project])
        names = {f.name for f in files}
        assert "README.md" in names
        assert "main.py" in names
        assert "app.py" in names
        assert "config.yaml" in names
        # 排除项
        assert "image.png" not in names
        assert "empty.py" not in names

    def test_skip_dirs(self, tmp_project):
        files = _collect_files([tmp_project])
        paths = [str(f) for f in files]
        assert not any(".git" in p.split("/") for p in paths)
        assert not any("__pycache__" in p.split("/") for p in paths)

    def test_single_file(self, tmp_project):
        files = _collect_files([tmp_project / "main.py"])
        assert len(files) == 1
        assert files[0].name == "main.py"


class TestInSkipDir:
    def test_in_git(self, tmp_path):
        root = tmp_path
        f = root / ".git" / "config"
        assert _in_skip_dir(f, root) is True

    def test_not_in_skip(self, tmp_path):
        root = tmp_path
        f = root / "src" / "app.py"
        assert _in_skip_dir(f, root) is False


class TestInferCollection:
    """infer_collection() — 自动推断 collection 名称"""

    def test_explicit_collection_respected(self, tmp_path):
        """用户显式配了 collection（非 default）→ 尊重配置"""
        cfg = Config(db_path=str(tmp_path / "t.db"), collection="my-proj")
        assert infer_collection([str(tmp_path)], cfg) == "my-proj"

    def test_git_project_root(self, tmp_path):
        """有 .git 的目录 → 用目录名作为 collection"""
        proj = tmp_path / "awesome-lib"
        proj.mkdir()
        (proj / ".git").mkdir()
        (proj / "main.py").write_text("x = 1")
        cfg = Config(db_path=str(tmp_path / "t.db"))
        assert infer_collection([str(proj / "main.py")], cfg) == "awesome-lib"

    def test_pyproject_toml_marker(self, tmp_path):
        """有 pyproject.toml 的目录 → 用目录名"""
        proj = tmp_path / "my-pkg"
        proj.mkdir()
        (proj / "pyproject.toml").write_text("[project]")
        cfg = Config(db_path=str(tmp_path / "t.db"))
        assert infer_collection([str(proj)], cfg) == "my-pkg"

    def test_package_json_marker(self, tmp_path):
        """有 package.json 的目录 → 用目录名"""
        proj = tmp_path / "frontend-app"
        proj.mkdir()
        (proj / "package.json").write_text("{}")
        cfg = Config(db_path=str(tmp_path / "t.db"))
        assert infer_collection([str(proj)], cfg) == "frontend-app"

    def test_subdirectory_finds_parent_root(self, tmp_path):
        """从子目录往上找到项目根"""
        proj = tmp_path / "big-project"
        proj.mkdir()
        (proj / ".git").mkdir()
        sub = proj / "src" / "lib"
        sub.mkdir(parents=True)
        (sub / "mod.py").write_text("pass")
        cfg = Config(db_path=str(tmp_path / "t.db"))
        assert infer_collection([str(sub / "mod.py")], cfg) == "big-project"

    def test_no_markers_uses_dir_name(self, tmp_path):
        """没有项目标记 → 用当前目录名"""
        plain = tmp_path / "plain-dir"
        plain.mkdir()
        (plain / "file.txt").write_text("hi")
        cfg = Config(db_path=str(tmp_path / "t.db"))
        result = infer_collection([str(plain / "file.txt")], cfg)
        assert result == "plain-dir"

    def test_empty_paths_returns_default(self, tmp_path):
        """空路径列表 → 返回 config.collection"""
        cfg = Config(db_path=str(tmp_path / "t.db"))
        assert infer_collection([], cfg) == cfg.collection

    def test_meaningless_name_src_fallback(self, tmp_path):
        """项目根叫 src → 无意义，fallback 到 parent"""
        parent = tmp_path / "real-project"
        parent.mkdir()
        src = parent / "src"
        src.mkdir()
        (src / ".git").mkdir()
        (src / "main.py").write_text("x = 1")
        cfg = Config(db_path=str(tmp_path / "t.db"))
        result = infer_collection([str(src / "main.py")], cfg)
        assert result == "real-project"

    def test_meaningless_name_no_parent_fallback(self, tmp_path):
        """项目根和 parent 都无意义 → fallback 到 default"""
        src = tmp_path / "src"
        src.mkdir()
        (src / ".git").mkdir()
        cfg = Config(db_path=str(tmp_path / "t.db"))
        result = infer_collection([str(src)], cfg)
        # parent 是 tmp_path（随机名），可能 meaningful 也可能不
        # 至少不应该返回 "src"
        assert result != "src"

    def test_no_markers_meaningless_dir_returns_default(self, tmp_path):
        """无项目标记 + 目录名无意义 → 返回 default"""
        src = tmp_path / "src"
        src.mkdir()
        (src / "file.txt").write_text("hi")
        cfg = Config(db_path=str(tmp_path / "t.db"))
        result = infer_collection([str(src / "file.txt")], cfg)
        assert result == "default"


class TestIndexFiles:
    @pytest.mark.asyncio
    async def test_index_basic(self, tmp_project, config, db, mock_embedder):
        """基本索引流程：扫描、分块、入库"""
        stats = await index_files(
            [str(tmp_project)], config, db, mock_embedder,
            collection="test", base_dir=tmp_project,
        )
        assert stats["total"] > 0
        assert stats["indexed"] > 0
        assert stats["failed"] == 0

        # 检查数据库有数据
        db_stats = db.get_stats()
        assert db_stats["doc_count"] > 0
        assert db_stats["chunk_count"] > 0

    @pytest.mark.asyncio
    async def test_incremental_skip(self, tmp_project, config, db, mock_embedder):
        """增量索引：未变更文件应跳过"""
        # 第一次索引
        stats1 = await index_files(
            [str(tmp_project)], config, db, mock_embedder,
            collection="test", base_dir=tmp_project,
        )
        # 第二次索引（文件未变更）
        stats2 = await index_files(
            [str(tmp_project)], config, db, mock_embedder,
            collection="test", base_dir=tmp_project,
        )
        assert stats2["skipped"] == stats1["indexed"]
        assert stats2["indexed"] == 0

    @pytest.mark.asyncio
    async def test_force_reindex(self, tmp_project, config, db, mock_embedder):
        """强制全量重索引"""
        # 第一次索引
        await index_files(
            [str(tmp_project)], config, db, mock_embedder,
            collection="test", base_dir=tmp_project,
        )
        # 强制重索引
        stats = await index_files(
            [str(tmp_project)], config, db, mock_embedder,
            collection="test", force=True, base_dir=tmp_project,
        )
        assert stats["indexed"] > 0
        assert stats["skipped"] == 0

    @pytest.mark.asyncio
    async def test_empty_directory(self, tmp_path, config, db, mock_embedder):
        """空目录不应报错"""
        empty = tmp_path / "empty"
        empty.mkdir()
        stats = await index_files(
            [str(empty)], config, db, mock_embedder,
        )
        assert stats["total"] == 0

    @pytest.mark.asyncio
    async def test_with_embeddings(self, tmp_project, config, db, mock_embedder):
        """测试带 embedding 的索引"""
        mock_embedder.check_availability = AsyncMock(return_value=True)
        mock_embedder.available = True
        # 返回假向量
        mock_embedder.embed_batch = AsyncMock(
            side_effect=lambda texts: [[0.1] * 768 for _ in texts]
        )

        stats = await index_files(
            [str(tmp_project)], config, db, mock_embedder,
            collection="test", base_dir=tmp_project,
        )
        assert stats["indexed"] > 0


class TestIndexRuns:
    """索引运行记录（断点续传）"""

    @pytest.mark.asyncio
    async def test_run_created_on_index(self, tmp_project, config, db, mock_embedder):
        """索引后 DB 应有 run 记录"""
        await index_files(
            [str(tmp_project)], config, db, mock_embedder,
            collection="test", base_dir=tmp_project,
        )
        runs = db.get_recent_runs("test")
        assert len(runs) >= 1
        run = runs[0]
        assert run["status"] == "completed"
        assert run["total_files"] > 0
        assert run["indexed"] > 0

    @pytest.mark.asyncio
    async def test_completed_run_not_resumable(self, tmp_project, config, db, mock_embedder):
        """成功完成的 run 不应被 resume 选中"""
        await index_files(
            [str(tmp_project)], config, db, mock_embedder,
            collection="test", base_dir=tmp_project,
        )
        run = db.get_last_interrupted_run("test")
        assert run is None

    @pytest.mark.asyncio
    async def test_failed_files_persisted(self, tmp_path, config, db, mock_embedder):
        """失败文件应被持久化到 run 记录"""
        # 创建一个无法读取的文件（二进制内容触发编码错误后 latin-1 兜底也为空）
        bad = tmp_path / "bad.py"
        bad.write_bytes(b"\x00" * 100)  # 空内容 → SkipFile
        good = tmp_path / "good.py"
        good.write_text("x = 1\n", encoding="utf-8")

        await index_files(
            [str(tmp_path)], config, db, mock_embedder,
            collection="test", base_dir=tmp_path,
        )
        runs = db.get_recent_runs("test")
        assert len(runs) >= 1
        # 至少 good.py 被索引
        assert runs[0]["indexed"] >= 1

    def test_create_and_update_run(self, db):
        """create_index_run + update_index_run 往返"""
        run_id = db.create_index_run("test-col", 10)
        assert run_id is not None

        db.update_index_run(run_id, {
            "indexed": 7, "skipped": 1, "failed": 2,
            "failed_files": ["/a.py", "/b.py"],
            "interrupted": True,
        })

        run = db.get_last_interrupted_run("test-col")
        assert run is not None
        assert run["id"] == run_id
        assert run["status"] == "interrupted"
        assert run["indexed"] == 7
        assert run["failed"] == 2
        assert run["failed_paths"] == ["/a.py", "/b.py"]

    def test_resume_returns_none_when_no_interrupted(self, db):
        """无中断 run 时 resume 返回 None"""
        run_id = db.create_index_run("test-col", 5)
        db.update_index_run(run_id, {
            "indexed": 5, "skipped": 0, "failed": 0,
            "failed_files": [], "interrupted": False,
        })
        assert db.get_last_interrupted_run("test-col") is None

    def test_recent_runs_returns_all(self, db):
        """get_recent_runs 应返回所有记录"""
        for i in range(3):
            rid = db.create_index_run("test-col", i + 1)
            db.update_index_run(rid, {
                "indexed": i + 1, "skipped": 0, "failed": 0,
                "failed_files": [], "interrupted": False,
            })
        runs = db.get_recent_runs("test-col")
        assert len(runs) == 3
        totals = sorted([r["total_files"] for r in runs])
        assert totals == [1, 2, 3]
