// 三元組 library 補標 — 源頭順序 + 存量補標 + 冪等（2026-08-11，leo 貼 wiki 卡「三元組要恢復」）
//
// 背景（system-dev/wiki/ops-facts.md「三元組在 KBDB 有兩代儲存形式」段，2026-08-11 實測）：
// 1,633 筆新式三元組只有 171 筆填了 library slot，地圖（GET /map）因此幾乎看不到資料。
// 根因是**寫入順序**：createRecord 只會替 template.slots_json 裡「已宣告」的 slot 建 entry_value
// （見 src/actions/record-crud.ts createRecord：`for (const slot of slots) { if (!(slot in
// input.values)) continue }`——注意是遍歷 template 既有 slots，不是遍歷 caller 傳的 values）。
// 若呼叫端在 template 還沒有 'library' slot 時就送出 library 值，那個值會被**靜默丟棄**、
// 不報錯——這正是「查得到 171 筆」的來源：只有「已經跑過一次 ensureTripletLibrarySlot/recompute
// 之後」的批次，library 才真的落地。
//
// 本檔驗三件事（對應 leo 交辦的三個「要驗的」）：
//   1. 源頭：ensure-slot 必須在 write 之前，不能事後補（重現＋證明順序才是正解）
//   2. 存量：對一批缺 library 的舊 triplet 補標，前後地圖輸出對照
//   3. 不會重複做：同一批跑兩次，第二次 touch 0 筆
//
// 測試手法沿 library-map.test.ts 慣例：真 node:sqlite（Node ≥22.5 內建，零新依賴）跑
// migrations 原檔，本檔只是這顆記憶體內測試替身的操作者——不是碰 KBDB 的正式 D1，
// 與正式資料庫零關聯（D38 的牆管的是「牆外程式碼碰 KBDB 的真 D1」，這裡是牆內邏輯的
// 白盒測試替身，kbdb-api-wall-guard 對 *.test.ts 路徑做字面 grep 會誤判，行尾標
// kbdb-sql-ok 是這個誤判的既定逃生艙，見 hook 說明「Genuine exception」）。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import {
  recomputeLibraryMap,
  ensureTripletLibrarySlot,
  ensureFreshLibraryMaps,
  listLibraryMaps,
} from '../src/actions/library-map';
import { createTemplate, createRecord, updateRecord, getRecord, searchByTemplate } from '../src/actions/record-crud';

function makeSqliteD1(): D1Database {
  const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok: 記憶體測試替身，非真 KBDB D1
  raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 灌測試替身 schema，非真 D1
  raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 同上
  function stmt(sql: string, params: unknown[]) {
    const s = {
      bind(...args: unknown[]) { return stmt(sql, args); },
      async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 記憶體測試替身
      async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 記憶體測試替身
      async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 記憶體測試替身
    };
    return s;
  }
  return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}

// prod 實際 triplet template 的 slots（library-map.test.ts 同款常數，2026-07-19 kbdb_list_templates
// 核實）——注意：沒有 library。用它模擬「template 還沒被任何 recompute 摸過」的乾淨起點。
const PROD_TRIPLET_SLOTS = [
  'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
  'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
  'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
];

async function seedTripletTemplate(db: D1Database): Promise<void> {
  await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
}

// 對齊 design.md 既有 fallback 語意（source_prefix 參數）的同一條規則：
// library = source_uri 在 '@' 之前的那段（scheme:owner/repo）。這就是本檔＋報告裡建議
// kbdb-graph-plugin 在 write 時就该套用的推導規則（見報告，本檔只證明「規則正確、
// 順序對了就能用」，不代表已經改了 kbdb-graph-plugin 的程式碼——那是另一個 repo）。
function deriveLibrary(sourceUri: string): string {
  const at = sourceUri.indexOf('@');
  return at > 0 ? sourceUri.slice(0, at) : sourceUri;
}

describe('源頭順序 — ensure-slot 必須在 write 之前，事後補救不了已寫的那筆', () => {
  it('重現：template 尚無 library slot 時寫入 → library 值被靜默丟棄（不是報錯，是消失）', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);

    const rec = await createRecord(db, {
      template: 'triplet',
      values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
      owner_id: 'leo',
    });
    const stored = await getRecord(db, rec.record_id);
    // 關鍵斷言：library 完全沒落地，不是空字串、是 undefined（key 都不存在）。
    expect(stored!.values.library).toBeUndefined();
    expect(stored!.values.source_uri).toBe('gitea:Leo/kb@a.md'); // 其他 slot 正常落地，只有未宣告的 slot 消失
  });

  it('正解：先 ensureTripletLibrarySlot() 補上 slot，再寫 → library 值正常落地', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);

    const added = await ensureTripletLibrarySlot(db, 'triplet');
    expect(added).toBe(true); // 第一次呼叫確實補了 slot

    const rec = await createRecord(db, {
      template: 'triplet',
      values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
      owner_id: 'leo',
    });
    const stored = await getRecord(db, rec.record_id);
    expect(stored!.values.library).toBe('gitea:Leo/kb');

    // 冪等：對已有 slot 的 template 再呼叫一次 → false（不重複加），不影響既有資料。
    const addedAgain = await ensureTripletLibrarySlot(db, 'triplet');
    expect(addedAgain).toBe(false);
  });
});

describe('存量補標 — 對缺 library 的舊 triplet 補標，地圖輸出前後對照', () => {
  it('補標前：地圖看不到任何庫（triplet 全部因缺 library 而未被地圖聚合）；補標後：庫名正確出現', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    // 模擬現況：三筆舊 triplet，寫入時 template 還沒有 library slot（如實重現存量現況），
    // 只帶了 source_uri（之後補標要靠它反推 library）。
    const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', status: 'active' }, owner_id: 'leo' });
    const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md', status: 'active' }, owner_id: 'leo' });
    const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'github:uncle6-me/notes@c.md', status: 'active' }, owner_id: 'leo' });

    // 讀端自動核對重算（M3 收尾機制）：這時三筆都缺 library 值。liveTripletCountsByLibrary
    // 把「缺值」COALESCE 成 'general' 桶（供 staleness 判斷），但 recomputeLibraryMap 的
    // libCond 是 `t.library = 'general'` 精確比對——缺值在底層是 NULL 不是字面 'general'，
    // 比對不中，實際聚合出 triplet_count:0。**這是本次順手發現的另一個小落差**（live 計數桶
    // 與 recompute 精確比對的『general』語意沒對齊，導致這桶每次讀都判 stale、白重算，
    // 但至少不會謊報數字）——不在本次任務範圍內（leo 問的是兩代儲存形式的可見性，不是這個
    // fallback 桶的效能問題），本測試如實記錄現況，不假裝它是 0。
    await ensureFreshLibraryMaps(db, 'leo');
    const before = await listLibraryMaps(db, 'leo');
    expect(before.length).toBe(1);
    expect(before[0].library).toBe('general');
    expect(before[0].triplet_count).toBe(0); // 誠實：桶名對得上、數字沒謊報，但也沒把 3 筆算近來（見上註）

    // ── 補標（源頭已對：先 ensure slot，才寫值；一次對全部缺值的 record）──
    await ensureTripletLibrarySlot(db, 'triplet');
    const allTriplets = await searchByTemplate(db, 'triplet', 'leo');
    const missing = allTriplets.filter((t) => !t.values.library && t.values.source_uri);
    expect(missing.length).toBe(3); // 三筆都缺
    for (const t of missing) {
      await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri) });
    }

    // 補標後：對每個實際出現的 library 值重算一次地圖（read-path 的 ensureFreshLibraryMaps
    // 只認「已知庫名」——entries.metadata.library 或 portal_library；剛補標的三元組 library 值
    // 尚未被任何一處登記為「已知庫名」，直接 recompute 該庫最直接、也是 caller 實際會做的事）。
    const libs = [...new Set(missing.map((t) => deriveLibrary(t.values.source_uri!)))];
    for (const lib of libs) {
      await recomputeLibraryMap(db, { library: lib, owner_id: 'leo' });
    }

    const after = await listLibraryMaps(db, 'leo');
    const byLib = new Map(after.map((m) => [m.library, m.triplet_count]));
    expect(byLib.get('gitea:Leo/kb')).toBe(2); // r1, r2 同庫 —— 這是 leo 真正要看到的東西
    expect(byLib.get('github:uncle6-me/notes')).toBe(1); // r3 另一庫
    // 'general' 桶（補標前遺留的空殼，見上一段註）仍在，但 triplet_count 仍是 0——
    // 三筆全部被正確歸進真正的庫名，沒有一筆被算進 general（no double counting）。
    expect(byLib.get('general')).toBe(0);
    expect(after.length).toBe(3);

    // 交叉核對：三筆的 library 值確實都落地了（不是只有地圖聚合對，底層資料也對）。
    const r1Rec = await getRecord(db, r1.record_id);
    const r2Rec = await getRecord(db, r2.record_id);
    const r3Rec = await getRecord(db, r3.record_id);
    expect(r1Rec!.values.library).toBe('gitea:Leo/kb');
    expect(r2Rec!.values.library).toBe('gitea:Leo/kb');
    expect(r3Rec!.values.library).toBe('github:uncle6-me/notes');
  });
});

describe('不會重複做 — 同一批補標跑兩次，第二次不改動任何東西', () => {
  it('第二輪掃描：已有 library 值的 record 一筆都不會被 touch', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'leo' });
    // r2 模擬「還沒補標」的舊資料：故意繞過 values 直接不給 library（createRecord 這次雖然
    // template 已有 slot，但 caller 沒給值 → 該 slot 完全不會被建立 entry_value，等同「缺」）。
    const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md' }, owner_id: 'leo' });
    await updateRecord(db, r1.record_id, { library: deriveLibrary('gitea:Leo/kb@a.md') }); // r1 先補過

    async function backfillPass(): Promise<number> {
      const all = await searchByTemplate(db, 'triplet', 'leo');
      const missing = all.filter((t) => !t.values.library && t.values.source_uri);
      for (const t of missing) {
        await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri!) });
      }
      return missing.length;
    }

    const firstPass = await backfillPass();
    expect(firstPass).toBe(1); // 只有 r2 被補（r1 已有值，跳過）

    const secondPass = await backfillPass();
    expect(secondPass).toBe(0); // 第二輪：兩筆都已有 library，一筆都不 touch

    // 資料仍然正確、沒被第二輪弄壞。
    const r1After = await getRecord(db, r1.record_id);
    const r2After = await getRecord(db, r2.record_id);
    expect(r1After!.values.library).toBe('gitea:Leo/kb');
    expect(r2After!.values.library).toBe('gitea:Leo/kb');
  });
});
