// Entries route — atomic data + tree (project/workflow). Base; embed is OPTIONAL (issue #7).
import { Hono } from 'hono';
import type { Bindings } from '../types';
import {
  createEntry,
  deprecateEntriesByLibrary,
  embeddedIdsByLibrary,
  markUnembedded,
  getEntry,
  listEntries,
  updateEntry,
  deleteEntry,
  searchEntries,
  isDeprecatedEntry,
} from '../actions/entry-crud';
import {
  embedEnabled,
  embedOnWrite,
  semanticSearch,
  relativeMinScore,
  backfillStatus,
  backfillEmbeddings,
  EmbedQueryFailedError,
} from '../embed';
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../actions/library-backfill';

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

// fire-and-forget：有 executionCtx（workerd）就 waitUntil，測試環境沒有就 detach（吞錯不吵）。
// 給搜尋路徑的「自癒」動作用——修復是順手做的背景事，絕不拖慢也絕不弄壞查詢本身。
function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknown>): void {
  let ctx: ExecutionContext | undefined;
  try { ctx = c.executionCtx; } catch { ctx = undefined; }
  if (ctx) ctx.waitUntil(p.catch(() => {}));
  else void p.catch(() => {});
}

// library 多值參數（逗號分隔，portal-auth P1，design §3.3）。空值/全空白 → undefined（＝不過濾，
// 行為與未帶參數一字不變——向後相容硬驗收）。
function parseLibraryParam(raw: string | undefined): string[] | undefined {
  if (!raw) return undefined;
  const libs = raw.split(',').map((s) => s.trim()).filter(Boolean);
  return libs.length > 0 ? libs : undefined;
}

// POST /entries — create (entry_type=block/value/project/workflow/...)
entryRoutes.post('/', async (c) => {
  const body = await c.req.json().catch(() => null);
  if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
  const entry = await createEntry(c.env.DB, body);
  // embed-on-write (#7 / #5 第4點)：模組開 + entry 標 embed:true 才做；fire-and-forget，不阻塞回應、失敗不致命。
  if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
  return c.json({ success: true, entry });
});

// GET /entries/libraries?owner_id=... — 這個租戶的資料裡實際出現過哪些庫（distinct）。
// t52（leo 2026-07-26：地端幾個資料夾＝雲端幾個庫）：庫由 ingest 蓋章決定，這裡直接從
// 資料反查，讓「蓋了章的庫」一定看得到，不必依賴任何登記動作。未蓋章的舊資料＝general。
// 註冊在 '/' 之前——Hono 路由先到先比，放後面會被 '/:id' 之類的樣式吃掉。
entryRoutes.get('/libraries', async (c) => {
  const owner = c.req.query('owner_id') || '';
  const rows = await c.env.DB.prepare(
    `SELECT DISTINCT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library
       FROM entries
      WHERE (?1 = '' OR owner_id = ?1)
        AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
      ORDER BY library`,
  )
    .bind(owner)
    .all<{ library: string }>();
  const libraries = (rows.results ?? []).map((r) => r.library).filter(Boolean);
  return c.json({ success: true, libraries, count: libraries.length });
});

// GET /entries/library-stats?owner_id=... — 每個庫的知識卡數（distinct page_name，非 block 數）。
// t142（2026-07-29）：政府驗收用——一眼看出每個庫有幾張卡（page 粒度，不是 block 粒度，
// 一張卡通常對應 3-5 個 block；不含 deprecated entries）。
// 只計 entry_type='block' 的條目，因為 block 才對應知識卡的一個段落（page_name 標記所屬頁面）。
entryRoutes.get('/library-stats', async (c) => {
  const owner = c.req.query('owner_id') || '';
  const rows = await c.env.DB.prepare(
    `SELECT
       COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
       COUNT(DISTINCT page_name) AS card_count
     FROM entries
     WHERE (?1 = '' OR owner_id = ?1)
       AND entry_type = 'block'
       AND page_name IS NOT NULL
       AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
     GROUP BY library
     ORDER BY library`,
  )
    .bind(owner)
    .all<{ library: string; card_count: number }>();
  const stats = (rows.results ?? []).map((r) => ({ library: r.library, card_count: r.card_count }));
  return c.json({ success: true, stats });
});

// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun
// e.g. filter by ingest source: ?source=logseq://vault/foo.md (issue #5.1)
// e.g. filter by library（多值逗號分隔，portal-auth P1）: ?library=finance,hr（未標記舊資料歸 general）
// e.g. keyword filter: ?q=遷移 或 ?search=遷移（別名，Arcrun#3 發現①：caller 實測時打的是 search=，
//   舊版完全不接這個 filter；q 與 search 兩個名字都認，避免同一個坑再踩一次）。
// count = 本頁筆數（受 limit 影響）；total = 符合條件全部筆數（不受 limit 影響，見 total 欄位）。
entryRoutes.get('/', async (c) => {
  const entryType = c.req.query('entry_type') || undefined;
  const ownerId = c.req.query('owner_id') || undefined;
  // 自癒搬遷（D38 收尾，2026-08-08）：credential 目錄查詢先確保舊表（若還在）已把這個
  // 租戶的資料搬進 entries——冪等、per-owner scoped、成本近零（見 credential-legacy-
  // migration.ts 檔頭）。只在 credential 讀取時觸發，不影響其餘 entry_type 的查詢路徑。
  if (entryType === 'credential' && ownerId) {
    await migrateLegacyCredentialsForOwner(c.env.DB, ownerId).catch(() => {
      // 搬遷失敗不阻塞查詢本身（例如舊表結構意外損毀）——誠實地讓查詢照常進行，
      // 缺席的 credential 由呼叫端既有的 fallback（cypher-executor 舊 KV）接住。
    });
  }
  const { entries, total } = await listEntries(c.env.DB, {
    entry_type: entryType,
    owner_id: ownerId,
    parent_id: c.req.query('parent_id') || undefined,
    page_name: c.req.query('page_name') || undefined,
    source: c.req.query('source') || undefined,
    library: parseLibraryParam(c.req.query('library')),
    q: c.req.query('q') || c.req.query('search') || undefined,
    limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
    offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined,
  });
  return c.json({ success: true, entries, count: entries.length, total });
});

// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic
//   - mode=keyword（預設）：D1 LIKE（base，永遠可用）。
//   - mode=semantic：需 embed 模組開（Vectorize+AI binding）。未開 → 降級 keyword +
//     capability_hint。capability_hint 是講給非技術使用者聽的人話
//     （2026-08-08 修：曾經直接透傳到封測用戶眼前的工程師導向文字，見該欄位旁註）；
//     技術細節另放 admin_hint 給維運者/CC 看。
//     🔴 2026-08-09（leo 直令）：語意搜尋是**一安裝就提供**的功能，模組不在＝故障，
//     文案照實說「壞了、是我們的問題、使用者不用做任何事」，禁止說成「還沒開通/未啟用」。
//     降級回應帶 degraded_reason（module_off / embed_query_failed）供前端與診斷分流。
//   - entry_type：base 通用 filter（caller 傳任意 type，如 workflow；base 不寫死語意，workflow-discovery Q4）。
//   - library：多值庫 filter（逗號分隔，portal-auth P1）。keyword 走 json_extract＋NULL→general；
//     semantic 走 Vectorize $in。未帶＝全庫（行為不變）。
//   - source：keyword 走 json_extract 謂詞（#66——#5.1 只接了 list 那半，這裡原本解析完即丟）；
//     semantic 走 Vectorize metadata filter（原本就有）。
//   - top_k / min_score（#67，semantic 專用）：topK 可調（預設 20、上限 100）＋分數閾值
//     （預設 0＝不過濾）。未帶＝行為與舊版一致（向後相容）；semantic 回應的 entry 另附 score
//     欄讓 caller 自裁（加欄不改形，keyword 路徑不受影響）。
//   - include_deprecated（daemon-beta t24，預設 false）：兩 mode 預設都濾掉已下架
//     （metadata_json.status==='deprecated'）的 entry——這是本次修的洞（t11 斷點①②，總管
//     0.971 親復現：下架後 keyword/semantic 都照樣回傳）。傳 `include_deprecated=true`
//     保留給管理面查殘留（驗證下架有沒有真的生效、盤點待清的向量殘留），一般搜尋不帶。
entryRoutes.get('/search', async (c) => {
  const q = c.req.query('q');
  if (!q) return c.json({ success: false, error: 'q required' }, 400);
  const owner_id = c.req.query('owner_id') || undefined;
  const source = c.req.query('source') || undefined;
  const entry_type = c.req.query('entry_type') || undefined;
  const library = parseLibraryParam(c.req.query('library'));
  const mode = c.req.query('mode') === 'semantic' ? 'semantic' : 'keyword';
  const include_deprecated = c.req.query('include_deprecated') === 'true';
  // 數字參數防呆：非數字/非正 → 當沒帶（回預設），不 400——與其他 filter「壞值靜默忽略」一致。
  const topKNum = Number(c.req.query('top_k'));
  const top_k = Number.isFinite(topKNum) && topKNum > 0 ? Math.floor(topKNum) : undefined;
  const minScoreNum = Number(c.req.query('min_score'));
  const min_score = Number.isFinite(minScoreNum) && minScoreNum > 0 ? minScoreNum : undefined;

  if (mode === 'semantic') {
    // 補位（daemon-beta t24）：Vectorize 的 indexed metadata 沒存 status（見 embed.ts upsert，
    // 只有 owner_id/entry_type/source/library），下架與否只能在 hydrate 回完整 entry 後才知道
    // ——換句話說 Vectorize 端沒辦法直接濾掉已下架向量，濾一定發生在 hydrate 之後。
    // 若濾完才截斷到請求的 topK，遇到「這頁命中大半已下架」（t11 ZZ-T10 實測案例：命中
    // 25 顆全下架）就會整頁被吃光、回傳筆數遠低於 caller 要的量。故过濾生效時（非
    // include_deprecated）**先多撈一批再濾再截斷**：單次 Vectorize query 成本不變（同一次
    // query 只是 topK 參數變大，非多一次 subrequest），用查詢端的餘量換掉「整頁被下架品吃光」
    // 的體驗劣化。這是單輪補位（非重試迴圈到湊滿為止）——若下架比例極高仍可能不足額，
    // 已在 PR 描述向 leo 說明這個 trade-off（多倍 margin vs 迴圈重撈的取捨）。
    const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊
    const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100);
    // 🔴 2026-08-09（leo 直令）：語意搜尋壞掉時**照實說是故障**。
    //   - 語意搜尋是一安裝就提供的功能。走到下面任一降級分支＝這台實例壞了，
    //     不是「還沒開通」「未啟用」——禁止把 bug 美化成沒提供（那會製造
    //     「請幫我開通」的客服工單，而真正的故障沒人修）。
    //   - capability_hint 給一般使用者看：說清楚「是我們的問題、不是你的錯、
    //     你不用做任何事」；技術細節放 admin_hint 給維運者/CC。
    //   - 降級仍回關鍵字結果：有退化的結果比空白有用，但誠實標示，不假裝是語意結果。
    let hits;
    try {
      hits = await semanticSearch(c.env, q, {
        owner_id, source, entry_type, library, topK: fetchTopK, min_score,
      });
    } catch (e) {
      if (e instanceof EmbedQueryFailedError) {
        // 查詢向量化失敗（Workers AI 額度用完/服務故障）：舊版在這裡回空結果集
        // ＝把「我們沒查成」偽裝成「你的庫裡沒有」——leo 08-09 點名的謊。改誠實降級。
        const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
        return c.json({
          success: true,
          entries,
          count: entries.length,
          mode: 'keyword',
          requested_mode: 'semantic',
          degraded_reason: 'embed_query_failed',
          capability_hint:
            '語意搜尋暫時故障，先用關鍵字幫你找了下面的結果。這是我們系統的問題，不是你的操作問題，你不需要做任何事，稍後它會自動恢復。',
          admin_hint: `${e.message}。常見原因：Workers AI 當日額度用完或服務暫時異常；本次已降級關鍵字搜尋，資料與索引皆未受影響。`,
        });
      }
      throw e;
    }
    if (hits === null) {
      // embed 模組不在（缺 VECTORIZE/AI binding）：對一安裝就提供的功能而言，這**是故障**
      // ——多半是某次部署把 binding 弄丟了（更新時沒帶 kbdb_embed、或安裝時 Vectorize
      // 建立失敗被靜默放行）。誠實降級 keyword，照實說壞了，不說「還沒開通」。
      const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
      return c.json({
        success: true,
        entries,
        count: entries.length,
        mode: 'keyword',
        requested_mode: 'semantic',
        degraded_reason: 'module_off',
        capability_hint:
          '語意搜尋目前故障，先用關鍵字幫你找了下面的結果。這是我們系統的問題，不是你的操作問題，你不需要做任何事，我們會修好它。',
        admin_hint:
          '故障：kbdb worker 缺 VECTORIZE/AI binding（embedEnabled=false）。語意搜尋是安裝即提供的功能，缺 binding＝部署層事故（常見：redeploy 沒帶 kbdb_embed 注入、或安裝時 Vectorize index 建立失敗被放行）。修法：確認 Vectorize index 存在後以 kbdb_embed:true 重部 kbdb。本次已降級關鍵字搜尋。',
      });
    }
    // hydrate vector hits → 完整 entry（保持回應形狀與 keyword 一致）。
    // #67：entry 附 score（相似分數）——加欄不改形，既有 caller 不解析多的欄位不受影響。
    // 2026-08-09 自癒：hydrate 過程順手記下「索引裡有、資料已不在」的向量
    //   - 孤兒（getEntry 找不到）→ 該向量已無對應資料，直接刪；
    //   - 殘影（已下架但向量還在，0.971 案的病原）→ 刪向量＋is_embedded 歸零。
    //   背景執行（fireAndForget），失敗下次搜尋再清；查詢本身不受影響。
    const orphanIds: string[] = [];
    const deprecatedIds: string[] = [];
    let entries = (
      await Promise.all(
        hits.map(async (h) => {
          const e = await getEntry(c.env.DB, h.id);
          if (!e) { orphanIds.push(h.id); return null; }
          return { ...e, score: h.score };
        }),
      )
    ).filter((e): e is NonNullable<typeof e> => e !== null);
    if (!include_deprecated) {
      entries = entries.filter((e) => {
        const dep = isDeprecatedEntry(e);
        if (dep) deprecatedIds.push(e.id);
        return !dep;
      });
    }
    const staleIds = [...orphanIds, ...deprecatedIds];
    if (staleIds.length > 0 && c.env.VECTORIZE) {
      fireAndForget(c, (async () => {
        await c.env.VECTORIZE!.deleteByIds(staleIds);
        await markUnembedded(c.env.DB, deprecatedIds);
      })());
    }
    // 🔴 2026-08-05：相對門檻砍低分尾（leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關）。
    // **一定要接在濾掉下架的後面**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走
    // （＝同日早上「0 命中」的翻版；t24 的 0.971 復現案就是這種殘影）。
    // caller 顯式帶 min_score 時尊重他的絕對值，不再加碼。
    if (min_score === undefined && entries.length > 1) {
      const cut = relativeMinScore(entries[0].score);
      entries = entries.filter((e) => e.score >= cut);
    }
    // 補位後截斷回 caller 實際要的量（多撈的餘量只用來墊背，不多回傳超過請求的筆數）。
    entries = entries.slice(0, requestedTopK);

    // 🔴 2026-08-08（總管交辦二修，Oscar 封測案：模組有開、但語意搜尋回空——回報後才發現
    // 這條路徑比「模組沒開」的 capability_hint 更常撞到，卻完全沒有 hint，是「誠實但沉默」）：
    // count:0 對用戶而言是無資訊的——「我打的字不對」跟「這個庫的索引根本沒建好」需要的下一步
    // 完全不同，系統卻兩種都回同一句「找不到」。分辨依據（不新開一套覆蓋率查詢，共用 embed.ts
    // 既有的 backfillStatus——2026-08-07 檢修孔／診斷聚合端點已在用同一支，同一件事只留一套
    // 實作，2026-08-08 credential 那次「兩套並存必然漂移」的教訓不重踩）：
    //   - hits.length===0（Vectorize 端零命中，含 embed.ts 內建絕對門檻）
    //     → 查 backfillStatus(owner_id).embedded：
    //       0 筆 → 'no_index'（這個租戶根本沒有索引資料，不是使用者的問題）
    //       >0 筆 → 'no_match'（有索引，這次查詢正常沒撞到——換句話說再搜）
    //   - hits.length>0 但濾光 → 'stale_index'。誠實核算過機制：relativeMinScore 的 cut
    //     必然 <= 最高分（cut = max(絕對下限, top×0.8) <= top），所以「最高分那筆」永遠會
    //     自己活下來，相對門檻**不可能**把非空結果砍成 0——這裡不能寫「相似度不夠」這種
    //     不符合實際機制的話（誠實限制，mindset §7）。真正會讓 hits>0 卻 entries=0 的只有
    //     兩種：命中的向量對應的資料**已下架**（isDeprecatedEntry 濾掉）、或**已被刪除**
    //     （getEntry 找不到，孤兒向量）——兩者都是「索引裡有，但實際資料不在了」，故稱
    //     stale_index（索引與資料兩邊不同步），不誤導使用者去猜「換個字」。
    // 三態都給人話 capability_hint（給使用者）+ admin_hint（技術細節，給維運者/CC）。
    // 正常有結果（entries.length>0）完全不受影響，回應形狀不變。
    if (entries.length === 0) {
      let empty_reason: 'no_index' | 'no_match' | 'stale_index';
      let capability_hint: string;
      let admin_hint: string;
      if (hits.length === 0) {
        const status = await backfillStatus(c.env, { owner_id });
        if (status.embedded === 0 && status.pending > 0) {
          // 資料在、索引卻一筆都沒建＝故障（寫入時嵌入沒成功過）。順手自癒：
          // 背景補嵌一批（冪等、分批），下次搜尋就有機會直接好——不叫使用者做任何事。
          empty_reason = 'no_index';
          capability_hint =
            '語意搜尋的索引出了狀況，所以暫時搜不到——這是我們系統的問題，不是你打的字有問題。系統正在自動重建，稍後再搜一次看看。';
          admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 但 pending=${status.pending}：資料在、索引從沒建成＝寫入端嵌入從未成功（故障）。本次已背景觸發 backfill 自癒（每批 100，冪等）。`;
          fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
        } else if (status.embedded === 0) {
          // 連「該被嵌的資料」都沒有＝這個庫還沒有整理好的內容（新裝好還沒同步），不是故障。
          empty_reason = 'no_index';
          capability_hint =
            '這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。';
          admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0：沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑（正常的空），少數情況是 ingest 管線沒標 embed 旗標（要查管線）。`;
        } else {
          empty_reason = 'no_match';
          capability_hint = '沒有找到符合的內容，換個說法或更具體的關鍵字再試試看。';
          admin_hint = `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料，但本次查詢在 Vectorize 端零命中（含 embed.ts 絕對門檻過濾）。`;
          // 順手自癒：pending>0＝有卡片在寫入時漏嵌（embedOnWrite 失敗是 fire-and-forget，
          // 沒有別的機制會回來補）。status 已經查了，不多花查詢，背景補一批。
          if (status.pending > 0) fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
        }
      } else {
        empty_reason = 'stale_index';
        capability_hint =
          '這次比對到的內容源頭已經被移除或下架了，所以沒有可顯示的結果。系統已自動清理過期索引（我們的問題，你不用做任何事），換個關鍵字就能正常搜。';
        admin_hint = `Vectorize 命中 ${hits.length} 筆，但 hydrate 後全部是已下架或找不到對應資料（孤兒向量），非分數門檻造成——相對門檻數學上不可能砍光非空結果（cut<=top）。本次已背景觸發向量清理（deleteByIds）。`;
      }
      return c.json({
        success: true, entries, count: entries.length, mode: 'semantic',
        empty_reason, capability_hint, admin_hint,
      });
    }
    return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
  }

  const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
  return c.json({ success: true, entries, count: entries.length, mode: 'keyword' });
});

// GET /entries/:id
entryRoutes.get('/:id', async (c) => {
  const entry = await getEntry(c.env.DB, c.req.param('id'));
  if (!entry) return c.json({ success: false, error: 'not found' }, 404);
  return c.json({ success: true, entry });
});

// PATCH /entries/deprecate-by-library — body {owner_id, library}。
// t135：把某租戶某庫的所有 entries 標 deprecated，讓庫從 auto 清單消失。
// 此路由必須在 '/:id' 之前，否則 'deprecate-by-library' 會被當成 id 參數。
entryRoutes.patch('/deprecate-by-library', async (c) => {
  const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null;
  const ownerId = String(body?.owner_id ?? '').trim();
  const library = String(body?.library ?? '').trim();
  if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400);
  // 🔴 2026-08-05（leo：「理論上它的向量也要刪掉，就不會有殘影了吧？」）：
  //   先撈 id 再標下架——順序反過來就撈不到「還有向量」的那批（標完 status 不影響 is_embedded，
  //   但先撈比較不依賴欄位語意，也讓失敗時不會留下「已標下架但向量還在」的中間態）。
  const ids = embedEnabled(c.env) ? await embeddedIdsByLibrary(c.env.DB, ownerId, library) : [];
  const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library);
  let vectors_deleted = 0;
  if (ids.length > 0) {
    // 刪向量＋把 is_embedded 歸零（讓 D1 與 Vectorize 不說兩套話）。
    // 失敗不擋下架本體：D1 已標 deprecated，搜尋端仍會濾掉；殘留向量下次再清。
    try {
      await c.env.VECTORIZE!.deleteByIds(ids);
      await markUnembedded(c.env.DB, ids);
      vectors_deleted = ids.length;
    } catch { /* 誠實回 0，不假裝清乾淨了 */ }
  }
  return c.json({ success: true, deprecated_count: count, vectors_deleted });
});

// POST /entries/backfill-library — 標庫補存量（Arcrun#85 二次裁決／相關票 Arcrun#87，2026-08-11）。
//   body（必填 library + owner_id）：{ library, owner_id, page_names?（string[]，精準比對，
//     leo 定案的正解——見 actions/library-backfill.ts 檔頭「拿原稿遍歷」）, entry_type?,
//     source_prefix?, page_name_prefix?（後兩者為過渡 fallback，精度不如 page_names）,
//     since?, until?, limit?(1-500，預設100) }。
//   冪等：只選「目前未標記 library」的候選；分批：單次 limit 上限，remaining>0 → 重複呼叫直到 0。
//   budget：與 /embed/reconcile 共用同一顆每日 D1 寫入額度（見 actions/maintenance-quota.ts）——
//     兩者都是「多筆 D1 write、不打 AI」的背景維護操作，不共用額度的話補存量會把世代核對的閘繞過去。
//   base 對內容語意無知：不猜「這批該貼哪個庫」，呼叫端（ingest／#87）決定 library 與篩選條件；
//   owner_id 必填（同 /entries/deprecate-by-library 的既有防線——批次改一大片既有資料不准無租戶範圍地掃）。
//   此路由必須在 '/:id' 之前註冊，否則 'backfill-library' 會被當成 id 參數。
entryRoutes.post('/backfill-library', async (c) => {
  const body = (await c.req.json().catch(() => ({}))) as {
    library?: string;
    owner_id?: string;
    entry_type?: string;
    page_names?: string[];
    source_prefix?: string;
    page_name_prefix?: string;
    since?: number | string;
    until?: number | string;
    limit?: number | string;
  };
  const library = String(body.library ?? '').trim();
  const ownerId = String(body.owner_id ?? '').trim();
  if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
  try {
    const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
      library,
      owner_id: ownerId,
      entry_type: body.entry_type || undefined,
      page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : undefined,
      source_prefix: body.source_prefix || undefined,
      page_name_prefix: body.page_name_prefix || undefined,
      since: body.since !== undefined ? Number(body.since) : undefined,
      until: body.until !== undefined ? Number(body.until) : undefined,
      limit: body.limit !== undefined ? Number(body.limit) : undefined,
    });
    return c.json({ success: true, ...result });
  } catch (e) {
    return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
  }
});

// GET /entries/backfill-library/status?owner_id=&entry_type=&source_prefix=&page_name_prefix=&since=&until=
//   — 符合條件、目前未標記 library 的筆數（backfill 前後都能查，判斷還剩多少）。
entryRoutes.get('/backfill-library/status', async (c) => {
  const status = await libraryBackfillStatus(c.env.DB, {
    owner_id: c.req.query('owner_id') || undefined,
    entry_type: c.req.query('entry_type') || undefined,
    source_prefix: c.req.query('source_prefix') || undefined,
    page_name_prefix: c.req.query('page_name_prefix') || undefined,
    since: c.req.query('since') ? Number(c.req.query('since')) : undefined,
    until: c.req.query('until') ? Number(c.req.query('until')) : undefined,
  });
  return c.json({ success: true, ...status });
});

// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
  const body = await c.req.json().catch(() => ({}));
  const entry = await updateEntry(c.env.DB, c.req.param('id'), body);
  if (!entry) return c.json({ success: false, error: 'not found' }, 404);
  // 內容改了 → 重 embed（保持向量新鮮）。embedOnWrite 內部自會檢查模組開 + entry 是否 embeddable。
  if (embedEnabled(c.env) && body.content !== undefined) {
    c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
  }
  return c.json({ success: true, entry });
});

// DELETE /entries/:id
//
// 🔴 2026-08-10（arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點：中途失敗要看得出來）：
//   舊版把向量刪除包成 fire-and-forget（`waitUntil(...).catch(()=>{})`）——失敗被靜默吞掉，
//   呼叫端（rag_takedown_direct workflow／未來的 portal 刪除 UI）永遠不知道向量沒清乾淨，
//   使用者看到「刪除成功」，但語意搜尋可能還留著殘影，直到下次搜尋命中它才被自癒清掉
//   （search 路徑的 orphan 清理是事後補救，不是保證）。
//   改法：與同檔 `/entries/deprecate-by-library`（見上）同款——**同步 await 再回應**，
//   誠實回報 `vector_deleted`（true=清了／false=清失敗，D1 仍照刪／null=模組未開，不適用）。
//   D1 刪除永遠執行到底（entry 本體一定會消失），差別只在向量那一步呼叫端看不看得見失敗。
entryRoutes.delete('/:id', async (c) => {
  const id = c.req.param('id');
  let vector_deleted: boolean | null = null; // 模組未開＝不適用，維持 null 誠實表達「這件事沒發生過」
  if (embedEnabled(c.env)) {
    try {
      await c.env.VECTORIZE!.deleteByIds([id]);
      vector_deleted = true;
    } catch {
      vector_deleted = false; // 誠實回 false，不假裝清乾淨了；D1 本體仍照刪，不因向量失敗而擋下
    }
  }
  await deleteEntry(c.env.DB, id);
  return c.json({ success: true, vector_deleted });
});
