/**
 * RAG Portal 查詢面 — P3：/portal/data/* server-side enforce（portal-auth design §3.3/§3.4/§5，
 * Gitea #24/#25）。本檔是本 SDD 的**安全核心**。
 *
 * 安全模型（design §3.3，與 console 的關鍵差異）：
 *   - console 登入後把 CONSOLE_TENANT 下發給前端直打 /kbdb/*；**portal 前端絕不持有租戶字串**，
 *     只有 portal session token。portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/search，
 *     所以 enforce 全在 server：session → 回讀 user record（唯一真相源）→ 取 libraries →
 *     server 注入 owner_id＋library 後轉發 KBDB。
 *   - caller 自帶的 owner_id / library query 參數**一律忽略**（不是拒絕——拒絕會變成
 *     「試參數名」的 oracle；直接靜默覆蓋，怎麼傳都是自己的權限範圍）。
 *   - ["*"]＝全庫：只注 owner_id、不注 library（design §3.3）。
 *
 * 不洩存在性（紅線）：越庫的 entry（含根本不存在的 id、別的租戶的 id）一律回**同一句 404**，
 * 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外＝graph 粗閘按 SDD 明定回 403（D-4）。
 *
 * 薄殼（rule 07）：本檔沒有新能力——搜尋/取條目能力真身在 KBDB base（P1 的 library filter），
 * graph 真身在 kbdb-graph-plugin，工作流真身在 WEBHOOKS/ANALYTICS KV（與 /webhooks/named、
 * /workflows/:name/executions 同一資料源）。這裡只做「權限注入＋轉發/讀取」。
 *
 * log 紅線：本檔不 log 任何 token / 密碼 / 查詢內容。
 */
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
// Arcrun#108：知識資料面的租戶字串只有一個產地（lib/tenant.ts）。這裡刻意**不再** import
// portalTenant——它是帳號層的值（回 string 不是 TenantId），拿來過濾知識就是本票的病。
import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant';
import { graphBase, graphHeaders } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';

export const portalDataRouter = new Hono<{ Bindings: Bindings }>();

// ── tenant workflow in-process 執行（portal-demo-suite）───────────────────────
//
// 為什麼：RAG self-hosted 實例不部署 kbdb-graph-plugin worker（graphBase 直連會 1042，
// Arcrun#57）；graph/chat 這類查詢能力在該實例是以 tenant 的 named workflow 形式存在
//（WEBHOOKS KV `{tenant}:wf:{name}`，與 /webhooks/named 同一資料源）。
// 怎麼接：直接 import executeWebhookGraph（webhooks-named.ts trigger/query 端點同一個
// 執行入口）在 **本 worker 進程內** 執行——絕不 fetch 自己的 hostname
//（global_fetch_strictly_public 下打自己＝self-loop，見 /portal/data/workflows 同款註解）。

/** 讀 tenant 的 named workflow graph（`{tenant}:wf:{name}`）。不存在/壞 record → null。 */
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
  // #108：workflow 是 CLI `acr push` 用實例 namespace 寫進來的（`{ns}:wf:*`），
  // 所以讀的時候也要用同一個 namespace，不是帳號層那個字串。
  const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text');
  if (!raw) return null;
  try {
    const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
    return rec.graph && typeof rec.graph === 'object' ? rec.graph : null;
  } catch {
    return null; // 壞 record 視同不存在（呼叫端各自誠實回報）
  }
}

/** workflow 最終節點輸出常見兩形：本體即結果，或再包一層 data（http_request 類零件慣例）。取有目標欄位的那層。 */
function unwrapWorkflowData(data: unknown, key: string): Record<string, unknown> {
  const outer = data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
  if (key in outer) return outer;
  const inner = outer.data;
  if (inner && typeof inner === 'object' && key in (inner as Record<string, unknown>)) {
    return inner as Record<string, unknown>;
  }
  return outer;
}

/**
 * graph_neighbors workflow 輸出 → 與 kbdb-graph-plugin 相同的回應形狀 {neighbors, edges, count}
 *（前端 doGraphSearch 讀 neighbors/edges；count＝neighbors 數，重算不信 workflow 自報）。
 * 純函式（單測用 export）。
 */
export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; edges: unknown[]; count: number } {
  const layer = unwrapWorkflowData(data, 'neighbors');
  const neighbors = Array.isArray(layer.neighbors) ? layer.neighbors : [];
  const edges = Array.isArray(layer.edges) ? layer.edges : [];
  return { neighbors, edges, count: neighbors.length };
}

/**
 * 出處清單按 page_name 去重（t129）：
 * rag_chat workflow 把同一張卡拆成多個 block，每個 block 各回一筆 source（同頁名）→ 前端列一整頁重複。
 * 後端去重：同一個 page_name / page 只保留第一筆，hit_count > 1 時附計數。
 * page_name 優先；page 備用；兩者皆無 → key 為空字串（歸為同一「無頁名」組）。
 * 純函式，單測用 export。
 */
export function dedupeSourcesByPage(sources: unknown[]): unknown[] {
  const seen = new Map<string, { item: Record<string, unknown>; count: number }>();
  for (const s of sources) {
    if (!s || typeof s !== 'object') continue;
    const item = s as Record<string, unknown>;
    const page = typeof item.page_name === 'string' ? item.page_name :
                 typeof item.page === 'string' ? item.page : '';
    const existing = seen.get(page);
    if (existing) {
      existing.count += 1;
    } else {
      seen.set(page, { item, count: 1 });
    }
  }
  return [...seen.values()].map(({ item, count }) =>
    count > 1 ? { ...item, hit_count: count } : item,
  );
}

/** 越庫/不存在 一律同一句 404（不洩存在性）。 */
function notFound(c: Context<{ Bindings: Bindings }>): Response {
  return c.json({ error: '找不到這筆資料' }, 404);
}

/** entry 的庫歸屬：metadata_json.$.library，未標記 → 'general'（design §3.2 fallback，與 KBDB P1 同語意）。 */
export function entryLibrary(entry: { metadata_json?: string | null }): string {
  try {
    const meta = JSON.parse(entry.metadata_json ?? 'null') as { library?: unknown } | null;
    if (meta && typeof meta.library === 'string' && meta.library.trim()) return meta.library;
  } catch {
    /* metadata 壞掉 → 視同未標記 */
  }
  return 'general';
}

/** 用戶庫集合是否覆蓋某庫（["*"]＝全庫）。 */
function canReadLibrary(userLibraries: string[], library: string): boolean {
  return userLibraries.includes('*') || userLibraries.includes(library);
}

/**
 * 搜尋殘影過濾（**Arcrun#46 上游修好前的 portal 端治標**——舊管線的 deprecated 產物還躺在
 * KBDB 裡污染搜尋結果；根治＝上游清資料/重建索引，那修好後這段可整段拔掉）。
 * 濾掉：metadata_json.status === 'deprecated' 的 entry、content 以「（舊管線產物」開頭的 entry、
 * 內部型別 entry（value＝slot 值外漏的無標題雜項列、workflow＝工作流定義——都是系統內部件，
 * 不是給搜尋用戶看的內容；2026-07-18 leo 客戶測試回饋「搜尋結果偶見無標題雜項列」）。
 * metadata_json parse 失敗 → 視為保留（治標不誤殺；壞 metadata ≠ deprecated）。
 * 純函式（單測用 export）。
 */
// execution_log/execution_log_usage（KV 額度事故修復，2026-08-07）：workflow 執行紀錄與其內部
// 用量計數器，entry_type 與既有 value/workflow 同層級的內部型別——一併排除，避免用戶搜尋知識時
// 混進執行 log（同層防線：本模組也從不設 metadata_json.embed=true，永不進語意搜尋索引）。
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);

export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
  entries: T[],
): T[] {
  return entries.filter((e) => {
    if (e.entry_type && INTERNAL_ENTRY_TYPES.has(e.entry_type)) return false;
    try {
      const meta = JSON.parse(e.metadata_json ?? 'null') as { status?: unknown } | null;
      if (meta && meta.status === 'deprecated') return false;
    } catch {
      /* parse 失敗 → 保留 */
    }
    if (String(e.content ?? '').startsWith('（舊管線產物')) return false;
    return true;
  });
}

/**
 * CJK/ASCII 邊界插空白正規化（t95）：
 * 「AI協作」→「AI 協作」；「協作AI」→「協作 AI」；已有空白不重複插。
 * 只動查詢端，不動索引端。純函式，單測用 export。
 */
export function normalizeCjkQuery(q: string): string {
  // U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat.
  const isCjk = (c: string) => /[぀-鿿豈-﫿]/.test(c);
  const isAsciiAlnum = (c: string) => /[぀-鿿豈-﫿]/.test(c);
  let result = '';
  for (let i = 0; i < q.length; i++) {
    const ch = q[i];
    if (result.length > 0) {
      const prev = result[result.length - 1];
      if (prev !== ' ' && ch !== ' ' &&
          ((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
        result += ' ';
      }
    }
    result += ch;
  }
  return result;
}

/**
 * 從三元組節點名清單找最佳比對（t96 fuzzy fallback 用）：
 * 正規化後做 contains 比對；多命中取最短名（前綴/最精確優先）。純函式，單測用 export。
 */
export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
  const term = normalizeCjkQuery(searchTerm).toLowerCase();
  if (!term) return null;
  const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
  if (hits.length === 0) return null;
  return hits.reduce((a, b) => a.length <= b.length ? a : b);
}

/**
 * 三元組條數（KBDB `/records/triplet-stats` 真 SQL COUNT）。owner 傳 '' ＝不限租戶（KBDB 端
 * `?1 = '' OR e.owner_id = ?1`）。null＝讀不到——caller 據此不敢宣稱 0。
 */
async function tripletCount(env: Bindings, owner: TenantId | null): Promise<number | null> {
  try {
    // owner=null ＝ 普查全庫（#100 用來分辨「查不到」與「沒有」）。這是唯一一個
    // 刻意不帶租戶範圍的查詢，因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。
    const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
    if (!res.ok) return null;
    const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
    if (!body || !Array.isArray(body.stats)) return null;
    let total = 0;
    for (const row of body.stats) {
      if (typeof row?.triplet_count !== 'number') return null;
      total += row.triplet_count;
    }
    return total;
  } catch {
    return null;
  }
}

/**
 * 三元組普查（Arcrun#100）——回答總圖那句「知識庫還沒有任何關聯」到底能不能講。
 *
 * leo 的原則（已寫在 portal.ts §②.5 daemon diagnostics）：**「不要讓『查不到』和『沒有』
 * 長得一樣」**。t161 前科：手補的 record owner_id 存成 None ⇒ 全量查得到、按 owner_id 過濾
 * 的畫面永遠空——比真的沒資料更難查。所以本租戶數為 0 時**再花一次查詢換一條路徑**
 * （同一支端點但不帶 owner），問「這個庫到底有沒有三元組」：
 *   owned>0                → 有資料
 *   owned=0 且 any=0       → 真的空（此時、也只有此時，畫面才准印 0）
 *   owned=0 但 any>0       → owner_id / 範圍對不上，不是空庫 → 畫面說讀不到
 *   owned=null             → 讀不到 → 畫面說讀不到
 */
async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> {
  const owned = await tripletCount(env, tenant);
  if (owned !== 0) return { owned, any: null }; // 非 0（含 null）不必多問一次
  return { owned, any: await tripletCount(env, null) };
}

/** 從 KBDB triplet records 找最佳比對節點名（t96 plugin fuzzy fallback 用）。 */
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
  try {
    const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`);
    if (!res.ok) return null;
    const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
    if (!body || !Array.isArray(body.records)) return null;
    const nodeNames = new Set<string>();
    for (const r of body.records) {
      const v = r?.values;
      if (!v || typeof v !== 'object') continue;
      if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
      if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
    }
    return findBestNodeMatch(searchTerm, [...nodeNames]);
  } catch {
    return null; // fallback 失敗靜默略過，原本 0 結果直接回
  }
}

// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
//（graph 走 /portal/data/graph/*）。server 注入 owner_id＋library；回應照 KBDB 原形
//（entries 含 metadata_json，前端自取 source 溯源；mode/capability_hint 誠實透傳——
// semantic 未開的降級行為沿 KBDB 既有，P1 已保 library 照 enforce）。
portalDataRouter.get('/portal/data/search', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const qRaw = c.req.query('q');
    if (!qRaw) return c.json({ error: 'q 必填' }, 400);
    const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白（只動查詢端）

    const libraries = parseLibraries(auth.user.values.libraries);
    if (libraries.length === 0) {
      // 帳號沒被授權任何庫：誠實空結果（不打 KBDB——沒有可查範圍就沒有查詢）
      return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫，請聯絡管理員。' });
    }

    const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
    if (!libraries.includes('*')) params.set('library', libraries.join(','));
    // 透傳的只有「在權限範圍內再收窄」的 filter；owner_id/library 上面已由 server 定死，
    // caller 傳什麼都不看（URLSearchParams 是新建的，蓋不掉）。
    if (c.req.query('mode') === 'semantic') {
      params.set('mode', 'semantic');
      // 🔴 t183（leo 08-04 實撞：「語義搜尋搜到一大堆不相關的內容」
      //    ——搜「火星座標」卻跑出 n8n 版本比較表、Leo 填答）：
      //    Vectorize 會**硬湊滿 topK 筆**，湊不到就把低分的塞進來 ⇒ 尾巴全是無關內容。
      //    kbdb 早就支援 min_score（`kbdb/src/embed.ts:225`，issue #67），
      //    但 portal **從來沒傳** ⇒ 等同沒有閾值，低分尾全端到用戶面前。
      //
      //    0.75 怎麼來的（**實測分數分布，不是猜的**；youlin 實例搜「火星座標 奧林帕斯山」）：
      //      0.908 / 0.881 / 0.881 / 0.880 / 0.870 / 0.815 / 0.798 / 0.787 ← 全是火星座標，真相關
      //      ─────────────────────── 斷崖 ───────────────────────
      //      0.742 姨媽說故事 ／ 0.740 ax-academy ／ 0.739×8 n8n 版本比較表 ← 全是雜訊
      //    斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75：相關的全留、雜訊全砍。
      //
      //    允許前端覆寫（想放寬看更多可傳 min_score），但**不接受 0／負數**
      //    ——那等於關掉閾值，正是 t183 要修的病本身。
      //
      // 🔴 2026-08-05 修正（leo 實撞「語義搜尋 0 命中」）：**這裡不再硬寫預設值**。
      //    上面 0.75 是照**舊模型 bge-base-en-v1.5** 的分數分布定的；08-05 換 bge-m3 後
      //    分數尺度整體下移，0.75 砍掉的變成正解 ⇒ 新上傳的檔一律 0 命中。
      //    根因＝**閾值是模型的性質，卻被複製到呼叫端**，換模型時沒人想到要回來改這行。
      //    ⇒ 預設值移到 `kbdb/src/embed.ts` 的 `DEFAULT_MIN_SCORE`（緊鄰 DEFAULT_EMBED_MODEL），
      //      portal 只在**使用者顯式指定**時才傳。**不要把數字搬回來。**
      const msRaw = Number(c.req.query('min_score'));
      if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw));
    }
    const entryType = c.req.query('entry_type');
    if (entryType) params.set('entry_type', entryType);
    const limit = c.req.query('limit');
    if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit);

    const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`);
    if (!res.ok) {
      // 錯誤回應照原樣透傳（誠實，不加工）
      return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
    }
    // Arcrun#46 治標：server-side 濾掉舊管線 deprecated 殘影再回（count 重算）。
    // 上游修好（清資料/重建索引）後，這段連同 filterDeprecatedEntries 一起拔掉。
    const body = (await res.json().catch(() => null)) as
      | { entries?: { metadata_json?: string | null; content?: string | null }[]; count?: number }
      | null;
    if (!body || !Array.isArray(body.entries)) {
      // 回應不是預期形狀 → 照原樣回（不因治標把正常錯誤形狀吃掉）
      return c.json(body ?? { error: 'KBDB 回應不是 JSON' }, body ? 200 : 502);
    }
    const entries = filterDeprecatedEntries(body.entries);
    return c.json({ ...body, entries, count: entries.length });
  }),
);

// GET /portal/data/entries/:id — 卡片詳頁。**逐筆驗庫**（design §5）：
//   ① entry 必須屬於本實例租戶（owner_id=CONSOLE_TENANT）——防拿別租戶 id 直讀；
//   ② entry 的 library（NULL→general）必須在用戶庫集合內——防拿越庫 id 直讀。
// 兩者不符與不存在同回 404（不洩存在性）。
portalDataRouter.get('/portal/data/entries/:id', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const libraries = parseLibraries(auth.user.values.libraries);
    if (libraries.length === 0) return notFound(c);

    const res = await kbdbFetch(c.env, `/entries/${encodeURIComponent(c.req.param('id'))}`);
    if (res.status === 404) return notFound(c);
    if (!res.ok) return c.json({ error: `KBDB 回錯（HTTP ${res.status}）` }, 502);
    const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
    const entry = body.entry;
    if (!entry) return notFound(c);
    if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
    if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
    return c.json({ success: true, entry });
  }),
);

// GET /portal/data/graph/neighbors/:name — graph 模式（D-4 粗閘）：
// 只對「擁有 graph 來源庫權限」的用戶開放；無權 → 403（SDD 明定，graph 粗閘是 404 紅線的例外）。
// 放行後的資料源二選一（Arcrun#57）：
//   ① tenant 有 `{tenant}:wf:graph_neighbors` workflow → in-process 執行它（RAG self-hosted
//      實例不部署 kbdb-graph-plugin，直連會 1042），輸出映射成與 plugin 相同形狀；
//   ② 沒有 → fallback 原本 graphBase 直連 plugin（Mira/leo21c 實例相容，行為一字不變）。
portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const libraries = parseLibraries(auth.user.values.libraries);
    if (!(await hasGraphAccess(c.env, libraries))) {
      return c.json({ error: '無知識圖譜檢視權限' }, 403);
    }

    // t95/t96: CJK 正規化後再用（避免「AI協作」找不到「AI 協作」節點）
    const nodeName = normalizeCjkQuery(c.req.param('name'));

    // ① tenant workflow 路徑（存在才走；input：node=path、depth=query 預設 2、namespace/owner=tenant）
    const tenant = knowledgeOwner(c.env);
    const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
    if (wfGraph) {
      const depthRaw = c.req.query('depth') ?? '';
      const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2;
      const result = await executeWebhookGraph(
        c.env,
        wfGraph,
        // t116: 補傳 kbdb_base；t128: 補傳 template（workflow fetch_triplets.url 用 {{input.template}}）
        { node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' },
        'graph_neighbors',
        tenant,
        c.executionCtx,
      );
      if (!result.success) {
        // workflow 執行失敗 → 誠實 502（不假裝無關聯）
        return c.json({ error: `graph_neighbors workflow 執行失敗：${result.error ?? '未知錯誤'}` }, 502);
      }
      return c.json(mapGraphWorkflowOutput(result.data));
    }

    // ② plugin fallback（Mira/leo21c 相容）
    const base = graphBase(c.env);
    const headers = graphHeaders(c.env);
    try {
      const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
      if (!res.ok) {
        return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
      }
      // t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名（如「AI 協作」→「AI 協作規範書」）
      const resText = await res.text().catch(() => '');
      let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
      try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
      if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
          Array.isArray(data.edges) && data.edges.length === 0) {
        const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
        if (fallbackName && fallbackName !== nodeName) {
          const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
          return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
        }
      }
      return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
    } catch (e) {
      // plugin 沒部署/不可達 → 誠實 502（前端顯示「關聯服務不可達」，不假裝無關聯）
      return c.json({ error: `kbdb-graph-plugin 不可達：${e instanceof Error ? e.message : String(e)}` }, 502);
    }
  }),
);

// GET /portal/data/graph/overview — 書庫總圖（Arcrun#39 藏書地圖的 GUI 資料切片）：
// 全租戶 active 三元組 → {nodes:[{name,degree}], edges:[{subject,predicate,object}]}。
// 權限＝與 neighbors 同一道 D-4 graph 粗閘；資料直讀 KBDB records（與 search 同 kbdbFetch 路徑，
// 不經 graph plugin、不撞 1042）。邊數上限 500（demo 量級遠低於此；超限誠實截斷並回 truncated）。
portalDataRouter.get('/portal/data/graph/overview', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const libraries = parseLibraries(auth.user.values.libraries);
    if (!(await hasGraphAccess(c.env, libraries))) {
      return c.json({ error: '無知識圖譜檢視權限' }, 403);
    }
    const tenant = knowledgeOwner(c.env);
    const [res, census] = await Promise.all([
      kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`),
      tripletCensus(c.env, tenant),
    ]);
    const tripletsTotal = census.owned;
    if (!res.ok) {
      return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
    }
    const body = (await res.json().catch(() => null)) as
      | { records?: { values?: Record<string, unknown> }[] }
      | null;
    // #100：形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖，
    // 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
    if (!body || !Array.isArray(body.records)) {
      return c.json({ error: '三元組讀取失敗：KBDB 回應不是預期的 records 清單' }, 502);
    }
    const records = body.records;
    const EDGE_CAP = 500;
    const seen = new Set<string>();
    const edges: { subject: string; predicate: string; object: string }[] = [];
    const degree = new Map<string, number>();
    let truncated = false;
    for (const r of records) {
      const v = r && typeof r.values === 'object' && r.values ? r.values : null;
      if (!v) continue;
      if (v.status === 'deprecated') continue;
      const s = typeof v.subject === 'string' ? v.subject.trim() : '';
      const o = typeof v.object === 'string' ? v.object.trim() : '';
      if (!s || !o) continue;
      const p = typeof v.predicate === 'string' ? v.predicate : '';
      const key = `${s}${p}${o}`;
      if (seen.has(key)) continue;
      seen.add(key);
      if (edges.length >= EDGE_CAP) { truncated = true; break; }
      edges.push({ subject: s, predicate: p, object: o });
      degree.set(s, (degree.get(s) ?? 0) + 1);
      degree.set(o, (degree.get(o) ?? 0) + 1);
    }
    const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
    // #100：一張空圖有三種成因，前端必須分得出來（判準留在 server，不留給前端猜）——
    //   confirmed_empty  ：本租戶真的一條都沒有，全庫也沒有 → 才准印「0 個實體・0 條關聯」
    //   scope_mismatch   ：全庫有、本租戶查不到 → owner_id/範圍對不上，不是空庫（t161 前科）
    //   unreadable       ：連條數都讀不到 → 只能說讀不到
    let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
    if (nodes.length === 0) {
      if (census.owned === null) emptyReason = 'unreadable';
      else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
      else if (census.any === null) emptyReason = 'unreadable';
      else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
    }
    return c.json({
      nodes,
      edges,
      node_count: nodes.length,
      edge_count: edges.length,
      // 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分，別讓 meta 看起來像全部
      truncated: truncated || records.length >= 500,
      triplets_total: tripletsTotal,
      empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
      empty_reason: emptyReason,
    });
  }),
);

// GET /portal/data/chat?question=... — AI 問答（portal-demo-suite）。
// 設計哲學：AI 檢索＝用戶手動搜尋同一套——同 search 的 requirePortalUser 閘、同一個租戶資料面，
// 只是把「人下關鍵字」換成「workflow 代查再作答」；前端不因走 AI 多拿任何權限。
// 機制同 graph_neighbors：in-process 執行 tenant 的 rag_chat workflow（executeWebhookGraph，
// 絕不 fetch 自己 hostname）；workflow 不存在 → 誠實 404，不假裝這實例有問答能力。
portalDataRouter.get('/portal/data/chat', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const question = c.req.query('question');
    if (!question) return c.json({ error: 'question 必填' }, 400);

    const wfGraph = await getTenantWorkflowGraph(c.env, 'rag_chat');
    if (!wfGraph) return c.json({ error: '此實例未安裝問答 workflow' }, 404);

    const result = await executeWebhookGraph(
      c.env,
      wfGraph,
      { question },
      'rag_chat',
      knowledgeOwner(c.env),
      c.executionCtx,
    );
    if (!result.success) {
      // workflow 執行失敗 → 誠實 502（不把錯誤編成答案）
      return c.json({ error: `rag_chat workflow 執行失敗：${result.error ?? '未知錯誤'}` }, 502);
    }
    // 回 workflow 回應內層 data：{answer, sources, graph_facts}（缺欄位誠實回空，不編造）
    // t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆，前端列一整頁重複；後端去重後乾淨。
    const inner = unwrapWorkflowData(result.data, 'answer');
    const rawSources = Array.isArray(inner.sources) ? inner.sources : [];
    return c.json({
      answer: typeof inner.answer === 'string' ? inner.answer : '',
      sources: dedupeSourcesByPage(rawSources),
      graph_facts: inner.graph_facts ?? null,
    });
  }),
);

// ── 上傳（portal-demo-suite）────────────────────────────────────────────────

/**
 * 上傳檔名驗證（純函式，單測用 export）：
 *   - 去路徑分隔（/ \）只取最後一段（擋 ../ 穿越）、去控制字元；
 *   - 空名/以 . 開頭（隱藏檔/./..）→ 無效（null）；
 *   - 強制 .md 結尾（.txt 改副檔名、其餘直接補 .md——上傳面收的是知識文件，一律當 markdown 收件）；
 *   - 最終長度限 100（含副檔名），超過 → 無效。
 */
export function sanitizeUploadFilename(raw: unknown): string | null {
  if (typeof raw !== 'string') return null;
  let name = (raw.split(/[/\\]/).pop() ?? '').trim();
  // eslint-disable-next-line no-control-regex
  name = name.replace(/[\u0000-\u001f\u007f]/g, '');
  if (!name || name.startsWith('.')) return null;
  if (/\.txt$/i.test(name)) name = name.replace(/\.txt$/i, '.md');
  if (!/\.md$/i.test(name)) name = `${name}.md`;
  if (name.length > 100) return null;
  return name;
}

// base64 內容上限（收 .md/.txt 知識文件，2 MiB 原文 ≈ 2.7 MB base64 已綽綽有餘；防拿上傳面塞大檔）
const MAX_UPLOAD_B64_CHARS = 3 * 1024 * 1024;

// POST /portal/data/upload — body {filename, content_b64}（portal-demo-suite）。
// requirePortalUser 閘；server-side POST Gitea contents API 寫進 PORTAL_UPLOAD_REPO 的
// docs/{filename}——token 只在 server 側，前端永遠拿不到（同 kbdb-proxy token 慣例）。
// 未設 upload bindings（三者任一缺）→ 404「未啟用上傳」（Mira 零影響：功能不存在）。
portalDataRouter.post('/portal/data/upload', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    if (!uploadEnabled(c.env)) return c.json({ error: '此實例未啟用上傳' }, 404);

    const body = await c.req.json().catch(() => null) as { filename?: unknown; content_b64?: unknown } | null;
    const filename = sanitizeUploadFilename(body?.filename);
    if (!filename) return c.json({ error: 'filename 無效（不可含路徑、不可空、長度限 100）' }, 400);
    const contentB64 = body?.content_b64;
    if (typeof contentB64 !== 'string' || !contentB64) return c.json({ error: 'content_b64 必填' }, 400);
    if (contentB64.length > MAX_UPLOAD_B64_CHARS) return c.json({ error: '檔案過大（上限約 2 MB）' }, 413);

    const base = (c.env.PORTAL_UPLOAD_GITEA ?? '').replace(/\/$/, '');
    const repo = c.env.PORTAL_UPLOAD_REPO ?? '';
    let res: Response;
    try {
      res = await fetch(`${base}/api/v1/repos/${repo}/contents/docs/${encodeURIComponent(filename)}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `token ${c.env.PORTAL_UPLOAD_TOKEN}`,
        },
        body: JSON.stringify({
          content: contentB64,
          // commit 訊息帶上傳者（display_name 非機密），收件溯源用；不進任何內容
          message: `portal 上傳：docs/${filename}（${auth.user.values.display_name ?? 'portal user'}）`,
        }),
      });
    } catch (e) {
      return c.json({ error: `知識庫收件服務不可達：${e instanceof Error ? e.message : String(e)}` }, 502);
    }
    if (res.status === 409 || res.status === 422) {
      // Gitea 同 path 已存在（版本差異回 409 或 422）→ 統一誠實回 409
      return c.json({ error: '同名文件已存在，請改檔名後重傳' }, 409);
    }
    if (!res.ok) {
      return c.json({ error: `知識庫收件失敗（HTTP ${res.status}）` }, 502);
    }
    return c.json({ success: true, filename, path: `docs/${filename}` });
  }),
);

// GET /portal/data/workflows — 工作流顯示（D-8）：唯讀 list＋每條的最近一次執行，**不開 trigger**
//（trigger 是 owner/console 的事；回應也不含 webhook_url，不給可打的把手）。
// 可見性：PORTAL_SHOW_WORKFLOWS=admin（預設，role 閘 403）/ all / off（整頁不存在 → 404）。
portalDataRouter.get('/portal/data/workflows', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const setting = (c.env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
    if (setting === 'off') return notFound(c);
    if (!workflowsVisible(c.env, auth.user.values.role ?? 'user')) {
      return c.json({ error: '需要 admin 權限' }, 403);
    }

    // 資料源與 /webhooks/named ＋ /workflows/:name/executions 同一份（WEBHOOKS/ANALYTICS KV）。
    // 不經 HTTP 打自己（global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop），
    // 直讀同 worker 的 KV binding；欄位收斂成唯讀展示需要的最小集合。
    const tenant = knowledgeOwner(c.env);
    const prefix = `${tenant}:wf:`;
    const list = await c.env.WEBHOOKS.list({ prefix });
    const workflows = await Promise.all(
      list.keys.map(async (k) => {
        const name = k.name.slice(prefix.length);
        const raw = await c.env.WEBHOOKS.get(k.name, 'text');
        let description = '';
        let created_at = '';
        let cron_expr: string | undefined;
        if (raw) {
          try {
            const rec = JSON.parse(raw) as { description?: string; created_at?: string; cron_expr?: string };
            description = rec.description ?? '';
            created_at = rec.created_at ?? '';
            cron_expr = rec.cron_expr;
          } catch {
            /* 壞 record 誠實留空 */
          }
        }
        // 最近一次執行：KV 額度事故修復（2026-08-07）改打 KBDB GET /execution-log/latest
        // （原走 ANALYTICS_KV stats:{name}:* list，免費層 list 也是 1,000/日）。KBDB＝
        // API-as-Wall：不直連 D1，走既有 kbdbFetch（本檔已在用，見上方 import）。
        let last_execution: { timestamp: string; verdict?: string } | null = null;
        const execRes = await kbdbFetch(
          c.env,
          `/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`,
        );
        const execBody = await execRes.json().catch(() => null) as {
          success?: boolean;
          execution?: { verdict: string; recorded_at: number } | null;
        } | null;
        if (execRes.ok && execBody?.success && execBody.execution) {
          last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict };
        }
        return { name, description, created_at, cron_expr, last_execution };
      }),
    );
    return c.json({ success: true, workflows, total: workflows.length, read_only: true });
  }),
);

// ═══════════════════════════════════════════════════════════════════════════
// 授權的 AI（arcrun-mcp）走的資料面 — 與人類 portal 同一道閘、同一份權限
// ═══════════════════════════════════════════════════════════════════════════
//
// leo 2026-08-12：「人類進 Portal 輸入帳密表示你是主人，可以查到你權限所有東西；
//   AI 透過輸入帳密的 MCP 查詢表示是授權的 AI，可以查到主人允許查的任何東西。」
//   「掛上 MCP 並輸入帳密，那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
//
// 之前的病：MCP 驗完帳密只留下一個布林值，身分當場丟掉（oauth/routes.ts 舊 `loginOk = res.ok`），
// 於是查詢時只好去找一把**服務內部金鑰**（KBDB_INTERNAL_TOKEN）直打 KBDB——
// 那條路繞過了本檔上半部所有的庫過濾，等於「誰登入都看到同一格、而且是全部」。
//
// 修法＝MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支：
//   ① 一律 requirePortalUser（session → 回讀 user record → 停用即時生效），
//   ② owner_id / library 由 server 注入，**呼叫端傳什麼都不看**（與上半部同一條紅線：
//      呼叫端自己帶租戶字串＝繞過庫過濾），
//   ③ 越權與不存在同回 404（不洩存在性）。
//
// 薄殼（rule 07）：這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤，
// 本段只做「權限注入＋轉發」，與上半部 search/entries 一模一樣的做法。

/**
 * record 的庫歸屬。與 entry 不同：**沒有 `library` slot 的 record 不套庫過濾**。
 *
 * 為什麼不比照 entry 用 'general' fallback：entry 是知識內容（庫是它的第一屬性，沒標就歸
 * general 是對的）；record 是結構化資料列（contact / workflow_metadata / triplet…），
 * 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback，
 * 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺，不是隔離。
 * 租戶邊界仍然守著（owner_id 由 server 注入/逐筆比對），這裡只多守「有標庫的別越庫」。
 */
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
  const lib = values?.library;
  return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
}

/** record 可讀？租戶要對；有標 library 的還要在用戶庫集合內。 */
function canReadRecord(
  rec: { values?: Record<string, unknown>; owner_id?: string | null },
  tenant: TenantId,
  libraries: string[],
): boolean {
  if (!isOwnedBy(rec.owner_id, tenant)) return false;
  const lib = recordLibrary(rec.values);
  return lib === null || canReadLibrary(libraries, lib);
}

// GET /portal/data/map — 藏書地圖全館視圖，**只回這個帳號有權限的庫**。
// KBDB 的 /map 對權限無知（它回全館），過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
//
// 🔴 Arcrun#108：一張空地圖有四種成因，**判準留在 server，不留給前端猜**
//    （沿 #100 總圖那條「讀不到就說讀不到」，同一套 census 機制）：
//      no_library_grant ：這個帳號一個庫都沒被授權 → 是權限問題，不是資料問題
//      filtered_out     ：實例有庫，但都不在這個帳號的權限內 → 正常且正確的隔離
//      confirmed_empty  ：實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」
//      scope_mismatch   ：實例有三元組，但本命名空間一條都撈不到 → **命名空間對不上**
//                        （就是本票：1854 條在 bfezv28v，卻拿 "leo" 去過濾）
//    scope_mismatch 這一格以前不存在，所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。
//
// ⚠️ 回應**絕不含租戶字串**（design §3.3 紅線：前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*）。
//    只回代碼與數字，文字說明講「請通知管理員」，命名空間本身不下發。
portalDataRouter.get('/portal/data/map', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const libraries = parseLibraries(auth.user.values.libraries);
    if (libraries.length === 0) {
      return c.json({
        success: true, libraries: [], count: 0,
        empty_confirmed: true, empty_reason: 'no_library_grant',
        note: '此帳號尚未被授權任何知識庫，請聯絡管理員。',
      });
    }
    const tenant = knowledgeOwner(c.env);
    const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);
    if (!res.ok) {
      return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
    }
    const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
    if (!body || !Array.isArray(body.libraries)) {
      return c.json({ error: '藏書地圖讀取失敗：KBDB 回應不是預期的 libraries 清單' }, 502);
    }
    const allowed = body.libraries.filter(
      (l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
    );
    if (allowed.length > 0) {
      return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
    }
    // 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由，不猜。
    if (body.libraries.length > 0) {
      // 命名空間對得上（撈得到庫），只是這個帳號沒有那些庫的權限＝隔離正常運作。
      return c.json({
        success: true, libraries: [], count: 0,
        empty_confirmed: true, empty_reason: 'filtered_out',
        note: '這個帳號目前沒有任何知識庫的檢視權限，請聯絡管理員開通。',
      });
    }
    const census = await tripletCensus(c.env, tenant);
    if (census.owned === null || (census.owned === 0 && census.any === null)) {
      return c.json({
        success: true, libraries: [], count: 0,
        empty_confirmed: false, empty_reason: 'unreadable',
        note: '讀不到知識庫的統計，無法確認庫裡有沒有東西——這不是「還沒有知識」，是這次讀取失敗。請稍後重整或通知管理員。',
      });
    }
    if (census.owned === 0 && (census.any ?? 0) > 0) {
      return c.json({
        success: true, libraries: [], count: 0,
        empty_confirmed: false, empty_reason: 'scope_mismatch',
        instance_triplet_count: census.any,
        note:
          `讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` +
          '這不是「還沒有知識」，不用去重新上傳；比較像知識的歸屬命名空間對不上。' +
          '請通知管理員跑一次 `acr update`（會把你安裝時的命名空間同步給雲端），或檢查 ARCRUN_NAMESPACE 設定。',
      });
    }
    return c.json({
      success: true, libraries: [], count: 0,
      empty_confirmed: true, empty_reason: 'confirmed_empty',
      note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
    });
  }),
);

// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404（不洩存在性）。
portalDataRouter.get('/portal/data/map/:library', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const libraries = parseLibraries(auth.user.values.libraries);
    const library = c.req.param('library');
    if (!canReadLibrary(libraries, library)) return notFound(c);
    const res = await kbdbFetch(
      c.env,
      `/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`,
    );
    if (res.status === 404) return notFound(c);
    if (!res.ok) return c.json({ error: `KBDB 回錯（HTTP ${res.status}）` }, 502);
    return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
  }),
);

// GET /portal/data/templates — template 清單。
// template＝虛擬表定義（schema），**全域共享不分租戶**（kbdb-proxy 同一裁定，leo 2026-06-14）：
// 它描述「資料長什麼形狀」，不含任何人的內容。內容的隔離在 records/entries 那層。
portalDataRouter.get('/portal/data/templates', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const res = await kbdbFetch(c.env, '/templates');
    if (!res.ok) return c.json({ error: `KBDB 回錯（HTTP ${res.status}）` }, 502);
    return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
  }),
);

// POST /portal/data/templates — 建 template（name + slots）。
// 鐵律：這是「虛擬表定義」，不是建真的資料表；KBDB 不提供建表/SQL。
// created_by 記租戶（溯源），template 本身全域可見可用。
portalDataRouter.post('/portal/data/templates', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const body = (await c.req.json().catch(() => null)) as
      | { name?: unknown; slots?: unknown; description?: unknown }
      | null;
    if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
      return c.json({ error: 'name 與 slots[] 必填' }, 400);
    }
    const res = await kbdbFetch(c.env, '/templates', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: body.name,
        slots: body.slots,
        description: typeof body.description === 'string' ? body.description : undefined,
        created_by: knowledgeOwner(c.env),
      }),
    });
    return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
  }),
);

// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
// server 注入 owner_id（呼叫端傳的一律忽略）；有標 library 的再逐筆過濾。
portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const libraries = parseLibraries(auth.user.values.libraries);
    if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
    const tenant = knowledgeOwner(c.env);
    const res = await kbdbFetch(
      c.env,
      `/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`,
    );
    if (!res.ok) return c.json({ error: `KBDB 回錯（HTTP ${res.status}）` }, 502);
    const body = (await res.json().catch(() => null)) as
      | { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
      | null;
    if (!body || !Array.isArray(body.records)) {
      return c.json({ error: 'record 讀取失敗：KBDB 回應不是預期的 records 清單' }, 502);
    }
    // KBDB 已按 owner_id 過濾；這裡再守一次庫（縱深防禦，且舊部署若回多了不會外洩）。
    const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
    return c.json({ success: true, records, count: records.length });
  }),
);

// GET /portal/data/records/:recordId — 單筆 record。
// 逐筆驗歸屬（owner_id 必須是本實例租戶）＋ 驗庫；兩者不符與不存在同回 404。
portalDataRouter.get('/portal/data/records/:recordId', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const libraries = parseLibraries(auth.user.values.libraries);
    if (libraries.length === 0) return notFound(c);
    const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
    if (res.status === 404) return notFound(c);
    if (!res.ok) return c.json({ error: `KBDB 回錯（HTTP ${res.status}）` }, 502);
    const body = (await res.json().catch(() => null)) as
      | { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
      | null;
    const record = body?.record;
    if (!record) return notFound(c);
    if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
    return c.json({ success: true, record });
  }),
);

// POST /portal/data/records — 依 template 填一筆 record。
// owner_id **一律由 server 定死成本實例租戶**（呼叫端傳的忽略）——寫入端若讓呼叫端挑歸屬，
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫（values.library）必須有該庫權限。
portalDataRouter.post('/portal/data/records', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const libraries = parseLibraries(auth.user.values.libraries);
    if (libraries.length === 0) {
      return c.json({ error: '此帳號尚未被授權任何知識庫，無法寫入' }, 403);
    }
    const body = (await c.req.json().catch(() => null)) as
      | { template?: unknown; values?: unknown }
      | null;
    if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
      return c.json({ error: 'template 與 values 必填' }, 400);
    }
    const values = body.values as Record<string, unknown>;
    const targetLib = recordLibrary(values);
    if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
      // 寫入越庫是**明確拒絕**（403），不套讀取那條 404 不洩存在性的規則：
      // 庫名是呼叫端自己指定的，這裡沒有「洩漏某庫存在」的問題，講清楚才可修正。
      return c.json({ error: `無「${targetLib}」庫的權限，不能寫入該庫` }, 403);
    }
    const res = await kbdbFetch(c.env, '/records', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }),
    });
    return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
  }),
);

// GET /portal/data/diagnostics — 檢修孔（2026-08-07 leo 直接指令）：
//
//   「可以很簡單，就是一顆按鈕在設定裡，他按鈕下載一個檔案，把檔案發給我，你看那個檔。」
//
// 設定頁「匯出診斷檔給我們看」按鈕打這支，前端把回應存成單一 JSON 檔下載。
//
// 🔴 t213（2026-08-08，InkStoneCo 總管交辦）：leo 實測拿真檔驗四個真實問題，只答得出一題
// （雲端這半的 bundle_version）——其餘三題（本機檔案總量、失敗分類統計、daemon 版本／
// 自我更新狀態）需要本機資料，雲端這支端點天生構不到（封測者的瀏覽器與他電腦上的
// daemon 是兩個獨立行程）。核准方案：本機那半改由 arcrun-app（daemon 桌面殼）匯出時
// 直接讀本機檔案，並改打**新增的** `GET /portal/daemon/diagnostics`（X-Arcrun-API-Key
// 認證，免帳密）取雲端這半，兩者合併成一份完整診斷檔——arcrun-app 那半見
// products/arcrun-rag repo t213 phase 2。本端點（portal 網頁版）保留當退路（daemon
// 完全掛掉時仍按得到），文案需誠實講清楚自己只有一半，完整診斷請去 daemon 匯出
// （portal 前端文案改動不在本次 matrix/arcrun 範圍內，由 arcrun-rag 那邊處理）。
//
// 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變，核心邏輯已抽成 buildDiagnostics()
// （portal.ts）——與新的 daemon 版共用同一份查詢邏輯（薄殼原則）。
portalDataRouter.get('/portal/data/diagnostics', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const tenant = knowledgeOwner(c.env);
    const core = await buildDiagnostics(c.env, tenant);
    return c.json({
      generated_at: new Date().toISOString(),
      instance_url: new URL(c.req.url).origin,
      bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
      ...core,
    });
  }),
);
