import { describe, it, expect, beforeEach } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import { registerGetMap } from "../../../src/tools/kbdb_map.js";
import {
  buildLibraryMapInstructions,
  renderLibraryMapLines,
  __resetLibraryMapInstructionsCacheForTests,
} from "../../../src/lib/library-map.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";

/** 服務級憑據（static token / partner key）——既有 KBDB 直連路徑，行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面（只看得到自己有權限的庫）。 */
const PORTAL: KnowledgeIdentity = {
  kind: "portal",
  portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
/** 本次改版前簽發的舊 token（沒有身分）。 */
const STALE: KnowledgeIdentity = { kind: "stale" };

/** 假 CYPHER_EXECUTOR binding（portal 資料面用）。 */
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
  const calls: { url: URL; init?: RequestInit }[] = [];
  const env = {
    CYPHER_EXECUTOR: {
      fetch: async (input: string, init?: RequestInit) => {
        const url = new URL(input);
        calls.push({ url, init });
        return respond(url, init);
      },
    },
  } as unknown as Env;
  return { env, calls };
}

// ── 假 McpServer：只攔 tool 註冊，抓出 handler 直接呼叫（比照 kbdb-graph.test.ts）──────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
  content: { type: string; text: string }[];
  isError?: boolean;
}>;

function makeServer() {
  const tools = new Map<string, { description: string; handler: ToolHandler }>();
  const server = {
    tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
      tools.set(name, { description, handler });
    },
  };
  return { server: server as unknown as McpServer, tools };
}

// ── 假 KBDB service binding：記錄請求、回預設 response ─────────────────────────────
function makeEnv(respond: (url: URL, init?: RequestInit) => Response) {
  const calls: { url: URL; init?: RequestInit }[] = [];
  const env = {
    KBDB: {
      fetch: async (input: string, init?: RequestInit) => {
        const url = new URL(input);
        calls.push({ url, init });
        return respond(url, init);
      },
    },
    KBDB_INTERNAL_TOKEN: "test-token",
  } as unknown as Env;
  return { env, calls };
}

function parseResult(r: { content: { text: string }[] }) {
  return JSON.parse(r.content[0].text) as Record<string, unknown>;
}

const KB_ROW = {
  library: "kb",
  narrative: "leo 的知識庫主庫",
  top_entities: ["00-INDEX", "kb/00-INDEX", "Gitea"],
  triplet_count: 111,
  updated_at: 1784451880,
};

describe("kbdb_get_map: registration", () => {
  it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
    const { server, tools } = makeServer();
    const { env } = makeEnv(() => new Response("{}"));
    registerGetMap(server, env, SERVICE);
    expect(tools.has("kbdb_get_map")).toBe(true);
    // 任務規格：description 必含「不確定該查什麼時，先呼叫此工具」
    expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時，先呼叫此工具");
  });
});

describe("kbdb_get_map: 全館地圖（無參數）", () => {
  it("hits GET /map via KBDB binding and returns per-library rows", async () => {
    const { server, tools } = makeServer();
    const { env, calls } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
    );
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({});

    expect(calls).toHaveLength(1);
    expect(calls[0].url.pathname).toBe("/map");
    // kbdbFetch 慣例：KBDB_INTERNAL_TOKEN 注入 Authorization
    expect(new Headers(calls[0].init?.headers).get("Authorization")).toBe("Bearer test-token");

    const body = parseResult(res);
    expect(body.ok).toBe(true);
    const data = body.data as { libraries: { library: string; top_entities: string[] }[]; count: number };
    expect(data.count).toBe(1);
    expect(data.libraries[0].library).toBe("kb");
    expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "kb/00-INDEX", "Gitea"]);
  });

  it("owner_id is forwarded as query param", async () => {
    const { server, tools } = makeServer();
    const { env, calls } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
    );
    registerGetMap(server, env, SERVICE);
    await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
    expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
  });

  it("Arcrun#87 三次收尾：triplet_count=0 但 entry_count>0 的庫附上「別當成沒有知識」的提示", async () => {
    // leo21c 實測現況：kb 以外幾乎每庫都長這樣——entries 有(且 library 標對)，三元組萃取沒跑過。
    const emptyTripletRow = {
      library: "arcrun",
      narrative: null,
      top_entities: [],
      triplet_count: 0,
      entry_count: 15,
      updated_at: 1786330534,
    };
    const { server, tools } = makeServer();
    const { env } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [emptyTripletRow], count: 1 })),
    );
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({});
    const body = parseResult(res);
    const data = body.data as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
    expect(data.libraries[0].entry_count).toBe(15);
    expect(data.libraries[0].triplet_count).toBe(0);
    // 提示要點名該庫、講清楚「有內容只是沒萃取」，別讓 AI 讀到 0 triplets 就跳過這個庫。
    const hints = (body.hints as string[]).join(" ");
    expect(hints).toContain("arcrun");
    expect(hints).toContain("entry_count");
    expect(hints).toContain("kbdb_search");
  });

  it("entry_count 缺席（舊部署未帶此欄位）→ 容錯當 0，不 crash、不誤發提示", async () => {
    const legacyRow = { ...KB_ROW }; // KB_ROW 本身沒有 entry_count 欄位（模擬舊部署回應）
    const { server, tools } = makeServer();
    const { env } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [legacyRow], count: 1 })),
    );
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({});
    const body = parseResult(res);
    const data = body.data as { libraries: { entry_count: number }[] };
    expect(data.libraries[0].entry_count).toBe(0);
  });

  it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => {
    const { server, tools } = makeServer();
    const row = {
      ...KB_ROW,
      top_entities: '[{"name":"00-INDEX","degree":29},{"name":"Gitea","degree":7}]',
      triplet_count: "111", // slot 底層存字串 → 要轉數字
    };
    const { env } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
    );
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({});
    const data = parseResult(res).data as {
      libraries: { top_entities: string[]; triplet_count: number }[];
    };
    expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "Gitea"]);
    expect(data.libraries[0].triplet_count).toBe(111);
  });

  it("空庫誠實回報＋提示跑 POST /map/recompute backfill（鐵律：不假綠不 crash）", async () => {
    const { server, tools } = makeServer();
    const { env } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
    );
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({});
    const body = parseResult(res);
    expect(body.ok).toBe(true);
    expect((body.data as { count: number }).count).toBe(0);
    expect(JSON.stringify(body.hints)).toContain("POST /map/recompute");
  });

  // 2026-08-08：舊版說明文字宣稱「地圖由 ingest 尾端自動重算（M3）」——那件事從沒接上過
  // （總管實測 grep 全 repo 查無任何呼叫點），是假話。修法：GET /map 讀端本身自動核對
  // 即時三元組數並補算，不靠任何外部呼叫者。這裡釘住那句謊言不會再出現在任何 hint 裡。
  it("不再宣稱「地圖由 ingest 尾端自動重算（M3）」——那件事從沒接上過，是假話（已修正措辭）", async () => {
    const { server, tools } = makeServer();
    const { env } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
    );
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({});
    const body = parseResult(res);
    const hintsText = JSON.stringify(body.hints);
    expect(hintsText).not.toContain("地圖由 ingest 尾端自動重算");
    expect(hintsText).not.toContain("(M3)");
    expect(hintsText).not.toContain("（M3）");
    // 誠實的新措辭：空＝這個租戶真的沒資料，不是「沒人跑過 recompute」
    expect(hintsText).toContain("這個租戶目前沒有任何三元組資料");
  });

  it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
    const { server, tools } = makeServer();
    const { env } = makeEnv(() => new Response("boom", { status: 500 }));
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({});
    expect(res.isError).toBe(true);
    const body = parseResult(res);
    expect(body.error_code).toBe("map_fetch_failed");
    expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
  });
});

describe("kbdb_get_map: 單庫詳圖（library 參數）", () => {
  const DETAIL = {
    record_id: "e_123",
    library: "kb",
    narrative: "leo 的知識庫主庫",
    content: "kb：leo 的知識庫主庫。核心：00-INDEX",
    top_entities: [{ name: "00-INDEX", degree: 29 }],
    relation_profile: [{ predicate: "連結至", count: 48 }],
    bridges: [{ entity: "Gitea", libraries: ["notes"] }],
    triplet_count: 111,
    commit_hash: null,
    status: "active",
    updated_at: 1784451880,
  };

  it("hits GET /map/:library and returns full detail", async () => {
    const { server, tools } = makeServer();
    const { env, calls } = makeEnv(
      () => new Response(JSON.stringify({ success: true, map: DETAIL })),
    );
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
    expect(calls[0].url.pathname).toBe("/map/kb");
    const map = (parseResult(res).data as { map: typeof DETAIL }).map;
    expect(map.record_id).toBe("e_123");
    expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]);
    expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]);
  });

  it("Arcrun#87 三次收尾：單庫詳圖 triplet_count=0、entry_count>0 → 附「別當空庫」提示", async () => {
    const emptyTripletDetail = {
      ...DETAIL,
      library: "arcrun",
      triplet_count: 0,
      entry_count: 15,
      top_entities: [],
      relation_profile: [],
      bridges: [],
    };
    const { server, tools } = makeServer();
    const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: emptyTripletDetail })));
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({ library: "arcrun" });
    const body = parseResult(res);
    const map = (body.data as { map: { entry_count: number; triplet_count: number } }).map;
    expect(map.triplet_count).toBe(0);
    expect(map.entry_count).toBe(15);
    const hints = (body.hints as string[]).join(" ");
    expect(hints).toContain("arcrun");
    expect(hints).toContain("kbdb_search");
  });

  it("triplet_count>0 的庫不附「別當空庫」提示（不需要時別洗版）", async () => {
    const { server, tools } = makeServer();
    const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: DETAIL }))); // DETAIL.triplet_count=111
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
    const body = parseResult(res);
    const hints = (body.hints as string[]).join(" ");
    expect(hints).not.toContain("entry_count");
  });

  it("JSON-string slot values are parsed into objects（任務規格）; broken JSON → 空陣列", async () => {
    const { server, tools } = makeServer();
    const raw = {
      ...DETAIL,
      top_entities: '[{"name":"00-INDEX","degree":29}]',
      relation_profile: '[{"predicate":"連結至","count":48}]',
      bridges: "not-json{{", // parse 失敗 → 當空陣列，不 crash
      triplet_count: "111",
    };
    const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
    expect(res.isError).toBeUndefined();
    const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
    expect(map.top_entities).toEqual([{ name: "00-INDEX", degree: 29 }]);
    expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]);
    expect(map.bridges).toEqual([]);
    expect(map.triplet_count).toBe(111);
  });

  it("404 → map_not_found with recompute backfill hint（誠實回報）", async () => {
    const { server, tools } = makeServer();
    const { env } = makeEnv(
      () => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
    );
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
    expect(res.isError).toBe(true);
    const body = parseResult(res);
    expect(body.error_code).toBe("map_not_found");
    expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
    // 2026-08-08：404 現在的語意是「查無此庫」（地圖是即時核對重算的，已知庫即使空也回 200），
    // 不再是舊版那句「從未 recompute」的曖昧說法。
    expect(String(body.human_message)).toContain("查無庫");
    expect(String(body.human_message)).toContain("從沒出現過");
  });

  it("binding throws → internal_error, not an unhandled crash", async () => {
    const { server, tools } = makeServer();
    const env = {
      KBDB: {
        fetch: async () => {
          throw new Error("binding down");
        },
      },
    } as unknown as Env;
    registerGetMap(server, env, SERVICE);
    const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
    expect(res.isError).toBe(true);
    expect(parseResult(res).error_code).toBe("internal_error");
  });
});

// ── instructions 注入（design §4：拉不到＝靜默略過，絕不擋連線）────────────────────────
describe("buildLibraryMapInstructions", () => {
  beforeEach(() => __resetLibraryMapInstructionsCacheForTests());

  it("renders one compact line per library in the指定格式", async () => {
    const { env } = makeEnv(
      () =>
        new Response(
          JSON.stringify({
            success: true,
            libraries: [
              KB_ROW,
              { library: "notes", narrative: "手機隨手筆記", top_entities: ["Gitea"], triplet_count: 108 },
            ],
            count: 2,
          }),
        ),
    );
    const text = await buildLibraryMapInstructions(env, SERVICE);
    expect(text).not.toBeNull();
    // design §4 格式：{library}：{narrative}｜核心：{top3}｜{triplet_count} triplets
    expect(text!).toContain("kb：leo 的知識庫主庫｜核心：00-INDEX、kb/00-INDEX、Gitea｜111 triplets");
    expect(text!).toContain("notes：手機隨手筆記｜核心：Gitea｜108 triplets");
    // 導引句：把地圖跟 get_map 工具接起來（design §6 retrieval 流程）
    expect(text!).toContain("kbdb_get_map");
  });

  it("HTTP error → null（靜默略過，不 throw 不擋連線）", async () => {
    const { env } = makeEnv(() => new Response("boom", { status: 500 }));
    await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
  });

  it("binding throws → null（靜默略過）", async () => {
    const env = {
      KBDB: {
        fetch: async () => {
          throw new Error("kbdb down");
        },
      },
    } as unknown as Env;
    await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
  });

  it("empty libraries → null（沒地圖就不注入，不塞空段落）", async () => {
    const { env } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
    );
    await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
  });

  it("caches within TTL：same isolate 第二次不再打 /map", async () => {
    const { env, calls } = makeEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
    );
    const first = await buildLibraryMapInstructions(env, SERVICE);
    const second = await buildLibraryMapInstructions(env, SERVICE);
    expect(second).toBe(first);
    expect(calls).toHaveLength(1);
  });
});

describe("renderLibraryMapLines", () => {
  it("null narrative / string-form top_entities / string count 都容錯", () => {
    const text = renderLibraryMapLines([
      {
        library: "kb",
        narrative: null,
        top_entities: '["00-INDEX"]',
        triplet_count: "5",
      },
    ]);
    expect(text).toBe("- kb：（narrative 待補）｜核心：00-INDEX｜5 triplets");
  });

  it("empty input → null", () => {
    expect(renderLibraryMapLines([])).toBeNull();
  });

  it("Arcrun#87 三次收尾：triplet_count=0 但 entry_count>0 → 開場那行不再讀成「這庫沒有知識」", () => {
    // 這是 leo21c 的實際現況（kb 以外 7 庫皆此況）——這段渲染出的文字是全新 session 連上 MCP
    // 第一眼看到的地圖，過去只印「0 triplets」，讀起來像空庫，session 因此跳過不查。
    const text = renderLibraryMapLines([
      { library: "arcrun", narrative: null, top_entities: [], triplet_count: 0, entry_count: 15 },
    ]);
    expect(text).toBe("- arcrun：（narrative 待補）｜核心：（尚無）｜0 triplets／15 筆原始內容（尚未萃取關係，kbdb_search 查得到）");
  });

  it("triplet_count=0 且 entry_count=0（真的沒有任何內容）→ 誠實講兩個都是 0", () => {
    const text = renderLibraryMapLines([
      { library: "empty-lib", narrative: null, top_entities: [], triplet_count: 0, entry_count: 0 },
    ]);
    expect(text).toBe("- empty-lib：（narrative 待補）｜核心：（尚無）｜0 triplets／0 內容");
  });

  it("entry_count 缺席（舊部署未帶欄位）→ 容錯當 0，維持既有 triplet-only 措辭不 crash", () => {
    const text = renderLibraryMapLines([
      { library: "kb", narrative: "摘要", top_entities: ["A"], triplet_count: 5 },
    ]);
    expect(text).toBe("- kb：摘要｜核心：A｜5 triplets");
  });
});

// ── 2026-08-12：地圖也要跟著登入者的權限走 ────────────────────────────────────
// 地圖本身就是情報（有哪些庫、各有多少關聯、核心 entity 是誰）——不能整館推給
// 一個只有部分權限的帳號。
describe("藏書地圖：登入身分（portal 資料面）", () => {
  beforeEach(() => __resetLibraryMapInstructionsCacheForTests());

  it("kbdb_get_map 打 /portal/data/map，帶登入者 session，不碰 KBDB 服務金鑰", async () => {
    const { server, tools } = makeServer();
    const { env, calls } = makePortalEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
    );
    registerGetMap(server, env, PORTAL);
    const res = await tools.get("kbdb_get_map")!.handler({});
    expect(res.isError).toBeUndefined();
    expect(calls).toHaveLength(1);
    expect(calls[0].url.pathname).toBe("/portal/data/map");
    expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
  });

  it("呼叫端硬塞 owner_id 也不生效（查詢範圍由帳號權限決定，不由呼叫端指定）", async () => {
    const { server, tools } = makeServer();
    const { env, calls } = makePortalEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
    );
    registerGetMap(server, env, PORTAL);
    await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
    expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
  });

  it("查沒權限的庫 → 與「不存在」同一句話（不洩存在性）", async () => {
    const { server, tools } = makeServer();
    const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
    registerGetMap(server, env, PORTAL);
    const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
    expect(res.isError).toBe(true);
    const body = parseResult(res);
    expect(body.error_code).toBe("map_not_found");
    expect(String(body.human_message)).toContain("不在你被授權");
  });

  it("session 過期（401）→ session_expired，不說「地圖是空的」", async () => {
    const { server, tools } = makeServer();
    const { env } = makePortalEnv(
      () => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
    );
    registerGetMap(server, env, PORTAL);
    const res = await tools.get("kbdb_get_map")!.handler({});
    expect(res.isError).toBe(true);
    expect(parseResult(res).error_code).toBe("session_expired");
  });

  it("舊 token（沒身分）→ identity_missing，且一個查詢都不發（fail-closed）", async () => {
    const { server, tools } = makeServer();
    const { env, calls } = makePortalEnv(() => new Response("{}"));
    registerGetMap(server, env, STALE);
    const res = await tools.get("kbdb_get_map")!.handler({});
    expect(res.isError).toBe(true);
    expect(parseResult(res).error_code).toBe("identity_missing");
    expect(calls).toHaveLength(0);
  });

  it("instructions 的地圖也走 portal 資料面（連線開場推的庫名不得超出權限）", async () => {
    const { env, calls } = makePortalEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
    );
    const text = await buildLibraryMapInstructions(env, PORTAL);
    expect(text).toContain("kb");
    expect(calls[0].url.pathname).toBe("/portal/data/map");
  });

  it("**快取不跨身分共用**：不同 session 各自打一次，不會拿到別人的視野", async () => {
    const { env, calls } = makePortalEnv(
      () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
    );
    const other: KnowledgeIdentity = {
      kind: "portal",
      portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
    };
    await buildLibraryMapInstructions(env, PORTAL);
    await buildLibraryMapInstructions(env, other);
    expect(calls).toHaveLength(2); // 兩次真的各打一次
    await buildLibraryMapInstructions(env, PORTAL);
    expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
  });

  it("舊 token → 不給地圖（instructions 不外洩任何庫名）", async () => {
    const { env, calls } = makePortalEnv(() => new Response("{}"));
    expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
    expect(calls).toHaveLength(0);
  });
});
