/**
 * 藏書地圖（library-map）共用邏輯 — SDD M4（system-dev/docs/3-specs/library-map/design.md §4）
 *
 * 兩個 consumer 共用本檔：
 *   1. kbdb_get_map MCP tool（tools/kbdb_map.ts）— 薄殼呼 GET /map／/map/:library
 *   2. MCP server instructions 注入（mcp-handler.ts）— 連線時把全館地圖渲染成緊湊文字嵌入
 *
 * 走既有 KBDB service binding（kbdbFetch），不新增 binding、不碰 D1（薄殼鐵律）。
 *
 * 鐵律（design §4）：注入失敗絕不能讓 MCP 連線失敗——buildLibraryMapInstructions 任何錯誤
 * （超時/HTTP 錯/空庫/JSON 壞）一律回 null，caller 靜默略過，instructions 沒地圖照常可用。
 *
 * 快取抉擇（design §4 給了「快取＋TTL 或每次現拉」兩個選項，這裡選 isolate 內 TTL 快取）：
 * 本 MCP 用 stateless StreamableHTTP（sessionIdGenerator: undefined）——每個 HTTP request 都
 * 重建 McpServer，也就是說「每次現拉」實際上是每個 tool call 都多打一次 /map，不是每條連線一次。
 * 地圖只在 ingest recompute 後才變，所以用 module 層（isolate 內）TTL 快取：成功 5 分鐘、
 * 失敗 1 分鐘（避免 kbdb 掛掉時每個 request 都白等 timeout）。isolate 回收即自然失效，無需失效協議。
 */

import type { Env } from "../types.js";
import { kbdbFetch } from "./kbdb-client.js";
import { portalFetch, type KnowledgeIdentity } from "./portal-client.js";

/** 全館視圖一行（kbdb GET /map 的 libraries[] 元素；top_entities 已是 top-3 名字）。 */
export interface LibraryMapRow {
  library: string;
  narrative: string | null;
  top_entities: unknown; // 正常是 string[]；防禦：舊部署可能回 JSON 字串形
  triplet_count: number | string;
  // Arcrun#87 三次收尾（2026-08-13，「藏書地圖說實話」）：triplet_count＝0 不等於這庫沒有知識——
  // 可能只是三元組萃取從沒對它跑過，但 entries（原始 ingest 內容）還在，kbdb_search 找得到。
  // 選填是因為舊部署（未帶這次修法）的 kbdb base 回應裡不會有這個欄位，容錯當 0。
  entry_count?: number | string;
  updated_at?: number;
}

/**
 * slot 值容錯 parse：kbdb 正常已回 parsed 陣列，但 slot 底層存的是 JSON 字串
 * （live 曾觀測到字串形直出）——遇字串就 JSON.parse，parse 失敗/非陣列一律回空陣列（不 crash）。
 */
export function parseSlotArray<T>(raw: unknown): T[] {
  if (Array.isArray(raw)) return raw as T[];
  if (typeof raw === "string") {
    try {
      const v = JSON.parse(raw);
      return Array.isArray(v) ? (v as T[]) : [];
    } catch {
      return [];
    }
  }
  return [];
}

/** top_entities 統一成名字清單（元素可能是 "name" 字串或 {name, degree} 物件）。 */
export function entityNames(raw: unknown, limit: number): string[] {
  return parseSlotArray<unknown>(raw)
    .map((e) => {
      if (typeof e === "string") return e;
      if (e && typeof e === "object" && typeof (e as { name?: unknown }).name === "string") {
        return (e as { name: string }).name;
      }
      return null;
    })
    .filter((n): n is string => !!n)
    .slice(0, limit);
}

/** 全館地圖渲染上限：庫行數與 narrative 截斷長度（instructions 總量控制在數百 token，R3）。 */
const MAX_LIBRARY_LINES = 30;
const MAX_NARRATIVE_CHARS = 60;

/**
 * 把 GET /map 的 libraries[] 渲染成緊湊文字（每庫一行，design §4 指定格式）。空清單回 null。
 *
 * Arcrun#87 三次收尾（2026-08-13）：這是「開場那份地圖」的真身——一個全新 session 連上 MCP，
 * 這段文字就是它對藏書地圖的第一印象。過去只印 triplet_count，kb 以外幾乎全庫顯示「0 triplets」，
 * 讀起來像「這些庫沒有知識」，於是 session 直接跳過不查——但那 7 個庫的 entries（原始 ingest
 * 內容）其實都在，只是三元組萃取沒對它們跑過，kbdb_search 找得到真答案。現在 triplet_count＝0
 * 且 entry_count＞0 時明講「有內容、只是還沒萃取」，別再讓這句話變成瞎猜的理由。
 */
export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null {
  const rows = libraries.filter((l) => l && typeof l.library === "string" && l.library);
  if (rows.length === 0) return null;
  const lines = rows.slice(0, MAX_LIBRARY_LINES).map((l) => {
    const narrative = (l.narrative ?? "").trim() || "（narrative 待補）";
    const clipped =
      narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}…` : narrative;
    const core = entityNames(l.top_entities, 3);
    const tripletCount = Number(l.triplet_count ?? 0) || 0;
    const entryCount = Number(l.entry_count ?? 0) || 0;
    const countPart =
      tripletCount > 0
        ? `${tripletCount} triplets`
        : entryCount > 0
          ? `0 triplets／${entryCount} 筆原始內容（尚未萃取關係，kbdb_search 查得到）`
          : `0 triplets／0 內容`;
    return `- ${l.library}：${clipped}｜核心：${core.length ? core.join("、") : "（尚無）"}｜${countPart}`;
  });
  const omitted = rows.length > MAX_LIBRARY_LINES ? `\n（其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略，kbdb_get_map 可看全部）` : "";
  return lines.join("\n") + omitted;
}

/** 拉 /map 的逾時上限（ms）：instructions 是加分不是依賴，不值得讓連線多等。 */
const MAP_FETCH_TIMEOUT_MS = 1500;
/** 快取 TTL：成功 5 分鐘（地圖只在 ingest recompute 後變）、失敗/空 1 分鐘（別每 request 白等）。 */
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
const CACHE_TTL_FAIL_MS = 60 * 1000;

/**
 * 快取以「身分」分格（2026-08-12）。
 *
 * 為什麼不能共用一格：地圖本身就是情報（哪些庫存在、各有多少關聯、核心 entity 是誰）。
 * 以帳密連線時只該看到自己有權限的庫；若跟服務級連線共用同一格快取，先連上的那個人
 * 會把自己的視野留給下一個人——那是跨帳號外洩，不是效能問題。
 */
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();

/** 測試用：清掉 isolate 內快取（prod 不呼叫）。 */
export function __resetLibraryMapInstructionsCacheForTests(): void {
  instructionsCache.clear();
}

/**
 * 組 MCP server instructions 的藏書地圖段（design §4 / §6「session 啟動 → instructions 已含
 * 全館地圖（push 零查詢）」）。任何失敗（超時/HTTP 錯/空庫/壞 JSON）→ null（caller 靜默略過）。
 *
 * 以帳密連線（identity.kind === 'portal'）時走 cypher `/portal/data/map`——只拿得到這個
 * 帳號有權限的庫；服務級憑據維持既有 KBDB `/map` 直連。舊 token（stale）不給地圖。
 */
export async function buildLibraryMapInstructions(
  env: Env,
  identity: KnowledgeIdentity,
): Promise<string | null> {
  if (identity.kind === "stale") return null;
  // 快取 key：portal 用 session（＝這個人這次登入），service 用固定字串。
  // session token 只當 Map 的 key 活在 isolate 記憶體內，不落地、不寫 log。
  const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
  const now = Date.now();
  const hit = instructionsCache.get(cacheKey);
  if (hit && hit.expiresAt > now) return hit.text;

  let text: string | null = null;
  try {
    const res = await Promise.race([
      identity.kind === "portal"
        ? portalFetch(env, identity.portal.session, "/portal/data/map")
        : kbdbFetch(env, "/map"),
      new Promise<never>((_, reject) =>
        setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
      ),
    ]);
    if (res.ok) {
      const data = (await res.json()) as { libraries?: LibraryMapRow[] };
      const body = renderLibraryMapLines(Array.isArray(data.libraries) ? data.libraries : []);
      if (body) {
        text =
          "【藏書地圖】KBDB 全館現況（每庫一行）。查資料前先看這裡定位該進哪個庫；" +
          "需要某庫細節呼叫 kbdb_get_map(library)，不確定該查什麼時先呼叫 kbdb_get_map。\n" +
          body;
      }
    }
  } catch {
    // 鐵律：地圖是加分不是依賴——任何錯誤都不往外拋，instructions 沒地圖照常可用。
    text = null;
  }

  instructionsCache.set(cacheKey, {
    text,
    expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
  });
  // isolate 內的快取，不做失效協議；但別讓不同帳號的格子無上限長大（isolate 可活很久）。
  if (instructionsCache.size > 64) {
    for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
  }
  return text;
}
