"""Tests for ONNX embedding backend (Issue #94)

覆盖：Embedder Protocol、FastEmbedEmbedder、create_embedder 工厂、
Config 扩展、维度迁移、类型标注。
"""

from __future__ import annotations

import sqlite3
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from index1.config import Config
from index1.embed import Embedder, OllamaEmbedder, create_embedder


# ── Embedder Protocol ──


class TestEmbedderProtocol:
    """Protocol 接口检查"""

    def test_ollama_embedder_is_embedder(self):
        """OllamaEmbedder 满足 Embedder Protocol"""
        embedder = OllamaEmbedder("test", "http://fake:11434", dim=4)
        assert isinstance(embedder, Embedder)

    def test_protocol_has_required_methods(self):
        """Protocol 定义了所有必需方法"""
        # 检查 Protocol 有这些属性
        assert hasattr(Embedder, "embed_one")
        assert hasattr(Embedder, "embed_batch")
        assert hasattr(Embedder, "check_availability")
        assert hasattr(Embedder, "close")
        assert hasattr(Embedder, "available")
        assert hasattr(Embedder, "unavailable_reason")
        assert hasattr(Embedder, "model")
        assert hasattr(Embedder, "dim")


# ── FastEmbedEmbedder ──


class TestFastEmbedEmbedder:
    """FastEmbedEmbedder 基本行为（mock fastembed）"""

    def test_init_default_model(self):
        """默认参数"""
        from index1.embed_onnx import FastEmbedEmbedder
        e = FastEmbedEmbedder()
        assert e.model == "BAAI/bge-small-en-v1.5"
        assert e.dim == 384
        assert e.available is False  # 未初始化前

    def test_init_custom_model(self):
        """自定义模型"""
        from index1.embed_onnx import FastEmbedEmbedder
        e = FastEmbedEmbedder(model="custom/model", dim=512)
        assert e.model == "custom/model"
        assert e.dim == 512

    @pytest.mark.asyncio
    async def test_embed_one_with_mock(self):
        """embed_one 通过 mock 测试"""
        from index1.embed_onnx import FastEmbedEmbedder

        # 使用简单的 mock 对象模拟 numpy array（有 tolist 方法）
        class FakeArray:
            def __init__(self, data):
                self._data = data
            def tolist(self):
                return self._data

        e = FastEmbedEmbedder(dim=4)
        mock_encoder = MagicMock()
        mock_encoder.embed.return_value = [FakeArray([0.1, 0.2, 0.3, 0.4])]
        e._encoder = mock_encoder
        e._available = True

        result = await e.embed_one("hello")
        assert len(result) == 4
        assert isinstance(result, list)
        mock_encoder.embed.assert_called_once_with(["hello"])

    @pytest.mark.asyncio
    async def test_embed_batch_with_mock(self):
        """embed_batch 通过 mock 测试"""
        from index1.embed_onnx import FastEmbedEmbedder

        class FakeArray:
            def __init__(self, data):
                self._data = data
            def tolist(self):
                return self._data

        e = FastEmbedEmbedder(dim=3)
        mock_encoder = MagicMock()
        mock_encoder.embed.return_value = [
            FakeArray([0.1, 0.2, 0.3]),
            FakeArray([0.4, 0.5, 0.6]),
        ]
        e._encoder = mock_encoder
        e._available = True

        results = await e.embed_batch(["hello", "world"], batch_size=32)
        assert len(results) == 2
        assert len(results[0]) == 3

    @pytest.mark.asyncio
    async def test_embed_batch_empty(self):
        """空列表返回空"""
        from index1.embed_onnx import FastEmbedEmbedder
        e = FastEmbedEmbedder()
        result = await e.embed_batch([])
        assert result == []

    @pytest.mark.asyncio
    async def test_check_availability_import_error(self):
        """fastembed 未安装时 check_availability 返回 False"""
        from index1.embed_onnx import FastEmbedEmbedder
        e = FastEmbedEmbedder()
        with patch.dict("sys.modules", {"fastembed": None}):
            # _ensure_encoder will raise ImportError
            e._encoder = None
            e._available = None
            result = await e.check_availability()
            assert result is False
            assert "fastembed" in e.unavailable_reason.lower() or not result

    @pytest.mark.asyncio
    async def test_close(self):
        """close 释放 encoder"""
        from index1.embed_onnx import FastEmbedEmbedder
        e = FastEmbedEmbedder()
        e._encoder = MagicMock()
        await e.close()
        assert e._encoder is None

    def test_satisfies_embedder_protocol(self):
        """FastEmbedEmbedder 满足 Embedder Protocol"""
        from index1.embed_onnx import FastEmbedEmbedder
        e = FastEmbedEmbedder()
        assert isinstance(e, Embedder)


# ── create_embedder 工厂函数 ──


class TestCreateEmbedder:
    """工厂函数测试"""

    def test_default_creates_onnx(self):
        """默认 embed_backend=onnx 时创建 FastEmbedEmbedder"""
        config = Config()
        assert config.embed_backend == "onnx"

        with patch("index1.embed_onnx.FastEmbedEmbedder") as MockFE:
            MockFE.return_value = MagicMock()
            e = create_embedder(config)
            MockFE.assert_called_once_with(
                model=config.onnx_model,
                dim=config.onnx_dim,
            )

    def test_ollama_backend(self):
        """embed_backend=ollama 时创建 OllamaEmbedder"""
        config = Config(embed_backend="ollama")
        e = create_embedder(config)
        assert isinstance(e, OllamaEmbedder)
        assert e.model == config.embedding_model
        assert e.dim == config.embedding_dim

    def test_onnx_import_error_fallback(self):
        """fastembed 未安装时降级到 Ollama"""
        import sys

        config = Config(embed_backend="onnx")
        # 临时使 embed_onnx 模块不可导入
        saved = sys.modules.get("index1.embed_onnx")
        sys.modules["index1.embed_onnx"] = None  # type: ignore
        try:
            e = create_embedder(config)
            # 应降级到 OllamaEmbedder
            assert isinstance(e, OllamaEmbedder)
        finally:
            if saved is not None:
                sys.modules["index1.embed_onnx"] = saved
            else:
                sys.modules.pop("index1.embed_onnx", None)


# ── Config 扩展 ──


class TestConfigExtension:
    """Config 新增字段测试"""

    def test_default_embed_backend(self):
        config = Config()
        assert config.embed_backend == "onnx"

    def test_default_onnx_model(self):
        config = Config()
        assert config.onnx_model == "BAAI/bge-small-en-v1.5"

    def test_default_onnx_dim(self):
        config = Config()
        assert config.onnx_dim == 384

    def test_effective_dim_onnx(self):
        """ONNX 后端使用 onnx_dim"""
        config = Config(embed_backend="onnx", onnx_dim=384, embedding_dim=768)
        assert config.effective_embedding_dim == 384

    def test_effective_dim_ollama(self):
        """Ollama 后端使用 embedding_dim"""
        config = Config(embed_backend="ollama", onnx_dim=384, embedding_dim=768)
        assert config.effective_embedding_dim == 768

    def test_invalid_backend_raises(self):
        """无效 embed_backend 报错"""
        with pytest.raises(ValueError, match="embed_backend"):
            Config(embed_backend="invalid")

    def test_config_merge_embed_backend(self):
        """三层配置合并支持 embed_backend"""
        from index1.config import _merge_into_config
        config = Config()
        _merge_into_config(config, {"embed_backend": "ollama"})
        assert config.embed_backend == "ollama"


# ── 维度迁移 ──


class TestDimensionMigration:
    """vec0 表维度迁移测试"""

    def test_db_vec_dim_migration_empty(self, tmp_path):
        """chunks_vec 空表时维度变更安全重建"""
        import sqlite_vec
        from index1.db import Database

        db_path = tmp_path / "test.db"

        # 第一次：创建 768d 的 vec0
        db = Database(db_path, embedding_dim=768)
        db.connect()
        row = db.conn.execute(
            "SELECT sql FROM sqlite_master WHERE name='chunks_vec'"
        ).fetchone()
        assert "768" in row[0]
        db.close()

        # 第二次：用 384d 打开，应该自动迁移
        db2 = Database(db_path, embedding_dim=384)
        db2.connect()
        row = db2.conn.execute(
            "SELECT sql FROM sqlite_master WHERE name='chunks_vec'"
        ).fetchone()
        assert "384" in row[0]
        db2.close()

    def test_db_vec_same_dim_no_migration(self, tmp_path):
        """维度相同时不重建"""
        from index1.db import Database

        db_path = tmp_path / "test.db"
        db = Database(db_path, embedding_dim=768)
        db.connect()
        db.close()

        # 再次打开，维度相同
        db2 = Database(db_path, embedding_dim=768)
        db2.connect()
        row = db2.conn.execute(
            "SELECT sql FROM sqlite_master WHERE name='chunks_vec'"
        ).fetchone()
        assert "768" in row[0]
        db2.close()

    def test_db_vec_dim_migration_refuses_with_data(self, tmp_path):
        """chunks_vec 有数据时拒绝破坏性迁移（#254 三层防御 L1）"""
        import struct
        from index1.db import Database

        db_path = tmp_path / "test.db"

        # 创建 768d 表并插入数据
        db = Database(db_path, embedding_dim=768)
        db.connect()
        if not db._vec_loaded:
            db.close()
            pytest.skip("sqlite-vec not available")

        # 插入一个 document + chunk + 对应向量
        db.conn.execute(
            "INSERT INTO documents (path, hash, collection, doc_type) VALUES (?, ?, ?, ?)",
            ("test.py", "abc123", "test", "python"),
        )
        doc_id = db.conn.execute("SELECT last_insert_rowid()").fetchone()[0]
        db.conn.execute(
            "INSERT INTO chunks (doc_id, content, chunk_index) VALUES (?, ?, ?)",
            (doc_id, "test content", 0),
        )
        chunk_rowid = db.conn.execute("SELECT last_insert_rowid()").fetchone()[0]
        vec_blob = struct.pack("768f", *([0.1] * 768))
        db.conn.execute(
            "INSERT INTO chunks_vec (rowid, embedding) VALUES (?, ?)",
            (chunk_rowid, vec_blob),
        )
        db.conn.commit()

        count = db.conn.execute("SELECT COUNT(*) FROM chunks_vec").fetchone()[0]
        assert count == 1
        db.close()

        # 用不同维度打开 — 应拒绝迁移并适配已有维度
        db2 = Database(db_path, embedding_dim=384)
        db2.connect()

        # L1 保护：数据未丢失
        count = db2.conn.execute("SELECT COUNT(*) FROM chunks_vec").fetchone()[0]
        assert count == 1, "L1 应保护数据不被删除"

        # 维度已适配为已有值
        assert db2._embedding_dim == 768, "应适配为 DB 中已有的维度"

        # 表结构未变
        row = db2.conn.execute(
            "SELECT sql FROM sqlite_master WHERE name='chunks_vec'"
        ).fetchone()
        assert "768" in row[0], "表结构应保持不变"
        db2.close()

    def test_db_embedding_dim_validation(self, tmp_path):
        """Database L1 校验：非法 embedding_dim 回退到 384"""
        from index1.db import Database

        db = Database(tmp_path / "test.db", embedding_dim=-1)
        assert db._embedding_dim == 384

        db2 = Database(tmp_path / "test2.db", embedding_dim=0)
        assert db2._embedding_dim == 384

    def test_cogdb_vec_dim_migration_empty(self, tmp_path):
        """facts_vec 空表时维度变更安全重建"""
        from index1.cognitive.db_cog import CogDatabase

        db_path = tmp_path / "cog.db"

        # 第一次：768d
        db = CogDatabase(db_path, embedding_dim=768)
        db.connect()
        row = db.conn.execute(
            "SELECT sql FROM sqlite_master WHERE name='facts_vec'"
        ).fetchone()
        if row:  # vec extension may not load in CI
            assert "768" in row[0]
        db.close()

        # 第二次：384d — 空表应安全重建
        db2 = CogDatabase(db_path, embedding_dim=384)
        db2.connect()
        row = db2.conn.execute(
            "SELECT sql FROM sqlite_master WHERE name='facts_vec'"
        ).fetchone()
        if row:
            assert "384" in row[0]
        db2.close()

    def test_cogdb_vec_dim_migration_refuses_with_data(self, tmp_path):
        """facts_vec 有数据时拒绝破坏性迁移（#254 三层防御 L1）"""
        from index1.cognitive.db_cog import CogDatabase
        import struct

        db_path = tmp_path / "cog.db"

        # 创建 768d 表并插入数据
        db = CogDatabase(db_path, embedding_dim=768)
        db.connect()
        if not db._vec_loaded:
            db.close()
            pytest.skip("sqlite-vec not available")

        # 插入一个 fact + 对应向量
        db.conn.execute(
            "INSERT INTO facts (assertion, category, confidence, source, collection) "
            "VALUES (?, ?, ?, ?, ?)",
            ("test assertion", "insight", 0.8, "test", "test"),
        )
        vec_blob = struct.pack("768f", *([0.1] * 768))
        db.conn.execute(
            "INSERT INTO facts_vec (rowid, embedding) VALUES (1, ?)", (vec_blob,)
        )
        db.conn.commit()

        # 确认数据存在
        count = db.conn.execute("SELECT COUNT(*) FROM facts_vec").fetchone()[0]
        assert count == 1
        db.close()

        # 用不同维度打开 — 应拒绝迁移并适配已有维度
        db2 = CogDatabase(db_path, embedding_dim=384)
        db2.connect()

        # L1 保护：数据未丢失
        count = db2.conn.execute("SELECT COUNT(*) FROM facts_vec").fetchone()[0]
        assert count == 1, "L1 应保护数据不被删除"

        # 维度已适配为已有值
        assert db2._embedding_dim == 768, "应适配为 DB 中已有的维度"

        # 表结构未变
        row = db2.conn.execute(
            "SELECT sql FROM sqlite_master WHERE name='facts_vec'"
        ).fetchone()
        assert "768" in row[0], "表结构应保持不变"
        db2.close()

    def test_cogdb_embedding_dim_validation(self, tmp_path):
        """CogDatabase L1 校验：非法 embedding_dim 回退到 384"""
        from index1.cognitive.db_cog import CogDatabase

        # 非法值回退到 384
        db = CogDatabase(tmp_path / "cog.db", embedding_dim=-1)
        assert db._embedding_dim == 384

        db2 = CogDatabase(tmp_path / "cog2.db", embedding_dim=0)
        assert db2._embedding_dim == 384


# ── 类型标注验证 ──


class TestTypeAnnotations:
    """确认所有模块使用 Embedder 类型"""

    def test_search_import(self):
        from index1.search import Embedder as SearchEmbedder
        assert SearchEmbedder is Embedder

    def test_indexer_import(self):
        from index1.indexer import Embedder as IndexerEmbedder
        assert IndexerEmbedder is Embedder

    def test_search_cog_import(self):
        from index1.cognitive.search_cog import Embedder as CogEmbedder
        assert CogEmbedder is Embedder

    def test_maintenance_import(self):
        from index1.cognitive.maintenance import Embedder as MaintEmbedder
        assert MaintEmbedder is Embedder

    def test_package_exports(self):
        """__init__.py 导出 Embedder 和 create_embedder"""
        import index1
        assert hasattr(index1, "Embedder")
        assert hasattr(index1, "create_embedder")
        assert hasattr(index1, "OllamaEmbedder")


# ── orient 混合搜索 ──


class TestOrientHybrid:
    """orient Hook 混合搜索测试"""

    def test_can_orient_hybrid_onnx(self):
        """ONNX 后端启用混合搜索"""
        from index1.cognitive.db_cog import CogDatabase
        from index1.cognitive.hooks import HookHandler

        config = Config(embed_backend="onnx")
        db_cog = MagicMock(spec=CogDatabase)
        handler = HookHandler(db_cog, config)
        assert handler._can_orient_hybrid() is True

    def test_can_orient_hybrid_ollama(self):
        """Ollama 后端不启用混合搜索"""
        from index1.cognitive.db_cog import CogDatabase
        from index1.cognitive.hooks import HookHandler

        config = Config(embed_backend="ollama")
        db_cog = MagicMock(spec=CogDatabase)
        handler = HookHandler(db_cog, config)
        assert handler._can_orient_hybrid() is False

    def test_can_orient_hybrid_no_config(self):
        """无 config 时不启用"""
        from index1.cognitive.db_cog import CogDatabase
        from index1.cognitive.hooks import HookHandler

        db_cog = MagicMock(spec=CogDatabase)
        handler = HookHandler(db_cog, config=None)
        assert handler._can_orient_hybrid() is False
