/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Asynchronous hash computer for the Blob interface.
 *
 * The Blob interface, part of the HTML5 File API, is supported on Chrome 7+,
 * Firefox 4.0 and Opera 11. No Blob interface implementation is expected on
 * Internet Explorer 10. Chrome 11, Firefox 5.0 and the subsequent release of
 * Opera are supposed to use vendor prefixes due to evolving API, see
 * http://dev.w3.org/2006/webapi/FileAPI/ for details.
 *
 * This implementation currently uses upcoming Chrome and Firefox prefixes,
 * plus the original Blob.slice specification, as implemented on Chrome 10
 * and Firefox 4.0.
 */

goog.provide('goog.crypt.BlobHasher');
goog.provide('goog.crypt.BlobHasher.EventType');

goog.require('goog.asserts');
goog.require('goog.events.EventTarget');
goog.require('goog.fs');
goog.require('goog.log');
goog.requireType('goog.crypt.Hash');



/**
 * Construct the hash computer.
 *
 * @param {!goog.crypt.Hash} hashFn The hash function to use.
 * @param {number=} opt_blockSize Processing block size.
 * @constructor
 * @struct
 * @extends {goog.events.EventTarget}
 * @final
 */
goog.crypt.BlobHasher = function(hashFn, opt_blockSize) {
  'use strict';
  goog.crypt.BlobHasher.base(this, 'constructor');

  /**
   * The actual hash function.
   * @type {!goog.crypt.Hash}
   * @private
   */
  this.hashFn_ = hashFn;

  /**
   * The blob being processed or null if no blob is being processed.
   * @type {?Blob}
   * @private
   */
  this.blob_ = null;

  /**
   * Computed hash value.
   * @type {?Array<number>}
   * @private
   */
  this.hashVal_ = null;

  /**
   * Number of bytes already processed.
   * @type {number}
   * @private
   */
  this.bytesProcessed_ = 0;

  /**
   * The number of bytes to hash or Infinity for no limit.
   * @type {number}
   * @private
   */
  this.hashingLimit_ = Infinity;

  /**
   * Processing block size.
   * @type {number}
   * @private
   */
  this.blockSize_ = opt_blockSize || 5000000;

  /**
   * File reader object. Will be null if no chunk is currently being read.
   * @type {?FileReader}
   * @private
   */
  this.fileReader_ = null;

  /**
   * The logger used by this object.
   * @type {goog.log.Logger}
   * @private
   */
  this.logger_ = goog.log.getLogger('goog.crypt.BlobHasher');
};
goog.inherits(goog.crypt.BlobHasher, goog.events.EventTarget);


/**
 * Event names for hash computation events
 * @enum {string}
 */
goog.crypt.BlobHasher.EventType = {
  STARTED: 'started',
  PROGRESS: 'progress',
  THROTTLED: 'throttled',
  COMPLETE: 'complete',
  ABORT: 'abort',
  ERROR: 'error'
};


/**
 * Start the hash computation.
 * @param {!Blob} blob The blob of data to compute the hash for.
 */
goog.crypt.BlobHasher.prototype.hash = function(blob) {
  'use strict';
  this.abort();
  this.hashFn_.reset();
  this.blob_ = blob;
  this.hashVal_ = null;
  this.bytesProcessed_ = 0;
  this.dispatchEvent(goog.crypt.BlobHasher.EventType.STARTED);

  this.processNextBlock_();
};


/**
 * Sets the maximum number of bytes to hash or Infinity for no limit. Can be
 * called before hash() to throttle the hash computation. The hash computation
 * can then be continued by repeatedly calling setHashingLimit() with greater
 * byte offsets. This is useful if you don't need the hash until some time in
 * the future, for example when uploading a file and you don't need the hash
 * until the transfer is complete.
 * @param {number} byteOffset The byte offset to compute the hash up to.
 *     Should be a non-negative integer or Infinity for no limit. Negative
 *     values are not allowed.
 */
goog.crypt.BlobHasher.prototype.setHashingLimit = function(byteOffset) {
  'use strict';
  goog.asserts.assert(byteOffset >= 0, 'Hashing limit must be non-negative.');
  this.hashingLimit_ = byteOffset;

  // Resume processing if a blob is currently being hashed, but no block read
  // is currently in progress.
  if (this.blob_ && !this.fileReader_) {
    this.processNextBlock_();
  }
};


/**
 * Abort hash computation.
 */
goog.crypt.BlobHasher.prototype.abort = function() {
  'use strict';
  if (this.fileReader_) {
    this.fileReader_.abort();
    this.fileReader_ = null;
  }

  if (this.blob_) {
    this.blob_ = null;
    this.dispatchEvent(goog.crypt.BlobHasher.EventType.ABORT);
  }
};


/**
 * @return {number} Number of bytes processed so far.
 */
goog.crypt.BlobHasher.prototype.getBytesProcessed = function() {
  'use strict';
  return this.bytesProcessed_;
};


/**
 * @return {Array<number>} The computed hash value or null if not ready.
 */
goog.crypt.BlobHasher.prototype.getHash = function() {
  'use strict';
  return this.hashVal_;
};


/**
 * Helper function setting up the processing for the next block, or finalizing
 * the computation if all blocks were processed.
 * @private
 */
goog.crypt.BlobHasher.prototype.processNextBlock_ = function() {
  'use strict';
  goog.asserts.assert(this.blob_, 'A hash computation must be in progress.');

  if (this.bytesProcessed_ < this.blob_.size) {
    if (this.hashingLimit_ <= this.bytesProcessed_) {
      // Throttle limit reached. Wait until we are allowed to hash more bytes.
      this.dispatchEvent(goog.crypt.BlobHasher.EventType.THROTTLED);
      return;
    }

    // We have to reset the FileReader every time, otherwise it fails on
    // Chrome, including the latest Chrome 12 beta.
    // http://code.google.com/p/chromium/issues/detail?id=82346
    this.fileReader_ = new FileReader();
    this.fileReader_.onload = goog.bind(this.onLoad_, this);
    this.fileReader_.onerror = goog.bind(this.onError_, this);

    var endOffset = Math.min(this.hashingLimit_, this.blob_.size);
    var size = Math.min(endOffset - this.bytesProcessed_, this.blockSize_);
    var chunk = goog.fs.sliceBlob(
        this.blob_, this.bytesProcessed_, this.bytesProcessed_ + size);
    if (!chunk || chunk.size != size) {
      goog.log.error(this.logger_, 'Failed slicing the blob');
      this.onError_();
      return;
    }

    if (this.fileReader_.readAsArrayBuffer) {
      this.fileReader_.readAsArrayBuffer(chunk);
    } else if (this.fileReader_.readAsBinaryString) {
      this.fileReader_.readAsBinaryString(chunk);
    } else {
      goog.log.error(this.logger_, 'Failed calling the chunk reader');
      this.onError_();
    }
  } else {
    this.hashVal_ = this.hashFn_.digest();
    this.blob_ = null;
    this.dispatchEvent(goog.crypt.BlobHasher.EventType.COMPLETE);
  }
};


/**
 * Handle processing block loaded.
 * @private
 */
goog.crypt.BlobHasher.prototype.onLoad_ = function() {
  'use strict';
  goog.log.info(this.logger_, 'Successfully loaded a chunk');

  var array = null;
  if (this.fileReader_.result instanceof Array ||
      typeof this.fileReader_.result === 'string') {
    array = this.fileReader_.result;
  } else if (
      goog.global['ArrayBuffer'] && goog.global['Uint8Array'] &&
      this.fileReader_.result instanceof ArrayBuffer) {
    array = new Uint8Array(this.fileReader_.result);
  }
  if (!array) {
    goog.log.error(this.logger_, 'Failed reading the chunk');
    this.onError_();
    return;
  }

  this.hashFn_.update(array);
  this.bytesProcessed_ += array.length;
  this.fileReader_ = null;
  this.dispatchEvent(goog.crypt.BlobHasher.EventType.PROGRESS);

  this.processNextBlock_();
};


/**
 * Handles error.
 * @private
 */
goog.crypt.BlobHasher.prototype.onError_ = function() {
  'use strict';
  this.fileReader_ = null;
  this.blob_ = null;
  this.dispatchEvent(goog.crypt.BlobHasher.EventType.ERROR);
};
