/**
 * arcrun console 駕駛艙 dashboard（T-cockpit ②，Arcrun#3 console 系，2026-07-04 總管派工；
 * 2026-07-07 fix/console-dashboard-live-data：stale 資料整修，總管交辦）
 *
 * ⚠️ 2026-07-21 cypher-ui-split 第一刀：本檔只剩 **API 端點**。原本的
 * `GET /console/dashboard`（單檔 HTML）已搬到 `console-ui/`（Cloudflare Pages 靜態站），
 * 頁面改由 Pages 託管、資料仍打本檔的 `/console/dashboard-data`。
 *
 * 端點皆「無需登入」（唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數）：
 *   - GET /console/dashboard-data：聚合 JSON。
 *   - GET /console/kb-scale-data：精耕層規模（wiki 卡/三元組/已嵌入；2026-07-07 leo 裁
 *     「遺產庫不用顯示」後 console 頭部統計改讀這裡）。
 *   - GET /console/settings-data：設定頁誠實系統值（MCP token TTL 佔位）。
 *
 * ── 2026-07-07 二修（fix/console-truth-audit）：「今日完成/今日路線」接 sprint 任務板 ──
 * leo 拍板（「今天做了這麼多事……其實就是我們到底完成了多少事」）：dash_task 同 dash_wait
 * 病（沒活管線），真相源＝sprint 檔「## 任務板」勾選。比照「等你的事」#36 模式：同一輪
 * Gitea fetch（90s 快取共用）解析任務板，「今日完成」只認「完成（今天台北日）」標記，
 * dash_task 降 fallback；板檔今天沒 commit → 頁面誠實標「今日任務板未更新（最後 N 小時前）」。
 *
 * ── 2026-07-07 整修：每個區塊都讀「live 一手資料」，讀不到就誠實標示，不擺 stale 殘骸 ──
 *
 * 資料源診斷（leo 抱怨「等你的事錯了好幾天」的根因）：
 *   - dash_wait（等你的事舊資料源）最後寫入 2026-07-04，**沒有活的維護管線**——等leo清單#11
 *     已於 07-05 銷案（誤判），dashboard 卻繼續掛著它。真相源其實是 InkStoneCo sprint 檔的
 *     「## 等 leo 清單」表格（progress-guard routine 每日核實維護）。
 *   - dash_task 的 scope:"today" 沒有日期——07-04 的「今日路線」到 07-07 還被當今天的。
 *   - dash_beat 是唯一有活管線的 dash_*（progress-guard/cloud-worker/watchdog 每日寫入）。
 *
 * 整修後的資料源：
 *   等你的事 → 首選 Gitea sprint 檔等leo清單（需 GITEA_BASE_URL var + GITEA_TOKEN secret；
 *              進程內 fetch Gitea API，非 GitHub、無 D20 疑慮）；讀不到 → fallback dash_wait
 *              但必標 age + stale 警示；連 dash_wait 都沒有 → 誠實顯示「管線未接」。
 *   今日路線 → dash_task，但以台北日曆日判 is_today；非今日寫入＝降級顯示「最後路線（N 天前）」，
 *              不假裝是今天的。今日無寫入時明講管線缺口（sprint 任務板→dashboard 無自動投影）。
 *   系統狀況 → live 健康信號：KBDB /health、/embed/backfill/status（enabled:false 誠實顯示）、
 *              kbdb-graph-plugin /triplets/stats、workflow 總數（KBDB entry_type=workflow）。
 *   總庫規模 → KBDB entries 總數／wiki_card 數／triplets 數，全部 live API 一手拉。
 *
 * 燈號判定（寫死在端點，頁面只渲染）：
 *   red    = 「今日寫入」的任務有 blocked，或最新心跳距今 > 240 分（台北 09:00-22:00 窗內判定），
 *            或 KBDB /health 打不通。stale 殘任務**不再**觸發燈號（07-04 的 blocked 不該讓 07-07 亮紅）。
 *   yellow = 無 red 條件，但今日任務有非標準 status（late/behind 等落後標記）。
 *   green  = 其餘。
 *
 * 薄殼定位：聚合端點（能力長在 API 一次，rule 07 正例）——頁面零業務邏輯；判定純函式抽在
 * lib/console-dashboard-model.ts（可單測）。讀 KBDB 走 HTTP（kbdbBase 慣例），不新增 binding。
 */
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import {
  type KbdbEntry,
  type WaitingItem,
  type WaitingModel,
  type CachedWaitingEnvelope,
  type SprintBoardTask,
  type SprintSnapshot,
  GITEA_WAITING_CACHE_TTL_SECONDS,
  parseCreatedAtMs,
  parseJsonContent,
  agoMinutes,
  buildRouteModel,
  buildSprintRouteModel,
  buildWaitingFallback,
  parseSprintTaskBoard,
  parseSprintWaitingTable,
  pickLatestSprintFiles,
  reviveWaitingAges,
  sortWaitingItems,
  taipeiDayKey,
} from '../lib/console-dashboard-model';
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
// Arcrun#108：租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT
// 過濾而看不到自己的資料——與 portal 同一個病，同一個修法。
import { knowledgeOwner } from '../lib/tenant';

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

const STALE_MINUTES = 240;
const JUDGE_START_HOUR = 9; // 台北時間，含
const JUDGE_END_HOUR = 22; // 台北時間，不含
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);

async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
  const { base, headers } = kbdbBase(env);
  const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
  try {
    const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
    if (!res.ok) return [];
    const data = (await res.json()) as { entries?: KbdbEntry[] };
    return data.entries ?? [];
  } catch {
    return [];
  }
}

/** 泛用 GET JSON（失敗回 null，caller 誠實顯示「讀不到」，不編數字）。 */
async function fetchJson<T>(url: string, headers?: Record<string, string>): Promise<T | null> {
  try {
    const res = await fetch(url, headers ? { headers } : undefined);
    if (!res.ok) return null;
    return (await res.json()) as T;
  } catch {
    return null;
  }
}

/**
 * 本租戶三元組的**真實總數**（null = 讀不到，畫面要顯示「讀不到」而非 0）。
 *
 * 🔴 Arcrun#100：不可以拿 graph-plugin `/triplets/stats` 的 `total` 當數量。
 * 那支的 `total` 是**分頁長度**不是 COUNT——它走 `/records/by-template/triplet`（KBDB 端
 * `searchByTemplate` 預設 limit=100、硬上限 500）且不帶 owner 過濾，所以 1854 條的庫
 * 只會回 100。修好 401 之後若還讀它，畫面會從「0」變成「100」——一樣是假的。
 * 真相源＝KBDB `/records/triplet-stats`（真 SQL COUNT(*)、依 owner_id 過濾、無上限），
 * 回 `{ success, stats: [{ library, triplet_count }] }`，加總即全庫條數。
 */
async function fetchTripletTotal(env: Bindings, tenant: string): Promise<number | null> {
  const { base, headers } = kbdbBase(env);
  const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>(
    `${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`,
    headers,
  );
  if (!data || !Array.isArray(data.stats)) return null;
  let total = 0;
  for (const row of data.stats) {
    if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到，不半信半疑加總
    total += row.triplet_count;
  }
  return total;
}

/** KBDB entries 符合條件的總數（limit=1 只拿 total 欄，不搬資料）。null = 讀不到。 */
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
  const { base, headers } = kbdbBase(env);
  const params = new URLSearchParams({ ...filters, limit: '1' });
  const data = await fetchJson<{ total?: unknown }>(`${base}/entries?${params.toString()}`, headers);
  return data && typeof data.total === 'number' ? data.total : null;
}

/**
 * sprint 檔活資料源（同一輪 fetch 兩個產物，leo 2026-07-07 拍板加「今日完成」）：
 *   - 「等你的事」＝「## 等 leo 清單」表格（progress-guard 每日維護）。
 *   - 「今日完成／今日路線」＝「## 任務板」checkbox（今天勾的才算今日完成）。
 * 需 GITEA_BASE_URL（var）+ GITEA_TOKEN（secret，建議唯讀 scope）。請求序：
 * 列目錄挑最新兩個 sprint-*.md（換 sprint 後前一檔常還有未銷案項/未收項，例：07b 開了、
 * 🔴 mira 憑證外洩與 [🔄] T-cockpit 仍掛 07a）→ 各抓 raw、兩個 parser 吃同一份文字 →
 * 最新檔的最後 commit 時間當「維護於」。等leo清單全解析失敗回 null → caller fallback
 * dash_wait / dash_task（標 age），不硬湊。
 */
async function fetchGiteaSprint(env: Bindings, nowMs: number): Promise<SprintSnapshot | null> {
  const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
  const token = env.GITEA_TOKEN;
  if (!base || !token) return null;
  const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
  const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
  const headers = { Authorization: `token ${token}` };
  try {
    const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers);
    if (!files) return null;
    const sprints = pickLatestSprintFiles(files.map((f) => f.name));
    if (!sprints.length) return null;
    const parsed = await Promise.all(
      sprints.map(async (name) => {
        const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers });
        if (!rawRes.ok) return null;
        const text = await rawRes.text();
        return { waiting: parseSprintWaitingTable(text, name), board: parseSprintTaskBoard(text, name) };
      }),
    );
    const readFiles = sprints.filter((_, i) => parsed[i]?.waiting != null);
    const merged = parsed.map((p) => p?.waiting).filter((p): p is WaitingItem[] => p != null).flat();
    if (!readFiles.length) return null; // 等leo清單全部解析失敗＝誠實 fallback
    // 任務板：新→舊合併（現役 sprint 的板先列）；兩檔都沒有可解析的板 → null（fallback dash_task）
    const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
    // 清單上次維護時間 = 現役 sprint 檔最後 commit（progress-guard 每日 commit，>48h 沒動才算 stale）
    let ago = -1;
    const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
      `${base}/api/v1/repos/${repo}/commits?path=${encodeURIComponent(`${dir}/${readFiles[0]}`)}&limit=1&stat=false&verification=false&files=false`,
      headers,
    );
    const date = commits?.[0]?.commit?.committer?.date;
    if (date) {
      const ms = Date.parse(date);
      if (!Number.isNaN(ms)) ago = agoMinutes(nowMs, ms);
    }
    return {
      waiting: {
        items: sortWaitingItems(merged),
        source: 'gitea_sprint',
        updated_ago_minutes: ago,
        stale: ago >= 0 && ago > 48 * 60,
        sprint_files: readFiles,
      },
      board: boardMerged.length ? boardMerged : null,
    };
  } catch {
    return null;
  }
}

export type GiteaSprintFetcher = (env: Bindings, nowMs: number) => Promise<SprintSnapshot | null>;

/**
 * fetchGiteaSprint 的快取層（總管 #36 審查要求）：CF Cache API（caches.default）、
 * TTL 90s（GITEA_WAITING_CACHE_TTL_SECONDS）。前端 60 秒刷新下，Gitea 從
 * 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」；快取是查詢面的讀優化，不是輪詢。
 *
 * - key：合成 URL（Cache API 要求合法 URL；host 用不會真的被打的保留名），帶
 *   base/repo/dir 參數——設定變了自然 miss，不會吐到別的 Gitea 的殘資料。
 * - hit 回放時用 reviveWaitingAges 把「維護於 N 分鐘前」隨牆鐘補算（存的是 fetch
 *   當下的 ago，直接回放會讓時間停走）。任務板存原始 completed_days，「今天完成幾件」
 *   由請求當下算——跨台北午夜的快取不會把昨天的完成冒領成今天。
 * - **失敗不快取**：negative cache 會把一時網路抖動放大成 90 秒盲區，caller 該
 *   當場 fallback dash_wait / dash_task。
 * - cache.put 走 waitUntil（不阻塞回應）；fetcher 參數可注入＝單測不用真打網路。
 * - 回傳多帶 cache:'hit'|'miss'，吐進 waiting_meta 當快取生效的客觀證據（curl 兩次
 *   第二次該是 hit）。
 */
export async function cachedGiteaSprint(
  env: Bindings,
  nowMs: number,
  waitUntil: (p: Promise<unknown>) => void,
  fetcher: GiteaSprintFetcher = fetchGiteaSprint,
): Promise<(SprintSnapshot & { cache: 'hit' | 'miss' }) | null> {
  if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null;
  const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
  const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
  const cacheKey = new Request(
    `https://console-dashboard.arcrun.internal/gitea-waiting?${new URLSearchParams({ base: env.GITEA_BASE_URL, repo, dir }).toString()}`,
  );
  const cache = caches.default;
  try {
    const hit = await cache.match(cacheKey);
    if (hit) {
      const envelope = (await hit.json()) as CachedWaitingEnvelope;
      return {
        waiting: reviveWaitingAges(envelope.snapshot.waiting, envelope.fetched_at_ms, nowMs),
        board: envelope.snapshot.board,
        cache: 'hit',
      };
    }
  } catch {
    /* cache 故障不致命，走 miss 路徑 */
  }
  const fresh = await fetcher(env, nowMs);
  if (!fresh) return null; // 失敗不快取，caller 誠實 fallback
  const envelope: CachedWaitingEnvelope = { snapshot: fresh, fetched_at_ms: nowMs };
  try {
    waitUntil(
      cache.put(
        cacheKey,
        new Response(JSON.stringify(envelope), {
          headers: {
            'Content-Type': 'application/json',
            'Cache-Control': `public, max-age=${GITEA_WAITING_CACHE_TTL_SECONDS}`,
          },
        }),
      ),
    );
  } catch {
    /* put 失敗只是少了快取，不影響本次回應 */
  }
  return { ...fresh, cache: 'miss' };
}

// GET /console/dashboard-data — 聚合 JSON（無需登入；唯讀、不含機敏值）
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
  const tenant = knowledgeOwner(c.env); // #108：知識資料面的 owner_id 只有一個產地（lib/tenant.ts）
  const now = Date.now();
  const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
  const graphUrl = graphBase(c.env);

  const [
    beatEntries,
    taskEntries,
    waitEntries,
    inboxEntries,
    giteaSprint,
    kbdbHealth,
    embedStatus,
    graphStats,
    tripletTotal,
    entriesTotal,
    wikiCardTotal,
    workflowTotal,
  ] = await Promise.all([
    fetchEntries(c.env, tenant, 'dash_beat', 100),
    fetchEntries(c.env, tenant, 'dash_task', 200),
    fetchEntries(c.env, tenant, 'dash_wait', 100),
    fetchEntries(c.env, tenant, 'inbox', 200),
    cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
    fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
    fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
    // graph-plugin 只拿來判「圖服務活著沒」（燈號）——數字不從這裡拿，見 fetchTripletTotal。
    // headers 一定要帶：plugin 的 /triplets 前綴掛 Bearer 閘，漏帶＝永遠 401＝永遠假紅燈（#100）。
    fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(
      `${graphUrl}/triplets/stats`,
      graphHeaders(c.env),
    ),
    fetchTripletTotal(c.env, tenant),
    // owner_id 一律鎖本租戶：原本不帶 owner 會混到別租戶（實測 459,137 vs leo 的 458,732）
    fetchEntryTotal(c.env, { owner_id: tenant }),
    fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
    fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
  ]);

  // dash_beat：每 actor 最新一筆（list 已 created_at DESC → first-seen 即最新）。唯一有活管線的 dash_*。
  const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
  const seenActors = new Set<string>();
  for (const e of beatEntries) {
    const j = parseJsonContent(e);
    const actor = typeof j?.actor === 'string' ? j.actor : null;
    if (!actor || seenActors.has(actor)) continue;
    seenActors.add(actor);
    const ms = parseCreatedAtMs(e.created_at);
    beats.push({
      actor,
      event: typeof j?.event === 'string' ? (j.event as string) : '',
      note: typeof j?.note === 'string' ? (j.note as string) : '',
      at: e.created_at,
      ago_minutes: agoMinutes(now, ms),
    });
  }
  const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;

  // 等你的事：Gitea sprint 等leo清單優先（走 90s 快取）；讀不到 fallback dash_wait（帶 age + stale）
  let waiting: WaitingModel;
  let waitingCache: 'hit' | 'miss' | null = null;
  if (giteaSprint) {
    waiting = giteaSprint.waiting;
    waitingCache = giteaSprint.cache;
  } else {
    waiting = buildWaitingFallback(waitEntries, now);
    if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
      waiting.note = 'Gitea sprint 清單未接（缺 GITEA_TOKEN secret）——以下是 dash_wait 殘資料';
    } else if (waiting.source === 'kbdb_dash_wait') {
      waiting.note = 'Gitea sprint 清單讀取失敗——以下是 dash_wait 殘資料';
    }
  }

  // 今日完成／今日路線：sprint 任務板優先（leo 2026-07-07 拍板——「到底完成了多少事」的
  // 真相源＝progress-guard/cloud-worker 每日勾選的板，dash_task 沒活管線降 fallback）。
  // 板的「今日完成」只認「完成（今天台北日）」標記；板檔今天沒 commit 過 → 誠實標示。
  const sprintRoute = giteaSprint?.board ? buildSprintRouteModel(giteaSprint.board, now) : null;
  const route = buildRouteModel(taskEntries, now); // fallback ＋ 燈號仍吃 dash_task 今日寫入
  const boardAgo = giteaSprint ? giteaSprint.waiting.updated_ago_minutes : -1;
  const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey(now - boardAgo * 60000) === taipeiDayKey(now);

  // inbox：未處理計數（status !== 'done'；沒標 status 視為未處理）
  const inboxNew = inboxEntries.reduce((n, e) => {
    const j = parseJsonContent(e);
    return j && j.status !== 'done' ? n + 1 : n;
  }, 0);

  // 燈號：只吃「今日寫入」的任務 + 心跳 + KBDB 健康（stale 殘任務不再觸發燈號）
  const todayWrites = route.tasks.filter((t) => t.is_today_write);
  const hasBlocked = todayWrites.some((t) => t.status === 'blocked');
  const hasLagMark = todayWrites.some((t) => !STANDARD_TASK_STATUS.has(t.status));
  const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
  const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
  const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
  const kbdbOk = kbdbHealth?.ok === true;
  const light: 'green' | 'yellow' | 'red' =
    hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green';
  const lightReason = !kbdbOk
    ? 'KBDB 基本盤 /health 打不通'
    : hasBlocked
      ? '今日任務有 blocked'
      : inJudgeWindow && beatStale
        ? `心跳超過 ${STALE_MINUTES} 分鐘`
        : hasLagMark
          ? '今日任務有落後標記'
          : '';

  return c.json({
    light,
    light_reason: lightReason,
    last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
    beats,
    // 路線：sprint 任務板優先（tasks 欄位形狀與 dash_task 版相容——title/status/scope）；
    // 板上開著的項 is_today_write=false（燈號沿 #36 原則只吃 dash_task 今日寫入＋心跳＋KBDB，
    // 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」）
    tasks: sprintRoute
      ? sprintRoute.tasks.map((t, i) => ({
          title: t.title,
          status: t.status,
          order: i,
          scope: 'today' as const,
          age_minutes: boardAgo,
          is_today_write: t.status === 'done', // done 項必然是「今天完成」的（模型已濾）
          sprint: t.sprint ?? null,
        }))
      : route.tasks.map((t) => ({
          title: t.title,
          status: t.status,
          order: t.order,
          scope: t.scope,
          age_minutes: t.age_minutes,
          is_today_write: t.is_today_write,
          sprint: null,
        })),
    route_meta: sprintRoute
      ? {
          source: 'gitea_sprint_board',
          // is_today＝板檔今天（台北）有 commit 過；false → 頁面誠實標「今日任務板未更新」
          is_today: boardUpdatedToday,
          updated_ago_minutes: boardAgo,
          sprint_files: waiting.sprint_files ?? null,
        }
      : {
          source: 'kbdb_dash_task',
          is_today: route.is_today,
          updated_ago_minutes: route.updated_ago_minutes,
          sprint_files: null,
        },
    today_done: sprintRoute ? sprintRoute.today_done : route.today_done,
    today_total: sprintRoute ? sprintRoute.today_total : route.today_total,
    done_today_titles: sprintRoute ? sprintRoute.done_today_titles : null,
    waiting: waiting.items,
    waiting_meta: {
      source: waiting.source,
      updated_ago_minutes: waiting.updated_ago_minutes,
      stale: waiting.stale,
      sprint_files: waiting.sprint_files ?? null,
      note: waiting.note ?? null,
      // Gitea 快取層狀態（hit/miss；fallback 路徑為 null）——快取生效的客觀證據
      cache: waitingCache,
    },
    inbox_new: inboxNew,
    system: {
      kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
      embed: embedStatus
        ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
        : null,
      // ok = plugin 通不通（graphStats 讀得到就是通）；triplets = KBDB 真 COUNT（與 plugin 分頁長度無關）
      graph: { ok: graphStats !== null, triplets: tripletTotal },
      workflow_total: workflowTotal,
    },
    kb: {
      entries_total: entriesTotal,
      wiki_card_total: wikiCardTotal,
      triplets_total: tripletTotal,
    },
    generated_at: new Date(now).toISOString(),
  });
});

// GET /console/kb-scale-data — 總庫「精耕層」規模（leo 2026-07-07 裁：45.8 萬 14-E 搬遷
// blocks 已 deprecated 之後要刪，頭部統計**不再拿遺產數字撐場面**，只顯示真的新的）。
// 免登入（純聚合計數、無內容原文，同 dashboard-data 標準）。3 個 subrequest，全是
// limit=1（只拿 total 欄）或現成 stats 聚合端點——不逐筆掃庫，不撞子請求上限。
// 搜尋功能本身仍可搜全庫（資料不藏），只是規模感不再引用遺產總數。
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
  const tenant = knowledgeOwner(c.env); // #108：知識資料面的 owner_id 只有一個產地（lib/tenant.ts）
  const { base, headers } = kbdbBase(c.env);
  const now = Date.now();
  const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
    // limit=1 順手拿最新一筆 created_at（list 為 created_at DESC）＝「最近寫入時間」
    fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
      `${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
      headers,
    ),
    // #100：三元組數改讀 KBDB 真 COUNT，不再讀 graph-plugin 的分頁長度（見 fetchTripletTotal 註）
    fetchTripletTotal(c.env, tenant),
    fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
  ]);
  const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
  // 讀不到的欄位誠實回 null（頁面顯示「讀不到」），不編數字
  return c.json({
    wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
    wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
    triplets_total: tripletTotal,
    embedded: embedStatus?.embedded ?? null,
    embed_enabled: embedStatus ? embedStatus.enabled === true : null,
    generated_at: new Date(now).toISOString(),
  });
});

// GET /console/settings-data — 設定頁的誠實系統值（目前只有 MCP token TTL 佔位區塊用）。
// TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`（mcp/src/types.ts，預設 2592000＝30 天）；
// cypher 讀的是自己這份同名 var（deploy 時兩處要一致，#32 形態 config 同步教訓）——
// source 欄位如實標 env/default，頁面不假裝這是能遠端改的設定。
consoleDashboardRouter.get('/console/settings-data', (c) => {
  const raw = c.env.MCP_TOKEN_TTL;
  const parsed = raw ? parseInt(raw, 10) : NaN;
  const fromEnv = Number.isFinite(parsed) && parsed > 0;
  return c.json({
    mcp_token_ttl_seconds: fromEnv ? parsed : 2592000,
    mcp_token_ttl_source: fromEnv ? 'env' : 'default',
  });
});

// GET /console/triage-data — 分流台資料（Mira Console 頁 7，Arcrun#9 收件夾改裝；原
// /console/inbox-data 的後繼——唯一消費者是 console 頁本身，一起改裝，不留死端點）。
// **需 console session**（Bearer）：dashboard-data 只吐計數可免登入；這裡吐待辦/訊息原文屬機敏，鎖登入。
// 資料源二合一（kb-ingest SDD R7）：entry_type=todo（Logseq 萃取，Arcrun#8 ingest 線）＋
// entry_type=inbox（Telegram）。契約解析/三欄分流/計數＝純函式 lib/console-triage-model.ts。
consoleDashboardRouter.get('/console/triage-data', async (c) => {
  const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
  if (!ok) return c.json({ error: '需要登入（console session）' }, 401);

  const tenant = knowledgeOwner(c.env); // #108：知識資料面的 owner_id 只有一個產地（lib/tenant.ts）
  const [todoEntries, inboxEntries] = await Promise.all([
    fetchEntries(c.env, tenant, 'todo', 500),
    fetchEntries(c.env, tenant, 'inbox', 200),
  ]);
  const model = buildTriageModel(todoEntries, inboxEntries);
  return c.json({ ...model, generated_at: new Date().toISOString() });
});

// POST /console/triage-check — 分流台勾掉/還原（leo 2026-07-08 拍板；body: {entry_id, action?}）。
// 為什麼開這個小端點而不讓瀏覽器直打 KBDB：瀏覽器沒有 KBDB_INTERNAL_TOKEN（token 只能在
// server 側，同 kbdb-graph proxy 理由），且 console session ≠ X-Arcrun-API-Key。沿用
// triage-data 同款 session 驗證，server 端做 KBDB PATCH（kbdbBase 慣例）。
//
// PATCH content 需**整串回寫**（KBDB updateEntry 是欄位級覆蓋，content 給什麼存什麼）——
// 先 GET 原 entry、只動 status/checked_* 欄再回寫，防蓋掉 text/marker/owner_tier 等別的欄位。
// 改寫邏輯＝lib/console-triage-model.ts applyTriageCheck（純函式，vitest 驗證）。
//
// ── 雙向銷案語意（死循環防呆，與 applyTriageCheck 註解同一套規約，萃取端會配合）──
//   console 勾掉＝終局（checked_via:"console"）：即使 Logseq 原文還是 TODO，萃取端也絕不
//   復活它；Logseq 改 DONE 的由萃取端 PATCH status:done（checked_via:"logseq"）。
//   console 只需忠實顯示非 done 項；還原＝status 回 new + 移除 checked_via/checked_at。
consoleDashboardRouter.post('/console/triage-check', async (c) => {
  const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
  if (!ok) return c.json({ error: '需要登入（console session）' }, 401);

  const body = await c.req.json().catch(() => null);
  const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : '';
  if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
  const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';

  const tenant = knowledgeOwner(c.env); // #108：知識資料面的 owner_id 只有一個產地（lib/tenant.ts）
  const { base, headers } = kbdbBase(c.env);

  // 先 GET 原 entry（整串回寫的前提），順便守兩道邊界：
  //   1. owner_id 必須＝console 固定租戶（session 只代表 leo 這個租戶，不能改到別人的資料）；
  //   2. entry_type 限分流台的兩個來源 todo/inbox（這端點不是泛用 entry 改寫器）。
  const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>(
    `${base}/entries/${encodeURIComponent(entryId)}`,
    headers,
  );
  const entry = got?.entry;
  if (!entry) return c.json({ error: '找不到這筆待辦（可能已被刪除）' }, 404);
  if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦（可能已被刪除）' }, 404); // 不洩漏他租戶存在性
  if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') {
    return c.json({ error: '只有分流台項目（todo/inbox）能在這裡勾掉' }, 400);
  }

  const newContent = applyTriageCheck(entry.content, action, new Date().toISOString());
  const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, {
    method: 'PATCH',
    headers,
    body: JSON.stringify({ content: newContent }),
  });
  if (!res.ok) return c.json({ error: `KBDB 回寫失敗（HTTP ${res.status}）` }, 502);
  return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
});

