import http from "node:http";
import https from "node:https";
import net from "node:net";
import tls from "node:tls";
import { domainToASCII } from "node:url";
import {
  readProxyEnv,
  resolveAmbientProxyForUrl,
  type ProxyEnvSnapshot,
} from "./env.js";
import { formatConnectAuthority, resolveProxyConnectTimeoutMs } from "./connect.js";
import { connectToProxy, type ProxyConnectOptions } from "./proxy-socket.js";
import { ProxylineError, decodeProxyUserinfoComponent, type ProxylineTlsOptions } from "./shared.js";
import type { ProxylineSurface, ProxyResolver } from "./types.js";

export type NodeHttpRequestOptions = http.RequestOptions & https.RequestOptions & {
  agent?: http.Agent | false;
};

type NodeHttpMethod = typeof http.request;
type NodeAgentFactory = (options: NodeHttpRequestOptions) => http.Agent;
type NodeAgentOptions = http.AgentOptions & https.AgentOptions;
type ResolveProxyConnectOptions = (proxyUrl: string) => ProxyConnectOptions;
const pendingRequestSymbol = Symbol("proxyline.pendingRequest");
type NodeAgentRequestOptions = http.RequestOptions & https.RequestOptions & {
  secureEndpoint?: boolean;
  [pendingRequestSymbol]?: http.ClientRequest;
};
type NodeAddRequestAgent = http.Agent & {
  addRequest(req: http.ClientRequest, options: NodeAgentRequestOptions): void;
};
type NodeCreateSocketAgent = http.Agent & {
  createSocket(
    req: http.ClientRequest,
    options: NodeAgentRequestOptions,
    callback: (error: Error | null, socket?: net.Socket) => void,
  ): void;
};
type RequestSetTimeout = (
  this: http.ClientRequest,
  timeout: number,
  callback?: () => void,
) => http.ClientRequest;
type RequestDestroy = (this: http.ClientRequest, error?: Error) => http.ClientRequest;
type NodeAgentWithOptions = http.Agent & {
  options?: NodeAgentOptions;
};
type NodeProxyAgentOptions = NodeAgentOptions & {
  defaultProtocol?: "http" | "https";
  getProxyForUrl: (
    url: string,
    surface?: ProxylineSurface,
    request?: http.ClientRequest,
  ) => string;
  proxyTls?: ProxylineTlsOptions;
  resolveProxyConnectOptions?: ResolveProxyConnectOptions;
};

const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
const INVALID_PROXY_TARGET_HOST_DELIMITER_PATTERN = /[/:?#@\\]/;
const INVALID_PROXY_TARGET_HOST_CONTROL_PATTERN = /[\u0000-\u0020\u007f]/;
const nodeAgentDefaultPorts = new WeakMap<object, number>();

export const CALLER_AGENT_TLS_OPTION_KEYS = [
  "ca",
  "cert",
  "ciphers",
  "clientCertEngine",
  "crl",
  "dhparam",
  "ecdhCurve",
  "honorCipherOrder",
  "key",
  "maxVersion",
  "minVersion",
  "passphrase",
  "pfx",
  "rejectUnauthorized",
  "secureOptions",
  "secureProtocol",
  "sessionIdContext",
] as const;

export type NodeHttpStackSnapshot = {
  httpRequest: typeof http.request;
  httpGet: typeof http.get;
  httpGlobalAgent: typeof http.globalAgent;
  httpsRequest: typeof https.request;
  httpsGet: typeof https.get;
  httpsGlobalAgent: typeof https.globalAgent;
};

function copyNodeHttpOptions(value: unknown): NodeHttpRequestOptions {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    return {};
  }
  return { ...(value as NodeHttpRequestOptions) };
}

function readAgentOptions(agent: http.Agent | false | undefined): NodeAgentOptions | undefined {
  if (agent === undefined || agent === false) {
    return undefined;
  }
  return (agent as NodeAgentWithOptions).options;
}

function preserveCallerAgentOptions(options: NodeHttpRequestOptions): void {
  const agentOptions = readAgentOptions(options.agent);
  if (agentOptions === undefined) {
    return;
  }
  for (const key of CALLER_AGENT_TLS_OPTION_KEYS) {
    const value = agentOptions[key];
    if (value !== undefined && options[key as keyof NodeHttpRequestOptions] === undefined) {
      options[key as keyof NodeHttpRequestOptions] = value as never;
    }
  }
}

function unbracketHostname(hostname: string): string {
  return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
}

function inferDestinationHostname(
  url: string | URL | undefined,
  options: NodeHttpRequestOptions,
): string | undefined {
  if (typeof options.hostname === "string") {
    return unbracketHostname(options.hostname);
  }
  if (url !== undefined) {
    return unbracketHostname(url instanceof URL ? url.hostname : new URL(url).hostname);
  }
  if (typeof options.host === "string") {
    return unbracketHostname(splitHostPort(options.host).host);
  }
  return undefined;
}

function preserveDestinationTlsIdentity(
  url: string | URL | undefined,
  options: NodeHttpRequestOptions,
): void {
  if (options.servername !== undefined) {
    return;
  }
  const hostname = inferDestinationHostname(url, options);
  if (!hostname) {
    return;
  }
  if (net.isIP(hostname) === 0) {
    options.servername = hostname;
  }
}

export function bindNodeHttpMethod<TMethod extends NodeHttpMethod>(
  originalMethod: TMethod,
  createAgent: NodeAgentFactory,
): TMethod {
  return ((...args: unknown[]) => {
    let url: string | URL | undefined;
    let options: NodeHttpRequestOptions;
    let callback: unknown;
    const firstArg = args[0];
    if (typeof firstArg === "string" || firstArg instanceof URL) {
      url = firstArg;
      if (typeof args[1] === "function") {
        options = {};
        callback = args[1];
      } else {
        options = copyNodeHttpOptions(args[1]);
        callback = args[2];
      }
    } else {
      options = copyNodeHttpOptions(firstArg);
      callback = args[1];
    }

    preserveCallerAgentOptions(options);
    preserveDestinationTlsIdentity(url, options);
    const agent = createAgent(options);
    options.agent = agent;
    delete options.createConnection;
    if (url !== undefined) {
      const request = originalMethod(url, options, callback as (res: http.IncomingMessage) => void);
      request.once("close", () => {
        agent.destroy();
      });
      return request;
    }
    const request = originalMethod(options, callback as (res: http.IncomingMessage) => void);
    request.once("close", () => {
      agent.destroy();
    });
    return request;
  }) as TMethod;
}

function proxyAuthorization(proxy: URL): string | undefined {
  if (!proxy.username && !proxy.password) {
    return undefined;
  }
  const username = decodeProxyUserinfoComponent(proxy.username);
  const password = decodeProxyUserinfoComponent(proxy.password);
  return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}

function assertSupportedNodeProxyProtocol(proxy: URL): void {
  if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
    throw new ProxylineError(
      "UNSUPPORTED_PROXY_PROTOCOL",
      `Node HTTP agents support http:// and https:// proxy endpoints: ${proxy.protocol}`,
    );
  }
}

function requestProtocol(
  req: http.ClientRequest,
  options: NodeAgentRequestOptions,
  stackProtocol: "http" | "https" | undefined,
): string {
  const isWebSocket = isWebSocketRequest(req);
  if (isSecureEndpoint(options, stackProtocol)) {
    return isWebSocket ? "wss:" : "https:";
  }
  return isWebSocket ? "ws:" : "http:";
}

function normalizedPort(value: unknown): number | undefined {
  if (typeof value !== "number" && typeof value !== "string") {
    return undefined;
  }
  const port = Number(value);
  return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : undefined;
}

function normalizedPositiveInteger(value: unknown): number | undefined {
  if (typeof value !== "number" && typeof value !== "string") {
    return undefined;
  }
  const integer = Number(value);
  return Number.isInteger(integer) && integer > 0 ? integer : undefined;
}

function requestAuthority(options: NodeAgentRequestOptions): string {
  const rawHost = options.hostname ?? options.host ?? "localhost";
  const parsed = splitHostPort(String(rawHost));
  const host = normalizeProxyTargetHost(parsed.host || "localhost");
  const port = parsed.port ?? normalizedPort(options.port);
  const authorityHost = net.isIPv6(host) ? `[${host}]` : host;
  return port === undefined ? authorityHost : `${authorityHost}:${port}`;
}

function requestDestinationUrl(
  req: http.ClientRequest,
  options: NodeAgentRequestOptions,
  stackProtocol: "http" | "https" | undefined,
): string {
  const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
  return `${requestProtocol(req, options, stackProtocol)}//${requestAuthority(options)}${path}`;
}

function proxyForwardRequestPath(req: http.ClientRequest, options: NodeAgentRequestOptions): string {
  if (/^(?:https?|wss?):\/\//i.test(req.path)) {
    return new URL(req.path).href;
  }
  return requestDestinationUrl(req, options, undefined);
}

function setProxyRequestHeaders(req: http.ClientRequest, proxy: URL, keepAlive: boolean): void {
  const authorization = proxyAuthorization(proxy);
  if (authorization !== undefined) {
    req.setHeader("Proxy-Authorization", authorization);
  }
  if (!req.hasHeader("Proxy-Connection")) {
    req.setHeader("Proxy-Connection", keepAlive ? "Keep-Alive" : "close");
  }
}

function setForwardProxyRequestPath(
  req: http.ClientRequest & { _header?: string | null },
  options: NodeAgentRequestOptions,
): void {
  req._header = null;
  req.path = proxyForwardRequestPath(req, options);
}

function isWebSocketRequest(req: http.ClientRequest): boolean {
  return String(req.getHeader("upgrade") ?? "").toLowerCase() === "websocket";
}

function isSecureEndpoint(
  options: NodeAgentRequestOptions,
  stackProtocol?: "http" | "https",
): boolean {
  return (
    stackProtocol === "https" ||
    options.secureEndpoint === true ||
    options.protocol === "https:" ||
    options.protocol === "wss:" ||
    options.defaultPort === 443
  );
}

function shouldTunnelRequest(
  req: http.ClientRequest,
  options: NodeAgentRequestOptions,
  stackProtocol: "http" | "https" | undefined,
): boolean {
  return isSecureEndpoint(options, stackProtocol) || isWebSocketRequest(req);
}

function requestSurface(
  req: http.ClientRequest,
  options: NodeAgentRequestOptions,
  stackProtocol: "http" | "https" | undefined,
): ProxylineSurface {
  if (isWebSocketRequest(req)) {
    return "websocket";
  }
  return isSecureEndpoint(options, stackProtocol) ? "node-https" : "node-http";
}

function splitHostPort(value: string): { host: string; port?: number } {
  const bracketed = value.match(/^\[([^\]]+)\](?::(\d+))?$/);
  if (bracketed) {
    return {
      host: bracketed[1] ?? "",
      ...(bracketed[2] !== undefined ? { port: Number(bracketed[2]) } : {}),
    };
  }
  const lastColon = value.lastIndexOf(":");
  const hasSingleColon = lastColon !== -1 && value.indexOf(":") === lastColon;
  if (hasSingleColon) {
    const possiblePort = value.slice(lastColon + 1);
    if (/^\d+$/.test(possiblePort)) {
      return { host: value.slice(0, lastColon), port: Number(possiblePort) };
    }
  }
  return { host: value };
}

function normalizeProxyTargetHost(host: string): string {
  const unbracketedHost =
    host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
  if (net.isIP(unbracketedHost) !== 0) {
    return unbracketedHost;
  }
  if (INVALID_PROXY_TARGET_HOST_DELIMITER_PATTERN.test(host)) {
    throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host contains unsafe delimiters.");
  }
  if (INVALID_PROXY_TARGET_HOST_CONTROL_PATTERN.test(host)) {
    throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host contains unsafe characters.");
  }
  const asciiHost = domainToASCII(host);
  if (!asciiHost) {
    throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is not a valid host name.");
  }
  return asciiHost;
}

function connectTarget(options: NodeAgentRequestOptions): { host: string; port: number } {
  const rawHost = options.hostname ?? options.host;
  if (typeof rawHost !== "string") {
    throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target is missing host.");
  }
  const parsed = splitHostPort(rawHost);
  const port = parsed.port ?? Number(options.port);
  if (!parsed.host || !Number.isInteger(port)) {
    throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target is missing host or port.");
  }
  const host = normalizeProxyTargetHost(parsed.host);
  formatConnectAuthority(host, port);
  return { host, port };
}

function destinationTlsConnectOptions(
  options: NodeAgentRequestOptions,
  socket: net.Socket,
): tls.ConnectionOptions {
  const target = connectTarget(options);
  const tlsOptions = { ...options, socket } as tls.ConnectionOptions & Record<string, unknown>;
  tlsOptions.host = target.host;
  delete tlsOptions.path;
  delete tlsOptions.port;
  delete tlsOptions.secureEndpoint;
  delete tlsOptions.agent;
  return tlsOptions;
}

abstract class ProxylineRequestAgent extends http.Agent {
  public addRequest(req: http.ClientRequest, options: NodeAgentRequestOptions): void {
    (http.Agent.prototype as unknown as NodeAddRequestAgent).addRequest.call(this, req, options);
  }

  public createSocket(
    req: http.ClientRequest,
    options: NodeAgentRequestOptions,
    callback: (error: Error | null, socket?: net.Socket) => void,
  ): void {
    // Node chooses queued requests by origin, not global FIFO, and clones options.
    (http.Agent.prototype as unknown as NodeCreateSocketAgent).createSocket.call(
      this, req, { ...options, [pendingRequestSymbol]: req }, callback,
    );
  }

  protected takePendingRequest(options: NodeAgentRequestOptions): http.ClientRequest | undefined {
    const request = options[pendingRequestSymbol];
    // Node retains these options in pooled socket listeners after handoff.
    delete options[pendingRequestSymbol];
    return request;
  }
}

class ProxylineHttpForwardAgent extends ProxylineRequestAgent {
  public readonly options: NodeAgentOptions;
  readonly #keepAlive: boolean;
  readonly #pendingConnectSockets = new Set<net.Socket>();
  readonly #proxy: URL;
  readonly #proxyTls: ProxylineTlsOptions | undefined;
  readonly #proxyConnect: ProxyConnectOptions;

  public constructor(proxy: URL, options: NodeAgentOptions, proxyTls: ProxylineTlsOptions | undefined, proxyConnect: ProxyConnectOptions) {
    super(options);
    this.options = options;
    this.#keepAlive = options.keepAlive === true;
    this.#proxy = proxy;
    this.#proxyTls = proxyTls;
    this.#proxyConnect = proxyConnect;
  }

  public override addRequest(req: http.ClientRequest, options: NodeAgentRequestOptions): void {
    setForwardProxyRequestPath(req as http.ClientRequest & { _header?: string | null }, options);
    setProxyRequestHeaders(req, this.#proxy, this.#keepAlive);
    super.addRequest(req, options);
  }

  public override createConnection(
    options: NodeAgentRequestOptions,
    callback?: (error: Error | null, socket: net.Socket) => void,
  ): net.Socket {
    const request = this.takePendingRequest(options);
    const socket = connectToProxy(this.#proxy, this.#proxyTls, this.#proxyConnect);
    // When Node supplies an async callback, deliver the socket only through that
    // path. Returning the same socket as well double-invokes Agent setup and can
    // hand an unready TLS proxy socket to plain HTTP forward traffic.
    if (callback !== undefined) {
      this.#pendingConnectSockets.add(socket);
      let pendingTimeout: ReturnType<typeof setTimeout> | undefined;
      let settled = false;
      let originalRequestSetTimeout: RequestSetTimeout | undefined;
      let hookedRequestSetTimeout: RequestSetTimeout | undefined;
      let originalRequestDestroy: RequestDestroy | undefined;
      let hookedRequestDestroy: RequestDestroy | undefined;

      const restoreRequestDestroyHook = (): void => {
        if (
          request !== undefined &&
          originalRequestDestroy !== undefined &&
          request.destroy === hookedRequestDestroy
        ) {
          request.destroy = originalRequestDestroy;
        }
        originalRequestDestroy = undefined;
        hookedRequestDestroy = undefined;
      };

      const startPendingTimeout = (timeoutMs: number): void => {
        if (pendingTimeout !== undefined) {
          clearTimeout(pendingTimeout);
        }
        pendingTimeout = setTimeout(() => {
          request?.emit("timeout");
          if (!settled) {
            fail(new ProxylineError("CONNECT_FAILED", "proxy connection timed out"));
          }
        }, timeoutMs);
        pendingTimeout.unref?.();
      };

      const clearPendingTimeout = (): void => {
        if (pendingTimeout !== undefined) {
          clearTimeout(pendingTimeout);
          pendingTimeout = undefined;
        }
      };

      const restoreRequestTimeoutHook = (): void => {
        if (
          request !== undefined &&
          originalRequestSetTimeout !== undefined &&
          request.setTimeout === hookedRequestSetTimeout
        ) {
          request.setTimeout = originalRequestSetTimeout;
        }
        originalRequestSetTimeout = undefined;
        hookedRequestSetTimeout = undefined;
      };

      const cleanup = (): void => {
        clearPendingTimeout();
        this.#pendingConnectSockets.delete(socket);
        restoreRequestTimeoutHook();
        restoreRequestDestroyHook();
        socket.off(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
        socket.off("error", onError);
        socket.off("close", onClosed);
        request?.off("abort", onRequestClosed);
        request?.off("close", onRequestClosed);
        request?.off("error", onRequestClosed);
        request?.off("timeout", onRequestTimedOut);
      };
      const finish = (error: Error | null): void => {
        if (settled) {
          return;
        }
        settled = true;
        cleanup();
        callback(error, socket);
      };
      const fail = (error: Error): void => {
        socket.destroy();
        finish(error);
      };
      const onError = (error: Error): void => {
        finish(error);
      };
      const onClosed = (): void => {
        finish(new ProxylineError("CONNECT_FAILED", "proxy socket closed before connection completed"));
      };
      const onConnected = (): void => {
        finish(null);
      };
      const onRequestTimedOut = (): void => {
        if (!settled) {
          fail(new ProxylineError("CONNECT_FAILED", "proxy connection timed out"));
        }
      };
      const onRequestClosed = (error?: Error): void => {
        if (!settled) {
          fail(error ?? new ProxylineError("CONNECT_FAILED", "request closed before proxy connection completed"));
        }
      };

      if (request !== undefined) {
        originalRequestSetTimeout = request.setTimeout;
        hookedRequestSetTimeout = function hookedSetTimeout(timeout, callback) {
          const result = originalRequestSetTimeout?.call(this, timeout, callback) ?? this;
          const timeoutMs = normalizedPositiveInteger(timeout);
          if (timeoutMs !== undefined) {
            startPendingTimeout(timeoutMs);
          } else {
            clearPendingTimeout();
          }
          return result;
        };
        request.setTimeout = hookedRequestSetTimeout;
      }

      // Prefer explicit agent/request timeout. Default 30s only when both are omitted.
      // Explicit values go through normalizedPositiveInteger first so fractional/invalid
      // timeouts keep the historical no-pending-timer behavior (not Math.trunc to 1ms).
      const requestTimeout = (request as { timeout?: unknown } | undefined)?.timeout;
      const rawTimeout = options.timeout !== undefined ? options.timeout : requestTimeout;
      if (rawTimeout === undefined) {
        const connectTimeoutMs = resolveProxyConnectTimeoutMs(undefined);
        if (connectTimeoutMs !== undefined) {
          startPendingTimeout(connectTimeoutMs);
        }
      } else {
        const timeoutMs = normalizedPositiveInteger(rawTimeout);
        if (timeoutMs !== undefined) {
          startPendingTimeout(timeoutMs);
        }
      }
      request?.once("timeout", onRequestTimedOut);

      socket.once(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
      socket.once("error", onError);
      socket.once("close", onClosed);
      // Node defers ClientRequest.destroy() until the async socket callback.
      if (request !== undefined) {
        originalRequestDestroy = request.destroy;
        hookedRequestDestroy = function hookedDestroy(this: http.ClientRequest, error?: Error) {
          const result = originalRequestDestroy?.call(this, error) ?? this;
          onRequestClosed(error);
          return result;
        };
        request.destroy = hookedRequestDestroy;
      }
      request?.once("abort", onRequestClosed);
      request?.once("close", onRequestClosed);
      request?.once("error", onRequestClosed);
      if (request?.destroyed) {
        onRequestClosed();
      }
      return undefined as unknown as net.Socket;
    }
    return socket;
  }

  public override destroy(): void {
    for (const socket of this.#pendingConnectSockets) {
      socket.destroy(new ProxylineError("CONNECT_FAILED", "proxy connection failed because agent was destroyed"));
    }
    this.#pendingConnectSockets.clear();
    super.destroy();
  }
}

class ProxylineConnectAgent extends ProxylineRequestAgent {
  public readonly options: NodeAgentOptions;
  readonly #keepAlive: boolean;
  readonly #pendingConnectSockets = new Set<net.Socket>();
  readonly #proxy: URL;
  readonly #proxyTls: ProxylineTlsOptions | undefined;
  readonly #proxyConnect: ProxyConnectOptions;

  public constructor(proxy: URL, options: NodeAgentOptions, proxyTls: ProxylineTlsOptions | undefined, proxyConnect: ProxyConnectOptions) {
    super(options);
    this.options = options;
    this.#keepAlive = options.keepAlive === true;
    this.#proxy = proxy;
    this.#proxyTls = proxyTls;
    this.#proxyConnect = proxyConnect;
  }

  public override createConnection(
    options: NodeAgentRequestOptions,
    callback?: (error: Error | null, socket: net.Socket) => void,
  ): net.Socket {
    const request = this.takePendingRequest(options);
    if (callback === undefined) {
      throw new ProxylineError("INVALID_CONNECT_CALLBACK", "CONNECT agents require an async socket callback.");
    }

    const proxySocket = connectToProxy(this.#proxy, this.#proxyTls, this.#proxyConnect);
    this.#pendingConnectSockets.add(proxySocket);
    let pendingTimeout: ReturnType<typeof setTimeout> | undefined;
    let settled = false;
    let responseBuffer = Buffer.alloc(0);
    let originalRequestSetTimeout: RequestSetTimeout | undefined;
    let hookedRequestSetTimeout: RequestSetTimeout | undefined;
    let originalRequestDestroy: RequestDestroy | undefined;
    let hookedRequestDestroy: RequestDestroy | undefined;
    let tlsSocket: tls.TLSSocket | undefined;

    const startPendingTimeout = (timeoutMs: number): void => {
      if (pendingTimeout !== undefined) {
        clearTimeout(pendingTimeout);
      }
      pendingTimeout = setTimeout(() => {
        request?.emit("timeout");
        if (!settled) {
          fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT timed out"));
        }
      }, timeoutMs);
      pendingTimeout.unref?.();
    };

    const clearPendingTimeout = (): void => {
      if (pendingTimeout !== undefined) {
        clearTimeout(pendingTimeout);
        pendingTimeout = undefined;
      }
    };

    const restoreRequestTimeoutHook = (): void => {
      if (
        request !== undefined &&
        originalRequestSetTimeout !== undefined &&
        request.setTimeout === hookedRequestSetTimeout
      ) {
        request.setTimeout = originalRequestSetTimeout;
      }
      originalRequestSetTimeout = undefined;
      hookedRequestSetTimeout = undefined;
    };

    const restoreRequestDestroyHook = (): void => {
      if (
        request !== undefined &&
        originalRequestDestroy !== undefined &&
        request.destroy === hookedRequestDestroy
      ) {
        request.destroy = originalRequestDestroy;
      }
      originalRequestDestroy = undefined;
      hookedRequestDestroy = undefined;
    };

    const cleanupProxyHandshakeListeners = (): void => {
      proxySocket.off("data", onData);
      proxySocket.off("error", onError);
      proxySocket.off("end", onClosed);
      proxySocket.off("close", onClosed);
      proxySocket.off("connect", onConnected);
      proxySocket.off("secureConnect", onConnected);
    };

    const cleanup = (): void => {
      clearPendingTimeout();
      this.#pendingConnectSockets.delete(proxySocket);
      if (tlsSocket !== undefined) {
        this.#pendingConnectSockets.delete(tlsSocket);
      }
      restoreRequestTimeoutHook();
      restoreRequestDestroyHook();
      cleanupProxyHandshakeListeners();
      request?.off("abort", onRequestClosed);
      request?.off("close", onRequestClosed);
      request?.off("error", onRequestClosed);
      request?.off("timeout", onRequestTimedOut);
    };

    const finish = (error: Error | null, socket: net.Socket): void => {
      if (settled) {
        if (error === null) {
          socket.destroy();
        }
        return;
      }
      settled = true;
      cleanup();
      callback(error, socket);
    };

    const fail = (error: Error): void => {
      proxySocket.destroy();
      finish(error, proxySocket);
    };

    const onConnected = (): void => {
      try {
        const { host, port } = connectTarget(options);
        const authority = formatConnectAuthority(host, port);
        const headers = [
          `CONNECT ${authority} HTTP/1.1`,
          `Host: ${authority}`,
          `Proxy-Connection: ${this.#keepAlive ? "Keep-Alive" : "close"}`,
        ];
        const authorization = proxyAuthorization(this.#proxy);
        if (authorization !== undefined) {
          headers.push(`Proxy-Authorization: ${authorization}`);
        }
        proxySocket.write([...headers, "", ""].join("\r\n"));
      } catch (error) {
        fail(error instanceof Error ? error : new Error(String(error)));
      }
    };

    const onData = (chunk: Buffer): void => {
      responseBuffer = Buffer.concat([responseBuffer, chunk]);
      const headerEnd = responseBuffer.indexOf("\r\n\r\n");
      if (headerEnd === -1) {
        if (responseBuffer.length > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
          fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT response headers were too large"));
        }
        return;
      }
      const bodyOffset = headerEnd + 4;
      if (bodyOffset > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
        fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT response headers were too large"));
        return;
      }
      const statusLine = responseBuffer.subarray(0, bodyOffset).toString("latin1").split("\r\n", 1)[0] ?? "";
      if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
        fail(new ProxylineError("CONNECT_FAILED", statusLine || "proxy returned an invalid CONNECT response"));
        return;
      }
      const tunneledBytes = responseBuffer.subarray(bodyOffset);
      cleanupProxyHandshakeListeners();
      if (tunneledBytes.length > 0) {
        proxySocket.unshift(tunneledBytes);
      }
      if (!isSecureEndpoint(options)) {
        finish(null, proxySocket);
        return;
      }
      const currentTlsSocket = tls.connect(destinationTlsConnectOptions(options, proxySocket));
      tlsSocket = currentTlsSocket;
      this.#pendingConnectSockets.add(currentTlsSocket);
      const onTlsError = (error: Error): void => {
        currentTlsSocket.off("close", onTlsClosed);
        finish(error, currentTlsSocket);
      };
      const onTlsSecureConnect = (): void => {
        currentTlsSocket.off("error", onTlsError);
        currentTlsSocket.off("close", onTlsClosed);
        finish(null, currentTlsSocket);
      };
      const onTlsClosed = (): void => {
        finish(
          new ProxylineError("CONNECT_FAILED", "destination TLS socket closed before secureConnect"),
          currentTlsSocket,
        );
      };
      currentTlsSocket.once("secureConnect", onTlsSecureConnect);
      currentTlsSocket.once("error", onTlsError);
      currentTlsSocket.once("close", onTlsClosed);
    };

    const onError = (error: Error): void => {
      fail(error);
    };

    const onClosed = (): void => {
      fail(new ProxylineError("CONNECT_FAILED", "proxy socket closed before CONNECT completed"));
    };

    const onRequestClosed = (error?: Error): void => {
      if (!settled) {
        fail(error ?? new ProxylineError("CONNECT_FAILED", "request closed before proxy CONNECT completed"));
      }
    };

    const onRequestTimedOut = (): void => {
      if (!settled) {
        fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT timed out"));
      }
    };

    if (request !== undefined) {
      originalRequestSetTimeout = request.setTimeout;
      hookedRequestSetTimeout = function hookedSetTimeout(timeout, callback) {
        const result = originalRequestSetTimeout?.call(this, timeout, callback) ?? this;
        const timeoutMs = normalizedPositiveInteger(timeout);
        if (timeoutMs !== undefined) {
          startPendingTimeout(timeoutMs);
        } else {
          clearPendingTimeout();
        }
        return result;
      };
      request.setTimeout = hookedRequestSetTimeout;
    }

    // Prefer explicit agent/request timeout. Default 30s only when both are omitted.
    // Explicit values go through normalizedPositiveInteger first so fractional/invalid
    // timeouts keep the historical no-pending-timer behavior (not Math.trunc to 1ms).
    const requestTimeout = (request as { timeout?: unknown } | undefined)?.timeout;
    const rawTimeout = options.timeout !== undefined ? options.timeout : requestTimeout;
    if (rawTimeout === undefined) {
      const connectTimeoutMs = resolveProxyConnectTimeoutMs(undefined);
      if (connectTimeoutMs !== undefined) {
        startPendingTimeout(connectTimeoutMs);
      }
    } else {
      const timeoutMs = normalizedPositiveInteger(rawTimeout);
      if (timeoutMs !== undefined) {
        startPendingTimeout(timeoutMs);
      }
    }
    request?.once("timeout", onRequestTimedOut);
    // Node defers ClientRequest.destroy() until the async socket callback.
    if (request !== undefined) {
      originalRequestDestroy = request.destroy;
      hookedRequestDestroy = function hookedDestroy(this: http.ClientRequest, error?: Error) {
        const result = originalRequestDestroy?.call(this, error) ?? this;
        onRequestClosed(error);
        return result;
      };
      request.destroy = hookedRequestDestroy;
    }
    request?.once("abort", onRequestClosed);
    request?.once("close", onRequestClosed);
    request?.once("error", onRequestClosed);
    if (request?.destroyed) {
      onRequestClosed();
    }

    proxySocket.once(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
    proxySocket.on("data", onData);
    proxySocket.once("error", onError);
    proxySocket.once("end", onClosed);
    proxySocket.once("close", onClosed);

    return undefined as unknown as net.Socket;
  }

  public override destroy(): void {
    for (const socket of this.#pendingConnectSockets) {
      socket.destroy();
    }
    this.#pendingConnectSockets.clear();
    super.destroy();
  }
}

export class ProxylineNodeProxyAgent extends http.Agent {
  public readonly options: NodeAgentOptions;
  readonly #agents = new Map<string, NodeAddRequestAgent>();
  readonly #defaultProtocol: "http" | "https";
  readonly #getProxyForUrl: (
    url: string,
    surface?: ProxylineSurface,
    request?: http.ClientRequest,
  ) => string;
  readonly #httpAgent: NodeAddRequestAgent;
  readonly #httpsAgent: NodeAddRequestAgent;
  readonly #proxyTls: ProxylineTlsOptions | undefined;
  readonly #resolveProxyConnectOptions: ResolveProxyConnectOptions | undefined;

  public constructor(options: NodeProxyAgentOptions) {
    const { defaultProtocol = "http", getProxyForUrl, proxyTls, resolveProxyConnectOptions, ...agentOptions } = options;
    super(agentOptions);
    if (nodeAgentDefaultPorts.get(this) === 80) {
      nodeAgentDefaultPorts.delete(this);
    }
    this.options = agentOptions;
    this.#defaultProtocol = defaultProtocol;
    this.#getProxyForUrl = getProxyForUrl;
    this.#proxyTls = proxyTls;
    this.#resolveProxyConnectOptions = resolveProxyConnectOptions;
    this.#httpAgent = new http.Agent(agentOptions) as NodeAddRequestAgent;
    this.#httpsAgent = new https.Agent(agentOptions) as unknown as NodeAddRequestAgent;
  }

  public get defaultPort(): number {
    const stackProtocol = this.#callStackProtocol();
    return nodeAgentDefaultPorts.get(this) ??
      ((stackProtocol ?? this.#defaultProtocol) === "https" ? 443 : 80);
  }

  public set defaultPort(value: number) {
    nodeAgentDefaultPorts.set(this, value);
  }

  public get protocol(): string {
    return `${this.#callStackProtocol() ?? this.#defaultProtocol}:`;
  }

  public set protocol(_value: string) {
    // Node's http.Agent constructor assigns this, but this wrapper is dual-use.
  }

  public getProxyForUrl(url: string, request?: http.ClientRequest): string {
    return this.#getProxyForUrl(url, undefined, request);
  }

  #callStackProtocol(): "http" | "https" | undefined {
    const originalStackTraceLimit = Error.stackTraceLimit;
    const errorConstructor = Error as unknown as { prepareStackTrace?: unknown };
    const originalPrepareStackTrace = errorConstructor.prepareStackTrace;
    if (typeof originalStackTraceLimit !== "number" || originalStackTraceLimit < 20) {
      // Node reads agent.protocol/defaultPort before addRequest, so this is the only caller signal.
      Error.stackTraceLimit = 20;
    }
    let stack: string | undefined;
    try {
      delete errorConstructor.prepareStackTrace;
      stack = new Error().stack;
    } finally {
      if (originalPrepareStackTrace === undefined) {
        delete errorConstructor.prepareStackTrace;
      } else {
        errorConstructor.prepareStackTrace = originalPrepareStackTrace;
      }
      Error.stackTraceLimit = originalStackTraceLimit;
    }
    if (typeof stack !== "string") {
      return undefined;
    }
    for (const line of stack.split("\n")) {
      if (line.includes("node:https:")) {
        return "https";
      }
      if (line.includes("node:http:")) {
        return "http";
      }
    }
    return undefined;
  }

  public addRequest(req: http.ClientRequest, options: NodeAgentRequestOptions): void {
    const stackProtocol = this.#callStackProtocol();
    const agentOptions =
      stackProtocol === "https" && options.secureEndpoint !== true
        ? { ...options, secureEndpoint: true }
        : options;
    const url = requestDestinationUrl(req, agentOptions, stackProtocol);
    const surface = requestSurface(req, agentOptions, stackProtocol);
    const proxy = this.#getProxyForUrl(url, surface, req);
    if (!proxy) {
      (isSecureEndpoint(agentOptions, stackProtocol) ? this.#httpsAgent : this.#httpAgent)
        .addRequest(req, agentOptions);
      return;
    }
    const proxyUrl = new URL(proxy);
    assertSupportedNodeProxyProtocol(proxyUrl);
    const tunnel = shouldTunnelRequest(req, agentOptions, stackProtocol);
    const key = `${tunnel ? "connect" : "forward"}:${proxyUrl.href}`;
    let agent = this.#agents.get(key);
    if (agent === undefined) {
      // Each cached child owns a snapshot; later mutation of the caller's
      // options cannot change an already-selected proxy connection policy.
      const proxyConnect = { ...this.#resolveProxyConnectOptions?.(proxyUrl.href) };
      const newAgent = tunnel
        ? new ProxylineConnectAgent(proxyUrl, this.options, this.#proxyTls, proxyConnect)
        : new ProxylineHttpForwardAgent(proxyUrl, this.options, this.#proxyTls, proxyConnect);
      agent = newAgent;
      this.#agents.set(key, agent);
    }
    agent.addRequest(req, agentOptions);
  }

  public override destroy(): void {
    for (const agent of this.#agents.values()) {
      agent.destroy();
    }
    this.#agents.clear();
    this.#httpAgent.destroy();
    this.#httpsAgent.destroy();
    super.destroy();
  }
}

export function createNodeProxyAgent(
  resolver: ProxyResolver,
  proxyCa: string | undefined,
  defaultProtocol: "http" | "https" = "http",
): ProxylineNodeProxyAgent {
  return new ProxylineNodeProxyAgent({
    defaultProtocol,
    getProxyForUrl: resolver.getProxyForUrl,
    ...(proxyCa !== undefined ? { proxyTls: { ca: proxyCa } } : {}),
  });
}

export function createDirectNodeAgent(): ProxylineNodeProxyAgent {
  return new ProxylineNodeProxyAgent({
    getProxyForUrl: () => "",
  });
}

export type AmbientNodeProxyAgentOptions = {
  env?: ProxyEnvSnapshot;
  protocol?: "http" | "https";
  proxyTls?: ProxylineTlsOptions;
  resolveProxyConnectOptions?: ResolveProxyConnectOptions;
};

function ambientProbeUrl(protocol: "http" | "https"): string {
  return `${protocol}://proxyline.invalid/`;
}

export function hasAmbientNodeProxyConfigured(
  options: AmbientNodeProxyAgentOptions = {},
): boolean {
  const env = options.env ?? readProxyEnv();
  const protocol = options.protocol ?? "https";
  return resolveAmbientProxyForUrl(ambientProbeUrl(protocol), env) !== undefined;
}

export function createAmbientNodeProxyAgent(
  options: AmbientNodeProxyAgentOptions = {},
): ProxylineNodeProxyAgent | undefined {
  const env = options.env ?? readProxyEnv();
  const protocol = options.protocol ?? "https";
  if (resolveAmbientProxyForUrl(ambientProbeUrl(protocol), env) === undefined) {
    return undefined;
  }
  return new ProxylineNodeProxyAgent({
    defaultProtocol: protocol,
    getProxyForUrl: (url) => resolveAmbientProxyForUrl(url, env) ?? "",
    ...(options.proxyTls !== undefined ? { proxyTls: options.proxyTls } : {}),
    ...(options.resolveProxyConnectOptions !== undefined
      ? { resolveProxyConnectOptions: options.resolveProxyConnectOptions }
      : {}),
  });
}
