// 標庫 backfill（Arcrun#85 二次裁決，2026-08-11）測試。
//
// 測試策略比照 embed-backfill.test.ts：真 SQLite（node:sqlite）套 migrations/0001_base.sql
// 原檔，驗真實 SQL 語意（json_set／WHERE／LIMIT），不是「以為 SQL 長這樣」。
//
// 覆蓋：
//   1. 只補「符合條件、目前未標記 library」的候選；已標記的不動（冪等）
//   2. owner_id 必填（缺了要拋錯，防「補錯 owner 等於白做」——2026-08-11 leo 直令）
//   3. source_prefix／page_name_prefix／since／until 篩選條件真的在篩
//   4. 與 reconcileEmbedGeneration 共用同一顆每日 D1 寫入額度（D69 的核心訴求：
//      補標不能把世代核對的閘繞過去，反之亦然）
//
// 本檔在 kbdb/tests/（牆外），依 D38 kbdb-api-wall-guard 規則，直接對 SQLite 治具下 SQL
// 的行集中在 helper（測試治具本身，非牆外業務邏輯繞過 API，每行標 kbdb-sql-ok 留痕）。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../src/actions/library-backfill';
import { reconcileEmbedGeneration } from '../src/embed';
import type { Bindings, Entry, EntryType } from '../src/types';

// ── node:sqlite → D1 介面最小 adapter（同 embed-backfill.test.ts 手法）──────────────
function makeSqliteD1(): D1Database {
  const raw = new DatabaseSync(':memory:');
  raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok：測試治具（node:sqlite→D1 shim）套 migration 原檔
  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：測試治具（node:sqlite→D1 shim）
      async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok：測試治具（node:sqlite→D1 shim）
      async run() {
        const r = raw.prepare(sql).run(...params); // kbdb-sql-ok：測試治具（node:sqlite→D1 shim），非牆外業務邏輯繞過 API
        return { success: true, meta: { changes: r.changes } };
      },
    };
    return s;
  }
  return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}

function insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
  const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, page_name, created_at, updated_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
  db.prepare(sql).bind( // kbdb-sql-ok：測試治具（node:sqlite→D1 shim）灌測試資料
    e.id,
    e.content === undefined ? 'x' : e.content,
    (e.entry_type ?? 'block') as EntryType,
    e.owner_id ?? 'bfezv28v',
    e.content_hash ?? null,
    e.is_embedded ?? 0,
    e.metadata_json === undefined ? null : e.metadata_json,
    e.page_name ?? null,
    e.created_at,
    e.created_at,
  ).run();
}

async function getLibrary(db: D1Database, id: string): Promise<string | null> {
  const row = await db.prepare("SELECT json_extract(metadata_json, '$.library') AS library FROM entries WHERE id = ?").bind(id).first<{ library: string | null }>(); // kbdb-sql-ok：測試治具讀回斷言用
  return row?.library ?? null;
}

function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
  return {
    DB: db,
    ENVIRONMENT: 'test',
    KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
  } as unknown as Bindings;
}

describe('backfillEntryLibraryTags — 基本行為', () => {
  it('只標記符合條件、目前未標記 library 的候選；已標記的不動', async () => {
    const db = makeSqliteD1();
    insertEntry(db, { id: 'a', created_at: 1 }); // 無 metadata_json → 未標記
    insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({}) }); // 有 metadata_json 但無 library
    insertEntry(db, { id: 'c', created_at: 3, metadata_json: JSON.stringify({ library: 'hr' }) }); // 已標記，不該被動
    const env = makeEnv(db);
    const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
    expect(r.tagged).toBe(2);
    expect(r.remaining).toBe(0);
    expect(await getLibrary(db, 'a')).toBe('finance');
    expect(await getLibrary(db, 'b')).toBe('finance');
    expect(await getLibrary(db, 'c')).toBe('hr'); // 未被覆寫
  });

  it('冪等：全部標記完後重跑不再處理', async () => {
    const db = makeSqliteD1();
    insertEntry(db, { id: 'a', created_at: 1 });
    const env = makeEnv(db);
    await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
    const r2 = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
    expect(r2.tagged).toBe(0);
    expect(r2.remaining).toBe(0);
  });

  it('owner_id 缺了要拋錯（防補錯 owner 等於白做，2026-08-11 leo 直令）', async () => {
    const db = makeSqliteD1();
    insertEntry(db, { id: 'a', created_at: 1 });
    const env = makeEnv(db);
    await expect(
      backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: '' }),
    ).rejects.toThrow(/owner_id/);
  });

  it('library 缺了要拋錯', async () => {
    const db = makeSqliteD1();
    const env = makeEnv(db);
    await expect(
      backfillEntryLibraryTags(db, env, { library: '', owner_id: 'bfezv28v' }),
    ).rejects.toThrow(/library/);
  });

  it('owner_id 篩選：只動指定租戶的資料，其他租戶不受影響（跨租戶隔離）', async () => {
    const db = makeSqliteD1();
    insertEntry(db, { id: 'mine', created_at: 1, owner_id: 'bfezv28v' });
    insertEntry(db, { id: 'theirs', created_at: 2, owner_id: 'someone-else' });
    const env = makeEnv(db);
    const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
    expect(r.tagged).toBe(1);
    expect(await getLibrary(db, 'mine')).toBe('finance');
    expect(await getLibrary(db, 'theirs')).toBe(null); // 別的租戶完全沒被動到
  });

  it('source_prefix／page_name_prefix／since／until 篩選真的在篩', async () => {
    const db = makeSqliteD1();
    insertEntry(db, { id: 'match-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/kb/foo.md' }) });
    insertEntry(db, { id: 'other-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/other/bar.md' }) });
    const r1 = await backfillEntryLibraryTags(db, makeEnv(db), {
      library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea://Leo/kb/',
    });
    expect(r1.tagged).toBe(1);
    expect(await getLibrary(db, 'match-source')).toBe('kb');
    expect(await getLibrary(db, 'other-source')).toBe(null);

    const db2 = makeSqliteD1();
    insertEntry(db2, { id: 'in-window', created_at: 500, page_name: 'wiki/foo' });
    insertEntry(db2, { id: 'out-window', created_at: 5000, page_name: 'wiki/bar' });
    const r2 = await backfillEntryLibraryTags(db2, makeEnv(db2), {
      library: 'wiki', owner_id: 'bfezv28v', page_name_prefix: 'wiki/', since: 0, until: 1000,
    });
    expect(r2.tagged).toBe(1);
    expect(await getLibrary(db2, 'in-window')).toBe('wiki');
    expect(await getLibrary(db2, 'out-window')).toBe(null);
  });

  it('page_names 精準比對（leo 定案的正解：拿 Gitea 原稿卡名逐批遍歷點名）', async () => {
    const db = makeSqliteD1();
    insertEntry(db, { id: 'a', created_at: 1, page_name: 'card-alpha' });
    insertEntry(db, { id: 'b', created_at: 2, page_name: 'card-beta' });
    insertEntry(db, { id: 'c', created_at: 3, page_name: 'card-gamma' }); // 不在點名清單內
    const r = await backfillEntryLibraryTags(db, makeEnv(db), {
      library: 'kb', owner_id: 'bfezv28v', page_names: ['card-alpha', 'card-beta'],
    });
    expect(r.tagged).toBe(2);
    expect(await getLibrary(db, 'a')).toBe('kb');
    expect(await getLibrary(db, 'b')).toBe('kb');
    expect(await getLibrary(db, 'c')).toBe(null); // 沒被點名，不動
  });

  it('libraryBackfillStatus 回報待補標筆數', async () => {
    const db = makeSqliteD1();
    insertEntry(db, { id: 'a', created_at: 1 });
    insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({ library: 'hr' }) });
    const s = await libraryBackfillStatus(db, { owner_id: 'bfezv28v' });
    expect(s.pending).toBe(1); // 只有 'a' 未標記
  });
});

describe('Arcrun#85 D69：標庫 backfill 與 reconcile 共用同一顆 D1 每日寫入額度', () => {
  it('reconcile 先消耗額度 → 標庫 backfill 看到的剩餘額度真的變少', async () => {
    const db = makeSqliteD1();
    // reconcile 的候選：is_embedded=1 且 content_hash 非現行世代
    // library 已標記（'hr'）→ 不會被下面的標庫 backfill 選中，讓兩種候選池互不重疊，
    // 才能單純驗證「額度共用」本身，不被「標庫候選也吃到 reconcile 資料」干擾。
    insertEntry(db, { id: 'reconcile-1', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
    insertEntry(db, { id: 'reconcile-2', created_at: 2, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
    // 標庫的候選：未標記 library
    insertEntry(db, { id: 'tag-1', created_at: 3 });
    insertEntry(db, { id: 'tag-2', created_at: 4 });
    insertEntry(db, { id: 'tag-3', created_at: 5 });

    const maintenanceLimit = '3'; // 5 個候選（2 reconcile + 3 tag），額度只夠 3 個
    const reconcileEnv = {
      DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
      AI: { async run() { return { data: [] }; } },
      VECTORIZE: {
        async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); }, // 全部視為現行 index 已有
        async upsert() { return { count: 0 }; },
      },
    } as unknown as Bindings;

    // 先跑 reconcile：吃掉 2 筆額度（3 - 2 = 1 剩）
    const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
    expect(rc.checked).toBe(2);
    expect(rc.quota_used_today).toBe(2);

    // 標庫 backfill 用同一顆 DB／同一個每日上限：只剩 1 筆額度可用，即使候選有 3 筆
    const tagEnv = makeEnv(db, { maintenanceLimit });
    const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
    expect(tagResult.scanned).toBe(3);   // 3 筆候選都掃到了
    expect(tagResult.tagged).toBe(1);    // 但只剩 1 筆額度，只標了 1 筆
    expect(tagResult.quota_exceeded).toBe(true);
    expect(tagResult.quota_used_today).toBe(3); // 2（reconcile）+ 1（本次）＝ 3，額度用滿
  });

  it('反過來也一樣：標庫 backfill 先消耗額度 → reconcile 看到的剩餘額度真的變少', async () => {
    const db = makeSqliteD1();
    insertEntry(db, { id: 'tag-1', created_at: 1 });
    insertEntry(db, { id: 'tag-2', created_at: 2 });
    insertEntry(db, { id: 'reconcile-1', created_at: 3, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true }) });

    const maintenanceLimit = '2';
    const tagEnv = makeEnv(db, { maintenanceLimit });
    const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
    expect(tagResult.tagged).toBe(2); // 額度剛好夠標完兩筆

    const reconcileEnv = {
      DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
      AI: { async run() { return { data: [] }; } },
      VECTORIZE: {
        async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); },
        async upsert() { return { count: 0 }; },
      },
    } as unknown as Bindings;
    const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
    expect(rc.scanned).toBe(1);      // 有 1 筆候選
    expect(rc.checked).toBe(0);      // 但額度已被標庫 backfill 用光，reconcile 一筆都動不了
    expect(rc.quota_exceeded).toBe(true);
  });
});
