/**
 * The multi-app **gateway** — serves several WrNexus apps behind one port and
 * routes each request to the right app by its `Host` header (domain). This is how
 * a monorepo becomes a multi-domain SaaS: `app-a.com` → apps/a, `app-b.com` → apps/b.
 *
 * Each app runs as its own **process** (full isolation — its own database
 * registry, pubsub, in-memory state), and the gateway is a thin host-based
 * reverse proxy for both HTTP and WebSocket. Apps talk to each other at runtime
 * via @wrnexus/pubsub (use the Redis driver so messages cross processes).
 */

import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX, RPC_STREAM_PATH_PREFIX } from "@wrnexus/rpc";
import { RESTART_EXIT_CODE } from "./restart.ts";

export type GatewayForwardAuth = (
  | {
      url: string;
      app?: never;
      path?: never;
    }
  | {
      app: string;
      path?: string;
      url?: never;
    }
) & {
  headers?: string[];
};

export interface GatewayAuth {
  basic?: { user: string; pass: string } | Array<{ user: string; pass: string }>;

  allowIps?: string[];

  forward?: GatewayForwardAuth;
}

export interface GatewayApp {
  /** App id (for logs). */
  name: string;
  /** Path to the app root (the dir containing `app/` and wrnexus.config.ts). */
  dir: string;
  /** Host names routed to this app (e.g. ["localhost", "web.localhost"]). */
  domains: string[];
  publicOrigin?: string;
  /** Optional fixed internal port; otherwise assigned from the gateway port. */
  port?: number;
  /** Access control enforced at the edge for this app. */
  auth?: GatewayAuth;
}

/** Gateway-wide security controls, enforced for every app. */
export interface GatewayRequestLimits {
  maxUrlLength?: number;
  maxHeaderCount?: number;
  maxHeaderBytes?: number;
  maxQueryParameters?: number;
  maxBodyBytes?: number;
  timeoutMs?: number;
  maxConcurrent?: number;
  fetchMetadata?: boolean;
}

export interface GatewayWebSocketSecurity {
  maxMessageBytes?: number;
  maxQueuedMessages?: number;
  allowedOrigins?: string[];
}

export interface GatewaySecurity {
  /** Reject requests whose Host matches no app (404) instead of routing to the first. */
  trustedHostsOnly?: boolean;
  /** Global rate limit by client IP (429 over the limit). */
  rateLimit?: { max: number; windowMs?: number };
  /** Add baseline security headers to responses (only where the app didn't set them). */
  headers?: boolean;
  /** Set X-Forwarded-For/Host/Proto so apps see the real client. Default true. */
  forwardedHeaders?: boolean;
  /** Log each request (host → app, method, path, status). */
  accessLog?: boolean;
  /** URL, header, body, timeout, concurrency, and Fetch Metadata limits. */
  requestLimits?: GatewayRequestLimits;
  /** WebSocket origin, payload, and pre-connect queue limits. */
  websocket?: GatewayWebSocketSecurity;
}

export interface GatewayOptions {
  port?: number;
  hostname?: string;
  mode?: "development" | "production";
  environment?: string;
  hmr?: boolean;
  apps: GatewayApp[];
  security?: GatewaySecurity;
}

export interface RunningGateway {
  port: number;
  url: string;
  stop(): void;
}

interface Target extends GatewayApp {
  port: number;
  origin: string;
  child?: ChildProcess;
}

interface WsBridge {
  origin: string;
  path: string;
  backend?: WebSocket;
  queue: Array<string | ArrayBuffer>;
  maxMessageBytes: number;
  maxQueuedMessages: number;
}

/** Decide whether a gateway child should be relaunched after it exits. */
export function gatewayRestartDelay(
  mode: "development" | "production",
  code: number | null,
  signal: NodeJS.Signals | null,
): number | null {
  if (mode !== "development" || signal) return null;
  if (code === RESTART_EXIT_CODE) return 0;
  if (code && code !== 0) return 1200;
  return null;
}

/** Fixed-window rate limiter keyed by client IP. */
function makeRateLimiter(max: number, windowMs: number) {
  const hits = new Map<string, { count: number; reset: number }>();
  return (ip: string, now: number): boolean => {
    const b = hits.get(ip);
    if (!b || now >= b.reset) {
      hits.set(ip, { count: 1, reset: now + windowMs });
      return true;
    }
    b.count++;
    return b.count <= max;
  };
}

function requestHeaderBytes(headers: Headers): number {
  let total = 0;
  headers.forEach((value, name) => {
    total += name.length + value.length + 4;
  });
  return total;
}

function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): number {
  if (typeof value === "string") return new TextEncoder().encode(value).byteLength;
  return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
}

function gatewayWebSocketOriginAllowed(
  req: Request,
  target: Target,
  configured: string[],
): boolean {
  const origin = req.headers.get("origin");
  if (!origin) return true;
  let parsed: URL;
  try {
    parsed = new URL(origin);
  } catch {
    return false;
  }
  if (configured.includes(origin)) return true;
  if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
  return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
}

/**
 * Constant-time string compare. Length is folded into the accumulator rather
 * than short-circuiting, so a wrong guess cannot be distinguished from a
 * wrong-length guess by timing.
 */
function timingSafeEqual(a: string, b: string): boolean {
  let diff = a.length ^ b.length;
  const max = Math.max(a.length, b.length);
  for (let i = 0; i < max; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
  return diff === 0;
}

/**
 * Verify an HTTP Basic `Authorization` header against the configured pairs.
 * Malformed base64 fails closed rather than throwing, and the username and
 * password are split on the FIRST colon so passwords may contain colons.
 */
export function verifyBasicAuth(
  header: string | null | undefined,
  pairs: readonly { user: string; pass: string }[],
): boolean {
  if (!header?.startsWith("Basic ")) return false;
  let decoded: string;
  try {
    decoded = atob(header.slice(6));
  } catch {
    return false;
  }
  const separator = decoded.indexOf(":");
  if (separator === -1) return false;
  const user = decoded.slice(0, separator);
  const pass = decoded.slice(separator + 1);
  // Evaluate every pair so the number of configured credentials is not
  // observable through response timing.
  return pairs.reduce(
    (ok, p) => (timingSafeEqual(user, p.user) && timingSafeEqual(pass, p.pass)) || ok,
    false,
  );
}

export function internalError(res: Response): string | null {
  const encoded = res.headers.get("x-wrnexus-internal-error");
  if (!encoded) return null;
  try {
    return decodeURIComponent(encoded);
  } catch {
    return "Malformed internal error diagnostic";
  }
}

export function stripInternalError(res: Response): Response {
  if (!res.headers.has("x-wrnexus-internal-error")) return res;
  const headers = new Headers(res.headers);
  headers.delete("x-wrnexus-internal-error");
  return new Response(res.body, {
    status: res.status,
    statusText: res.statusText,
    headers,
  });
}

/** Preserve an intentional verifier redirect while keeping other failures opaque. */
export function forwardAuthFailure(
  res: Response,
  verifierUrl: string,
  verifierPublicOrigin = verifierUrl,
): Response {
  const location = res.headers.get("location");
  if (res.status >= 300 && res.status < 400 && location) {
    try {
      const redirect = new URL(location, verifierPublicOrigin);
      if (redirect.protocol === "http:" || redirect.protocol === "https:") {
        return new Response(null, { status: res.status, headers: { location: redirect.href } });
      }
    } catch {
      // Malformed or unsafe redirects fail closed below.
    }
  }
  return new Response("Unauthorized", { status: res.status === 200 ? 401 : res.status });
}

/** Describe the original gateway request to a forward-auth verifier. */
export function forwardAuthHeaders(req: Request, publicOrigin?: string): Headers {
  const original = new URL(req.url);
  const host = req.headers.get("host") ?? original.host;
  const protocol = (publicOrigin ? new URL(publicOrigin).protocol : original.protocol).replace(
    ":",
    "",
  );
  return new Headers({
    cookie: req.headers.get("cookie") ?? "",
    authorization: req.headers.get("authorization") ?? "",
    "x-forwarded-host": host,
    "x-forwarded-proto": protocol,
    // These survive when a verifier such as sso.localhost is routed through
    // this gateway again. The second hop may replace X-Forwarded-Host with the
    // verifier host, but must not lose the protected application's return URL.
    "x-original-host": host,
    "x-original-proto": protocol,
    "x-original-method": req.method,
    "x-original-uri": `${original.pathname}${original.search}`,
  });
}

/**
 * Enforce a per-app auth policy. Returns a Response to block, or null to allow.
 * `ip` is the client address (for the IP allowlist).
 */
async function checkAuth(
  auth: GatewayAuth | undefined,
  req: Request,
  ip: string,
  internalOrigins: Readonly<Record<string, string>>,
  publicOrigins: Readonly<Record<string, string>>,
  publicOrigin?: string,
): Promise<Response | null> {
  if (!auth) return null;

  if (auth.allowIps && !auth.allowIps.includes(ip)) {
    return new Response("Forbidden", { status: 403 });
  }

  if (auth.basic) {
    const pairs = Array.isArray(auth.basic) ? auth.basic : [auth.basic];
    const ok = verifyBasicAuth(req.headers.get("authorization"), pairs);
    if (!ok) {
      return new Response("Authentication required", {
        status: 401,
        headers: { "www-authenticate": 'Basic realm="Restricted"' },
      });
    }
  }

  if (auth.forward) {
    const verifyUrl = resolveForwardAuthUrl(auth.forward, internalOrigins);
    const verifierPublicOrigin =
      typeof auth.forward.app === "string"
        ? publicOrigins[auth.forward.app]
        : new URL(auth.forward.url).origin;

    try {
      const res = await fetch(verifyUrl, {
        headers: forwardAuthHeaders(req, publicOrigin),
        redirect: "manual",
      });
      if (!res.ok) {
        if (res.status >= 500) {
          const requestUrl = new URL(req.url);
          const diagnostic = internalError(res);
          console.error(
            `[wrnexus] forward-auth verifier error: ${req.method} ${requestUrl.pathname} via ${verifyUrl} returned ${res.status}${diagnostic ? ` — ${diagnostic}` : ""}`,
          );
        }
        return forwardAuthFailure(res, verifyUrl, verifierPublicOrigin);
      }
    } catch (error) {
      console.error(
        `[wrnexus] forward-auth verifier unavailable: ${verifyUrl}`,
        error instanceof Error ? (error.stack ?? error.message) : error,
      );
      return new Response("Auth service unavailable", { status: 503 });
    }
  }

  return null;
}

function resolveForwardAuthUrl(
  forward: NonNullable<GatewayAuth["forward"]>,
  internalOrigins: Readonly<Record<string, string>>,
): string {
  if (typeof forward.app === "string") {
    const appName = forward.app;
    const origin = internalOrigins[appName];

    if (!origin) {
      throw new Error(`Forward-auth app "${appName}" was not found.`);
    }

    const path = forward.path ?? "/api/verify";

    if (!path.startsWith("/")) {
      throw new Error(`Forward-auth path must start with "/": ${path}`);
    }

    return new URL(path, `${origin}/`).href;
  }

  if (typeof forward.url === "string") {
    const url = new URL(forward.url);

    if (url.protocol !== "http:" && url.protocol !== "https:") {
      throw new Error(`Forward-auth URL must use HTTP or HTTPS: ${forward.url}`);
    }

    return url.href;
  }

  throw new Error("Forward auth requires either `app` or `url`.");
}

/** Baseline edge security headers, only where the app didn't already set them. */
function applyEdgeHeaders(res: Response): Response {
  const defaults: Record<string, string> = {
    "x-content-type-options": "nosniff",
    "x-frame-options": "SAMEORIGIN",
    "referrer-policy": "strict-origin-when-cross-origin",
  };
  const headers = new Headers(res.headers);
  for (const [k, v] of Object.entries(defaults)) if (!headers.has(k)) headers.set(k, v);
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
}

async function waitReady(target: Target, timeoutMs = 15000): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  for (;;) {
    if (!target.child || target.child.exitCode !== null) {
      throw new Error(
        `app '${target.name}' exited with code ${target.child?.exitCode ?? "unknown"} during startup`,
      );
    }
    try {
      await fetch(target.origin + "/__wrnexus/health-probe", { method: "HEAD" });
      return; // any HTTP response (incl. 404) means the server is up
    } catch {
      if (Date.now() > deadline) {
        throw new Error(`app '${target.name}' at ${target.origin} did not start in time`);
      }
      await new Promise((r) => setTimeout(r, 150));
    }
  }
}

/** Platform-safe defaults: IPv4 loopback in dev, all IPv4 interfaces in production. */
export function defaultGatewayHostname(mode: "development" | "production"): string {
  return mode === "production" ? "0.0.0.0" : "127.0.0.1";
}

export function gatewayProxyHeaders(
  req: Request,
  url: URL,
  ip: string,
  forwardedHeaders: boolean,
): Headers {
  const headers = new Headers(req.headers);
  // Bun's internal fetch transparently decompresses response bodies but preserves
  // Content-Encoding. Asking child apps for identity encoding prevents clients from
  // trying to decompress an already-decoded proxied body.
  headers.set("accept-encoding", "identity");
  if (forwardedHeaders) {
    headers.set("x-forwarded-host", req.headers.get("host") ?? "");
    headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
    if (ip) headers.set("x-forwarded-for", ip);
  }
  return headers;
}

/** Remove headers that only a direct workspace-to-app request may supply. */
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
  const sanitized = new Headers(headers);
  sanitized.delete(RPC_INTERNAL_HEADER);
  return sanitized;
}

/**
 * The reserved inter-app RPC namespace is refused at the gateway edge, before
 * any proxying — it is only ever mounted by a child app's own dev-server and
 * must never be reachable from outside the workspace.
 */
export function isRpcGatewayPath(pathname: string): boolean {
  return (
    pathname === RPC_PATH_PREFIX ||
    pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
    pathname === RPC_STREAM_PATH_PREFIX ||
    pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
  );
}

/** Boot every app as a child process, then route by Host on one gateway port. */
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
  const port = opts.port ?? 3000;
  const mode = opts.mode ?? "development";
  const hostname = opts.hostname ?? defaultGatewayHostname(mode);
  const environment = opts.environment ?? (mode === "production" ? "production" : "development");

  const workspaceOrigins: Readonly<Record<string, string>> = Object.freeze(
    Object.fromEntries(
      opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
    ),
  );
  // Loopback-only origins, computed up front (ports are assigned by index
  // before any child spawns) so every child can reach every other child
  // directly — bypassing the gateway, which 404s the RPC prefix by design.
  const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
    Object.fromEntries(
      opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
    ),
  );
  const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
  // When the CLI is executed directly from a framework checkout, keep child
  // apps on that same source tree. Resolving the package name from an external
  // workspace can otherwise select its older installed release on restart.
  const sourceServeEntry = fileURLToPath(new URL("./serve-entry.ts", import.meta.url));
  const usesSourceServeEntry = existsSync(sourceServeEntry);
  const serveEntry = usesSourceServeEntry
    ? sourceServeEntry
    : fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
  const frameworkRoot = resolve(dirname(sourceServeEntry), "../../..");

  let stopping = false;
  const targets: Target[] = opts.apps.map((app, i) => {
    const appPort = app.port ?? port + 1 + i;
    const dir = resolve(app.dir);
    const target: Target = {
      ...app,
      port: appPort,
      origin: `http://127.0.0.1:${appPort}`,
    };

    const launch = (): void => {
      if (stopping) return;
      const child =
        mode === "production"
          ? spawn(process.execPath, [join(dir, "dist", "server.js")], {
              stdio: "inherit",
              env: {
                ...process.env,
                PORT: String(appPort),
                // App ports are private gateway internals. Loopback prevents
                // clients from bypassing gateway auth and edge middleware.
                WRNEXUS_HOSTNAME: "127.0.0.1",
                WRNEXUS_ENV: environment,
                WRNEXUS_APP_NAME: app.name,
                WRNEXUS_APP_ORIGIN: app.publicOrigin ?? `http://${app.domains[0]}:${port}`,
                WRNEXUS_WORKSPACE_ORIGINS: JSON.stringify(workspaceOrigins),
                WRNEXUS_INTERNAL_ORIGINS: JSON.stringify(internalOriginsEnv),
              },
            })
          : spawn(
              process.execPath,
              [
                serveEntry,
                join(dir, "app"),
                String(appPort),
                mode,
                "127.0.0.1",
                String(opts.hmr ?? true),
              ],
              {
                stdio: "inherit",
                // Bun can otherwise resolve bare @wrnexus imports against the
                // external application's node_modules after an HMR restart.
                // Keep source-checkout children anchored to the same framework
                // checkout as the gateway command.
                cwd: usesSourceServeEntry ? frameworkRoot : dir,
                env: {
                  ...process.env,
                  WRNEXUS_ENV: environment,
                  WRNEXUS_APP_NAME: app.name,
                  WRNEXUS_APP_ORIGIN: app.publicOrigin ?? `http://${app.domains[0]}:${port}`,
                  WRNEXUS_WORKSPACE_ORIGINS: JSON.stringify(workspaceOrigins),
                  WRNEXUS_INTERNAL_ORIGINS: JSON.stringify(internalOriginsEnv),
                },
              },
            );
      target.child = child;

      child.once("exit", (code, signal) => {
        if (stopping || target.child !== child) return;
        const delay = gatewayRestartDelay(mode, code, signal);
        if (delay === null) {
          if (code !== 0 || signal) {
            console.error(
              `[wrnexus] app '${target.name}' stopped unexpectedly (${signal ? `signal ${signal}` : `code ${code}`})`,
            );
          }
          return;
        }
        if (delay > 0) {
          console.error(`[wrnexus] app '${target.name}' exited (code ${code}); retrying in 1.2s…`);
          setTimeout(launch, delay);
        } else {
          launch();
        }
      });
    };

    launch();
    return target;
  });

  const internalOrigins: Readonly<Record<string, string>> = Object.freeze(
    Object.fromEntries(targets.map((target) => [target.name, target.origin])),
  );

  const stopChildren = () => {
    stopping = true;
    for (const target of targets) {
      if (target.child?.exitCode === null) target.child.kill();
    }
  };

  try {
    await Promise.all(targets.map((target) => waitReady(target)));
  } catch (error) {
    stopChildren();
    throw error;
  }

  const byHost = new Map<string, Target>();
  for (const t of targets) for (const d of t.domains) byHost.set(d.toLowerCase(), t);
  const pick = (host: string): Target | null =>
    byHost.get((host.split(":")[0] ?? "").toLowerCase()) ?? null;

  const sec = opts.security ?? {};
  const forwardedHeaders = sec.forwardedHeaders !== false; // default on
  const rateLimit = sec.rateLimit
    ? makeRateLimiter(sec.rateLimit.max, sec.rateLimit.windowMs ?? 60_000)
    : null;
  const now = () => Date.now();
  const limits = sec.requestLimits ?? {};
  const websocketSecurity = sec.websocket ?? {};
  const maxBodyBytes = limits.maxBodyBytes ?? 10 * 1024 * 1024;
  const maxConcurrent = limits.maxConcurrent ?? 1_000;
  let activeRequests = 0;

  const createGatewayServer = () =>
    Bun.serve<WsBridge>({
      port,
      hostname,
      development: mode === "development",
      maxRequestBodySize: maxBodyBytes,
      async fetch(req, srv) {
        const url = new URL(req.url);
        const ip = srv.requestIP(req)?.address ?? "";
        if (req.url.length > (limits.maxUrlLength ?? 8_192)) {
          return new Response("URI Too Long", { status: 414 });
        }
        if ([...req.headers].length > (limits.maxHeaderCount ?? 100)) {
          return new Response("Too Many Headers", { status: 431 });
        }
        if (requestHeaderBytes(req.headers) > (limits.maxHeaderBytes ?? 32 * 1024)) {
          return new Response("Request Headers Too Large", { status: 431 });
        }
        if ([...url.searchParams].length > (limits.maxQueryParameters ?? 100)) {
          return new Response("Too Many Query Parameters", { status: 400 });
        }
        const declaredLength = Number(req.headers.get("content-length") ?? 0);
        if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
          return new Response("Payload Too Large", { status: 413 });
        }
        if (limits.fetchMetadata !== false) {
          const site = req.headers.get("sec-fetch-site");
          const mode = req.headers.get("sec-fetch-mode");
          if (
            site === "cross-site" &&
            !["GET", "HEAD", "OPTIONS"].includes(req.method) &&
            mode !== "cors"
          ) {
            return new Response("Cross-site request denied", { status: 403 });
          }
        }

        // Health/status endpoint (not proxied).
        if (url.pathname === "/__gateway/health") {
          return Response.json({
            ok: true,
            apps: targets.map((t) => ({ name: t.name, domains: t.domains, origin: t.origin })),
          });
        }

        if (isRpcGatewayPath(url.pathname)) {
          return new Response("Not found", { status: 404 });
        }

        // Edge rate limit (global, by client IP).
        if (rateLimit && !rateLimit(ip, now())) {
          return new Response("Too Many Requests", {
            status: 429,
            headers: { "retry-after": "60" },
          });
        }

        // Route by Host. Unknown host → 404 when trustedHostsOnly, else first app.
        const target =
          pick(req.headers.get("host") ?? "") ?? (sec.trustedHostsOnly ? null : targets[0]!);
        if (!target) {
          return new Response("Unknown host", { status: 404 });
        }

        // Per-app access control (basic auth / IP allowlist / forward-auth).
        const denied = await checkAuth(
          target.auth,
          req,
          ip,
          internalOrigins,
          workspaceOrigins,
          target.publicOrigin,
        );
        if (denied) {
          if (sec.accessLog)
            console.log(
              `  ⛔ ${req.headers.get("host")} ${req.method} ${url.pathname} → ${denied.status} (${target.name})`,
            );
          return denied;
        }

        // WebSocket upgrade → proxy the socket to the app's realtime server.
        if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
          if (!gatewayWebSocketOriginAllowed(req, target, websocketSecurity.allowedOrigins ?? [])) {
            return new Response("WebSocket origin denied", { status: 403 });
          }
          const ok = srv.upgrade(req, {
            data: {
              origin: target.origin,
              path: url.pathname + url.search,
              queue: [],
              maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
              maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
            },
          });
          return ok ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
        }

        // HTTP → reverse-proxy to the app, preserving method/headers/body.
        const headers = stripUntrustedInternalHeaders(
          gatewayProxyHeaders(req, url, ip, forwardedHeaders),
        );
        const body =
          req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
        let res: Response;
        if (activeRequests >= maxConcurrent) {
          return new Response("Gateway overloaded", {
            status: 503,
            headers: { "retry-after": "1" },
          });
        }
        activeRequests += 1;
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), limits.timeoutMs ?? 30_000);
        try {
          res = await fetch(target.origin + url.pathname + url.search, {
            method: req.method,
            headers,
            body,
            redirect: "manual",
            signal: controller.signal,
          });
        } catch (error) {
          console.error(
            `[wrnexus] gateway proxy error: ${req.method} ${url.pathname} → ${target.name} (${target.origin})`,
            error instanceof Error ? (error.stack ?? error.message) : error,
          );
          res = new Response(`Gateway: app '${target.name}' is unavailable.`, {
            status: error instanceof DOMException && error.name === "AbortError" ? 504 : 502,
          });
        } finally {
          clearTimeout(timeout);
          activeRequests -= 1;
        }
        const diagnostic = internalError(res);
        if (res.status >= 500 && diagnostic) {
          console.error(
            `[wrnexus] app response error: ${target.name} ${req.method} ${url.pathname} → ${res.status} — ${diagnostic}`,
          );
        }
        res = stripInternalError(res);
        if (sec.headers) res = applyEdgeHeaders(res);
        if (sec.accessLog)
          console.log(
            `  ${req.headers.get("host")} ${req.method} ${url.pathname} → ${res.status} (${target.name})`,
          );
        return res;
      },
      websocket: {
        open(ws) {
          const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
          const backend = new WebSocket(backendUrl);
          ws.data.backend = backend;
          backend.addEventListener("open", () => {
            for (const m of ws.data.queue) backend.send(m);
            ws.data.queue = [];
          });
          backend.addEventListener("message", async (event) => {
            const data = event.data;
            const size =
              typeof data === "string"
                ? new TextEncoder().encode(data).byteLength
                : data instanceof Blob
                  ? data.size
                  : data instanceof ArrayBuffer
                    ? data.byteLength
                    : ArrayBuffer.isView(data)
                      ? data.byteLength
                      : 0;
            if (size > ws.data.maxMessageBytes) {
              ws.close(1009, "Message too large");
              backend.close(1009, "Message too large");
              return;
            }

            if (typeof data === "string") {
              ws.send(data);
              return;
            }

            if (data instanceof Blob) {
              const buffer = await data.arrayBuffer();
              ws.send(buffer);
              return;
            }

            if (data instanceof ArrayBuffer) {
              ws.send(data);
              return;
            }

            if (ArrayBuffer.isView(data)) {
              const buffer = data.buffer.slice(
                data.byteOffset,
                data.byteOffset + data.byteLength,
              ) as ArrayBuffer;

              ws.send(buffer);
            }
          });
          backend.addEventListener("close", () => ws.close());
          backend.addEventListener("error", () => ws.close());
        },
        message(ws, message) {
          if (requestMessageBytes(message) > ws.data.maxMessageBytes) {
            ws.close(1009, "Message too large");
            ws.data.backend?.close(1009, "Message too large");
            return;
          }
          let data: string | ArrayBuffer;

          if (typeof message === "string") {
            data = message;
          } else if (message instanceof ArrayBuffer) {
            data = message;
          } else {
            const view = message as ArrayBufferView;

            data = view.buffer.slice(
              view.byteOffset,
              view.byteOffset + view.byteLength,
            ) as ArrayBuffer;
          }

          const backend = ws.data.backend;

          if (backend && backend.readyState === WebSocket.OPEN) {
            backend.send(data);
          } else if (ws.data.queue.length < ws.data.maxQueuedMessages) {
            ws.data.queue.push(data);
          } else {
            ws.close(1013, "WebSocket queue limit exceeded");
            ws.data.backend?.close(1013, "WebSocket queue limit exceeded");
          }
        },
        close(ws) {
          ws.data.backend?.close();
        },
      },
    });

  let server: ReturnType<typeof createGatewayServer>;
  try {
    server = createGatewayServer();
  } catch (error) {
    stopChildren();
    if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") {
      throw new Error(
        `Gateway cannot listen on ${hostname}:${port} because the port is already in use. ` +
          `Stop the existing process or run \`wrnexus gateway --port=<another-port>\`.`,
        { cause: error },
      );
    }
    throw error;
  }

  const stop = () => {
    server.stop();
    stopChildren();
  };
  process.on("SIGINT", stop);
  process.on("SIGTERM", stop);

  const url = `http://${displayHost}:${port}`;
  console.log(`\n  ⚡ WrNexus gateway — ${url}`);
  for (const t of targets) {
    console.log(`    ${t.domains.join(", ")}  →  ${t.name}  (${t.origin})`);
  }
  console.log("");

  return { port, url, stop };
}
