/**
 * @license
 * Copyright 2020 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import {SecurityException} from '../exception/security_exception';
import * as PrimitiveSet from '../internal/primitive_set';
import {PbKeysetKey, PbKeyStatusType, PbOutputPrefixType} from '../internal/proto';
import * as Bytes from '../subtle/bytes';
import * as Random from '../subtle/random';

import {HybridDecryptWrapper} from './hybrid_decrypt_wrapper';
import {HybridEncryptWrapper} from './hybrid_encrypt_wrapper';
import {HybridDecrypt} from './internal/hybrid_decrypt';
import {HybridEncrypt} from './internal/hybrid_encrypt';

describe('hybrid decrypt wrapper test', function() {
  it('decrypt, invalid ciphertext', async function() {
    const primitiveSets = createDummyPrimitiveSets();
    const decryptPrimitiveSet = primitiveSets['decryptPrimitiveSet'];
    const hybridDecrypt = new HybridDecryptWrapper().wrap(decryptPrimitiveSet);
    // Ciphertext which cannot be decrypted by any primitive in the primitive
    // set.
    const ciphertext = new Uint8Array([9, 8, 7, 6, 5, 4, 3]);

    try {
      await hybridDecrypt.decrypt(ciphertext);
      fail('Should throw an exception');
    } catch (e: any) {
      expect(e.toString()).toBe(ExceptionText.cannotBeDecrypted());
    }
  });

  it('decrypt, should work', async function() {
    const primitiveSets = createDummyPrimitiveSets();
    const plaintext = Random.randBytes(10);
    // As keys are just dummy keys which do not contain key data, the same key
    // is used for both encrypt and decrypt.
    const key = createDummyKeysetKey(
        /** keyId = */ 0xFFFFFFFF, PbOutputPrefixType.TINK,
        /** enabled = */ true);
    const ciphertextSuffix = new Uint8Array([0, 0, 0, 0xFF]);

    // Get the ciphertext.
    const encryptPrimitiveSet = primitiveSets['encryptPrimitiveSet'];
    const encryptPrimitive = new DummyHybridEncrypt(ciphertextSuffix);
    const entry = encryptPrimitiveSet.addPrimitive(encryptPrimitive, key);
    // Has to be set to primary as then it is used in encryption.
    encryptPrimitiveSet.setPrimary(entry);
    const hybridEncrypt = new HybridEncryptWrapper().wrap(encryptPrimitiveSet);
    const ciphertext = await hybridEncrypt.encrypt(plaintext);

    // Create a primitive set containing the primitive which can be used for
    // encryption. Add also few more primitives with the same key as the
    // primitive set should decrypt the ciphertext whenever there is at least
    // one primitive which does not fail to decrypt the ciphertext.
    const decryptPrimitiveSet = primitiveSets['decryptPrimitiveSet'];
    const decryptPrimitive = new DummyHybridDecrypt(ciphertextSuffix);
    decryptPrimitiveSet.addPrimitive(
        new DummyHybridDecrypt(Random.randBytes(5)), key);
    decryptPrimitiveSet.addPrimitive(decryptPrimitive, key);
    decryptPrimitiveSet.addPrimitive(
        new DummyHybridDecrypt(Random.randBytes(5)), key);

    // Decrypt the ciphertext.
    const hybridDecrypt = new HybridDecryptWrapper().wrap(decryptPrimitiveSet);
    const decryptedCiphertext = await hybridDecrypt.decrypt(ciphertext);

    // Test that the result is the original plaintext.
    expect(decryptedCiphertext).toEqual(plaintext);
  });

  it('decrypt, ciphertext encrypted by raw primitive', async function() {
    const primitiveSets = createDummyPrimitiveSets();
    const plaintext = Random.randBytes(10);
    // As keys are just dummy keys which do not contain key data, the same key
    // is used for both encrypt and decrypt.
    const key = createDummyKeysetKey(
        /** keyId = */ 0xFFFFFFFF, PbOutputPrefixType.RAW,
        /** enabled = */ true);
    const ciphertextSuffix = new Uint8Array([0, 0, 0, 0xFF]);

    // Get the ciphertext.
    const encryptPrimitive = new DummyHybridEncrypt(ciphertextSuffix);
    const ciphertext = await encryptPrimitive.encrypt(plaintext);

    // Decrypt the ciphertext.
    const decryptPrimitiveSet = primitiveSets['decryptPrimitiveSet'];
    const decryptPrimitive = new DummyHybridDecrypt(ciphertextSuffix);
    decryptPrimitiveSet.addPrimitive(decryptPrimitive, key);
    const hybridDecrypt = new HybridDecryptWrapper().wrap(decryptPrimitiveSet);
    const decryptedCiphertext = await hybridDecrypt.decrypt(ciphertext);

    // Test that the result is the original plaintext.
    expect(decryptedCiphertext).toEqual(plaintext);
  });

  it('decrypt, with context info', async function() {
    const primitiveSets = createDummyPrimitiveSets();
    const plaintext = Random.randBytes(10);
    const contextInfo = Random.randBytes(10);
    // As keys are just dummy keys which do not contain key data, the same key
    // is used for both encrypt and decrypt.
    const key = createDummyKeysetKey(
        /** keyId = */ 0xFFFFFFFF, PbOutputPrefixType.RAW,
        /** enabled = */ true);
    const ciphertextSuffix = new Uint8Array([0, 0, 0, 0xFF]);

    // Get the ciphertext.
    const encryptPrimitive = new DummyHybridEncrypt(ciphertextSuffix);
    const ciphertext = await encryptPrimitive.encrypt(plaintext, contextInfo);

    // Get primitive for decryption.
    const decryptPrimitiveSet = primitiveSets['decryptPrimitiveSet'];
    const decryptPrimitive = new DummyHybridDecrypt(ciphertextSuffix);
    decryptPrimitiveSet.addPrimitive(decryptPrimitive, key);
    const hybridDecrypt = new HybridDecryptWrapper().wrap(decryptPrimitiveSet);

    // Check that contextInfo was passed correctly (decryption without
    // contextInfo argument should not work, but with contextInfo it should work
    // properly).
    try {
      await hybridDecrypt.decrypt(ciphertext);
      fail('An exception should be thrown.');
    } catch (e: any) {
      expect(e.toString()).toBe(ExceptionText.cannotBeDecrypted());
    }
    const decryptedCiphertext =
        await hybridDecrypt.decrypt(ciphertext, contextInfo);

    // Test that the result is the original plaintext.
    expect(decryptedCiphertext).toEqual(plaintext);
  });

  it('decrypt, with disabled primitive', async function() {
    const primitiveSets = createDummyPrimitiveSets();
    const plaintext = Random.randBytes(10);
    const key = createDummyKeysetKey(
        /** keyId = */ 0xFFFFFFFF, PbOutputPrefixType.RAW,
        /** enabled = */ false);
    const ciphertextSuffix = new Uint8Array([0, 0, 0, 0xFF]);

    const encryptPrimitive = new DummyHybridEncrypt(ciphertextSuffix);
    const ciphertext = await encryptPrimitive.encrypt(plaintext);

    const decryptPrimitiveSet = primitiveSets['decryptPrimitiveSet'];
    const decryptPrimitive = new DummyHybridDecrypt(ciphertextSuffix);
    decryptPrimitiveSet.addPrimitive(decryptPrimitive, key);
    const hybridDecrypt = new HybridDecryptWrapper().wrap(decryptPrimitiveSet);

    try {
      await hybridDecrypt.decrypt(ciphertext);
      fail('An exception should be thrown.');
    } catch (e: any) {
      expect(e.toString()).toBe(ExceptionText.cannotBeDecrypted());
    }
  });

  it('decrypt, with empty ciphertext', async function() {
    const primitiveSets = createDummyPrimitiveSets();
    const decryptPrimitiveSet = primitiveSets['decryptPrimitiveSet'];
    const hybridDecrypt = new HybridDecryptWrapper().wrap(decryptPrimitiveSet);

    try {
      await hybridDecrypt.decrypt(new Uint8Array(0));
      fail('An exception should be thrown.');
    } catch (e: any) {
      expect(e.toString()).toBe(ExceptionText.cannotBeDecrypted());
    }
  });
});

/** @final */
class ExceptionText {
  static nullPrimitiveSet(): string {
    return 'SecurityException: Primitive set has to be non-null.';
  }

  static cannotBeDecrypted(): string {
    return 'SecurityException: Decryption failed for the given ciphertext.';
  }

  static nullCiphertext(): string {
    return 'SecurityException: Ciphertext has to be non-null.';
  }
}

/** Function for creating keys for testing purposes. */
function createDummyKeysetKey(
    keyId: number, outputPrefix: PbOutputPrefixType,
    enabled: boolean): PbKeysetKey {
  const key = new PbKeysetKey();

  if (enabled) {
    key.setStatus(PbKeyStatusType.ENABLED);
  } else {
    key.setStatus(PbKeyStatusType.DISABLED);
  }

  key.setOutputPrefixType(outputPrefix);
  key.setKeyId(keyId);

  return key;
}

/**
 * Creates a primitive sets for HybridEncrypt and HybridDecrypt with
 * 'numberOfPrimitives' primitives. The keys corresponding to the primitives
 * have ids from the set [1, ..., numberOfPrimitives] and the primitive
 * corresponding to key with id 'numberOfPrimitives' is set to be primary
 * whenever opt_withPrimary is set to true (where true is the default value).
 */
function createDummyPrimitiveSets(opt_withPrimary: boolean = true): {
  encryptPrimitiveSet: PrimitiveSet.PrimitiveSet<DummyHybridEncrypt>,
  decryptPrimitiveSet: PrimitiveSet.PrimitiveSet<DummyHybridDecrypt>
} {
  const numberOfPrimitives = 5;

  const encryptPrimitiveSet =
      new PrimitiveSet.PrimitiveSet<DummyHybridEncrypt>(DummyHybridEncrypt);
  const decryptPrimitiveSet =
      new PrimitiveSet.PrimitiveSet<DummyHybridDecrypt>(DummyHybridDecrypt);
  for (let i = 1; i < numberOfPrimitives; i++) {
    let outputPrefix: PbOutputPrefixType;
    switch (i % 3) {
      case 0:
        outputPrefix = PbOutputPrefixType.TINK;
        break;
      case 1:
        outputPrefix = PbOutputPrefixType.LEGACY;
        break;
      default:
        outputPrefix = PbOutputPrefixType.RAW;
    }
    const key =
        createDummyKeysetKey(i, outputPrefix, /* enabled = */ i % 4 < 2);
    const ciphertextSuffix = new Uint8Array([0, 0, i]);
    const hybridEncrypt = new DummyHybridEncrypt(ciphertextSuffix);
    encryptPrimitiveSet.addPrimitive(hybridEncrypt, key);
    const hybridDecrypt = new DummyHybridDecrypt(ciphertextSuffix);
    decryptPrimitiveSet.addPrimitive(hybridDecrypt, key);
  }

  const key = createDummyKeysetKey(
      numberOfPrimitives, PbOutputPrefixType.TINK, /* enabled = */ true);
  const ciphertextSuffix = new Uint8Array([0, 0, numberOfPrimitives]);
  const hybridEncrypt = new DummyHybridEncrypt(ciphertextSuffix);
  const encryptEntry = encryptPrimitiveSet.addPrimitive(hybridEncrypt, key);
  const hybridDecrypt = new DummyHybridDecrypt(ciphertextSuffix);
  const decryptEntry = decryptPrimitiveSet.addPrimitive(hybridDecrypt, key);
  if (opt_withPrimary) {
    encryptPrimitiveSet.setPrimary(encryptEntry);
    decryptPrimitiveSet.setPrimary(decryptEntry);
  }

  return {
    'encryptPrimitiveSet': encryptPrimitiveSet,
    'decryptPrimitiveSet': decryptPrimitiveSet
  };
}

/** @final */
class DummyHybridEncrypt extends HybridEncrypt {
  constructor(private readonly ciphertextSuffix: Uint8Array) {
    super();
  }

  async encrypt(plaintext: Uint8Array, opt_contextInfo?: Uint8Array) {
    const ciphertext = Bytes.concat(plaintext, this.ciphertextSuffix);
    if (opt_contextInfo) {
      return Bytes.concat(ciphertext, opt_contextInfo);
    }
    return ciphertext;
  }
}

/** @final */
class DummyHybridDecrypt extends HybridDecrypt {
  constructor(private readonly ciphertextSuffix: Uint8Array) {
    super();
  }

  async decrypt(ciphertext: Uint8Array, opt_contextInfo?: Uint8Array) {
    if (opt_contextInfo) {
      const infoLength = opt_contextInfo.length;
      const contextInfo =
          ciphertext.slice(ciphertext.length - infoLength, ciphertext.length);
      if ([...contextInfo].toString() !== [...opt_contextInfo].toString()) {
        throw new SecurityException('Context info does not match.');
      }
      ciphertext = ciphertext.slice(0, ciphertext.length - infoLength);
    }
    const plaintext =
        ciphertext.slice(0, ciphertext.length - this.ciphertextSuffix.length);
    const suffix = ciphertext.slice(
        ciphertext.length - this.ciphertextSuffix.length, ciphertext.length);
    if ([...suffix].toString() === [...this.ciphertextSuffix].toString()) {
      return plaintext;
    }
    throw new SecurityException(ExceptionText.cannotBeDecrypted());
  }
}
