// KBDB optional embed module (issue #7 / mira-dissolve SDD T2.4).
//
// 鐵律對齊：
//   - embedding 屬 **base 的 optional 模組**（非 graph/ingest）。CF 內建（Vectorize+AI），程式薄。
//   - **不拆 repo，binding 開/關**：有 env.VECTORIZE + env.AI 才啟用；沒有 → base 維持 LIKE keyword，API 不變。
//   - 不動三表結構（只標既有 entries.is_embedded / content_hash bookkeeping 欄；那些 base 從不讀，embed 才寫）。
//   - 不對每個 block 地毯式 embed（精耕，非 RAG 一股腦灌）：只 embed「被標記為 embeddable」的 entry
//     （wiki 段落 + graph node gloss）。標記方式＝寫入時 metadata_json.embed === true（caller 顯式標）。
//
// 為何用 metadata flag 而非 entry_type 白名單：base 不該寫死「哪些 entry_type 該 embed」（那是上游語意，
// 會讓 base 知道 wiki/graph 概念，破壞解耦）。改由 caller（wiki/gloss 寫入端）顯式標 embed:true，
// base 只認這個通用旗標 → base 維持對內容語意無知。

import type { Bindings, Entry } from './types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './actions/maintenance-quota';

// ── 嵌入模型（Arcrun#59：模型應可配置＋index 版本化，支援換代重刷）────────────────
//
// 2026-08-03 換代：`@cf/baai/bge-base-en-v1.5`（768-dim）→ `@cf/baai/bge-m3`（1024-dim）。
//
// 為什麼換（實測，不是憑感覺）：舊模型是**英文模型**，拿來嵌中文等於嵌一堆看不懂的 token。
// 用 5 組中文問答測資（每組 1 問 + 2 段相關 + 3 段無關，無關的刻意放同一知識庫裡的其他主題），
// 算 margin = min(相關分數) − max(無關分數)，margin ≤ 0 代表**排序是錯的**：
//   @cf/baai/bge-base-en-v1.5   768   排序正確 2/5   平均 margin -0.0413   1660 ms  ← 舊
//   @cf/google/embeddinggemma-300m  768   4/5   +0.1275   1174 ms
//   @cf/baai/bge-m3            1024   **5/5**   **+0.1410**    959 ms  ← 新（品質最好且最快）
//   @cf/qwen/qwen3-embedding-0.6b  1024   4/5   +0.1381   3238 ms
// 舊模型最刺眼的一組：問「知識庫問答為什麼要標出處？」→「**會議室預約規則**」0.7789
// 竟然高於真正相關的 0.7306。這正是 leo 2026-07-18 回報的「問 RAG 卻引用會議室規範」。
//
// 🔴 換模型＝**必須換 Vectorize index**，兩個理由：
//   ① 維度不同（768→1024），舊 index 收不進新向量；
//   ② 就算維度相同也不能沿用——不同模型的向量混在同一個 index，比對出來是垃圾，
//      而 Arcrun#58（Vectorize vector delete 未接）代表舊向量**刪不掉**。
//   ⇒ 開新 index 反而順手繞開 #58：新 index 天生乾淨，舊的整個丟掉。
//
// 換代步驟（installer 已把新 index 名與維度對齊）：建新 index → 重新部署 kbdb（binding 指新 index）
//   → 打 backfill 的 `reindex=true`（把 embed=1 的既有 entry 全部重嵌）→ 舊 index 可刪。
const DEFAULT_EMBED_MODEL = '@cf/baai/bge-m3'; // 1024-dim，與 Vectorize index dimensions=1024 對齊

// 🔴 2026-08-05 leo 實撞：換 bge-m3 後**語義搜尋全 0 命中**（新上傳的檔搜不到、舊檔偶爾才中）。
//    根因不在向量——實測 Vectorize 端排序完全正確（搜「閉環機」，目標檔穩坐 1-4 名）——
//    而在**分數閾值是綁在舊模型的分數尺度上的**：
//      舊 bge-base-en-v1.5：中文分數全擠 0.65-0.90（沒區辨力）⇒ t183 取 0.75 砍雜訊，對
//      新 bge-m3          ：分數尺度整體下移（相關 0.5-0.85、雜訊 0.4 上下）⇒ 0.75 砍掉的是**正解**
//    youlin 實例實測分布（bge-m3，08-05，直打 Vectorize query）：
//      「閉環機」            0.638 / 0.603 / 0.588 / 0.552 ← 全是目標檔，**全被 0.75 砍光**
//                           ────── 斷崖 ────── 0.446 以下才是雜訊
//      「火星座標 奧林帕斯山」 0.750…0.500 全是火星座標，0.475 以下才是雜訊
//      「人力媒合系統規劃書」   0.842 ← 僥倖 >0.75 存活。**這就是 leo 看到「舊檔中、新檔不中」的由來**
//    ⇒ 斷崖普遍落在 0.5 附近，取 **0.5**：相關的全留、雜訊仍砍。
//    誠實 trade-off：0.5 不是每個查詢都乾淨（實測「AI 上課名冊」0.658 的 ax-academy 會擠進來），
//    但「偶有雜訊」遠優於「什麼都搜不到」——後者是現在的狀態。
//
// 🔴 為什麼閾值住在這裡（而不是 portal）：它是**模型的性質**，不是頁面的偏好。
//    原本硬寫在 `cypher-executor/src/routes/portal-data.ts`，換模型時那裡沒人想到要改
//    ——這正是 bge-m3 換代「四處同步」清單漏掉的第五處。放在模型常數旁邊，
//    下次換模型的人一定會看到它。**別再把數字複製回呼叫端。**
// 🔴 2026-08-05 二修（leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關 ⇒「閾值設太寬？」——對）：
//    **固定門檻兩頭都不對**，因為每個查詢的分數尺度不一樣：
//      查詢                  正解區間        雜訊起點
//      關懷型 AI             0.645-0.770     0.547   ← 固定 0.5 會放進 6 筆雜訊
//      閉環機                0.552-0.638     0.446   ← 固定 0.6 會把正解砍到剩 2/4（＝今早那個 0 命中）
//      人力媒合系統規劃書      0.842           0.550
//    ⇒ 改成**相對門檻**：跟著這次查詢的最高分走，取 `max(絕對下限, top × 比例)`。
//      實測五組（上表＋「閉環機是什麼」「火星座標 奧林帕斯山」）：
//        固定 0.5 → 正解全留，但混入 9 筆雜訊
//        固定 0.6 → 雜訊 0，但「閉環機」兩組正解被砍到 2/4、1/4
//        相對     → 四組雜訊 0 且正解全留；「火星座標」留 3/6
//                   （被砍的是同一份檔的其他段落，使用者照樣找得到那份檔）
//    絕對下限的作用：整批分數都很低時（查詢與知識庫無關），純比例會讓垃圾等比放行 ⇒ 兜底。
const MIN_SCORE_ABS_FLOOR = 0.45;
const MIN_SCORE_TOP_RATIO = 0.8;

/**
 * 相對門檻：由「這批結果的最高分」推出要砍在哪。
 *
 * 🔴 為什麼不在 semanticSearch 裡直接套（寫測試時才發現的真問題，不是 fixture 過時）：
 *   Vectorize 端**不知道哪些已下架**（indexed metadata 沒有 status，見 upsert）。
 *   若最高分那筆是已下架的殘影（實測有 0.971 這種），拿它當基準算出的門檻
 *   會把真正的正解（0.6）一起砍光 ⇒ **又變成 0 命中**，正是 leo 08-05 早上撞的那個病。
 *   ⇒ 門檻必須在「hydrate＋濾掉下架」**之後**、對倖存者的最高分計算（見 routes/entries.ts）。
 */
export function relativeMinScore(topScore: number): number {
  return Math.max(MIN_SCORE_ABS_FLOOR, topScore * MIN_SCORE_TOP_RATIO);
}

/** 實際使用的嵌入模型：env 可覆寫（#59），未設用預設。 */
function embedModel(env: Bindings): string {
  const m = (env.EMBED_MODEL ?? '').trim();
  return m || DEFAULT_EMBED_MODEL;
}

/** embed 模組是否啟用（binding 都在才算開）。base 一切 embed 動作先過這關。 */
export function embedEnabled(env: Bindings): boolean {
  return !!(env.VECTORIZE && env.AI);
}

/** 一段文字 → 1024 維向量（Workers AI bge-m3，可由 env.EMBED_MODEL 覆寫）。空字串回 null（不 embed）。 */
async function embedText(env: Bindings, text: string): Promise<number[] | null> {
  const t = (text ?? '').trim();
  if (!t || !env.AI) return null;
  const res = (await env.AI.run(embedModel(env), { text: [t] })) as { data: number[][] };
  return res?.data?.[0] ?? null;
}

/**
 * 寫入時選擇性 embed（embed-on-write，#5 第4點併入此）。
 *   - 模組未開 → no-op（base 輕量）。
 *   - 只 embed 被標 embeddable 的 entry（metadata_json.embed === true）。其餘略過（非地毯式）。
 * 失敗不致命（fire-and-forget 由 caller 用 waitUntil 包；這裡只負責「能 embed 就 embed」）。
 * 回傳是否真的 embed 了（讓 caller 決定要不要標 is_embedded）。
 */
export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean> {
  if (!embedEnabled(env)) return false;
  if (!isEmbeddable(entry)) return false;
  const vec = await embedText(env, entry.content ?? '');
  if (!vec) return false;
  await env.VECTORIZE!.upsert([
    {
      id: entry.id,
      values: vec,
      // metadata 走 indexed 範圍：owner_id（租戶隔離）、entry_type、source（#5.1 過濾與語義共用）、
      // library（portal-auth P1「庫」filter）。library 在寫入端正規化：未標記＝'general'（design §3.2
      // 「未蓋章的舊資料視同 general」——D1 側用查詢端 COALESCE fallback，Vectorize filter 做不了
      // COALESCE，故在 upsert 時蓋 'general'，查詢端單純 $in 即可）。
      metadata: {
        owner_id: entry.owner_id ?? '',
        entry_type: entry.entry_type,
        source: readSource(entry) ?? '',
        library: readLibrary(entry) ?? 'general',
      },
    },
  ]);
  // 標記 bookkeeping（既有欄，base 不讀、僅供「已 embed」可查）。不動表結構。
  // content_hash 順手蓋成「這次嵌入用的模型」（世代戳記，見下方 reconcileEmbedGeneration 的
  // 說明）——這裡是「新寫的立刻算」的路徑，寫入當下 model 必為現行 model，不會有世代落差。
  await env.DB
    .prepare('UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?')
    .bind(embedModel(env), entry.id)
    .run();
  return true;
}

/** entry 是否該被 embed：caller 在 metadata_json 標 embed:true（精耕，非地毯式）。 */
function isEmbeddable(entry: Entry): boolean {
  const meta = parseMeta(entry.metadata_json);
  return meta?.embed === true;
}

function readSource(entry: Entry): string | null {
  const meta = parseMeta(entry.metadata_json);
  const s = meta?.source;
  return typeof s === 'string' ? s : null;
}

/** metadata_json.$.library（portal-auth P1）。非字串/空字串一律視同未標記（→ caller fallback 'general'）。 */
function readLibrary(entry: Entry): string | null {
  const meta = parseMeta(entry.metadata_json);
  const l = meta?.library;
  return typeof l === 'string' && l.trim() !== '' ? l : null;
}

function parseMeta(json: string | null): Record<string, unknown> | null {
  if (!json) return null;
  try {
    const p = JSON.parse(json);
    return p && typeof p === 'object' ? (p as Record<string, unknown>) : null;
  } catch {
    return null;
  }
}

// SQL predicate for "an entry that SHOULD be embedded but isn't yet".
//   - isEmbeddable 契約 = metadata_json.embed === true（base 通用旗標，對內容語意無知，不寫死 entry_type）。
//     SQLite json_extract 對 JSON boolean true 回整數 1 → `= 1` 精確對齊 TS 的 `=== true`。
//   - is_embedded = 0：尚未（對「當前」index）補嵌的 bookkeeping。
//   - content 非空：空字串 embedText 會回 null，排除以免變成永遠清不掉的殘留候選。
const BACKFILL_PREDICATE =
  "is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";

// ── 每日額度上限（D68，2026-08-11：leo「補算向量照時間新到舊、且每天有額度上限」）─────────
//
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日免費 10,000 neurons（UTC 午夜重置，
// 見頂層 wiki ops-facts.md「萃取與向量化吃同一份 Workers AI 額度」）。backfill 是背景低優先
// 動作，不該把當天額度燒光讓萃取／今天的新寫入整天卡死（embedOnWrite 不受此上限——「新寫的
// 立刻算」是 D68 三條之一，不能被 backfill 的節制連坐）。自設「軟上限」，非 Cloudflare 硬限制，
// 可用 env.EMBED_BACKFILL_DAILY_LIMIT 覆寫（精神比照 execution-log.ts 的 DEFAULT_DAILY_LIMIT）。
//
// 預設值怎麼選（不是拍腦袋，2026-08-11 查證 Cloudflare 官方定價後回推）：
//   bge-m3 定價：1,075 neurons / 1,000,000 input tokens（無輸出 token 成本，embedding 只有輸入）。
//   保守估計每筆中文知識卡片 ~800 tokens（寧可高估——CJK tokenizer 密度通常高於英文，
//     高估 token 數 ⇒ 算出的「每日可嵌筆數」偏保守，不會撞真的 CF 額度）：
//     800 tokens × 1,075 / 1,000,000 ≈ 0.86 neurons/entry
//   backfill 分到日配額 20%（比照 execution-log.ts「自我節制、留大部分給主流程」的既有慣例）：
//     10,000 × 20% = 2,000 neurons/日
//   2,000 ÷ 0.86 ≈ 2,325 entries/日，再打八折留緩衝（token 估計誤差／其他背景消耗）：
//     2,325 × 0.8 ≈ 1,860 → 取整數 1,800。
const DEFAULT_BACKFILL_DAILY_LIMIT = 1800;

function backfillDailyLimit(env: Pick<Bindings, 'EMBED_BACKFILL_DAILY_LIMIT'>): number {
  const raw = env.EMBED_BACKFILL_DAILY_LIMIT;
  const n = raw ? parseInt(raw, 10) : NaN;
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
}

function utcDay(): string {
  return new Date().toISOString().slice(0, 10);
}

/** 額度計數器 entries id（單一列/日，UTC 日期字串，換日自然歸零；不分租戶——Workers AI 額度是帳號級）。 */
function backfillUsageId(): string {
  return `embed-backfill-usage:${utcDay()}`;
}

/**
 * 今天 backfill 已消耗的筆數。儲存精神完全比照 execution-log.ts 的 checkUsage：單一 entries 列/日
 * （entry_type='embed_backfill_usage'，計數包進 metadata_json），不新增表。
 * 讀取失敗（含壞資料）誠實視為 0（caller 決定是否 fail-open）。
 */
async function getBackfillUsageToday(db: D1Database): Promise<number> {
  const row = await db
    .prepare('SELECT metadata_json FROM entries WHERE id = ?')
    .bind(backfillUsageId())
    .first<{ metadata_json: string | null }>();
  if (!row) return 0;
  try {
    const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { embedded?: number }) : {};
    return Number(parsed.embedded) || 0;
  } catch {
    return 0; // 壞資料誠實視為 0，不讓損毀的計數器卡死額度機制
  }
}

/** 今天 backfill 額度用量 +by（upsert：讀現有列 → +by → UPDATE，不存在則 INSERT，冪等日切）。 */
async function addBackfillUsage(db: D1Database, by: number): Promise<void> {
  if (by <= 0) return;
  const id = backfillUsageId();
  const existing = await db
    .prepare('SELECT metadata_json FROM entries WHERE id = ?')
    .bind(id)
    .first<{ metadata_json: string | null }>();
  let prev = 0;
  if (existing) {
    try {
      const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { embedded?: number }) : {};
      prev = Number(parsed.embedded) || 0;
    } catch {
      prev = 0;
    }
    await db
      .prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
      .bind(JSON.stringify({ day: utcDay(), embedded: prev + by }), id)
      .run();
  } else {
    await db
      .prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`)
      .bind(id, JSON.stringify({ day: utcDay(), embedded: by }))
      .run();
  }
}

// ── 「挑哪一批」可以從外面指定（Arcrun#85，2026-08-11 leo 二度裁決）───────────────
//
// leo 的優先序不是「一律新到舊」的單一佇列，是**分層**：今天寫的立刻／這週在跑的先跑／
// 有查詢紀錄的庫優先／半年前的慢慢跑。分層要能實作，前提是「這次補哪一批」要能從外面
// （工作流）指定，不能只靠資料層自己決定的固定排序——策略要住在 leo 打得開的地方
// （工作流頁），不是焊死在這裡看不見也改不動。
//
// 這裡不預先幫 caller 決定「四層怎麼切」（那是策略，屬於呼叫端/工作流，見 Arcrun#85
// D70 段落的意圖草案），只提供**同一套篩選形狀**讓任何一層都能表達：
//   - since／until：時間窗（unix seconds，created_at 半開區間 [since, until)）——時間分層
//     （①今天／②本週／④半年前）都是同一個 since/until 參數，差別只在呼叫端傳的值。
//   - library：依 metadata_json.$.library 過濾——一旦資料身上有庫這個資訊（Arcrun#87），
//     「有查詢紀錄的庫優先」這層可以直接用同一個參數，不必再改介面形狀。
// 三個操作（backfillEmbeddings／reconcileEmbedGeneration／backfillEntryLibraryTags，
// 見 actions/library-backfill.ts）共用這個形狀，這就是「判定標準只有一份」的意思——
// 不是先做時間、之後為了庫再回頭改介面。
export interface SelectionCriteria {
  owner_id?: string;
  source?: string;
  library?: string; // 精確比對 metadata_json.$.library（未標記的舊資料一律歸 'general'，同 embedOnWrite 慣例）
  since?: number;    // created_at >= since（unix seconds）
  until?: number;    // created_at < until（unix seconds）
}

function selectionCriteriaPredicate(opts: SelectionCriteria): { conds: string[]; params: unknown[] } {
  const conds: string[] = [];
  const params: unknown[] = [];
  if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
  if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
  if (opts.library) {
    conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?");
    params.push(opts.library);
  }
  if (typeof opts.since === 'number') { conds.push('created_at >= ?'); params.push(opts.since); }
  if (typeof opts.until === 'number') { conds.push('created_at < ?'); params.push(opts.until); }
  return { conds, params };
}

export interface BackfillResult {
  enabled: boolean;   // 模組是否開（false → 什麼都沒做，caller 該誠實回錯，不假裝）。
  processed: number;  // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
  skipped: number;    // 掃到但沒嵌（例如 embedText 回 null，或本批被額度擋下）的筆數。
  remaining: number;  // 本次之後仍待補嵌的筆數（可重複呼叫直到 0，與額度無關——單純候選總量）。
  scanned: number;    // 本批掃出的候選筆數（受 limit 限制）。
  quota_limit: number;      // 今日 backfill 額度上限（env.EMBED_BACKFILL_DAILY_LIMIT 或預設值）。
  quota_used_today: number; // 本次呼叫後，今日累積已消耗的 backfill 額度。
  quota_exceeded: boolean;  // 本批是否因額度不足被截斷（true＝還有可嵌的候選但今天不再打 AI，等明天/調高上限）。
}

/**
 * Backfill（回填）：對「開 Vectorize 之前就寫入、或 embed-on-write 當時漏掉」的既有 entry 批次補嵌。
 * 冪等（重跑已補嵌的不會重複算，upsert 同 id 冪等）、分批（單次 limit 上限，避開 subrequest/CPU/timeout）、
 * 回傳處理筆數 + 剩餘筆數（caller 重複呼叫直到 remaining=0）。
 *   - 模組未開（無 VECTORIZE+AI）→ 誠實回 { enabled:false }，不假裝成功（mindset §7 禁假綠）。
 *   - 只補「isEmbeddable（metadata.embed===true）且 is_embedded=0」的 entry——與 embedOnWrite 同一契約，
 *     base 維持對內容語意無知（不知 triplet/wiki，只認通用 embed 旗標）。
 *   - 效率：整批用「單次 AI.run（陣列輸入）+ 單次 VECTORIZE.upsert（陣列）+ 單次 UPDATE ... IN(...)」，
 *     一批 ≈ 3 個 subrequest，不隨 limit 線性增長 → free/paid tier 都安全。
 */
export async function backfillEmbeddings(
  env: Bindings,
  opts: SelectionCriteria & { limit?: number; reindex?: boolean; offset?: number } = {},
): Promise<BackfillResult> {
  if (!embedEnabled(env)) {
    return {
      enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0,
      quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
    };
  }
  const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
  const offset = Math.max(opts.offset ?? 0, 0);

  // reindex（Arcrun#11 根因修復）：對「既有已嵌」向量原樣重嵌重推 upsert，讓它們被『事後才建立』的
  //   Vectorize metadata index（owner_id/entry_type/source）收錄。Vectorize 只索引「metadata index
  //   建立之後 upsert」的向量 → 既有向量不重推就永遠 filter 不到（= 本 bug）。upsert 同 id 冪等。
  //   非 reindex（預設）＝原行為：只補 is_embedded=0 的漏網。
  const basePredicate = opts.reindex
    ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1"
    : BACKFILL_PREDICATE;

  // 🔴 2026-08-05：**已下架的一律不嵌**（leo：「理論上它的向量也要刪掉，就不會有殘影了吧？」）。
  //   沒有這條，下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活，
  //   而且 `reindex=true` 那條路更嚴重（它連 is_embedded=1 的都重推）。
  // 「挑哪一批」（Arcrun#85）：owner_id/source/library/since/until 全部走同一套
  // selectionCriteriaPredicate，讓呼叫端（工作流）能表達時間分層與庫分層，不必等
  // base 幫忙決定；本函式不預設任何一層，caller 傳什麼就篩什麼。
  const sel = selectionCriteriaPredicate(opts);
  const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
  const params: unknown[] = [...sel.params];
  const where = conds.join(' AND ');

  // D68：由新到舊——最可能被查到的最先補回來（見檔頭 DEFAULT_BACKFILL_DAILY_LIMIT 段的決策脈絡）。
  const res = await env.DB
    .prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
    .bind(...params, limit, offset)
    .all<Entry>();
  const rows = res.results ?? [];
  const scanned = rows.length;

  // D68：每日額度上限。額度是「這次呼叫要不要打 AI」的唯一守門——reindex 一樣要打 AI.run，
  // 同樣受限（不因為是 reindex 就例外，會打 Workers AI 的動作都算）。
  const dailyCap = backfillDailyLimit(env);
  let usedToday = 0;
  try {
    usedToday = await getBackfillUsageToday(env.DB);
  } catch {
    usedToday = 0; // fail-open：計數器本身故障（含 D1 額度打滿）不該連 backfill 都不做
  }
  const remainingQuota = Math.max(0, dailyCap - usedToday);

  let processed = 0;
  const candidates = rows.filter((e) => (e.content ?? '').trim().length > 0);
  // 額度截斷：candidates 已按 created_at DESC 排序，取前 remainingQuota 筆＝優先保留最新的。
  const embeddable = candidates.slice(0, remainingQuota);
  const quotaExceeded = candidates.length > embeddable.length;

  if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
    const texts = embeddable.map((e) => (e.content ?? '').trim());
    const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] };
    const data = out?.data ?? [];
    const vectors = embeddable
      .map((e, i) => ({ e, vec: data[i] }))
      .filter((x): x is { e: Entry; vec: number[] } => Array.isArray(x.vec) && x.vec.length > 0)
      .map((x) => ({
        id: x.e.id,
        values: x.vec,
        metadata: {
          owner_id: x.e.owner_id ?? '',
          entry_type: x.e.entry_type,
          source: readSource(x.e) ?? '',
          library: readLibrary(x.e) ?? 'general', // 同 embedOnWrite：寫入端正規化（P1）
        },
      }));
    if (vectors.length > 0) {
      await env.VECTORIZE.upsert(vectors);
      const ids = vectors.map((v) => v.id);
      const placeholders = ids.map(() => '?').join(',');
      // content_hash 順手蓋成現行模型（世代戳記，見 reconcileEmbedGeneration）。
      await env.DB
        .prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`)
        .bind(embedModel(env), ...ids)
        .run();
      processed = vectors.length;
      try {
        await addBackfillUsage(env.DB, processed);
      } catch {
        // fail-open：額度計數寫入失敗不影響已經完成的嵌入（別讓 bookkeeping 故障吞掉已做的工）；
        // 代價是下次呼叫可能少算一點用量——比「明明做了卻沒生效」安全（誠實限制，mindset §7）。
      }
    }
  }

  const remRow = await env.DB
    .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
    .bind(...params)
    .first<{ c: number }>();
  const totalMatching = remRow?.c ?? 0;
  // 非 reindex：predicate 含 is_embedded=0，處理後該筆變 1 → COUNT 自然遞減（重呼直到 0）。
  // reindex：predicate 不含 is_embedded，COUNT 恆等於總數 → 改用 offset 分頁計 remaining（否則永不終止）。
  const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
  return {
    enabled: true,
    processed,
    skipped: scanned - processed,
    remaining,
    scanned,
    quota_limit: dailyCap,
    quota_used_today: usedToday + processed,
    quota_exceeded: quotaExceeded,
  };
}

/** 補嵌進度統計（回報用；模組未開仍可查 pending 數，誠實標 enabled:false）。 */
export async function backfillStatus(
  env: Bindings,
  opts: { owner_id?: string; source?: string } = {},
): Promise<{ enabled: boolean; pending: number; embedded: number }> {
  const conds: string[] = [];
  const params: unknown[] = [];
  if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
  if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
  const extra = conds.length ? ` AND ${conds.join(' AND ')}` : '';
  const pendingRow = await env.DB
    .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${BACKFILL_PREDICATE}${extra}`)
    .bind(...params)
    .first<{ c: number }>();
  const embeddedRow = await env.DB
    .prepare(`SELECT COUNT(*) as c FROM entries WHERE is_embedded = 1 AND json_extract(metadata_json, '$.embed') = 1${extra}`)
    .bind(...params)
    .first<{ c: number }>();
  return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
}

export interface ReconcileResult {
  enabled: boolean;
  checked: number;            // 本批「真的核對＋寫回」的筆數（受下方 D1 額度截斷後的量）。
  confirmed_current: number;  // 核對後確認已在現行 Vectorize index：只補標 content_hash，未打 AI。
  reset_to_pending: number;   // 核對後確認不在現行 index：重置 is_embedded=0，回到正常 backfill 佇列。
  remaining: number;          // 本次之後仍待核對的筆數（不受額度影響，可重複呼叫直到 0）。
  scanned: number;            // 本批掃到的候選筆數（受 limit 限制，額度截斷前）。
  quota_limit: number;        // 今日「背景維護 D1 寫入」額度上限（與標庫 backfill 共用，見 maintenance-quota.ts）。
  quota_used_today: number;   // 本次呼叫後，今日累積已消耗的背景維護寫入額度。
  quota_exceeded: boolean;    // 本批是否因額度不足被截斷（true＝還有候選但今天不再寫 D1，等明天/調高上限）。
}

/**
 * 世代核對（Generation reconciliation，D68 配套修復，2026-08-11）。
 *
 * 背景：`is_embedded=1` 只代表「曾經對某個 Vectorize index 嵌過」，不保證是**現行**的
 * index/模型（見檔頭 2026-08-03 換代註解：換模型必須換 index，舊向量收不進新 index、也刪不掉）。
 * 從備份整批灌回的資料尤其會帶著對**已退役索引**（例：768 維 `arcrun-kbdb-embed`）的
 * `is_embedded=1`——現行 backfill 的預設路徑（只補 `is_embedded=0`）永遠不會碰它們，
 * 語意搜尋對現行（1024 維 `arcrun-kbdb-embed-m3`）索引而言永遠搜不到那批東西，畫面不會說壞掉。
 *
 * 做法：不猜（`is_embedded` 本身此刻不可信），直接問現行 Vectorize index「這些 id 真的在你這嗎」
 * （`env.VECTORIZE.getByIds`，ground truth，而非比對 content_hash 字串本身——後者在這次修復
 * 之前從未被寫過，所有既有 is_embedded=1 的列 content_hash 皆為 NULL，無法只憑字串判斷「哪些是
 * 這次修復前的正常資料、哪些是真正的舊世代殘留」，必須問 Vectorize 本身）：
 *   - 真的在現行 index → 只是這次修復之前的正常資料，沒補寫過 content_hash。補標記，不重打 AI
 *     （不浪費額度在已經正確的資料上）。
 *   - 不在現行 index → 對現行 index 而言等於沒嵌過，重置 is_embedded=0、清空 content_hash，
 *     交回正常 backfill 佇列（下一輪照樣受「新到舊」排序＋每日額度上限保護，不特別優待）。
 *
 * 不消耗 Workers AI 額度：零 AI.run，只有一次 D1 掃描 + 一次 Vectorize.getByIds + D1 寫回。
 *
 * D69（Arcrun#85，2026-08-11 leo 逐行複核找到的破口）：**這一步雖不打 AI，但逐筆寫 D1**——
 * 每個候選最多消耗一次 row write（補標 content_hash 或重置 is_embedded，兩條路互斥、恰好一次），
 * 47 萬筆候選 ≈ 4.7 倍 D1 100,000 rows written/日免費額度。與標庫 backfill（同樣是多筆 D1
 * write、不打 AI）共用 `actions/maintenance-quota.ts` 的同一顆每日計數器——不共用的話，
 * 補標庫時會把這裡的閘繞過去（反之亦然）。額度用完 → 誠實截斷候選，不再寫 D1，等明天。
 *
 * 「挑哪一批」：owner_id/library/since/until 走 SelectionCriteria（同 backfillEmbeddings／
 * backfillEntryLibraryTags 共用的篩選形狀），讓時間分層／庫分層能從外面指定。
 */
export async function reconcileEmbedGeneration(
  env: Bindings,
  opts: Pick<SelectionCriteria, 'owner_id' | 'library' | 'since' | 'until'> & { limit?: number } = {},
): Promise<ReconcileResult> {
  if (!embedEnabled(env)) {
    return {
      enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0,
      scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
    };
  }
  const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
  const currentModel = embedModel(env);

  const sel = selectionCriteriaPredicate(opts);
  const conds = [
    'is_embedded = 1',
    '(content_hash IS NULL OR content_hash != ?)',
    "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
    ...sel.conds,
  ];
  const params: unknown[] = [currentModel, ...sel.params];
  const where = conds.join(' AND ');

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

  // D69：額度截斷——每個候選最多 1 次 D1 write，直接照剩餘額度砍候選清單長度。
  const budget = await maintenanceBudgetToday(env, env.DB);
  const ids = scannedIds.slice(0, budget.remaining);
  const quotaExceeded = scanned > ids.length;
  const checked = ids.length;

  let confirmed_current = 0;
  let reset_to_pending = 0;
  if (ids.length > 0 && env.VECTORIZE) {
    const found = await env.VECTORIZE.getByIds(ids);
    const foundIds = new Set(found.map((v) => v.id));
    const presentIds = ids.filter((id) => foundIds.has(id));
    const missingIds = ids.filter((id) => !foundIds.has(id));

    if (presentIds.length > 0) {
      const ph = presentIds.map(() => '?').join(',');
      await env.DB
        .prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`)
        .bind(currentModel, ...presentIds)
        .run();
      confirmed_current = presentIds.length;
    }
    if (missingIds.length > 0) {
      const ph = missingIds.map(() => '?').join(',');
      await env.DB
        .prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`)
        .bind(...missingIds)
        .run();
      reset_to_pending = missingIds.length;
    }
  }

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

  const written = confirmed_current + reset_to_pending;
  try {
    await addMaintenanceUsage(env.DB, written);
  } catch {
    // fail-open：額度計數寫入失敗不影響已經完成的核對寫入（精神同 backfillEmbeddings 的
    // addBackfillUsage 失敗處理——寧可下次呼叫少算一點用量，也不讓計數故障吞掉已做的工）。
  }

  return {
    enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0,
    scanned, quota_limit: budget.limit, quota_used_today: budget.used + written, quota_exceeded: quotaExceeded,
  };
}

export interface SelfTestResult {
  enabled: boolean;      // embed 模組是否開（binding 都在）
  tested: boolean;       // 是否真的跑了一次自我查詢（false＝連測都測不了，非失敗）
  passed: boolean | null; // 拿已嵌入卡片的內容查自己，能不能搜到自己（null＝沒測）
  note: string;          // 給人看的一句話結論，供檢修孔診斷檔直接引用
}

/**
 * Embed 自我檢查（檢修孔用，2026-08-07 leo 直接指令：「先把檢修孔做出來發版」）。
 *
 * 為什麼需要這個，不只是 backfillStatus 的 pending/embedded 計數：08-05 撞過的真實故障
 * 是「is_embedded=1（已嵌入）但語義搜尋還是搜不到」——metadata index 事後才建，既有向量
 * 沒被收錄（Arcrun#11）。計數看不出這種病，因為計數只問「有沒有嵌」，不問「嵌完查得到嗎」。
 * 本函式挑一筆「已標記已嵌入」的既有 entry，拿它自己的內容做一次真實語義查詢，檢查
 * 「自己是否搜得到自己」——這是唯一能端到端驗證 index 真的可用的方法。
 *
 * 隱私邊界（檢修孔規格紅線：診斷檔不准帶卡片內容本體）：本函式只回布林 + 一句話 note，
 * 不回傳卡片內容、不回傳 entry id。取樣內容只在函式內部這一次查詢中用過即丟。
 */
export async function embedSelfTest(
  env: Bindings,
  opts: { owner_id?: string } = {},
): Promise<SelfTestResult> {
  if (!embedEnabled(env)) {
    return { enabled: false, tested: false, passed: null, note: 'embed 模組未開（缺 Vectorize/AI binding），語義搜尋這條路目前不存在' };
  }
  const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
  const params: unknown[] = [];
  if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
  const where = conds.join(' AND ');
  const row = await env.DB
    .prepare(`SELECT * FROM entries WHERE ${where} ORDER BY updated_at DESC LIMIT 1`)
    .bind(...params)
    .first<Entry>();
  if (!row) {
    return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」，無法自我檢查（可能是還沒卡片，也可能是嵌入從未成功過）' };
  }
  const sample = (row.content ?? '').trim().slice(0, 200);
  if (!sample) {
    return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空，跳過自我檢查' };
  }
  // min_score:0——自我檢查要看「找不找得到」，不能被查詢端的相對門檻先濾掉。
  let hits: SemanticHit[] | null;
  try {
    hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 });
  } catch (e) {
    if (e instanceof EmbedQueryFailedError) {
      // 向量化本身失敗（額度用完/模型故障）＝「這條路現在是斷的」，誠實回報，不算 passed/failed。
      return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成：${e.message}（語義搜尋此刻同樣會故障，多半是 Workers AI 額度或服務問題）` };
    }
    throw e;
  }
  if (hits === null) {
    return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開（binding 檢查期間消失，罕見）' };
  }
  const passed = hits.some((h) => h.id === row.id);
  return {
    enabled: true,
    tested: true,
    passed,
    note: passed
      ? '拿一張已標記「已嵌入」的卡片自我查詢，能搜到自己——語義搜尋這條路是通的'
      : '拿一張已標記「已嵌入」的卡片自我查詢，卻搜不到自己——像是 index 沒收錄到這批向量（需要重新 reindex）',
  };
}

export interface SemanticHit {
  id: string;
  score: number;
  owner_id?: string;
  entry_type?: string;
  source?: string;
  library?: string;
}

/**
 * 查詢向量化失敗（2026-08-09 leo 直令：「查詢的向量化如果失敗（例如當天額度用完），
 * 目前會回一個空的結果集——那是騙人，不是降級」）。
 *
 * 舊行為：embedText 拿不到向量 → semanticSearch 回 []，caller 分不出
 * 「真的沒命中」和「根本沒查成」，使用者看到「查無資料」，以為知識庫裡沒有這筆東西。
 * 新行為：AI.run 丟錯（額度用完/模型故障）或回不出向量 → 丟這個錯，
 * 由 route 層誠實降級 keyword ＋告知「這是我們的故障」，不再偽裝成空結果。
 */
export class EmbedQueryFailedError extends Error {
  constructor(detail: string) {
    super(`查詢向量化失敗：${detail}`);
    this.name = 'EmbedQueryFailedError';
  }
}

/**
 * 語義搜尋（mode:'semantic'）。模組未開 → 回 null（caller 降級 keyword + 告知缺能力）。
 * owner_id / source / entry_type 過濾走 Vectorize metadata filter（entry_type 已 index，見上 upsert metadata）。
 * entry_type 是 base 通用 filter（caller 傳任意 type，base 不寫死語意）。
 * library（portal-auth P1）：多值庫 filter 走 `$in`（官方支援已核實 2026-07-14：文件明列 $in/$nin＋
 * workers-types 原生 typing；design §3.3 的 fan-out fallback 不需啟用）。未帶＝行為不變。
 * 註：向量 metadata 的 library 在寫入端已正規化（未標記＝'general'），故 $in 不需 NULL 處理；
 * 但「建 library metadata index 之前」upsert 的既有向量沒有此欄 → 部署清單強制 reindex backfill。
 * min_score（issue #67）：分數閾值——Vectorize 只會硬湊 topK 筆，低分尾全是無關內容；
 * 過濾放查詢端（非 Vectorize 端，API 無此參數）。
 * 🔴 2026-08-05 起預設＝`DEFAULT_MIN_SCORE`（跟著模型走，見該常數的實測分布），
 *    不再是 0（0＝不過濾＝#67 要修的病本身，而呼叫端各自硬寫數字則是 08-05 全 0 命中的根因）。
 *    caller 顯式傳值仍優先。
 */
export async function semanticSearch(
  env: Bindings,
  q: string,
  opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number; min_score?: number } = {},
): Promise<SemanticHit[] | null> {
  if (!embedEnabled(env)) return null;
  // 空查詢＝真的沒東西可查（route 層已擋 q 必填，這裡只兜底），不算故障。
  if (!(q ?? '').trim()) return [];
  // 🔴 2026-08-09（leo 直令）：向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」，
  //    向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實，混在一起就是說謊。
  let vec: number[] | null;
  try {
    vec = await embedText(env, q);
  } catch (e) {
    throw new EmbedQueryFailedError(e instanceof Error ? e.message : String(e));
  }
  if (!vec) throw new EmbedQueryFailedError('Workers AI 沒有回出向量（回應形狀異常或空回應）');
  const filter: VectorizeVectorMetadataFilter = {};
  if (opts.owner_id) filter.owner_id = opts.owner_id;
  if (opts.source) filter.source = opts.source;
  if (opts.entry_type) filter.entry_type = opts.entry_type;
  if (opts.library && opts.library.length > 0) filter.library = { $in: opts.library };
  const res = await env.VECTORIZE!.query(vec, {
    topK: Math.min(opts.topK ?? 20, 100),
    returnMetadata: 'indexed',
    ...(Object.keys(filter).length ? { filter } : {}),
  });
  // 這裡只套**絕對下限**；相對門檻要等「濾掉已下架」之後才能算（見 relativeMinScore 的註解）。
  const minScore = opts.min_score ?? MIN_SCORE_ABS_FLOOR;
  return (res.matches ?? [])
    .filter((m) => m.score >= minScore)
    .map((m) => ({
      id: m.id,
      score: m.score,
      owner_id: m.metadata?.owner_id as string | undefined,
      entry_type: m.metadata?.entry_type as string | undefined,
      source: m.metadata?.source as string | undefined,
      library: m.metadata?.library as string | undefined,
    }));
}
