// 搜尋框裡的 `%` 與 `_` 是「要找的字」，不是萬用字元 —— Arcrun#94（2026-08-12）
//
// 病徵（leo 回報）：搜尋框打 `%` 或 `_`，搜出來一堆跟他打的字**無關**的東西。
//   根因：pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插，而 SQLite 的 LIKE 有兩個
//   萬用字元 `%`／`_` 且**沒有預設跳脫字元** ⇒ 使用者打的符號被當成 pattern 語法。
//
// 舊病，不是 08-10 斷詞（search-tokenize.test.ts）引進的：pattern 從來就是這樣拼的。
//   之前關鍵字搜尋幾乎恆為 0 命中，這個洞被那個洞蓋住；斷詞讓搜尋真的會回東西之後才浮出來。
//
// 測試策略：**用真 SQLite 跑真的 SQL**（node:sqlite，與 library-map／embed-backfill 同款 adapter）。
//   只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事，只有真的跑一次 LIKE 才看得見。
//   每組驗收都同時跑「舊寫法」與「現行寫法」，讓前後對照直接長在測試裡（legacyPattern）。
//
// 註：直接對 SQLite 治具下 SQL 的行集中在下面的 helper（測試治具本身，非牆外業務邏輯繞過
//   API），每行標 kbdb-sql-ok 留痕——與 embed-backfill／library-backfill 等既有測試同慣例。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import {
  escapeLikeLiteral,
  buildContentLike,
  buildSearchScore,
  searchEntries,
  createEntry,
} from '../src/actions/entry-crud';

// ── node:sqlite → D1 最小 adapter ────────────────────────────────────────────
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：測試治具套 migration 原檔
  function stmt(sql: string, params: unknown[]) {
    return {
      bind(...args: unknown[]) { return stmt(sql, args); },
      async all<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok：測試治具（node:sqlite→D1 shim）
      async first<T>() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok：測試治具（node:sqlite→D1 shim）
      async run() { raw.prepare(sql).run(...(params as never[])); return { success: true }; }, // kbdb-sql-ok：測試治具（node:sqlite→D1 shim）
    };
  }
  return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}

/** 舊寫法（本次修掉的那個）：使用者輸入直接內插、LIKE 不帶 ESCAPE。前後對照用。 */
const legacyPattern = (q: string) => `%${q}%`;

/** 對真 SQLite 跑一次 `content LIKE ?`，回命中的 content（要不要帶 ESCAPE 可選）。 */
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
  const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
  const res = await db
    .prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok：測試治具讀回斷言用
    .bind(pattern)
    .all<{ content: string }>();
  return (res.results ?? []).map((x) => x.content);
}

/** 把 searchEntries 送出的 SQL 側錄下來（不改行為，只是中間插一層）。 */
function sqlSpy(db: D1Database): { spy: D1Database; sqls: string[] } {
  const sqls: string[] = [];
  const spy = {
    prepare: (sql: string) => { sqls.push(sql); return db.prepare(sql); }, // kbdb-sql-ok：測試治具側錄，轉呼叫同一顆治具 DB
  } as unknown as D1Database;
  return { spy, sqls };
}

const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限（承 2026-08-03 的 500 修復）

// 一組刻意設計的語料：每一筆都用來分辨「字面命中」與「萬用字元誤中」。
const CORPUS = [
  '毛利率 100% 達成',          // 含字面 %
  '共有 100 個待辦項目',        // 含 100 但不含 %，`%100%%` 會誤中它
  'owner_id 是租戶隔離的欄位',  // 含字面 _
  'ownerXid 是打錯的欄位名',    // `_` 當萬用字元才會中
  '路徑 C:\\_temp 底下',         // 含字面「反斜線＋底線」（跳脫字元本身 ＋ 萬用字元）
  '路徑 C:\\Xtemp 底下',         // 反斜線後接任一字元——`_` 漏成萬用字元才會中
  '完全無關的一筆內容',          // 對照組：什麼都不該中
];

async function seeded(): Promise<D1Database> {
  const db = makeSqliteD1();
  for (const [i, content] of CORPUS.entries()) {
    await createEntry(db, { id: `e${i}`, content, entry_type: 'block', owner_id: 'leo' });
  }
  return db;
}

describe('① 前後對照：使用者打什麼字，就照那些字找', () => {
  it('`100%`：舊寫法把 % 當萬用字元、連「100 個待辦」都撈回來；現行只回真的含 100% 的', async () => {
    const db = await seeded();
    const before = await likeHits(db, legacyPattern('100%'), false);
    const after = await likeHits(db, buildContentLike('100%').params[0], true);

    expect(before).toEqual(['毛利率 100% 達成', '共有 100 個待辦項目']); // ← 病徵：多了不相干的
    expect(after).toEqual(['毛利率 100% 達成']);                        // ← 只有真的含「100%」的
  });

  it('`owner_id`：舊寫法 _ 匹配任一字元、把 ownerXid 也撈回來；現行只回字面相符的', async () => {
    const db = await seeded();
    expect(await likeHits(db, legacyPattern('owner_id'), false)).toEqual([
      'owner_id 是租戶隔離的欄位',
      'ownerXid 是打錯的欄位名', // ← 病徵
    ]);
    expect(await likeHits(db, buildContentLike('owner_id').params[0], true)).toEqual([
      'owner_id 是租戶隔離的欄位',
    ]);
  });

  it('只打一個 `%` 或 `_`：舊寫法把**整個庫**倒回來（leo 回報的那個畫面）', async () => {
    const db = await seeded();
    // `%%%` 匹配任何字串；`%_%` 只要有一個字元就中 ⇒ 兩者都等於「全庫」
    expect(await likeHits(db, legacyPattern('%'), false)).toHaveLength(CORPUS.length);
    expect(await likeHits(db, legacyPattern('_'), false)).toHaveLength(CORPUS.length);

    // 現行：只回真的含那個字元的（`_` 有兩筆——欄位名那筆與路徑那筆，兩筆都是字面命中）
    expect(await likeHits(db, buildContentLike('%').params[0], true)).toEqual(['毛利率 100% 達成']);
    expect(await likeHits(db, buildContentLike('_').params[0], true)).toEqual([
      'owner_id 是租戶隔離的欄位',
      '路徑 C:\\_temp 底下',
    ]);
  });

  it('走完整搜尋路徑（searchEntries，含斷詞與算分）結果一致——不是只有底層函式對', async () => {
    const db = await seeded();
    expect((await searchEntries(db, '100%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
    expect((await searchEntries(db, 'owner_id', 'leo')).map((e) => e.content)).toEqual([
      'owner_id 是租戶隔離的欄位',
    ]);
    // 單打一個符號：以前是全庫，現在是「真的含那個字的那一筆」
    expect((await searchEntries(db, '%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
    expect((await searchEntries(db, '_', 'leo')).map((e) => e.content).sort()).toEqual(
      ['owner_id 是租戶隔離的欄位', '路徑 C:\\_temp 底下'].sort(),
    );
  });
});

describe('② 邊界：跳脫字元本身（`\\`）也必須跳脫', () => {
  // 為什麼這組必須存在：宣告了 ESCAPE 之後，`\` 就變成 pattern 裡有意義的字元。
  // 只跳脫 % 和 _、不跳脫 `\`，等於用新的漏洞換掉舊的——而且更難發現，因為它
  // **不會報錯**，只會靜靜地把後面那個字吃掉、去找一個使用者沒打過的字串。
  const halfDone = (q: string) => `%${q.replace(/[%_]/g, (c) => '\\' + c)}%`; // 只跳脫 %/_ 的假想修法

  it('打 `C:\\`：不跳脫反斜線的話尾巴變成「字面 %」，反而找不到任何真正含 `C:\\` 的內容', async () => {
    const db = await seeded();
    // pattern `%C:\%` ⇒ 尾巴的 `\%` 被讀成「字面的 %」⇒ 實際去找 `C:%`，庫裡沒有 ⇒ 全漏
    expect(await likeHits(db, halfDone('C:\\'), true)).toEqual([]);
    expect(await likeHits(db, buildContentLike('C:\\').params[0], true)).toEqual([
      '路徑 C:\\_temp 底下',
      '路徑 C:\\Xtemp 底下',
    ]);
  });

  it('打 `C:\\_temp`：反斜線沒跳脫 ⇒ 它把 `_` 的跳脫吃掉，萬用字元漏回來、撈到不相干的', async () => {
    const db = await seeded();
    // `%C:\\_temp%`：`\\` 先被讀成「字面 \」，後面那個 `_` 就變回萬用字元 ⇒ C:\Xtemp 也中
    expect(await likeHits(db, halfDone('C:\\_temp'), true)).toEqual([
      '路徑 C:\\_temp 底下',
      '路徑 C:\\Xtemp 底下', // ← 使用者沒打過這個字
    ]);
    expect(await likeHits(db, buildContentLike('C:\\_temp').params[0], true)).toEqual([
      '路徑 C:\\_temp 底下',
    ]);
  });

  it('打 `100\\%`：三個都不跳脫 ⇒ `\\%` 被讀成「字面 %」⇒ 去找 `100%`，跟他打的不一樣', async () => {
    const db = await seeded();
    // 原始寫法（一個都不跳脫）＝ pattern `%100\%%`：`\%`＝字面 %、尾巴那個 `%`＝萬用字元
    expect(await likeHits(db, legacyPattern('100\\%'), true)).toEqual(['毛利率 100% 達成']); // ← 找錯東西
    // 正解：庫裡沒有字面的 `100\%` ⇒ 就該零命中，而不是拿別的東西充數
    expect(await likeHits(db, buildContentLike('100\\%').params[0], true)).toEqual([]);
  });

  it('escapeLikeLiteral 只碰這三個字元，且不會把自己剛加的跳脫再跳脫一次', () => {
    expect(escapeLikeLiteral('100%')).toBe('100\\%');
    expect(escapeLikeLiteral('owner_id')).toBe('owner\\_id');
    expect(escapeLikeLiteral('C:\\')).toBe('C:\\\\');
    expect(escapeLikeLiteral('%_\\')).toBe('\\%\\_\\\\'); // 三個各自跳脫一次，不是兩次
    // LIKE 沒有 [] ? * 這些萬用字元（那是 GLOB／別的方言）⇒ 不該白白吃掉 byte 預算
    expect(escapeLikeLiteral('a[b]?c*d 中文')).toBe('a[b]?c*d 中文');
  });
});

describe('③ 每個 LIKE 都要帶 ESCAPE 宣告，否則跳脫過的 pattern 反而被當字面', () => {
  it('buildContentLike／buildSearchScore 產生的謂詞都含 ESCAPE', () => {
    for (const c of buildContentLike('100%').conds) expect(c).toContain("ESCAPE '\\'");
    for (const c of buildContentLike('a'.repeat(200)).conds) expect(c).toContain("ESCAPE '\\'");
    expect(buildSearchScore('Gemini 逃生口').scoreExpr).toContain("ESCAPE '\\'");
    expect(buildSearchScore('。。。').scoreExpr).toContain("ESCAPE '\\'"); // 一個詞都拆不出來的退路
  });

  it('沒有任何 `content LIKE ?` 是裸的（漏掉一個就等於那條路沒修）', async () => {
    const { spy, sqls } = sqlSpy(await seeded());
    for (const q of ['100%', 'Gemini 逃生口', '。。。', 'a'.repeat(200)]) await searchEntries(spy, q, 'leo');
    expect(sqls.length).toBeGreaterThan(0);
    for (const sql of sqls) expect(sql.match(/content LIKE \?(?! ESCAPE)/g) ?? []).toHaveLength(0);
  });

  it('ESCAPE 宣告本身是合法 SQL（D1＝SQLite；真的跑得起來，不是形狀對而已）', async () => {
    const db = await seeded();
    await expect(likeHits(db, '%100\\%%', true)).resolves.toEqual(['毛利率 100% 達成']);
  });
});

describe('④ 不退化：不含 % _ \\ 的查詢，行為與修改前逐字相同', () => {
  it('pattern 一個字都沒變（跳脫對這些字串是恆等變換）', () => {
    for (const q of ['語意檢索', 'arcrun', 'Gemini 逃生口', '為什麼今天額度用完']) {
      expect(escapeLikeLiteral(q)).toBe(q);
    }
    expect(buildContentLike('語意檢索').params).toEqual(['%語意檢索%']);
    expect(buildSearchScore('arcrun').scoreParams).toEqual(['%arcrun%']);
    expect(buildSearchScore('語意檢索').legacyShape).toBe(true); // 最熱路徑仍是單一 LIKE
  });

  it('斷詞（08-10，Arcrun#84 已判定留下）沒被動到：問句照樣拆得開', () => {
    expect(buildSearchScore('Gemini 逃生口').scoreParams).toEqual(
      expect.arrayContaining(['%Gemini%', '%逃生口%']),
    );
  });
});

describe('⑤ 跳脫會變長 ⇒ byte 預算要用「跳脫後」的長度算，否則退回 2026-08-03 那個 500', () => {
  it('滿是 % 的長查詢，每個 pattern 仍在 D1 的 50 bytes 上限內', () => {
    const qs = [
      '%'.repeat(200),                    // 每個字元跳脫後變 2 bytes
      '_'.repeat(60),
      '\\'.repeat(60),
      `${'%'.repeat(30)}中文${'_'.repeat(30)}`,
      'a'.repeat(24) + '%'.repeat(24),    // 卡在舊上限附近的混合
    ];
    for (const q of qs) {
      for (const p of buildContentLike(q).params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
      const plan = buildSearchScore(q);
      expect(plan.scoreParams.length).toBeGreaterThan(0); // 永不空條件（空條件＝WHERE 塌掉）
      for (const p of plan.scoreParams) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
    }
  });

  it('48 個 `%`（跳脫前剛好在舊上限內）不會產生 98 bytes 的 pattern', () => {
    const q = '%'.repeat(48);
    expect(bytes(q)).toBe(48);              // 用舊的算法看，它「在上限內」
    const m = buildContentLike(q);
    expect(m.split).toBe(true);             // 用跳脫後的長度看，它必須被拆開
    for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
  });

  it('拆片段仍切在字元邊界上，不會把跳脫序列切成半個', async () => {
    const db = await seeded();
    for (const p of buildContentLike(`${'%'.repeat(40)}中文${'_'.repeat(40)}`).params) {
      expect(p).not.toContain('\uFFFD');
      // 切壞的跳脫序列（尾巴是落單的 `\`）會讓 SQLite 把後面的 `%` 讀成字面 ⇒ 語意錯掉
      expect(/(^|[^\\])(\\\\)*\\%$/.test(p)).toBe(false);
      await expect(likeHits(db, p, true)).resolves.toBeDefined(); // 真的送得進 SQLite
    }
  });
});
