// engine/logs-store.js — SQL-backed implementation of engine/log.json.
//
// Logs differ from work_items / pull_requests / dispatches: they are
// pure append-only with a tail-cap, so the store exposes a different
// shape:
//
//   appendLogsToDbFile(dbFilePath, entries)
//     → bulk INSERT into the `logs` table at the given state.db path,
//       then trim the head if the table exceeded the cap. Routes per
//       dbFilePath so test isolation (each MINIONS_TEST_DIR has its own
//       state.db) keeps working — the legacy `_logPath` capture-at-write
//       pattern was the only correct way to keep test logs out of
//       production storage, and we preserve the same idea here.
//
//   readRecentLogs(n, { level? })
//     → SELECT ... ORDER BY id DESC LIMIT n against the singleton DB.
//       Honors MINIONS_TEST_DIR / MINIONS_HOME automatically through
//       engine/db.getDb().
//
// All writes happen through the per-path appender; the singleton is only
// used for reads. This way a deferred buffer flush after a test ends
// can't accidentally drop entries into the production state.db.

const fs = require('fs');
const path = require('path');

// Per-dbPath connection cache. Avoids close+reopen thrash on the
// singleton when buffered entries are routed to multiple test state.db
// files in quick succession. Connections are closed via
// closeAllLogWriterConnections() at process shutdown.
const _connCache = new Map();

function _ensureDir(filePath) {
  try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } catch { /* exists */ }
}

function _openWriter(dbFilePath) {
  let conn = _connCache.get(dbFilePath);
  if (conn) return conn;
  _ensureDir(dbFilePath);
  // Use the same migration runner / pragma setup as the singleton, but
  // against an explicit path. Re-running runMigrations against an
  // already-migrated DB is a no-op (the version row gates it), so this
  // is safe regardless of whether the singleton has touched the file.
  const { DatabaseSync } = require('node:sqlite');
  const db = new DatabaseSync(dbFilePath);
  db.exec('PRAGMA journal_mode = WAL');
  db.exec('PRAGMA foreign_keys = ON');
  db.exec('PRAGMA synchronous = NORMAL');
  db.exec('PRAGMA busy_timeout = 5000');
  const { runMigrations } = require('./db/migrate');
  runMigrations(db);
  conn = {
    db,
    insertStmt: db.prepare('INSERT INTO logs (ts_ms, level, message, meta) VALUES (?, ?, ?, ?)'),
    countStmt: db.prepare('SELECT COUNT(*) AS n FROM logs'),
  };
  _connCache.set(dbFilePath, conn);
  return conn;
}

function _toMs(v) {
  if (v == null) return Date.now();
  if (typeof v === 'number') return Number.isFinite(v) ? v : Date.now();
  const parsed = Date.parse(v);
  return Number.isFinite(parsed) ? parsed : Date.now();
}

// Strip the routing-only fields (timestamp, level, message, _logPath,
// _dbPath) so `meta` only contains caller-provided extras.
const _BUILTIN_KEYS = new Set(['timestamp', 'level', 'message', '_logPath', '_dbPath']);
function _extractMeta(entry) {
  const meta = {};
  for (const k of Object.keys(entry)) {
    if (_BUILTIN_KEYS.has(k)) continue;
    meta[k] = entry[k];
  }
  return Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
}

// Append a batch of log entries to a specific state.db file.
//
// Bulk-wrapped in a transaction so a partial failure rolls back cleanly
// and disk fsync only fires once per batch (matters at the 50-entry
// buffer threshold under high load).
//
// After insert, if the table exceeds the ring-buffer cap, trim the
// oldest entries down to logCapTrimTo. Trim is bounded — only runs when
// we've actually overflowed, not on every batch.
function appendLogsToDbFile(dbFilePath, entries) {
  if (!entries || entries.length === 0) return 0;
  let conn;
  try { conn = _openWriter(dbFilePath); }
  catch (e) {
    // Logging must never crash the caller — swallow and bail.
    return 0;
  }

  const shared = require('./shared');
  const cap = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.logCapEntries) || 2500;
  const trimTo = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.logCapTrimTo) || 2000;

  let inserted = 0;
  try {
    conn.db.exec('BEGIN IMMEDIATE');
    for (const entry of entries) {
      const tsMs = _toMs(entry.timestamp);
      const level = String(entry.level || 'info');
      const message = String(entry.message != null ? entry.message : '');
      const meta = _extractMeta(entry);
      conn.insertStmt.run(tsMs, level, message, meta);
      inserted += 1;
    }
    const total = conn.countStmt.get().n;
    if (total > cap) {
      // Trim the oldest rows. `id` is monotonic via AUTOINCREMENT, so
      // ORDER BY id ASC + LIMIT (total - trimTo) deletes exactly the
      // overflow. Single DELETE; no rowid scan required.
      const toRemove = total - trimTo;
      conn.db.prepare(`
        DELETE FROM logs
        WHERE id IN (SELECT id FROM logs ORDER BY id ASC LIMIT ?)
      `).run(toRemove);
    }
    conn.db.exec('COMMIT');
  } catch {
    try { conn.db.exec('ROLLBACK'); } catch { /* best-effort */ }
    return 0;
  }

  // Emit a single events row so dashboards / cache invalidators wake up
  // once per batch instead of per entry. Routed against the singleton
  // (not per-dbPath) because events are a cross-cutting concern keyed
  // off the engine's active state.db.
  try { require('./db-events').emitStateEvent('logs', { batch: inserted }); } catch { /* optional */ }
  return inserted;
}

// Read the most recent N entries from the singleton DB (test-aware
// because getDb() re-resolves MINIONS_TEST_DIR on every call).
//
// Returns entries in NEW-EST-FIRST order — matches what the dashboard
// expects when slicing the tail of the array. Optional `level` filter
// uses the level index.
function readRecentLogs(n, opts) {
  const limit = Math.max(1, Math.min(10000, Number(n) || 50));
  const level = opts && opts.level ? String(opts.level) : null;
  const { getDb } = require('./db');
  let db;
  try { db = getDb(); }
  catch { return []; }
  const rows = level
    ? db.prepare('SELECT ts_ms, level, message, meta FROM logs WHERE level = ? ORDER BY id DESC LIMIT ?').all(level, limit)
    : db.prepare('SELECT ts_ms, level, message, meta FROM logs ORDER BY id DESC LIMIT ?').all(limit);

  const out = [];
  for (const row of rows) {
    const entry = {
      timestamp: new Date(row.ts_ms).toISOString(),
      level: row.level,
      message: row.message,
    };
    if (row.meta) {
      try { Object.assign(entry, JSON.parse(row.meta)); } catch { /* skip malformed meta */ }
    }
    out.push(entry);
  }
  return out;
}

// Convenience: same shape as the legacy `JSON.parse(log.json).slice(-N)`
// — chronological order (oldest first). Inverts the readRecentLogs DESC
// output so existing callers can drop in without re-sorting.
function readRecentLogsChronological(n, opts) {
  return readRecentLogs(n, opts).reverse();
}

function closeAllLogWriterConnections() {
  for (const [, conn] of _connCache) {
    try { conn.db.close(); } catch { /* already closed */ }
  }
  _connCache.clear();
}

module.exports = {
  appendLogsToDbFile,
  readRecentLogs,
  readRecentLogsChronological,
  closeAllLogWriterConnections,
};
