import * as fs from 'fs/promises';
import { type MongoCryptContext, type MongoCryptKMSRequest } from 'mongodb-client-encryption';
import * as net from 'net';
import * as tls from 'tls';

import {
  type BSONSerializeOptions,
  deserialize,
  type Document,
  pluckBSONSerializeOptions,
  serialize
} from '../bson';
import { type ProxyOptions } from '../cmap/connection';
import { getSocks, type SocksLib } from '../deps';
import { type MongoClient, type MongoClientOptions } from '../mongo_client';
import { BufferPool, MongoDBCollectionNamespace } from '../utils';
import { type DataKey } from './client_encryption';
import { MongoCryptError } from './errors';
import { type MongocryptdManager } from './mongocryptd_manager';
import { type ClientEncryptionDataKeyProvider, type KMSProviders } from './providers';

let socks: SocksLib | null = null;
function loadSocks(): SocksLib {
  if (socks == null) {
    const socksImport = getSocks();
    if ('kModuleError' in socksImport) {
      throw socksImport.kModuleError;
    }
    socks = socksImport;
  }
  return socks;
}

// libmongocrypt states
const MONGOCRYPT_CTX_ERROR = 0;
const MONGOCRYPT_CTX_NEED_MONGO_COLLINFO = 1;
const MONGOCRYPT_CTX_NEED_MONGO_MARKINGS = 2;
const MONGOCRYPT_CTX_NEED_MONGO_KEYS = 3;
const MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS = 7;
const MONGOCRYPT_CTX_NEED_KMS = 4;
const MONGOCRYPT_CTX_READY = 5;
const MONGOCRYPT_CTX_DONE = 6;

const HTTPS_PORT = 443;

const stateToString = new Map([
  [MONGOCRYPT_CTX_ERROR, 'MONGOCRYPT_CTX_ERROR'],
  [MONGOCRYPT_CTX_NEED_MONGO_COLLINFO, 'MONGOCRYPT_CTX_NEED_MONGO_COLLINFO'],
  [MONGOCRYPT_CTX_NEED_MONGO_MARKINGS, 'MONGOCRYPT_CTX_NEED_MONGO_MARKINGS'],
  [MONGOCRYPT_CTX_NEED_MONGO_KEYS, 'MONGOCRYPT_CTX_NEED_MONGO_KEYS'],
  [MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS, 'MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS'],
  [MONGOCRYPT_CTX_NEED_KMS, 'MONGOCRYPT_CTX_NEED_KMS'],
  [MONGOCRYPT_CTX_READY, 'MONGOCRYPT_CTX_READY'],
  [MONGOCRYPT_CTX_DONE, 'MONGOCRYPT_CTX_DONE']
]);

const INSECURE_TLS_OPTIONS = [
  'tlsInsecure',
  'tlsAllowInvalidCertificates',
  'tlsAllowInvalidHostnames',

  // These options are disallowed by the spec, so we explicitly filter them out if provided, even
  // though the StateMachine does not declare support for these options.
  'tlsDisableOCSPEndpointCheck',
  'tlsDisableCertificateRevocationCheck'
];

/**
 * Helper function for logging. Enabled by setting the environment flag MONGODB_CRYPT_DEBUG.
 * @param msg - Anything you want to be logged.
 */
function debug(msg: unknown) {
  if (process.env.MONGODB_CRYPT_DEBUG) {
    // eslint-disable-next-line no-console
    console.error(msg);
  }
}

declare module 'mongodb-client-encryption' {
  // the properties added to `MongoCryptContext` here are only used for the `StateMachine`'s
  // execute method and are not part of the C++ bindings.
  interface MongoCryptContext {
    id: number;
    document: Document;
    ns: string;
  }
}

/**
 * @public
 *
 * TLS options to use when connecting. The spec specifically calls out which insecure
 * tls options are not allowed:
 *
 *  - tlsAllowInvalidCertificates
 *  - tlsAllowInvalidHostnames
 *  - tlsInsecure
 *
 * These options are not included in the type, and are ignored if provided.
 */
export type ClientEncryptionTlsOptions = Pick<
  MongoClientOptions,
  'tlsCAFile' | 'tlsCertificateKeyFile' | 'tlsCertificateKeyFilePassword'
>;

/** @public */
export type CSFLEKMSTlsOptions = {
  aws?: ClientEncryptionTlsOptions;
  gcp?: ClientEncryptionTlsOptions;
  kmip?: ClientEncryptionTlsOptions;
  local?: ClientEncryptionTlsOptions;
  azure?: ClientEncryptionTlsOptions;
};

/**
 * @internal
 *
 * An interface representing an object that can be passed to the `StateMachine.execute` method.
 *
 * Not all properties are required for all operations.
 */
export interface StateMachineExecutable {
  _keyVaultNamespace: string;
  _keyVaultClient: MongoClient;
  askForKMSCredentials: () => Promise<KMSProviders>;

  /** only used for auto encryption */
  _metaDataClient?: MongoClient;
  /** only used for auto encryption */
  _mongocryptdClient?: MongoClient;
  /** only used for auto encryption */
  _mongocryptdManager?: MongocryptdManager;
}

export type StateMachineOptions = {
  /** socks5 proxy options, if set. */
  proxyOptions: ProxyOptions;

  /** TLS options for KMS requests, if set. */
  tlsOptions: CSFLEKMSTlsOptions;
} & Pick<BSONSerializeOptions, 'promoteLongs' | 'promoteValues'>;

/**
 * @internal
 * An internal class that executes across a MongoCryptContext until either
 * a finishing state or an error is reached. Do not instantiate directly.
 */
export class StateMachine {
  constructor(
    private options: StateMachineOptions,
    private bsonOptions = pluckBSONSerializeOptions(options)
  ) {}

  /**
   * Executes the state machine according to the specification
   */
  async execute<T extends Document>(
    executor: StateMachineExecutable,
    context: MongoCryptContext
  ): Promise<T> {
    const keyVaultNamespace = executor._keyVaultNamespace;
    const keyVaultClient = executor._keyVaultClient;
    const metaDataClient = executor._metaDataClient;
    const mongocryptdClient = executor._mongocryptdClient;
    const mongocryptdManager = executor._mongocryptdManager;
    let result: T | null = null;

    while (context.state !== MONGOCRYPT_CTX_DONE && context.state !== MONGOCRYPT_CTX_ERROR) {
      debug(`[context#${context.id}] ${stateToString.get(context.state) || context.state}`);

      switch (context.state) {
        case MONGOCRYPT_CTX_NEED_MONGO_COLLINFO: {
          const filter = deserialize(context.nextMongoOperation());
          if (!metaDataClient) {
            throw new MongoCryptError(
              'unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_COLLINFO but metadata client is undefined'
            );
          }
          const collInfo = await this.fetchCollectionInfo(metaDataClient, context.ns, filter);

          if (collInfo) {
            context.addMongoOperationResponse(collInfo);
          }

          context.finishMongoOperation();
          break;
        }

        case MONGOCRYPT_CTX_NEED_MONGO_MARKINGS: {
          const command = context.nextMongoOperation();
          if (!mongocryptdClient) {
            throw new MongoCryptError(
              'unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_MARKINGS but mongocryptdClient is undefined'
            );
          }

          // When we are using the shared library, we don't have a mongocryptd manager.
          const markedCommand: Uint8Array = mongocryptdManager
            ? await mongocryptdManager.withRespawn(
                this.markCommand.bind(this, mongocryptdClient, context.ns, command)
              )
            : await this.markCommand(mongocryptdClient, context.ns, command);

          context.addMongoOperationResponse(markedCommand);
          context.finishMongoOperation();
          break;
        }

        case MONGOCRYPT_CTX_NEED_MONGO_KEYS: {
          const filter = context.nextMongoOperation();
          const keys = await this.fetchKeys(keyVaultClient, keyVaultNamespace, filter);

          if (keys.length === 0) {
            // This is kind of a hack.  For `rewrapManyDataKey`, we have tests that
            // guarantee that when there are no matching keys, `rewrapManyDataKey` returns
            // nothing.  We also have tests for auto encryption that guarantee for `encrypt`
            // we return an error when there are no matching keys.  This error is generated in
            // subsequent iterations of the state machine.
            // Some apis (`encrypt`) throw if there are no filter matches and others (`rewrapManyDataKey`)
            // do not.  We set the result manually here, and let the state machine continue.  `libmongocrypt`
            // will inform us if we need to error by setting the state to `MONGOCRYPT_CTX_ERROR` but
            // otherwise we'll return `{ v: [] }`.
            result = { v: [] } as any as T;
          }
          for await (const key of keys) {
            context.addMongoOperationResponse(serialize(key));
          }

          context.finishMongoOperation();

          break;
        }

        case MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS: {
          const kmsProviders = await executor.askForKMSCredentials();
          context.provideKMSProviders(serialize(kmsProviders));
          break;
        }

        case MONGOCRYPT_CTX_NEED_KMS: {
          const requests = Array.from(this.requests(context));
          await Promise.all(requests);

          context.finishKMSRequests();
          break;
        }

        case MONGOCRYPT_CTX_READY: {
          const finalizedContext = context.finalize();
          // @ts-expect-error finalize can change the state, check for error
          if (context.state === MONGOCRYPT_CTX_ERROR) {
            const message = context.status.message || 'Finalization error';
            throw new MongoCryptError(message);
          }
          result = deserialize(finalizedContext, this.options) as T;
          break;
        }

        default:
          throw new MongoCryptError(`Unknown state: ${context.state}`);
      }
    }

    if (context.state === MONGOCRYPT_CTX_ERROR || result == null) {
      const message = context.status.message;
      if (!message) {
        debug(
          `unidentifiable error in MongoCrypt - received an error status from \`libmongocrypt\` but received no error message.`
        );
      }
      throw new MongoCryptError(
        message ??
          'unidentifiable error in MongoCrypt - received an error status from `libmongocrypt` but received no error message.'
      );
    }

    return result;
  }

  /**
   * Handles the request to the KMS service. Exposed for testing purposes. Do not directly invoke.
   * @param kmsContext - A C++ KMS context returned from the bindings
   * @returns A promise that resolves when the KMS reply has be fully parsed
   */
  kmsRequest(request: MongoCryptKMSRequest): Promise<void> {
    const parsedUrl = request.endpoint.split(':');
    const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT;
    const options: tls.ConnectionOptions & { host: string; port: number } = {
      host: parsedUrl[0],
      servername: parsedUrl[0],
      port
    };
    const message = request.message;

    // TODO(NODE-3959): We can adopt `for-await on(socket, 'data')` with logic to control abort
    // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
    return new Promise(async (resolve, reject) => {
      const buffer = new BufferPool();

      // eslint-disable-next-line prefer-const
      let socket: net.Socket;
      let rawSocket: net.Socket;

      function destroySockets() {
        for (const sock of [socket, rawSocket]) {
          if (sock) {
            sock.removeAllListeners();
            sock.destroy();
          }
        }
      }

      function ontimeout() {
        destroySockets();
        reject(new MongoCryptError('KMS request timed out'));
      }

      function onerror(err: Error) {
        destroySockets();
        const mcError = new MongoCryptError('KMS request failed', { cause: err });
        reject(mcError);
      }

      if (this.options.proxyOptions && this.options.proxyOptions.proxyHost) {
        rawSocket = net.connect({
          host: this.options.proxyOptions.proxyHost,
          port: this.options.proxyOptions.proxyPort || 1080
        });

        rawSocket.on('timeout', ontimeout);
        rawSocket.on('error', onerror);
        try {
          // eslint-disable-next-line @typescript-eslint/no-var-requires
          const events = require('events') as typeof import('events');
          await events.once(rawSocket, 'connect');
          socks ??= loadSocks();
          options.socket = (
            await socks.SocksClient.createConnection({
              existing_socket: rawSocket,
              command: 'connect',
              destination: { host: options.host, port: options.port },
              proxy: {
                // host and port are ignored because we pass existing_socket
                host: 'iLoveJavaScript',
                port: 0,
                type: 5,
                userId: this.options.proxyOptions.proxyUsername,
                password: this.options.proxyOptions.proxyPassword
              }
            })
          ).socket;
        } catch (err) {
          return onerror(err);
        }
      }

      const tlsOptions = this.options.tlsOptions;
      if (tlsOptions) {
        const kmsProvider = request.kmsProvider as ClientEncryptionDataKeyProvider;
        const providerTlsOptions = tlsOptions[kmsProvider];
        if (providerTlsOptions) {
          const error = this.validateTlsOptions(kmsProvider, providerTlsOptions);
          if (error) reject(error);
          try {
            await this.setTlsOptions(providerTlsOptions, options);
          } catch (error) {
            return onerror(error);
          }
        }
      }
      socket = tls.connect(options, () => {
        socket.write(message);
      });

      socket.once('timeout', ontimeout);
      socket.once('error', onerror);

      socket.on('data', data => {
        buffer.append(data);
        while (request.bytesNeeded > 0 && buffer.length) {
          const bytesNeeded = Math.min(request.bytesNeeded, buffer.length);
          request.addResponse(buffer.read(bytesNeeded));
        }

        if (request.bytesNeeded <= 0) {
          // There's no need for any more activity on this socket at this point.
          destroySockets();
          resolve();
        }
      });
    });
  }

  *requests(context: MongoCryptContext) {
    for (
      let request = context.nextKMSRequest();
      request != null;
      request = context.nextKMSRequest()
    ) {
      yield this.kmsRequest(request);
    }
  }

  /**
   * Validates the provided TLS options are secure.
   *
   * @param kmsProvider - The KMS provider name.
   * @param tlsOptions - The client TLS options for the provider.
   *
   * @returns An error if any option is invalid.
   */
  validateTlsOptions(
    kmsProvider: string,
    tlsOptions: ClientEncryptionTlsOptions
  ): MongoCryptError | void {
    const tlsOptionNames = Object.keys(tlsOptions);
    for (const option of INSECURE_TLS_OPTIONS) {
      if (tlsOptionNames.includes(option)) {
        return new MongoCryptError(`Insecure TLS options prohibited for ${kmsProvider}: ${option}`);
      }
    }
  }

  /**
   * Sets only the valid secure TLS options.
   *
   * @param tlsOptions - The client TLS options for the provider.
   * @param options - The existing connection options.
   */
  async setTlsOptions(
    tlsOptions: ClientEncryptionTlsOptions,
    options: tls.ConnectionOptions
  ): Promise<void> {
    if (tlsOptions.tlsCertificateKeyFile) {
      const cert = await fs.readFile(tlsOptions.tlsCertificateKeyFile);
      options.cert = options.key = cert;
    }
    if (tlsOptions.tlsCAFile) {
      options.ca = await fs.readFile(tlsOptions.tlsCAFile);
    }
    if (tlsOptions.tlsCertificateKeyFilePassword) {
      options.passphrase = tlsOptions.tlsCertificateKeyFilePassword;
    }
  }

  /**
   * Fetches collection info for a provided namespace, when libmongocrypt
   * enters the `MONGOCRYPT_CTX_NEED_MONGO_COLLINFO` state. The result is
   * used to inform libmongocrypt of the schema associated with this
   * namespace. Exposed for testing purposes. Do not directly invoke.
   *
   * @param client - A MongoClient connected to the topology
   * @param ns - The namespace to list collections from
   * @param filter - A filter for the listCollections command
   * @param callback - Invoked with the info of the requested collection, or with an error
   */
  async fetchCollectionInfo(
    client: MongoClient,
    ns: string,
    filter: Document
  ): Promise<Uint8Array | null> {
    const { db } = MongoDBCollectionNamespace.fromString(ns);

    const collections = await client
      .db(db)
      .listCollections(filter, {
        promoteLongs: false,
        promoteValues: false
      })
      .toArray();

    const info = collections.length > 0 ? serialize(collections[0]) : null;
    return info;
  }

  /**
   * Calls to the mongocryptd to provide markings for a command.
   * Exposed for testing purposes. Do not directly invoke.
   * @param client - A MongoClient connected to a mongocryptd
   * @param ns - The namespace (database.collection) the command is being executed on
   * @param command - The command to execute.
   * @param callback - Invoked with the serialized and marked bson command, or with an error
   */
  async markCommand(client: MongoClient, ns: string, command: Uint8Array): Promise<Uint8Array> {
    const options = { promoteLongs: false, promoteValues: false };
    const { db } = MongoDBCollectionNamespace.fromString(ns);
    const rawCommand = deserialize(command, options);

    const response = await client.db(db).command(rawCommand, options);

    return serialize(response, this.bsonOptions);
  }

  /**
   * Requests keys from the keyVault collection on the topology.
   * Exposed for testing purposes. Do not directly invoke.
   * @param client - A MongoClient connected to the topology
   * @param keyVaultNamespace - The namespace (database.collection) of the keyVault Collection
   * @param filter - The filter for the find query against the keyVault Collection
   * @param callback - Invoked with the found keys, or with an error
   */
  fetchKeys(
    client: MongoClient,
    keyVaultNamespace: string,
    filter: Uint8Array
  ): Promise<Array<DataKey>> {
    const { db: dbName, collection: collectionName } =
      MongoDBCollectionNamespace.fromString(keyVaultNamespace);

    return client
      .db(dbName)
      .collection<DataKey>(collectionName, { readConcern: { level: 'majority' } })
      .find(deserialize(filter))
      .toArray();
  }
}
