/**
 * Phase E4 (olam-dogfood-vision): WorldsSource composition + dedup.
 *
 * Runs every configured WorldsSource (E1) in parallel and dedupes by
 * `id`. Source-array order expresses precedence: the LAST source to
 * claim an id wins on collision. server.mjs (E4 wiring via
 * `buildWorldsSources`) orders sources `[localSource, pylonSource]`
 * so cloud-side metadata overrides local stubs when the Pylon SDK
 * eventually returns real data for a world that's also docker-
 * resident locally.
 *
 * The function is intentionally pure + dep-free (no env reads, no
 * http, no module-level state) so vitest can drive it with two mock
 * sources to assert dedup direction without spinning up the server.
 *
 * ## Failure-mode contract (CP3 audit follow-up — closes CRIT/HIGH-1+2)
 *
 * Robustness goals:
 *   1. **One bad source must NOT take down the union.** Pylon SDK
 *      transient outages, auth errors, network blips — these MUST
 *      degrade to "cloud worlds missing this poll" rather than
 *      "/api/worlds endpoint hangs". Achieved via `Promise.allSettled`
 *      + per-source try/log/treat-as-empty.
 *   2. **Slow sources MUST NOT extend wall time past the SPA poll
 *      cadence.** The SPA polls every 4s (Worlds.tsx:124); a Pylon
 *      `client.worlds.list()` that takes 8s would block, queue
 *      sockets, and pile up overlapping fetches. Achieved via
 *      per-source `Promise.race` with `timeoutMs` (default 2000ms,
 *      matching the existing docker-inspect timeout in
 *      fetchWorldServices). A timed-out source is treated as `[]` for
 *      this poll.
 *   3. **A failing source must produce a log line, not a silent
 *      empty.** Operators need to see "[worlds-source] pylon-cloud
 *      list() failed: <err>" in the host-cp boot log so the
 *      degradation is observable.
 *
 * ## Dedup semantics on collision (CP3 audit follow-up — closes HIGH-4)
 *
 * Whole-record replacement (the pre-audit behavior) blanks fields the
 * later source doesn't populate. Concrete example: Pylon returns
 * `{services: undefined}` (or omits the field entirely) for a freshly-
 * claimed world while Local has `{services: [4 entries]}`. Whole-
 * record replacement would drop the local services array; the SPA
 * would render the world with no clickable links until Pylon
 * back-fills.
 *
 * Field-merge (the post-audit behavior): later source's defined
 * fields override earlier; earlier source's fields are preserved
 * where the later source omits them. `id` and `source` always come
 * from the later source (the precedence contract). Implementation:
 * `{ ...byId.get(id), ...world }` — ES spread skips own properties
 * with value `undefined` only if the producer ELIDES them; explicit
 * `field: undefined` does override. Therefore source authors should
 * OMIT fields they don't manage rather than setting them to
 * `undefined` / `[]`.
 *
 * @typedef {import('./worlds-source.mjs').WorldsSource} WorldsSource
 * @typedef {import('./worlds-source.mjs').WorldSummary} WorldSummary
 */

/**
 * @typedef {object} ComposeWorldsSourcesOptions
 * @property {number} [timeoutMs=2000]
 *   Per-source timeout cap. A source whose `list()` doesn't resolve
 *   within this budget is treated as `[]` for this composition pass
 *   (logged at error level). Default matches the docker-inspect
 *   timeout used elsewhere in host-cp so the /api/worlds path's worst-
 *   case wall time stays bounded by it.
 * @property {(sourceName: string, err: unknown) => void} [onSourceError]
 *   Invoked when a source rejects or times out. Defaults to
 *   `console.error('[worlds-source] <name> list() failed:', err)`.
 *   Tests inject a spy to assert log behavior without polluting
 *   stderr.
 */

const DEFAULT_TIMEOUT_MS = 8000;

/**
 * Per-source last-known-good cache. Keyed by source.name → WorldSummary[].
 * When a source resolves successfully, its output is stored here. When a
 * source rejects or times out, we fall back to the cached value so the
 * dashboard shows stale data rather than blanking. Stale data self-heals
 * on the next successful poll.
 *
 * Process-local, no TTL — the running server is authoritative. Tests that
 * need a clean slate should call _resetLastKnownGoodCache().
 *
 * @type {Map<string, import('./worlds-source.mjs').WorldSummary[]>}
 */
const _lastKnownGood = new Map();

/**
 * Wraps a Promise in a per-source timeout race. The timeout error
 * carries the source name so `onSourceError` can log it usefully.
 *
 * @template T
 * @param {Promise<T>} promise
 * @param {number} ms
 * @param {string} sourceName
 * @returns {Promise<T>}
 */
function withTimeout(promise, ms, sourceName) {
  /** @type {ReturnType<typeof setTimeout> | null} */
  let timer = null;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => {
      reject(new Error(`source "${sourceName}" timed out after ${ms}ms`));
    }, ms);
  });
  return Promise.race([promise, timeout]).finally(() => {
    if (timer !== null) clearTimeout(timer);
  });
}

/**
 * Reset the last-known-good cache. Exposed for tests only — call before
 * each test that needs a clean slate.
 */
export function _resetLastKnownGoodCache() {
  _lastKnownGood.clear();
}

/**
 * @param {WorldsSource[]} sources
 *   Sources to compose. Order expresses precedence: later wins.
 * @param {ComposeWorldsSourcesOptions} [options]
 * @returns {Promise<WorldSummary[]>}
 *   Deduped union of every source's `list()` output, keyed by `id`.
 *   On collision: fields from later source override earlier where
 *   defined; earlier fields preserved where later source omits them.
 */
export async function composeWorldsSources(sources, options = {}) {
  if (sources.length === 0) return [];
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const onSourceError =
    options.onSourceError ??
    ((name, err) => {
      console.error(`[worlds-source] ${name} list() failed:`, err);
    });

  const settled = await Promise.allSettled(
    sources.map((s) => withTimeout(s.list(), timeoutMs, s.name)),
  );

  /** @type {Map<string, WorldSummary>} */
  const byId = new Map();
  for (let i = 0; i < settled.length; i++) {
    const result = settled[i];
    const source = sources[i];
    let resolved;
    if (result.status === 'rejected') {
      onSourceError(source.name, result.reason);
      const lkg = _lastKnownGood.get(source.name);
      if (!lkg) continue;
      resolved = lkg;
    } else {
      resolved = result.value;
      _lastKnownGood.set(source.name, result.value);
    }
    for (const world of resolved) {
      // Field-merge on collision: later source overrides earlier
      // where defined; earlier preserved where later omits. Keeps
      // local service-strip + host_port intact when Pylon claims a
      // world but hasn't populated those fields yet.
      const prior = byId.get(world.id);
      byId.set(world.id, prior ? { ...prior, ...world } : world);
    }
  }
  return [...byId.values()];
}
