import { Convert } from '@web5/common';
import { getWebcryptoSubtle } from '@noble/ciphers/webcrypto';

import type { Jwk } from '@web5/crypto';

import { computeJwkThumbprint, isOctPrivateJwk } from '@web5/crypto';

/**
 * Const defining the AES-GCM initialization vector (IV) length in bits.
 *
 * @remarks
 * NIST Special Publication 800-38D, Section 5.2.1.1 states that the IV length:
 * > For IVs, it is recommended that implementations restrict support to the length of 96 bits, to
 * > promote interoperability, efficiency, and simplicity of design.
 *
 * This implementation does not support IV lengths that are different from the value defined by
 * this constant.
 *
 * @see {@link https://doi.org/10.6028/NIST.SP.800-38D | NIST SP 800-38D}
 */
const AES_GCM_IV_LENGTH = 96;

/**
 * Constant defining the AES key length values in bits.
 *
 * @remarks
 * NIST publication FIPS 197 states:
 * > The AES algorithm is capable of using cryptographic keys of 128, 192, and 256 bits to encrypt
 * > and decrypt data in blocks of 128 bits.
 *
 * This implementation does not support key lengths that are different from the three values
 * defined by this constant.
 *
 * @see {@link https://doi.org/10.6028/NIST.FIPS.197-upd1 | NIST FIPS 197}
 */
const AES_KEY_LENGTHS = [128, 192, 256] as const;

/**
 * Constant defining the AES-GCM tag length values in bits.
 *
 * @remarks
 * NIST Special Publication 800-38D, Section 5.2.1.2 states that the tag length:
 * > may be any one of the following five values: 128, 120, 112, 104, or 96
 *
 * Although the NIST specification allows for tag lengths of 32 or 64 bits in certain applications,
 * the use of shorter tag lengths can be problematic for GCM due to targeted forgery attacks. As a
 * precaution, this implementation does not support tag lengths that are different from the five
 * values defined by this constant. See Appendix C of the NIST SP 800-38D specification for
 * additional guidance and details.
 *
 * @see {@link https://doi.org/10.6028/NIST.SP.800-38D | NIST SP 800-38D}
 */
export const AES_GCM_TAG_LENGTHS = [96, 104, 112, 120, 128] as const;

/**
 * The `AesGcm` class provides a comprehensive set of utilities for cryptographic operations
 * using the Advanced Encryption Standard (AES) in Galois/Counter Mode (GCM). This class includes
 * methods for key generation, encryption, decryption, and conversions between raw byte arrays
 * and JSON Web Key (JWK) formats. It is designed to support AES-GCM, a symmetric key algorithm
 * that is widely used for its efficiency, security, and provision of authenticated encryption.
 *
 * AES-GCM is particularly favored for scenarios that require both confidentiality and integrity
 * of data. It integrates the counter mode of encryption with the Galois mode of authentication,
 * offering high performance and parallel processing capabilities.
 *
 * Key Features:
 * - Key Generation: Generate AES symmetric keys in JWK format.
 * - Key Conversion: Transform keys between raw byte arrays and JWK formats.
 * - Encryption: Encrypt data using AES-GCM with the provided symmetric key.
 * - Decryption: Decrypt data encrypted with AES-GCM using the corresponding symmetric key.
 *
 * The methods in this class are asynchronous, returning Promises to accommodate various
 * JavaScript environments.
 *
 * @example
 * ```ts
 * // Key Generation
 * const length = 256; // Length of the key in bits (e.g., 128, 192, 256)
 * const privateKey = await AesGcm.generateKey({ length });
 *
 * // Encryption
 * const data = new TextEncoder().encode('Messsage');
 * const iv = new Uint8Array(12); // 12-byte initialization vector
 * const encryptedData = await AesGcm.encrypt({
 *   data,
 *   iv,
 *   key: privateKey
 * });
 *
 * // Decryption
 * const decryptedData = await AesGcm.decrypt({
 *   data: encryptedData,
 *   iv,
 *   key: privateKey
 * });
 *
 * // Key Conversion
 * const privateKeyBytes = await AesGcm.privateKeyToBytes({ privateKey });
 * ```
 */
export class AesGcm {
  /**
 * Converts a raw private key in bytes to its corresponding JSON Web Key (JWK) format.
 *
 * @remarks
 * This method accepts a symmetric key represented as a byte array (Uint8Array) and
 * converts it into a JWK object for use with AES-GCM (Advanced Encryption Standard -
 * Galois/Counter Mode). The conversion process involves encoding the key into
 * base64url format and setting the appropriate JWK parameters.
 *
 * The resulting JWK object includes the following properties:
 * - `kty`: Key Type, set to 'oct' for Octet Sequence (representing a symmetric key).
 * - `k`: The symmetric key, base64url-encoded.
 * - `kid`: Key ID, generated based on the JWK thumbprint.
 *
 * @example
 * ```ts
 * const privateKeyBytes = new Uint8Array([...]); // Replace with actual symmetric key bytes
 * const privateKey = await AesGcm.bytesToPrivateKey({ privateKeyBytes });
 * ```
 *
 * @param params - The parameters for the symmetric key conversion.
 * @param params.privateKeyBytes - The raw symmetric key as a Uint8Array.
 *
 * @returns A Promise that resolves to the symmetric key in JWK format.
 */
  public static async bytesToPrivateKey({ privateKeyBytes }: {
    privateKeyBytes: Uint8Array;
  }): Promise<Jwk> {
    // Construct the private key in JWK format.
    const privateKey: Jwk = {
      k   : Convert.uint8Array(privateKeyBytes).toBase64Url(),
      kty : 'oct'
    };

    // Compute the JWK thumbprint and set as the key ID.
    privateKey.kid = await computeJwkThumbprint({ jwk: privateKey });

    // Add algorithm identifier based on key length.
    const lengthInBits = privateKeyBytes.length * 8;
    privateKey.alg = { 128: 'A128GCM', 192: 'A192GCM', 256: 'A256GCM' }[lengthInBits];

    return privateKey;
  }

  /**
   * Decrypts the provided data using AES-GCM.
   *
   * @remarks
   * This method performs AES-GCM decryption on the given encrypted data using the specified key.
   * It requires an initialization vector (IV), the encrypted data along with the decryption key,
   * and optionally, additional authenticated data (AAD). The method returns the decrypted data as a
   * Uint8Array. The optional `tagLength` parameter specifies the size in bits of the authentication
   * tag used when encrypting the data. If not specified, the default tag length of 128 bits is
   * used.
   *
   * @example
   * ```ts
   * const encryptedData = new Uint8Array([...]); // Encrypted data
   * const iv = new Uint8Array([...]); // Initialization vector used during encryption
   * const additionalData = new Uint8Array([...]); // Optional additional authenticated data
   * const key = { ... }; // A Jwk object representing the AES key
   * const decryptedData = await AesGcm.decrypt({
   *   data: encryptedData,
   *   iv,
   *   additionalData,
   *   key,
   *   tagLength: 128 // Optional tag length in bits
   * });
   * ```
   *
   * @param params - The parameters for the decryption operation.
   * @param params.key - The key to use for decryption, represented in JWK format.
   * @param params.data - The encrypted data to decrypt, represented as a Uint8Array.
   * @param params.iv - The initialization vector, represented as a Uint8Array.
   * @param params.additionalData - Optional additional authenticated data. Optional.
   * @param params.tagLength - The length of the authentication tag in bits. Optional.
   *
   * @returns A Promise that resolves to the decrypted data as a Uint8Array.
   */
  public static async decrypt({ key, data, iv, additionalData, tagLength }: {
    key: Jwk;
    data: Uint8Array;
    iv: Uint8Array;
    additionalData?: Uint8Array;
    tagLength?: typeof AES_GCM_TAG_LENGTHS[number];
  }): Promise<Uint8Array> {
    // Validate the initialization vector length.
    if (iv.byteLength !== AES_GCM_IV_LENGTH / 8) {
      throw new TypeError(`The initialization vector must be ${AES_GCM_IV_LENGTH} bits in length`);
    }

    // Validate the tag length.
    if (tagLength && !AES_GCM_TAG_LENGTHS.includes(tagLength as any)) {
      throw new RangeError(`The tag length is invalid: Must be ${AES_GCM_TAG_LENGTHS.join(', ')} bits`);
    }

    // Get the Web Crypto API interface.
    const webCrypto = getWebcryptoSubtle();

    // Import the JWK into the Web Crypto API to use for the decrypt operation.
    const webCryptoKey = await webCrypto.importKey('jwk', key, { name: 'AES-GCM' }, true, ['decrypt']);

    // Browser implementations of the Web Crypto API throw an error if additionalData is undefined.
    const algorithm = (additionalData === undefined)
      ? { name: 'AES-GCM', iv, tagLength }
      : { name: 'AES-GCM', additionalData, iv, tagLength };

    // Decrypt the data.
    const plaintextBuffer = await webCrypto.decrypt(algorithm, webCryptoKey, data);

    // Convert from ArrayBuffer to Uint8Array.
    const plaintext = new Uint8Array(plaintextBuffer);

    return plaintext;
  }

  /**
   * Encrypts the provided data using AES-GCM.
   *
   * @remarks
   * This method performs AES-GCM encryption on the given data using the specified key.
   * It requires an initialization vector (IV), the encrypted data along with the decryption key,
   * and optionally, additional authenticated data (AAD). The method returns the encrypted data as a
   * Uint8Array. The optional `tagLength` parameter specifies the size in bits of the authentication
   * tag generated in the encryption operation and used for authentication in the corresponding
   * decryption. If not specified, the default tag length of 128 bits is used.
   *
   * @example
   * ```ts
   * const data = new TextEncoder().encode('Messsage');
   * const iv = new Uint8Array([...]); // Initialization vector
   * const additionalData = new Uint8Array([...]); // Optional additional authenticated data
   * const key = { ... }; // A Jwk object representing an AES key
   * const encryptedData = await AesGcm.encrypt({
   *   data,
   *   iv,
   *   additionalData,
   *   key,
   *   tagLength: 128 // Optional tag length in bits
   * });
   * ```
   *
   * @param params - The parameters for the encryption operation.
   * @param params.key - The key to use for encryption, represented in JWK format.
   * @param params.data - The data to encrypt, represented as a Uint8Array.
   * @param params.iv - The initialization vector, represented as a Uint8Array.
   * @param params.additionalData - Optional additional authenticated data. Optional.
   * @param params.tagLength - The length of the authentication tag in bits. Optional.
   *
   * @returns A Promise that resolves to the encrypted data as a Uint8Array.
   */
  public static async encrypt({ data, iv, key, additionalData, tagLength }: {
    key: Jwk;
    data: Uint8Array;
    iv: Uint8Array;
    additionalData?: Uint8Array;
    tagLength?: typeof AES_GCM_TAG_LENGTHS[number];
  }): Promise<Uint8Array> {
    // Validate the initialization vector length.
    if (iv.byteLength !== AES_GCM_IV_LENGTH / 8) {
      throw new TypeError(`The initialization vector must be ${AES_GCM_IV_LENGTH} bits in length`);
    }

    // Validate the tag length.
    if (tagLength && !AES_GCM_TAG_LENGTHS.includes(tagLength as any)) {
      throw new RangeError(`The tag length is invalid: Must be ${AES_GCM_TAG_LENGTHS.join(', ')} bits`);
    }

    // Get the Web Crypto API interface.
    const webCrypto = getWebcryptoSubtle();

    // Import the JWK into the Web Crypto API to use for the encrypt operation.
    const webCryptoKey = await webCrypto.importKey('jwk', key, { name: 'AES-GCM' }, true, ['encrypt']);

    // Browser implementations of the Web Crypto API throw an error if additionalData is undefined.
    const algorithm = (additionalData === undefined)
      ? { name: 'AES-GCM', iv, tagLength }
      : { name: 'AES-GCM', additionalData, iv, tagLength };

    // Encrypt the data.
    const ciphertextBuffer = await webCrypto.encrypt(algorithm, webCryptoKey, data);

    // Convert from ArrayBuffer to Uint8Array.
    const ciphertext = new Uint8Array(ciphertextBuffer);

    return ciphertext;
  }

  /**
   * Generates a symmetric key for AES in Galois/Counter Mode (GCM) in JSON Web Key (JWK) format.
   *
   * @remarks
   * This method creates a new symmetric key of a specified length suitable for use with
   * AES-GCM encryption. It leverages cryptographically secure random number generation
   * to ensure the uniqueness and security of the key. The generated key adheres to the JWK
   * format, facilitating compatibility with common cryptographic standards and ease of use
   * in various cryptographic applications.
   *
   * The generated key includes these components:
   * - `kty`: Key Type, set to 'oct' for Octet Sequence, indicating a symmetric key.
   * - `k`: The symmetric key component, base64url-encoded.
   * - `kid`: Key ID, generated based on the JWK thumbprint, providing a unique identifier.
   *
   * @example
   * ```ts
   * const length = 256; // Length of the key in bits (e.g., 128, 192, 256)
   * const privateKey = await AesGcm.generateKey({ length });
   * ```
   *
   * @param params - The parameters for the key generation.
   * @param params.length - The length of the key in bits. Common lengths are 128, 192, and 256 bits.
   *
   * @returns A Promise that resolves to the generated symmetric key in JWK format.
   */
  public static async generateKey({ length }: {
    length: typeof AES_KEY_LENGTHS[number];
  }): Promise<Jwk> {
    // Validate the key length.
    if (!AES_KEY_LENGTHS.includes(length as any)) {
      throw new RangeError(`The key length is invalid: Must be ${AES_KEY_LENGTHS.join(', ')} bits`);
    }

    // Get the Web Crypto API interface.
    const webCrypto = getWebcryptoSubtle();

    // Generate a random private key.
    // See https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#usage_notes for
    // an explanation for why Web Crypto generateKey() is used instead of getRandomValues().
    const webCryptoKey = await webCrypto.generateKey( { name: 'AES-GCM', length }, true, ['encrypt']);

    // Export the private key in JWK format.
    const { ext, key_ops, ...privateKey } = await webCrypto.exportKey('jwk', webCryptoKey);

    // Compute the JWK thumbprint and set as the key ID.
    privateKey.kid = await computeJwkThumbprint({ jwk: privateKey });

    return privateKey;
  }

  /**
   * Converts a private key from JSON Web Key (JWK) format to a raw byte array (Uint8Array).
   *
   * @remarks
   * This method takes a symmetric key in JWK format and extracts its raw byte representation.
   * It focuses on the 'k' parameter of the JWK, which represents the symmetric key component
   * in base64url encoding. The method decodes this value into a byte array, providing
   * the symmetric key in its raw binary form.
   *
   * @example
   * ```ts
   * const privateKey = { ... }; // A symmetric key in JWK format
   * const privateKeyBytes = await AesGcm.privateKeyToBytes({ privateKey });
   * ```
   *
   * @param params - The parameters for the symmetric key conversion.
   * @param params.privateKey - The symmetric key in JWK format.
   *
   * @returns A Promise that resolves to the symmetric key as a Uint8Array.
   */
  public static async privateKeyToBytes({ privateKey }: {
    privateKey: Jwk;
  }): Promise<Uint8Array> {
    // Verify the provided JWK represents a valid oct private key.
    if (!isOctPrivateJwk(privateKey)) {
      throw new Error(`AesGcm: The provided key is not a valid oct private key.`);
    }

    // Decode the provided private key to bytes.
    const privateKeyBytes = Convert.base64Url(privateKey.k).toUint8Array();

    return privateKeyBytes;
  }
}