// engine/db/index.js — Local SQLite state store, zero deps.
//
// Backed by Node's built-in `node:sqlite` module (stable in Node 22.5+,
// matures further in 24.x). The whole project's "zero deps beyond Node
// built-ins" contract stays intact.
//
// This file is intentionally tiny: open the DB, set sane pragmas, run
// migrations, hand back a singleton. Everything else lives in callers.
// Singleton (not per-require) so the WAL writer + dashboard reader share
// one connection per process — better-sqlite3-style.
//
// Failure mode: if SQLite is unavailable (e.g. user on Node < 22.5 despite
// our engines field bumping the minimum), `getDb()` throws with a clear
// install-version error. Callers that want best-effort behaviour
// (emitStateEvent, the dashboard's cache-version bump) should try/catch.

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

let _db = null;
let _dbPath = null;
let _dbInitError = null;

function _resolveDbPath() {
  // Lazy-require shared/queries so this module can be safely required
  // before MINIONS_DIR is computed (e.g. in tests). Falls back to
  // process.env.MINIONS_HOME when available.
  const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
  let minionsDir = envHome;
  if (!minionsDir) {
    try { minionsDir = require('../shared').MINIONS_DIR; } catch { /* shared not loaded */ }
  }
  if (!minionsDir) throw new Error('engine/db: MINIONS_DIR not resolvable');
  const engineDir = path.join(minionsDir, 'engine');
  try { fs.mkdirSync(engineDir, { recursive: true }); } catch { /* exists */ }
  return path.join(engineDir, 'state.db');
}

// Suppress Node's ExperimentalWarning for `node:sqlite`. It fires once per
// process the first time the module is required; without filtering it
// leaks into every engine/dashboard log and CI run. Re-fires only for
// non-sqlite experimental warnings.
let _warningFilterInstalled = false;
function _installExperimentalWarningFilter() {
  if (_warningFilterInstalled) return;
  _warningFilterInstalled = true;
  const origEmit = process.emit;
  process.emit = function (name, data, ...rest) {
    if (name === 'warning' && data && data.name === 'ExperimentalWarning' && /SQLite/.test(String(data.message))) {
      return false;
    }
    return origEmit.call(process, name, data, ...rest);
  };
}

function getDb() {
  // Hard opt-out (issue #3035, option #3): MINIONS_FORCE_JSON=1 makes
  // every getDb() call throw, forcing the entire stack onto the JSON
  // fallback path. Useful for users who can't enable
  // --experimental-sqlite on Node 22.x and for regression tests that
  // need to exercise the JSON-fallback branches deterministically.
  if (process.env.MINIONS_FORCE_JSON === '1') {
    throw new Error('engine/db: SQL disabled via MINIONS_FORCE_JSON=1');
  }
  // Re-resolve the DB path on every call so tests that swap MINIONS_TEST_DIR
  // (or production-side configuration that changes MINIONS_HOME between
  // operations) get a fresh connection to the new location instead of
  // a stale handle pointing at a now-deleted tmpdir. The resolve itself
  // is just env-var reads + path.join — sub-microsecond.
  let dbPath;
  try { dbPath = _resolveDbPath(); }
  catch (e) {
    _dbInitError = new Error(`engine/db: cannot resolve DB path — ${e.message}`);
    throw _dbInitError;
  }

  if (_db) {
    if (_dbPath === dbPath) return _db;
    // Path changed (test isolation swap, or operator reconfigured
    // MINIONS_HOME). Close the old handle and reopen against the new path.
    try { _db.close(); } catch { /* already closed */ }
    _db = null;
    _dbPath = null;
    _dbInitError = null;
  }

  if (_dbInitError) throw _dbInitError;
  try {
    _installExperimentalWarningFilter();
    const { DatabaseSync } = require('node:sqlite');
    // Concurrent cold-openers (parallel child processes in tests; engine
    // + dashboard cold-starting together) can race on the exclusive lock
    // that SQLite needs to either create the file or upgrade
    // journal_mode to WAL on the first connection. `busy_timeout` only
    // applies to PRAGMAs and queries AFTER the connection is open, so
    // `new DatabaseSync(dbPath)` itself and the initial WAL PRAGMA can
    // still return SQLITE_BUSY. Retry the whole open+PRAGMA sequence
    // with backoff so a brief lock contention isn't a hard failure.
    // Phase 9.4 removed the JSON fallback that previously hid this
    // race — without retry, parallel test runs fail intermittently with
    // "small-state-store: SQLite unavailable (... database is locked)".
    const maxAttempts = 20;
    let lastErr = null;
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      try {
        _db = new DatabaseSync(dbPath);
        // busy_timeout MUST be set before any other PRAGMA so subsequent
        // statements wait instead of failing immediately on contention.
        _db.exec('PRAGMA busy_timeout = 5000');
        _db.exec('PRAGMA journal_mode = WAL');
        _db.exec('PRAGMA foreign_keys = ON');
        _db.exec('PRAGMA synchronous = NORMAL');
        _dbPath = dbPath;
        lastErr = null;
        break;
      } catch (e) {
        lastErr = e;
        const msg = String(e && e.message || '');
        const isBusy = /database is locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(msg);
        if (!isBusy || attempt === maxAttempts - 1) throw e;
        if (_db) { try { _db.close(); } catch { /* best-effort */ } _db = null; }
        // Synchronous backoff: 10ms, 20ms, 40ms... capped at 250ms.
        // Total worst-case wait ~3s, still under the 5s busy_timeout.
        const delayMs = Math.min(250, 10 * (1 << Math.min(attempt, 5)));
        const end = Date.now() + delayMs;
        while (Date.now() < end) { /* busy-wait — sub-300ms total */ }
      }
    }
    if (lastErr) throw lastErr;
    const { runMigrations } = require('./migrate');
    runMigrations(_db);
    return _db;
  } catch (e) {
    const nodeMajor = parseInt(String(process.versions.node).split('.')[0], 10);
    const flagHint = nodeMajor === 22
      ? ` On Node 22.x, node:sqlite is gated behind --experimental-sqlite. Restart with NODE_OPTIONS=--experimental-sqlite, upgrade to Node 24+, or set MINIONS_FORCE_JSON=1 to bypass SQL.`
      : ` Node 22.5+ (with --experimental-sqlite on 22.x) or 24+ required for built-in 'node:sqlite' support.`;
    _dbInitError = new Error(`engine/db: failed to open SQLite — ${e.message}.${flagHint}`);
    throw _dbInitError;
  }
}

// Force-close — used by tests + graceful shutdown. Safe to call when not open.
function closeDb() {
  if (_db) { try { _db.close(); } catch { /* already closed */ } _db = null; }
  _dbPath = null;
  _dbInitError = null;
}

// Run `fn(db)` inside a transaction. Nestable: only the OUTERMOST call
// issues BEGIN/COMMIT; inner calls run inside the enclosing tx so multiple
// stores (dispatches + work_items + ...) can compose into one atomic
// mutation. Inner rollback propagates outward, which is the correct
// semantics for cross-store invariants — if a nested mutation fails, the
// enclosing one must also fail.
//
// We track depth on the db instance itself rather than module-level so
// reopening the singleton (test isolation, path changes) doesn't leak
// stale depth into a fresh connection.
function withTransaction(db, fn) {
  if (!db) throw new Error('engine/db.withTransaction: db is required');
  const isOuter = !db._minionsTxDepth;
  db._minionsTxDepth = (db._minionsTxDepth || 0) + 1;
  if (isOuter) db.exec('BEGIN IMMEDIATE');
  try {
    const result = fn(db);
    db._minionsTxDepth -= 1;
    if (db._minionsTxDepth === 0) db.exec('COMMIT');
    return result;
  } catch (e) {
    db._minionsTxDepth -= 1;
    if (db._minionsTxDepth === 0) {
      try { db.exec('ROLLBACK'); } catch { /* best-effort */ }
    }
    throw e;
  }
}

module.exports = { getDb, closeDb, withTransaction };
