// credential-legacy-migration.test.ts — 「新讀取端上線、舊資料還沒搬完」自癒補丁的迴歸測試
// （D38 圍牆修復收尾，總管交辦，2026-08-08；youlin 測試實例 2026-08-07 事故的根因修復）。
//
// 測試策略比照既有 execution-log.test.ts / library-map.test.ts：真 SQLite（node:sqlite）
// 套 migration 原檔，比 mock DB 更硬——驗的是真實 SQL 語意，不是「以為 SQL 長這樣」。
// 本檔對 D1 介面的直接呼叫全是測試灌資料/驗證用（與上述兩份既有測試同一慣例），
// 不是牆外業務程式碼繞過 API，逐行標 kbdb-sql-ok。
//
// ── 這份測試在證明什麼（對應 leo 08-08 追加的三個安全性質）─────────────────
//   1. 反向驗證（禁假綠的核心）：先重建 2026-08-07 事故的確切狀態——0002 舊表有資料、
//      entries 沒有——直接呼叫 cypher-executor 熱路徑會打的同一個端點（GET /entries?
//      entry_type=credential&owner_id=X），**在補丁加入之前這裡本該回空陣列**（就是
//      事故當天「缺少 credential: kbdb_internal_token」的成因）。本檔驗證補丁讓它改回
//      找得到，等於把事故重現一次、再證明修好。
//   2. 冪等：同一個 owner 呼叫兩次、三次，entries 筆數不重複增加。
//   3. 對「已搬過」與「還沒搬」的實例都正確：不同 owner 各自獨立、互不干擾；已無舊表
//      （模擬清理步驟做完之後）時查詢仍正常運作、不報錯。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { migrateLegacyCredentialsForOwner } from '../src/actions/credential-legacy-migration';
import type { Bindings } from '../src/types';

// ── node:sqlite → D1 介面最小 adapter（同 execution-log.test.ts / library-map.test.ts 手法）──
function makeSqliteD1(): D1Database {
  const raw = new DatabaseSync(':memory:');
  raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔，比照 execution-log.test.ts
  raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
  raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
  function stmt(sql: string, params: unknown[]) {
    const s = {
      bind(...args: unknown[]) { return stmt(sql, args); },
      async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 測試 adapter，比照 execution-log.test.ts
      async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 測試 adapter
      async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 測試 adapter
    };
    return s;
  }
  return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; // kbdb-sql-ok: 測試 adapter 的 D1 介面實作本身
}

function envWith(db: D1Database): Bindings {
  return { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings;
}

function app(db: D1Database) {
  const a = new Hono<{ Bindings: Bindings }>();
  a.route('/entries', entryRoutes);
  return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
}

describe('credential-legacy-migration — 反向驗證：重現 2026-08-07 youlin 事故並證明修好', () => {
  it('事故前置狀態（舊表有資料、entries 沒有）下，GET /entries 一樣能讀到 credential（自癒生效）', async () => {
    const db = makeSqliteD1();
    // 重建事故現場：舊表寫一筆 kbdb_internal_token，entries 完全沒有對應列
    // （新 code 部署了、migration 沒跑——2026-08-07 youlin 的確切狀態）。
    await db
      .prepare( // kbdb-sql-ok: 測試重建舊表資料現場，比照 execution-log.test.ts
        `INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
         VALUES (?, ?, ?, ?, ?, ?, NULL)`,
      )
      .bind('yuga3bse', 'kbdb_internal_token', 'kbdb', 'high', 'CRED_KBDB_INTERNAL_TOKEN_DEADBEEF', Math.floor(Date.now() / 1000))
      .run();

    // 事故當天的確切呼叫形狀：cypher-executor credentials.ts 的 findCredentialEntry /
    // getCredentialDirectory 都是打這個端點。
    const a = app(db);
    const res = await a.fetch('/entries?owner_id=yuga3bse&entry_type=credential&page_name=kbdb_internal_token&limit=1');
    const body = (await res.json()) as { success: boolean; entries: Array<{ page_name: string; metadata_json: string }> };

    expect(body.success).toBe(true);
    expect(body.entries.length).toBe(1); // 補丁加入前這裡是 0——2026-08-07 事故的確切失敗形狀
    expect(body.entries[0].page_name).toBe('kbdb_internal_token');
    const meta = JSON.parse(body.entries[0].metadata_json) as { secret_ref: string; service: string };
    expect(meta.secret_ref).toBe('CRED_KBDB_INTERNAL_TOKEN_DEADBEEF');
    expect(meta.service).toBe('kbdb');
  });

  it('搬移後 KBDB 核心三表結構不變，舊表刻意保留（本檔不清舊表，交由之後的清理步驟）', async () => {
    const db = makeSqliteD1();
    await db
      .prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
      .bind('t1', 'x', null, 'standard', 'CRED_X_AAAA', 1)
      .run();
    await migrateLegacyCredentialsForOwner(db, 't1');
    const tables = await db
      .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢
      .all<{ name: string }>();
    const names = (tables.results ?? []).map((t) => t.name).sort();
    // entries/templates/entry_values 三張核心表 + credentials（舊表，尚未清理）——沒有第五張表。
    expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']);
  });
});

describe('credential-legacy-migration — 冪等（同一 owner 呼叫多次不重複搬）', () => {
  it('連呼叫三次，entries 裡該租戶的 credential 筆數固定為 1', async () => {
    const db = makeSqliteD1();
    await db
      .prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
      .bind('owner-idem', 'telegram_bot_token', 'telegram', 'standard', 'CRED_TELEGRAM_BOT_TOKEN_BEEF', 1000)
      .run();

    const n1 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
    const n2 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
    const n3 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
    expect(n1).toBe(1); // 第一次：真的搬了一筆
    expect(n2).toBe(0); // 第二次起：NOT EXISTS 擋下，不重複
    expect(n3).toBe(0);

    const rows = await db
      .prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type='credential' AND owner_id=?1`) // kbdb-sql-ok: 測試查詢
      .bind('owner-idem')
      .first<{ n: number }>();
    expect(rows?.n).toBe(1);
  });
});

describe('credential-legacy-migration — 多租戶互不干擾，且對「已搬過」與「還沒搬」同時安全', () => {
  it('兩個 owner 各自的 credential 不互相污染；沒有資料的 owner 查詢回空、不報錯', async () => {
    const db = makeSqliteD1();
    await db
      .prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
      .bind('tenant-a', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_A1', 1)
      .run();
    await db
      .prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
      .bind('tenant-b', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_B2', 1)
      .run();

    await migrateLegacyCredentialsForOwner(db, 'tenant-a');
    // tenant-b 完全沒觸發過搬遷（模擬「還沒走到這個租戶的下一次 workflow 執行」）。

    const a = app(db);
    const resA = await a.fetch('/entries?owner_id=tenant-a&entry_type=credential&page_name=gemini_api_key&limit=1');
    const bodyA = (await resA.json()) as { entries: Array<{ metadata_json: string }> };
    expect(JSON.parse(bodyA.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_A1');

    // tenant-b 第一次讀取才觸發自己的搬遷（GET /entries 路由本身會呼叫，不需要呼叫端先知道）。
    const resB = await a.fetch('/entries?owner_id=tenant-b&entry_type=credential&page_name=gemini_api_key&limit=1');
    const bodyB = (await resB.json()) as { entries: Array<{ metadata_json: string }> };
    expect(JSON.parse(bodyB.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_B2');

    // 沒有任何資料的第三個 owner：不報錯、乾淨回空。
    const resC = await a.fetch('/entries?owner_id=tenant-c&entry_type=credential&limit=200');
    const bodyC = (await resC.json()) as { success: boolean; entries: unknown[] };
    expect(bodyC.success).toBe(true);
    expect(bodyC.entries).toEqual([]);
  });

  it('舊表已被清理（不存在）時查詢照常運作（模擬所有租戶搬完後的最終清理狀態）', async () => {
    const db = makeSqliteD1();
    await db.prepare(`DROP TABLE credentials`).run(); // kbdb-sql-ok: 測試模擬「清理步驟已執行」的終態，非牆外存取
    const n = await migrateLegacyCredentialsForOwner(db, 'anyone');
    expect(n).toBe(0); // 短路，不報錯

    const a = app(db);
    const res = await a.fetch('/entries?owner_id=anyone&entry_type=credential&limit=200');
    const body = (await res.json()) as { success: boolean; entries: unknown[] };
    expect(body.success).toBe(true);
    expect(body.entries).toEqual([]);
  });
});
