"""中古史学术IDE · 汉籍语料索引 MCP server（插件 v0.1 · 北辰 CLI vendored 副本）

★ vendored 自 mvp_backend/hj_corpus_mcp_server.py（检索核心单源不复制原则的例外=同源两份）：
  改库/改检索逻辑一律先改主 IDE 源 mvp_backend/，本副本跟版同步；两件的差异只许在
  ①本段 vendored 注记 ②工具 annotations（readOnlyHint·⑥通用免问机制）
  ③B3 远程模式分支（方案 v0.8 §三「库在线」指定分叉：本地库不可用 → 四器改打北辰网关
    /hj/* 端点·订阅令牌鉴权·读 ~/.ws/auth.json；本地模式与主源逐字一致）
  ④stdio 壳 mcp 2.x 兼容层（2026-08-27：mcp 2.x 起 FastMCP 改名 MCPServer——订阅端
    pip install mcp 现实装到 2.x，不改则 server 起不来；只动 build_mcp_app 的 import 壳，
    检索核心 do_*/hj_index_search 单源不动·服务器侧副本不受影响[只用 do_* 纯函数]）。
依赖：python 3.10+ + `pip install mcp`（1.x FastMCP / 2.x MCPServer 双兼容）；库路径经
  HJ_CORPUS_DB 环境变量注入（CLI settings 默认注入已配好；2026-08-27 开闸后无库也注入·
  python 侧自动转 B3 远程，本条旧注「无库隐身」已随 TS 开闸作废）。

汉籍索引库封装进 agent，用插件组装，agent 用 MCP 协议访问。

定位：hj_4_jt_fts.sqlite（4.86GB trigram FTS5 全库，66876 篇，含《唐六典》全 30 卷）的
只读检索 MCP 服务（stdio JSON-RPC）。检索核心复用 hj_index_search.py（单源不复制）。

本服务修缺三件（agent 多词组合 0 命中、误报「本地无《唐六典》」）：
1. 多词组合检索（hj_search mode=all_terms）——旧接口把整串归一化成连续短语
   （normalize_with_map 连空格一并吞掉），多词查询必然 0 命中；
2. 书名/卷名定位（hj_find_book）——旧接口没有「库里有没有某书、有哪些卷」的问法；
3. 命中总数如实报（total_hits）——旧接口 hit_count=截断后条数（1061 条真实命中报 10），
   配 offset 分页，不静默截断（A-26 量具之报）。
另补 hj_get_article 按篇取全文（分片，防一次取爆）。

trigram 索引固有边界（如实报，不掩饰）：规范化后少于 3 字的词进不了 trigram 倒排，
all_terms 模式下短词以 LIKE 在命中集内过滤（原文若被标点隔断则匹配不到，warning 注明）。

安全边界：file:?mode=ro + PRAGMA query_only=ON，只读；绝不写库。
证据政策每返回必带：命中只作出处候选与上下文依据，不作页码依据（page_basis=false）。

运行：python hj_corpus_mcp_server.py [--db <path>]      # stdio MCP server
直测：python hj_corpus_mcp_server.py --selftest         # 不经协议直测 SQL 层
"""

from __future__ import annotations

import argparse
import json
import os
import sqlite3
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

BACKEND_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BACKEND_DIR))

import hj_index_search as hj  # 检索核心单源：normalize/fts_phrase/open/context/candidate/policy

DB_PATH = Path(os.environ.get("HJ_CORPUS_DB") or hj.DEFAULT_DB)

# ===== B3 远程模式（方案 v0.8 §三·库在线）=====
# 本地库不可用（HJ_CORPUS_DB 未设或指向文件不存在）→ 四器改打北辰网关 /hj/* 端点
# （验卡同款：Authorization Bearer 卡密 + x-ws-device 设备头·读 ~/.ws/auth.json）。
HJ_REMOTE_BASE_URL = os.environ.get(
    "HJ_REMOTE_BASE_URL", "https://api.gobi-starweaver.cn:8443").rstrip("/")
_HJ_REMOTE_TIMEOUT = int(os.environ.get("HJ_REMOTE_TIMEOUT", "60"))


def _local_db_available() -> bool:
    try:
        return DB_PATH.exists()
    except OSError:
        return False


def _header_safe(value: str) -> str:
    """头值消毒（与 CLI openai-client.toHeaderSafeAscii 同律·小写百分号十六进制）：
    非可见 ASCII(33-126)按码点 UTF-8 百分号转义。病案 2026-08-27：中文计算机名机器
    旧版 machine-id 含汉字，x-ws-device 头带非 Latin-1 字符在请求编码层必炸（CLI 侧
    Headers ByteString 报错）。auth.json.device_id 是登录时 machine-id 快照，读面消毒
    兜住残留污染，并保证与 CLI 侧消毒逐字节同值同形（网关按设备号绑定，两侧不等会使
    同一台机器占双设备位）。"""
    out = []
    for ch in value:
        if 33 <= ord(ch) <= 126:
            out.append(ch)
        else:
            out.append("".join(f"%{b:02x}" for b in ch.encode("utf-8")))
    return "".join(out)


def _read_auth() -> tuple[str, str]:
    """读 ~/.ws/auth.json → (card_token, device_id)；无文件/损坏/缺字段 → ("", "")。
    device_id 经 _header_safe 消毒后返回（病案 2026-08-27，见 _header_safe 注释）。"""
    try:
        raw = Path.home().joinpath(".ws", "auth.json").read_text(encoding="utf-8")
        data = json.loads(raw)
        device_id = _header_safe(str(data.get("device_id") or ""))
        return str(data.get("card_token") or ""), device_id
    except Exception:
        return "", ""


def _remote_call(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
    """POST 网关 /hj/<endpoint>；回包 dict 原样（与本地 do_* 同构契约）。
    未登录/网络异常 → error dict 如实报（不 raise·与 do_* 的错误随包契约一致）。"""
    token, device = _read_auth()
    if not token or not device:
        return {"error": "远程汉籍库需要登录态：未读到有效登录（~/.ws/auth.json），"
                         "请先在 CLI 内 /login 后重试。"}
    req = urllib.request.Request(
        f"{HJ_REMOTE_BASE_URL}/hj/{endpoint}",
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {token}",
            "x-ws-device": device,
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=_HJ_REMOTE_TIMEOUT) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        try:
            return json.loads(e.read().decode("utf-8"))
        except Exception:
            return {"error": f"网关回 HTTP {e.code}（远程汉籍库暂不可用）。"}
    except Exception as e:
        return {"error": f"远程汉籍库连接失败：{type(e).__name__}（请检查网络后重试）。"}

ARTICLE_SLICE_MAX = 8000
SEARCH_LIMIT_MAX = 50


def _connect() -> sqlite3.Connection:
    return hj.open_hj_index(DB_PATH)


def _policy() -> dict[str, Any]:
    return dict(hj.EVIDENCE_POLICY)


def _terms_of(query: str) -> tuple[list[str], list[str], list[str]]:
    """按空白拆词并归一化；返回 (全部词原貌, 可进 trigram 的词≥3字, 短词<3字)。"""
    raw_terms = [t for t in hj.compact_query(query).split(" ") if t]
    long_terms: list[str] = []
    short_terms: list[str] = []
    for t in raw_terms:
        n = hj.normalize_query(t)
        if len(n) >= 3:
            long_terms.append(n)
        elif n:
            short_terms.append(t)  # 短词留原貌走 LIKE
    return raw_terms, long_terms, short_terms


def do_find_book(name_like: str, limit: int = 50) -> dict[str, Any]:
    name_like = hj.compact_query(name_like)
    if not name_like:
        return {"error": "name_like 不能为空。"}
    limit = max(1, min(int(limit), 200))
    if not _local_db_available():
        return _remote_call("find_book", {"name_like": name_like, "limit": limit})
    conn = _connect()
    try:
        pattern = f"%{name_like}%"
        total = conn.execute(
            "SELECT COUNT(*) FROM docs WHERE title LIKE ?", (pattern,)
        ).fetchone()[0]
        rows = conn.execute(
            "SELECT docid, title, book_base, article_no, char_count FROM docs "
            "WHERE title LIKE ? ORDER BY book_base, article_no LIMIT ?",
            (pattern, limit),
        ).fetchall()
    finally:
        conn.close()
    return {
        "name_like": name_like,
        "total_matches": total,
        "returned": len(rows),
        "note": "按篇名（title，形如「唐六典·卷五·尚书兵部」）模糊匹配；total_matches 为全库真实总数。",
        "items": [
            {"docid": r["docid"], "title": r["title"], "book_base": r["book_base"],
             "article_no": r["article_no"], "char_count": r["char_count"]}
            for r in rows
        ],
        "evidence_policy": _policy(),
    }


def do_search(query: str, mode: str = "phrase", title_like: str = "",
              limit: int = 10, offset: int = 0, context_chars: int = 80) -> dict[str, Any]:
    raw_query = hj.compact_query(query)
    if not raw_query:
        return {"error": "query 不能为空。"}
    mode = (mode or "phrase").strip().lower()
    if mode not in ("phrase", "all_terms"):
        return {"error": f"mode 须为 phrase 或 all_terms，收到：{mode}"}
    limit = max(1, min(int(limit), SEARCH_LIMIT_MAX))
    offset = max(0, int(offset))
    context_chars = max(20, min(int(context_chars), 400))
    if not _local_db_available():
        return _remote_call("search", {
            "query": raw_query, "mode": mode, "title_like": title_like,
            "limit": limit, "offset": offset, "context_chars": context_chars,
        })
    warnings: list[str] = []

    where: list[str] = []
    params: list[object] = []
    from_clause = "FROM docs_fts JOIN docs AS d ON d.docid = docs_fts.rowid"
    rank_select = "bm25(docs_fts) AS rank"
    order_by = "ORDER BY rank"
    like_filter_terms: list[str] = []

    if mode == "phrase":
        normalized = hj.normalize_query(raw_query)
        if len(normalized) < 3:
            return {"error": "短语经规范化后不足 3 字，trigram 索引无法命中；"
                             "请加长短语，或用 all_terms 模式与较长词组合。"}
        where.append("docs_fts MATCH ?")
        params.append(hj.fts_phrase(normalized))
        primary_terms = [raw_query]
    else:  # all_terms
        raw_terms, long_terms, short_terms = _terms_of(raw_query)
        if not raw_terms:
            return {"error": "query 不能为空。"}
        if not long_terms:
            return {"error": "all_terms 模式至少需要一个规范化后 ≥3 字的词"
                             "（trigram 索引边界）；全部短词请改用更长的词或加长短语。"}
        where.append("docs_fts MATCH ?")
        params.append(" AND ".join(hj.fts_phrase(t) for t in long_terms))
        like_filter_terms = short_terms
        for st in short_terms:
            where.append("d.body LIKE ?")
            params.append(f"%{st}%")
        if short_terms:
            warnings.append(
                f"短词 {short_terms} 以 LIKE 在命中集内过滤（不足 3 字进不了 trigram 倒排）；"
                "原文中若被标点/空白隔断则匹配不到。"
            )
        primary_terms = raw_terms

    if title_like:
        where.append("d.title LIKE ?")
        params.append(f"%{hj.compact_query(title_like)}%")

    where_sql = " AND ".join(where)
    conn = _connect()
    try:
        total_hits = conn.execute(
            f"SELECT COUNT(*) {from_clause} WHERE {where_sql}", params
        ).fetchone()[0]
        rows = conn.execute(
            f"SELECT d.docid, d.rel_path, d.book_base, d.article_no, d.title, d.source, "
            f"d.body, d.char_count, {rank_select} {from_clause} WHERE {where_sql} "
            f"{order_by} LIMIT ? OFFSET ?",
            [*params, limit, offset],
        ).fetchall()
    finally:
        conn.close()

    locate_term = primary_terms[0]
    normalized_locate = hj.normalize_query(locate_term)
    candidates = [
        hj.row_to_candidate(row, locate_term, normalized_locate, context_chars)
        for row in rows
    ]
    if total_hits > offset + len(candidates):
        warnings.append(
            f"total_hits={total_hits}，本次仅返回第 {offset + 1}—{offset + len(candidates)} 条"
            f"（bm25 相关度序）；其余经 offset 翻页取。"
        )
    return {
        "query": raw_query,
        "mode": mode,
        "title_like": title_like or None,
        "total_hits": total_hits,
        "offset": offset,
        "returned": len(candidates),
        "warnings": warnings,
        "results": candidates,
        "evidence_policy": _policy(),
    }


def do_get_article(docid: int = 0, title: str = "",
                   body_offset: int = 0, length: int = 4000) -> dict[str, Any]:
    if not docid and not title:
        return {"error": "docid 与 title 至少给一个（title 须精确篇名，可先经 hj_find_book 取得）。"}
    body_offset = max(0, int(body_offset))
    length = max(200, min(int(length), ARTICLE_SLICE_MAX))
    if not _local_db_available():
        return _remote_call("get_article", {
            "docid": docid, "title": title,
            "body_offset": body_offset, "length": length,
        })
    conn = _connect()
    try:
        if docid:
            row = conn.execute(
                "SELECT docid, rel_path, book_base, article_no, title, source, body, char_count "
                "FROM docs WHERE docid = ?", (int(docid),)
            ).fetchone()
        else:
            exact = hj.compact_query(title)
            row = conn.execute(
                "SELECT docid, rel_path, book_base, article_no, title, source, body, char_count "
                "FROM docs WHERE title = ?", (exact,)
            ).fetchone()
            if row is None:
                cands = conn.execute(
                    "SELECT docid, title FROM docs WHERE title LIKE ? LIMIT 8",
                    (f"%{exact}%",),
                ).fetchall()
                return {"error": f"未找到精确篇名「{exact}」。",
                        "near_titles": [{"docid": c["docid"], "title": c["title"]} for c in cands]}
    finally:
        conn.close()
    if row is None:
        return {"error": f"docid={docid} 不存在。"}
    body = row["body"] or ""
    piece = body[body_offset: body_offset + length]
    return {
        "docid": row["docid"],
        "title": row["title"],
        "book_base": row["book_base"],
        "article_no": row["article_no"],
        "source": row["source"],
        "body_total_chars": len(body),
        "body_offset": body_offset,
        "returned_chars": len(piece),
        "has_more": body_offset + len(piece) < len(body),
        "note": "全文按片返回（单片上限 8000 字）；has_more=true 时调大 body_offset 继续取。",
        "body": piece,
        "evidence_policy": _policy(),
    }


def do_stats() -> dict[str, Any]:
    if not _local_db_available():
        return _remote_call("stats", {})
    info = hj.inspect_hj_index(DB_PATH)
    info["note"] = ("汉籍语料索引（trigram FTS5）只读服务；检索命中只作出处候选与上下文依据，"
                    "不作页码依据。")
    return info


# ---- MCP server（FastMCP，stdio）----

def build_mcp_app():
    # vendored 差异④：mcp 2.x 起 FastMCP 改名 MCPServer（mcp.server.mcpserver）——双版本
    # import 兼容（订阅端 pip 现实装到 2.x）；API 面（构造 instructions/.tool(annotations=)/.run()
    # stdio）1.x/2.x 同形，实测双方均通。检索核心不涉此处。
    try:
        from mcp.server.fastmcp import FastMCP  # mcp 1.x
    except ModuleNotFoundError:
        from mcp.server.mcpserver import MCPServer as FastMCP  # mcp 2.x（FastMCP→MCPServer）
    from mcp.types import ToolAnnotations

    # 只读自声明（⑥通用免问机制）：本服务确实只读（file:?mode=ro + PRAGMA query_only=ON·
    # 绝不写库），如实挂 readOnlyHint——宿主 CLI 权限层凭它对 mcp 工具免 ask（A-26 如实消费）。
    # 注意：hj_stats 同为只读，一并挂（handoff 旧稿列 3 工具为笔误，4 个全只读）。
    READ_ONLY = ToolAnnotations(readOnlyHint=True)

    app = FastMCP(
        "hj-corpus",
        instructions=(
            "汉籍语料索引只读检索服务（含两《唐书》《唐六典》《资治通鉴》等；"
            "实际篇数以 hj_stats 返回为准——封装核心库已剔清明清以后小说戏曲簇，篇数少于开发全量库）。"
            "用法：hj_find_book 先定位书/卷；hj_search 检索（phrase=连续短语、"
            "all_terms=多词组合 AND，title_like 可限定在某书内）；hj_get_article 按篇取全文。"
            "铁律：命中只作出处候选与上下文依据，不作页码依据（page_basis=false）；"
            "版本/卷次细目/页码须按拟引用版本另行人工核定。"
            "边界：本库若为分发核心版，已剔 C07/C08/非 allowlist 的 C09 明清小说秘史簇"
            "与 D02 文学戏曲辅助簇及大型元曲杂剧；hj 未命中只能说核心库未命中，不等于汉籍无该出处。"
        ),
    )

    @app.tool(annotations=READ_ONLY)
    def hj_find_book(name_like: str, limit: int = 50) -> dict:
        """书名/卷名定位：按篇名模糊匹配（如「唐六典」可列出全部 30 卷），返回全库真实总数
        total_matches 与篇目清单（docid/title/book_base/char_count）。检索正文前先用本工具
        确认书在不在库、篇名长什么样。"""
        return do_find_book(name_like, limit)

    @app.tool(annotations=READ_ONLY)
    def hj_search(query: str, mode: str = "phrase", title_like: str = "",
                  limit: int = 10, offset: int = 0, context_chars: int = 80) -> dict:
        """全库正文检索（trigram FTS5，bm25 相关度排序）。mode=phrase：query 作连续短语
        （引文原句用这个）；mode=all_terms：query 按空格拆多词 AND 组合（如「骠骑将军 品级」，
        概念组合用这个；规范化后不足 3 字的短词以 LIKE 过滤并报 warning）。title_like 限定
        在某书/某卷内检索（如「唐六典」）。返回 total_hits 全库真实命中总数 + offset 分页。
        命中只作出处候选与上下文依据，不作页码依据（page_basis=false）。"""
        return do_search(query, mode, title_like, limit, offset, context_chars)

    @app.tool(annotations=READ_ONLY)
    def hj_get_article(docid: int = 0, title: str = "",
                       body_offset: int = 0, length: int = 4000) -> dict:
        """按篇取全文：docid（hj_find_book/hj_search 返回）或精确篇名二选一。全文分片返回
        （单片上限 8000 字，has_more=true 时调大 body_offset 续取）。引用校核须取全文上下文
        时用本工具，不要只凭检索摘录下判断。"""
        return do_get_article(docid, title, body_offset, length)

    @app.tool(annotations=READ_ONLY)
    def hj_stats() -> dict:
        """库概况：篇目总数、构建元数据、只读状态、证据政策。"""
        return do_stats()

    return app


def selftest() -> int:
    checks: dict[str, bool] = {}
    fb = do_find_book("唐六典")
    checks["find_book 唐六典 30 卷"] = fb["total_matches"] == 30
    checks["find_book 含诸卫府卷"] = any("卷二十五·诸卫府" in i["title"] for i in fb["items"])

    s1 = do_search("骠骑将军", mode="phrase", title_like="唐六典")
    checks["phrase 骠骑将军@唐六典 命中2"] = s1["total_hits"] == 2
    checks["命中含卷五尚书兵部"] = any("卷五" in r["title"] for r in s1["results"])
    checks["命中含卷二十五诸卫府"] = any("卷二十五" in r["title"] for r in s1["results"])

    s2 = do_search("骠骑将军 车骑将军 折冲", mode="all_terms")
    checks["all_terms 三词组合有命中(血证查询)"] = s2["total_hits"] > 0
    s2b = do_search("骠骑将军 车骑将军 折冲", mode="phrase")
    checks["同串 phrase 0 命中(旧缺陷对照)"] = s2b["total_hits"] == 0

    s3 = do_search("骠骑将军", mode="phrase")
    checks["total_hits 如实报全库 1061"] = s3["total_hits"] == 1061
    checks["返回带分页 warning"] = any("total_hits=1061" in w for w in s3["warnings"])

    twentyfive = next(i for i in fb["items"] if "卷二十五·诸卫府" in i["title"])
    art = do_get_article(docid=twentyfive["docid"], body_offset=0, length=2000)
    checks["get_article 取卷二十五全文片"] = art["returned_chars"] == 2000 and art["has_more"]
    art2 = do_get_article(title="唐六典·卷二十五·诸卫府", body_offset=0, length=500)
    checks["get_article 精确篇名可取"] = art2.get("docid") == twentyfive["docid"]

    st = do_stats()
    checks["stats 只读 66876 篇"] = st["doc_count"] == 66876 and st["query_only"] == 1
    checks["evidence_policy 必带"] = all(
        "evidence_policy" in x for x in (fb, s1, s2, s3, art, st)
    )

    for k, v in checks.items():
        print(("PASS" if v else "FAIL"), k)
    ok = all(checks.values())
    print("==>", "ALL PASS" if ok else "HAS FAILURE")
    return 0 if ok else 1


def main() -> int:
    parser = argparse.ArgumentParser(description="汉籍语料索引 MCP server（只读）")
    parser.add_argument("--db", default="", help="hj_4_jt_fts.sqlite 路径（缺省=项目内全库）")
    parser.add_argument("--selftest", action="store_true", help="直测 SQL 层（不起 MCP）")
    args = parser.parse_args()
    global DB_PATH
    if args.db:
        DB_PATH = Path(args.db)
    if args.selftest:
        return selftest()
    build_mcp_app().run()  # stdio transport
    return 0


if __name__ == "__main__":
    sys.exit(main())
