// portal-auth P1 — 「庫」filter 地基（design §3.2/§3.3，Gitea #24/#25）。
// 覆蓋：D1 filter SQL 形狀（單值/多值/NULL→general fallback 謂詞）、route 參數解析（含向後相容：
// 不帶 library ＝ SQL 一字不變）、semantic 路徑 Vectorize $in filter（mock VECTORIZE）、
// embed 寫入端 library metadata 正規化（未標記→'general'）。
// D1 真實 SQL 語意（COALESCE/json_extract 實際執行）由本機 miniflare + wrangler d1 驗證（PR 驗收證據）。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { listEntries, searchEntries } from '../src/actions/entry-crud';
import { semanticSearch, embedOnWrite } from '../src/embed';
import type { Bindings, Entry } from '../src/types';

const LIB_PREDICATE = "COALESCE(json_extract(metadata_json, '$.library'), 'general') IN";

// ── fake D1：只捕捉 prepared SQL 與 bound params（不解讀語意——真語意交給 miniflare 實跑）──
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>() { 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, metadata_json: string | null): 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, created_at: 1, updated_at: 1,
  };
}

describe('D1 library filter — SQL 形狀（entry-crud）', () => {
  it('listEntries 帶 library 多值 → COALESCE…IN (?,?) 謂詞＋參數', async () => {
    const captured: Captured[] = [];
    await listEntries(makeCaptureDB(captured), { library: ['finance', 'hr'] });
    const select = captured.find((c) => c.sql.startsWith('SELECT *'))!;
    expect(select.sql).toContain(`${LIB_PREDICATE} (?,?)`);
    expect(select.params.slice(0, 2)).toEqual(['finance', 'hr']);
    // COUNT 查詢同謂詞（total 與分頁一致）
    const count = captured.find((c) => c.sql.includes('COUNT(*)'))!;
    expect(count.sql).toContain(`${LIB_PREDICATE} (?,?)`);
  });

  it('listEntries 不帶 library → SQL 無庫謂詞（向後相容：行為一字不變）', async () => {
    const captured: Captured[] = [];
    await listEntries(makeCaptureDB(captured), { owner_id: 'tenant1' });
    for (const c of captured) expect(c.sql).not.toContain('$.library');
  });

  it('searchEntries 帶 library → LIKE＋庫謂詞；不帶 → 原樣', async () => {
    const withLib: Captured[] = [];
    await searchEntries(makeCaptureDB(withLib), '遷移', 'tenant1', undefined, undefined, ['general']);
    expect(withLib[0].sql).toContain('content LIKE ?');
    expect(withLib[0].sql).toContain(`${LIB_PREDICATE} (?)`);
    expect(withLib[0].params).toContain('general');

    const without: Captured[] = [];
    await searchEntries(makeCaptureDB(without), '遷移', 'tenant1');
    expect(without[0].sql).not.toContain('$.library');
  });
});

describe('route 參數解析（GET /entries、/entries/search）', () => {
  function makeApp(captured: Captured[]) {
    const app = new Hono<{ Bindings: Bindings }>();
    app.route('/entries', entryRoutes);
    const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test' } as unknown as Bindings;
    return { app, env };
  }

  it('GET /entries?library=finance,hr →（含空白容忍）庫謂詞＋兩參數', async () => {
    const captured: Captured[] = [];
    const { app, env } = makeApp(captured);
    const res = await app.request('/entries?library=finance,%20hr', {}, env);
    expect(res.status).toBe(200);
    const select = captured.find((c) => c.sql.startsWith('SELECT *'))!;
    expect(select.sql).toContain(`${LIB_PREDICATE} (?,?)`);
    expect(select.params).toContain('finance');
    expect(select.params).toContain('hr');
  });

  it('GET /entries 不帶 library / library=空 → SQL 無庫謂詞（向後相容）', async () => {
    for (const qs of ['', '?library=', '?library=%20,%20']) {
      const captured: Captured[] = [];
      const { app, env } = makeApp(captured);
      const res = await app.request(`/entries${qs}`, {}, env);
      expect(res.status).toBe(200);
      for (const c of captured) expect(c.sql).not.toContain('$.library');
    }
  });

  it('GET /entries/search?q=x&library=finance（keyword 模式）→ 庫謂詞下到 searchEntries', async () => {
    const captured: Captured[] = [];
    const { app, env } = makeApp(captured);
    const res = await app.request('/entries/search?q=x&library=finance', {}, env);
    expect(res.status).toBe(200);
    expect(captured[0].sql).toContain(`${LIB_PREDICATE} (?)`);
    expect(captured[0].params).toContain('finance');
  });

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

describe('semantic 路徑 — Vectorize $in filter（mock VECTORIZE）', () => {
  function makeSemanticEnv(queryCalls: { vec: number[]; opts: Record<string, unknown> }[]) {
    return {
      DB: makeCaptureDB([]),
      ENVIRONMENT: 'test',
      AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
      VECTORIZE: {
        async query(vec: number[], opts: Record<string, unknown>) {
          queryCalls.push({ vec, opts });
          return { matches: [{ id: 'e1', score: 0.9, metadata: { library: 'finance' } }] };
        },
        async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
      },
    } as unknown as Bindings;
  }

  it('帶 library 多值 → filter.library = { $in: [...] }（主路徑，不 fan-out）', async () => {
    const calls: { vec: number[]; opts: Record<string, unknown> }[] = [];
    const env = makeSemanticEnv(calls);
    const hits = await semanticSearch(env, 'query', { owner_id: 'tenant1', library: ['finance', 'hr'] });
    expect(calls.length).toBe(1); // 單次 query（非每庫 fan-out）
    const filter = calls[0].opts.filter as Record<string, unknown>;
    expect(filter.owner_id).toBe('tenant1');
    expect(filter.library).toEqual({ $in: ['finance', 'hr'] });
    expect(hits?.[0]?.library).toBe('finance'); // hit 帶回 library metadata
  });

  it('不帶 library → filter 無 library 鍵（行為不變）', async () => {
    const calls: { vec: number[]; opts: Record<string, unknown> }[] = [];
    const env = makeSemanticEnv(calls);
    await semanticSearch(env, 'query', { owner_id: 'tenant1' });
    const filter = calls[0].opts.filter as Record<string, unknown>;
    expect('library' in filter).toBe(false);
  });
});

describe('embed 寫入端 — library metadata 正規化', () => {
  function makeUpsertEnv(upserts: { id: string; metadata: Record<string, unknown> }[]) {
    const db = {
      prepare: () => {
        const stmt = { bind: () => stmt, run: async () => ({ success: true }), all: async () => ({ results: [] }), first: async () => null };
        return stmt;
      },
    } as unknown as D1Database;
    return {
      DB: db,
      ENVIRONMENT: 'test',
      AI: { async run(_m: string, i: { text: string[] }) { return { data: i.text.map(() => [0.1, 0.2]) }; } },
      VECTORIZE: { async upsert(v: { id: string; metadata: Record<string, unknown> }[]) { upserts.push(...v); return { count: v.length }; } },
    } as unknown as Bindings;
  }

  it('metadata_json 有 library → upsert metadata.library 原值', async () => {
    const upserts: { id: string; metadata: Record<string, unknown> }[] = [];
    const env = makeUpsertEnv(upserts);
    await embedOnWrite(env, mkEntry('e1', JSON.stringify({ embed: true, library: 'finance' })));
    expect(upserts[0].metadata.library).toBe('finance');
  });

  it('未標記 / 空字串 / 非字串 → 正規化為 general（design §3.2 舊資料歸 general）', async () => {
    for (const meta of [{ embed: true }, { embed: true, library: '' }, { embed: true, library: 42 }]) {
      const upserts: { id: string; metadata: Record<string, unknown> }[] = [];
      const env = makeUpsertEnv(upserts);
      await embedOnWrite(env, mkEntry('e1', JSON.stringify(meta)));
      expect(upserts[0].metadata.library).toBe('general');
    }
  });
});
