// Phase F-2-B (B3): host CP HTTP proxy.
//
// Rewrites incoming requests under `/api/world/<id>/<route...>` to the
// per-world CP at `<perWorldBase>/<route...>` with `X-Olam-Secret`
// injected server-side.
//
// Pattern lifted from `packages/cloudflare-worker/src/index.ts:462-551`
// (`proxyContainer`). CF Worker uses Workers' `fetch()`; host CP uses
// Node's `http.request` so SSE streams flow byte-for-byte without
// buffering. Verbatim passthrough on /hooks/* and /api/auth/* (D8) is
// implemented in B4 (this module is JSON-API-only — B4 wraps).

import http from 'node:http';

/**
 * Default upstream-request timeout for proxied per-world CP calls. SSE
 * streams (`/api/stream`, `/hooks/*` long-poll) MUST opt out — they
 * intentionally hold the socket open. Everything else should respond
 * within a few seconds; if the per-world CP wedges (slow sqlite,
 * tmux command stuck, long docker exec), this prevents the host-cp
 * connection from hanging until the OS RSTs it. The browser sees a
 * clean 504 instead of Safari's TypeError "Load failed", and useLanes /
 * useReadiness can retry on a known status code.
 *
 * 10s matches the longest legitimate handler we've measured (cold
 * sqlite open + readiness query) with headroom.
 *
 * @internal exported for test override
 */
export const DEFAULT_PROXY_TIMEOUT_MS = 10_000;

/**
 * Parse `/api/world/<id>/<route...>` from a request path. Returns
 * `{ worldId, subPath }` or null if the path doesn't match.
 *
 * Anchored at `^/api/world/` to prevent prefix-matching from /api/worlds
 * (the worlds-list endpoint, plural). Empty world IDs do not match.
 *
 * @param {string} path
 * @returns {{ worldId: string, subPath: string } | null}
 */
export function parseProxyPath(path) {
  const m = /^\/api\/world\/([^/?#]+)(\/.*|\?.*|#.*)?$/.exec(path);
  if (!m) return null;
  return {
    worldId: m[1],
    subPath: m[2] ?? '/',
  };
}

/**
 * Compute the per-world CP's base URL from a worldId. Today the world
 * registry stores port offsets; the canonical port is `19080 + offset`.
 * For B3, accept the port directly (deferring worlds.db integration to
 * B6/B10). The caller (server.mjs) resolves worldId → port via worlds.db
 * and passes the port here.
 *
 * In Docker Compose mode, host-cp is in its own network and reaches
 * world CPs via `host.docker.internal:<port>` (compose.yaml's
 * extra_hosts: host-gateway). On Docker Desktop this is automatic;
 * on Linux it requires the `host-gateway` extra-host directive.
 *
 * @param {number} port  per-world CP host port (e.g., 20780)
 * @param {string} [host]  optional hostname override (default 'host.docker.internal')
 * @returns {string}
 */
export function perWorldBase(port, host = 'host.docker.internal') { // bare-node-allow: container-mode default; bare callers pass WORLD_HOST explicitly (server.mjs)
  return `http://${host}:${port}`;
}

/**
 * SSE / long-poll paths whose handlers intentionally hold the socket
 * open. These MUST be exempt from the upstream timeout — applying it
 * would kill the stream every 10s. Caller can override per-request via
 * `streaming: true`.
 *
 * @param {string} subPath
 * @returns {boolean}
 */
function isStreamingPath(subPath) {
  // Strip query string before matching.
  const p = subPath.split('?')[0];
  return (
    p === '/api/stream' ||
    p.endsWith('/api/stream') ||
    p.startsWith('/hooks/') ||
    p === '/hooks' ||
    /^\/api\/auth\/events(\/|$)/.test(p)
  );
}

/**
 * Proxy an incoming request to a per-world CP, injecting X-Olam-Secret.
 *
 * Forwards: method, path (subPath), body bytes, ALL request headers
 * EXCEPT `host` (rewritten) and `x-olam-secret` (overwritten with the
 * injected secret to prevent client spoofing).
 *
 * Returns: status code, ALL response headers (verbatim — D8 contract
 * forwards Set-Cookie, Location, etc. unchanged), body bytes streamed
 * via Node's http.IncomingMessage→ServerResponse pipe (no buffering).
 *
 * Upstream timeout: short-request handlers (≠ SSE) get an upstream
 * socket timeout of `timeoutMs` (defaults to DEFAULT_PROXY_TIMEOUT_MS).
 * On expiry we abort the upstream socket and respond 504 — this
 * converts a wedged per-world CP into a deterministic status code
 * instead of a TCP RST that Safari surfaces as `TypeError: Load
 * failed`. Pass `streaming: true` (or hit a path matching
 * `isStreamingPath`) to opt out.
 *
 * @param {object} args
 * @param {import('node:http').IncomingMessage} args.req
 * @param {import('node:http').ServerResponse} args.res
 * @param {string} args.subPath              e.g., '/api/world' or '/api/stream'
 * @param {string} args.targetBase           e.g., 'http://host.docker.internal:20780'
 * @param {string} args.secret               the X-Olam-Secret value
 * @param {(message: string) => void} [args.log]
 * @param {number} [args.timeoutMs]          per-request upstream timeout; ignored for streams
 * @param {boolean} [args.streaming]         force SSE/long-poll mode (skip timeout)
 */
export function proxyToWorld({
  req,
  res,
  subPath,
  targetBase,
  secret,
  log = console.log,
  timeoutMs = DEFAULT_PROXY_TIMEOUT_MS,
  streaming = false,
}) {
  const target = new URL(subPath, targetBase);
  const isStream = streaming || isStreamingPath(subPath);

  // Build outbound headers. Filter `host` (Node will set from URL) +
  // overwrite `x-olam-secret` (defense against client spoofing).
  /** @type {Record<string, string | string[]>} */
  const outHeaders = {};
  for (const [k, v] of Object.entries(req.headers)) {
    if (v === undefined) continue;
    const lower = k.toLowerCase();
    if (lower === 'host' || lower === 'x-olam-secret') continue;
    outHeaders[k] = v;
  }
  outHeaders['x-olam-secret'] = secret;
  outHeaders['x-forwarded-by'] = 'olam-host-cp';

  const upstreamReq = http.request(
    target,
    {
      method: req.method ?? 'GET',
      headers: outHeaders,
    },
    (upstreamRes) => {
      // Once headers come back from upstream, the request is no longer
      // "stuck" — clear the timeout so a slow stream-of-body doesn't
      // get killed mid-flight. Streaming handlers that intentionally
      // delay between writes still rely on the no-timeout path.
      if (timer !== null) {
        clearTimeout(timer);
        timer = null;
      }
      // Verbatim passthrough: status + ALL headers + body bytes.
      // Use res.writeHead so the headers go out atomically with the
      // status line (response.statusCode + setHeader split would race
      // on early body write). statusMessage may be undefined on some
      // upstream paths — fall back to the default.
      res.writeHead(
        upstreamRes.statusCode ?? 502,
        upstreamRes.statusMessage,
        upstreamRes.headers,
      );
      upstreamRes.pipe(res);
    },
  );

  /** @type {ReturnType<typeof setTimeout> | null} */
  let timer = null;
  if (!isStream && timeoutMs > 0) {
    timer = setTimeout(() => {
      timer = null;
      log(`proxy: upstream timeout (${timeoutMs}ms) for ${target}`);
      // Destroying the upstream req triggers the 'error' handler with
      // a generic socket error; we pre-empt it with an explicit 504
      // first so the client sees a clean status instead of the generic
      // 502 the error handler would emit.
      if (!res.headersSent) {
        res.writeHead(504, { 'Content-Type': 'application/json; charset=utf-8' });
        res.end(JSON.stringify({
          error: 'upstream_timeout',
          message: `per-world CP did not respond within ${timeoutMs}ms`,
          worldUrl: target.origin,
        }));
      } else {
        res.end();
      }
      try {
        upstreamReq.destroy(new Error('proxy upstream timeout'));
      } catch {
        // already destroyed
      }
    }, timeoutMs);
  }

  // Upstream connection error — don't leak internals to the client.
  upstreamReq.on('error', (err) => {
    if (timer !== null) {
      clearTimeout(timer);
      timer = null;
    }
    log(`proxy: upstream error for ${target}: ${err.message}`);
    if (!res.headersSent) {
      res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
      res.end(JSON.stringify({
        error: 'upstream_unreachable',
        message: 'per-world CP did not respond',
        worldUrl: target.origin,
      }));
    } else {
      // Response already started (likely SSE); just close.
      res.end();
    }
  });

  // Client closed early (browser navigated away, Safari unloaded the
  // EventSource, etc.). Tear down the upstream so we don't keep an
  // open socket to the per-world CP for an answer the caller no longer
  // wants. Without this, host-cp leaks sockets per cancelled poll.
  res.on('close', () => {
    if (timer !== null) {
      clearTimeout(timer);
      timer = null;
    }
    if (!upstreamReq.destroyed) {
      try {
        upstreamReq.destroy();
      } catch {
        // already gone
      }
    }
  });

  // Pipe request body. For GET/HEAD this is a no-op (no body bytes);
  // for POST/PUT/PATCH this streams the body upstream.
  req.pipe(upstreamReq);
}
