// Service enrichment (Phase F-2-D dogfood fix) — extracted from server.mjs.
//
// Fetch port bindings for a world's container via docker-socket-proxy
// inspect, map each to a clickable URL tagged with well-known internal
// ports, and probe each for actual reachability.
//
// Extracted as a standalone module so the probe + enrichment logic can be
// unit-tested in isolation (server.mjs has module-level side effects that
// make direct import impractical). The two host-specific values that the
// inline version read from server.mjs module constants — HOST_FOR_WORLD and
// DOCKER_HOST — are injected as a `deps` object so the functions stay pure
// and deterministically testable.

export const WELL_KNOWN_PORTS = {
  3000: 'atlas-core (Rails)',
  5175: 'diner-app (Vite)',
  7681: 'Terminal (ttyd)',
  7682: 'Terminal Shell (ttyd)',
  8080: 'Per-world CP',
};

/**
 * Quick liveness probe against a service URL. Returns true if the
 * service responds with ANY HTTP response (1xx-5xx) — we don't care
 * about status codes because each app has its own conventions (Vite
 * 200s on /, ttyd may 401, Rails may 500 on /, the per-world CP 200s).
 * What matters is that something is listening.
 *
 * Probed from inside the host-cp container so we use hostForWorld
 * (host.docker.internal on macOS/Windows, 172.17.0.1 on Linux) — the
 * SPA's own 127.0.0.1:<port> URL is unreachable from container-side.
 *
 * Tight 800ms timeout. Worst case: 4 services × 800ms in parallel ≤ 1s
 * added to the /api/worlds response — acceptable for a 4s poll cycle.
 *
 * @param {number} hostPort
 * @param {{ hostForWorld: string }} deps
 * @returns {Promise<boolean>}
 */
export async function probeServiceLive(hostPort, { hostForWorld }) {
  const probeUrl = `http://${hostForWorld}:${hostPort}/`;
  try {
    const res = await fetch(probeUrl, {
      method: 'HEAD',
      signal: AbortSignal.timeout(800),
      redirect: 'manual',
    });
    return res.status > 0;
  } catch {
    // ECONNREFUSED, timeout, DNS — anything counts as not-live. Try
    // GET as a fallback because some servers (e.g. ttyd) close on HEAD
    // and we don't want false negatives from picky upstream behavior.
    try {
      const res2 = await fetch(probeUrl, {
        method: 'GET',
        signal: AbortSignal.timeout(800),
        redirect: 'manual',
      });
      return res2.status > 0;
    } catch {
      return false;
    }
  }
}

/**
 * Get the running container's port bindings from socket-proxy + map
 * each to a clickable URL. Each service is then probed in parallel
 * for actual reachability — the docker port mapping just tells us
 * what's CONFIGURED; the probe confirms what's actually LISTENING.
 *
 * Returns [] on any docker-inspect failure (container missing, socket-
 * proxy down) so the API still returns a valid worlds list.
 *
 * @param {string} worldId
 * @param {{ hostForWorld: string, dockerHost: string }} deps
 * @returns {Promise<Array<{name: string, host_port: number, internal_port: number, url: string, live: boolean}>>}
 */
export async function fetchWorldServices(worldId, { hostForWorld, dockerHost }) {
  const containerName = `olam-${worldId}-devbox`;
  let data;
  try {
    if (dockerHost === 'docker-cli') {
      // Bare-node mode: shell out to `docker inspect` instead of HTTP.
      // Same fix pattern as fetchContainerSecret (PR #108). Without
      // this, the services array is always empty in bare-node and the
      // SPA can't find the ttyd host port → terminal renders blank.
      const { spawnSync } = await import('node:child_process');
      const result = spawnSync(
        'docker',
        ['inspect', containerName],
        { encoding: 'utf-8', timeout: 2000 },
      );
      if (result.status !== 0) return [];
      const arr = JSON.parse(result.stdout || '[]');
      data = Array.isArray(arr) && arr.length > 0 ? arr[0] : null;
      if (!data) return [];
    } else {
      const apiBase = dockerHost.replace(/^tcp:\/\//, 'http://');
      const res = await fetch(`${apiBase}/containers/${encodeURIComponent(containerName)}/json`, {
        signal: AbortSignal.timeout(2000),
      });
      if (!res.ok) return [];
      data = await res.json();
    }
    const ports = data?.NetworkSettings?.Ports ?? {};
    const draft = [];
    for (const [internal, bindings] of Object.entries(ports)) {
      if (!Array.isArray(bindings) || bindings.length === 0) continue;
      const internalPort = parseInt(internal.split('/')[0], 10);
      const hostPort = parseInt(bindings[0].HostPort, 10);
      if (!Number.isFinite(internalPort) || !Number.isFinite(hostPort)) continue;
      draft.push({
        name: WELL_KNOWN_PORTS[internalPort] ?? `App (port ${internalPort})`,
        host_port: hostPort,
        internal_port: internalPort,
        url: `http://127.0.0.1:${hostPort}`,
      });
    }

    // Probe each service in parallel for actual reachability. Adds a
    // `live: boolean` field. The UI dims chips for non-live services
    // so operators can see what's configured-but-down vs configured-
    // and-up at a glance.
    const liveResults = await Promise.all(
      draft.map((s) => probeServiceLive(s.host_port, { hostForWorld })),
    );
    const services = draft.map((s, i) => ({ ...s, live: liveResults[i] }));

    // Stable order: well-known ports first (CP, then Rails/Vite, then terminal).
    services.sort((a, b) => a.internal_port - b.internal_port);
    return services;
  } catch {
    return [];
  }
}
