// 標庫 backfill（Arcrun#85 二次裁決，2026-08-11；相關票 Arcrun#87「藏書地圖是空的」）。
//
// 背景：leo 把向量化優先序講成一句話後又補一刀：「它是兩件事？其實是一件——沒有庫就
// 剩一個，但你把庫標好以後，原來的判定要修改對吧」。⇒ 判定標準（embed.ts 的
// SelectionCriteria）必須從第一天就同時容納時間與庫，本檔提供「庫」這一半真正的資料。
//
// base 的 write path 早就支援（`createEntry` 的 `metadata_json.$.library`，t52：「庫由
// ingest 蓋章決定」）——既有的搜尋／embed／deprecate-by-library 全都讀這個欄位，
// **缺的不是機制，是既有資料沒被蓋章**（library-map.ts 檔頭 2026-07-19 對 prod 核實：
// 既有 entries 的 metadata.library 是空的）。「源頭寫入就貼標」是呼叫端（ingest）的事，
// base 這裡管不到、也不該猜（base 對內容語意無知的既有原則）；triplet 的實際寫入更是
// 在另一個 repo（見 kbdb/src/index.ts 檔頭「triplet (separate repo)」）。
//
// 這個模組只做「補存量」那一半，且刻意設計成呼叫端驅動：
//   - base 不猜「這筆該屬於哪個庫」——那是語意判斷。呼叫端給一個 target library 值
//     ＋一組篩選條件，base 只負責把符合條件、目前未標記的 entries 安全、節流地蓋上這個值。
//   - 篩選條件有兩種精度（2026-08-11 leo 定案的做法後補上）：
//     ① 精準比對 `page_names`（IN 清單）——leo 定的正解：「有 2 份原稿，在 gitea 和我的
//        Mac……去 gitea 把每個庫有哪些的卡名列出，跑來遍歷應該就搞定了」。呼叫端（daemon／
//        #87）從 Gitea repo 列出卡名，逐批把「這些卡名屬於庫 X」精準地寫進來，不必猜。
//     ② `source_prefix`／`page_name_prefix` 前綴 fallback（同 library-map.ts
//        recomputeLibraryMap 的 source_prefix 精神，只是這裡是寫入不是聚合）——沒有精準
//        清單時的過渡手段，精度不如①，兩者可並用（AND）縮小範圍。
//   - 冪等：已標記的 entries 不會再入選（WHERE 帶「library 為空」）。
//
// D69 節流：與 reconcileEmbedGeneration（embed.ts）共用 maintenance-quota.ts 的同一顆
// 每日 D1 寫入計數器——兩者都是「多筆 D1 row write、不打 AI」的背景維護操作，不共用
// 計數器的話，補存量時會把世代核對的閘繞過去（leo 2026-08-11 二次裁決原話：「每日上限
// 這件事不只管向量化，也要管補標，否則做標庫時就會把補算的閘繞過去」）。
import type { Bindings } from '../types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';

// IN 清單長度上限（避開 D1/SQLite bound-parameter 上限；一次點名這麼多張卡已經很夠用，
// 呼叫端清單更長就自然分批呼叫，跟 limit 分頁是同一種節奏）。
const MAX_PAGE_NAMES = 300;

export interface LibraryBackfillCriteria {
  owner_id?: string;
  entry_type?: string;
  page_names?: string[];      // 精準比對 page_name（IN 清單）——leo 定案的正解：從 Gitea repo
                               // 列出卡名，逐批精準點名「這些卡名屬於庫 X」（見檔頭說明①）。
  source_prefix?: string;     // metadata_json.$.source LIKE prefix%（過渡 fallback，見檔頭②）
  page_name_prefix?: string;  // page_name LIKE prefix%（過渡 fallback，見檔頭②）
  since?: number;             // created_at >= since（unix seconds）
  until?: number;             // created_at < until（unix seconds）
}

export interface LibraryBackfillResult {
  library: string;
  scanned: number;          // 本批掃到的候選筆數（受 limit 限制，額度截斷前）。
  tagged: number;           // 本次真的寫入 metadata_json.$.library 的筆數。
  remaining: number;        // 本次之後仍待補標（符合條件、仍未標記）的筆數，不受額度影響。
  quota_limit: number;      // 今日「背景維護 D1 寫入」額度上限（與 reconcile 共用）。
  quota_used_today: number; // 本次呼叫後，今日累積已消耗的背景維護寫入額度。
  quota_exceeded: boolean;  // 本批是否因額度不足被截斷。
}

// 單次呼叫候選上限（避開 subrequest/CPU/timeout；一批只有 1 次 SELECT + 1 次 UPDATE，
// 比 reconcile 多一次 Vectorize 呼叫的成本低，故上限可以放寬一些）。
const HARD_LIMIT_CAP = 500;

function criteriaPredicate(c: LibraryBackfillCriteria): { conds: string[]; params: unknown[] } {
  // 冪等的核心：只選「目前沒有 library 值」的候選，已標記過的（含標成 'general' 的）不會再入選。
  const conds: string[] = [
    "(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')",
  ];
  const params: unknown[] = [];
  if (c.owner_id) { conds.push('owner_id = ?'); params.push(c.owner_id); }
  if (c.entry_type) { conds.push('entry_type = ?'); params.push(c.entry_type); }
  if (c.page_names && c.page_names.length > 0) {
    const names = c.page_names.slice(0, MAX_PAGE_NAMES);
    conds.push(`page_name IN (${names.map(() => '?').join(',')})`);
    params.push(...names);
  }
  if (c.source_prefix) { conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'"); params.push(c.source_prefix); }
  if (c.page_name_prefix) { conds.push("page_name LIKE ? || '%'"); params.push(c.page_name_prefix); }
  if (typeof c.since === 'number') { conds.push('created_at >= ?'); params.push(c.since); }
  if (typeof c.until === 'number') { conds.push('created_at < ?'); params.push(c.until); }
  return { conds, params };
}

/**
 * 對「符合條件、目前未標記 library」的既有 entries 批次蓋上 target library 值。
 * 冪等 + 分批（單次 limit 上限）+ budget（與 reconcile 共用每日 D1 寫入額度，見檔頭）。
 * 呼叫端（ingest / Arcrun#87）決定「這批是誰、該貼哪個庫」，本函式只負責安全、節流地
 * 把值寫進去——base 不猜語意，也因此不假裝「這樣就把 #87 做完了」（mindset §7）。
 *
 * `owner_id` 刻意設成**必填**（不同於 LibraryBackfillCriteria 其餘欄位皆選填）：
 * 2026-08-11 leo 在票上點出「補標補在錯的 owner 底下等於白做」（實查發現卡片實際掛在
 * `owner_id=bfezv28v`，換成 `owner_id='leo'` 查卻是空的——兩個候選 owner 已經在互相打架）。
 * 跟既有的 `deprecateEntriesByLibrary`（同樣是「依 library 批次改一大片既有資料」的操作）
 * 同一個防線：不給不知道自己在改誰的資料的呼叫端一個「忘記帶 owner_id 就變成跨租戶全庫掃」
 * 的後門，逼呼叫端明確想清楚「這批是哪個 owner」再動手。
 */
export async function backfillEntryLibraryTags(
  db: D1Database,
  env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
  opts: { library: string; owner_id: string; limit?: number } & Omit<LibraryBackfillCriteria, 'owner_id'>,
): Promise<LibraryBackfillResult> {
  const library = (opts.library ?? '').trim();
  if (!library) throw new Error('library required');
  const ownerId = (opts.owner_id ?? '').trim();
  if (!ownerId) throw new Error('owner_id required（標庫是跨大量既有資料的批次寫入，不准無租戶範圍地掃全庫——2026-08-11 leo 直令）');
  const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);

  const sel = criteriaPredicate({ ...opts, owner_id: ownerId });
  const where = sel.conds.join(' AND ');
  const params = sel.params;

  const res = await db
    .prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`)
    .bind(...params, limit)
    .all<{ id: string }>();
  const scannedIds = (res.results ?? []).map((r) => r.id);
  const scanned = scannedIds.length;

  // D69：額度截斷——每個候選最多 1 次 D1 write，與 reconcile 共用同一顆計數器。
  const budget = await maintenanceBudgetToday(env, db);
  const ids = scannedIds.slice(0, budget.remaining);
  const quotaExceeded = scanned > ids.length;

  let tagged = 0;
  if (ids.length > 0) {
    const ph = ids.map(() => '?').join(',');
    await db
      .prepare(
        `UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`,
      )
      .bind(library, ...ids)
      .run();
    tagged = ids.length;
  }

  try {
    await addMaintenanceUsage(db, tagged);
  } catch {
    // fail-open：額度計數寫入失敗不影響已經完成的標庫寫入（精神同 embed.ts 的做法）。
  }

  const remRow = await db
    .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
    .bind(...params)
    .first<{ c: number }>();

  return {
    library,
    scanned,
    tagged,
    remaining: remRow?.c ?? 0,
    quota_limit: budget.limit,
    quota_used_today: budget.used + tagged,
    quota_exceeded: quotaExceeded,
  };
}

/** 待補標統計（回報用）：符合條件、目前未標記 library 的筆數。 */
export async function libraryBackfillStatus(
  db: D1Database,
  opts: LibraryBackfillCriteria = {},
): Promise<{ pending: number }> {
  const sel = criteriaPredicate(opts);
  const where = sel.conds.join(' AND ');
  const row = await db
    .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
    .bind(...sel.params)
    .first<{ c: number }>();
  return { pending: row?.c ?? 0 };
}
