/**
 * @license
 * Copyright 2020 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import {SecurityException} from '../exception/security_exception';

import {CryptoFormat} from './crypto_format';
import {PbKeysetKey, PbKeyStatusType, PbOutputPrefixType} from './proto';
import {Constructor} from './util';

/**
 * Auxiliary class for PrimitiveSet
 * Entry-objects hold individual instances of primitives in the set.
 *
 * @template P
 * @final
 */
export class Entry<P> {
  constructor(
      private readonly primitive: P, private readonly identifier: Uint8Array,
      private readonly keyStatus: PbKeyStatusType,
      private readonly outputPrefixType: PbOutputPrefixType) {}

  getPrimitive(): P {
    return this.primitive;
  }

  getIdentifier(): Uint8Array {
    return this.identifier;
  }

  getKeyStatus(): PbKeyStatusType {
    return this.keyStatus;
  }

  getOutputPrefixType(): PbOutputPrefixType {
    return this.outputPrefixType;
  }
}

/**
 * A container class for a set of primitives (i.e. implementations of
 * cryptographic primitives offered by Tink). It provides also additional
 * properties for the primitives it holds. In particular, one of the primitives
 * in the set can be distinguished as "the primary" one.
 *
 * PrimitiveSet is an auxiliary class used for supporting key rotation:
 * primitives in a set correspond to keys in a keyset. Users will usually work
 * with primitive instances which essentially wrap primitive sets. For example
 * an instance of an Aead-primitive for a given keyset holds a set of
 * Aead-primitives corresponding to the keys in the keyset, and uses the set
 * members to do the actual crypto operations: to encrypt data the primary
 * Aead-primitive from the set is used, and upon decryption the ciphertext's
 * prefix determines the identifier of the primitive from the set.
 *
 * PrimitiveSet is a public class to allow its use in implementations of custom
 * primitives.
 *
 * @final
 */
export class PrimitiveSet<P> {
  private primary: Entry<P>|null = null;

  // Keys have to be stored as strings as two Uint8Arrays holding the same
  // digits are still different objects.
  private readonly identifierToPrimitivesMap: Map<string, Array<Entry<P>>>;

  constructor(private readonly primitiveType: Constructor<P>) {
    this.identifierToPrimitivesMap = new Map();
  }

  /**
   * Returns the type of primitives contained in this set.
   *
   */
  getPrimitiveType(): Constructor<P> {
    return this.primitiveType;
  }

  /**
   * Creates an entry in the primitive table and returns it.
   *
   *
   */
  addPrimitive(primitive: P, key: PbKeysetKey): Entry<P> {
    if (!primitive) {
      throw new SecurityException('Primitive has to be non null.');
    }
    if (!key) {
      throw new SecurityException('Key has to be non null.');
    }
    const identifier = CryptoFormat.getOutputPrefix(key);
    const entry = new Entry(
        primitive, identifier, key.getStatus(), key.getOutputPrefixType());
    this.addPrimitiveToMap(entry);
    return entry;
  }

  /**
   * Returns the entry with the primary primitive.
   *
   */
  getPrimary(): Entry<P>|null {
    return this.primary;
  }

  /**
   * Sets given Entry as the primary one.
   *
   */
  setPrimary(primitive: Entry<P>) {
    if (!primitive) {
      throw new SecurityException('Primary cannot be set to null.');
    }
    if (primitive.getKeyStatus() != PbKeyStatusType.ENABLED) {
      throw new SecurityException('Primary has to be enabled.');
    }

    // There has to be exactly one key enabled with this identifier.
    const entries = this.getPrimitives(primitive.getIdentifier());
    let entryFound = false;
    const entriesLength = entries.length;
    for (let i = 0; i < entriesLength; i++) {
      if (entries[i].getKeyStatus() === PbKeyStatusType.ENABLED) {
        entryFound = true;
        break;
      }
    }
    if (!entryFound) {
      throw new SecurityException(
          'Primary cannot be set to an entry which is ' +
          'not held by this primitive set.');
    }
    this.primary = primitive;
  }

  /**
   * Returns all primitives using RAW prefix.
   *
   */
  getRawPrimitives(): Array<Entry<P>> {
    return this.getPrimitives(CryptoFormat.RAW_PREFIX);
  }

  /**
   * Returns the entries with primitive identified with identifier.
   *
   *
   */
  getPrimitives(identifier: Uint8Array): Array<Entry<P>> {
    const result = this.getPrimitivesFromMap(identifier);
    if (!result) {
      return [];
    } else {
      return result;
    }
  }

  /**
   * Returns a set of primitives which corresponds to the given identifier.
   *
   *
   */
  private getPrimitivesFromMap(identifier: Uint8Array|
                               string): Array<Entry<P>>|undefined {
    if (identifier instanceof Uint8Array) {
      identifier = [...identifier].toString();
    }
    return this.identifierToPrimitivesMap.get(identifier);
  }

  /**
   * Add primitive to map.
   *
   */
  private addPrimitiveToMap(entry: Entry<P>) {
    const identifier = entry.getIdentifier();
    const id = [...identifier].toString();
    const existing = this.getPrimitivesFromMap(id);
    if (!existing) {
      this.identifierToPrimitivesMap.set(id, [entry]);
    } else {
      existing.push(entry);
      this.identifierToPrimitivesMap.set(id, existing);
    }
  }
}
