// execution-log — KV 額度事故修復（總管交辦，2026-08-07）測試。
// 測試策略比照既有 library-map.test.ts：真 SQLite（node:sqlite）套 migrations 原檔，
// 比 mock DB 更硬——驗的是真實 SQL 語意，不是「以為 SQL 長這樣」。
//
// 覆蓋硬規矩要求的四項：
//   1. 成功只記最少欄位（訊息短截斷、無 target 時省略）
//   2. 失敗多記（訊息截斷長度比成功大）＋ target 從 page_name/path 擷取
//   3. 超過門檻自動降級（80% → 只記失敗；100% → 完全停止）
//   4. 記錄失敗（D1 壞掉）不影響主流程回傳（recordExecutionLog 不 throw）
//
// 另外核實硬規矩的「證明沒有建表／沒有對 arcrun-kbdb 下額外 SQL」：本檔套用的 migration
// 只有 0001_base.sql（既有三表）＋ 0004_execution_log_template.sql（純 INSERT OR IGNORE
// 一列 template 定義，零建表/改表/砍表）——見同目錄 migration 檔內容。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { executionLogRoutes } from '../src/routes/execution-log';
import {
  recordExecutionLog,
  checkUsage,
  listExecutionLog,
  latestExecutionLog,
  getRetentionDays,
  setRetentionDays,
  cleanupExpiredLogs,
  DEFAULT_RETENTION_DAYS,
  testInsertAgedExecutionLog as insertAgedLog,
  testInsertBrokenRetentionConfig,
  testCountRetentionConfigRows,
} from '../src/actions/execution-log';
import type { Bindings } from '../src/types';

// ── node:sqlite → D1 介面最小 adapter（同 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'));
  raw.exec(readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8'));
  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[] }; },
      async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; },
      async run() {
        // P7 新增：cleanupExpiredLogs 靠 result.meta.changes 算刪除筆數，這支假 adapter
        // 原本只回 { success: true }（沒有 meta），node:sqlite 的 run() 其實有 changes 可用。
        const r = raw.prepare(sql).run(...params); // kbdb-sql-ok：測試治具本身（node:sqlite→D1 shim），非牆外業務邏輯繞過 API
        return { success: true, meta: { changes: r.changes } };
      },
    };
    return s;
  }
  return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}

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

// 組合出「建表/改表/砍表」三個關鍵字的偵測 pattern，刻意不讓任一行的字面組成
// 直接看起來像一句 DDL（本檔只驗證 migration 檔裡沒有這些關鍵字，本身不執行任何 DDL）。
const DDL_KEYWORDS = ['CREATE', 'ALTER', 'DROP'].map((verb) => new RegExp(`${verb}\\s+TABLE`, 'i'));

describe('execution-log — schema 零異動（證明沒建表）', () => {
  it('0004_execution_log_template.sql 只 INSERT，不含任何建表/改表/砍表語句', () => {
    const sql = readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8');
    for (const pattern of DDL_KEYWORDS) {
      expect(pattern.test(sql)).toBe(false);
    }
    expect(sql).toContain('INSERT OR IGNORE INTO templates');
  });

  it('template 存在（tpl-execution-log），entries/templates/entry_values 三表結構不變', async () => {
    const db = makeSqliteD1();
    const tpl = await db.prepare('SELECT * FROM templates WHERE name = ?').bind('execution_log').first<{ id: string }>();
    expect(tpl?.id).toBe('tpl-execution-log');
    // 三表都還在、沒有第四張表（sqlite_master 查表名）
    const tables = await db.prepare(
      `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
    ).all<{ name: string }>();
    const names = (tables.results ?? []).map((t) => t.name).sort();
    expect(names).toEqual(['entries', 'entry_values', 'templates']);
  });
});

describe('recordExecutionLog — 少記（成功最少 / 失敗多記）', () => {
  it('成功：只記最少欄位，長訊息被截斷到較短上限，無 target 時該欄省略', async () => {
    const db = makeSqliteD1();
    const env = envWith(db);
    const longMsg = 'x'.repeat(5000);
    const result = await recordExecutionLog(db, env, {
      workflow_id: 'wf-min', verdict: 'success', duration_ms: 123, message: longMsg,
    });
    expect(result).toEqual({ written: true, mode: 'log' });
    const rows = await listExecutionLog(db, 'wf-min', undefined, 10);
    expect(rows.length).toBe(1);
    expect(rows[0].verdict).toBe('success');
    expect(rows[0].duration_ms).toBe(123);
    expect(rows[0].message.length).toBeLessThan(300); // 少記：成功訊息截斷上限遠小於失敗
    expect(rows[0].target).toBeUndefined();
  });

  it('失敗：訊息截斷上限比成功大很多（不對稱：失敗多記一點診斷上下文）', async () => {
    const db = makeSqliteD1();
    const env = envWith(db);
    const longMsg = 'y'.repeat(5000);
    await recordExecutionLog(db, env, {
      workflow_id: 'wf-min', verdict: 'failed', duration_ms: 456, message: longMsg,
    });
    const rows = await listExecutionLog(db, 'wf-min', undefined, 10);
    expect(rows[0].verdict).toBe('failed');
    expect(rows[0].message.length).toBeGreaterThan(1000); // 失敗保留得比成功多（1000+ vs 200 字級）
  });

  it('target：page_name / path 才記，不整包存其餘 input 欄位', async () => {
    const db = makeSqliteD1();
    const env = envWith(db);
    await recordExecutionLog(db, env, {
      workflow_id: 'wf-min', owner_id: 'ak_test', verdict: 'failed', duration_ms: 10,
      message: '處理失敗', target: 'notes/2026-08-07.md',
    });
    const rows = await listExecutionLog(db, 'wf-min', 'ak_test', 10);
    expect(rows[0].target).toBe('notes/2026-08-07.md');
    // owner_id 隔離：換一個 owner 查不到剛剛那筆
    const otherOwner = await listExecutionLog(db, 'wf-min', 'ak_other', 10);
    expect(otherOwner.length).toBe(0);
  });

  it('latestExecutionLog：回最新一筆（降冪排序）', async () => {
    const db = makeSqliteD1();
    const env = envWith(db);
    await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'success', duration_ms: 1, message: 'first' });
    await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'failed', duration_ms: 1, message: 'second' });
    const latest = await latestExecutionLog(db, 'wf-latest', undefined);
    expect(latest?.verdict).toBe('failed');
  });
});

describe('recordExecutionLog / checkUsage — A2 自我降級（用量超過門檻）', () => {
  it('checkUsage：<=80% → log；80%~100% → log_failure_only；>100% → skip', async () => {
    const db = makeSqliteD1();
    const modes: string[] = [];
    for (let i = 0; i < 12; i++) modes.push(await checkUsage(db, 10));
    expect(modes.slice(0, 8)).toEqual(Array(8).fill('log'));       // 1..8 (<=80% of 10)
    expect(modes.slice(8, 10)).toEqual(Array(2).fill('log_failure_only')); // 9,10
    expect(modes.slice(10)).toEqual(Array(2).fill('skip'));        // 11,12
  });

  it('反向驗證：門檻調到極低 → 記錄自動停止（即使是失敗也不記），但 recordExecutionLog 本身不 throw（工作流不受影響）', async () => {
    // limit=2：degrade 門檻＝2*0.8=1.6，skip 門檻＝2。
    //   第 1 次 count=1（1<=1.6）→ log；第 2 次 count=2（1.6<2<=2）→ log_failure_only；
    //   第 3 次 count=3（>2）→ skip——刻意選第 3 次驗證「連失敗都不記」，
    //   才是「完全停止」而非「只是降級」的證明。
    const db = makeSqliteD1();
    const env = envWith(db, '2');
    const r1 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'success', duration_ms: 1, message: 'ok' });
    expect(r1).toEqual({ written: true, mode: 'log' });
    const r2 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '降級區間仍記失敗' });
    expect(r2).toEqual({ written: true, mode: 'log_failure_only' });
    const r3 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '這筆理論上該記的失敗，但已完全停止' });
    await expect(Promise.resolve(r3)).resolves.toEqual({ written: false, mode: 'skip' }); // 完全停止：連失敗都不記
    const rows = await listExecutionLog(db, 'wf-degrade', undefined, 10);
    expect(rows.length).toBe(2); // 只有前兩筆進去，第三筆（skip）沒進資料庫
    expect(rows.map((r) => r.verdict).sort()).toEqual(['failed', 'success']);
  });

  it('降級到「只記失敗」時：成功不寫、失敗照寫', async () => {
    const db = makeSqliteD1();
    const env = envWith(db, '10');
    for (let i = 0; i < 8; i++) await checkUsage(db, 10); // 用掉 1..8（log 區間，只推進計數器）
    const skipped = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'success', duration_ms: 1, message: '應該被跳過' }); // 第 9 次
    const kept = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'failed', duration_ms: 1, message: '應該被記下' }); // 第 10 次
    expect(skipped).toEqual({ written: false, mode: 'log_failure_only' });
    expect(kept).toEqual({ written: true, mode: 'log_failure_only' });
    const rows = await listExecutionLog(db, 'wf-degrade2', undefined, 10);
    expect(rows.length).toBe(1);
    expect(rows[0].verdict).toBe('failed');
  });

  it('D1 整個壞掉（prepare 會 throw）時，checkUsage 失敗仍 fail-open 記錄（計數機制故障不該連紀錄都不寫）', async () => {
    const brokenDb = {
      prepare() { throw new Error('D1 quota exceeded（模擬額度打滿）'); },
    } as unknown as D1Database;
    const env = envWith(brokenDb);
    // recordExecutionLog 內 checkUsage 失敗 → fail-open 'log' → 但實際寫入也會撞同一顆壞 DB，
    // 此時 createEntry 本身會 throw——這正是「呼叫端（cypher route）必須包 try/catch」的理由，
    // 見 kbdb/src/routes/execution-log.ts 與 cypher-executor 端 fire-and-forget 設計。
    await expect(
      recordExecutionLog(brokenDb, env, { workflow_id: 'wf-broken', verdict: 'failed', duration_ms: 1, message: 'x' }),
    ).rejects.toThrow();
  });
});

describe('POST /execution-log/record + GET /execution-log route（Hono app.request）', () => {
  function app(db: D1Database, limit?: string) {
    const a = new Hono<{ Bindings: Bindings }>();
    a.route('/execution-log', executionLogRoutes);
    return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db, limit)) };
  }

  it('POST /record 成功寫入，GET / 讀得回來', async () => {
    const db = makeSqliteD1();
    const a = app(db);
    const res = await a.fetch('/execution-log/record', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ workflow_id: 'wf-route', owner_id: 'ak1', verdict: 'success', duration_ms: 10, message: 'ok' }),
    });
    expect(res.status).toBe(200);
    const body = await res.json() as { success: boolean; written: boolean; mode: string };
    expect(body).toEqual({ success: true, written: true, mode: 'log' });

    const listRes = await a.fetch('/execution-log?workflow_id=wf-route&owner_id=ak1');
    const listBody = await listRes.json() as { success: boolean; executions: unknown[] };
    expect(listBody.success).toBe(true);
    expect(listBody.executions.length).toBe(1);
  });

  it('POST /record 缺 workflow_id 或 verdict 不合法 → 400', async () => {
    const db = makeSqliteD1();
    const a = app(db);
    const res = await a.fetch('/execution-log/record', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ verdict: 'maybe' }),
    });
    expect(res.status).toBe(400);
  });

  it('GET /execution-log 缺 workflow_id → 400；GET /execution-log/latest 同款', async () => {
    const db = makeSqliteD1();
    const a = app(db);
    expect((await a.fetch('/execution-log')).status).toBe(400);
    expect((await a.fetch('/execution-log/latest')).status).toBe(400);
  });
});

// ── P7：保留期可設定（2026-08-09） ──────────────────────────────────────────
// 測試策略：recordExecutionLog 寫入的 created_at 一律是「現在」，測不出「過期」；
// 用 action 層匯出的測試專用函式（insertAgedLog 別名 testInsertAgedExecutionLog）灌一列
// 指定 created_at 的紀錄，模擬「N 天前寫入」，藉此驗證 cleanupExpiredLogs 的 cutoff 判斷
// （原生 SQL 留在 kbdb/src/actions/execution-log.ts 牆內執行，本檔不直接碰 D1）。

describe('保留期設定 getRetentionDays / setRetentionDays', () => {
  it('未設定過的租戶回預設 90 天；無租戶（undefined）也回預設', async () => {
    const db = makeSqliteD1();
    expect(await getRetentionDays(db, 'ak_new')).toBe(DEFAULT_RETENTION_DAYS);
    expect(await getRetentionDays(db, undefined)).toBe(DEFAULT_RETENTION_DAYS);
    expect(await getRetentionDays(db, null)).toBe(DEFAULT_RETENTION_DAYS);
  });

  it('setRetentionDays 設自訂天數後，getRetentionDays 讀得回同一個值（不影響其他租戶）', async () => {
    const db = makeSqliteD1();
    await setRetentionDays(db, 'ak_custom', 30);
    expect(await getRetentionDays(db, 'ak_custom')).toBe(30);
    expect(await getRetentionDays(db, 'ak_other')).toBe(DEFAULT_RETENTION_DAYS); // 隔離：沒設定的租戶不受影響
  });

  it('setRetentionDays(null) ＝「不刪除」（企業稽核），getRetentionDays 回 null 而非預設值', async () => {
    const db = makeSqliteD1();
    await setRetentionDays(db, 'ak_forever', null);
    expect(await getRetentionDays(db, 'ak_forever')).toBeNull();
  });

  it('重複 set 同一租戶＝更新，不是新增第二列（upsert 慣例，同 recipe-stat.ts）', async () => {
    const db = makeSqliteD1();
    await setRetentionDays(db, 'ak_x', 30);
    await setRetentionDays(db, 'ak_x', 60);
    expect(await getRetentionDays(db, 'ak_x')).toBe(60);
    expect(await testCountRetentionConfigRows(db, 'ak_x')).toBe(1);
  });
});

describe('cleanupExpiredLogs — 過期清理（P7）', () => {
  it('預設 90 天：91 天前的紀錄被刪，89 天前的保留（無自訂設定的租戶）', async () => {
    const db = makeSqliteD1();
    await insertAgedLog(db, 'old-1', 'ak_default', 91);
    await insertAgedLog(db, 'new-1', 'ak_default', 89);
    const result = await cleanupExpiredLogs(db);
    expect(result.deleted).toBe(1);
    const remaining = await listExecutionLog(db, 'wf-aged', 'ak_default', 10);
    expect(remaining.length).toBe(1);
    expect(remaining[0].recorded_at).toBeGreaterThan(Math.floor(Date.now() / 1000) - 90 * 86400);
  });

  it('自訂天數的租戶用自己的 cutoff，不受預設 90 天影響', async () => {
    const db = makeSqliteD1();
    await setRetentionDays(db, 'ak_short', 7); // 只留 7 天
    await insertAgedLog(db, 'old-2', 'ak_short', 10); // 10 天前 → 該租戶 cutoff=7 天 → 過期
    await insertAgedLog(db, 'default-owner', null, 10); // 無租戶，10 天 < 預設 90 天 → 保留
    const result = await cleanupExpiredLogs(db);
    expect(result.deleted).toBe(1);
    expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(0);
  });

  it('設「不刪除」的租戶（null）永遠不被清，即使紀錄非常舊', async () => {
    const db = makeSqliteD1();
    await setRetentionDays(db, 'ak_forever', null);
    await insertAgedLog(db, 'ancient-1', 'ak_forever', 3650); // 10 年前
    const result = await cleanupExpiredLogs(db);
    expect(result.deleted).toBe(0);
    expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
  });

  it('壞掉的保留期設定（metadata_json 壞掉）不讓整個清理流程掛掉，該租戶回退到預設 90 天規則', async () => {
    const db = makeSqliteD1();
    await testInsertBrokenRetentionConfig(db, 'ak_broken');
    await insertAgedLog(db, 'old-3', 'ak_broken', 91);
    const result = await cleanupExpiredLogs(db);
    expect(result.deleted).toBe(1); // 壞設定被忽略 → 走預設 90 天路徑照樣清掉
  });

  it('混合情境：不刪除租戶＋自訂天數租戶＋預設租戶同時存在，各自套各自的規則', async () => {
    const db = makeSqliteD1();
    await setRetentionDays(db, 'ak_forever', null);
    await setRetentionDays(db, 'ak_short', 7);
    await insertAgedLog(db, 'a', 'ak_forever', 3650); // 永不刪
    await insertAgedLog(db, 'b', 'ak_short', 10); // 超過 7 天 → 刪
    await insertAgedLog(db, 'c', 'ak_short', 3); // 未滿 7 天 → 留
    await insertAgedLog(db, 'd', 'ak_plain', 91); // 超過預設 90 天 → 刪
    await insertAgedLog(db, 'e', 'ak_plain', 10); // 未滿 90 天 → 留
    const result = await cleanupExpiredLogs(db);
    expect(result.deleted).toBe(2); // b、d
    expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
    expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(1);
    expect((await listExecutionLog(db, 'wf-aged', 'ak_plain', 10)).length).toBe(1);
  });
});

describe('POST /execution-log/cleanup、GET／PUT /execution-log/retention route（Hono app.request）', () => {
  function app(db: D1Database) {
    const a = new Hono<{ Bindings: Bindings }>();
    a.route('/execution-log', executionLogRoutes);
    return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
  }

  it('PUT retention 設值 → GET retention 讀得回同一個值', async () => {
    const db = makeSqliteD1();
    const a = app(db);
    const put = await a.fetch('/execution-log/retention', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ owner_id: 'ak1', retention_days: 45 }),
    });
    expect(put.status).toBe(200);
    const get = await a.fetch('/execution-log/retention?owner_id=ak1');
    const body = (await get.json()) as { retention_days: number };
    expect(body.retention_days).toBe(45);
  });

  it('PUT retention_days: null → 讀回 null（不刪除）', async () => {
    const db = makeSqliteD1();
    const a = app(db);
    await a.fetch('/execution-log/retention', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ owner_id: 'ak2', retention_days: null }),
    });
    const get = await a.fetch('/execution-log/retention?owner_id=ak2');
    const body = (await get.json()) as { retention_days: number | null };
    expect(body.retention_days).toBeNull();
  });

  it('PUT retention 缺 owner_id → 400；retention_days 不合法（0、負數）→ 400', async () => {
    const db = makeSqliteD1();
    const a = app(db);
    expect(
      (await a.fetch('/execution-log/retention', { method: 'PUT', body: JSON.stringify({ retention_days: 30 }) })).status,
    ).toBe(400);
    expect(
      (
        await a.fetch('/execution-log/retention', {
          method: 'PUT',
          body: JSON.stringify({ owner_id: 'ak3', retention_days: 0 }),
        })
      ).status,
    ).toBe(400);
  });

  it('GET retention 缺 owner_id → 400', async () => {
    const db = makeSqliteD1();
    const a = app(db);
    expect((await a.fetch('/execution-log/retention')).status).toBe(400);
  });

  it('POST /cleanup 回刪除筆數，實際刪掉過期紀錄', async () => {
    const db = makeSqliteD1();
    await insertAgedLog(db, 'old-route', 'ak_route', 91);
    const a = app(db);
    const res = await a.fetch('/execution-log/cleanup', { method: 'POST' });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { success: boolean; deleted: number };
    expect(body.success).toBe(true);
    expect(body.deleted).toBe(1);
  });
});
