'use strict';

// Main-thread DB census + budget guard.
//
// THE PROBLEM IT SOLVES: CTM's freeze is never really "addApprovalObservation" or
// "backfillRewrite" — those are just WHICH of the dozens of synchronous main-thread DB
// callers happened to hit cold Dropbox pages first this boot. The existing
// [perf-lag]/[freeze-probe] diagnostics surface the single worst block, so we fix one
// driver, the next surfaces, forever (whack-a-mole). This module turns the hunt into a
// CENSUS: instrument the main-thread DB handle so a single cold boot yields the COMPLETE
// ranked list of every main-thread DB op (by cumulative + max cost, with a stack for the
// slow ones) — and a definition of done: zero UNBOUNDED main-thread ops on the cold path.
//
// THE INVARIANT IT ENFORCES: "no unbounded synchronous DB op on the main thread." A
// single-row indexed .get() is fine even cold (one page); a full scan, a SELECT * on a
// blob column, or a multi-row write freezes the loop. When an op exceeds the budget we
// log `[db-budget]` with the SQL + stack so a NEW violation shows up the moment it's
// written, not three restarts later.
//
// SAFETY: this is debug instrumentation, OFF by default. When disabled, maybeInstrument()
// returns the raw better-sqlite3 handle untouched — zero overhead, zero behavior change.
// HARD RULE: it adds per-DB-call overhead, so it must NEVER run on the primary (doing so
// caused a real production slowdown). It only arms on a dev/staging instance (which sets
// DEV_CTM_PORT) with CTM_DB_CENSUS=1. There is no primary-arming path.


// Capture a stack for an op only when it is at least this slow (ms) — keeps per-call
// overhead negligible for the thousands of fast ops while still attributing every freeze.
const STACK_MS = clampInt(process.env.CTM_DB_CENSUS_STACK_MS, 25, 1, 60000);
// Emit a `[db-budget]` warning when a single main-thread op exceeds this (ms). This is the
// invariant tripwire — an unbounded op (scan / blob / big write) on the loop.
const BUDGET_MS = clampInt(process.env.CTM_DB_CENSUS_BUDGET_MS, 100, 1, 600000);
// Cap distinct SQL keys retained, so a pathological dynamic-SQL generator can't grow the
// census map without bound. The hottest keys (by sumMs) are always kept.
const MAX_KEYS = clampInt(process.env.CTM_DB_CENSUS_MAX_KEYS, 2000, 16, 100000);

function clampInt(raw, dflt, lo, hi) {
  const n = Number(raw);
  if (!Number.isFinite(n)) return dflt;
  return Math.max(lo, Math.min(hi, Math.trunc(n)));
}

let _enabled = false;
let _armedReason = '';
const _census = new Map(); // sqlKey → stats
let _startedAt = 0;
let _budgetWarnings = 0;

// Decide whether to arm. HARD RULE: this census wraps the live DB handle and adds per-call
// overhead — it must NEVER run on the primary (it caused a real production slowdown there).
// It only arms on a dev/staging instance, which sets DEV_CTM_PORT. There is deliberately no
// primary-arming path (no sentinel) — staging only.
function _shouldArm() {
  if (!process.env.DEV_CTM_PORT) return ''; // primary: never arm, full stop
  if (process.env.CTM_DB_CENSUS === '1') return 'env';
  return '';
}

function isEnabled() { return _enabled; }
function armedReason() { return _armedReason; }

function _shortSql(sql) {
  const s = String(sql || '').replace(/\s+/g, ' ').trim();
  return s.length > 200 ? s.slice(0, 200) + '…' : s;
}

// A trimmed stack: drop this module's frames + the node internals, keep the CTM call site.
function _captureStack() {
  const raw = new Error().stack || '';
  return raw
    .split('\n')
    .slice(1)
    .filter((l) => !l.includes('main-db-census.js') && !l.includes('node:internal'))
    .slice(0, 6)
    .map((l) => l.trim())
    .join(' <- ');
}

function _record(sqlKey, op, ms, rows) {
  let st = _census.get(sqlKey);
  if (!st) {
    if (_census.size >= MAX_KEYS) return; // bounded; hottest keys already retained
    st = { sql: _shortSql(sqlKey), op, count: 0, sumMs: 0, maxMs: 0, maxRows: 0, slowStack: '', slowMs: 0 };
    _census.set(sqlKey, st);
  }
  st.count += 1;
  st.sumMs += ms;
  if (ms > st.maxMs) st.maxMs = ms;
  if (rows > st.maxRows) st.maxRows = rows;
  if (ms >= STACK_MS && ms > st.slowMs) {
    st.slowMs = ms;
    st.slowStack = _captureStack();
  }
  if (ms >= BUDGET_MS) {
    _budgetWarnings += 1;
    // The invariant tripwire. Logged (not thrown) in prod so it can never break a request;
    // a dev harness can choose to throw on these.
    console.warn(`[db-budget] main-thread ${op} took ${Math.round(ms)}ms rows=${rows} sql="${st.sql}"`
      + (st.slowStack ? `\n           ${st.slowStack}` : ''));
  }
}

// Wrap a better-sqlite3 Statement with timed get/all/run/iterate. Toggle/chaining methods
// (pluck/expand/raw/bind/safeIntegers) return `this` in better-sqlite3, so our versions
// return the WRAPPER to keep the proxy across chains (e.g. `.pluck().get()` stays timed).
function _wrapStatement(stmt) {
  const sqlKey = stmt.source || '(unknown sql)';
  const timed = (op) => function (...args) {
    const t0 = process.hrtime.bigint();
    const out = stmt[op](...args);
    const ms = Number(process.hrtime.bigint() - t0) / 1e6;
    let rows = 0;
    if (op === 'all' && Array.isArray(out)) rows = out.length;
    else if (op === 'get') rows = out ? 1 : 0;
    else if (op === 'run' && out && typeof out.changes === 'number') rows = out.changes;
    _record(sqlKey, op, ms, rows);
    return out;
  };
  const wrapper = {
    get: timed('get'),
    all: timed('all'),
    run: timed('run'),
    iterate: timed('iterate'),
  };
  // Chaining toggles → delegate, but return the wrapper so timing survives the chain.
  for (const m of ['pluck', 'expand', 'raw', 'bind', 'safeIntegers']) {
    if (typeof stmt[m] === 'function') {
      wrapper[m] = function (...args) { stmt[m](...args); return wrapper; };
    }
  }
  // Pass-through for metadata methods/props that callers read.
  wrapper.columns = function (...args) { return stmt.columns(...args); };
  Object.defineProperties(wrapper, {
    source: { get: () => stmt.source },
    reader: { get: () => stmt.reader },
    readonly: { get: () => stmt.readonly },
    busy: { get: () => stmt.busy },
    database: { get: () => stmt.database },
    _rawStatement: { value: stmt },
  });
  return wrapper;
}

// Wrap the connection so .prepare() returns wrapped statements and .exec/.pragma are timed.
// Everything else delegates to the raw handle unchanged (transaction, backup, pragma config,
// close, etc.), so behavior is identical — only the timed paths are observed.
function _wrapConnection(rawDb) {
  return new Proxy(rawDb, {
    get(target, prop, receiver) {
      if (prop === 'prepare') {
        return function (sql) {
          const stmt = target.prepare(sql);
          try { return _wrapStatement(stmt); } catch { return stmt; }
        };
      }
      if (prop === 'exec') {
        return function (sql) {
          const t0 = process.hrtime.bigint();
          const out = target.exec(sql);
          const ms = Number(process.hrtime.bigint() - t0) / 1e6;
          _record(`exec: ${_shortSql(sql)}`, 'exec', ms, 0);
          return out;
        };
      }
      if (prop === 'pragma') {
        return function (source, options) {
          const t0 = process.hrtime.bigint();
          const out = target.pragma(source, options);
          const ms = Number(process.hrtime.bigint() - t0) / 1e6;
          _record(`pragma: ${_shortSql(source)}`, 'pragma', ms, 0);
          return out;
        };
      }
      const val = Reflect.get(target, prop, receiver);
      return typeof val === 'function' ? val.bind(target) : val;
    },
  });
}

// Wrap the handle IF armed; otherwise return it untouched. Called once per initDb/connectDb
// right after the connection is opened, BEFORE the restore storm runs.
function maybeInstrument(rawDb) {
  if (!rawDb) return rawDb;
  if (!_enabled) {
    const reason = _shouldArm();
    if (!reason) return rawDb; // default path: raw handle, zero overhead
    _enabled = true;
    _armedReason = reason;
    _startedAt = Date.now();
    console.warn(`[db-census] ARMED (${reason}) — instrumenting main-thread DB handle; `
      + `budget=${BUDGET_MS}ms stackThreshold=${STACK_MS}ms. GET /api/diagnostics/db-census to read.`);
  }
  try { return _wrapConnection(rawDb); } catch { return rawDb; }
}

// Ranked snapshot (by cumulative main-thread ms — the cold-boot cost of each query).
function getCensus({ limit = 100 } = {}) {
  const rows = [..._census.values()]
    .map((s) => ({ ...s, avgMs: s.count ? +(s.sumMs / s.count).toFixed(2) : 0,
      sumMs: +s.sumMs.toFixed(1), maxMs: +s.maxMs.toFixed(1) }))
    .sort((a, b) => b.sumMs - a.sumMs);
  return {
    enabled: _enabled,
    armedReason: _armedReason,
    sinceMs: _startedAt ? Date.now() - _startedAt : 0,
    budgetMs: BUDGET_MS,
    stackThresholdMs: STACK_MS,
    budgetWarnings: _budgetWarnings,
    distinctSql: _census.size,
    top: rows.slice(0, Math.max(1, limit)),
  };
}

function reset() { _census.clear(); _budgetWarnings = 0; _startedAt = Date.now(); }

module.exports = { maybeInstrument, getCensus, reset, isEnabled, armedReason };
