"""MCP Server — 6 tools: recall, learn, read, status, reindex, config

recall:  统一搜索 — corpus + cognition 一次检索
learn:   记录洞察、决策、教训
read:    读取文件内容 + 索引元数据（上下文恢复）
status:  系统状态 — 索引 + 认知 + embedding 统计
reindex: 重建索引
config:  查看/修改配置
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

from mcp.server.fastmcp import Context, FastMCP

from .config import Config, load_config, save_config
from .db import Database
from .embed import Embedder, create_embedder
from .indexer import index_files
from .resilience import mcp_error_boundary

# config tool 输出中需要遮蔽的敏感字段
_SENSITIVE_KEYS = frozenset({"anthropic_api_key"})


@dataclass
class AppContext:
    """MCP Server 共享上下文"""

    config: Config
    db: Database
    embedder: Embedder
    db_cog: object | None = None  # CogDatabase | None
    default_collection: str = "default"  # lifespan 阶段预推断（Issue #136）


@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
    """管理服务器生命周期：启动时连接数据库 + 检查 Ollama，关闭时清理"""
    import logging

    logger = logging.getLogger(__name__)

    config = load_config()
    db = Database(config.db_path_resolved, config.effective_embedding_dim)
    db.connect()
    embedder = create_embedder(config)

    # 启动时检查 embedding 后端可用性
    try:
        embed_ok = await embedder.check_availability()
        if embed_ok:
            logger.info(
                "index1 启动: %s 后端可用，模型 %s 已就绪 (%dd)",
                config.embed_backend.upper(), embedder.model, embedder.dim,
            )
            # 维度一致性检查
            expected_dim = config.effective_embedding_dim
            if embedder.dim != expected_dim:
                logger.error(
                    "Embedding 维度不一致: config=%d, embedder=%d。"
                    "请检查 onnx_dim / embedding_dim 配置",
                    expected_dim, embedder.dim,
                )
        else:
            logger.warning(
                "index1 启动: embedding 不可用 — %s。向量搜索已禁用，仅 BM25 关键词搜索可用",
                embedder.unavailable_reason,
            )
    except Exception as e:
        logger.warning("index1 启动: embedding 检测异常 — %s。继续以 BM25 模式运行", e)

    # 认知数据库（可选，失败不影响 corpus 功能）
    db_cog = None
    if config.cognitive_enabled:
        try:
            from .cognitive.db_cog import CogDatabase
            db_cog = CogDatabase(config.cognitive_db_path_resolved, config.effective_embedding_dim)
            db_cog.connect()
            logger.info("index1 启动: 认知模块已启用 (%s)", config.cognitive_db_path)
        except Exception as e:
            logger.warning("index1 启动: 认知模块加载失败 — %s。认知功能不可用", e)
            db_cog = None

    # 启动时推断 collection，存入 AppContext（Issue #136）
    from .collection import detect_collection
    default_collection = detect_collection(config=config)
    logger.info("index1 启动: 默认 collection = %s", default_collection)

    try:
        yield AppContext(
            config=config, db=db, embedder=embedder,
            db_cog=db_cog, default_collection=default_collection,
        )
    finally:
        if db_cog:
            db_cog.close()
        await embedder.close()
        db.close()


mcp = FastMCP("index1", lifespan=app_lifespan)


def _get_ctx(ctx: Context) -> AppContext:
    """从 MCP Context 获取应用上下文"""
    return ctx.request_context.lifespan_context


def _require_cog(ctx: Context):
    """获取 CogDatabase，不可用时返回 None"""
    app = _get_ctx(ctx)
    return app.db_cog


# ── Tool 1: recall — 统一搜索 ──


@mcp.tool()
@mcp_error_boundary
async def recall(
    query: str,
    ctx: Context,
    collection: str | None = None,
    top_k: int = 10,
    session_id: str | None = None,
) -> dict:
    """搜索项目记忆 — 代码 + 经验一次检索

    自动跨库搜索(corpus 代码索引 + cognition 认知事实),
    服务端决定搜索策略,AI 无需关心数据在哪。

    返回按类型分组:code(代码片段)、facts(经验事实)、
    tactics(策略)、model(心智模型)。

    分数语义:
    - score: 查询相关性(RRF 融合 + MaxScore 归一化,[0,1],1.0=最相关)
    - confidence(仅 facts): 事实质量(静态属性,与查询无关)
    - created_at/session_id(仅 facts): 可选,早期数据可能为 null

    Args:
        query: 搜索查询文本
        collection: 限定搜索范围(可选)
        top_k: 返回结果数量,默认 10
        session_id: 会话 ID(可选,用于会话上下文增强)
    """
    from .unified_search import unified_recall

    app = _get_ctx(ctx)
    effective_collection = collection or app.default_collection
    result = await unified_recall(
        query, app.config, app.db, app.embedder,
        db_cog=app.db_cog,
        collection=effective_collection, top_k=top_k,
        session_id=session_id,
    )

    output = {
        "code": [
            {
                "id": item.id,
                "score": item.score,
                "source": item.source,
                "section": item.section,
                "content": item.content,
                "symbols": item.symbols,
            }
            for item in result.code
        ],
        "facts": [
            {
                "id": item.id,
                "score": item.score,
                "assertion": item.assertion,
                "category": item.category,
                "confidence": item.confidence,
                "created_at": item.created_at,
                "session_id": item.session_id,
            }
            for item in result.facts
        ],
        "tactics": [
            {
                "id": item.id,
                "trigger_pattern": item.trigger_pattern,
                "steps": item.steps,
                "success_rate": item.success_rate,
            }
            for item in result.tactics
        ],
        "model": result.model,
        "mode": result.mode,
        "hint": result.hint,
        "query_ms": result.query_ms,
    }

    # P3-4: 结果层级分组 — 同文件多 chunk 聚合
    if getattr(app.config, "result_grouping_enabled", False) and output["code"]:
        output["grouped_code"] = _group_code_by_file(output["code"])

    # Phase 3c 可观测性
    if result.debug:
        output["debug"] = result.debug

    # P3-5: 自动记录已返回的 IDs（session 去重用，宪法第 3 条合规）
    if (session_id and app.db_cog is not None
            and getattr(app.config, "returned_demotion_enabled", False)):
        try:
            returned_fact_ids = [item.id for item in result.facts]
            returned_code_ids = [item.id for item in result.code]
            if returned_fact_ids or returned_code_ids:
                app.db_cog.insert_raw_event(
                    session_id=session_id,
                    hook_event="MCP_Recall",
                    collection=effective_collection,
                    payload={
                        "returned_fact_ids": returned_fact_ids,
                        "returned_code_ids": returned_code_ids,
                    },
                    tool_name="mcp_recall",
                )
        except Exception as e:
            logger.debug("P3-5 returned IDs 记录失败: %s", e)

    # embedding 不可用时提示
    if not app.embedder.available:
        reason = app.embedder.unavailable_reason
        output["embed_status"] = "unavailable"
        if "维度不匹配" in reason or "维度不一致" in reason or "dimension mismatch" in reason.lower():
            output["embed_hint"] = (
                "Embedding 维度配置错误，向量搜索已禁用。"
                "请修改 config.yaml 中的 onnx_dim 或 embedding_dim，然后重启。"
            )
        else:
            output["embed_hint"] = "当前仅 BM25 关键词搜索可用。向量搜索功能已禁用。"

    return output


def _group_code_by_file(code_items: list[dict]) -> list[dict]:
    """P3-4: 按文件聚合 code 结果，减少碎片化

    同文件多 chunk → 合并为一组，保留 top_score + items 列表。
    返回按 top_score 降序排列的分组列表。
    """
    from collections import OrderedDict

    groups: OrderedDict[str, dict] = OrderedDict()
    for item in code_items:
        source = item.get("source", "")
        if source not in groups:
            groups[source] = {
                "source": source,
                "top_score": item.get("score", 0),
                "items": [],
                "total_items": 0,
            }
        g = groups[source]
        g["items"].append(item)
        g["total_items"] += 1
        if item.get("score", 0) > g["top_score"]:
            g["top_score"] = item["score"]

    result = sorted(groups.values(), key=lambda g: -g["top_score"])
    return result


# ── Tool 2: learn — 统一写入 ──


@mcp.tool()
@mcp_error_boundary
async def learn(
    insight: str,
    ctx: Context,
    context: str = "",
    collection: str | None = None,
    session_id: str | None = None,
) -> dict:
    """Record an insight, decision, or lesson learned (简化写入接口)

    2 参数替代 cog_save_fact 的 7 参数。Category, confidence, scope 全部自动推断。
    写入前自动去重（精确 + Jaccard + 可选向量余弦）。

    Args:
        insight: 一句话描述学到了什么（如 "SQLite FTS5 contentless 模式需要手动同步索引"）
        context: 可选补充上下文 — 为什么重要、相关背景。支持纯文本或 JSON
        collection: 项目名称（可选，默认从上下文推断）
        session_id: 会话 ID（可选，用于同 session 去重过滤）
    """
    from .cognitive.auto_classify import (
        build_context_json,
        classify_category,
        estimate_confidence,
        extract_scope,
    )
    from .cognitive.dedup import check_duplicate

    db_cog = _require_cog(ctx)
    if not db_cog:
        return {"error": "[Disabled] cognitive module not available"}

    app = _get_ctx(ctx)
    coll = collection or app.default_collection

    # L0 自动推断
    category = classify_category(insight)
    confidence = estimate_confidence(insight, context)
    scope = extract_scope(insight)
    context_json = build_context_json(insight, context)

    # 多层去重
    embedder = getattr(app, "embedder", None)
    dedup_result = check_duplicate(
        assertion=insight,
        collection=coll,
        session_id=session_id,
        db_cog=db_cog,
        embedder=embedder,
    )
    if dedup_result.is_dup:
        return {
            "status": "duplicate",
            "method": dedup_result.method,
            "similarity": dedup_result.similarity,
            "similar_facts": dedup_result.similar_facts[:3],
        }

    # 写入
    import json

    fact = {
        "collection": coll,
        "assertion": insight,
        "category": category,
        "confidence": confidence,
        "scope_files": json.dumps(scope["scope_files"]),
        "scope_modules": json.dumps(scope["scope_modules"]),
        "scope_entities": json.dumps(scope["scope_entities"]),
        "context_json": context_json,
        "scope": "project",
        "source": "claude",
    }
    if session_id:
        fact["session_id"] = session_id
    fact_id = db_cog.insert_fact(fact)

    return {
        "status": "created",
        "fact_id": fact_id,
        "auto_inferred": {
            "category": category,
            "confidence": round(confidence, 2),
            "scope_files": scope["scope_files"],
            "scope_entities": scope["scope_entities"],
            "scope_modules": scope["scope_modules"],
        },
        "similar_facts": dedup_result.similar_facts[:3],
    }


# ── Tool 3: read — 文件内容读取 ──


_MAX_LINES_DEFAULT = 500
_MAX_SYMBOLS = 30
_KIND_PRIORITY = {"class": 0, "function": 1, "method": 2, "variable": 3}


def _is_path_allowed(path: str | Path, config: Config) -> bool:
    """路径必须在 watch_paths 之内"""
    from pathlib import Path

    resolved = Path(path).resolve()
    for wp in config.watch_paths:
        try:
            if resolved.is_relative_to(Path(wp).resolve()):
                return True
        except (ValueError, OSError):
            continue
    return False


def _resolve_disk_path(stored_path: str, config: Config) -> Path | None:
    """从存储路径解析磁盘路径，遍历所有 watch_paths"""
    from pathlib import Path

    p = Path(stored_path)
    if p.is_absolute():
        return p if p.is_file() and _is_path_allowed(p, config) else None
    for wp in config.watch_paths:
        try:
            candidate = Path(wp).resolve() / stored_path
            if candidate.is_file():
                return candidate
        except (ValueError, OSError):
            continue
    return None


def _sort_symbols(symbols: list[dict], limit: int = _MAX_SYMBOLS) -> list[dict]:
    """按 PageRank + kind 权重 + 行号三级排序，截断并返回结构化列表"""
    sorted_syms = sorted(symbols, key=lambda s: (
        -s.get("pagerank", 0),
        _KIND_PRIORITY.get(s.get("kind", ""), 9),
        s.get("line_start", 0),
    ))[:limit]
    return [
        {
            "name": s["name"],
            "kind": s["kind"],
            "line": s["line_start"],
            "signature": s.get("signature"),
        }
        for s in sorted_syms
    ]


@mcp.tool()
@mcp_error_boundary
async def read(
    file_path: str,
    ctx: Context,
    collection: str | None = None,
    offset: int | None = None,
    limit: int | None = None,
) -> dict:
    """读取文件内容 + 索引元数据，用于上下文恢复

    磁盘优先读取（100% 准确），附加 symbols、chunk_count 等索引信息。
    上下文压缩后快速恢复"文件有什么"的认知，减少重新分析时间。
    文件从磁盘删除时自动从 chunks 重建（best-effort）。

    Args:
        file_path: 文件路径（支持绝对路径、相对路径、文件名模糊匹配）
        collection: 限定集合（可选）
        offset: 起始行号（1-based，可选）
        limit: 读取行数（可选）
    """
    from pathlib import Path

    app = _get_ctx(ctx)

    # Step 1: 从 DB 查找文档记录
    doc = app.db.find_document(file_path, collection, watch_paths=app.config.watch_paths)

    content = None
    source = "disk"
    disk_path = None
    hint = None
    encoding = "utf-8"
    stale = False

    if doc:
        # 有索引记录：用 DB 中的可信路径
        disk_path = _resolve_disk_path(doc["path"], app.config)
    else:
        # 无索引记录：复用 _resolve_disk_path（内含安全校验）
        disk_path = _resolve_disk_path(file_path, app.config)

    # Step 2: 读取内容
    if disk_path and disk_path.is_file():
        try:
            content = disk_path.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            try:
                content = disk_path.read_text(encoding="latin-1")
                encoding = "latin-1"
            except OSError as e:
                return {"error": f"无法读取文件: {file_path}（{e}）"}
        except OSError as e:
            return {"error": f"读取文件失败: {e}"}
    elif doc:
        # 磁盘已删除，从 chunks 重建
        chunks = app.db.get_doc_chunks(doc["id"])
        if chunks:
            content = "\n".join(c.content for c in chunks)
            source = "chunks"
            hint = "File not on disk, reconstructed from index chunks (may contain overlap)"
        else:
            return {"error": f"文件不存在且无索引数据: {file_path}"}
    else:
        return {"error": f"文件未找到: {file_path}（不在索引中且不在 watch_paths 范围内）"}

    # Step 3: 分页处理
    lines = content.split("\n")
    total_lines = len(lines)
    has_more = False

    if offset is not None or limit is not None:
        start = max((offset or 1) - 1, 0)  # 1-based → 0-based
        end = start + (limit or total_lines)
        lines = lines[start:end]
        has_more = end < total_lines
        returned_range = [start + 1, start + len(lines)]
    elif total_lines > _MAX_LINES_DEFAULT:
        lines = lines[:_MAX_LINES_DEFAULT]
        has_more = True
        returned_range = [1, _MAX_LINES_DEFAULT]
        hint = hint or f"File has {total_lines} lines, showing first {_MAX_LINES_DEFAULT}. Use offset/limit for more."
    else:
        returned_range = [1, total_lines]

    content = "\n".join(lines)

    # 空文件提示
    if not content.strip():
        hint = hint or "File exists but is empty"

    # Step 4: 元数据
    symbols_out: list[dict] = []
    chunk_count = 0
    indexed_at = None

    if doc:
        meta = app.db.get_file_meta(doc["id"])
        chunk_count = meta["chunk_count"]
        symbols_out = _sort_symbols(meta["symbols"])
        indexed_at = doc.get("indexed_at")

        # stale 检测
        if disk_path and indexed_at:
            try:
                import datetime as _dt

                mtime = disk_path.stat().st_mtime
                indexed_ts = _dt.datetime.fromisoformat(indexed_at).timestamp()
                stale = mtime > indexed_ts
            except (OSError, ValueError):
                pass

    # Step 5: 构建返回
    result: dict = {
        "path": doc["path"] if doc else str(disk_path or file_path),
        "content": content,
        "total_lines": total_lines,
        "returned_lines": returned_range,
        "has_more": has_more,
        "source": source,
    }

    if encoding != "utf-8":
        result["encoding"] = encoding
    if stale:
        result["stale"] = True
    if hint:
        result["hint"] = hint
    if symbols_out:
        result["symbols"] = symbols_out
    if chunk_count:
        result["chunk_count"] = chunk_count
    if indexed_at:
        result["last_indexed"] = indexed_at

    return result


# ── Tool 4: status — 系统状态 ──


@mcp.tool()
@mcp_error_boundary
async def status(ctx: Context) -> dict:
    """系统状态 — 索引统计 + 认知记忆统计 + embedding 后端状态

    返回 corpus 索引（文档数、片段数、集合列表）+
    cognition 状态（fact/model/tactic 数量）+
    embedding 后端信息。
    """
    app = _get_ctx(ctx)

    # corpus 统计
    stats = app.db.get_stats()

    # 当前 collection
    stats["current_collection"] = app.config.collection

    # embedding 状态
    stats["embedding"] = {
        "available": app.embedder.available,
        "backend": getattr(app.config, "embed_backend", "unknown"),
        "model": app.embedder.model,
        "dim": app.embedder.dim,
        "reason": app.embedder.unavailable_reason or None,
    }

    # cognition 统计
    if app.db_cog:
        try:
            cog_stats = {}
            conn = app.db_cog.conn
            cog_stats["facts"] = conn.execute(
                "SELECT COUNT(*) FROM facts WHERE status='active'"
            ).fetchone()[0]
            cog_stats["models"] = conn.execute(
                "SELECT COUNT(*) FROM mental_models WHERE status='active'"
            ).fetchone()[0]
            cog_stats["tactics"] = conn.execute(
                "SELECT COUNT(*) FROM tactics WHERE status='active'"
            ).fetchone()[0]
            cog_stats["enabled"] = True
            stats["cognition"] = cog_stats
        except Exception:
            stats["cognition"] = {"enabled": True, "error": "统计查询失败"}
    else:
        stats["cognition"] = {"enabled": False}

    # Observer 健康状态 (#237 Phase 3)
    if app.db_cog:
        try:
            from .cognitive.observer_health import check_observer_health
            stats["observer_health"] = check_observer_health(app.db_cog)
        except Exception:
            stats["observer_health"] = {"status": "unknown", "message": "health check failed"}
    else:
        stats["observer_health"] = {"status": "unknown", "message": "cognition not enabled"}

    # #255: Corpus 新鲜度
    if app.db:
        try:
            from .watcher import check_corpus_stale
            freshness = check_corpus_stale(
                app.db._db_path,
                getattr(app.config, "watcher_stale_threshold", 3600),
            )
            stats["corpus_freshness"] = freshness
        except Exception:
            pass

    return stats


# ── Tool 5: reindex — 重建索引 ──


@mcp.tool()
@mcp_error_boundary
async def reindex(
    ctx: Context,
    collection: str | None = None,
    path: str | None = None,
) -> dict:
    """重建索引

    重新扫描并索引指定路径或集合。如果不指定参数则使用配置中的 watch_paths。

    Args:
        collection: 集合名称（可选）
        path: 要索引的文件或目录路径（可选）
    """
    import logging
    from pathlib import Path

    logger = logging.getLogger(__name__)
    app = _get_ctx(ctx)

    paths = [path] if path else app.config.watch_paths
    if not paths:
        return {"error": "未指定索引路径，请先配置 watch_paths 或传入 path 参数"}

    # 路径安全校验（null byte / symlink / home 边界）
    home_dir = Path.home().resolve()
    valid_paths: list[str] = []
    for p_str in paths:
        if not isinstance(p_str, str) or "\x00" in p_str:
            return {"error": "invalid path"}
        orig = Path(p_str).expanduser()
        if not orig.exists():
            return {"error": f"path not found: {p_str}"}
        for part in [orig] + list(orig.parents):
            if part.is_symlink():
                return {"error": f"symlink in path not allowed: {p_str}"}
        resolved = orig.resolve()
        try:
            resolved.relative_to(home_dir)
        except ValueError:
            return {"error": f"path not allowed: {p_str}"}
        valid_paths.append(str(resolved))

    # 推断 base_dir
    base_dir = None
    if len(valid_paths) == 1:
        p = Path(valid_paths[0]).resolve()
        base_dir = p if p.is_dir() else p.parent

    logger.info("开始重建索引: %s", ", ".join(valid_paths))
    effective_collection = collection or app.default_collection
    stats = await index_files(
        valid_paths, app.config, app.db, app.embedder,
        collection=effective_collection, force=True, base_dir=base_dir,
    )
    logger.info(
        "索引完成: %d 已索引, %d 跳过, %d 失败 (共 %d 文件)",
        stats["indexed"], stats["skipped"], stats["failed"], stats["total"],
    )
    return stats


# ── Tool 6: config — 查看/修改配置 ──


_TYPE_CONVERTERS = {
    "db_path": str,
    "embedding_model": str,
    "embedding_dim": int,
    "ollama_url": str,
    "top_k": int,
    "rrf_k": int,
    "collection": str,
}


def _convert_value(value: str, key: str) -> object:
    """根据配置键名安全转换值"""
    converter = _TYPE_CONVERTERS.get(key, str)
    if converter is int:
        return int(value)
    if converter is float:
        return float(value)
    if key == "watch_paths":
        return [v.strip() for v in value.split(",") if v.strip()]
    return value


@mcp.tool()
@mcp_error_boundary
async def config(
    ctx: Context,
    key: str | None = None,
    value: str | None = None,
) -> dict:
    """查看或修改配置

    不传参数时返回当前配置。传入 key 和 value 时修改配置。

    Args:
        key: 配置键名（可选），如 embedding_model、top_k
        value: 配置值（可选）
    """
    app = _get_ctx(ctx)

    # 特殊命令：重连 Ollama（仅 OllamaEmbedder 支持 reset_cooldown）
    if key == "ollama_reconnect":
        if hasattr(app.embedder, "reset_cooldown"):
            app.embedder.reset_cooldown()
        ok = await app.embedder.check_availability()
        if ok:
            return {
                "message": "Ollama 重连成功，向量搜索已恢复",
                "ollama_available": True,
                "model": app.embedder.model,
            }
        return {
            "message": "Ollama 重连失败，仍使用 BM25 降级搜索",
            "ollama_available": False,
            "reason": app.embedder.unavailable_reason,
            "hint": "请检查: 1) ollama serve 是否运行  2) ollama pull " + app.embedder.model,
        }

    if key and value:
        from dataclasses import fields

        valid_keys = {f.name for f in fields(Config)}
        if key not in valid_keys:
            return {"error": f"未知配置项: {key}"}

        converted = _convert_value(value, key)
        setattr(app.config, key, converted)
        save_config(app.config)
        return {"message": f"已更新 {key} = {converted}"}

    # 返回当前配置（过滤敏感字段）
    from dataclasses import asdict

    result = asdict(app.config)
    for k in _SENSITIVE_KEYS:
        if k in result and result[k]:
            result[k] = "***"
    return result


def run_server() -> None:
    """启动 MCP Server（stdio 模式）"""
    mcp.run()
