"""MCP read 工具测试 — 文件内容读取 + 索引元数据

覆盖：正常读取、分页、chunk 兜底、路径安全、多 watch_paths、
大文件截断、stale 检测、空文件、编码降级、symbols 排序。
"""

from __future__ import annotations

import asyncio
from pathlib import Path
from unittest.mock import MagicMock

import pytest

from index1.config import Config
from index1.db import Database


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


@pytest.fixture()
def watch_dir(tmp_path: Path) -> Path:
    """创建一个 watch 目录，内含测试文件"""
    d = tmp_path / "project"
    d.mkdir()
    (d / "src").mkdir()
    return d


@pytest.fixture()
def app_ctx(corpus_db: Database, watch_dir: Path) -> MagicMock:
    """模拟 AppContext"""
    ctx = MagicMock()
    ctx.db = corpus_db
    ctx.db_cog = None
    ctx.embedder = MagicMock()
    ctx.embedder.available = False
    ctx.config = Config(watch_paths=[str(watch_dir)])
    return ctx


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


def _run(coro):
    return asyncio.run(coro)


class TestReadBasic:
    """基础读取功能"""

    def test_read_indexed_file(self, mock_mcp_ctx, corpus_db, watch_dir):
        """正常读取：文件在索引中 + 磁盘存在"""
        from index1.mcp_server import read

        # 创建文件
        f = watch_dir / "src" / "hello.py"
        f.write_text("def hello():\n    return 'world'\n", encoding="utf-8")

        # 索引文件
        doc_id = corpus_db.upsert_document("src/hello.py", "hash1", "test", "python")
        corpus_db.insert_chunks(doc_id, [{
            "content": "def hello():\n    return 'world'",
            "chunk_index": 0,
        }])

        result = _run(read(file_path="src/hello.py", ctx=mock_mcp_ctx))
        assert "error" not in result
        assert result["path"] == "src/hello.py"
        assert "def hello():" in result["content"]
        assert result["source"] == "disk"
        assert result["total_lines"] == 3
        assert result["has_more"] is False

    def test_read_unindexed_file_in_watch_paths(self, mock_mcp_ctx, watch_dir):
        """未索引文件 + 在 watch_paths 内 → 降级读磁盘"""
        from index1.mcp_server import read

        f = watch_dir / "README.md"
        f.write_text("# Hello\n\nWorld\n", encoding="utf-8")

        result = _run(read(file_path="README.md", ctx=mock_mcp_ctx))
        assert "error" not in result
        assert "# Hello" in result["content"]
        assert result["source"] == "disk"
        # 无索引元数据
        assert "symbols" not in result
        assert "chunk_count" not in result

    def test_read_path_outside_watch_paths(self, mock_mcp_ctx, tmp_path):
        """路径穿越：不在 watch_paths 内 → 拒绝"""
        from index1.mcp_server import read

        # 创建一个 watch_paths 外的文件
        outside = tmp_path / "outside" / "secret.txt"
        outside.parent.mkdir(parents=True, exist_ok=True)
        outside.write_text("secret", encoding="utf-8")

        result = _run(read(file_path=str(outside), ctx=mock_mcp_ctx))
        assert "error" in result

    def test_read_nonexistent_file(self, mock_mcp_ctx):
        """文件不存在"""
        from index1.mcp_server import read

        result = _run(read(file_path="does/not/exist.py", ctx=mock_mcp_ctx))
        assert "error" in result

    def test_read_empty_file(self, mock_mcp_ctx, watch_dir):
        """空文件"""
        from index1.mcp_server import read

        f = watch_dir / "empty.txt"
        f.write_text("", encoding="utf-8")

        result = _run(read(file_path="empty.txt", ctx=mock_mcp_ctx))
        assert "error" not in result
        assert result["content"] == ""
        assert "hint" in result
        assert "empty" in result["hint"].lower()


class TestReadPagination:
    """分页功能"""

    def test_offset_and_limit(self, mock_mcp_ctx, watch_dir):
        """offset + limit 分页"""
        from index1.mcp_server import read

        lines = [f"line {i}" for i in range(1, 21)]
        f = watch_dir / "lines.txt"
        f.write_text("\n".join(lines), encoding="utf-8")

        result = _run(read(file_path="lines.txt", ctx=mock_mcp_ctx, offset=5, limit=3))
        assert "error" not in result
        assert result["returned_lines"] == [5, 7]
        assert result["total_lines"] == 20
        assert result["has_more"] is True
        assert "line 5" in result["content"]
        assert "line 7" in result["content"]
        assert "line 8" not in result["content"]

    def test_large_file_auto_truncate(self, mock_mcp_ctx, watch_dir):
        """大文件自动截断到 500 行"""
        from index1.mcp_server import read

        lines = [f"line {i}" for i in range(1, 802)]
        f = watch_dir / "big.txt"
        f.write_text("\n".join(lines), encoding="utf-8")

        result = _run(read(file_path="big.txt", ctx=mock_mcp_ctx))
        assert "error" not in result
        assert result["total_lines"] == 801
        assert result["has_more"] is True
        assert result["returned_lines"] == [1, 500]
        assert "hint" in result
        assert "500" in result["hint"]

    def test_offset_only(self, mock_mcp_ctx, watch_dir):
        """只指定 offset，不指定 limit → 读到末尾"""
        from index1.mcp_server import read

        lines = [f"line {i}" for i in range(1, 11)]
        f = watch_dir / "ten.txt"
        f.write_text("\n".join(lines), encoding="utf-8")

        result = _run(read(file_path="ten.txt", ctx=mock_mcp_ctx, offset=8))
        assert "error" not in result
        assert result["returned_lines"] == [8, 10]
        assert result["has_more"] is False


class TestReadChunkFallback:
    """chunk 拼接兜底（文件已从磁盘删除）"""

    def test_chunks_rebuild(self, mock_mcp_ctx, corpus_db, watch_dir):
        """磁盘已删除 → 从 chunks 重建"""
        from index1.mcp_server import read

        # 索引文件但不在磁盘上
        doc_id = corpus_db.upsert_document("src/deleted.py", "hash1", "test", "python")
        corpus_db.insert_chunks(doc_id, [
            {"content": "# part 1", "chunk_index": 0},
            {"content": "# part 2", "chunk_index": 1},
        ])

        result = _run(read(file_path="src/deleted.py", ctx=mock_mcp_ctx))
        assert "error" not in result
        assert result["source"] == "chunks"
        assert "# part 1" in result["content"]
        assert "# part 2" in result["content"]
        assert "hint" in result
        assert "overlap" in result["hint"].lower()


class TestReadEncoding:
    """编码处理"""

    def test_latin1_fallback(self, mock_mcp_ctx, watch_dir):
        """UTF-8 失败 → Latin-1 降级"""
        from index1.mcp_server import read

        f = watch_dir / "latin.txt"
        f.write_bytes(b"caf\xe9 cr\xe8me")  # Latin-1 编码

        result = _run(read(file_path="latin.txt", ctx=mock_mcp_ctx))
        assert "error" not in result
        assert result["encoding"] == "latin-1"
        assert "café" in result["content"]


class TestReadSymbols:
    """symbols 排序"""

    def test_symbols_sorted_by_pagerank(self, mock_mcp_ctx, corpus_db, watch_dir):
        """symbols 按 PageRank 降序排列"""
        from index1.mcp_server import read

        f = watch_dir / "src" / "mod.py"
        f.write_text("class Foo:\n    pass\ndef bar():\n    pass\ndef _helper():\n    pass\n")

        doc_id = corpus_db.upsert_document("src/mod.py", "hash1", "test", "python")
        corpus_db.insert_chunks(doc_id, [{"content": "class Foo...", "chunk_index": 0}])
        corpus_db.insert_symbols(doc_id, [
            {"name": "_helper", "kind": "function", "line_start": 5, "signature": "def _helper()"},
            {"name": "Foo", "kind": "class", "line_start": 1, "signature": "class Foo"},
            {"name": "bar", "kind": "function", "line_start": 3, "signature": "def bar()"},
        ])
        # 设置 PageRank: bar > Foo > _helper
        corpus_db.conn.execute("UPDATE symbols SET pagerank = 0.8 WHERE name = 'bar' AND doc_id = ?", (doc_id,))
        corpus_db.conn.execute("UPDATE symbols SET pagerank = 0.5 WHERE name = 'Foo' AND doc_id = ?", (doc_id,))
        corpus_db.conn.execute("UPDATE symbols SET pagerank = 0.1 WHERE name = '_helper' AND doc_id = ?", (doc_id,))
        corpus_db.conn.commit()

        result = _run(read(file_path="src/mod.py", ctx=mock_mcp_ctx))
        assert "symbols" in result
        names = [s["name"] for s in result["symbols"]]
        assert names == ["bar", "Foo", "_helper"]

    def test_symbols_kind_priority_when_no_pagerank(self, mock_mcp_ctx, corpus_db, watch_dir):
        """PageRank 全为 0 → 按 kind 优先级排序（class > function > method）"""
        from index1.mcp_server import read

        f = watch_dir / "src" / "kinds.py"
        f.write_text("x = 1\ndef f(): pass\nclass C: pass\n")

        doc_id = corpus_db.upsert_document("src/kinds.py", "hash1", "test", "python")
        corpus_db.insert_chunks(doc_id, [{"content": "...", "chunk_index": 0}])
        corpus_db.insert_symbols(doc_id, [
            {"name": "x", "kind": "variable", "line_start": 1},
            {"name": "f", "kind": "function", "line_start": 2},
            {"name": "C", "kind": "class", "line_start": 3},
        ])

        result = _run(read(file_path="src/kinds.py", ctx=mock_mcp_ctx))
        assert "symbols" in result
        names = [s["name"] for s in result["symbols"]]
        assert names == ["C", "f", "x"]

    def test_symbols_include_signature(self, mock_mcp_ctx, corpus_db, watch_dir):
        """symbols 包含 signature"""
        from index1.mcp_server import read

        f = watch_dir / "src" / "sig.py"
        f.write_text("def greet(name: str) -> str:\n    return f'hi {name}'\n")

        doc_id = corpus_db.upsert_document("src/sig.py", "hash1", "test", "python")
        corpus_db.insert_chunks(doc_id, [{"content": "...", "chunk_index": 0}])
        corpus_db.insert_symbols(doc_id, [
            {"name": "greet", "kind": "function", "line_start": 1,
             "signature": "def greet(name: str) -> str"},
        ])

        result = _run(read(file_path="src/sig.py", ctx=mock_mcp_ctx))
        sym = result["symbols"][0]
        assert sym["name"] == "greet"
        assert sym["signature"] == "def greet(name: str) -> str"
        assert sym["line"] == 1


class TestReadStale:
    """stale 检测"""

    def test_stale_detection(self, mock_mcp_ctx, corpus_db, watch_dir):
        """文件修改时间 > indexed_at → stale"""
        from index1.mcp_server import read
        import time

        f = watch_dir / "src" / "stale.py"
        f.write_text("v1")

        # 索引（indexed_at = 过去时间）
        doc_id = corpus_db.upsert_document("src/stale.py", "hash1", "test", "python")
        corpus_db.insert_chunks(doc_id, [{"content": "v1", "chunk_index": 0}])
        # 手动设置 indexed_at 到过去
        corpus_db.conn.execute(
            "UPDATE documents SET indexed_at = '2020-01-01T00:00:00' WHERE id = ?",
            (doc_id,),
        )
        corpus_db.conn.commit()

        # 修改文件（mtime > indexed_at）
        time.sleep(0.01)
        f.write_text("v2")

        result = _run(read(file_path="src/stale.py", ctx=mock_mcp_ctx))
        assert result.get("stale") is True


class TestReadFindDocument:
    """find_document 三级匹配"""

    def test_exact_match(self, corpus_db):
        """精确匹配"""
        corpus_db.upsert_document("src/index1/db.py", "h1", "test", "python")
        result = corpus_db.find_document("src/index1/db.py")
        assert result is not None
        assert result["path"] == "src/index1/db.py"

    def test_suffix_match(self, corpus_db):
        """后缀匹配"""
        corpus_db.upsert_document("src/index1/db.py", "h1", "test", "python")
        result = corpus_db.find_document("index1/db.py")
        assert result is not None
        assert result["path"] == "src/index1/db.py"

    def test_basename_match_unique(self, corpus_db):
        """basename 匹配（唯一）"""
        corpus_db.upsert_document("src/index1/unique_name.py", "h1", "test", "python")
        result = corpus_db.find_document("unique_name.py")
        assert result is not None
        assert result["path"] == "src/index1/unique_name.py"

    def test_basename_match_ambiguous(self, corpus_db):
        """basename 匹配（歧义 → 返回 None）"""
        corpus_db.upsert_document("src/a/config.py", "h1", "test", "python")
        corpus_db.upsert_document("src/b/config.py", "h2", "test", "python")
        result = corpus_db.find_document("config.py")
        assert result is None

    def test_collection_filter(self, corpus_db):
        """collection 过滤"""
        corpus_db.upsert_document("src/db.py", "h1", "proj-a", "python")
        corpus_db.upsert_document("src/db.py", "h2", "proj-b", "python")  # same path, diff collection — will overwrite
        # 用不同路径测试
        corpus_db.upsert_document("lib/db.py", "h3", "proj-b", "python")

        result = corpus_db.find_document("lib/db.py", collection="proj-b")
        assert result is not None
        assert result["collection"] == "proj-b"

        result = corpus_db.find_document("lib/db.py", collection="proj-a")
        assert result is None


class TestReadMultiWatchPaths:
    """多 watch_paths 解析"""

    def test_resolve_across_watch_paths(self, mock_mcp_ctx, corpus_db, tmp_path):
        """文件在第二个 watch_path 中"""
        from index1.mcp_server import read

        # 创建两个 watch 目录
        dir1 = tmp_path / "src"
        dir2 = tmp_path / "docs"
        dir1.mkdir()
        dir2.mkdir()

        # 文件在 docs/ 下
        (dir2 / "api.md").write_text("# API\n\nEndpoints...\n")

        # 索引
        doc_id = corpus_db.upsert_document("api.md", "h1", "test", "markdown")
        corpus_db.insert_chunks(doc_id, [{"content": "# API", "chunk_index": 0}])

        # 配置多 watch_paths
        app = mock_mcp_ctx.request_context.lifespan_context
        app.config = Config(watch_paths=[str(dir1), str(dir2)])

        result = _run(read(file_path="api.md", ctx=mock_mcp_ctx))
        assert "error" not in result
        assert "# API" in result["content"]
        assert result["source"] == "disk"
