'use strict';

// Tiny shared module for tagging the current synchronous operation. The
// always-on event-loop lag detector reads `getOp()` to identify what was
// running when a block fired. Without a tag, every [perf-lag] entry says
// `active op: (unknown)` and we can't tell which periodic task caused it.
//
// Why a module: the scheduler (lib/scheduler.js) and the main HTTP server
// both want to tag operations, but they run from different require trees.
// A single module-scoped `current` ensures they share the same tag.
//
// Cost: two property writes per wrapped call. Negligible.

let _current = null;
let _lastCompleted = null;
const _debug = process.env.CTM_PERF_DEBUG === '1';

// Perf diagnostic console output ([perf-lag] / [freeze-probe]) is OFF by default — once the hot
// paths are tuned, the per-block log lines are noise. The rolling stats are STILL recorded (always
// queryable at GET /api/diagnostics/event-loop), and CRITICAL freezes (>= PERF_CRITICAL_MS) are
// ALWAYS logged so a genuine multi-second stall is never silently swallowed. Flip CTM_PERF_LOGS=1
// (or CTM_PERF_DEBUG=1) to restore full verbose perf logging while debugging.
const PERF_CRITICAL_MS = (() => {
  const n = Number(process.env.CTM_PERF_CRITICAL_MS);
  return Number.isFinite(n) && n > 0 ? n : 2000;
})();
// Re-read the env each call so the flag is runtime-toggleable (and testable) — no restart needed
// to flip verbose perf logging on/off. Cheap: a couple of process.env property reads.
function perfLogsEnabled() {
  return process.env.CTM_PERF_LOGS === '1' || process.env.CTM_PERF_DEBUG === '1';
}
function shouldLogPerf(durMs) { return perfLogsEnabled() || Number(durMs) >= PERF_CRITICAL_MS; }

// Ring buffer of recently-completed spans. `getOp()` only names the single op active
// (or last completed within 1s) when a [perf-lag] block fires — but a freeze is often
// the SUM of several sync chunks running back-to-back after awaits, the last of which
// is `(unknown)`. This trail ("what ran in the last few seconds, and for how long")
// turns an anonymous block into an attributable one. Cheap: one push per span over a
// small threshold, capped length.
const _RECENT_SPAN_MIN_MS = (() => {
  const n = Number(process.env.CTM_PERF_SPAN_MIN_MS);
  return Number.isFinite(n) && n >= 0 ? n : 20;
})();
const _RECENT_SPANS_MAX = 24;
const _recentSpans = []; // {name, dur, end}
function _recordSpan(name, dur) {
  if (dur < _RECENT_SPAN_MIN_MS) return;
  _recentSpans.push({ name, dur, end: Date.now() });
  if (_recentSpans.length > _RECENT_SPANS_MAX) _recentSpans.shift();
}
// Most-recent-first list of spans that ended within maxAgeMs, plus their summed time
// grouped by name. Read only on the slow path (when a lag line is being emitted).
function getRecentSpans({ maxAgeMs = 3000, limit = 8 } = {}) {
  const cutoff = Date.now() - maxAgeMs;
  const recent = [];
  const byName = new Map();
  for (let i = _recentSpans.length - 1; i >= 0; i--) {
    const s = _recentSpans[i];
    if (s.end < cutoff) break;
    if (recent.length < limit) recent.push(s);
    byName.set(s.name, (byName.get(s.name) || 0) + s.dur);
  }
  const top = Array.from(byName.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, limit)
    .map(([name, ms]) => ({ name, ms }));
  return { recent, top };
}

// Phase 5: continuous event-loop budget. The lag detector feeds every block here so a
// rolling window of "blocked ms attributed by op" is queryable (GET /api/diagnostics/
// event-loop) instead of only grep-able after the fact. Cumulative-since-reset (?reset=1
// for windowed sampling), matching the write-lock diagnostics. Blocks >= the sleep
// threshold (laptop suspend → a giant wall-clock gap, not a real freeze) are bucketed
// separately so they don't pollute the named op stats.
const _SLEEP_BLOCK_MS = (() => {
  const n = Number(process.env.CTM_SLEEP_BLOCK_MS);
  return Number.isFinite(n) && n > 0 ? n : 20000;
})();
let _blockSince = Date.now();
let _blockCount = 0;
let _blockSumMs = 0;
let _blockMaxMs = 0;
let _blockUnknownMs = 0;
let _sleepCount = 0;
let _sleepMs = 0;
const _blockByOp = new Map(); // name -> { count, sumMs, maxMs }

// GC-pause attribution. A major GC on CTM's multi-GB heap can pause the loop
// 100-400ms, and the lag detector blames that pause on whatever synchronous op
// happened to be running (pty:processData, scrollback-snapshot, ...) — producing
// scattered ~400ms blocks with no single fixable cause. Observing GC directly
// lets the diagnostic answer "was that block GC or real CPU?": compare gc.maxMs
// to maxBlockMs (close → the big block was a GC pause, not code to optimize).
// Read-only: receiving GC entries does not change GC behavior. The observer is
// cheap (V8 emits these regardless; the callback only bumps counters). Disable
// with CTM_TRACK_GC=0 if the observer itself is ever suspected.
let _gcCount = 0;
let _gcMs = 0;
let _gcMaxMs = 0;
const _gcByKind = new Map(); // kind -> { count, ms, maxMs }
// performance GC entry `kind` flags (perf_hooks NODE_PERFORMANCE_GC_*).
const _GC_KIND = { 1: 'minor', 2: 'major', 4: 'incremental', 8: 'weakcb' };
let _gcObserver = null;
function _startGcObserver() {
  if (_gcObserver || process.env.CTM_TRACK_GC === '0') return;
  try {
    const { PerformanceObserver } = require('perf_hooks');
    _gcObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        const ms = Number(entry.duration) || 0;
        _gcCount += 1;
        _gcMs += ms;
        if (ms > _gcMaxMs) _gcMaxMs = ms;
        const flag = entry.detail ? entry.detail.kind : entry.kind;
        const kind = _GC_KIND[flag] || 'gc';
        const e = _gcByKind.get(kind) || { count: 0, ms: 0, maxMs: 0 };
        e.count += 1; e.ms += ms; if (ms > e.maxMs) e.maxMs = ms;
        _gcByKind.set(kind, e);
      }
    });
    _gcObserver.observe({ entryTypes: ['gc'], buffered: false });
    if (typeof _gcObserver.unref === 'function') _gcObserver.unref();
  } catch { _gcObserver = null; }
}
_startGcObserver();
function recordEventLoopBlock(name, blockedMs) {
  const ms = Math.max(0, Number(blockedMs) || 0);
  if (ms <= 0) return;
  if (ms >= _SLEEP_BLOCK_MS) { _sleepCount += 1; _sleepMs += ms; return; }
  _blockCount += 1;
  _blockSumMs += ms;
  if (ms > _blockMaxMs) _blockMaxMs = ms;
  const key = String(name || '(unknown)');
  if (key.includes('(unknown)')) _blockUnknownMs += ms;
  const e = _blockByOp.get(key) || { count: 0, sumMs: 0, maxMs: 0 };
  e.count += 1; e.sumMs += ms; if (ms > e.maxMs) e.maxMs = ms;
  _blockByOp.set(key, e);
}
function getEventLoopStats({ reset = false } = {}) {
  const now = Date.now();
  const windowMs = Math.max(1, now - _blockSince);
  const topOps = Array.from(_blockByOp.entries())
    .map(([name, e]) => ({ name, count: e.count, sumMs: e.sumMs, maxMs: e.maxMs }))
    .sort((a, b) => b.sumMs - a.sumMs)
    .slice(0, 15);
  const gcByKind = Array.from(_gcByKind.entries())
    .map(([kind, e]) => ({ kind, count: e.count, ms: Math.round(e.ms), maxMs: Math.round(e.maxMs) }))
    .sort((a, b) => b.ms - a.ms);
  const snap = {
    windowMs,
    blocks: _blockCount,
    totalBlockedMs: _blockSumMs,
    maxBlockMs: _blockMaxMs,
    busyPct: Math.round(1000 * _blockSumMs / windowMs) / 10, // % of wall-clock spent in >threshold blocks
    unknownMs: _blockUnknownMs,
    unknownSharePct: _blockSumMs > 0 ? Math.round(100 * _blockUnknownMs / _blockSumMs) : 0,
    suspectedSleep: { count: _sleepCount, ms: _sleepMs },
    topOps,
    // GC pauses over the same window. Compare gc.maxMs to maxBlockMs: if they're
    // close, the worst block was a GC pause (heap pressure), not a sync op to
    // optimize. enabled:false means the observer was turned off (CTM_TRACK_GC=0).
    gc: {
      enabled: !!_gcObserver,
      count: _gcCount,
      ms: Math.round(_gcMs),
      maxMs: Math.round(_gcMaxMs),
      byKind: gcByKind,
    },
  };
  if (reset) {
    _blockSince = now; _blockCount = 0; _blockSumMs = 0; _blockMaxMs = 0;
    _blockUnknownMs = 0; _sleepCount = 0; _sleepMs = 0; _blockByOp.clear();
    _gcCount = 0; _gcMs = 0; _gcMaxMs = 0; _gcByKind.clear();
  }
  return snap;
}

function getOp() {
  if (_current) return _current;
  if (_lastCompleted && Date.now() - _lastCompleted.end <= 1000) {
    return { name: `${_lastCompleted.name} (last completed)`, start: _lastCompleted.start };
  }
  return null;
}

function eventLoopBlockMs(deltaMs, intervalMs = 100) {
  return Math.max(0, Number(deltaMs || 0) - Number(intervalMs || 0));
}

function shouldReportLag(deltaMs, { intervalMs = 100, thresholdMs = 200 } = {}) {
  return eventLoopBlockMs(deltaMs, intervalMs) > thresholdMs;
}

/**
 * Wrap a function so its name is tagged for the duration of each call.
 * Smart about sync vs async: sync functions return sync, and async functions
 * are tagged only for their immediate synchronous prefix. Keeping a global tag
 * set across awaits misattributes unrelated event-loop stalls to whichever
 * scheduler job happens to be awaiting I/O.
 */
function wrap(name, fn) {
  return function (...args) {
    const prev = _current;
    _current = { name, start: Date.now() };
    const t0 = Date.now();
    let result;
    try {
      result = fn.apply(this, args);
    } catch (e) {
      _current = prev;
      if (_debug && Date.now() - t0 > 100) console.warn(`[perf] ${name} threw after ${Date.now() - t0}ms`);
      throw e;
    }
    // Synchronous-prefix duration: the time fn() ran on the loop before returning. For an
    // async function this is the only part that BLOCKED the event loop — the awaited time
    // afterward is I/O wait, not a block. Recording wall-clock for async spans made the
    // recent-spans trail over-attribute (e.g. an async health tick showed "1243ms" that was
    // mostly network wait, not a freeze). Record the sync prefix so the trail is faithful.
    const syncDur = Date.now() - t0;
    // Promise/thenable: the synchronous prefix is done. Clear the tag now so
    // unrelated timers do not inherit a stale "active op" while this awaits I/O.
    if (result && typeof result.then === 'function') {
      _current = prev;
      _recordSpan(name, syncDur); // only the sync prefix counts as a loop block
      return result.finally(() => {
        const dur = Date.now() - t0; // wall-clock — for the "(last completed)" attribution only
        if (dur > 100) _lastCompleted = { name, start: t0, end: Date.now(), dur };
        if (_debug && dur > 100) console.warn(`[perf] ${name} took ${dur}ms (incl await)`);
      });
    }
    // Sync: restore immediately and pass through. The whole call blocked the loop.
    const dur = syncDur;
    if (dur > 100) _lastCompleted = { name, start: t0, end: Date.now(), dur };
    _recordSpan(name, dur);
    _current = prev;
    if (_debug && dur > 100) console.warn(`[perf] ${name} took ${dur}ms`);
    return result;
  };
}

// Back-compat alias — both behave identically now (smart sync/async detection).
const wrapAsync = wrap;

/**
 * Run a synchronous block as a named span and return its result.
 *
 * Why this exists: `wrap()` tags an async function only for its synchronous
 * prefix, then clears the tag at the first `await`. So a heavy synchronous
 * CHUNK that runs *after* an await (a per-row DB loop, a big JSON.parse) is
 * invisible — the event-loop-lag detector fires in a later timer tick and sees
 * `(unknown)`. Wrapping that chunk with `runSync('name', () => {...})` records
 * it into `_lastCompleted`, so `getOp()` can attribute the block within 1s.
 * Use around whole chunks, not per loop iteration (one closure alloc per call).
 */
function runSync(name, fn) {
  return wrap(name, fn)();
}

// Default freeze-probe threshold (ms). A single synchronous span over this bound
// is a user-perceptible stall on a single-threaded server. Overridable per call.
const FREEZE_PROBE_MS = (() => {
  const n = Number(process.env.CTM_FREEZE_PROBE_MS);
  return Number.isFinite(n) && n > 0 ? n : 150;
})();

/**
 * Run a synchronous block as a named span AND emit a one-line `[freeze-probe]`
 * diagnostic when it exceeds a threshold. Builds on runSync() (so the span is
 * still attributed to getOp()), but additionally logs WHY the loop froze with
 * caller-supplied context — turning the next freeze into a self-describing log
 * entry instead of an anonymous `(unknown)` lag block.
 *
 * @param {string} name span name (also the freeze-probe `where`)
 * @param {() => any} fn the synchronous work
 * @param {object} [opts]
 * @param {number} [opts.thresholdMs] override the default threshold
 * @param {() => object} [opts.context] lazily-built context object, only invoked
 *        on the slow path (so the common fast path pays nothing). Keys are logged
 *        as `k=v` pairs.
 */
function runSyncProbed(name, fn, opts = {}) {
  const t0 = Date.now();
  try {
    return wrap(name, fn)();
  } finally {
    const dur = Date.now() - t0;
    const threshold = Number(opts.thresholdMs) > 0 ? Number(opts.thresholdMs) : FREEZE_PROBE_MS;
    if (dur >= threshold && shouldLogPerf(dur)) {
      let ctxStr = '';
      try {
        const ctx = typeof opts.context === 'function' ? opts.context() : null;
        if (ctx && typeof ctx === 'object') {
          ctxStr = Object.keys(ctx).map((k) => `${k}=${ctx[k]}`).join(' ');
        }
      } catch { /* context build must never break the probe */ }
      // eslint-disable-next-line no-console
      console.warn(`[freeze-probe] ${name} blocked ${dur}ms${ctxStr ? ' ' + ctxStr : ''}`);
    }
  }
}

module.exports = { eventLoopBlockMs, getOp, getRecentSpans, recordEventLoopBlock, getEventLoopStats, shouldReportLag, wrap, wrapAsync, runSync, runSyncProbed, FREEZE_PROBE_MS, perfLogsEnabled, shouldLogPerf, PERF_CRITICAL_MS };
