/**
 * 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_* 資料）。
 */

import { taipeiDayKey } from './taipei-time';

// 台北日曆日 key 的唯一實作搬到 lib/taipei-time.ts（fix/console-truth-audit：console 全站
// 日期顯示統一台北，server/client 共用同一套）。這裡 re-export 維持既有 import 路徑不破。
export { taipeiDayKey };

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

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)}…`;
  // 截斷可能留下未閉合的全形括號 → 共用 trimUnbalancedParen（板標題同款處理）
  return trimUnbalancedParen(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）',
  };
}

// ── sprint 任務板（「今日完成」活資料源，leo 2026-07-07 拍板）──────────────────────
//
// 動機（leo 原話）：「今天做了這麼多事……其實就是我們到底完成了多少事」。dash_task 沒有
// 活管線（同 dash_wait 病），真相源＝InkStoneCo sprint 檔「## 任務板」（progress-guard /
// cloud-worker 每日維護勾選）。比照「等你的事」#36 模式：同一輪 Gitea fetch 撈的檔案
// 順手解析任務板，dash_task 降為 fallback。

export interface SprintBoardTask {
  title: string;
  /** [x]=done、[🔄]=doing、[ ]=todo、[!]=blocked（板上維護慣例，對照 sprint-2026-07a.md）。
   *  🔄/! 後帶註記也認（07b 實例 `[🔄→只剩1042]`）；x 帶註記不冒領 done、整行略過。 */
  status: 'done' | 'doing' | 'todo' | 'blocked';
  /** 「完成（…YYYY-MM-DD…）」標記對應的**台北候選日**（一項可有多次完成，如 T6-T9 分兩天）。
   *  [x] 但沒有帶日期的完成標記 → 空陣列＝無法歸日，任何一天都不冒領（寧可略過不誤判）。
   *  ⚠️ 跨 UTC 日界（fix/console-today-write-dayline，2026-07-08 live bug）：cloud-worker
   *  的 sandbox 時鐘是 UTC、routine 排程在台北清晨（06:30＝UTC 前一日 22:30）——它戳的
   *  日期 D 其實是台北 D+1 凌晨的完成。故帶 [cloud-worker] 署名的戳展開成 {D, D+1} 兩個
   *  候選日（不展開＝今天凌晨完成的整天數不到＝leo 實際撞到的 0/10）；本機/總管戳台北日，
   *  照舊精確不展開。 */
  completed_days: string[];
  /** 合併多個 sprint 檔時標出處（如 "sprint-2026-07b.md"） */
  sprint?: string;
}

/** 狀態欄放寬到「任意非 ] 內容」再由 classifyBoardMark 分類——07b 實例 `[🔄→只剩1042]`
 *  用舊的單字元 regex 會讓整項任務蒸發（連 today_total 都少算）。 */
const BOARD_LINE = /^- \[([^\]]*)\]\s*(.*)$/u;
/** 完成標記的括號內容（到閉括號/行尾）。日期不再要求緊貼「完成（」——07a 慣例日期在前
 *  （`完成（2026-07-02，[cloud-worker]）`）、07b 起署名在前（`完成（[cloud-worker]
 *  2026-07-07，…）`），舊 regex 只吃得下前者，T7 的完成日因此抓不到（live bug 根因 1）。 */
const DONE_STAMP = /完成（([^）\n]*)/gu;
const STAMP_DAY = /\d{4}-\d{2}-\d{2}/u;
/** 時鐘走 UTC 且排程落在台北 00:00-08:00 窗（會跨日界）的雲端寫手署名。progress-guard
 *  跑台北 16:30（UTC 08:30，兩邊同日）不在此列——對它展開只會多冒領不會少算。 */
const UTC_DAYLINE_ACTORS = /\[cloud-worker\]/u;

/** 台北日 key 的隔天（'2026-07-07' → '2026-07-08'）。 */
export function nextDayKey(day: string): string {
  const ms = Date.parse(`${day}T00:00:00Z`);
  return Number.isNaN(ms) ? day : new Date(ms + 24 * 3600 * 1000).toISOString().slice(0, 10);
}

/** checkbox 狀態分類。null＝看不懂的標記（寬鬆容錯：整行略過，尤其不冒領 done）。 */
function classifyBoardMark(mark: string): SprintBoardTask['status'] | null {
  if (mark === ' ' || mark === '') return 'todo';
  if (mark === 'x' || mark === 'X') return 'done';
  if (mark.startsWith('🔄')) return 'doing';
  if (mark.startsWith('!')) return 'blocked';
  return null;
}

/** 從一段文字抽完成標記的台北候選日（含 UTC 寫手的 {D, D+1} 展開，見 SprintBoardTask 註解）。 */
export function extractCompletedDays(text: string): string[] {
  const days: string[] = [];
  const push = (d: string) => {
    if (!days.includes(d)) days.push(d);
  };
  for (const m of text.matchAll(DONE_STAMP)) {
    const content = m[1];
    const day = content.match(STAMP_DAY)?.[0];
    if (!day) continue;
    push(day);
    if (UTC_DAYLINE_ACTORS.test(content)) push(nextDayKey(day));
  }
  return days;
}

/** 截斷/切冒號後可能留下未閉合的全形括號 → 從最後一個未配對「（」剪掉。
 *  整串都在括號裡（如「（備註：…」這種註記行）會剪成空字串——
 *  caller 視空標題為「不是任務」跳過，恰好把括號註記行濾掉。 */
function trimUnbalancedParen(s: string): string {
  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;
}

/** 板上事項標題＝「第一個全形冒號前」的裸文字（板的維護慣例：`**任務代號**：長敘述…`）。
 *  不能用「第一個粗體」——實讀 07a 發現長敘述裡的 `**完成（2026-07-02…）**` 註記常比
 *  任務名先出現粗體，會抽成「完成（…）」假標題。冒號太前（<4 字，如「附帶：」）不切。 */
export function cleanBoardTitle(raw: string): string {
  let s = raw.replace(/\*\*/g, '').replace(/~~/g, '').replace(/`/g, '').trim();
  const colon = s.indexOf('：');
  if (colon >= 4) s = s.slice(0, colon);
  if (s.length > 80) s = `${s.slice(0, 79)}…`;
  return trimUnbalancedParen(s.trim());
}

/**
 * 從 sprint md 撈「## 任務板」段（到下一個 `## ` 為止，`###` 子段照收），解析頂層
 * checkbox 事項。只有**行首**的 `- [x]` 型式會成為任務——縮排子彈（「  - 做法：」）與
 * 無 checkbox 的候補區純敘述行都不是任務；但縮排子彈上的**完成標記歸到所屬任務**
 * （07b 實際維護慣例：CP1/T5-provider 的任務行只有 [x]，完成註記整段寫在子彈上——
 * 舊 parser 不掃子彈 → [x] 無日期＝整項蒸發，live bug 根因 2）。找不到任務板段落 →
 * null（caller 誠實 fallback dash_task，不硬湊）。解析不了的行一律略過，不誤判成完成
 * （寬鬆容錯原則）。
 */
export function parseSprintTaskBoard(md: string, sprintFile?: string): SprintBoardTask[] | null {
  const secIdx = md.search(/^##\s*任務板/mu);
  if (secIdx < 0) return null;
  const section = md.slice(secIdx);
  const lines = section.split('\n');
  const tasks: SprintBoardTask[] = [];
  let current: SprintBoardTask | null = null; // 縮排子彈的完成標記要歸的任務
  for (const line of lines.slice(1)) {
    if (/^##\s/.test(line) && !/^###/.test(line)) break; // 下一個 H2 段落，任務板結束
    if (/^###\s/.test(line)) {
      current = null; // 新子段開始，別把後面的子彈掛到前段的任務
      continue;
    }
    const m = line.match(BOARD_LINE);
    if (m) {
      current = null;
      const status = classifyBoardMark(m[1]);
      const body = m[2];
      const title = cleanBoardTitle(body);
      if (status === null || !title) continue;
      current = { title, status, completed_days: extractCompletedDays(body), sprint: sprintFile };
      tasks.push(current);
      continue;
    }
    if (/^\s+\S/.test(line)) {
      // 縮排補充行：完成標記歸到所屬任務；行本身不是任務
      if (current) {
        for (const d of extractCompletedDays(line)) {
          if (!current.completed_days.includes(d)) current.completed_days.push(d);
        }
      }
      continue;
    }
    if (line.trim() !== '') current = null; // 非縮排敘述行（候補區等）＝脫離上一個任務
  }
  return tasks.length ? tasks : null;
}

export interface SprintRouteModel {
  /** 今日路線清單：今天完成的（done）＋ 板上仍開著的（doing/todo/blocked），板序即優先序 */
  tasks: { title: string; status: 'done' | 'doing' | 'todo' | 'blocked'; sprint?: string }[];
  today_done: number;
  /** 今日完成 ＋ 仍開著的板上事項（今天的可做面）——不是「今天排定量」，板不按日排定 */
  today_total: number;
  done_today_titles: string[];
}

/**
 * 任務板 →「今日完成」模型：done 只數「台北候選完成日含今天」的項（candidate 日集合
 * 見 SprintBoardTask.completed_days 註解，UTC 寫手的跨日界已在 parse 側展開）；歷史
 * 完成項（前幾天勾的）不進今日清單也不進計數——不冒領昨天的功。開著的項照板序全列。
 */
export function buildSprintRouteModel(tasks: SprintBoardTask[], nowMs: number): SprintRouteModel {
  const todayKey = taipeiDayKey(nowMs);
  const doneToday = tasks.filter((t) => t.status === 'done' && t.completed_days.includes(todayKey));
  const open = tasks.filter((t) => t.status !== 'done');
  return {
    tasks: [...doneToday, ...open].map((t) => ({ title: t.title, status: t.status, sprint: t.sprint })),
    today_done: doneToday.length,
    today_total: doneToday.length + open.length,
    done_today_titles: doneToday.map((t) => t.title),
  };
}

/** 人話 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;

/** 同一輪 Gitea fetch 的兩個產物：等你的事（等leo清單）＋ 今日完成/路線（任務板）。
 *  板任務存「原始 completed_days」而非「今天完成幾件」——今天是誰由請求當下判定
 *  （buildSprintRouteModel），快取跨台北午夜也不會把昨天的完成假裝成今天的。 */
export interface SprintSnapshot {
  waiting: WaitingModel;
  /** null = 檔案讀到了但沒有可解析的任務板（caller fallback dash_task） */
  board: SprintBoardTask[] | null;
}

/** Cache API 裡存的封套：快照 + fetch 當下的牆鐘（回放時補算 age 用）。 */
export interface CachedWaitingEnvelope {
  snapshot: SprintSnapshot;
  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 };
}
