/**
 * process-poller.mjs — per-world docker top SSE fanout.
 *
 * Dual-mode: HTTP API when DOCKER_HOST != 'docker-cli'; spawnSync otherwise.
 *
 * NOTE: process argv may contain secrets (--api-key=, --token=). Post-v1 audit needed. (S1)
 */

import { spawnSync } from 'node:child_process';

const DOCKER_HOST = process.env.DOCKER_HOST ?? 'docker-cli';

/**
 * @typedef {{ pid: string, user: string, cpu: string, mem: string, started: string, state: string, command: string }} ProcessRow
 */

function worldContainerName(worldId) {
  return `olam-${worldId}-devbox`;
}

/**
 * Parse docker top JSON (Titles + Processes arrays) into normalized rows.
 * Falls back gracefully if the response is not JSON.
 * lstart is stored as a raw string — no Date parse (T1).
 *
 * @param {string} stdout
 * @returns {ProcessRow[]}
 */
function parseDockerTop(stdout) {
  let parsed;
  try {
    parsed = JSON.parse(stdout);
  } catch {
    return [];
  }

  const titles = parsed?.Titles;
  const processes = parsed?.Processes;
  if (!Array.isArray(titles) || !Array.isArray(processes)) return [];

  // Find column indices by title (case-insensitive partial match).
  function idx(name) {
    const n = name.toLowerCase();
    const i = titles.findIndex((t) => typeof t === 'string' && t.toLowerCase().includes(n));
    return i;
  }

  const pidIdx = idx('pid');
  const userIdx = idx('user');
  const cpuIdx = idx('cpu');
  const memIdx = idx('mem');
  // Accept LSTART, STARTED, STIME, or START_TIME (T1: store as raw string)
  const startIdx = (() => {
    for (const candidate of ['lstart', 'stime', 'start_time', 'start']) {
      const i = idx(candidate);
      if (i !== -1) return i;
    }
    return -1;
  })();
  const stateIdx = idx('stat');
  const cmdIdx = (() => {
    // CMD may be titled "CMD", "COMMAND", or "cmd"
    const i = idx('command');
    return i !== -1 ? i : idx('cmd');
  })();

  return processes.map((row) => ({
    pid: pidIdx !== -1 ? String(row[pidIdx] ?? '').trim() : '',
    user: userIdx !== -1 ? String(row[userIdx] ?? '').trim() : '',
    cpu: cpuIdx !== -1 ? String(row[cpuIdx] ?? '').trim() : '0',
    mem: memIdx !== -1 ? String(row[memIdx] ?? '').trim() : '0',
    started: startIdx !== -1 ? String(row[startIdx] ?? '').trim() : '',
    state: stateIdx !== -1 ? String(row[stateIdx] ?? '').trim() : '',
    command: cmdIdx !== -1 ? String(row[cmdIdx] ?? '').trim() : '',
  }));
}

/**
 * Fetch processes for a world container.
 * Returns {ts, processes, error?}.
 * Non-running containers return an empty array + error field (T3).
 *
 * @param {string} worldId
 * @returns {Promise<{ts: number, processes: ProcessRow[], error?: string}>}
 */
async function fetchProcesses(worldId) {
  const containerName = worldContainerName(worldId);
  // Docker's /containers/<name>/top?ps_args=<X> passes ps_args verbatim to
  // ps(1) inside the container. The pre-2026-05-05 form `pid,user,...` was
  // a bare comma-separated list that ps treats as a process-ID *list*, not
  // a column selector — yielding 500 "ps: error: process ID list syntax
  // error" from the Docker API and a misleading "container not running"
  // chip in the SPA. Correct invocation is `ps -eo <cols>` to select all
  // processes (`-e`) and project specific columns (`-o`). Confirmed via
  // host-cp container against olam-dawn-arc-5703-devbox: this form returns
  // 200 with both Titles + Processes arrays, which parseDockerTop expects.
  //
  // Switched lstart → stime to match the CLI path's column choice (line 98)
  // and avoid multi-word timestamp values; the CLI path's split-on-1+ws
  // parser would break on "Mon May 4 14:00:00 2026", and consistency between
  // paths reduces surprise. parseDockerTop accepts either via title match.
  const ps_args = '-eo pid,user,pcpu,pmem,stime,stat,cmd';

  let stdout;
  try {
    if (DOCKER_HOST === 'docker-cli') {
      // Bare-node mode: spawnSync blocks ~50ms at 5s cadence (P2 — acceptable).
      // Use `stime` (single-word start time) instead of `lstart` to avoid
      // multi-word timestamp values that break column-split parsing.
      const result = spawnSync(
        'docker',
        ['top', containerName, 'pid', 'user', 'pcpu', 'pmem', 'stime', 'stat', 'cmd'],
        { encoding: 'utf-8', timeout: 3000 },
      );
      if (result.status !== 0 || result.error) {
        return { ts: Date.now(), processes: [], error: 'container not running' };
      }
      // docker top bare CLI outputs tabular text, not JSON. Wrap it for parseDockerTop.
      stdout = result.stdout ?? '';
      const lines = stdout.trim().split('\n');
      if (lines.length < 1) return { ts: Date.now(), processes: [] };
      // First line is the header row; remaining are process rows.
      // stime is always a single word (e.g. "10:00" or "Feb11"), so splitting
      // on 1+ whitespace is safe.
      const titleFields = lines[0].trim().split(/\s+/);
      const dataRows = lines.slice(1).map((line) => {
        const parts = line.trim().split(/\s+/);
        // CMD may contain spaces — rejoin everything after the 7th token.
        if (parts.length > 7) {
          return [...parts.slice(0, 6), parts.slice(6).join(' ')];
        }
        return parts;
      });
      const wrapped = JSON.stringify({ Titles: titleFields, Processes: dataRows });
      return { ts: Date.now(), processes: parseDockerTop(wrapped) };
    } else {
      // Container mode: Docker HTTP API.
      const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
      const url = `${apiBase}/containers/${encodeURIComponent(containerName)}/top?ps_args=${encodeURIComponent(ps_args)}`;
      const resp = await fetch(url, { signal: AbortSignal.timeout(3000) });
      if (!resp.ok) {
        return { ts: Date.now(), processes: [], error: 'container not running' };
      }
      stdout = await resp.text();
      return { ts: Date.now(), processes: parseDockerTop(stdout) };
    }
  } catch {
    return { ts: Date.now(), processes: [], error: 'container not running' };
  }
}

/**
 * Snapshot — thin wrapper over fetchProcesses.
 *
 * @param {string} worldId
 */
export async function getProcessSnapshot(worldId) {
  return fetchProcesses(worldId);
}

// ── SSE fanout state ─────────────────────────────────────────────────

/**
 * Per-world subscriber registry.
 * @type {Map<string, {pollTimer: ReturnType<typeof setInterval>, heartbeatTimer: ReturnType<typeof setInterval>, subscribers: Set<import('node:http').ServerResponse>}>}
 */
const worldPollers = new Map();

/**
 * Broadcast a payload to all subscribers for a world.
 * @param {string} worldId
 * @param {{ts: number, processes: ProcessRow[], error?: string}} data
 */
function broadcast(worldId, data) {
  const entry = worldPollers.get(worldId);
  if (!entry) return;
  const payload = `event: processes\ndata: ${JSON.stringify(data)}\n\n`;
  for (const res of entry.subscribers) {
    try { res.write(payload); } catch { /* subscriber gone; cleanup fires on close */ }
  }
}

/**
 * Subscribe an SSE response to the world's process stream.
 *
 * SSE headers are written BEFORE adding to the Set (T2: prevents leak if close
 * fires before headers are flushed — the cleanup handler is safe to call even
 * with an empty Set).
 *
 * @param {string} worldId
 * @param {import('node:http').ServerResponse} res
 */
export function subscribeToProcesses(worldId, res) {
  // Write SSE headers synchronously before touching the subscriber Set (T2).
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  });

  let entry = worldPollers.get(worldId);

  if (!entry) {
    // First subscriber — start the poll + heartbeat timers.
    const pollTimer = setInterval(async () => {
      const data = await fetchProcesses(worldId);
      broadcast(worldId, data);
    }, 5000);

    const heartbeatTimer = setInterval(() => {
      const e = worldPollers.get(worldId);
      if (!e) return;
      for (const r of e.subscribers) {
        try { r.write(': heartbeat\n\n'); } catch { /* ignore */ }
      }
    }, 25000);

    entry = { pollTimer, heartbeatTimer, subscribers: new Set() };
    worldPollers.set(worldId, entry);
  }

  entry.subscribers.add(res);

  // Send an immediate first snapshot so the client doesn't wait 5s.
  fetchProcesses(worldId).then((data) => {
    try { res.write(`event: processes\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* gone */ }
  });

  // Cleanup on disconnect — mirrors wireRelease pattern with once-flag.
  let cleaned = false;
  function cleanup() {
    if (cleaned) return;
    cleaned = true;
    const e = worldPollers.get(worldId);
    if (!e) return;
    e.subscribers.delete(res);
    if (e.subscribers.size === 0) {
      clearInterval(e.pollTimer);
      clearInterval(e.heartbeatTimer);
      worldPollers.delete(worldId);
    }
  }

  res.on('close', cleanup);
  res.on('finish', cleanup);
}

// Export parseDockerTop for unit tests.
export { parseDockerTop };
