import net from "node:net";
import tls from "node:tls";
import { connectToProxy, type ProxyConnectOptions } from "./proxy-socket.js";
import { ProxylineError, decodeProxyUserinfoComponent, type ProxylineTlsOptions, redactProxyUrl } from "./shared.js";

export type OpenProxyConnectTunnelOptions = Readonly<{
  proxyUrl: string | URL;
  proxyTls?: ProxylineTlsOptions;
  proxyConnect?: ProxyConnectOptions;
  targetHost: string;
  targetPort: number;
  /**
   * Overall budget for the CONNECT handshake.
   * Omit for the default (30s). Pass `0` (or a negative value) for no timeout.
   */
  timeoutMs?: number;
  signal?: AbortSignal;
}>;

/** Default CONNECT handshake budget when `timeoutMs` is omitted. */
export const DEFAULT_PROXY_CONNECT_TIMEOUT_MS = 30_000;

/**
 * Resolve the CONNECT timeout to apply.
 * - `undefined` → default 30s
 * - `<= 0` → no timeout (unbounded)
 * - positive → truncated milliseconds
 */
export function resolveProxyConnectTimeoutMs(timeoutMs: number | undefined): number | undefined {
  if (timeoutMs === undefined) {
    return DEFAULT_PROXY_CONNECT_TIMEOUT_MS;
  }
  return timeoutMs > 0 ? Math.trunc(timeoutMs) : undefined;
}

const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
const INVALID_CONNECT_AUTHORITY_PATTERN = /[\u0000-\u0020\u007f]/;
const INVALID_CONNECT_HOST_DELIMITER_PATTERN = /[/:?#@\\]/;

type ProxySocket = net.Socket | tls.TLSSocket;

function resolveProxyAuthorization(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")}`;
}

export function formatConnectAuthority(targetHost: string, targetPort: number): string {
  if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65_535) {
    throw new ProxylineError("INVALID_CONNECT_TARGET", `Invalid CONNECT target port: ${targetPort}`);
  }
  if (!targetHost || INVALID_CONNECT_AUTHORITY_PATTERN.test(targetHost)) {
    throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is empty or unsafe.");
  }
  const unbracketedHost =
    targetHost.startsWith("[") && targetHost.endsWith("]")
      ? targetHost.slice(1, -1)
      : targetHost;
  if (net.isIP(unbracketedHost) === 6) {
    return `[${unbracketedHost}]:${targetPort}`;
  }
  if (targetHost.includes("[") || targetHost.includes("]")) {
    throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host has invalid brackets.");
  }
  if (targetHost.includes(":") || INVALID_CONNECT_HOST_DELIMITER_PATTERN.test(targetHost)) {
    throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is not a host name.");
  }
  return `${targetHost}:${targetPort}`;
}

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

function writeConnectRequest(socket: net.Socket, proxy: URL, target: string): void {
  const headers = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`, "Proxy-Connection: Keep-Alive"];
  const authorization = resolveProxyAuthorization(proxy);
  if (authorization !== undefined) {
    headers.push(`Proxy-Authorization: ${authorization}`);
  }
  socket.write([...headers, "", ""].join("\r\n"));
}

function failConnect(proxy: URL, error: unknown): Error {
  const message = error instanceof Error ? error.message : String(error);
  return new ProxylineError("CONNECT_FAILED", `Proxy CONNECT failed via ${redactProxyUrl(proxy)}: ${message}`);
}

export async function openProxyConnectTunnel(
  options: OpenProxyConnectTunnelOptions,
): Promise<ProxySocket> {
  const proxy = options.proxyUrl instanceof URL ? new URL(options.proxyUrl.href) : new URL(options.proxyUrl);
  assertSupportedConnectProxyProtocol(proxy);
  const target = formatConnectAuthority(options.targetHost, options.targetPort);

  return await new Promise<ProxySocket>((resolve, reject) => {
    let settled = false;
    let responseBuffer = Buffer.alloc(0);
    let timeout: NodeJS.Timeout | undefined;
    let socket: ProxySocket | undefined;

    const cleanup = (): void => {
      if (timeout !== undefined) {
        clearTimeout(timeout);
        timeout = undefined;
      }
      socket?.off("data", onData);
      socket?.off("error", onError);
      socket?.off("end", onClosed);
      socket?.off("close", onClosed);
      socket?.off("connect", onConnected);
      socket?.off("secureConnect", onConnected);
      options.signal?.removeEventListener("abort", onAbort);
    };

    const fail = (error: unknown): void => {
      if (settled) {
        return;
      }
      settled = true;
      cleanup();
      socket?.destroy();
      reject(error instanceof ProxylineError ? error : failConnect(proxy, error));
    };

    const succeed = (connectedSocket: ProxySocket, tunneledBytes: Buffer | undefined): void => {
      if (settled) {
        connectedSocket.destroy();
        return;
      }
      settled = true;
      cleanup();
      if (tunneledBytes !== undefined && tunneledBytes.length > 0) {
        connectedSocket.unshift(tunneledBytes);
      }
      resolve(connectedSocket);
    };

    const onConnected = (): void => {
      if (socket === undefined) {
        fail(new Error("proxy socket missing after connect"));
        return;
      }
      try {
        writeConnectRequest(socket, proxy, target);
      } catch (error) {
        fail(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 Error(`proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`));
        }
        return;
      }

      const bodyOffset = headerEnd + 4;
      if (bodyOffset > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
        fail(new Error(`proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`));
        return;
      }
      const responseHeader = responseBuffer.subarray(0, bodyOffset).toString("latin1");
      const statusLine = responseHeader.split("\r\n", 1)[0] ?? "";
      if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
        fail(new Error(statusLine || "proxy returned an invalid CONNECT response"));
        return;
      }

      if (socket === undefined) {
        fail(new Error("proxy socket missing after CONNECT response"));
        return;
      }
      const tunneledBytes =
        responseBuffer.length > bodyOffset ? responseBuffer.subarray(bodyOffset) : undefined;
      succeed(socket, tunneledBytes);
    };

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

    const onClosed = (): void => {
      fail(new Error("proxy socket closed before CONNECT response"));
    };

    const onAbort = (): void => {
      fail(options.signal?.reason ?? new Error("proxy CONNECT aborted"));
    };

    try {
      options.signal?.addEventListener("abort", onAbort, { once: true });
      if (options.signal?.aborted) {
        onAbort();
        return;
      }
      const connectTimeoutMs = resolveProxyConnectTimeoutMs(options.timeoutMs);
      if (connectTimeoutMs !== undefined) {
        timeout = setTimeout(() => {
          fail(new Error(`proxy CONNECT timed out after ${connectTimeoutMs}ms`));
        }, connectTimeoutMs);
      }
      socket = connectToProxy(proxy, options.proxyTls, options.proxyConnect);
      socket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
      socket.on("data", onData);
      socket.once("error", onError);
      socket.once("end", onClosed);
      socket.once("close", onClosed);
    } catch (error) {
      fail(error);
    }
  });
}
