#!/usr/bin/env node
/**
 * engine/supervisor.js — External watchdog for engine + dashboard.
 *
 * Spawned `detached: true` by `minions start`/`restart` after the engine and
 * dashboard are up. Polls every SUPERVISOR_INTERVAL_MS:
 *   - engine PID from `engine/control.json`
 *   - dashboard PID via port-listener probe (port 7331 by default)
 *
 * When either is dead AND the stop-intent flag is NOT set, respawns the dead
 * one in the same way the CLI does (detached, stdio routed to the engine-
 * authored log files). When the stop-intent flag IS set, the supervisor
 * stands down — the user explicitly asked Minions to be down.
 *
 * Background: see conversation 2026-06-01. The dashboard runs an in-process
 * watchdog that respawns the engine, but nothing watched the dashboard. When
 * both processes died together (terminal close, logoff, OOM), neither could
 * recover. This external supervisor closes that gap.
 *
 * The supervisor itself is intentionally unmonitored. If you need stronger
 * guarantees (machine reboot, OS-level user logoff), register Minions as a
 * Windows Service via nssm — see docs/supervisor.md.
 */

require('./stdio-timestamps').installIfNotInstalled();

const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn, execSync } = require('child_process');

// Lazy path getters route through engine/shared.js when available so the
// supervisor's notion of "the engine dir" honors MINIONS_TEST_DIR (used by
// the unit tests). Fall back to __dirname-based resolution when shared.js
// isn't loadable (e.g. early load / bad upgrade) so the supervisor still
// boots in production.
const MINIONS_DIR = path.resolve(__dirname, '..');
function _sharedOrNull() {
  try { return require('./shared'); } catch { return null; }
}
function _engineDir() { return _sharedOrNull()?.ENGINE_DIR || __dirname; }
const STATIC_ENGINE_DIR = __dirname; // for require-time paths that can't be lazy
function CONTROL_PATH_FN() { return path.join(_engineDir(), 'control.json'); }
function STOP_INTENT_PATH_FN() { return _sharedOrNull()?.STOP_INTENT_PATH || path.join(_engineDir(), 'stop-intent.json'); }
function SUPERVISOR_PID_PATH_FN() { return path.join(_engineDir(), 'supervisor.pid'); }

// Eager bindings preserved for backwards-compatible exports — read-only
// snapshots of the path at module-load time. Internal callers should use
// the _FN getters above so MINIONS_TEST_DIR overrides take effect.
const CONTROL_PATH = path.join(STATIC_ENGINE_DIR, 'control.json');
const STOP_INTENT_PATH = path.join(STATIC_ENGINE_DIR, 'stop-intent.json');
const SUPERVISOR_PID_PATH = path.join(STATIC_ENGINE_DIR, 'supervisor.pid');

const SUPERVISOR_INTERVAL_MS = Number(process.env.MINIONS_SUPERVISOR_INTERVAL_MS) || 30000;
// Dashboard port resolves dynamically each tick (W-mq5nwl9l). The dashboard
// auto-falls-back to the next free port when 7331 is in use and persists the
// actually-bound port to ~/.minions/engine/dashboard-port.json. Hard-coding
// 7331 here would make the supervisor probe the wrong port and respawn-loop
// the dashboard. _resolveDashPort() consults the runtime file first, falls
// back to the env var, then to the static default.
const DASH_PORT_DEFAULT = Number(process.env.MINIONS_DASHBOARD_PORT) || 7331;
function _resolveDashPort() {
  const shared = _sharedOrNull();
  if (shared && typeof shared.readDashboardPortFile === 'function') {
    try {
      const home = process.env.MINIONS_HOME || path.join(os.homedir(), '.minions');
      const entry = shared.readDashboardPortFile(home);
      if (entry && Number.isInteger(entry.port) && entry.port > 0) return entry.port;
    } catch { /* fall through */ }
  }
  return DASH_PORT_DEFAULT;
}
// Grace window after we (re)spawn a process — gives it time to bind its port
// or write its PID before we re-probe. Without this we'd race the freshly
// spawned process and double-spawn.
const POST_SPAWN_GRACE_MS = Number(process.env.MINIONS_SUPERVISOR_GRACE_MS) || 15000;
const isWin = process.platform === 'win32';

function safeReadJson(p) {
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
}

// Single source of truth lives in engine/shared.js (writeStopIntent /
// clearStopIntent / isStopIntentSet). The supervisor used to re-implement
// this locally; centralizing it ensures dashboard.js, bin/minions.js,
// engine/cli.js, and the supervisor all read/write through one contract.
// Fall back to a local file-presence check if shared.js fails to load (e.g.
// during a bad upgrade) — we'd rather fail-closed than crash the supervisor.
function isStopIntentSet() {
  try {
    const shared = require('./shared');
    if (shared && typeof shared.isStopIntentSet === 'function') {
      return shared.isStopIntentSet();
    }
  } catch { /* shared unavailable — fall through to local probe */ }
  return fs.existsSync(STOP_INTENT_PATH_FN());
}

function isPidAlive(pid) {
  if (!pid) return false;
  try {
    if (isWin) {
      const out = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
        encoding: 'utf8', timeout: 3000, windowsHide: true,
      });
      return out.includes(String(pid));
    }
    process.kill(pid, 0);
    return true;
  } catch { return false; }
}

function listeningPidsForPort(port) {
  try {
    if (isWin) {
      const out = execSync(`netstat -ano | findstr ":${port} " | findstr LISTENING`, {
        encoding: 'utf8', timeout: 5000, windowsHide: true,
      });
      const pids = new Set();
      for (const line of out.split('\n')) {
        const pid = line.trim().split(/\s+/).pop();
        if (pid && /^\d+$/.test(pid) && pid !== '0') pids.add(Number(pid));
      }
      return [...pids];
    }
    const out = execSync(`lsof -ti:${port}`, { encoding: 'utf8', timeout: 5000 });
    return out.split('\n').map(s => s.trim()).filter(Boolean).map(Number);
  } catch { return []; }
}

function openAppendFd(name) {
  // Try to use the same append-fd helper the rest of the codebase uses, fall
  // back to a plain fs.openSync(..., 'a') when it isn't available. We re-route
  // child stdio to engine/<name> so a respawned process produces a postmortem
  // in the same place a CLI-spawned one would.
  //
  // Uses _engineDir() (lazy getter) — not the removed top-level ENGINE_DIR.
  // The fixup commit renamed top-level ENGINE_DIR to STATIC_ENGINE_DIR and
  // _engineDir(), and a stale ENGINE_DIR reference here silently returned
  // 'ignore' for both stdout AND stderr, causing every supervisor-spawned
  // dashboard and engine to lose its stdio trail entirely (the dashboard
  // still serves HTTP 200, but you can't see anything it logs).
  const dir = _engineDir();
  try {
    const shared = require('./shared');
    if (shared && typeof shared.openAppendLogFd === 'function') {
      return shared.openAppendLogFd(name, dir, { fallback: 'ignore' }).fd;
    }
  } catch { /* shared unavailable — fall through */ }
  try { return fs.openSync(path.join(dir, name), 'a'); } catch { return 'ignore'; }
}

function _sqliteSpawnFlags() {
  if (process.env.MINIONS_FORCE_JSON === '1') return [];
  const major = parseInt(String(process.versions.node).split('.')[0], 10);
  if (!Number.isFinite(major) || major < 22 || major >= 24) return [];
  const nodeOpts = String(process.env.NODE_OPTIONS || '');
  if (nodeOpts.includes('--experimental-sqlite')) return [];
  return ['--experimental-sqlite'];
}

function spawnEngine() {
  const out = openAppendFd('engine-stdio.log');
  const err = openAppendFd('engine-stdio.log');
  const proc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_DIR, 'engine.js'), 'start'], {
    cwd: MINIONS_DIR,
    stdio: ['ignore', out, err],
    detached: true,
    windowsHide: true,
  });
  proc.unref();
  return proc.pid;
}

function spawnDashboard() {
  const out = openAppendFd('dashboard-stdio.log');
  const err = openAppendFd('dashboard-stdio.log');
  const env = { ...process.env, MINIONS_NO_AUTO_OPEN: '1' };
  const proc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_DIR, 'dashboard.js')], {
    cwd: MINIONS_DIR,
    stdio: ['ignore', out, err],
    detached: true,
    windowsHide: true,
    env,
  });
  proc.unref();
  return proc.pid;
}

// Initialized to Date.now() in main() so the supervisor's first tick (fires
// at +2s after spawn) is INSIDE the POST_SPAWN_GRACE_MS window. Without this
// the first tick can race the freshly-spawned engine from `minions restart`
// before that engine has written its PID to control.json, double-spawning
// the engine.
let _lastEngineRespawnAt = 0;
let _lastDashboardRespawnAt = 0;

// Window during which a `null` pid + recent `restarted_at` is interpreted as
// "another watchdog is currently respawning the engine — don't double-spawn."
// The dashboard's in-process engine watchdog (dashboard.js:11873-11901) calls
// `mutateControl({pid:null, restarted_at})` BEFORE spawning, opening a gap
// where supervisor would otherwise see `state==='running' && !control.pid`
// and racefully spawn a second engine. 60s gives the new engine ample time
// to enter cli.js:443 and write its own PID on cold Windows boots.
const RESPAWN_IN_PROGRESS_WINDOW_MS = Number(process.env.MINIONS_SUPERVISOR_RESPAWN_WINDOW_MS) || 60000;

function checkEngine(now) {
  if (now - _lastEngineRespawnAt < POST_SPAWN_GRACE_MS) return;
  const control = safeReadJson(CONTROL_PATH_FN());
  // Only respawn when control.json says "running" — paused/stopped/stopping
  // are legitimate states the supervisor must not override.
  if (!control || control.state !== 'running') return;
  if (control.pid && isPidAlive(control.pid)) return;

  // Cross-watchdog race guard: dashboard.js's in-process engine watchdog
  // writes `{pid: null, restarted_at}` BEFORE spawning a new engine. During
  // that window supervisor's `control.pid && isPidAlive` check falls through
  // (pid is null/falsy) and we would double-spawn. If `restarted_at` was
  // updated in the last RESPAWN_IN_PROGRESS_WINDOW_MS, assume someone else
  // owns the respawn and skip this tick.
  if (control.pid == null && control.restarted_at) {
    const restartedAtMs = Date.parse(control.restarted_at);
    if (Number.isFinite(restartedAtMs) && (now - restartedAtMs) < RESPAWN_IN_PROGRESS_WINDOW_MS) {
      return;
    }
  }

  console.log(`[supervisor] Engine PID ${control.pid || '(none)'} is dead — respawning...`);
  const newPid = spawnEngine();
  _lastEngineRespawnAt = now;
  console.log(`[supervisor] Engine respawned (new PID: ${newPid})`);
}

function checkDashboard(now) {
  if (now - _lastDashboardRespawnAt < POST_SPAWN_GRACE_MS) return;
  const dashPort = _resolveDashPort();
  const pids = listeningPidsForPort(dashPort);
  if (pids.length > 0) return;

  console.log(`[supervisor] Dashboard not listening on port ${dashPort} — respawning...`);
  const newPid = spawnDashboard();
  _lastDashboardRespawnAt = now;
  console.log(`[supervisor] Dashboard respawned (new PID: ${newPid})`);
}

function tick() {
  try {
    if (isStopIntentSet()) return;
    const now = Date.now();
    checkEngine(now);
    checkDashboard(now);
  } catch (e) {
    console.error(`[supervisor] tick error: ${e && e.message}`);
  }
}

function writePidFile() {
  try { fs.writeFileSync(SUPERVISOR_PID_PATH_FN(), String(process.pid)); } catch { /* best effort */ }
}

function clearPidFileIfMine() {
  try {
    const existing = fs.readFileSync(SUPERVISOR_PID_PATH_FN(), 'utf8').trim();
    if (existing === String(process.pid)) fs.unlinkSync(SUPERVISOR_PID_PATH_FN());
  } catch { /* file already gone or read failed — fine */ }
}

function main() {
  // Single-instance guard: if another supervisor is already alive (PID file
  // points to a live process), exit cleanly. Avoids supervisor-spawning-
  // supervisor loops during rapid `minions restart`.
  const existing = (() => {
    try { return Number(fs.readFileSync(SUPERVISOR_PID_PATH_FN(), 'utf8').trim()); } catch { return null; }
  })();
  if (existing && existing !== process.pid && isPidAlive(existing)) {
    console.log(`[supervisor] Another supervisor already running (PID ${existing}); exiting.`);
    process.exit(0);
  }

  writePidFile();
  // Seed both respawn timestamps so the first tick is inside the post-spawn
  // grace window — supervisor was spawned moments after engine + dashboard
  // and we don't want to race them. Without this seed, _lastEngineRespawnAt
  // = 0 and the +2s warm-up tick can fire before the fresh engine writes
  // its PID to control.json on cold Windows boots.
  _lastEngineRespawnAt = Date.now();
  _lastDashboardRespawnAt = Date.now();
  console.log(`[supervisor] Started (PID ${process.pid}); interval=${SUPERVISOR_INTERVAL_MS}ms, dashboardPort=${_resolveDashPort()}(dynamic)`);

  const interval = setInterval(tick, SUPERVISOR_INTERVAL_MS);
  // Run one tick immediately so a freshly-restarted Minions covers any early
  // crash in the first SUPERVISOR_INTERVAL_MS window.
  setTimeout(tick, 2000);

  function shutdown(reason) {
    console.log(`[supervisor] Shutting down (${reason}).`);
    clearInterval(interval);
    clearPidFileIfMine();
    // Don't process.exit — let the runtime drain remaining log writes.
  }
  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT', () => shutdown('SIGINT'));
  process.on('beforeExit', () => clearPidFileIfMine());
}

if (require.main === module) {
  main();
}

module.exports = {
  // Exposed for unit tests — engine code shouldn't reach into these.
  _internals: {
    isStopIntentSet,
    isPidAlive,
    listeningPidsForPort,
    openAppendFd,
    checkEngine,
    checkDashboard,
    tick,
    // Path getters honor MINIONS_TEST_DIR via shared.ENGINE_DIR, so test
    // isolation correctly redirects writes/reads under createTestMinionsDir.
    get paths() {
      return {
        CONTROL_PATH: CONTROL_PATH_FN(),
        STOP_INTENT_PATH: STOP_INTENT_PATH_FN(),
        SUPERVISOR_PID_PATH: SUPERVISOR_PID_PATH_FN(),
      };
    },
  },
};
