/**
 * console 駕駛艙 dashboard 的聚合純函式層（2026-07-07 fix/console-dashboard-live-data）
 *
 * 為什麼獨立成 lib：routes/console-dashboard.ts 原本把「解析 + 判定」焊在 route 裡，
 * 無法單測；本檔只放**純函式**（無 fetch、無 env），route 負責 IO、這裡負責判定，
 * vitest 可直接餵資料驗證（誠實限制 mindset §7：修 stale bug 要有客觀證據）。
 *
 * 本次修的三個 stale 根因（leo 抱怨「等你的事錯了好幾天」的診斷結論）：
 *   1. dash_wait 沒有活的維護管線（最後寫入 2026-07-04；等leo清單#11 已於 07-05 銷案＝誤判，
 *      dashboard 卻繼續掛著它）——「等 leo」的真相源其實是 InkStoneCo sprint 檔的
 *      「## 等 leo 清單」表格（progress-guard 每日核實維護）。→ 改讀 Gitea sprint 檔，
 *      讀不到才 fallback dash_wait 且必標 age，不假裝是今天的。
 *   2. dash_task 的 scope:"today" 沒有日期概念——07-04 寫入的「今日路線」到 07-07 還被
 *      當成今天的。→ 以台北日曆日判定 is_today，非今日寫入的任務誠實降級為「最後路線（N 天前）」。
 *   3. 燈號 hasBlocked/hasLagMark 吃到 stale 任務——幾天前的 blocked 會讓今天亮紅燈。
 *      → 燈號只吃「今日寫入」的任務 + 心跳（心跳 dash_beat 是唯一有活管線的 dash_* 資料）。
 */

export interface KbdbEntry {
  id: string;
  content: string | null;
  entry_type: string;
  created_at: string | number; // leo21c 實測是 epoch 秒（number）；防禦性也吃 sqlite/ISO 字串
}

/** created_at 實測（leo21c KBDB）＝epoch 秒數 number；防禦性同時吃 epoch 毫秒與
 *  'YYYY-MM-DD HH:MM:SS'（UTC）/ ISO 字串（別的部署可能不同 schema 版本）。 */
export function parseCreatedAtMs(s: string | number | null | undefined): number | null {
  if (s === null || s === undefined || s === '') return null;
  if (typeof s === 'number') return s < 1e12 ? s * 1000 : s; // 秒 vs 毫秒
  if (/^\d+$/.test(s)) {
    const n = Number(s);
    return n < 1e12 ? n * 1000 : n;
  }
  const iso = /T/.test(s) ? s : `${s.replace(' ', 'T')}Z`;
  const ms = Date.parse(iso);
  return Number.isNaN(ms) ? null : ms;
}

export function parseJsonContent(e: KbdbEntry): Record<string, unknown> | null {
  if (!e.content) return null;
  try {
    const v = JSON.parse(e.content);
    return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
  } catch {
    return null;
  }
}

/** 台北（UTC+8）日曆日 key，用來判定「這筆 dash_task 是不是今天寫的」。 */
export function taipeiDayKey(ms: number): string {
  return new Date(ms + 8 * 3600 * 1000).toISOString().slice(0, 10);
}

export function agoMinutes(nowMs: number, ms: number | null): number {
  return ms === null ? -1 : Math.max(0, Math.round((nowMs - ms) / 60000));
}

// ── 今日路線（dash_task）───────────────────────────────────────────────────────

export interface DashTask {
  title: string;
  status: string;
  order: number;
  scope: 'today' | 'week';
  at_ms: number | null;
  age_minutes: number;
  /** 台北日曆日判定：這筆的最新寫入是不是「今天」寫的 */
  is_today_write: boolean;
}

export interface RouteModel {
  tasks: DashTask[];
  /** 最新一筆 dash_task 寫入距今分鐘數；-1 = 全庫沒有 dash_task */
  updated_ago_minutes: number;
  /** 最新寫入是否發生在台北「今天」——false = 整包路線是殘資料 */
  is_today: boolean;
  today_done: number;
  today_total: number;
}

/** dash_task 聚合：同 title 取最新（list 已 created_at DESC → first-seen 即最新），
 *  但「今日完成 n/m」只數今天（台北）寫入的任務——幾天前的殘路線不假裝是今天的。 */
export function buildRouteModel(entries: KbdbEntry[], nowMs: number): RouteModel {
  const todayKey = taipeiDayKey(nowMs);
  const byTitle = new Map<string, DashTask>();
  for (const e of entries) {
    const j = parseJsonContent(e);
    const title = typeof j?.title === 'string' ? (j.title as string) : null;
    if (!title || byTitle.has(title)) continue;
    const ms = parseCreatedAtMs(e.created_at);
    byTitle.set(title, {
      title,
      status: typeof j?.status === 'string' ? (j.status as string) : 'todo',
      order: typeof j?.order === 'number' ? (j.order as number) : 999,
      scope: j?.scope === 'week' ? 'week' : 'today',
      at_ms: ms,
      age_minutes: agoMinutes(nowMs, ms),
      is_today_write: ms !== null && taipeiDayKey(ms) === todayKey,
    });
  }
  const tasks = [...byTitle.values()].sort((a, b) =>
    a.scope !== b.scope ? (a.scope === 'today' ? -1 : 1) : a.order - b.order,
  );
  const newestMs = tasks.reduce<number | null>((m, t) => (t.at_ms !== null && (m === null || t.at_ms > m) ? t.at_ms : m), null);
  const isToday = newestMs !== null && taipeiDayKey(newestMs) === todayKey;
  const todayTasks = tasks.filter((t) => t.scope === 'today' && t.is_today_write);
  return {
    tasks,
    updated_ago_minutes: agoMinutes(nowMs, newestMs),
    is_today: isToday,
    today_done: todayTasks.filter((t) => t.status === 'done').length,
    today_total: todayTasks.length,
  };
}

// ── 等你的事 ──────────────────────────────────────────────────────────────────

export interface WaitingItem {
  title: string;
  /** Gitea sprint 來源才有：等leo清單編號（"13"、"R1"——07b 起用字母前綴）與急迫 emoji */
  id?: string;
  urgency?: string;
  /** 合併多個 sprint 檔時標出處（如 "sprint-2026-07a.md"） */
  sprint?: string;
}

export interface WaitingModel {
  items: WaitingItem[];
  /** 'gitea_sprint' = 讀到活資料源；'kbdb_dash_wait' = fallback（必標 age）；'none' = 管線未接 */
  source: 'gitea_sprint' | 'kbdb_dash_wait' | 'none';
  /** 資料源上次維護距今分鐘；-1 = 不明 */
  updated_ago_minutes: number;
  /** true = 這包資料超過閾值沒人維護，頁面必須明示「可能過時」 */
  stale: boolean;
  /** gitea_sprint 來源：實際讀了哪些 sprint 檔（新→舊） */
  sprint_files?: string[];
  note?: string;
}

const URGENCY_EMOJI = /(🔴|🟡|🟢|⚪)/u;
/** 急迫欄含這些字樣＝已結案，不再是「等你的事」 */
const CLOSED_MARKERS = /(✅|已完成|已解|銷案|已銷)/u;

/** 從 sprint md 撈「## 等 leo 清單」表格（含「（RAG 線）」等帶註記變體），回傳仍 open 的項目。
 *  規則（對照 sprint-2026-07a/07b.md 實際維護慣例）：
 *   - 編號欄吃英數（07a 純數字 "13"；07b 起 "R1" 字母前綴）
 *   - 事項欄以 ~~ 開頭（整項劃掉）→ 已結案，跳過
 *   - 急迫欄含 ✅/已完成/已解/銷案 → 已結案，跳過
 *  解析失敗（找不到段落/表格）→ 回 null，caller 誠實 fallback，不硬湊。排序交給 sortWaitingItems。 */
export function parseSprintWaitingTable(md: string, sprintFile?: string): WaitingItem[] | null {
  const secIdx = md.search(/^##\s*等\s*leo\s*清單/mu);
  if (secIdx < 0) return null;
  const section = md.slice(secIdx);
  const lines = section.split('\n');
  const items: WaitingItem[] = [];
  let sawTable = false;
  for (const line of lines.slice(1)) {
    if (/^#{2,3}\s/.test(line) && !/^##\s*等/.test(line)) break; // 下一個段落，停
    const m = line.match(/^\|\s*([A-Za-z]?\d+)\s*\|(.*)\|(.*)\|\s*$/u);
    if (!m) continue;
    sawTable = true;
    const id = m[1];
    const item = m[2].trim();
    const urgency = m[3].trim();
    if (item.startsWith('~~')) continue;
    if (CLOSED_MARKERS.test(urgency)) continue;
    items.push({
      id,
      urgency: urgency.match(URGENCY_EMOJI)?.[1] ?? '',
      title: cleanWaitingTitle(item),
      sprint: sprintFile,
    });
  }
  if (!sawTable) return null;
  return sortWaitingItems(items);
}

/** 🔴 > 🟡 > 其他；同急迫維持傳入順序（stable sort，現役 sprint 的項目先傳先顯示）。 */
export function sortWaitingItems(items: WaitingItem[]): WaitingItem[] {
  const rank = (u?: string) => (u === '🔴' ? 0 : u === '🟡' ? 1 : 2);
  return items
    .map((it, i) => ({ it, i }))
    .sort((a, b) => rank(a.it.urgency) - rank(b.it.urgency) || a.i - b.i)
    .map((x) => x.it);
}

/** 事項欄清洗：去 markdown 粗體/劃掉/行內 code、去開頭急迫 emoji（急迫欄已有，別重複）、
 *  截前 80 字（cell 動輒數百字，儀表板只要標題），截斷若留下未閉合的「（」順手剪掉。 */
export function cleanWaitingTitle(raw: string): string {
  let s = raw.replace(/\*\*/g, '').replace(/~~/g, '').replace(/`/g, '').trim();
  s = s.replace(/^(?:🔴|🟡|🟢|⚪)\s*/u, '');
  // 常見格式「**標題**（時間 說明）：長解釋…」→ 取第一個「：」前；太短則不切
  const colon = s.indexOf('：');
  if (colon >= 12) s = s.slice(0, colon);
  if (s.length > 80) s = `${s.slice(0, 79)}…`;
  // 截斷可能留下未閉合的全形括號 → 從最後一個未配對「（」剪掉
  let depth = 0;
  let lastOpen = -1;
  for (let i = 0; i < s.length; i++) {
    if (s[i] === '（') {
      if (depth === 0) lastOpen = i;
      depth++;
    } else if (s[i] === '）') {
      depth = Math.max(0, depth - 1);
    }
  }
  if (depth > 0 && lastOpen >= 0) s = s.slice(0, lastOpen).trim();
  return s;
}

/** sprint 檔命名慣例 sprint-YYYY-MM<letter>.md，字典序即時間序。
 *  取最新 n 個（新→舊）：現役 sprint 換檔後，前一個 sprint 的等leo清單常還有未銷案項
 *  （實例：07b 開了、🔴 mira 憑證外洩仍掛在 07a）——只讀最新一檔會漏掉真正在等的事。 */
export function pickLatestSprintFiles(names: string[], n = 2): string[] {
  return names
    .filter((name) => /^sprint-.*\.md$/.test(name))
    .sort()
    .slice(-n)
    .reverse();
}

/** fallback：dash_wait 同 title 取最新、只留 open——但每項帶 age、整包帶 stale 判定。
 *  這條路只在 Gitea 讀不到時走；它的資料 2026-07-04 之後就沒人維護，所以 stale 標記是義務。 */
export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): WaitingModel {
  const byTitle = new Map<string, { status: string; at_ms: number | null }>();
  for (const e of entries) {
    const j = parseJsonContent(e);
    const title = typeof j?.title === 'string' ? (j.title as string) : null;
    if (!title || byTitle.has(title)) continue;
    byTitle.set(title, {
      status: typeof j?.status === 'string' ? (j.status as string) : 'open',
      at_ms: parseCreatedAtMs(e.created_at),
    });
  }
  const open = [...byTitle.entries()].filter(([, v]) => v.status === 'open');
  const newestMs = [...byTitle.values()].reduce<number | null>(
    (m, v) => (v.at_ms !== null && (m === null || v.at_ms > m) ? v.at_ms : m),
    null,
  );
  const ago = agoMinutes(nowMs, newestMs);
  return {
    items: open.map(([title]) => ({ title })),
    source: byTitle.size ? 'kbdb_dash_wait' : 'none',
    updated_ago_minutes: ago,
    stale: ago < 0 || ago > 24 * 60,
    note: byTitle.size
      ? undefined
      : '管線未接：dash_wait 無資料、Gitea sprint 讀取未設定（GITEA_TOKEN）',
  };
}

/** 人話 age：分鐘 → 「3 分鐘前 / 5 小時前 / 3 天前」。-1 → 「時間不明」。 */
export function humanAge(minutes: number): string {
  if (minutes < 0) return '時間不明';
  if (minutes < 60) return `${minutes} 分鐘前`;
  if (minutes < 48 * 60) return `${Math.round(minutes / 60)} 小時前`;
  return `${Math.round(minutes / (24 * 60))} 天前`;
}

// ── Gitea 讀取快取（總管 #36 審查要求：查詢面加快取——別讓前端 60 秒刷新變成
//    每分鐘 3-4 個 API call 打自家 Gitea；快取是讀，不違「不輪詢」紅線）──────────

/** 快取 TTL（秒）。90s ＝ 前端 60s 刷新下，Gitea 實際被打 ≤1 輪/90s；
 *  等leo清單由 progress-guard 每日維護，90 秒新鮮度綽綽有餘。 */
export const GITEA_WAITING_CACHE_TTL_SECONDS = 90;

/** Cache API 裡存的封套：模型 + fetch 當下的牆鐘（回放時補算 age 用）。 */
export interface CachedWaitingEnvelope {
  model: WaitingModel;
  fetched_at_ms: number;
}

/** 快取回放時把「維護於 N 分鐘前」隨牆鐘推進：存的是 fetch 當下算好的 ago，
 *  直接回放會讓時間停走（最多差一個 TTL，雖小仍是假時間）；補回經過的分鐘數、
 *  stale 判定同步重算，才符合「不擺 stale 殘骸」的原則。 */
export function reviveWaitingAges(model: WaitingModel, fetchedAtMs: number, nowMs: number): WaitingModel {
  if (model.updated_ago_minutes < 0) return model;
  const drift = Math.max(0, Math.round((nowMs - fetchedAtMs) / 60000));
  const ago = model.updated_ago_minutes + drift;
  return { ...model, updated_ago_minutes: ago, stale: ago > 48 * 60 };
}
