// Gitea #66/#67 — /entries/search 兩個檢索缺口的回歸測試。
// #66：keyword 路徑 source 參數解析後丟棄（#5.1 只接了 listEntries 那半）→ searchEntries 補
//   json_extract 謂詞、route 傳入；含向後相容（不帶 source ＝ SQL 一字不變）。
// #67：semantic 固定 topK=20、零分數閾值 → route 曝 top_k/min_score、hit 依 min_score 過濾、
//   回應 entry 附 score；含向後相容（不帶新參數 ＝ 行為不變，僅多 score 資訊）。
// 測試手法同 library-filter.test.ts：fake D1 捕 SQL 形狀、mock VECTORIZE 捕 query opts——
// 真 SQL 語意由本機 miniflare 驗（PR 驗收證據）。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { searchEntries } from '../src/actions/entry-crud';
import { semanticSearch } from '../src/embed';
import type { Bindings, Entry } from '../src/types';

const SOURCE_PREDICATE = "json_extract(metadata_json, '$.source') = ?";

// ── fake D1：捕捉 prepared SQL 與 bound params；getEntry（SELECT … WHERE id = ?）回假 entry
//   讓 semantic hydrate 路徑走得完 ──
interface Captured { sql: string; params: unknown[] }
function makeCaptureDB(captured: Captured[]) {
  const prepare = (sql: string) => {
    const rec: Captured = { sql, params: [] };
    captured.push(rec);
    const stmt = {
      bind(...args: unknown[]) { rec.params = args; return stmt; },
      async all<T>() { return { results: [] as T[] }; },
      async first<T>() {
        if (sql.includes('WHERE id = ?')) return mkEntry(String(rec.params[0])) as unknown as T;
        return { total: 0, c: 0 } as unknown as T;
      },
      async run() { return { success: true }; },
    };
    return stmt;
  };
  return { prepare } as unknown as D1Database;
}

function mkEntry(id: string): Entry {
  return {
    id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
    page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
    is_embedded: 0, confidence: null, metadata_json: null, created_at: 1, updated_at: 1,
  };
}

function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
  const app = new Hono<{ Bindings: Bindings }>();
  app.route('/entries', entryRoutes);
  const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
  return { app, env };
}

// ══ #66 source filter ══════════════════════════════════════════════════════

describe('#66 — searchEntries source filter（SQL 形狀）', () => {
  it('帶 source → LIKE＋json_extract($.source) 謂詞＋參數（與 listEntries #5.1 同款）', async () => {
    const captured: Captured[] = [];
    await searchEntries(makeCaptureDB(captured), '遷移', 'tenant1', undefined, undefined, undefined, 'gitea:Leo/kb@main/foo.md');
    expect(captured[0].sql).toContain('content LIKE ?');
    expect(captured[0].sql).toContain(SOURCE_PREDICATE);
    expect(captured[0].params).toContain('gitea:Leo/kb@main/foo.md');
  });

  it('不帶 source → SQL 無 $.source 謂詞（向後相容：行為一字不變）', async () => {
    const captured: Captured[] = [];
    await searchEntries(makeCaptureDB(captured), '遷移', 'tenant1');
    expect(captured[0].sql).not.toContain('$.source');
  });

  it('source＋library 併用 → 兩謂詞都在、參數順序對（source 先於 library）', async () => {
    const captured: Captured[] = [];
    await searchEntries(makeCaptureDB(captured), '遷移', undefined, undefined, undefined, ['finance'], 'src-a');
    expect(captured[0].sql).toContain(SOURCE_PREDICATE);
    expect(captured[0].sql).toContain('$.library');
    // params: [%遷移%, 'src-a', 'finance', limit]
    expect(captured[0].params[1]).toBe('src-a');
    expect(captured[0].params[2]).toBe('finance');
  });
});

describe('#66 — route GET /entries/search（keyword）source 下傳', () => {
  it('?q=x&source=… → 謂詞下到 searchEntries（原 bug：解析完即丟）', async () => {
    const captured: Captured[] = [];
    const { app, env } = makeApp(captured);
    const res = await app.request('/entries/search?q=x&source=gitea%3ALeo%2Fkb%40main%2Ffoo.md', {}, env);
    expect(res.status).toBe(200);
    expect(captured[0].sql).toContain(SOURCE_PREDICATE);
    expect(captured[0].params).toContain('gitea:Leo/kb@main/foo.md');
  });

  it('不帶 source → SQL 無 $.source（向後相容）', async () => {
    const captured: Captured[] = [];
    const { app, env } = makeApp(captured);
    const res = await app.request('/entries/search?q=x', {}, env);
    expect(res.status).toBe(200);
    expect(captured[0].sql).not.toContain('$.source');
  });

  it('semantic 模組未開＋帶 source → 降級 keyword 仍套 source filter（不因降級洩 source）', async () => {
    const captured: Captured[] = [];
    const { app, env } = makeApp(captured); // 無 VECTORIZE/AI → semanticSearch 回 null
    const res = await app.request('/entries/search?q=x&mode=semantic&source=src-a', {}, env);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { mode: string };
    expect(body.mode).toBe('keyword');
    expect(captured[0].sql).toContain(SOURCE_PREDICATE);
    expect(captured[0].params).toContain('src-a');
  });
});

// ══ #67 top_k / min_score ══════════════════════════════════════════════════

// mock VECTORIZE：捕 query opts、回三筆遞減分數（0.9 / 0.5 / 0.2）供閾值截斷驗證。
function makeSemanticEnv(queryCalls: { opts: Record<string, unknown> }[]) {
  return {
    AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
    VECTORIZE: {
      async query(_vec: number[], opts: Record<string, unknown>) {
        queryCalls.push({ opts });
        return {
          matches: [
            { id: 'e-high', score: 0.9, metadata: {} },
            { id: 'e-mid', score: 0.5, metadata: {} },
            { id: 'e-low', score: 0.2, metadata: {} },
          ],
        };
      },
      async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
    },
  };
}

describe('#67 — semanticSearch topK / min_score', () => {
  // 🔴 2026-08-05：預設 min_score 由 0（不過濾）改為 DEFAULT_MIN_SCORE（跟著 embed 模型走）。
  //    原因＝閾值原本硬寫在 portal 呼叫端，換 bge-m3 後沒人回頭改 ⇒ 語義搜尋全 0 命中。
  //    測資分數 0.9 / 0.5 / 0.2：預設閾值 0.5 ⇒ 只有 0.2 的低分尾被砍。
  it('不帶 min_score → topK=20、套用預設閾值（低分尾 0.2 被砍）', async () => {
    const calls: { opts: Record<string, unknown> }[] = [];
    const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
    const hits = await semanticSearch(env, 'query', {});
    expect(calls[0].opts.topK).toBe(20);
    expect(hits?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
    expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5]); // score 帶回
  });

  it('min_score=0.5 → 低分尾截掉（>= 閾值者留）', async () => {
    const calls: { opts: Record<string, unknown> }[] = [];
    const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
    const hits = await semanticSearch(env, 'query', { min_score: 0.5 });
    expect(hits?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
  });

  it('topK 透傳且封頂 100', async () => {
    const calls: { opts: Record<string, unknown> }[] = [];
    const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
    await semanticSearch(env, 'query', { topK: 5 });
    expect(calls[0].opts.topK).toBe(5);
    await semanticSearch(env, 'query', { topK: 500 });
    expect(calls[1].opts.topK).toBe(100);
  });
});

describe('#67 — route GET /entries/search（semantic）top_k / min_score / score 欄', () => {
  function makeSemanticApp(calls: { opts: Record<string, unknown> }[], captured: Captured[] = []) {
    return makeApp(captured, makeSemanticEnv(calls));
  }

  // daemon-beta t24（07-24 補位變更）：route 現在對 Vectorize 的實際查詢 topK 會做「補位」
  // （預設過濾 deprecated 時 ×3 封頂 100，見 entries.ts 補位註解），不再是 top_k 原封透傳到
  // VECTORIZE.query。route 對 caller 的回應仍會照 top_k 截斷（見 body.count/entries 斷言不變）
  // ——這裡改的只是「送進 Vectorize 那次呼叫的 topK 參數」，非對外契約。三筆測試同步更新
  // calls[0].opts.topK 期望值（5→15＝5×3、20→60＝20×3），其餘斷言（回應筆數/內容/score）不動。

  it('?top_k=5&min_score=0.5 → Vectorize 補位 topK=15（5×3）、回應仍照 top_k 截後低分尾、entry 附 score', async () => {
    const calls: { opts: Record<string, unknown> }[] = [];
    const { app, env } = makeSemanticApp(calls);
    const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5&min_score=0.5', {}, env);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { mode: string; count: number; entries: (Entry & { score?: number })[] };
    expect(body.mode).toBe('semantic');
    expect(calls[0].opts.topK).toBe(15); // t24 補位：5 × 3
    expect(body.count).toBe(2); // 0.2 的低分尾被 min_score 截掉
    expect(body.entries.map((e) => e.id)).toEqual(['e-high', 'e-mid']);
    expect(body.entries.map((e) => e.score)).toEqual([0.9, 0.5]);
  });

  it('不帶新參數 → Vectorize 補位 topK=60（預設 20×3），套用相對門檻後只回最高分那筆，entry 仍附 score（加欄不改形）', async () => {
    const calls: { opts: Record<string, unknown> }[] = [];
    const { app, env } = makeSemanticApp(calls);
    const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { count: number; entries: (Entry & { score?: number })[] };
    expect(calls[0].opts.topK).toBe(60); // t24 補位：預設 20 × 3
    // 08-05：未帶 min_score ⇒ 相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只有 0.9 留下
    expect(body.count).toBe(1);
    expect(body.entries[0].score).toBe(0.9);
    // 原有欄位一個不少（回應形狀向後相容）
    expect(body.entries[0].id).toBe('e-high');
    expect(body.entries[0].entry_type).toBe('block');
  });

  it('壞值防呆：top_k=abc / top_k=0 / min_score=-1 → 視同沒帶（回預設 20，補位後 Vectorize topK=60，不 400）', async () => {
    for (const qs of ['top_k=abc', 'top_k=0', 'min_score=-1', 'top_k=abc&min_score=xyz']) {
      const calls: { opts: Record<string, unknown> }[] = [];
      const { app, env } = makeSemanticApp(calls);
      const res = await app.request(`/entries/search?q=x&mode=semantic&${qs}`, {}, env);
      expect(res.status).toBe(200);
      const body = (await res.json()) as { count: number };
      expect(calls[0].opts.topK).toBe(60); // t24 補位：預設 20 × 3
      // 壞值＝視同沒帶 ⇒ 落回相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只留最高分那筆。
      expect(body.count).toBe(1);
    }
  });

  it('keyword 路徑不受 top_k/min_score 影響（參數只作用於 semantic）', async () => {
    const captured: Captured[] = [];
    const { app, env } = makeApp(captured);
    const res = await app.request('/entries/search?q=x&top_k=5&min_score=0.9', {}, env);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { mode: string };
    expect(body.mode).toBe('keyword');
    expect(captured[0].sql).toContain('content LIKE ?'); // SQL 形狀不變
  });
});
