/**
 * engine/keep-process-sweep.js -- opt-in `meta.keep_processes` reaper.
 *
 * Walks agents/<id>/keep-pids.json files and, for each:
 *   - Validates the file (bounded JSON shape, see validateKeepPidsRecord).
 *     Malformed files are left alone (the user/agent can clean them up by
 *     hand) but they are never honored as anchors.
 *   - When expires_at < now: kills each declared PID via shared.killByPidImmediate
 *     and unlinks the file. Audit-logs {agentId, pid, reason: 'ttl_expired'}.
 *   - When all declared PIDs are dead but expires_at has not fired: silently
 *     unlinks the file (nothing to anchor).
 *
 * Run once at engine boot AND from the tick cycle every
 * ENGINE_DEFAULTS.keepProcesses.sweepEvery ticks. Idempotent -- a no-op when
 * no agents declared keep_processes.
 *
 * See W-mp68q6ke0010de68 for the file shape and acceptance tests.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const queries = require('./queries');

const { log, ENGINE_DEFAULTS } = shared;

const KEEP_PIDS_FILENAME = 'keep-pids.json';
const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';

function _agentsDir() {
  return queries.AGENTS_DIR || path.join(shared.MINIONS_DIR, 'agents');
}

function _resolveRequireGitWorkdir(opts) {
  const limits = ENGINE_DEFAULTS.keepProcesses || {};
  if (opts && Object.prototype.hasOwnProperty.call(opts, 'requireGitWorkdir')) {
    return !!opts.requireGitWorkdir;
  }
  // Treat undefined default as ON to fail closed.
  if (limits.requireGitWorkdir === false) return false;
  return true;
}

function validateKeepPidsRecord(parsed, opts) {
  opts = opts || {};
  const limits = ENGINE_DEFAULTS.keepProcesses || {};
  const maxPids = Math.max(1, Number(limits.maxPerAgent) || 5);
  const maxTtlMs = Math.max(60000, (Number(limits.maxTtlMinutes) || 1440) * 60 * 1000);
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
  const requireGitWorkdir = _resolveRequireGitWorkdir(opts);

  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
    return { ok: false, reason: 'not-an-object' };
  }
  if (!Array.isArray(parsed.pids)) return { ok: false, reason: 'pids-missing' };
  if (parsed.pids.length === 0) return { ok: false, reason: 'pids-empty' };
  if (parsed.pids.length > maxPids) return { ok: false, reason: 'pids-too-many (>' + maxPids + ')' };
  const pids = [];
  for (const raw of parsed.pids) {
    const n = Number(raw);
    if (!Number.isInteger(n) || n <= 0) return { ok: false, reason: 'pid-invalid (' + raw + ')' };
    pids.push(n);
  }

  if (typeof parsed.expires_at !== 'string') return { ok: false, reason: 'expires_at-missing' };
  const expiresAtMs = Date.parse(parsed.expires_at);
  if (!Number.isFinite(expiresAtMs)) return { ok: false, reason: 'expires_at-unparsable' };
  if (expiresAtMs - now > maxTtlMs) {
    return { ok: false, reason: 'ttl-too-long (>' + Math.round(maxTtlMs / 60000) + 'min)' };
  }

  if (parsed.purpose != null && typeof parsed.purpose !== 'string') {
    return { ok: false, reason: 'purpose-not-string' };
  }
  if (typeof parsed.purpose === 'string' && parsed.purpose.length > 500) {
    return { ok: false, reason: 'purpose-too-long' };
  }
  if (parsed.cwd != null && typeof parsed.cwd !== 'string') {
    return { ok: false, reason: 'cwd-not-string' };
  }
  if (typeof parsed.cwd === 'string' && parsed.cwd.length > 500) {
    return { ok: false, reason: 'cwd-too-long' };
  }
  // W-mp6k7ywi000fa33c — when the engine requires a real git workdir
  // (default true; per-WI override via `meta.keep_processes_skip_workdir_check`),
  // verify the recorded `cwd` looks like a real worktree. Empty/missing
  // `cwd` is not validated for back-compat with files that pre-date this
  // check; agents authored after this change SHOULD include `cwd`. The
  // engine acceptance hook in engine.js keys off the `invalid-workdir:`
  // prefix to mark the WI failed and emit an inbox alert.
  if (requireGitWorkdir && typeof parsed.cwd === 'string' && parsed.cwd.length > 0) {
    const wt = shared.isValidGitWorktree(parsed.cwd);
    if (!wt.ok) {
      return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + wt.reason };
    }
  }
  if (parsed.ports != null) {
    if (!Array.isArray(parsed.ports)) return { ok: false, reason: 'ports-not-array' };
    if (parsed.ports.length > 20) return { ok: false, reason: 'ports-too-many (>20)' };
    for (const p of parsed.ports) {
      const n = Number(p);
      if (!Number.isInteger(n) || n <= 0 || n > 65535) {
        return { ok: false, reason: 'port-invalid (' + p + ')' };
      }
    }
  }
  if (parsed.written_by != null && typeof parsed.written_by !== 'string') {
    return { ok: false, reason: 'written_by-not-string' };
  }
  if (parsed.wi_id != null && typeof parsed.wi_id !== 'string') {
    return { ok: false, reason: 'wi_id-not-string' };
  }

  return {
    ok: true,
    value: {
      pids: pids,
      purpose: typeof parsed.purpose === 'string' ? parsed.purpose : '',
      cwd: typeof parsed.cwd === 'string' ? parsed.cwd : '',
      ports: Array.isArray(parsed.ports) ? parsed.ports.map(Number) : [],
      expires_at: parsed.expires_at,
      expiresAtMs: expiresAtMs,
      written_by: typeof parsed.written_by === 'string' ? parsed.written_by : '',
      wi_id: typeof parsed.wi_id === 'string' ? parsed.wi_id : '',
    },
  };
}

function readKeepPidsFile(agentId, opts) {
  opts = opts || {};
  const filePath = path.join(_agentsDir(), agentId, KEEP_PIDS_FILENAME);
  let raw;
  try { raw = fs.readFileSync(filePath, 'utf8'); }
  catch (_e) { return null; }
  let parsed = null;
  try { parsed = JSON.parse(raw); }
  catch (e) {
    return { agentId: agentId, filePath: filePath, valid: false, reason: 'json-parse: ' + e.message, parsed: null };
  }
  const v = validateKeepPidsRecord(parsed, opts);
  if (!v.ok) return { agentId: agentId, filePath: filePath, valid: false, reason: v.reason, parsed: parsed };
  return { agentId: agentId, filePath: filePath, valid: true, value: v.value, parsed: parsed };
}

function listAllKeepPidsFiles(opts) {
  opts = opts || {};
  const dir = _agentsDir();
  let entries = [];
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
  catch (_e) { return []; }
  const out = [];
  for (const ent of entries) {
    if (!ent.isDirectory()) continue;
    if (ent.name.startsWith('_') || ent.name.startsWith('.')) continue;
    // W-mp6k7ywi000fa33c — forward opts (notably `requireGitWorkdir`) so
    // sweep callers can disable workdir validation for cleanup of files
    // pointing at directories that no longer exist.
    const rec = readKeepPidsFile(ent.name, opts);
    if (rec) out.push(rec);
  }
  return out;
}

function alivePids(pids, opts) {
  opts = opts || {};
  const isAlive = typeof opts.isAlive === 'function'
    ? opts.isAlive
    : function (pid) { try { process.kill(pid, 0); return true; } catch (_e) { return false; } };
  return pids.filter(isAlive);
}

function _audit(eventType, payload) {
  try {
    log('info', 'keep-processes ' + eventType + ': ' + JSON.stringify(payload));
  } catch (_e) { /* logging best-effort */ }
}

function sweepKeepProcesses(opts) {
  opts = opts || {};
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
  const killOne = typeof opts.killOne === 'function'
    ? opts.killOne
    : function (pid) { return shared.killByPidImmediate(pid); };
  const stats = { scanned: 0, expiredFiles: 0, killedPids: 0, deadFiles: 0, malformed: 0 };
  // W-mp6k7ywi000fa33c — sweep ignores workdir validation so TTL/dead-PID
  // cleanup still drains files whose cwd no longer exists (e.g., the agent
  // worktree was already torn down). Anchor reads (computeReapPlan,
  // getActiveAnchorPids*) keep the default-on validation so invalid records
  // are not honored as anchors.
  const records = listAllKeepPidsFiles({ now: now, requireGitWorkdir: false });
  for (const rec of records) {
    stats.scanned++;
    if (!rec.valid) {
      stats.malformed++;
      log('warn', 'keep-processes: malformed ' + rec.filePath + ' -- ' + rec.reason + '; leaving in place');
      continue;
    }
    const value = rec.value;
    const filePath = rec.filePath;
    const agentId = rec.agentId;
    if (value.expiresAtMs < now) {
      let killed = 0;
      for (const pid of value.pids) {
        const ok = killOne(pid);
        if (ok) killed++;
        _audit('ttl-expired-kill', { agentId: agentId, pid: pid, reason: 'ttl_expired', wi_id: value.wi_id });
      }
      stats.killedPids += killed;
      stats.expiredFiles++;
      try { fs.unlinkSync(filePath); } catch (_e) { /* gone already */ }
      continue;
    }
    const stillAlive = alivePids(value.pids, opts);
    if (stillAlive.length === 0) {
      stats.deadFiles++;
      try { fs.unlinkSync(filePath); } catch (_e) { /* gone already */ }
      _audit('dead-pids-cleanup', { agentId: agentId, pids: value.pids, wi_id: value.wi_id });
    }
  }
  return stats;
}

function getActiveAnchorPids(opts) {
  opts = opts || {};
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
  const out = new Set();
  for (const rec of listAllKeepPidsFiles({ now: now })) {
    if (!rec.valid) continue;
    if (rec.value.expiresAtMs < now) continue;
    for (const pid of rec.value.pids) out.add(pid);
  }
  return out;
}

function getActiveAnchorPidsForAgent(agentId, opts) {
  opts = opts || {};
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
  const out = new Set();
  if (!agentId) return { pids: out, record: null };
  // W-mp6k7ywi000fa33c — forward `requireGitWorkdir` (and any future opts)
  // so the per-WI override path can disable workdir validation. Earlier
  // implementation only forwarded `now`, which silently ignored opts.
  const readOpts = { now: now };
  if (Object.prototype.hasOwnProperty.call(opts, 'requireGitWorkdir')) {
    readOpts.requireGitWorkdir = opts.requireGitWorkdir;
  }
  const rec = readKeepPidsFile(String(agentId), readOpts);
  if (!rec) return { pids: out, record: null };
  if (!rec.valid) {
    log('warn', 'keep-processes: ignoring ' + rec.filePath + ' for ' + agentId + ' -- ' + rec.reason);
    return { pids: out, record: null, reason: rec.reason };
  }
  if (rec.value.expiresAtMs < now) return { pids: out, record: rec.value, reason: 'expired' };
  for (const pid of rec.value.pids) out.add(pid);
  return { pids: out, record: rec.value };
}

function computeReapPlan(descendants, agentId, opts) {
  opts = opts || {};
  const list = (Array.isArray(descendants) ? descendants : []).map(Number)
    .filter(function (n) { return Number.isInteger(n) && n > 0; });
  const loader = typeof opts.loadAnchors === 'function'
    ? opts.loadAnchors
    : function (id, o) { return getActiveAnchorPidsForAgent(id, o); };
  const result = loader(agentId, opts);
  const keptSet = result && result.pids;
  const record = result && result.record;
  const reason = result && result.reason;
  if (!keptSet || keptSet.size === 0) {
    return { toKill: list, kept: [], record: record || null, reason: reason || null };
  }
  const kept = [];
  const toKill = [];
  for (const pid of list) {
    if (keptSet.has(pid)) kept.push(pid);
    else toKill.push(pid);
  }
  return { toKill: toKill, kept: kept, record: record || null, reason: null };
}

// W-mp6k7ywi000fa33c — engine-side acceptance helper. Returns a structured
// summary the engine.js close handler uses to decide whether to:
//   (a) accept the file silently (no keep-pids.json present, or valid),
//   (b) reject it as workdir-invalid → mark WI failed + emit inbox alert
//       + delete the file so it does not anchor anything stale,
//   (c) note a non-workdir validation failure (logged but not WI-failing,
//       since spawn-agent already reaped the PIDs).
//
// Pure: no side effects. Caller is responsible for the WI/inbox writes.
//
// opts.requireGitWorkdir defaults to ENGINE_DEFAULTS.keepProcesses.requireGitWorkdir.
// Pass `false` to bypass workdir validation (per-WI override path).
function evaluateKeepPidsAcceptance(agentId, opts) {
  opts = opts || {};
  const filePath = path.join(_agentsDir(), String(agentId || ''), KEEP_PIDS_FILENAME);
  if (!fs.existsSync(filePath)) {
    return { exists: false, accepted: false, isWorkdirRejection: false, reason: null, record: null, recordedCwd: null, filePath: filePath };
  }
  const rec = readKeepPidsFile(agentId, opts);
  if (!rec) {
    // Race: file existed in `existsSync` check above but was unlinked
    // before readFileSync ran. Treat as no-file.
    return { exists: false, accepted: false, isWorkdirRejection: false, reason: null, record: null, recordedCwd: null, filePath: filePath };
  }
  if (rec.valid) {
    return {
      exists: true,
      accepted: true,
      isWorkdirRejection: false,
      reason: null,
      record: rec.value,
      recordedCwd: rec.value.cwd || null,
      filePath: rec.filePath,
    };
  }
  const reason = rec.reason || 'unknown';
  const isWorkdirRejection = typeof reason === 'string' && reason.indexOf(INVALID_WORKDIR_REASON_PREFIX) === 0;
  let recordedCwd = null;
  if (rec.parsed && typeof rec.parsed === 'object' && typeof rec.parsed.cwd === 'string') {
    recordedCwd = rec.parsed.cwd;
  }
  return {
    exists: true,
    accepted: false,
    isWorkdirRejection: isWorkdirRejection,
    reason: reason,
    record: null,
    recordedCwd: recordedCwd,
    filePath: rec.filePath,
    parsedRaw: rec.parsed || null,
  };
}

function buildKeepProcessesHint(opts) {
  opts = opts || {};
  const limits = ENGINE_DEFAULTS.keepProcesses || {};
  const maxTtl = Math.max(1, Number(limits.maxTtlMinutes) || 1440);
  const defaultTtl = Math.max(1, Number(limits.defaultTtlMinutes) || 60);
  const maxPids = Math.max(1, Number(limits.maxPerAgent) || 5);
  const ttlIn = Number(opts.ttlMinutes);
  const ttl = Number.isFinite(ttlIn) && ttlIn > 0
    ? Math.min(maxTtl, Math.floor(ttlIn))
    : defaultTtl;
  const agentId = opts.agentId || '<your-agent-id>';
  const wiId = opts.workItemId || '<this-work-item-id>';
  const minionsDir = opts.minionsDir || '<minions-dir>';
  // W-mp7i902u000l991f — dashboard port for the self-check curl. Caller can
  // pass an explicit `dashboardPort`; falls back to 7331 (the documented
  // default for `dashboard.js`).
  const portIn = Number(opts.dashboardPort);
  const dashboardPort = Number.isFinite(portIn) && portIn > 0 ? portIn : 7331;
  const lines = [
    '',
    '',
    '---',
    '',
    '## Long-running processes (keep_processes flag)',
    '',
    'This work item permits you to leave specific descendant processes running after you exit (TTL: ' + ttl + ' minutes, hard-cap ' + maxTtl + ' minutes).',
    '',
    'If -- and ONLY if -- you start a long-running process (dev server, watcher, daemon, emulator, etc.) that the user explicitly asked to remain alive, then BEFORE you write your completion report you MUST write its PID(s) to:',
    '',
    '  `' + minionsDir + '/agents/' + agentId + '/keep-pids.json`',
    '',
    'with this exact JSON shape:',
    '',
    '```json',
    '{',
    '  "pids": [12345, 12346],',
    '  "purpose": "bun run dev (Constellation server + dashboard)",',
    '  "cwd": "D:/repos/constellation",',
    '  "ports": [3001, 5173],',
    '  "expires_at": "<ISO-8601 timestamp <= ' + ttl + ' minutes from now>",',
    '  "written_by": "' + agentId + '",',
    '  "wi_id": "' + wiId + '"',
    '}',
    '```',
    '',
    'Caps the engine enforces: max ' + maxPids + ' PIDs, TTL <= ' + maxTtl + ' minutes, `purpose`/`cwd` <= 500 chars, <= 20 ports. Files outside the cap are dropped (engine reaps as normal) and a warning is logged.',
    '',
    'If you do NOT write the file, the engine will kill ALL of your descendant processes when you exit (today\'s default). Do not write the file unless you are intentionally leaving a process behind for the human or follow-up work.',
    '',
    '### How to spawn long-running processes (CRITICAL on Windows)',
    '',
    'On Windows, child processes spawned with stdio inherited or piped from your agent process die SECONDS after you exit, because the next time they write to stdout the pipe is closed and they get EPIPE. Vite, bun run dev, file watchers, and HMR all log on every event, so they crash within seconds.',
    '',
    'Use this PowerShell pattern -- it gives the child its own log file descriptor (not a pipe) so it survives your exit:',
    '',
    '```powershell',
    '# Adjust $log, $cmd, $args, $cwd for your target.',
    '$log = "D:\\logs\\<service-name>.log"',
    'New-Item -Path (Split-Path $log) -ItemType Directory -Force | Out-Null',
    '$proc = Start-Process -FilePath "bun" -ArgumentList "run","dev" `',
    '  -WorkingDirectory "<absolute-cwd>" `',
    '  -RedirectStandardOutput $log -RedirectStandardError $log `',
    '  -WindowStyle Hidden -PassThru',
    'Write-Host "spawned PID $($proc.Id) -> $log"',
    '```',
    '',
    'For Node.js / bun spawn() callers, use:',
    '',
    '```js',
    'const fs = require(\'fs\');',
    'const { spawn } = require(\'child_process\');',
    'const logFd = fs.openSync(\'D:/logs/<service-name>.log\', \'a\');',
    'const proc = spawn(\'bun\', [\'run\', \'dev\'], {',
    '  cwd: \'<absolute-cwd>\',',
    '  detached: true,',
    '  stdio: [\'ignore\', logFd, logFd],   // file fds, NOT pipes',
    '  windowsHide: true,',
    '});',
    'proc.unref();',
    'console.log(\'spawned PID\', proc.pid);',
    '```',
    '',
    'Key requirements (DO NOT skip any):',
    '1. **stdio MUST be a file descriptor** (`-RedirectStandardOutput` in PowerShell, or `fs.openSync` + numeric fd in Node). NEVER `inherit`, `pipe`, or omit.',
    '2. **`-WindowStyle Hidden`** in PowerShell, **`windowsHide: true`** in Node -- keeps the child off your desktop.',
    '3. **`detached: true` + `proc.unref()`** in Node so the parent can exit without taking the child down.',
    '4. **Capture the spawned PID immediately** -- that\'s what goes into `keep-pids.json`.',
    '5. **Verify it actually bound the port before you exit.** Run `Get-NetTCPConnection -LocalPort <port> -State Listen` (PowerShell) or `netstat -an | findstr :<port>` and confirm the port is listening. If not, read the log file (`Get-Content $log -Tail 100`) to diagnose. Don\'t exit claiming success if the port isn\'t listening.',
    '',
    'What NOT to do:',
    '- `Start-Process bun run dev` (no `-RedirectStandardOutput` -> child inherits your console handle -> dies on exit).',
    '- `bun run dev &` (PowerShell `&` is the call operator, not background -- runs synchronously).',
    '- `Start-Job { bun run dev }` (PS jobs die when the host exits, which is you).',
    '- `child_process.spawn(...)` with default stdio (`pipe`) -- same EPIPE problem.',
    '',
    'If you hit a problem the recipe doesn\'t cover (e.g. Vite needs `VITE_HOST=127.0.0.1` because it\'s defaulting to `::1` and not binding), fix the env in the spawn call (`-Environment` in PowerShell or `env:` in Node spawn opts), don\'t hack around the spawn pattern itself.',
    '',
    'After you write the file, the engine\'s sweep removes it automatically when its TTL fires (it kills the kept PIDs at that point) or when all declared PIDs are already dead. Humans can also kill any kept PID early from the dashboard.',
    '',
    '## Verify before exit (REQUIRED)',
    '',
    'Before you write your completion report, verify the engine accepted your file:',
    '',
    '  curl -s http://localhost:' + dashboardPort + '/api/keep-processes',
    '',
    'Find your entry (`agentId: "' + agentId + '"`) and confirm `valid: true`. If `valid: false`, the `reason` field tells you what to fix (e.g. `pids-missing` means you used a wrong top-level key like `processes` instead of `pids`; `ttl-too-long` means your `expires_at` is too far in the future; `expires_at-missing` means the field is absent or non-string). Rewrite the file with the exact shape above and re-check. If you exit with `valid: false`, the engine will mark the dispatch as ERROR (non-retryable) and your kept PIDs will be reaped.',
    '',
  ];
  return lines.join('\n');
}

module.exports = {
  KEEP_PIDS_FILENAME: KEEP_PIDS_FILENAME,
  INVALID_WORKDIR_REASON_PREFIX: INVALID_WORKDIR_REASON_PREFIX,
  validateKeepPidsRecord: validateKeepPidsRecord,
  readKeepPidsFile: readKeepPidsFile,
  listAllKeepPidsFiles: listAllKeepPidsFiles,
  alivePids: alivePids,
  sweepKeepProcesses: sweepKeepProcesses,
  getActiveAnchorPids: getActiveAnchorPids,
  getActiveAnchorPidsForAgent: getActiveAnchorPidsForAgent,
  computeReapPlan: computeReapPlan,
  buildKeepProcessesHint: buildKeepProcessesHint,
  evaluateKeepPidsAcceptance: evaluateKeepPidsAcceptance,
};
