import { promisify } from 'util';
import * as zlib from 'zlib';

import { LEGACY_HELLO_COMMAND } from '../../constants';
import { getSnappy, getZstdLibrary, type SnappyLib, type ZStandard } from '../../deps';
import { MongoDecompressionError, MongoInvalidArgumentError } from '../../error';
import {
  type MessageHeader,
  OpCompressedRequest,
  OpMsgResponse,
  OpReply,
  type WriteProtocolMessageType
} from '../commands';
import { OP_COMPRESSED, OP_MSG } from './constants';

/** @public */
export const Compressor = Object.freeze({
  none: 0,
  snappy: 1,
  zlib: 2,
  zstd: 3
} as const);

/** @public */
export type Compressor = (typeof Compressor)[CompressorName];

/** @public */
export type CompressorName = keyof typeof Compressor;

export const uncompressibleCommands = new Set([
  LEGACY_HELLO_COMMAND,
  'saslStart',
  'saslContinue',
  'getnonce',
  'authenticate',
  'createUser',
  'updateUser',
  'copydbSaslStart',
  'copydbgetnonce',
  'copydb'
]);

const ZSTD_COMPRESSION_LEVEL = 3;

const zlibInflate = promisify(zlib.inflate.bind(zlib));
const zlibDeflate = promisify(zlib.deflate.bind(zlib));

let zstd: ZStandard;
let Snappy: SnappyLib | null = null;
function loadSnappy() {
  if (Snappy == null) {
    const snappyImport = getSnappy();
    if ('kModuleError' in snappyImport) {
      throw snappyImport.kModuleError;
    }
    Snappy = snappyImport;
  }
  return Snappy;
}

// Facilitate compressing a message using an agreed compressor
export async function compress(
  options: { zlibCompressionLevel: number; agreedCompressor: CompressorName },
  dataToBeCompressed: Buffer
): Promise<Buffer> {
  const zlibOptions = {} as zlib.ZlibOptions;
  switch (options.agreedCompressor) {
    case 'snappy': {
      Snappy ??= loadSnappy();
      return await Snappy.compress(dataToBeCompressed);
    }
    case 'zstd': {
      loadZstd();
      if ('kModuleError' in zstd) {
        throw zstd['kModuleError'];
      }
      return await zstd.compress(dataToBeCompressed, ZSTD_COMPRESSION_LEVEL);
    }
    case 'zlib': {
      if (options.zlibCompressionLevel) {
        zlibOptions.level = options.zlibCompressionLevel;
      }
      return await zlibDeflate(dataToBeCompressed, zlibOptions);
    }
    default: {
      throw new MongoInvalidArgumentError(
        `Unknown compressor ${options.agreedCompressor} failed to compress`
      );
    }
  }
}

// Decompress a message using the given compressor
export async function decompress(compressorID: number, compressedData: Buffer): Promise<Buffer> {
  if (
    compressorID !== Compressor.snappy &&
    compressorID !== Compressor.zstd &&
    compressorID !== Compressor.zlib &&
    compressorID !== Compressor.none
  ) {
    throw new MongoDecompressionError(
      `Server sent message compressed using an unsupported compressor. (Received compressor ID ${compressorID})`
    );
  }

  switch (compressorID) {
    case Compressor.snappy: {
      Snappy ??= loadSnappy();
      return await Snappy.uncompress(compressedData, { asBuffer: true });
    }
    case Compressor.zstd: {
      loadZstd();
      if ('kModuleError' in zstd) {
        throw zstd['kModuleError'];
      }
      return await zstd.decompress(compressedData);
    }
    case Compressor.zlib: {
      return await zlibInflate(compressedData);
    }
    default: {
      return compressedData;
    }
  }
}

/**
 * Load ZStandard if it is not already set.
 */
function loadZstd() {
  if (!zstd) {
    zstd = getZstdLibrary();
  }
}

const MESSAGE_HEADER_SIZE = 16;

/**
 * @internal
 *
 * Compresses an OP_MSG or OP_QUERY message, if compression is configured.  This method
 * also serializes the command to BSON.
 */
export async function compressCommand(
  command: WriteProtocolMessageType,
  description: { agreedCompressor?: CompressorName; zlibCompressionLevel?: number }
): Promise<Buffer> {
  const finalCommand =
    description.agreedCompressor === 'none' || !OpCompressedRequest.canCompress(command)
      ? command
      : new OpCompressedRequest(command, {
          agreedCompressor: description.agreedCompressor ?? 'none',
          zlibCompressionLevel: description.zlibCompressionLevel ?? 0
        });
  const data = await finalCommand.toBin();
  return Buffer.concat(data);
}

/**
 * @internal
 *
 * Decompresses an OP_MSG or OP_QUERY response from the server, if compression is configured.
 *
 * This method does not parse the response's BSON.
 */
export async function decompressResponse(message: Buffer): Promise<OpMsgResponse | OpReply> {
  const messageHeader: MessageHeader = {
    length: message.readInt32LE(0),
    requestId: message.readInt32LE(4),
    responseTo: message.readInt32LE(8),
    opCode: message.readInt32LE(12)
  };

  if (messageHeader.opCode !== OP_COMPRESSED) {
    const ResponseType = messageHeader.opCode === OP_MSG ? OpMsgResponse : OpReply;
    const messageBody = message.subarray(MESSAGE_HEADER_SIZE);
    return new ResponseType(message, messageHeader, messageBody);
  }

  const header: MessageHeader = {
    ...messageHeader,
    fromCompressed: true,
    opCode: message.readInt32LE(MESSAGE_HEADER_SIZE),
    length: message.readInt32LE(MESSAGE_HEADER_SIZE + 4)
  };
  const compressorID = message[MESSAGE_HEADER_SIZE + 8];
  const compressedBuffer = message.slice(MESSAGE_HEADER_SIZE + 9);

  // recalculate based on wrapped opcode
  const ResponseType = header.opCode === OP_MSG ? OpMsgResponse : OpReply;
  const messageBody = await decompress(compressorID, compressedBuffer);
  if (messageBody.length !== header.length) {
    throw new MongoDecompressionError('Message body and message header must be the same length');
  }
  return new ResponseType(message, header, messageBody);
}
