// 語意搜尋回空時「為什麼空」的回歸測試（2026-08-08，總管交辦二修，Oscar 封測案更正後的真因）。
//
// 背景：原本以為 Oscar 撞到的是「capability_hint 文案太工程師」，後來查出模組其實有開，
// 真正發生的是 GET /entries/search?mode=semantic 零命中時回 {success:true, entries:[], count:0}
// ——誠實（沒假裝有結果）但完全不說為什麼，用戶看到的是「這裡沒有這筆資料」，
// 真相可能是「索引根本沒建好」。三態：
//   - no_index     ：這個 owner 範圍從沒 embed 過（backfillStatus.embedded===0）
//   - no_match     ：有索引，這次查詢在 Vectorize 端零命中（正常的「找不到」）
//   - stale_index  ：Vectorize 端有命中，但 hydrate 後全部是已下架／找不到對應資料（孤兒向量）
//     （不是「相對門檻濾光」——relativeMinScore 的 cut 數學上 <= 最高分，不可能讓非空結果變空）
// 正常有結果（count>0）不受影響，不應該出現 empty_reason 欄位。
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: 'oscar-tenant', 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' }) : JSON.stringify({ embed: true }),
    created_at: 1, updated_at: 1,
  };
}

// fake D1：COUNT 查詢回傳可配置的 embeddedCount；`WHERE id = ?`（getEntry）回傳可配置的 entry。
function makeFakeDB(opts: { embeddedCount?: number; hydrateEntry?: Entry | null } = {}) {
  const embeddedCount = opts.embeddedCount ?? 0;
  const prepare = (sql: string) => {
    let bound: unknown[] = [];
    const stmt = {
      bind(...args: unknown[]) { bound = args; return stmt; },
      async first<T>() {
        if (sql.includes('WHERE id = ?')) {
          return (opts.hydrateEntry ?? null) as unknown as T;
        }
        return { c: embeddedCount } as unknown as T;
      },
      async all<T>() { return { results: [] as T[] }; },
      async run() { return { success: true }; },
    };
    return stmt;
  };
  return { prepare } as unknown as D1Database;
}

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

function makeEnv(dbOpts: Parameters<typeof makeFakeDB>[0], matches: { id: string; score: number }[]): Bindings {
  return {
    DB: makeFakeDB(dbOpts),
    ENVIRONMENT: 'test',
    AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
    VECTORIZE: { async query() { return { matches }; } },
  } as unknown as Bindings;
}

describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼空」', () => {
  it('embedded=0（從沒 embed 過）→ empty_reason=no_index，人話不含 vectorize/redeploy/CC', async () => {
    const app = makeApp();
    const env = makeEnv({ embeddedCount: 0 }, []);
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.mode).toBe('semantic');
    expect(body.count).toBe(0);
    expect(body.empty_reason).toBe('no_index');
    const hint = body.capability_hint as string;
    expect(hint).toBeTruthy();
    expect(/vectorize|redeploy|CC「|binding|kbdb_embed/i.test(hint)).toBe(false);
    expect(body.admin_hint).toBeTruthy();
  });

  it('embedded>0 但這次零命中 → empty_reason=no_match（正常的「找不到」，非故障）', async () => {
    const app = makeApp();
    const env = makeEnv({ embeddedCount: 42 }, []);
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.mode).toBe('semantic');
    expect(body.count).toBe(0);
    expect(body.empty_reason).toBe('no_match');
  });

  it('Vectorize 有命中但對應資料已下架 → empty_reason=stale_index（非分數門檻）', async () => {
    const app = makeApp();
    // 命中一筆，但 hydrate 回來的 entry 是已下架的 → 濾光 → entries=0，hits.length=1(>0)。
    const env = makeEnv({ hydrateEntry: mkEntry('e1', { deprecated: true }) }, [{ id: 'e1', score: 0.6 }]);
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.mode).toBe('semantic');
    expect(body.count).toBe(0);
    expect(body.empty_reason).toBe('stale_index');
  });

  it('正常有結果（count>0）不受影響：無 empty_reason 欄位', async () => {
    const app = makeApp();
    const env = makeEnv({ hydrateEntry: mkEntry('e1') }, [{ id: 'e1', score: 0.6 }]);
    const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.mode).toBe('semantic');
    expect(body.count).toBe(1);
    expect(body.empty_reason).toBeUndefined();
    expect(body.capability_hint).toBeUndefined();
  });
});
