// HTTP transport for enquire-mcp.
//
// v2.6.0 — remote-MCP support. Runs the MCP server over Streamable HTTP
// (the protocol claude.ai web, ChatGPT, mobile clients, and Cursor's HTTP
// mode all use). Three layers in front of the SDK transport:
//
//   1. Bearer auth — constant-time token compare, fail-closed (401 on
//      missing/wrong header). Token is generated by the user (we never
//      mint tokens — this is not OAuth) and passed via --bearer-token or
//      --bearer-token-env. See docs/http-transport.md for the security
//      model and Tailscale Funnel / Cloudflare Tunnel deployment recipes.
//   2. Rate-limit — per-token sliding-window bucket, 429 on overflow.
//      In-memory; survives a single process. Default 120 req/min.
//   3. CORS — strict allowlist via --cors-origin (repeatable). Default
//      empty: no Access-Control-Allow-Origin header sent. Same-origin
//      and credentialed Bearer requests work either way.
//
// We use the SDK's StreamableHTTPServerTransport in stateless mode
// (sessionIdGenerator: undefined). A fresh transport + McpServer is
// connected per request. The Vault, FtsIndex, EmbedDb handles are
// SHARED across all sessions — opening SQLite once and reusing across
// thousands of remote-MCP calls. This is why prepareServerDeps() is
// called once in startHttpServer() and buildMcpServer() is called per
// request: the heavy I/O happens once at boot.
//
// Stateful sessions (sessionId-keyed transports) are deferred to v2.7+
// because they require persistence-aware shutdown handling and are only
// needed for SSE long-running operations. Our tools are short-running
// (search, read, frontmatter ops), so stateless is the right default.

import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildMcpServer, formatReadyBanner, prepareServerDeps, type ServeOptions, type ServerDeps } from "./index.js";
import { DEFAULT_MAX_FILE_BYTES } from "./vault.js";

/**
 * Configuration for the HTTP transport. Extends ServeOptions so every
 * stdio-mode flag (--enable-write, --persistent-index, --watch, etc.) is
 * available over HTTP too.
 */
export interface HttpServeOptions extends ServeOptions {
  /** TCP port to listen on. */
  port: number;
  /**
   * Bind host. Default 127.0.0.1 — explicit because remote-MCP must
   * opt-in to bind 0.0.0.0. Most users should keep it on localhost and
   * front it with Tailscale Funnel / Cloudflare Tunnel for remote access
   * (auth-and-encryption-in-depth). See docs/http-transport.md.
   */
  host: string;
  /**
   * Bearer token. Comparing constant-time. Required: a missing token
   * fails closed at startup (we refuse to bind without auth). Generate
   * with: `node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`.
   */
  bearerToken: string;
  /** URL path prefix for the MCP endpoint. Default `/mcp`. */
  mcpPath?: string;
  /** Max requests per minute per bearer token. Default 120. 0 disables. */
  rateLimitPerMinute?: number;
  /**
   * CORS origin allowlist. Repeatable. Default empty (no
   * Access-Control-Allow-Origin sent). Bearer-credentialed requests work
   * cross-origin only when the origin is in this list.
   */
  corsOrigins?: string[];
  /** Optional path-prefix to match a /health probe under (e.g. for Tailscale). Default `/health`. */
  healthPath?: string;
  /**
   * v2.14.0 — when `true`, the HTTP transport runs in stateful mode:
   * sessions are keyed by the `Mcp-Session-Id` header, each session has
   * its own `McpServer` + `StreamableHTTPServerTransport` pair, and
   * server-initiated notifications stream over a long-lived `GET /mcp`
   * SSE connection. Required for ChatGPT custom GPT actions and other
   * clients that expect persistent state across requests. Off by default
   * (stateless mode is the right choice for short-running tools and
   * minimizes attack surface).
   */
  stateful?: boolean;
  /**
   * v2.14.0 — when stateful, evict sessions idle longer than this many
   * milliseconds. Default 30 minutes. Sweep runs lazily on every
   * request (no separate timer). Idle = time since the last
   * `handleRequest` for that session.
   */
  sessionIdleTimeoutMs?: number;
  /**
   * v2.14.0 — max concurrent sessions. New sessions beyond this cap are
   * rejected with 503 + `Retry-After`. Default 100; protects against
   * memory exhaustion under adversarial create-and-abandon traffic.
   */
  maxSessions?: number;
  /**
   * When `true` (default), registers SIGINT/SIGTERM/beforeExit listeners
   * for graceful shutdown of the HTTP server, vault, FTS5 index, watcher,
   * and persistent cache. Tests pass `false` to avoid leaking listeners
   * when spawning many `startHttpServer()` instances in one process —
   * cleanup happens via `httpServer.close()` which still triggers the
   * underlying resource cleanup.
   */
  installSignalHandlers?: boolean;
}

/**
 * Sliding-window rate limiter. Per-token. Window is rolling 60 seconds.
 * Uses a tiny circular buffer of timestamps per token — O(burst-size)
 * memory per token, no GC churn (we trim when we check).
 *
 * NOT distributed. If the user runs multiple processes behind a load
 * balancer they'd want a shared store (Redis); deferred to v2.7+.
 */
class RateLimiter {
  private readonly perMinute: number;
  private readonly windows = new Map<string, number[]>();

  constructor(perMinute: number) {
    this.perMinute = perMinute;
  }

  /** Returns true if request allowed; false if over budget. */
  consume(tokenId: string, nowMs: number = Date.now()): boolean {
    if (this.perMinute <= 0) return true; // disabled
    const cutoff = nowMs - 60_000;
    let timestamps = this.windows.get(tokenId);
    if (!timestamps) {
      timestamps = [];
      this.windows.set(tokenId, timestamps);
    }
    // Trim out-of-window entries from the front (they're append-ordered).
    while (timestamps.length > 0 && (timestamps[0] ?? 0) < cutoff) {
      timestamps.shift();
    }
    if (timestamps.length >= this.perMinute) return false;
    timestamps.push(nowMs);
    return true;
  }

  /** Test-only: reset all windows. */
  reset(): void {
    this.windows.clear();
  }
}

/**
 * Constant-time bearer-token compare. The Authorization header is
 * `Bearer <token>` per RFC 6750. We compare against the raw token
 * supplied at startup — both sides padded to a hash so timingSafeEqual
 * doesn't leak length.
 *
 * Returns the SHA-256 digest of the token (used as the rate-limit key)
 * if valid, or null if not. We never use the raw token as the key — a
 * compromised log dump would expose it.
 */
function verifyBearer(authHeader: string | undefined, expectedToken: string): string | null {
  if (!authHeader?.startsWith("Bearer ")) return null;
  const presented = authHeader.slice("Bearer ".length).trim();
  if (presented.length === 0) return null;
  // Hash both sides so timingSafeEqual gets equal-length buffers regardless
  // of whether the presented token is longer or shorter than expected. The
  // hash is constant-length, so length-comparison short-circuits don't leak.
  const expectedHash = createHash("sha256").update(expectedToken).digest();
  const presentedHash = createHash("sha256").update(presented).digest();
  if (!timingSafeEqual(expectedHash, presentedHash)) return null;
  // Return the digest as the rate-limit key. Different tokens produce
  // different keys; the same token always produces the same key.
  return presentedHash.toString("hex").slice(0, 16);
}

/** Read the entire request body (UTF-8 JSON). Returns undefined for empty. */
async function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
  const chunks: Buffer[] = [];
  let total = 0;
  for await (const chunk of req) {
    const buf = Buffer.from(chunk);
    total += buf.length;
    if (total > maxBytes) {
      throw new Error(`request body exceeds max ${maxBytes} bytes`);
    }
    chunks.push(buf);
  }
  if (total === 0) return undefined;
  const text = Buffer.concat(chunks).toString("utf8");
  return JSON.parse(text);
}

/**
 * v3.7.13 H2 — detect a JSON-RPC 2.0 `initialize` request without parsing
 * the full envelope. The body can be either a single message or a batch
 * (JSON-RPC permits arrays); we accept both, requiring AT LEAST ONE
 * element in a batch to be `method: "initialize"`. Returns false on
 * anything else (including malformed bodies — the caller has already
 * parsed JSON, so the shape is unknown but not parse-error territory).
 *
 * @internal — exported only for tests.
 */
export function isInitializeRequest(body: unknown): boolean {
  if (Array.isArray(body)) return body.some(isInitializeRequest);
  if (!body || typeof body !== "object") return false;
  const method = (body as { method?: unknown }).method;
  return method === "initialize";
}

/** Send a JSON-RPC error response with the given HTTP + RPC status. */
function sendJsonRpcError(res: ServerResponse, httpStatus: number, code: number, message: string): void {
  if (res.headersSent) return;
  res.statusCode = httpStatus;
  res.setHeader("Content-Type", "application/json");
  res.end(
    JSON.stringify({
      jsonrpc: "2.0",
      error: { code, message },
      id: null
    })
  );
}

/**
 * Apply CORS headers if the request's Origin is allowed.
 *
 * Defense-in-depth against the CORS-credentials-misconfig class of bugs:
 *
 *   • For an explicit allowlisted origin (e.g. https://claude.ai), we
 *     reflect ONLY that exact origin in `Access-Control-Allow-Origin` and
 *     emit `Vary: Origin` so caches don't cross-pollinate across origins.
 *     Pair with `Access-Control-Allow-Credentials: true` so cookies +
 *     credentialed Bearer requests work as expected.
 *
 *   • For the wildcard `*` allowlist entry, we reflect `*` literally (NOT
 *     the request's origin) and DO NOT send `Allow-Credentials: true`.
 *     Browsers reject the combo of wildcard-origin + credentials anyway,
 *     so this matches what they enforce. Equally important: it kills the
 *     CORS-credential-leak attack surface where a misconfigured wildcard
 *     could otherwise hand attacker.com a credential-bearing CORS grant.
 *
 *   • Disallowed origins get nothing — no Allow-Origin header → browser
 *     blocks the actual request even if we 200.
 *
 * Documented as a deliberate behavior split for users who pass `*`: with
 * `*` you get an unauthenticated public CORS endpoint (still bearer-gated
 * server-side, just no credentialed-cookie path). That's the right
 * trade-off.
 */
function applyCors(req: IncomingMessage, res: ServerResponse, allowOrigins: string[]): void {
  const requestOrigin = req.headers.origin;
  if (typeof requestOrigin !== "string") return;
  // Sourcing the response value from `allowOrigins` (a server-controlled
  // CLI flag) instead of from `req.headers.origin` (an attacker-controlled
  // request header) is a deliberate defense against the CORS-credential-
  // leak class of bug. CodeQL's `js/cors-misconfiguration-for-credentials`
  // query is satisfied because `matchedOrigin`'s data source is the
  // allowlist, not the request — even though the strings are equal by
  // construction at this point, the data-flow taint is what matters.
  const matchedOrigin = allowOrigins.find((o) => o === requestOrigin);
  const wildcardAllowed = matchedOrigin === undefined && allowOrigins.includes("*");
  if (matchedOrigin === undefined && !wildcardAllowed) return;
  // Wildcard branch: reflect literal "*" + OMIT Allow-Credentials (the
  // browser would reject the combo anyway, and we don't grant attacker
  // origins a credentialed window). Exact-match branch: use the trusted
  // allowlist value + send Allow-Credentials: true so cookies + Bearer
  // requests work cross-origin.
  if (wildcardAllowed) {
    res.setHeader("Access-Control-Allow-Origin", "*");
  } else if (matchedOrigin !== undefined) {
    res.setHeader("Access-Control-Allow-Origin", matchedOrigin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }
  res.setHeader("Vary", "Origin");
  res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Mcp-Session-Id, Last-Event-ID");
  res.setHeader("Access-Control-Max-Age", "600");
}

/**
 * Build the request handler. Factored out of the listener-creation path
 * so tests can drive it without binding a TCP port.
 */
/**
 * v2.14.0 — one entry in the stateful-session map. Each session pairs an
 * `McpServer` instance with a `StreamableHTTPServerTransport` (in
 * stateful mode the SDK keys requests by the session id we assign at
 * `initialize` time) and tracks the last activity timestamp for
 * idle-eviction.
 */
interface StatefulSession {
  server: ReturnType<typeof buildMcpServer>;
  transport: InstanceType<typeof StreamableHTTPServerTransport>;
  /** Epoch-ms of the last `handleRequest` for this session. */
  lastActivityMs: number;
}

/**
 * v2.14.0 — exported for tests so they can poke at the session map
 * directly (idle eviction, max-cap, etc.) without spinning up a real
 * HTTP server.
 */
export interface SessionRegistry {
  readonly sessions: Map<string, StatefulSession>;
  /** Sweep idle sessions older than `idleTimeoutMs`. Returns evicted count. */
  sweepIdle(nowMs?: number): number;
  /** Total sessions currently tracked. */
  size(): number;
}

export function createSessionRegistry(idleTimeoutMs: number): SessionRegistry {
  const sessions = new Map<string, StatefulSession>();
  return {
    sessions,
    sweepIdle(nowMs = Date.now()): number {
      const cutoff = nowMs - idleTimeoutMs;
      let evicted = 0;
      for (const [sid, session] of sessions) {
        if (session.lastActivityMs < cutoff) {
          // Best-effort cleanup — don't block on slow close.
          void session.transport.close().catch(() => {});
          void session.server.close().catch(() => {});
          sessions.delete(sid);
          evicted += 1;
        }
      }
      return evicted;
    },
    size: () => sessions.size
  };
}

/**
 * v3.7.12 M4 — parse the `maxFileBytes` ServeOptions field WITHOUT calling
 * back into `prepareServerDeps` (we only need the parsed number, not the
 * full vault). Returns `undefined` for unset / malformed / non-positive
 * input so the caller falls back to `DEFAULT_MAX_FILE_BYTES`.
 *
 * @internal — exported only for tests.
 */
export function parseMaxFileBytes(raw: string | undefined): number | undefined {
  if (raw === undefined) return undefined;
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0 || Math.floor(n) !== n) return undefined;
  return n;
}

/**
 * v3.7.12 M4 — derive the HTTP body-size cap from the vault's `maxFileBytes`
 * (or `DEFAULT_MAX_FILE_BYTES` when unset). Scales 1.5× to leave headroom
 * for the JSON-RPC envelope + string-escaping overhead, with a 4 MB floor
 * so tiny vaults don't shrink the cap below `tools/list` response size.
 *
 * @internal — exported only for tests.
 */
export function deriveHttpBodyCap(maxFileBytes: string | undefined): number {
  const vaultMaxBytes = parseMaxFileBytes(maxFileBytes) ?? DEFAULT_MAX_FILE_BYTES;
  return Math.max(4 * 1024 * 1024, Math.floor(vaultMaxBytes * 1.5));
}

export function createHttpHandler(
  deps: ServerDeps,
  opts: HttpServeOptions
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
  const mcpPath = opts.mcpPath ?? "/mcp";
  const healthPath = opts.healthPath ?? "/health";
  const corsOrigins = opts.corsOrigins ?? [];
  const limiter = new RateLimiter(opts.rateLimitPerMinute ?? 120);
  // v3.7.12 M4 — body cap derived from the vault's `maxFileBytes` via
  // `deriveHttpBodyCap`. Previously hardcoded 4 MB, which was BELOW the default
  // per-file cap of 5 MB — a `create_note` with a 4.5 MB payload would clear
  // `Vault.assertSize` but be rejected at the HTTP layer with a confusing 413.
  const maxBodyBytes = deriveHttpBodyCap(opts.maxFileBytes);

  // v2.14.0 stateful-mode state.
  const stateful = opts.stateful === true;
  const idleTimeoutMs = opts.sessionIdleTimeoutMs ?? 30 * 60 * 1000; // 30 min default
  const maxSessions = opts.maxSessions ?? 100;
  const registry = stateful ? createSessionRegistry(idleTimeoutMs) : null;

  return async (req, res) => {
    try {
      applyCors(req, res, corsOrigins);

      // OPTIONS preflight short-circuit.
      if (req.method === "OPTIONS") {
        res.statusCode = 204;
        res.end();
        return;
      }

      const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);

      // Health probe — unauthenticated, no rate-limit. Useful for
      // Tailscale/Cloudflare healthchecks. Returns just `ok` so it
      // doesn't leak version info to unauth callers.
      if (url.pathname === healthPath && req.method === "GET") {
        res.statusCode = 200;
        res.setHeader("Content-Type", "text/plain");
        res.end("ok");
        return;
      }

      if (url.pathname !== mcpPath) {
        res.statusCode = 404;
        res.setHeader("Content-Type", "application/json");
        res.end(JSON.stringify({ error: "not found", path: url.pathname }));
        return;
      }

      // Auth: bearer token required for /mcp.
      const authHeader = req.headers.authorization;
      const tokenKey = verifyBearer(typeof authHeader === "string" ? authHeader : undefined, opts.bearerToken);
      if (tokenKey === null) {
        res.statusCode = 401;
        res.setHeader("WWW-Authenticate", 'Bearer realm="enquire-mcp"');
        res.setHeader("Content-Type", "application/json");
        res.end(JSON.stringify({ error: "unauthorized" }));
        return;
      }

      // Rate-limit per token.
      if (!limiter.consume(tokenKey)) {
        res.statusCode = 429;
        res.setHeader("Retry-After", "60");
        res.setHeader("Content-Type", "application/json");
        res.end(JSON.stringify({ error: "rate limit exceeded" }));
        return;
      }

      // Stateless mode (default) — POST only; fresh server+transport
      // per request. Same path as v2.6.0.
      if (!stateful || !registry) {
        if (req.method !== "POST") {
          sendJsonRpcError(res, 405, -32000, `Method ${req.method} not allowed for ${mcpPath}`);
          return;
        }
        await handleStatelessRequest(req, res, deps, opts, maxBodyBytes);
        return;
      }

      // ─── v2.14.0 stateful mode ─────────────────────────────────────────
      // Lazy idle-sweep on every request. Bounded work — at most O(|sessions|)
      // and cheap (Date compare per entry).
      registry.sweepIdle();

      const sessionIdHeader = req.headers["mcp-session-id"];
      const sessionId = typeof sessionIdHeader === "string" ? sessionIdHeader : undefined;

      // DELETE /mcp — explicit session termination. SDK's transport
      // handles the protocol-level details; we just route it.
      if (req.method === "DELETE") {
        if (!sessionId) {
          sendJsonRpcError(res, 400, -32000, "DELETE requires Mcp-Session-Id header");
          return;
        }
        const session = registry.sessions.get(sessionId);
        if (!session) {
          // Idempotent — if the session is already gone, treat as success.
          res.statusCode = 204;
          res.end();
          return;
        }
        // Let the transport's handleRequest run the protocol shutdown,
        // then drop our entry.
        try {
          await session.transport.handleRequest(req, res);
        } catch {
          /* shutdown errors don't matter — we're killing the session anyway */
        }
        void session.transport.close().catch(() => {});
        void session.server.close().catch(() => {});
        registry.sessions.delete(sessionId);
        return;
      }

      // GET /mcp — long-lived SSE stream for server-initiated
      // notifications. The transport handles the SSE framing; we just
      // route the request to the right session.
      if (req.method === "GET") {
        if (!sessionId) {
          sendJsonRpcError(res, 400, -32000, "GET requires Mcp-Session-Id header (initialize first)");
          return;
        }
        const session = registry.sessions.get(sessionId);
        if (!session) {
          sendJsonRpcError(res, 404, -32000, `Unknown session ${sessionId}`);
          return;
        }
        session.lastActivityMs = Date.now();
        try {
          await session.transport.handleRequest(req, res);
        } catch (err) {
          process.stderr.write(`enquire http: SSE error — ${err instanceof Error ? err.message : String(err)}\n`);
        }
        return;
      }

      if (req.method !== "POST") {
        sendJsonRpcError(res, 405, -32000, `Method ${req.method} not allowed for ${mcpPath}`);
        return;
      }

      // POST /mcp — either an existing session (Mcp-Session-Id header)
      // or a new initialize. Read the body once; we'll need it to
      // detect initialize and to feed handleRequest.
      let body: unknown;
      try {
        body = await readJsonBody(req, maxBodyBytes);
      } catch (err) {
        sendJsonRpcError(res, 400, -32700, err instanceof Error ? err.message : "Parse error");
        return;
      }

      if (sessionId) {
        // Existing session — route to its transport.
        const session = registry.sessions.get(sessionId);
        if (!session) {
          sendJsonRpcError(res, 404, -32000, `Unknown session ${sessionId} (it may have expired)`);
          return;
        }
        session.lastActivityMs = Date.now();
        try {
          await session.transport.handleRequest(req, res, body);
        } catch (err) {
          process.stderr.write(
            `enquire http: stateful transport error — ${err instanceof Error ? err.message : String(err)}\n`
          );
          if (!res.headersSent) {
            sendJsonRpcError(res, 500, -32603, "Internal server error");
          }
        }
        return;
      }

      // No session id — must be a fresh `initialize`. Cap before allocating.
      if (registry.size() >= maxSessions) {
        res.statusCode = 503;
        res.setHeader("Retry-After", "60");
        res.setHeader("Content-Type", "application/json");
        res.end(
          JSON.stringify({
            error: "max sessions reached",
            current: registry.size(),
            max: maxSessions
          })
        );
        return;
      }

      // v3.7.13 H2 — verify the body is an `initialize` JSON-RPC request
      // BEFORE we allocate a server + transport. Pre-3.7.13, any POST
      // without `Mcp-Session-Id` allocated both, then handed the body to
      // the SDK; if the body wasn't `initialize`, the SDK would either
      // reject internally or surface a protocol error — but in some
      // failure modes `onsessioninitialized` never fires and cleanup
      // never runs, leaking the McpServer + transport pair. Bearer-token
      // DoS: not anonymous, but easy for any client with a valid token
      // to fire create-and-abandon traffic until `maxSessions` blocks
      // further work. This pre-validation short-circuits the leak by
      // rejecting non-initialize POSTs at the JSON-RPC level before any
      // allocation.
      if (!isInitializeRequest(body)) {
        sendJsonRpcError(
          res,
          400,
          -32600,
          "First POST without Mcp-Session-Id header must be a JSON-RPC `initialize` request"
        );
        return;
      }

      // Allocate session: server + transport with a fresh session id.
      // The SDK's `sessionIdGenerator` returns a UUID; the transport
      // sets the `Mcp-Session-Id` response header automatically on
      // initialize.
      const server = buildMcpServer(deps, opts);
      const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: () => randomBytes(16).toString("hex"),
        onsessioninitialized: (sid: string) => {
          // The SDK guarantees this fires before initialize's response
          // ships, so the entry exists when subsequent requests arrive.
          registry.sessions.set(sid, { server, transport, lastActivityMs: Date.now() });
        }
      });
      // Wire up cleanup on transport close (e.g. client disconnect mid-stream).
      transport.onclose = () => {
        const sid = transport.sessionId;
        if (sid) registry.sessions.delete(sid);
      };
      try {
        await server.connect(transport);
        await transport.handleRequest(req, res, body);
      } catch (err) {
        process.stderr.write(
          `enquire http: stateful initialize error — ${err instanceof Error ? err.message : String(err)}\n`
        );
        // Best-effort: free resources we allocated.
        void transport.close().catch(() => {});
        void server.close().catch(() => {});
        if (!res.headersSent) {
          sendJsonRpcError(res, 500, -32603, "Internal server error");
        }
      }
    } catch (err) {
      // Final safety net — if anything in the outer block throws (URL
      // parse, header read), don't leave the connection hung.
      process.stderr.write(
        `enquire http: handler error — ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`
      );
      if (!res.headersSent) {
        sendJsonRpcError(res, 500, -32603, "Internal server error");
      }
    }
  };
}

/**
 * Stateless POST handler — extracted from createHttpHandler so the
 * stateful path can keep using fresh server+transport per non-session
 * request when we want to (we don't currently). Identical to the
 * v2.6.0 default flow.
 */
async function handleStatelessRequest(
  req: IncomingMessage,
  res: ServerResponse,
  deps: ServerDeps,
  opts: HttpServeOptions,
  maxBodyBytes: number
): Promise<void> {
  let body: unknown;
  try {
    body = await readJsonBody(req, maxBodyBytes);
  } catch (err) {
    sendJsonRpcError(res, 400, -32700, err instanceof Error ? err.message : "Parse error");
    return;
  }
  const server = buildMcpServer(deps, opts);
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  try {
    await server.connect(transport);
    const cleanup = () => {
      void transport.close();
      void server.close();
    };
    res.on("close", cleanup);
    await transport.handleRequest(req, res, body);
  } catch (err) {
    process.stderr.write(`enquire http: transport error — ${err instanceof Error ? err.message : String(err)}\n`);
    if (!res.headersSent) {
      sendJsonRpcError(res, 500, -32603, "Internal server error");
    }
  }
}

/**
 * Generate a fresh 32-byte base64url bearer token. Used by the
 * `enquire-mcp gen-token` subcommand.
 */
export function generateBearerToken(): string {
  return randomBytes(32).toString("base64url");
}

/**
 * Bind and start the HTTP transport. Returns the underlying http.Server
 * so callers (tests + CLI) can listen for `listening` / close it.
 */
export async function startHttpServer(opts: HttpServeOptions): Promise<HttpServer> {
  if (!opts.bearerToken || opts.bearerToken.length < 16) {
    throw new Error(
      "enquire serve-http: --bearer-token is required and must be ≥16 chars. " +
        "Generate one with: enquire-mcp gen-token"
    );
  }
  const deps = await prepareServerDeps(opts);
  const handler = createHttpHandler(deps, opts);
  const httpServer = createServer((req, res) => {
    void handler(req, res);
  });

  // Persistent-cache flush + watcher cleanup on signal. Same hooks as
  // stdio mode — the deps own the lifecycle. Skipped under
  // installSignalHandlers=false so tests can spawn many servers in one
  // process without accumulating SIGINT/SIGTERM listeners.
  if (opts.installSignalHandlers !== false) {
    if (deps.vault.persistentCacheEnabled) {
      let saving = false;
      let saved = false;
      const flush = async () => {
        if (saving || saved) return;
        saving = true;
        try {
          await deps.vault.saveDiskCache();
          saved = true;
        } catch (err) {
          process.stderr.write(`enquire: cache flush failed — ${err instanceof Error ? err.message : String(err)}\n`);
        } finally {
          saving = false;
        }
      };
      process.once("SIGINT", () => {
        flush().finally(() => process.exit(0));
      });
      process.once("SIGTERM", () => {
        flush().finally(() => process.exit(0));
      });
      process.on("beforeExit", () => {
        if (!saved && !saving) void flush();
      });
    }
    if (deps.watcher) {
      const closeWatcher = () => {
        void deps.watcher?.close();
        // v3.8.0-rc.2 R-7 — close watcher-owned embed-db handle (HTTP path).
        deps.watcherEmbedDb?.close();
      };
      process.once("SIGINT", closeWatcher);
      process.once("SIGTERM", closeWatcher);
      process.on("beforeExit", closeWatcher);
    }
    if (deps.ftsIndex) {
      const closeFts = () => deps.ftsIndex?.close();
      process.once("SIGINT", closeFts);
      process.once("SIGTERM", closeFts);
      process.on("beforeExit", closeFts);
    }
    // Graceful HTTP-server shutdown on signal.
    const shutdown = () => {
      httpServer.close(() => {
        // Cascade-close happens via beforeExit hooks.
      });
    };
    process.once("SIGINT", shutdown);
    process.once("SIGTERM", shutdown);
  }

  await new Promise<void>((resolve, reject) => {
    httpServer.once("error", reject);
    httpServer.listen(opts.port, opts.host, () => {
      httpServer.removeListener("error", reject);
      resolve();
    });
  });

  const addr = httpServer.address();
  const bound =
    addr && typeof addr === "object"
      ? `http://${addr.address === "::" ? "::" : addr.address}:${addr.port}`
      : `http://${opts.host}:${opts.port}`;
  const corsLabel = (opts.corsOrigins?.length ?? 0) > 0 ? `, cors=${opts.corsOrigins?.length ?? 0}` : "";
  const rateLabel = (opts.rateLimitPerMinute ?? 120) > 0 ? `, rate-limit=${opts.rateLimitPerMinute ?? 120}/min` : "";
  process.stderr.write(
    `${formatReadyBanner(deps)} (transport=http, bound=${bound}${opts.mcpPath ?? "/mcp"}${corsLabel}${rateLabel})\n`
  );
  return httpServer;
}

// Re-export the rate limiter and helpers for tests.
export { RateLimiter, readJsonBody, verifyBearer };
