// Execution log — workflow 執行紀錄（KV 額度事故修復，總管交辦，2026-08-07；
// 保留期可設定＝P7，2026-08-09，leo 08-08 confirm：`system-dev/docs/3-specs/pending-changes.md` P7）
//
// SDD：無專屬 SDD（延續 2026-08-07 的事故修復任務範圍——同一個 execution_log 資料模型，
// 加保留期設定與清理，不是新架構）。root cause 見 kbdb/migrations/0004_execution_log_template.sql
// 開頭註解：cypher-executor 舊版每跑完一次 workflow 就 ANALYTICS_KV.put() 一筆新 key（永不覆蓋）
// ⇒ 封測者 690 個檔案就把 KV 免費層 1,000 write/日打爆（實測 1,070 write）。
//
// KBDB 鐵律（leo 2026-06-14）：三張表打天下，永遠不加新 table；新資料類型一律用 template。
// 本模組 schema 走 template 機制（tpl-execution-log，見上述 migration），但**儲存精神比照既有
// recipe-stat.ts**：template 只負責文件化（GET /templates 可發現欄位定義），實際一筆執行紀錄
// 是 entries 表的**一列**（entry_type='execution_log'，結構化欄位打包進 metadata_json），
// 不走 entry_values 全展開的多列 record——那樣一筆執行要拆 5+ 列，1 次執行變 6+ 次 D1 寫入，
// 直接違反「少記」精神；recipe_stat 早已示範「template 存在＋entries 直接存」這個模式合法。
//
// leo 兩條判準：
//   ① 執行紀錄是稽核資料 → 搬 D1（entries 表，rows written 100,000/日，額度是 KV 的 100 倍）。
//   ② 不是 n8n、不靠 Execution 計費 → 少記：不留每節點輸入輸出，只留時間/workflow/verdict/
//      duration/錯誤訊息/(可得的)目標；成功記最少，失敗多記一點（見 SUCCESS/FAILED_MESSAGE_MAX）。
//
// A2 自我降級：執行紀錄與知識卡（一般 entries）共用同一顆 D1 100,000 rows/日，搬 D1 只是油箱
// 大了 100 倍，不是解掉共用額度本身。本模組自設更低的「軟上限」（DEFAULT_DAILY_LIMIT），
// 用量超過 80% → 降成只記失敗；超過 100% → 完全停止記錄，但呼叫端（cypher-executor）的
// workflow 執行永遠照跑——寫入永不 throw（recordExecutionLog 本身 catch 見呼叫端 route）。
//
// 隔離（不污染知識搜尋）：entry_type='execution_log'/'execution_log_usage'/
// 'execution_log_retention_config' 是內部型別，與既有 'value'/'workflow' 同層級。cypher-executor
// 端（portal-data.ts INTERNAL_ENTRY_TYPES）比照這些一併排除；本模組也從不設
// metadata_json.embed=true，故永不進 Vectorize 語意搜尋索引。
//
// P7 保留期（leo 08-07 兩段發言合起來的最終規格，見 pending-changes.md「提議的規格」段）：
//   儲存 D1、預設保留 90 天（3 個月），過期即清；租戶可自訂天數，也可設「不刪除」（企業稽核）。
//   清理不掛 Cloudflare Cron（wrangler.toml 的 [triggers] 段落是受保護檔案、AI 不可編輯——
//   見 InkStoneCo 頂層 P9 段 L1 權限閘），改「搭便車」：cypher-executor 既有的每分鐘
//   scheduled tick（cron workflow 用，見 cypher-executor/src/scheduled.ts）本來就會醒，
//   在那支既有 handler 裡加一段「一天一次」呼叫本模組的 cleanupExpiredLogs 端點即可，
//   不需要新的排程基礎設施、不違反「禁輪詢」（那條鐵律管的是主動去戳外部系統要狀態，
//   這裡是既有 tick 順手打理自己的表，且頻率仍是「一天一次」而非高頻輪詢）。
import type { Bindings } from '../types';
import { createEntry, listEntries } from './entry-crud';

export interface ExecutionLogInput {
  workflow_id: string;
  owner_id?: string | null;
  verdict: 'success' | 'failed';
  duration_ms: number;
  message?: string;
  target?: string | null;
}

export interface ExecutionLogRow {
  workflow_id: string;
  verdict: string;
  duration_ms: number;
  message: string;
  target?: string;
  recorded_at: number; // unix seconds（entries.created_at 既有慣例，非毫秒）
}

/** 成功訊息截斷長度（少記：夠看一眼結果就好，不留診斷用的長上下文）。 */
const SUCCESS_MESSAGE_MAX = 200;
/** 失敗訊息截斷長度（不對稱：失敗要留夠診斷用的上下文，比成功多 10 倍）。 */
const FAILED_MESSAGE_MAX = 2000;
/** target 欄位截斷長度（page_name / path 通常是檔名或路徑，不會太長；異常長輸入也不整包吞）。 */
const TARGET_MAX = 300;

/**
 * 每日軟上限預設值：D1 免費層 100,000 rows written/日與知識卡（一般 entries）共用，
 * 本模組自設 20%（20,000）——不是 Cloudflare 硬限制，是「執行紀錄不該把知識卡的額度吃光」的
 * 自我節制門檻，可用 env.EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫。
 */
const DEFAULT_DAILY_LIMIT = 20000;
/** 用量超過門檻比例 → 降成只記失敗（寫死比例＋可測試，不靠感覺調參）。 */
const DEGRADE_RATIO = 0.8;

export type UsageMode = 'log' | 'log_failure_only' | 'skip';

function dailyLimit(env: Pick<Bindings, 'EXECUTION_LOG_DAILY_WRITE_LIMIT'>): number {
  const raw = env.EXECUTION_LOG_DAILY_WRITE_LIMIT;
  const n = raw ? parseInt(raw, 10) : NaN;
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
}

function utcDay(): string {
  return new Date().toISOString().slice(0, 10);
}

function truncate(s: string, max: number): string {
  if (s.length <= max) return s;
  return s.slice(0, Math.max(0, max - 1)) + '…';
}

/**
 * A2 用量計數＋降級判斷。單一 entries 列/日（id=`exlog-usage:{day}`，entry_type=
 * 'execution_log_usage'，計數包進 metadata_json）——精神完全比照 recipe-stat.ts 的
 * upsert 慣例（讀現有列 → +1 → UPDATE，不存在則 INSERT）。
 *
 * 刻意計「每次呼叫嘗試次數」而非「實際寫入 execution_log 的列數」——即使已降級到
 * 「只記失敗」或「完全停止」，仍要繼續計數，不然額度耗盡後下一次呼叫又會誤判成
 * 「還沒超過」而重新開始寫爆（等於沒有降級機制）。day 用 UTC 日期字串，換日自然歸零。
 */
export async function checkUsage(db: D1Database, limit: number): Promise<UsageMode> {
  const id = `exlog-usage:${utcDay()}`;
  const existing = await db
    .prepare('SELECT metadata_json FROM entries WHERE id = ?')
    .bind(id)
    .first<{ metadata_json: string | null }>();

  let count: number;
  if (existing) {
    let prevWrites = 0;
    try {
      const prev = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
      prevWrites = Number(prev.writes) || 0;
    } catch {
      prevWrites = 0; // 壞資料誠實視為 0，不讓損毀的計數器卡死降級機制
    }
    count = prevWrites + 1;
    await db
      .prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
      .bind(JSON.stringify({ day: utcDay(), writes: count }), id)
      .run();
  } else {
    count = 1;
    await db
      .prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`)
      .bind(id, JSON.stringify({ day: utcDay(), writes: count }))
      .run();
  }

  if (count > limit) return 'skip';
  if (count > limit * DEGRADE_RATIO) return 'log_failure_only';
  return 'log';
}

/**
 * 寫入一筆執行紀錄（fire-and-forget 語意由呼叫端 route 的 try/catch 保證，本函式本身
 * 不主動吞錯——route 層統一吞，保持單一吞錯點，避免兩層都吞導致除錯時看不到真因）。
 */
export async function recordExecutionLog(
  db: D1Database,
  env: Pick<Bindings, 'EXECUTION_LOG_DAILY_WRITE_LIMIT'>,
  input: ExecutionLogInput,
): Promise<{ written: boolean; mode: UsageMode }> {
  const limit = dailyLimit(env);
  let mode: UsageMode;
  try {
    mode = await checkUsage(db, limit);
  } catch {
    // fail-open：計數機制本身故障（含 D1 額度打滿）不該連執行紀錄都不寫，
    // 寧可暫時失去降級能力也不要靜默漏記——這一步的失敗仍不影響下面的實際寫入。
    mode = 'log';
  }
  if (mode === 'skip') return { written: false, mode };
  if (mode === 'log_failure_only' && input.verdict !== 'failed') return { written: false, mode };

  const maxLen = input.verdict === 'failed' ? FAILED_MESSAGE_MAX : SUCCESS_MESSAGE_MAX;
  const target = input.target ? truncate(String(input.target), TARGET_MAX) : null;

  await createEntry(db, {
    entry_type: 'execution_log',
    owner_id: input.owner_id ?? null,
    page_name: input.workflow_id, // 索引欄位（idx_entries_page）＝查詢鍵，讀取端靠它篩單一 workflow
    content: truncate(input.message ?? '', maxLen),
    metadata_json: JSON.stringify({
      verdict: input.verdict,
      duration_ms: Math.max(0, Math.round(input.duration_ms)),
      target,
    }),
  });
  return { written: true, mode };
}

/** 讀某 workflow 最近 N 次執行紀錄（降冪）。owner_id 給了才過濾（租戶隔離，caller 決定）。 */
export async function listExecutionLog(
  db: D1Database,
  workflowId: string,
  ownerId: string | undefined,
  limit: number,
): Promise<ExecutionLogRow[]> {
  const { entries } = await listEntries(db, {
    entry_type: 'execution_log',
    page_name: workflowId,
    owner_id: ownerId,
    limit,
  });
  return entries.map((e) => {
    let meta: { verdict?: string; duration_ms?: number; target?: string | null } = {};
    try {
      meta = e.metadata_json ? (JSON.parse(e.metadata_json) as typeof meta) : {};
    } catch {
      /* 壞資料誠實留空，不整筆丟掉（still 回傳 verdict='unknown' 好過整筆消失） */
    }
    return {
      workflow_id: workflowId,
      verdict: meta.verdict ?? 'unknown',
      duration_ms: meta.duration_ms ?? 0,
      message: e.content ?? '',
      ...(meta.target ? { target: meta.target } : {}),
      recorded_at: e.created_at,
    };
  });
}

/** 讀某 workflow 最新一次執行紀錄（portal-data.ts last_execution 用）。 */
export async function latestExecutionLog(
  db: D1Database,
  workflowId: string,
  ownerId: string | undefined,
): Promise<ExecutionLogRow | null> {
  const rows = await listExecutionLog(db, workflowId, ownerId, 1);
  return rows[0] ?? null;
}

// ── P7：保留期可設定（2026-08-09） ──────────────────────────────────────────
//
// leo 08-07 原話合起來的規格：「預設可以永久保存，但我設定每 3 個月把超過的刪掉……
// 我願意花很多錢保存，不要刪除」——翻成可執行規則＝**預設保留 90 天、租戶可自訂天數、
// 也可設「不刪除」**（企業稽核用，這是付費理由不是成本負擔，schema 不擋未來計費）。
//
// 儲存：沿用 execution_log_usage 的 upsert 慣例——單一 entries 列/租戶
// （id=`exlog-retention:{owner_id}`，entry_type='execution_log_retention_config'）。
// 無租戶（owner_id 缺，例如舊版 /execute 路徑）套用預設天數，不可個別設定
// （沒有租戶就沒有「誰的設定」這個概念，硬要存會變成一筆沒有主人的孤兒設定）。

/** 預設保留天數：3 個月（leo 08-07：「我設定每 3 個月把超過的刪掉」）。 */
export const DEFAULT_RETENTION_DAYS = 90;

/** 單次清理呼叫最多刪幾列——避免單次 D1 查詢過重；呼叫端（cypher 每日一次 tick）多次呼叫可逐步清完累積量。 */
const CLEANUP_BATCH_LIMIT = 500;

function retentionConfigId(ownerId: string): string {
  return `exlog-retention:${ownerId}`;
}

/** 讀某租戶的保留天數；null＝該租戶已設「不刪除」；未設定過＝回預設值（不是 null）。 */
export async function getRetentionDays(
  db: D1Database,
  ownerId: string | null | undefined,
): Promise<number | null> {
  if (!ownerId) return DEFAULT_RETENTION_DAYS; // 無租戶＝套預設，不可個別設定（見上方註解）
  const row = await db
    .prepare(`SELECT metadata_json FROM entries WHERE id = ?`)
    .bind(retentionConfigId(ownerId))
    .first<{ metadata_json: string | null }>();
  if (!row) return DEFAULT_RETENTION_DAYS;
  try {
    const parsed = row.metadata_json
      ? (JSON.parse(row.metadata_json) as { retention_days?: number | null })
      : {};
    if (parsed.retention_days === null) return null; // 「不刪除」
    const n = Number(parsed.retention_days);
    return Number.isFinite(n) && n > 0 ? n : DEFAULT_RETENTION_DAYS; // 壞資料誠實退回預設，不讓損毀設定卡死清理
  } catch {
    return DEFAULT_RETENTION_DAYS;
  }
}

/** 設定某租戶的保留天數。days=null＝「不刪除」（企業稽核選項）；days=正整數＝自訂天數。 */
export async function setRetentionDays(
  db: D1Database,
  ownerId: string,
  days: number | null,
): Promise<void> {
  const id = retentionConfigId(ownerId);
  const metadata = JSON.stringify({ retention_days: days, updated_at: Math.floor(Date.now() / 1000) });
  const existing = await db.prepare(`SELECT id FROM entries WHERE id = ?`).bind(id).first();
  if (existing) {
    await db
      .prepare(`UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?`)
      .bind(metadata, id)
      .run();
  } else {
    await db
      .prepare(
        `INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`,
      )
      .bind(id, ownerId, metadata)
      .run();
  }
}

export interface CleanupResult {
  deleted: number;
  checked_overrides: number;
}

/**
 * 清掉過期的執行紀錄（entry_type='execution_log' 且早於各自租戶的保留期限）。
 * 分兩段跑：
 *   ① 有自訂天數的租戶：各自用自己的 cutoff 刪。
 *   ② 其餘（含無租戶／未設定過的租戶）：套預設 90 天，但排除「已設不刪除」與
 *      「剛才①處理過」的租戶，避免同一輪重複掃描。
 * 每段各受 CLEANUP_BATCH_LIMIT 界限——呼叫端（cypher 每日一次 tick）長期呼叫可逐步清完累積量，
 * 不追求一次清光（那樣單次 D1 查詢會過重，且清理本身不是使用者等待中的路徑，慢慢清沒有壞處）。
 */
export async function cleanupExpiredLogs(db: D1Database): Promise<CleanupResult> {
  const nowSec = Math.floor(Date.now() / 1000);

  const overridesRes = await db
    .prepare(`SELECT owner_id, metadata_json FROM entries WHERE entry_type = 'execution_log_retention_config'`)
    .all<{ owner_id: string | null; metadata_json: string | null }>();
  const overrides = overridesRes.results ?? [];

  const neverDeleteOwners: string[] = [];
  const customOwners: Array<{ owner_id: string; days: number }> = [];
  for (const row of overrides) {
    if (!row.owner_id) continue;
    let parsed: { retention_days?: number | null } = {};
    try {
      parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as typeof parsed) : {};
    } catch {
      continue; // 壞資料：不當成任何一種 override，讓該租戶回退到①之外的預設路徑
    }
    if (parsed.retention_days === null) {
      neverDeleteOwners.push(row.owner_id);
    } else {
      const n = Number(parsed.retention_days);
      if (Number.isFinite(n) && n > 0) customOwners.push({ owner_id: row.owner_id, days: n });
    }
  }

  let deleted = 0;

  // ① 自訂天數的租戶，各自 cutoff
  for (const { owner_id, days } of customOwners) {
    const cutoff = nowSec - days * 86400;
    const res = await db
      .prepare(
        `DELETE FROM entries WHERE id IN (
           SELECT id FROM entries WHERE entry_type = 'execution_log' AND owner_id = ? AND created_at < ?
           LIMIT ?
         )`,
      )
      .bind(owner_id, cutoff, CLEANUP_BATCH_LIMIT)
      .run();
    deleted += (res.meta?.changes as number | undefined) ?? 0;
  }

  // ② 其餘：預設 90 天，排除「不刪除」與①已處理的租戶
  const defaultCutoff = nowSec - DEFAULT_RETENTION_DAYS * 86400;
  const excluded = [...neverDeleteOwners, ...customOwners.map((o) => o.owner_id)];
  const sql =
    excluded.length > 0
      ? `DELETE FROM entries WHERE id IN (
           SELECT id FROM entries WHERE entry_type = 'execution_log'
             AND created_at < ?
             AND (owner_id IS NULL OR owner_id NOT IN (${excluded.map(() => '?').join(',')}))
           LIMIT ?
         )`
      : `DELETE FROM entries WHERE id IN (
           SELECT id FROM entries WHERE entry_type = 'execution_log' AND created_at < ? LIMIT ?
         )`;
  const binds = excluded.length > 0 ? [defaultCutoff, ...excluded, CLEANUP_BATCH_LIMIT] : [defaultCutoff, CLEANUP_BATCH_LIMIT];
  const res2 = await db.prepare(sql).bind(...binds).run();
  deleted += (res2.meta?.changes as number | undefined) ?? 0;

  return { deleted, checked_overrides: overrides.length };
}

// ── 測試專用 helpers（P7，2026-08-09） ──────────────────────────────────────
// 這支檔在 kbdb/src/actions/ 下（資料層 worker 自己＝API-as-Wall 的牆本身，D38 允許在
// 這裡直接碰 D1）。單元測試（kbdb/tests/execution-log.test.ts）不該自己在測試檔裡寫原生
// SQL——那個檔在「牆外」，即使是測試治具也不該養成在那裡打 SQL 的習慣。所以把「插入一列
// 指定 created_at 的過期紀錄」「數某類設定列有幾筆」這兩個測試才需要的原語做成正式匯出的
// 函式，放在牆內、由牆內的程式碼實際執行 SQL，測試檔只呼叫函式——與正式的 recordExecutionLog
// 刻意不開放指定過去時間形成對照（那是正式寫入路徑的正確限制，這裡是測試的例外通道）。

/** 測試專用：直接寫一列指定 created_at 的 execution_log（模擬「N 天前寫入的紀錄」）。 */
export async function testInsertAgedExecutionLog(
  db: D1Database,
  id: string,
  ownerId: string | null,
  daysAgo: number,
): Promise<void> {
  const createdAt = Math.floor(Date.now() / 1000) - daysAgo * 86400;
  await db
    .prepare(
      `INSERT INTO entries (id, entry_type, owner_id, page_name, content, metadata_json, created_at)
       VALUES (?, 'execution_log', ?, 'wf-aged', 'old', '{"verdict":"success","duration_ms":1}', ?)`,
    )
    .bind(id, ownerId, createdAt)
    .run();
}

/** 測試專用：寫一列**損毀** metadata_json 的保留期設定（驗證 cleanupExpiredLogs 對壞資料的容錯）。 */
export async function testInsertBrokenRetentionConfig(db: D1Database, ownerId: string): Promise<void> {
  await db
    .prepare(
      `INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`,
    )
    .bind(retentionConfigId(ownerId), ownerId, '{not valid json')
    .run();
}

/** 測試專用：數某租戶目前有幾列保留期設定（驗證 setRetentionDays 是 upsert，不是每次都新增一列）。 */
export async function testCountRetentionConfigRows(db: D1Database, ownerId: string): Promise<number> {
  const row = await db
    .prepare(`SELECT COUNT(*) as n FROM entries WHERE entry_type = 'execution_log_retention_config' AND owner_id = ?`)
    .bind(ownerId)
    .first<{ n: number }>();
  return row?.n ?? 0;
}
