// 語意搜尋「故障要照實說是故障」的回歸測試（2026-08-09 leo 直令）。
//
// 事故：portal 曾把「kbdb 缺 VECTORIZE/AI binding（＝壞了）」顯示成「語意搜尋還沒開通，
// 想開通請匯出診斷檔給我們」——把 bug 美化成沒提供的功能，會製造「幫我開通」的客服工單，
// 而真正的故障沒人修。leo 原話：「沒有人會把 bug 美化成沒提供沒開通」。
//
// 三條鐵則（本檔全部驗死）：
//   1. 模組不在（module_off）＝故障：文案說「故障/我們的問題/你不用做任何事」，
//      禁出現「開通/未啟用/尚未提供」這類把壞說成沒有的字眼，也不要求使用者任何動作。
//   2. 查詢向量化失敗（embed_query_failed）＝故障：**不准回空結果集**（舊行為＝
//      使用者以為自己的庫裡沒有這筆資料）。誠實降級 keyword＋帶 degraded_reason。
//   3. 索引與資料不同步（孤兒向量/下架殘影）→ 搜尋順手自癒（背景刪向量），不留給用戶撞。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import type { Bindings, Entry } from '../src/types';

function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
  return {
    id, content: '一些內容', entry_type: 'block', owner_id: 't1', parent_id: null,
    page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
    is_embedded: 1, confidence: null,
    metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated', embed: true }) : JSON.stringify({ embed: true }),
    created_at: 1, updated_at: 1,
  };
}

// fake D1：紀錄所有 prepare 過的 SQL（驗自癒有沒有真的動手）；COUNT 依 SQL 內容回
// embedded/pending 兩種計數；getEntry（WHERE id = ?）回可配置 entry。
function makeFakeDB(opts: {
  embeddedCount?: number;
  pendingCount?: number;
  hydrate?: Record<string, Entry | null>;
  pendingRows?: Entry[];
} = {}) {
  const sqls: string[] = [];
  const prepare = (sql: string) => {
    sqls.push(sql);
    let bound: unknown[] = [];
    const stmt = {
      bind(...args: unknown[]) { bound = args; return stmt; },
      async first<T>() {
        if (sql.includes('WHERE id = ?')) {
          const id = String(bound[0]);
          return ((opts.hydrate ?? {})[id] ?? null) as unknown as T;
        }
        if (sql.includes('COUNT(*)')) {
          // backfillStatus：pending 用 BACKFILL_PREDICATE（含 is_embedded = 0），embedded 用 is_embedded = 1
          if (sql.includes('is_embedded = 0')) return { c: opts.pendingCount ?? 0 } as unknown as T;
          return { c: opts.embeddedCount ?? 0 } as unknown as T;
        }
        return null as unknown as T;
      },
      async all<T>() {
        // backfillEmbeddings 的候選 SELECT（含 is_embedded = 0）
        if (sql.includes('is_embedded = 0') && sql.includes('SELECT *')) {
          return { results: (opts.pendingRows ?? []) as unknown as T[] };
        }
        return { results: [] as T[] };
      },
      async run() { return { success: true }; },
    };
    return stmt;
  };
  return { db: { prepare } as unknown as D1Database, sqls };
}

function makeApp() {
  const app = new Hono<{ Bindings: Bindings }>();
  app.route('/entries', entryRoutes);
  return app;
}

/** 收集 waitUntil 的 promise，測試結尾 await 全部，讓背景自癒動作跑完再斷言。 */
function makeCtx() {
  const tasks: Promise<unknown>[] = [];
  return {
    ctx: { waitUntil: (p: Promise<unknown>) => { tasks.push(p); }, passThroughOnException() {}, props: {} } as unknown as ExecutionContext,
    flush: async () => { await Promise.allSettled(tasks); return tasks.length; },
  };
}

const NO_BLAME_USER = (hint: string) => {
  // 禁把故障說成「沒提供/沒開通」；禁要求使用者做「申請開通」類動作
  expect(/開通|尚未啟用|未啟用|還沒啟用|尚未提供|沒有提供/.test(hint)).toBe(false);
  expect(/請聯絡我們(開通|啟用)|匯出診斷/.test(hint)).toBe(false);
  // 必須講明是系統端的問題、使用者不用動作
  expect(/我們(系統)?的問題|系統的問題/.test(hint)).toBe(true);
};

describe('mode=semantic 但 embed 模組不在（module_off）——故障，不是「沒開通」', () => {
  it('誠實降級 keyword＋degraded_reason=module_off，文案照實說故障、不叫使用者做事', async () => {
    const app = makeApp();
    const { db } = makeFakeDB();
    const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; // 無 AI/VECTORIZE
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
    expect(res.status).toBe(200);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.mode).toBe('keyword');
    expect(body.requested_mode).toBe('semantic');
    expect(body.degraded_reason).toBe('module_off');
    const hint = body.capability_hint as string;
    expect(hint).toContain('故障');
    NO_BLAME_USER(hint);
    // admin_hint 保留技術細節給維運者
    expect(String(body.admin_hint)).toMatch(/VECTORIZE|binding/);
  });
});

describe('查詢向量化失敗（embed_query_failed）——不准偽裝成「查無資料」', () => {
  it('AI.run 丟錯（額度用完）→ 200 誠實降級 keyword，不回空語意結果', async () => {
    const app = makeApp();
    const { db } = makeFakeDB();
    const env = {
      DB: db, ENVIRONMENT: 'test',
      AI: { async run() { throw new Error('3040: daily limit exceeded'); } },
      VECTORIZE: { async query() { throw new Error('不應該走到 Vectorize'); } },
    } as unknown as Bindings;
    const res = await app.request('/entries/search?q=閉環機&mode=semantic&owner_id=t1', {}, env);
    expect(res.status).toBe(200);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.mode).toBe('keyword');
    expect(body.requested_mode).toBe('semantic');
    expect(body.degraded_reason).toBe('embed_query_failed');
    const hint = body.capability_hint as string;
    expect(hint).toContain('故障');
    NO_BLAME_USER(hint);
    expect(String(body.admin_hint)).toContain('daily limit exceeded');
  });

  it('AI.run 回不出向量（形狀異常）→ 同樣走誠實降級，不是空結果', async () => {
    const app = makeApp();
    const { db } = makeFakeDB();
    const env = {
      DB: db, ENVIRONMENT: 'test',
      AI: { async run() { return {}; } }, // 沒有 data
      VECTORIZE: { async query() { return { matches: [] }; } },
    } as unknown as Bindings;
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.degraded_reason).toBe('embed_query_failed');
    expect(body.mode).toBe('keyword');
  });
});

describe('索引與資料不同步 → 搜尋順手自癒（不留給下一個用戶撞）', () => {
  it('孤兒向量＋下架殘影：背景 deleteByIds 兩顆、殘影 is_embedded 歸零', async () => {
    const app = makeApp();
    const deleted: string[][] = [];
    const { db, sqls } = makeFakeDB({
      embeddedCount: 5,
      hydrate: { live1: mkEntry('live1'), dep1: mkEntry('dep1', { deprecated: true }), gone1: null },
    });
    const env = {
      DB: db, ENVIRONMENT: 'test',
      AI: { async run() { return { data: [[0.1, 0.2]] }; } },
      VECTORIZE: {
        async query() {
          return { matches: [ { id: 'live1', score: 0.9 }, { id: 'dep1', score: 0.8 }, { id: 'gone1', score: 0.7 } ] };
        },
        async deleteByIds(ids: string[]) { deleted.push(ids); },
      },
    } as unknown as Bindings;
    const { ctx, flush } = makeCtx();
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.count).toBe(1); // 正常結果不受自癒影響
    await flush();
    expect(deleted.flat().sort()).toEqual(['dep1', 'gone1']);
    // 殘影（dep1）另外把 is_embedded 歸零，讓 D1 與 Vectorize 不說兩套話
    expect(sqls.some((s) => s.includes('SET is_embedded = 0'))).toBe(true);
  });

  it('no_index 且 pending>0（資料在、索引從沒建成＝故障）→ 文案不怪用戶＋背景觸發 backfill', async () => {
    const app = makeApp();
    let aiCalls = 0;
    const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 3, pendingRows: [mkEntry('p1')] });
    const env = {
      DB: db, ENVIRONMENT: 'test',
      AI: { async run() { aiCalls++; return { data: [[0.1, 0.2]] }; } },
      VECTORIZE: { async query() { return { matches: [] }; }, async upsert() {}, async deleteByIds() {} },
    } as unknown as Bindings;
    const { ctx, flush } = makeCtx();
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.empty_reason).toBe('no_index');
    const hint = body.capability_hint as string;
    NO_BLAME_USER(hint);
    await flush();
    // backfill 有真的跑（查詢那次 + 補嵌那批 ≥ 2 次 AI.run）
    expect(aiCalls).toBeGreaterThanOrEqual(2);
  });

  it('no_index 且 pending=0（庫真的還沒內容）→ 誠實說還沒有資料，不謊稱故障', async () => {
    const app = makeApp();
    const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 0 });
    const env = {
      DB: db, ENVIRONMENT: 'test',
      AI: { async run() { return { data: [[0.1, 0.2]] }; } },
      VECTORIZE: { async query() { return { matches: [] }; } },
    } as unknown as Bindings;
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.empty_reason).toBe('no_index');
    expect(String(body.capability_hint)).toContain('還沒有');
    expect(/故障/.test(String(body.capability_hint))).toBe(false);
  });
});
