/**
 * 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, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
import { graphBase } 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> {
  const raw = await env.WEBHOOKS.get(`${portalTenant(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 };
}

/** 越庫/不存在 一律同一句 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）。
 */
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);

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;
  });
}

// 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 q = c.req.query('q');
    if (!q) return c.json({ error: 'q 必填' }, 400);

    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: portalTenant(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');
    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 ((entry.owner_id ?? '') !== portalTenant(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);
    }

    // ① tenant workflow 路徑（存在才走；input：node=path、depth=query 預設 2、namespace/owner=tenant）
    const tenant = portalTenant(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,
        { node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
        '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: Record<string, string> = {};
    if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
    try {
      const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
      return new Response(res.body, { 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 = portalTenant(c.env);
    const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(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
      | { records?: { values?: Record<string, unknown> }[] }
      | null;
    const records = body && Array.isArray(body.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 }));
    return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
  }),
);

// 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',
      portalTenant(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}（缺欄位誠實回空，不編造）
    const inner = unwrapWorkflowData(result.data, 'answer');
    return c.json({
      answer: typeof inner.answer === 'string' ? inner.answer : '',
      sources: Array.isArray(inner.sources) ? inner.sources : [],
      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 = portalTenant(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 誠實留空 */
          }
        }
        // 最近一次執行：ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp，
        // 字典序＝時間序，取最後一把 key 即最新（同 /workflows/:name/executions 的排序邏輯）。
        let last_execution: { timestamp: string; verdict?: string } | null = null;
        const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
        if (stats.keys.length > 0) {
          const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
          const ts = latest.name.split(':').pop() ?? '';
          const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
          let verdict: string | undefined;
          if (rawStat) {
            try {
              verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
            } catch {
              /* 壞 record 誠實留空 */
            }
          }
          last_execution = { timestamp: ts, verdict };
        }
        return { name, description, created_at, cron_expr, last_execution };
      }),
    );
    return c.json({ success: true, workflows, total: workflows.length, read_only: true });
  }),
);
