/**
 * Legacy cryptography module.
 *
 * @internal
 */

import { CryptorConfiguration } from '../../interfaces/crypto-module';
import { LoggerManager } from '../logger-manager';
import { Payload } from '../../types/api';
import { decode } from '../base64_codec';
import CryptoJS from './hmac-sha256';

/**
 * Convert bytes array to words array.
 *
 * @param b - Bytes array (buffer) which should be converted.
 *
 * @returns Word sized array.
 *
 * @internal
 */
/* eslint-disable  @typescript-eslint/no-explicit-any */
function bufferToWordArray(b: string | any[] | Uint8ClampedArray) {
  const wa: number[] = [];
  let i;
  for (i = 0; i < b.length; i += 1) {
    wa[(i / 4) | 0] |= b[i] << (24 - 8 * i);
  }

  // @ts-expect-error Bundled library without types.
  return CryptoJS.lib.WordArray.create(wa, b.length);
}

/**
 * Legacy cryptor configuration options.
 *
 * @internal
 */
type CryptoConfiguration = {
  encryptKey?: boolean;
  keyEncoding?: 'hex' | 'utf8' | 'base64' | 'binary';
  keyLength?: 128 | 256;
  mode?: 'ecb' | 'cbc';
};

/**
 * Legacy cryptography module for files and signature.
 *
 * @internal
 */
export default class {
  /**
   * Crypto initialization vector.
   */
  private iv = '0123456789012345';

  /**
   * List os allowed cipher key encodings.
   */
  private allowedKeyEncodings = ['hex', 'utf8', 'base64', 'binary'];

  /**
   * Allowed cipher key lengths.
   */
  private allowedKeyLengths = [128, 256];

  /**
   * Allowed crypto modes.
   */
  private allowedModes = ['ecb', 'cbc'];

  /**
   * Default cryptor configuration options.
   */
  private readonly defaultOptions: Required<CryptoConfiguration>;

  /**
   * Registered loggers' manager.
   */
  private _logger?: LoggerManager;

  constructor(private readonly configuration: CryptorConfiguration) {
    this.logger = configuration.logger;

    this.defaultOptions = {
      encryptKey: true,
      keyEncoding: 'utf8',
      keyLength: 256,
      mode: 'cbc',
    };
  }

  /**
   * Update registered loggers' manager.
   *
   * @param [logger] - Logger, which crypto should use.
   */
  set logger(logger: LoggerManager | undefined) {
    this._logger = logger;

    if (this.logger) {
      this.logger.debug('Crypto', () => ({
        messageType: 'object',
        message: this.configuration as unknown as Record<string, unknown>,
        details: 'Create with configuration:',
        ignoredKeys(key: string, obj: Record<string, unknown>) {
          return typeof obj[key] === 'function' || key === 'logger';
        },
      }));
    }
  }

  /**
   * Get loggers' manager.
   *
   * @returns Loggers' manager (if set).
   */
  get logger() {
    return this._logger;
  }

  /**
   * Generate HMAC-SHA256 hash from input data.
   *
   * @param data - Data from which hash should be generated.
   *
   * @returns HMAC-SHA256 hash from provided `data`.
   */
  public HMACSHA256(data: string): string {
    // @ts-expect-error Bundled library without types.
    const hash = CryptoJS.HmacSHA256(data, this.configuration.secretKey);
    // @ts-expect-error Bundled library without types.
    return hash.toString(CryptoJS.enc.Base64);
  }

  /**
   * Generate SHA256 hash from input data.
   *
   * @param data - Data from which hash should be generated.
   *
   * @returns SHA256 hash from provided `data`.
   */
  public SHA256(data: string): string {
    // @ts-expect-error Bundled library without types.
    return CryptoJS.SHA256(data).toString(CryptoJS.enc.Hex);
  }

  /**
   * Encrypt provided data.
   *
   * @param data - Source data which should be encrypted.
   * @param [customCipherKey] - Custom cipher key (different from defined on client level).
   * @param [options] - Specific crypto configuration options.
   *
   * @returns Encrypted `data`.
   */
  public encrypt(data: string | Payload, customCipherKey?: string, options?: CryptoConfiguration) {
    if (this.configuration.customEncrypt) {
      if (this.logger)
        this.logger.warn('Crypto', "'customEncrypt' is deprecated. Consult docs for better alternative.");

      return this.configuration.customEncrypt(data);
    }

    return this.pnEncrypt(data as string, customCipherKey, options);
  }

  /**
   * Decrypt provided data.
   *
   * @param data - Encrypted data which should be decrypted.
   * @param [customCipherKey] - Custom cipher key (different from defined on client level).
   * @param [options] - Specific crypto configuration options.
   *
   * @returns Decrypted `data`.
   */
  public decrypt(data: string, customCipherKey?: string, options?: CryptoConfiguration) {
    if (this.configuration.customDecrypt) {
      if (this.logger)
        this.logger.warn('Crypto', "'customDecrypt' is deprecated. Consult docs for better alternative.");

      return this.configuration.customDecrypt(data);
    }

    return this.pnDecrypt(data, customCipherKey, options);
  }

  /**
   * Encrypt provided data.
   *
   * @param data - Source data which should be encrypted.
   * @param [customCipherKey] - Custom cipher key (different from defined on client level).
   * @param [options] - Specific crypto configuration options.
   *
   * @returns Encrypted `data` as string.
   */
  private pnEncrypt(data: string, customCipherKey?: string, options?: CryptoConfiguration): string {
    const decidedCipherKey = customCipherKey ?? this.configuration.cipherKey;
    if (!decidedCipherKey) return data;

    if (this.logger) {
      this.logger.debug('Crypto', () => ({
        messageType: 'object',
        message: { data, cipherKey: decidedCipherKey, ...(options ?? {}) },
        details: 'Encrypt with parameters:',
      }));
    }

    options = this.parseOptions(options);
    const mode = this.getMode(options);
    const cipherKey = this.getPaddedKey(decidedCipherKey, options);

    if (this.configuration.useRandomIVs) {
      const waIv = this.getRandomIV();
      // @ts-expect-error Bundled library without types.
      const waPayload = CryptoJS.AES.encrypt(data, cipherKey, { iv: waIv, mode }).ciphertext;

      // @ts-expect-error Bundled library without types.
      return waIv.clone().concat(waPayload.clone()).toString(CryptoJS.enc.Base64);
    }

    const iv = this.getIV(options);
    // @ts-expect-error Bundled library without types.
    const encryptedHexArray = CryptoJS.AES.encrypt(data, cipherKey, { iv, mode }).ciphertext;
    // @ts-expect-error Bundled library without types.
    const base64Encrypted = encryptedHexArray.toString(CryptoJS.enc.Base64);

    return base64Encrypted || data;
  }

  /**
   * Decrypt provided data.
   *
   * @param data - Encrypted data which should be decrypted.
   * @param [customCipherKey] - Custom cipher key (different from defined on client level).
   * @param [options] - Specific crypto configuration options.
   *
   * @returns Decrypted `data`.
   */
  private pnDecrypt(data: string, customCipherKey?: string, options?: CryptoConfiguration): Payload | null {
    const decidedCipherKey = customCipherKey ?? this.configuration.cipherKey;
    if (!decidedCipherKey) return data;

    if (this.logger) {
      this.logger.debug('Crypto', () => ({
        messageType: 'object',
        message: { data, cipherKey: decidedCipherKey, ...(options ?? {}) },
        details: 'Decrypt with parameters:',
      }));
    }

    options = this.parseOptions(options);
    const mode = this.getMode(options);
    const cipherKey = this.getPaddedKey(decidedCipherKey, options);

    if (this.configuration.useRandomIVs) {
      const ciphertext = new Uint8ClampedArray(decode(data));

      const iv = bufferToWordArray(ciphertext.slice(0, 16));
      const payload = bufferToWordArray(ciphertext.slice(16));

      try {
        // @ts-expect-error Bundled library without types.
        const plainJSON = CryptoJS.AES.decrypt({ ciphertext: payload }, cipherKey, { iv, mode }).toString(
          // @ts-expect-error Bundled library without types.
          CryptoJS.enc.Utf8,
        );
        return JSON.parse(plainJSON);
      } catch (e) {
        if (this.logger) this.logger.error('Crypto', () => ({ messageType: 'error', message: e }));

        return null;
      }
    } else {
      const iv = this.getIV(options);
      try {
        // @ts-expect-error Bundled library without types.
        const ciphertext = CryptoJS.enc.Base64.parse(data);
        // @ts-expect-error Bundled library without types.
        const plainJSON = CryptoJS.AES.decrypt({ ciphertext }, cipherKey, { iv, mode }).toString(CryptoJS.enc.Utf8);
        return JSON.parse(plainJSON);
      } catch (e) {
        if (this.logger) this.logger.error('Crypto', () => ({ messageType: 'error', message: e }));

        return null;
      }
    }
  }

  /**
   * Pre-process provided custom crypto configuration.
   *
   * @param incomingOptions - Configuration which should be pre-processed before use.
   *
   * @returns Normalized crypto configuration options.
   */
  private parseOptions(incomingOptions?: CryptoConfiguration): Required<CryptoConfiguration> {
    if (!incomingOptions) return this.defaultOptions;

    // Defaults
    const options = {
      encryptKey: incomingOptions.encryptKey ?? this.defaultOptions.encryptKey,
      keyEncoding: incomingOptions.keyEncoding ?? this.defaultOptions.keyEncoding,
      keyLength: incomingOptions.keyLength ?? this.defaultOptions.keyLength,
      mode: incomingOptions.mode ?? this.defaultOptions.mode,
    };

    // Validation
    if (this.allowedKeyEncodings.indexOf(options.keyEncoding!.toLowerCase()) === -1)
      options.keyEncoding = this.defaultOptions.keyEncoding;
    if (this.allowedKeyLengths.indexOf(options.keyLength!) === -1) options.keyLength = this.defaultOptions.keyLength;
    if (this.allowedModes.indexOf(options.mode!.toLowerCase()) === -1) options.mode = this.defaultOptions.mode;

    return options;
  }

  /**
   * Decode provided cipher key.
   *
   * @param key - Key in `encoding` provided by `options`.
   * @param options - Crypto configuration options with cipher key details.
   *
   * @returns Array buffer with decoded key.
   */
  private decodeKey(key: string, options: CryptoConfiguration) {
    // @ts-expect-error Bundled library without types.
    if (options.keyEncoding === 'base64') return CryptoJS.enc.Base64.parse(key);
    // @ts-expect-error Bundled library without types.
    if (options.keyEncoding === 'hex') return CryptoJS.enc.Hex.parse(key);

    return key;
  }

  /**
   * Add padding to the cipher key.
   *
   * @param key - Key which should be padded.
   * @param options - Crypto configuration options with cipher key details.
   *
   * @returns Properly padded cipher key.
   */
  private getPaddedKey(key: string, options: CryptoConfiguration) {
    key = this.decodeKey(key, options);

    // @ts-expect-error Bundled library without types.
    if (options.encryptKey) return CryptoJS.enc.Utf8.parse(this.SHA256(key).slice(0, 32));

    return key;
  }

  /**
   * Cipher mode.
   *
   * @param options - Crypto configuration with information about cipher mode.
   *
   * @returns Crypto cipher mode.
   */
  private getMode(options: CryptoConfiguration) {
    // @ts-expect-error Bundled library without types.
    if (options.mode === 'ecb') return CryptoJS.mode.ECB;

    // @ts-expect-error Bundled library without types.
    return CryptoJS.mode.CBC;
  }

  /**
   * Cipher initialization vector.
   *
   * @param options - Crypto configuration with information about cipher mode.
   *
   * @returns Initialization vector.
   */
  private getIV(options: CryptoConfiguration) {
    // @ts-expect-error Bundled library without types.
    return options.mode === 'cbc' ? CryptoJS.enc.Utf8.parse(this.iv) : null;
  }

  /**
   * Random initialization vector.
   *
   * @returns Generated random initialization vector.
   */
  private getRandomIV() {
    // @ts-expect-error Bundled library without types.
    return CryptoJS.lib.WordArray.random(16);
  }
}
