/* See LICENSE for licensing and NOTICE for copyright. */
package org.cryptacular.util;

import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import org.bouncycastle.crypto.Digest;
import org.bouncycastle.crypto.digests.SHA1Digest;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.digests.SHA3Digest;
import org.bouncycastle.crypto.digests.SHA512Digest;
import org.cryptacular.CryptUtil;
import org.cryptacular.CryptoException;
import org.cryptacular.SaltedHash;
import org.cryptacular.StreamException;
import org.cryptacular.io.Resource;

/**
 * Utility class for computing cryptographic hashes.
 *
 * @author  Middleware Services
 */
public final class HashUtil
{

  /** Private constructor of utility class. */
  private HashUtil() {}


  /**
   * Computes the hash of the given data using the given algorithm. A salted hash may be produced as follows:
   *
   * <pre>
       // data is a byte array containing raw data to digest
       final byte[] salt = new RBGNonce(16).generate();
       final byte[] hash = HashUtil.hash(new SHA1Digest(), data, salt);
   * </pre>
   *
   * @param  digest  Hash algorithm.
   * @param  data  Data to hash. Supported types are <code>byte[]</code>, {@link CharSequence} ,{@link InputStream}, and
   *               {@link Resource}. Character data is processed in the <code>UTF-8</code> character set; if another
   *               character set is desired, the caller should convert to <code>byte[]</code> and provide the resulting
   *               bytes.
   *
   * @return  Byte array of length {@link Digest#getDigestSize()} containing hash output.
   *
   * @throws  CryptoException  on hash computation errors.
   * @throws  StreamException  on stream IO errors.
   */
  public static byte[] hash(final Digest digest, final Object... data) throws CryptoException, StreamException
  {
    CryptUtil.assertNotNullArg(digest, "Digest cannot be null");
    CryptUtil.assertNotNullArg(data, "Data cannot be null");
    for (Object o : data) {
      if (o instanceof byte[]) {
        final byte[] bytes = (byte[]) o;
        digest.update(bytes, 0, bytes.length);
      } else if (o instanceof CharSequence) {
        final byte[] bytes = ByteUtil.toBytes((CharSequence) o);
        digest.update(bytes, 0, bytes.length);
      } else if (o instanceof InputStream) {
        hashStream(digest, (InputStream) o);
      } else if (o instanceof Resource) {
        final InputStream in;
        try {
          in = ((Resource) o).getInputStream();
        } catch (IOException e) {
          throw new StreamException(e);
        }
        hashStream(digest, in);
      } else {
        throw new IllegalArgumentException("Invalid input data type " + o);
      }
    }

    final byte[] output = new byte[digest.getDigestSize()];
    try {
      digest.doFinal(output, 0);
    } catch (RuntimeException e) {
      throw new CryptoException("Hash computation error", e);
    }
    return output;
  }


  /**
   * Computes the iterated hash of the given data using the given algorithm. The following example demonstrates a
   * typical usage pattern, a salted hash with 10 rounds:
   *
   * <pre>
       // data is a byte array containing raw data to digest
       final byte[] salt = new RBGNonce(16).generate();
       final byte[] hash = HashUtil.hash(new SHA1Digest(), 10, data, salt);
   * </pre>
   *
   * @param  digest  Hash algorithm.
   * @param  iterations  Number of hash rounds. Must be positive value.
   * @param  data  Data to hash. Supported types are <code>byte[]</code>, {@link CharSequence} ,{@link InputStream}, and
   *               {@link Resource}. Character data is processed in the <code>UTF-8</code> character set; if another
   *               character set is desired, the caller should convert to <code>byte[]</code> and provide the resulting
   *               bytes.
   *
   * @return  Byte array of length {@link Digest#getDigestSize()} containing hash output.
   *
   * @throws  CryptoException  on hash computation errors.
   * @throws  StreamException  on stream IO errors.
   */
  public static byte[] hash(final Digest digest, final int iterations, final Object... data)
      throws CryptoException, StreamException
  {
    if (iterations < 1) {
      throw new IllegalArgumentException("Iterations must be positive");
    }
    final byte[] output = hash(digest, data);
    try {
      for (int i = 1; i < iterations; i++) {
        digest.update(output, 0, output.length);
        digest.doFinal(output, 0);
      }
    } catch (RuntimeException e) {
      throw new CryptoException("Hash computation error", e);
    }
    return output;
  }


  /**
   * Determines whether the hash of the given input equals a known value.
   *
   * @param  digest  Hash algorithm.
   * @param  hash  Hash to compare with. If the length of the array is greater than the length of the digest output,
   *               anything beyond the digest length is considered salt data that is hashed <strong>after</strong> the
   *               input data.
   * @param  iterations  Number of hash rounds.
   * @param  data  Data to hash.
   *
   * @return  True if the hash of the data under the given digest is equal to the hash, false otherwise.
   *
   * @throws  CryptoException  on hash computation errors.
   * @throws  StreamException  on stream IO errors.
   */
  public static boolean compareHash(final Digest digest, final byte[] hash, final int iterations, final Object... data)
      throws CryptoException, StreamException
  {
    if (iterations < 1) {
      throw new IllegalArgumentException("Iterations must be positive");
    }
    CryptUtil.assertNotNullArg(digest, "Digest cannot be null");
    CryptUtil.assertNotNullArg(hash, "Hash cannot be null");
    CryptUtil.assertNotNullArg(data, "Data cannot be null");
    if (hash.length > digest.getDigestSize()) {
      final byte[] hashPart = Arrays.copyOfRange(hash, 0, digest.getDigestSize());
      final byte[] saltPart = Arrays.copyOfRange(hash, digest.getDigestSize(), hash.length);
      final Object[] dataWithSalt = Arrays.copyOf(data, data.length + 1);
      dataWithSalt[data.length] = saltPart;
      return Arrays.equals(hash(digest, iterations, dataWithSalt), hashPart);
    }
    return Arrays.equals(hash(digest, iterations, data), hash);
  }


  /**
   * Determines whether the salted hash of the given input equals a known hash value.
   *
   * @param  digest  Hash algorithm.
   * @param  hash  Salted hash data.
   * @param  iterations  Number of hash rounds.
   * @param  saltAfterData  True to apply salt after data, false to apply salt before data.
   * @param  data  Data to hash, which should NOT include the salt value.
   *
   * @return  True if the hash of the data under the given digest is equal to the hash, false otherwise.
   *
   * @throws  CryptoException  on hash computation errors.
   * @throws  StreamException  on stream IO errors.
   */
  public static boolean compareHash(
    final Digest digest,
    final SaltedHash hash,
    final int iterations,
    final boolean saltAfterData,
    final Object... data)
    throws CryptoException, StreamException
  {
    if (iterations < 1) {
      throw new IllegalArgumentException("Iterations must be positive");
    }
    CryptUtil.assertNotNullArg(digest, "Digest cannot be null");
    CryptUtil.assertNotNullArg(hash, "Hash cannot be null");
    CryptUtil.assertNotNullArg(data, "Data cannot be null");
    final Object[] dataWithSalt;
    if (saltAfterData) {
      dataWithSalt = Arrays.copyOf(data, data.length + 1);
      dataWithSalt[data.length] = hash.getSalt();
    } else {
      dataWithSalt = new Object[data.length + 1];
      dataWithSalt[0] = hash.getSalt();
      System.arraycopy(data, 0, dataWithSalt, 1, data.length);
    }
    return Arrays.equals(hash(digest, iterations, dataWithSalt), hash.getHash());
  }


  /**
   * Produces the SHA-1 hash of the given data.
   *
   * @param  data  Data to hash. See {@link #hash(Digest, Object...)} for supported inputs.
   *
   * @return  20-byte array containing hash output.
   *
   * @see  #hash(Digest, Object...)
   */
  public static byte[] sha1(final Object... data)
  {
    return hash(new SHA1Digest(), data);
  }


  /**
   * Produces the SHA-256 hash of the given data.
   *
   * @param  data  Data to hash. See {@link #hash(Digest, Object...)} for supported inputs.
   *
   * @return  32-byte array containing hash output.
   *
   * @see  #hash(Digest, Object...)
   */
  public static byte[] sha256(final Object... data)
  {
    return hash(new SHA256Digest(), data);
  }


  /**
   * Produces the SHA-512 hash of the given data.
   *
   * @param  data  Data to hash. See {@link #hash(Digest, Object...)} for supported inputs.
   *
   * @return  64-byte array containing hash output.
   *
   * @see  #hash(Digest, Object...)
   */
  public static byte[] sha512(final Object... data)
  {
    return hash(new SHA512Digest(), data);
  }


  /**
   * Produces the SHA-3 hash of the given data.
   *
   * @param  bitLength  One of the supported SHA-3 output bit lengths: 224, 256, 384, or 512.
   * @param  data  Data to hash. See {@link #hash(Digest, Object...)} for supported inputs.
   *
   * @return  Byte array of size <code>bitLength</code> containing hash output.
   *
   * @see  #hash(Digest, Object...)
   */
  public static byte[] sha3(final int bitLength, final Object... data)
  {
    return hash(new SHA3Digest(bitLength), data);
  }


  /**
   * Digests the data in the given stream. Note this method does not finalize the digest process by calling {@link
   * Digest#doFinal(byte[], int)}.
   *
   * @param  digest  Digest algorithm.
   * @param  in  Input stream containing data to hash.
   */
  private static void hashStream(final Digest digest, final InputStream in)
  {
    final byte[] buffer = new byte[StreamUtil.CHUNK_SIZE];
    int length;
    try {
      while ((length = in.read(buffer)) > 0) {
        digest.update(buffer, 0, length);
      }
    } catch (IOException e) {
      throw new StreamException(e);
    }
  }
}
