// Phase F-2-B (B3): subscribe to docker events stream and invalidate
// the secret cache on lifecycle events for known worlds.
//
// M2 ship gate: `docker restart <world>; within 10s, proxy call returns
// 200 not 401`. The 10s budget is dominated by docker-events latency
// (events fire ~1s after the docker daemon emits them) + JSON parse +
// cache invalidate (<100ms). 10s is conservative.
//
// Stream format: Docker sends NDJSON — newline-delimited JSON events.
// Each event has shape:
//   {"Type":"container","Action":"start","Actor":{"Attributes":{"name":"<container-name>"}},...}
// We filter `Type === 'container'` && `Action ∈ INVALIDATING_ACTIONS` and
// extract the worldId from the container name to invalidate the secret cache.
//
// Dogfood incident (2026-05-08): host-cp returned `secret_fetch_failed`
// 502 / `unauthorized 401` after operators ran `docker start <devbox>`
// on previously-exited world containers. Two bugs combined:
//   1. The action filter excluded `start`. After SIGKILL → exit, the
//      operator's `docker start` emits a `start` event (NOT `restart`),
//      which the filter dropped — so the stale cached secret survived.
//   2. The container-name regex was `/^(.+)-devbox$/`, predating the
//      `olam-` prefix added in Phase F-2-D. Even when the filter did
//      fire, it invalidated the wrong cache key (`olam-foo` instead of
//      `foo`), so the actual cache entry stayed.
// Both are fixed below; tests use production naming to prevent drift.

import http from 'node:http';
import { spawn } from 'node:child_process';
import { getDockerRequestOptions } from './lib/docker-request-options.mjs';

/**
 * Container lifecycle events that may change the per-world secret.
 *
 * - `start`  — fresh boot of a previously-exited container; secret is
 *              regenerated by Phase E init, cache MUST drop the old value.
 * - `restart` — implicit stop+start; same secret-regeneration semantics.
 * - `stop` / `die` / `kill` — secret is no longer reachable; invalidating
 *              prevents host-cp from handing out a stale value the moment
 *              `docker start` brings the container back.
 *
 * `pause` / `unpause` are intentionally excluded — those don't change the
 * secret, and invalidating would force an unnecessary docker-exec on
 * resume.
 */
const INVALIDATING_ACTIONS = ['start', 'restart', 'stop', 'die', 'kill'];

/**
 * Subscribe to docker events. Returns a stop function. Auto-reconnects
 * on transient errors (the events stream is long-lived; a daemon
 * restart breaks the connection but the function recovers).
 *
 * @param {object} args
 * @param {string} args.dockerHost      Either `tcp://...` for socket-proxy
 *   mode or the sentinel `'docker-cli'` for bare-node mode (spawns
 *   `docker events --format json` via child_process).
 * @param {(worldId: string) => void} args.onWorldRestart
 *   called when a known world restarts/stops/dies
 * @param {(info: { worldId: string, action: string, exitCode?: number }) => void} [args.onWorldLifecycleEvent]
 *   Additive observer (Killshot #2): fires alongside onWorldRestart with
 *   the raw docker action + exitCode when present. Wired in server.mjs
 *   to map docker actions → WorldLifecyclePhase emissions on host-stream.
 *   Optional + nullable — existing callers (tests, etc.) don't supply it.
 * @param {(message: string) => void} [args.log]
 * @returns {() => void}                stop function
 */
export function subscribeDockerEvents({ dockerHost, onWorldRestart, onWorldLifecycleEvent, log = console.log }) {
  let stopped = false;
  let activeReq = null;
  let activeProc = null;
  let reconnectTimer = null;

  // Bare-node mode: shell out to `docker events --format json` and parse
  // its stdout as NDJSON. Same semantic as the HTTP path; different
  // transport. Eliminates the `tcp://docker-cli` URL-construction crash.
  function connectViaCli() {
    if (stopped) return;
    const filters = ['--filter', 'type=container'];
    log('docker-events: spawning `docker events --format json`');
    const child = spawn(
      'docker',
      ['events', '--format', '{{json .}}', ...filters],
      { stdio: ['ignore', 'pipe', 'pipe'] },
    );
    activeProc = child;
    let buf = '';
    child.stdout.setEncoding('utf-8');
    child.stdout.on('data', (chunk) => {
      buf += chunk;
      let nl;
      while ((nl = buf.indexOf('\n')) !== -1) {
        const line = buf.slice(0, nl);
        buf = buf.slice(nl + 1);
        if (!line.trim()) continue;
        try {
          const event = JSON.parse(line);
          // CLI shape uses `status` instead of HTTP API's `Action`; normalize.
          if (event.status && !event.Action) event.Action = event.status;
          if (event.Type === undefined && event.Type !== 'container') event.Type = 'container';
          handleEvent(event, { onWorldRestart, onWorldLifecycleEvent, log });
        } catch (err) {
          log(`docker-events: parse error on line: ${line.slice(0, 120)} (${err.message})`);
        }
      }
    });
    child.stderr.on('data', (chunk) => {
      const text = String(chunk).trim();
      if (text) log(`docker-events: stderr: ${text}`);
    });
    child.on('exit', (code, signal) => {
      activeProc = null;
      log(`docker-events: child exited code=${code} signal=${signal}; reconnecting`);
      scheduleReconnect();
    });
    child.on('error', (err) => {
      log(`docker-events: spawn error: ${err.message}; reconnecting`);
      scheduleReconnect();
    });
  }

  function connect() {
    if (stopped) return;
    if (dockerHost === 'docker-cli') {
      return connectViaCli();
    }
    // Docker Engine API: GET /events?filters=...
    // Filter: type=container AND event=restart|stop|die
    // (Note: `event` filter takes a JSON-stringified array.)
    //
    // B8 fix (Phase 2 recovery round-2): use getDockerRequestOptions(substrate)
    // instead of constructing a URL from dockerHost. The old code did:
    //   new URL('/events', dockerHost.replace(/^tcp:\/\//, 'http://'))
    // On kubernetes, dockerHost = 'unix:///var/run/docker.sock' — the replace
    // is a no-op, `unix:` is not a valid http URL base, and Node throws
    // ERR_INVALID_URL. The options-spread form routes through socketPath
    // (kubernetes) or host+port (compose), which Node's http module
    // understands natively. No URL construction needed.
    const substrate = dockerHost.startsWith('unix:') ? 'kubernetes' : 'compose';
    const filters = JSON.stringify({
      type: ['container'],
      event: INVALIDATING_ACTIONS,
    });
    const filtersParam = encodeURIComponent(filters);
    const path = `/events?filters=${filtersParam}`;
    const dockerOpts = getDockerRequestOptions(substrate);
    const connLabel = substrate === 'kubernetes'
      ? `unix:${dockerOpts.socketPath}/events`
      : `http://${dockerOpts.host}:${dockerOpts.port}/events`;

    log(`docker-events: connecting to ${connLabel}`);
    activeReq = http.get({ ...dockerOpts, path }, (res) => {
      if (res.statusCode !== 200) {
        log(`docker-events: unexpected status ${res.statusCode}; will retry`);
        scheduleReconnect();
        return;
      }
      let buf = '';
      res.setEncoding('utf-8');
      res.on('data', (chunk) => {
        buf += chunk;
        // NDJSON: split on newlines; last fragment may be partial.
        let nl;
        while ((nl = buf.indexOf('\n')) !== -1) {
          const line = buf.slice(0, nl);
          buf = buf.slice(nl + 1);
          if (!line.trim()) continue;
          try {
            handleEvent(JSON.parse(line), { onWorldRestart, onWorldLifecycleEvent, log });
          } catch (err) {
            log(`docker-events: parse error on line: ${line.slice(0, 120)} (${err.message})`);
          }
        }
      });
      res.on('end', () => {
        log('docker-events: stream closed; reconnecting');
        scheduleReconnect();
      });
      res.on('error', (err) => {
        log(`docker-events: stream error: ${err.message}; reconnecting`);
        scheduleReconnect();
      });
    });
    activeReq.on('error', (err) => {
      log(`docker-events: connect error: ${err.message}; reconnecting`);
      scheduleReconnect();
    });
  }

  function scheduleReconnect() {
    if (stopped) return;
    if (reconnectTimer) return;
    reconnectTimer = setTimeout(() => {
      reconnectTimer = null;
      connect();
    }, 2000); // 2s backoff
  }

  connect();

  return function stop() {
    stopped = true;
    if (reconnectTimer) clearTimeout(reconnectTimer);
    if (activeReq) activeReq.destroy();
    if (activeProc) {
      try { activeProc.kill('SIGTERM'); } catch { /* ignore */ }
      activeProc = null;
    }
  };
}

/**
 * Inspect a docker event and call onWorldRestart if it matches a
 * world container. Container naming convention: `olam-<worldId>-devbox`
 * (per packages/adapters/src/docker/container.ts:67).
 *
 * Exported for unit testing.
 *
 * @param {{ Type?: string, Action?: string, Actor?: { Attributes?: Record<string, string> } }} event
 * @param {{ onWorldRestart: (worldId: string) => void, onWorldLifecycleEvent?: (info: { worldId: string, action: string, exitCode?: number }) => void, log: (m: string) => void }} ctx
 */
export function handleEvent(event, { onWorldRestart, onWorldLifecycleEvent, log }) {
  if (event?.Type !== 'container') return;
  if (!INVALIDATING_ACTIONS.includes(event.Action ?? '')) return;
  const name = event.Actor?.Attributes?.name;
  if (!name) return;
  // Strip leading slash that Docker sometimes prepends to container names.
  const cleanName = name.startsWith('/') ? name.slice(1) : name;
  // Match the production naming `olam-<worldId>-devbox` literally — the
  // `olam-` prefix was added in Phase F-2-D and was not reflected in the
  // pre-fix regex. Anchoring on it also keeps host-cp's own container
  // (`olam-host-cp`) and the docker-socket-proxy out of the cache-invalidate
  // path even though they happen to start with `olam-`.
  const m = /^olam-(.+)-devbox$/.exec(cleanName);
  if (!m) return;
  const worldId = m[1];
  log(`docker-events: ${event.Action} on ${cleanName} → invalidating ${worldId}`);
  onWorldRestart(worldId);

  // Killshot #2 (additive): also notify the lifecycle observer when one
  // is wired. Docker's `die` events carry the container exit code in
  // Actor.Attributes.exitCode as a string; parse it best-effort and
  // forward NaN/missing as undefined so the classifier sees the
  // unambiguous "no exit code observed" signal.
  if (onWorldLifecycleEvent) {
    const action = event.Action ?? '';
    const rawExit = event.Actor?.Attributes?.exitCode;
    const parsed = rawExit !== undefined ? Number(rawExit) : NaN;
    const exitCode = Number.isFinite(parsed) ? parsed : undefined;
    try {
      onWorldLifecycleEvent({ worldId, action, exitCode });
    } catch (err) {
      // The lifecycle observer is best-effort instrumentation; a thrown
      // error here must not break the cache-invalidate hot path.
      log(`docker-events: onWorldLifecycleEvent threw for ${worldId}: ${err.message}`);
    }
  }
}
