"""MCP tools 测试 — status, reindex, config

补充 test_mcp_cog.py（recall/learn）和 test_mcp_read.py（read）
覆盖剩余 3 个工具的 happy path + 错误处理。
"""

from __future__ import annotations

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

import pytest

from index1.cognitive.db_cog import CogDatabase
from index1.config import Config
from index1.db import Database


# ── 通用 fixtures ──


@pytest.fixture()
def corpus_db(tmp_path: Path) -> Database:
    db = Database(str(tmp_path / "test_index.db"), embedding_dim=4)
    db.connect()
    yield db
    db.close()


@pytest.fixture()
def cog_db(tmp_path: Path) -> CogDatabase:
    db = CogDatabase(tmp_path / "test_cog.db", embedding_dim=4)
    db.connect()
    yield db
    db.close()


@pytest.fixture()
def app_ctx(corpus_db, cog_db, tmp_path) -> MagicMock:
    """模拟 AppContext — 含 corpus + cognition"""
    ctx = MagicMock()
    ctx.db = corpus_db
    ctx.db_cog = cog_db
    ctx.embedder = MagicMock()
    ctx.embedder.available = True
    ctx.embedder.model = "bge-small-en-v1.5"
    ctx.embedder.dim = 384
    ctx.embedder.unavailable_reason = None
    ctx.config = Config()
    ctx.config.embed_backend = "onnx"
    ctx.config.watch_paths = [str(tmp_path)]
    ctx.default_collection = "test-proj"
    return ctx


@pytest.fixture()
def mock_mcp_ctx(app_ctx) -> MagicMock:
    """模拟 MCP Context"""
    ctx = MagicMock()
    ctx.request_context.lifespan_context = app_ctx
    return ctx


# ═══════════════════════════════════════════
# status 工具
# ═══════════════════════════════════════════


class TestStatusTool:
    """MCP status 工具测试"""

    def test_status_basic(self, mock_mcp_ctx):
        """返回 corpus + embedding + cognition 统计"""
        from index1.mcp_server import status

        result = asyncio.run(status(ctx=mock_mcp_ctx))

        # corpus 统计
        assert "doc_count" in result
        assert "chunk_count" in result
        assert "collections" in result

        # embedding 状态
        assert "embedding" in result
        assert result["embedding"]["available"] is True
        assert result["embedding"]["backend"] == "onnx"
        assert result["embedding"]["model"] == "bge-small-en-v1.5"
        assert result["embedding"]["dim"] == 384

        # cognition 统计
        assert "cognition" in result
        assert result["cognition"]["enabled"] is True
        assert result["cognition"]["facts"] >= 0
        assert result["cognition"]["models"] >= 0
        assert result["cognition"]["tactics"] >= 0

    def test_status_no_cognition(self, mock_mcp_ctx, app_ctx):
        """db_cog=None 时 cognition 标记 disabled"""
        from index1.mcp_server import status

        app_ctx.db_cog = None
        result = asyncio.run(status(ctx=mock_mcp_ctx))

        assert result["cognition"]["enabled"] is False

    def test_status_embedding_unavailable(self, mock_mcp_ctx, app_ctx):
        """embedding 不可用时显示原因"""
        from index1.mcp_server import status

        app_ctx.embedder.available = False
        app_ctx.embedder.unavailable_reason = "fastembed not installed"
        result = asyncio.run(status(ctx=mock_mcp_ctx))

        assert result["embedding"]["available"] is False
        assert result["embedding"]["reason"] == "fastembed not installed"

    def test_status_observer_health(self, mock_mcp_ctx):
        """包含 observer 健康状态"""
        from index1.mcp_server import status

        result = asyncio.run(status(ctx=mock_mcp_ctx))
        assert "observer_health" in result

    def test_status_observer_health_no_cog(self, mock_mcp_ctx, app_ctx):
        """无 cognition 时 observer 状态为 unknown"""
        from index1.mcp_server import status

        app_ctx.db_cog = None
        result = asyncio.run(status(ctx=mock_mcp_ctx))
        assert result["observer_health"]["status"] == "unknown"

    def test_status_current_collection(self, mock_mcp_ctx, app_ctx):
        """返回当前 collection"""
        from index1.mcp_server import status

        app_ctx.config.collection = "my-project"
        result = asyncio.run(status(ctx=mock_mcp_ctx))
        assert result["current_collection"] == "my-project"

    def test_status_with_data(self, mock_mcp_ctx, corpus_db, cog_db):
        """有实际数据时统计正确"""
        from index1.mcp_server import status

        # 写入 corpus 数据
        corpus_db.conn.execute(
            "INSERT INTO documents (path, hash, collection, doc_type)"
            " VALUES (?, ?, ?, ?)",
            ("src/foo.py", "abc", "test-proj", "python"),
        )
        corpus_db.conn.execute(
            "INSERT INTO chunks (doc_id, content, section, parent_section,"
            " chunk_index, tags, token_count)"
            " VALUES (1, 'def foo(): pass', 'foo', '', 0, '', 5)",
        )
        corpus_db.conn.commit()

        # 写入 cognition 数据
        cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "test fact",
            "confidence": 0.9,
        })

        result = asyncio.run(status(ctx=mock_mcp_ctx))
        assert result["doc_count"] == 1
        assert result["chunk_count"] == 1
        assert result["cognition"]["facts"] >= 1


# ═══════════════════════════════════════════
# reindex 工具
# ═══════════════════════════════════════════


class TestReindexTool:
    """MCP reindex 工具测试"""

    @pytest.fixture(autouse=True)
    def _fake_home(self, tmp_path):
        """让 tmp_path 落在 'home' 范围内，避免路径校验拦截"""
        with patch("pathlib.Path.home", return_value=tmp_path):
            yield

    def test_reindex_with_path(self, mock_mcp_ctx, tmp_path):
        """指定路径 → 索引该路径"""
        from index1.mcp_server import reindex

        # 创建测试文件
        test_file = tmp_path / "test.py"
        test_file.write_text("def hello(): pass")

        with patch("index1.mcp_server.index_files", new_callable=AsyncMock) as mock_idx:
            mock_idx.return_value = {
                "indexed": 1, "skipped": 0, "failed": 0, "total": 1,
            }
            result = asyncio.run(reindex(
                ctx=mock_mcp_ctx,
                path=str(test_file),
            ))

        assert result["indexed"] == 1
        assert result["total"] == 1
        mock_idx.assert_called_once()

    def test_reindex_no_path_uses_watch_paths(self, mock_mcp_ctx, app_ctx, tmp_path):
        """未指定路径 → 使用 config.watch_paths"""
        from index1.mcp_server import reindex

        app_ctx.config.watch_paths = [str(tmp_path)]

        with patch("index1.mcp_server.index_files", new_callable=AsyncMock) as mock_idx:
            mock_idx.return_value = {
                "indexed": 0, "skipped": 0, "failed": 0, "total": 0,
            }
            result = asyncio.run(reindex(ctx=mock_mcp_ctx))

        mock_idx.assert_called_once()
        call_args = mock_idx.call_args
        assert str(tmp_path) in call_args[0][0][0]

    def test_reindex_no_paths_configured(self, mock_mcp_ctx, app_ctx):
        """无路径配置 → 报错"""
        from index1.mcp_server import reindex

        app_ctx.config.watch_paths = []
        result = asyncio.run(reindex(ctx=mock_mcp_ctx))
        assert "error" in result

    def test_reindex_with_collection(self, mock_mcp_ctx, tmp_path):
        """指定 collection → 传递给 index_files"""
        from index1.mcp_server import reindex

        with patch("index1.mcp_server.index_files", new_callable=AsyncMock) as mock_idx:
            mock_idx.return_value = {
                "indexed": 0, "skipped": 0, "failed": 0, "total": 0,
            }
            asyncio.run(reindex(
                ctx=mock_mcp_ctx,
                path=str(tmp_path),
                collection="custom-coll",
            ))

        call_kwargs = mock_idx.call_args
        assert call_kwargs[1]["collection"] == "custom-coll"

    def test_reindex_default_collection(self, mock_mcp_ctx, app_ctx, tmp_path):
        """未指定 collection → 使用 default_collection"""
        from index1.mcp_server import reindex

        app_ctx.default_collection = "inferred-proj"

        with patch("index1.mcp_server.index_files", new_callable=AsyncMock) as mock_idx:
            mock_idx.return_value = {
                "indexed": 0, "skipped": 0, "failed": 0, "total": 0,
            }
            asyncio.run(reindex(
                ctx=mock_mcp_ctx,
                path=str(tmp_path),
            ))

        call_kwargs = mock_idx.call_args
        assert call_kwargs[1]["collection"] == "inferred-proj"

    def test_reindex_force_true(self, mock_mcp_ctx, tmp_path):
        """reindex 始终传 force=True"""
        from index1.mcp_server import reindex

        with patch("index1.mcp_server.index_files", new_callable=AsyncMock) as mock_idx:
            mock_idx.return_value = {
                "indexed": 0, "skipped": 0, "failed": 0, "total": 0,
            }
            asyncio.run(reindex(
                ctx=mock_mcp_ctx,
                path=str(tmp_path),
            ))

        call_kwargs = mock_idx.call_args
        assert call_kwargs[1]["force"] is True

    def test_reindex_path_not_found(self, mock_mcp_ctx, tmp_path):
        """不存在的路径 → 报错"""
        from index1.mcp_server import reindex

        result = asyncio.run(reindex(
            ctx=mock_mcp_ctx,
            path=str(tmp_path / "nonexistent_xyz"),
        ))
        assert "error" in result
        assert "not found" in result["error"]

    def test_reindex_null_byte(self, mock_mcp_ctx):
        """null byte 路径 → 报错"""
        from index1.mcp_server import reindex

        result = asyncio.run(reindex(
            ctx=mock_mcp_ctx,
            path="/tmp/test\x00evil",
        ))
        assert "error" in result

    def test_reindex_outside_home(self, mock_mcp_ctx):
        """home 目录外的路径 → 报错"""
        from index1.mcp_server import reindex

        result = asyncio.run(reindex(
            ctx=mock_mcp_ctx,
            path="/etc/passwd",
        ))
        assert "error" in result
        assert "not allowed" in result["error"]

    def test_reindex_symlink_blocked(self, mock_mcp_ctx, tmp_path):
        """符号链接 → 报错"""
        from index1.mcp_server import reindex

        target = tmp_path / "real_dir"
        target.mkdir()
        link = tmp_path / "link_dir"
        link.symlink_to(target)

        result = asyncio.run(reindex(
            ctx=mock_mcp_ctx,
            path=str(link),
        ))
        assert "error" in result
        assert "symlink" in result["error"]


# ═══════════════════════════════════════════
# config 工具
# ═══════════════════════════════════════════


class TestConfigTool:
    """MCP config 工具测试"""

    def test_config_view_all(self, mock_mcp_ctx):
        """无参数 → 返回全部配置"""
        from index1.mcp_server import config

        result = asyncio.run(config(ctx=mock_mcp_ctx))
        # 应返回 Config dataclass 的所有字段
        assert "embed_backend" in result
        assert "top_k" in result

    def test_config_sensitive_masked(self, mock_mcp_ctx, app_ctx):
        """敏感字段应被遮蔽"""
        from index1.mcp_server import config

        app_ctx.config.anthropic_api_key = "sk-secret-12345"
        result = asyncio.run(config(ctx=mock_mcp_ctx))
        assert result.get("anthropic_api_key") == "***"

    def test_config_set_value(self, mock_mcp_ctx):
        """设置合法配置项"""
        from index1.mcp_server import config

        with patch("index1.mcp_server.save_config"):
            result = asyncio.run(config(
                ctx=mock_mcp_ctx,
                key="top_k",
                value="20",
            ))

        assert "已更新" in result["message"]
        assert "top_k" in result["message"]

    def test_config_set_invalid_key(self, mock_mcp_ctx):
        """设置不存在的配置项 → 报错"""
        from index1.mcp_server import config

        result = asyncio.run(config(
            ctx=mock_mcp_ctx,
            key="nonexistent_key_xyz",
            value="some_value",
        ))
        assert "error" in result
        assert "未知配置项" in result["error"]

    def test_config_ollama_reconnect_success(self, mock_mcp_ctx, app_ctx):
        """ollama_reconnect 成功"""
        from index1.mcp_server import config

        app_ctx.embedder.reset_cooldown = MagicMock()
        app_ctx.embedder.check_availability = AsyncMock(return_value=True)
        app_ctx.embedder.model = "bge-m3"

        result = asyncio.run(config(
            ctx=mock_mcp_ctx,
            key="ollama_reconnect",
        ))

        assert result["ollama_available"] is True
        assert "重连成功" in result["message"]
        app_ctx.embedder.reset_cooldown.assert_called_once()

    def test_config_ollama_reconnect_failure(self, mock_mcp_ctx, app_ctx):
        """ollama_reconnect 失败"""
        from index1.mcp_server import config

        app_ctx.embedder.reset_cooldown = MagicMock()
        app_ctx.embedder.check_availability = AsyncMock(return_value=False)
        app_ctx.embedder.unavailable_reason = "Connection refused"

        result = asyncio.run(config(
            ctx=mock_mcp_ctx,
            key="ollama_reconnect",
        ))

        assert result["ollama_available"] is False
        assert "重连失败" in result["message"]

    def test_config_ollama_reconnect_no_reset(self, mock_mcp_ctx, app_ctx):
        """ONNX embedder 没有 reset_cooldown → 跳过"""
        from index1.mcp_server import config

        # 删除 reset_cooldown 属性（模拟 ONNX embedder）
        del app_ctx.embedder.reset_cooldown
        app_ctx.embedder.check_availability = AsyncMock(return_value=True)

        result = asyncio.run(config(
            ctx=mock_mcp_ctx,
            key="ollama_reconnect",
        ))

        # 不应崩溃，应正常返回可用状态
        assert result["ollama_available"] is True

    def test_config_type_conversion_int(self, mock_mcp_ctx):
        """整数类型配置项正确转换"""
        from index1.mcp_server import config

        with patch("index1.mcp_server.save_config"):
            result = asyncio.run(config(
                ctx=mock_mcp_ctx,
                key="top_k",
                value="15",
            ))

        assert "已更新" in result["message"]

    def test_config_key_only_no_value(self, mock_mcp_ctx):
        """只传 key 不传 value → 返回全部配置（不是修改）"""
        from index1.mcp_server import config

        result = asyncio.run(config(
            ctx=mock_mcp_ctx,
            key="top_k",
        ))

        # key without value → 走查看全部配置逻辑
        assert "top_k" in result
