// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import { Agent as HTTPSAgent } from 'node:https';
import type { AgentOptions, RequestOptions } from 'node:https';
import type { LookupAddress } from 'node:dns';
import type net from 'node:net';
import tls from 'node:tls';
import type { ConnectionOptions } from 'node:tls';
import { callbackify, promisify } from 'node:util';
import pTimeout from 'p-timeout';

import { createLogger } from '../logging/log.std.ts';
import {
  electronLookup as electronLookupWithCb,
  interleaveAddresses,
} from './dns.node.ts';
import { strictAssert } from './assert.std.ts';
import { parseIntOrThrow } from './parseIntOrThrow.std.ts';
import { sleep } from './sleep.std.ts';
import { SECOND } from './durations/index.std.ts';
import { dropNull } from './dropNull.std.ts';
import { explodePromise } from './explodePromise.std.ts';

const log = createLogger('createHTTPSAgent');

// https://www.rfc-editor.org/rfc/rfc8305#section-8 recommends 250ms, but since
// we also try to establish a TLS session - use higher value.
const DELAY_MS = 500;

// Warning threshold
const CONNECT_THRESHOLD_MS = SECOND;

const CONNECT_TIMEOUT_MS = 10 * SECOND;

const electronLookup = promisify(electronLookupWithCb);

const HOST_LOG_ALLOWLIST = new Set([
  // Production
  'chat.signal.org',
  'storage.signal.org',
  'cdsi.signal.org',
  'cdn.signal.org',
  'cdn2.signal.org',
  'cdn3.signal.org',

  // Staging
  'chat.staging.signal.org',
  'storage-staging.signal.org',
  'cdsi.staging.signal.org',
  'cdn-staging.signal.org',
  'cdn2-staging.signal.org',
  'create.staging.signal.art',

  // Common
  'updates2.signal.org',
  'sfu.voip.signal.org',
]);

class Agent extends HTTPSAgent {
  constructor(options: AgentOptions = {}) {
    super({
      ...options,
      lookup: electronLookup,
    });
  }

  // @ts-expect-error - callbackify() returns the wrong type
  public createConnection: HTTPSAgent['createConnection'] = callbackify(
    async (options: RequestOptions): Promise<net.Socket> => {
      const { host = options.hostname, port: portString } = options;
      strictAssert(host, 'Agent.createConnection: Missing options.host');
      strictAssert(portString, 'Agent.createConnection: Missing options.port');

      const port = parseIntOrThrow(
        portString,
        'Agent.createConnection: options.port is not an integer'
      );

      const addresses = await electronLookup(host, { all: true });

      const start = Date.now();

      const { socket, address, v4Attempts, v6Attempts } = await happyEyeballs({
        addresses,
        port,
        tlsOptions: {
          ca: options.ca,
          servername: options.servername ?? dropNull(options.host),
        },
      });

      if (HOST_LOG_ALLOWLIST.has(host)) {
        const duration = Date.now() - start;
        const logLine =
          `createHTTPSAgent.createConnection(${host}): connected to ` +
          `IPv${address.family} addr after ${duration}ms ` +
          `(attempts v4=${v4Attempts} v6=${v6Attempts})`;

        if (v4Attempts + v6Attempts > 1 || duration > CONNECT_THRESHOLD_MS) {
          log.warn(logLine);
        } else {
          log.info(logLine);
        }
      }

      return socket;
    }
  );
}

export type HappyEyeballsOptions = Readonly<{
  addresses: ReadonlyArray<LookupAddress>;
  port?: number;
  connect?: typeof defaultConnect;
  tlsOptions?: ConnectionOptions;
}>;

export type HappyEyeballsResult = Readonly<{
  socket: net.Socket;
  address: LookupAddress;
  v4Attempts: number;
  v6Attempts: number;
}>;

export async function happyEyeballs({
  addresses,
  port = 443,
  tlsOptions,
  connect = defaultConnect,
}: HappyEyeballsOptions): Promise<HappyEyeballsResult> {
  let v4Attempts = 0;
  let v6Attempts = 0;

  const interleaved = interleaveAddresses(addresses);
  const abortControllers = interleaved.map(() => new AbortController());

  const results = await Promise.allSettled(
    interleaved.map(async (addr, index) => {
      const abortController = abortControllers[index];
      strictAssert(abortController, 'Missing abortController');
      if (index !== 0) {
        await sleep(index * DELAY_MS, abortController.signal);
      }

      if (addr.family === 4) {
        v4Attempts += 1;
      } else {
        v6Attempts += 1;
      }

      let socket: net.Socket;
      try {
        socket = await pTimeout(
          connect({
            address: addr.address,
            port,
            tlsOptions,
            abortSignal: abortController.signal,
          }),
          {
            milliseconds: CONNECT_TIMEOUT_MS,
            message: 'createHTTPSAgent.connect: connection timed out',
          }
        );
      } catch (error) {
        abortController.abort();
        throw error;
      }

      if (abortController.signal.aborted) {
        throw new Error('Aborted');
      }

      // Abort other connection attempts
      for (const otherController of abortControllers) {
        if (otherController !== abortController) {
          otherController.abort();
        }
      }
      return { socket, abortController, index };
    })
  );

  const fulfilled = results.find(({ status }) => status === 'fulfilled');
  if (fulfilled) {
    strictAssert(
      fulfilled.status === 'fulfilled',
      'Fulfilled promise was not fulfilled'
    );
    const { socket, index } = fulfilled.value;
    const address = interleaved[index];
    strictAssert(address, 'Missing address');

    return {
      socket,
      address,
      v4Attempts,
      v6Attempts,
    };
  }

  const firstResult = results[0];
  strictAssert(firstResult, 'Missing firstResult');

  strictAssert(
    firstResult.status === 'rejected',
    'No fulfilled promises, but no rejected either'
  );

  // Abort all connection attempts for consistency
  for (const controller of abortControllers) {
    controller.abort();
  }
  throw firstResult.reason;
}

export type ConnectOptionsType = Readonly<{
  port: number;
  address: string;
  tlsOptions?: ConnectionOptions;
  abortSignal?: AbortSignal;
}>;

async function defaultConnect({
  port,
  address,
  tlsOptions,
  abortSignal,
}: ConnectOptionsType): Promise<net.Socket> {
  const socket = tls.connect(port, address, {
    ...tlsOptions,
  });
  abortSignal?.addEventListener('abort', () =>
    socket.destroy(new Error('Aborted'))
  );

  const { promise: onHandshake, resolve, reject } = explodePromise<void>();

  socket.once('secureConnect', resolve);
  socket.once('error', reject);

  try {
    await onHandshake;
  } finally {
    socket.removeListener('secureConnect', resolve);
    socket.removeListener('error', reject);
  }

  return socket;
}

export function createHTTPSAgent(options: AgentOptions = {}): Agent {
  return new Agent(options);
}
