// Phase A → E (sse-consolidation): server-side multiplexed-SSE broadcaster.
//
// Single endpoint /api/host-stream replaces ~20 SPA polling loops. Hooks
// subscribe to typed events on one connection instead of opening one
// setInterval-loop per resource.
//
// Mirrors planOrchestrator.addEventSink fanout pattern verbatim — same
// per-sink ServerResponse Set, same `event: <name>\ndata: <json>\n\n`
// wire format, same cleanup-on-disconnect contract. Differences:
//
//   - Keyed by event TYPE rather than conversationId (the broadcaster is
//     global to the host-cp, not per-conversation).
//   - Caches last-known payload per event type so reconnecting clients
//     receive an immediate snapshot replay before live updates resume.
//   - No turn-buffering — snapshots are idempotent so reconnect == latest.
//
// Phase E adds operational polish:
//   - E1: per-event-type trailing-edge debounce (default 100ms).
//         Coalesces broadcast storms during world boot.
//   - E2: per-sink 25s heartbeat (`:\n\n` comment) to keep idle SSE
//         connections alive across most proxy 60s timeouts.
//   - E3: backpressure-aware writes — slow sinks queue up to a bounded
//         in-memory buffer; overflow drops oldest events with an
//         `:overflow` comment so consumers know they missed updates.
//   - E4: per-event-type broadcast counter + sink count metric line.
//   - E5: the metrics tick ALSO broadcasts a `stream.health` typed event
//         carrying the same counters it logs, so any SPA tab can observe
//         live stream health (sink count, per-event broadcast rates,
//         overflow drops) without polling. Snapshot-cached like every
//         other state event — reconnecting clients replay the last
//         health payload immediately (first-paint parity). Opt out via
//         `deps.healthEvents = false`.
//
// Pure module: no docker, no DB, no global clock except `setInterval`
// for the heartbeat/metrics timers (injectable in tests). Wiring those
// sources to broadcast(...) lives in server.mjs (A4 + A5).
//
// References:
//   - packages/host-cp/src/server.mjs:1531  SSE writer template
//   - packages/host-cp/src/plan-orchestrator.mjs:967  addEventSink shape
//   - docs/plans/sse-consolidation/plan-source.md  full design
//   - docs/plans/sse-consolidation/phase-e-tasks.md  E1-E4 acceptance

import crypto from 'node:crypto';

/**
 * @typedef {object} HostStreamDeps
 * @property {(message: string) => void} [log]                  defaults to no-op
 * @property {object}                    [debounceMs]          per-event-type debounce override
 * @property {number}                    [debounceMs.default]  default trailing-edge ms (Phase E1)
 * @property {number}                    [heartbeatMs]         per-sink heartbeat interval (Phase E2)
 * @property {number}                    [metricsMs]           per-broadcaster metrics tick (Phase E4)
 * @property {boolean}                   [healthEvents]        broadcast `stream.health` on each metrics tick (Phase E5; default true)
 * @property {number}                    [maxQueuedPerSink]    bounded queue size (Phase E3)
 * @property {() => number}              [now]                 injectable clock for `stream.health.at` (tests)
 * @property {(cb: () => void, ms: number) => any} [setTimer]  injectable setInterval (tests)
 * @property {(handle: any) => void}     [clearTimer]          injectable clearInterval (tests)
 */

/**
 * @typedef {object} HostStream
 * @property {(res: import('node:http').ServerResponse) => () => void} addSink
 * @property {(eventType: string, payload: unknown) => number}         broadcast
 * @property {() => Record<string, unknown>}                            snapshot
 * @property {() => void}                                               close
 * @property {() => number}                                             sinkCount
 * @property {() => HostStreamMetrics}                                  metrics
 * @property {() => void}                                               flushDebounced  test-only — fire all pending coalesced broadcasts immediately
 */

/**
 * @typedef {object} HostStreamMetrics
 * @property {Record<string, number>} events   per-event-type broadcasts since last reset
 * @property {number}                 sinks    current active-sink count
 * @property {number}                 overflows total `:overflow` drops since last reset
 */

/**
 * Payload wire-shape for the `stream.health` event (Phase E5). A
 * point-in-time projection of the broadcaster's own observability
 * counters, emitted on each metrics tick. `events` carries the
 * per-event-type broadcast counts accrued during the just-elapsed
 * interval (reset afterward), so consumers see a per-interval RATE
 * rather than a monotonic total. `at` is the wall-clock emit time so a
 * reconnecting client can tell how stale the replayed snapshot is.
 *
 * @typedef {object} StreamHealthPayload
 * @property {Record<string, number>} events     per-event broadcasts during the interval
 * @property {number}                 sinks      active-sink count at emit time
 * @property {number}                 overflows  `:overflow` drops during the interval
 * @property {number}                 intervalMs the metrics-tick cadence that produced this payload
 * @property {number}                 at         Date.now() at emit time
 */

/** Event type emitted by the metrics tick (Phase E5). */
export const STREAM_HEALTH_EVENT = 'stream.health';

const DEFAULT_DEBOUNCE_MS = 100;
const DEFAULT_HEARTBEAT_MS = 25_000;
const DEFAULT_METRICS_MS = 60_000;
const DEFAULT_MAX_QUEUED = 64;

/**
 * Event types that opt INTO the trailing-edge debounce (Phase E1). The
 * default callers — `world.snapshot`, `tunnels.snapshot`, `servers.snapshot`,
 * `listening.snapshot` — are all idempotent state-replay events where
 * "last writer wins" is correct and a 100ms cap on update propagation
 * is acceptable. Latency-sensitive events (`question.pending`) and
 * connect-only events (`ready`) stay immediate by NOT being in this set.
 *
 * Per-event-type overrides via `deps.debounceMs[type] = 0` force any
 * event off the debounce path; non-zero override flips it on with a
 * custom window. Callers should not need to opt anything new into
 * debouncing — adding a new snapshot event implies adding to this set.
 */
const DEFAULT_DEBOUNCED_EVENTS = new Set([
  'world.snapshot',
  'tunnels.snapshot',
  'servers.snapshot',
  'listening.snapshot',
]);

/**
 * Create a host-stream broadcaster. Stateless w.r.t. the request — all
 * source-of-truth wiring (docker events, worlds.db, etc.) is done by
 * the caller via repeated `broadcast()` invocations.
 *
 * @param {HostStreamDeps} [deps]
 * @returns {HostStream}
 */
export function createHostStream(deps = {}) {
  const log = deps.log ?? (() => {});
  const defaultDebounceMs = deps.debounceMs?.default ?? DEFAULT_DEBOUNCE_MS;
  const heartbeatMs = deps.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
  const metricsMs = deps.metricsMs ?? DEFAULT_METRICS_MS;
  const healthEvents = deps.healthEvents ?? true;
  const now = deps.now ?? (() => Date.now());
  const maxQueuedPerSink = deps.maxQueuedPerSink ?? DEFAULT_MAX_QUEUED;
  const setTimer = deps.setTimer ?? ((cb, ms) => setInterval(cb, ms));
  const clearTimer = deps.clearTimer ?? ((h) => clearInterval(h));

  /**
   * @typedef {object} SinkState
   * @property {import('node:http').ServerResponse} res
   * @property {string[]}              queue
   * @property {boolean}               paused   true while waiting for a `drain` event
   * @property {boolean}               draining true while flushQueue is iterating
   * @property {boolean}               drainListenerAttached
   * @property {any | null}            heartbeatHandle
   * @property {number}                overflows
   */

  /** @type {Map<import('node:http').ServerResponse, SinkState>} */
  const sinks = new Map();

  /** @type {Map<string, unknown>} last-known payload per event type */
  const snapshots = new Map();

  /** @type {Map<string, any>} pending debounce timers per event type */
  const debounceTimers = new Map();

  /** Per-event-type broadcast counters since last metrics flush. */
  const eventCounters = new Map();
  let overflowCounter = 0;

  let closed = false;
  let metricsHandle = null;

  function formatEvent(eventType, payload) {
    return `event: ${eventType}\ndata: ${JSON.stringify(payload)}\n\n`;
  }

  /**
   * Queue-aware write. If the underlying socket's `res.write` returns
   * `false` we buffer the chunk in the per-sink queue and register a
   * one-shot `drain` listener to flush it when the kernel reports the
   * socket is writable again. On overflow we emit `:overflow` so
   * consumers know they missed updates and drop oldest.
   *
   * @returns {boolean} true if the chunk was accepted (synchronously or
   *   queued) — false only when the sink is dead and was removed.
   */
  function writeSafe(state, chunk) {
    const { res } = state;
    if (res.writableEnded || res.destroyed) return false;

    // If a previous write reported backpressure (returned false), queue
    // unconditionally — preserves event ordering. The drain handler
    // flushes the queue in FIFO order.
    if (state.paused) {
      enqueue(state, chunk);
      return true;
    }

    try {
      const ok = res.write(chunk);
      if (ok) return true;
      // Returned false — kernel buffer is full. Switch to queue mode so
      // subsequent writes don't race past this one.
      state.paused = true;
      attachDrain(state);
      return true;
    } catch {
      // Sink already closed — drop it; further writes would throw.
      teardownSink(res);
      return false;
    }
  }

  function enqueue(state, chunk) {
    if (state.queue.length >= maxQueuedPerSink) {
      // Drop oldest, emit :overflow comment when the drain eventually
      // flushes. The overflow comment is enqueued (not written directly)
      // so consumers see it inline with surrounding events.
      state.queue.shift();
      state.overflows += 1;
      overflowCounter += 1;
      if (!state.queue.some((s) => s === ':overflow\n\n')) {
        state.queue.unshift(':overflow\n\n');
      }
    }
    state.queue.push(chunk);
    attachDrain(state);
  }

  function attachDrain(state) {
    if (state.drainListenerAttached) return;
    const { res } = state;
    if (typeof res.once !== 'function') return; // testing-sink fallback
    state.drainListenerAttached = true;
    res.once('drain', () => {
      state.drainListenerAttached = false;
      flushQueue(state);
    });
  }

  function flushQueue(state) {
    const { res } = state;
    if (state.draining) return;
    state.draining = true;
    state.paused = false;
    try {
      while (state.queue.length > 0) {
        if (res.writableEnded || res.destroyed) {
          state.queue.length = 0;
          break;
        }
        const next = state.queue[0];
        let ok = false;
        try {
          ok = res.write(next);
        } catch {
          teardownSink(res);
          return;
        }
        state.queue.shift();
        if (!ok) {
          state.paused = true;
          attachDrain(state);
          break;
        }
      }
    } finally {
      state.draining = false;
    }
  }

  function teardownSink(res) {
    const state = sinks.get(res);
    if (!state) return;
    if (state.heartbeatHandle) {
      try { clearTimer(state.heartbeatHandle); } catch { /* ignore */ }
      state.heartbeatHandle = null;
    }
    state.queue.length = 0;
    sinks.delete(res);
  }

  function doBroadcast(eventType, payload) {
    if (closed) return 0;
    snapshots.set(eventType, payload);
    eventCounters.set(eventType, (eventCounters.get(eventType) ?? 0) + 1);
    const chunk = formatEvent(eventType, payload);
    let reached = 0;
    // Snapshot the iteration order so concurrent sink removal during
    // a write doesn't skip a sibling sink.
    for (const state of [...sinks.values()]) {
      if (writeSafe(state, chunk)) reached += 1;
    }
    return reached;
  }

  function flushDebounced() {
    for (const [type, info] of debounceTimers) {
      clearTimeout(info.handle);
      debounceTimers.delete(type);
      doBroadcast(type, info.payload);
    }
  }

  function logMetrics() {
    if (eventCounters.size === 0 && sinks.size === 0 && overflowCounter === 0) return;
    /** @type {Record<string, number>} */
    const events = {};
    for (const [type, count] of eventCounters) events[type] = count;
    log(`events=${JSON.stringify(events)} sinks=${sinks.size}${overflowCounter > 0 ? ` overflows=${overflowCounter}` : ''}`);

    // Phase E5: broadcast the same counters as a typed `stream.health`
    // event so SPA tabs can observe live stream health without polling.
    // Built from the interval's counters BEFORE the reset below, so the
    // payload is a per-interval rate. The broadcast itself bumps the
    // `stream.health` counter, but the immediately-following reset wipes
    // it — the next interval never double-counts this tick's own emit.
    // Bypasses debounce (immediate path) since each tick is already
    // rate-limited to the metrics cadence.
    if (healthEvents) {
      /** @type {StreamHealthPayload} */
      const payload = {
        events,
        sinks: sinks.size,
        overflows: overflowCounter,
        intervalMs: metricsMs,
        at: now(),
      };
      doBroadcast(STREAM_HEALTH_EVENT, payload);
    }

    eventCounters.clear();
    overflowCounter = 0;
  }

  // Start the metrics tick eagerly — operators want visibility from
  // boot, not just after the first event lands.
  if (metricsMs > 0) {
    metricsHandle = setTimer(logMetrics, metricsMs);
    // Don't pin the event loop just for metrics in tests / shutdown paths.
    if (metricsHandle && typeof metricsHandle.unref === 'function') metricsHandle.unref();
  }

  return {
    addSink(res) {
      if (closed) {
        // Best-effort: end the response so the client sees the channel
        // closing instead of hanging on an empty stream.
        try { res.end(); } catch { /* ignore */ }
        return () => {};
      }

      const state = /** @type {SinkState} */ ({
        res,
        queue: [],
        paused: false,
        draining: false,
        drainListenerAttached: false,
        heartbeatHandle: null,
        overflows: 0,
      });
      sinks.set(res, state);

      // Replay last-known snapshot for every event type so the new
      // subscriber gets current state without waiting for the next change.
      // Sorting keeps test assertions deterministic.
      const types = [...snapshots.keys()].sort();
      for (const type of types) {
        writeSafe(state, formatEvent(type, snapshots.get(type)));
      }

      // Phase E2: per-sink heartbeat. Write a comment line every
      // `heartbeatMs` so the SSE channel survives idle proxies. The
      // comment is invisible to client EventSource listeners (the
      // browser passes only `event:`/`data:` lines through), so this
      // does NOT trigger any handler — it's pure connection-keepalive.
      if (heartbeatMs > 0) {
        state.heartbeatHandle = setTimer(() => {
          // Use writeSafe so backpressure / overflow handling applies
          // uniformly. Heartbeats that fail to flush are uninteresting
          // — the regular broadcast loop will discover the dead sink.
          writeSafe(state, ':\n\n');
        }, heartbeatMs);
        if (state.heartbeatHandle && typeof state.heartbeatHandle.unref === 'function') {
          state.heartbeatHandle.unref();
        }
      }

      return () => {
        teardownSink(res);
      };
    },

    broadcast(eventType, payload) {
      if (closed) return 0;
      if (typeof eventType !== 'string' || eventType.length === 0) {
        throw new TypeError('broadcast: eventType must be a non-empty string');
      }

      // Phase E1: opt-in trailing-edge debounce.
      //   - DEFAULT_DEBOUNCED_EVENTS opts the canonical snapshot events
      //     into trailing-edge coalescing. Last writer wins because those
      //     events are idempotent state replays.
      //   - Every other event type bypasses the timer and writes
      //     immediately — preserves the Phase A synchronous broadcast
      //     contract that existing tests / consumers depend on.
      //   - Per-event-type overrides via `deps.debounceMs[eventType]`
      //     win in both directions (set to 0 to disable, or specify a
      //     custom window).
      //   - `flushDebounced()` is exposed for tests that want to assert
      //     immediate effects without waiting for the timer.
      let debounceFor;
      const override = deps.debounceMs?.[eventType];
      if (override !== undefined) {
        debounceFor = override;
      } else if (DEFAULT_DEBOUNCED_EVENTS.has(eventType)) {
        debounceFor = defaultDebounceMs;
      } else {
        debounceFor = 0;
      }

      if (debounceFor <= 0) {
        // Take the immediate path; flush any pending coalesce for this
        // type first so order is preserved.
        const pending = debounceTimers.get(eventType);
        if (pending) {
          clearTimeout(pending.handle);
          debounceTimers.delete(eventType);
        }
        return doBroadcast(eventType, payload);
      }

      // Coalesce: keep the latest payload, restart the trailing timer.
      const pending = debounceTimers.get(eventType);
      if (pending) clearTimeout(pending.handle);
      const handle = setTimeout(() => {
        debounceTimers.delete(eventType);
        doBroadcast(eventType, payload);
      }, debounceFor);
      if (typeof handle.unref === 'function') handle.unref();
      debounceTimers.set(eventType, { handle, payload });
      // Returns sinks.size as an approximation; the actual broadcast
      // will happen after the trailing-edge delay. Tests assert via the
      // sink writes anyway.
      return sinks.size;
    },

    snapshot() {
      /** @type {Record<string, unknown>} */
      const out = {};
      for (const [type, payload] of snapshots) out[type] = payload;
      return out;
    },

    close() {
      if (closed) return;
      closed = true;
      // Cancel pending debounce timers — anything still queued is
      // discarded; we don't write to sinks during shutdown.
      for (const [, info] of debounceTimers) clearTimeout(info.handle);
      debounceTimers.clear();
      if (metricsHandle) {
        try { clearTimer(metricsHandle); } catch { /* ignore */ }
        metricsHandle = null;
      }
      for (const [res, state] of [...sinks.entries()]) {
        if (state.heartbeatHandle) {
          try { clearTimer(state.heartbeatHandle); } catch { /* ignore */ }
        }
        try { res.end(); } catch { /* ignore */ }
        sinks.delete(res);
      }
      log('closed');
    },

    sinkCount() {
      return sinks.size;
    },

    metrics() {
      /** @type {Record<string, number>} */
      const events = {};
      for (const [type, count] of eventCounters) events[type] = count;
      return { events, sinks: sinks.size, overflows: overflowCounter };
    },

    flushDebounced,
  };
}

/**
 * Generate a fresh streamId for the `ready` event payload. Exposed so
 * route handlers can attach the same id to log lines and the wire.
 *
 * @returns {string}
 */
export function newStreamId() {
  return crypto.randomBytes(8).toString('hex');
}
