import run from 'ember-runloop';
import { RequestAbortedError } from 'web-client/utilities/error';
import RSVP from 'rsvp';
import times from 'web-client/utilities/times';

/**
 * A FileTransfer sends a file. It may retry the transmission indefinitely and
 * it reports progress events as the file is transferred.
 */
export default class FileTransfer {
  /**
   * Create a new FileTransfer object.
   *
   * @param {File} file - A file to upload
   * @param {function} sendPart - A function that sends a FilePart over the network
   * @param {function} onProgress - A callback to notify consumers of progress updates
   * @param {RetryTimer} retryTimer - An object that controls the timing of retry attempts
   * @param {number} partSize - The maximum part size to send to the server
   * @param {number} maxInFlightRequests - The maximum number of requests to have inFlight at once
   */
  constructor({
    file,
    sendPart,
    onUploadStart,
    onProgress,
    retryTimer,
    partSize,
    maxInFlightRequests
  }) {
    this.file = file;
    this._partSize = partSize;
    this._sendPartOverNetwork = sendPart;
    this._onUploadStart = onUploadStart || (() => {});
    this._onProgress = onProgress || (() => {});
    this._currentByteIndex = 0;
    this._aknowledgedByteCount = 0;
    this._inFlightParts = [];
    this._maxInFlightRequests = maxInFlightRequests || 1;
    this._retryTimer = retryTimer;
  }

  /**
   * Notify listeners that this fileTransfer started uploading then start
   * dispatching parallel chains of requests to the server according to the
   * supplied maxInFlightRequests parameter.
   *
   * @return {Promise} A promise that resolves when all parallel requests resolve
   */
  send() {
    this._onUploadStart();

    let requestChains = times(this._maxInFlightRequests, () => this._sendNextPart());
    return RSVP.all(requestChains);
  }

  /**
   * Begin a serial chain of promises that slices the next part of a file and
   * sends it over the network.
   *
   * @private
   * @return {Promise} A promise that resolves when the file has finished
   * uploading.
   */
  _sendNextPart() {
    // We've sent all parts so this is the end of the chain.
    if (this._currentByteIndex >= this.file.size) {
      return;
    }

    let blob = fileSlice(this.file, this._currentByteIndex, this._currentByteIndex + this._partSize);

    // Advance the index by one part.
    this._currentByteIndex += this._partSize;

    let filePart = new FilePart({
      file: blob,
      number: this._currentByteIndex / this._partSize,
      onProgress: (event) => this._reportTotalProgress(event)
    });

    // Keep track of inFlight requests
    this._inFlightParts.push(filePart);

    return this._sendPart(filePart).then(() => {
      this._aknowledgedByteCount += filePart.file.size;
      removeFirst(this._inFlightParts, filePart);

      return this._sendNextPart();
    });
  }

  /**
   * Use the _sendPartOverNetwork callback to send file parts over the network
   * in serial. This method will retry the request indefinitely until it
   * receives an unrecoverable error indicating that is should stop the upload.
   *
   * @private
   * @return {Promise} A promise that resolves when a file part is successfully sent
   */
  _sendPart(filePart) {
    return this._sendPartOverNetwork(filePart).then(() => {
      this._retryTimer.reset();
    }, (error) => {
      if (isUnrecoverableError(error)) {
        throw error;
      }

      let waitTime = this._retryTimer.nextInterval();

      return resolveLater(waitTime).then(() => {
        return this._sendPart(filePart);
      });
    });
  }

  /**
   * This method aborts all inFlight requests.
   *
   * @private
   * @return {undefined}
   */
  abort() {
    this._inFlightParts.forEach((p) => p.abort());
  }

  /**
   * Report the overall progress for this file transfer taking into account
   * any previously completed parts and any inFlight requests.
   *
   * @private
   * @return {undefined}
   */
  _reportTotalProgress() {
    this._onProgress({
      loaded: this._aknowledgedByteCount + this._inFlightByteCount,
      total: this.file.size
    });
  }

  /**
   * @private
   * @return {number} The total of loaded bytes for inFlight requests
   */
  get _inFlightByteCount() {
    return this._inFlightParts.reduce((sum, p) => sum + p.loaded, 0);
  }
}

/**
 * A FilePart encasulates the properties needed for the FileTransfer's sendPart
 * callback. It holds an abort callback and tracks its bytes loaded.
 */
class FilePart {
  constructor({ file, number, onProgress }) {
    this.file = file;
    this.number = number;
    this._onProgressCallback = onProgress;
    this.retryCount = 0;
    this.loaded = 0;
  }

  // Added by adapter when inFlight
  abort() {}

  onProgress({ loaded }) {
    this.loaded = loaded;
    this._onProgressCallback();
  }
}

/**
 * Use the Blob.slice() API to create a blob slice of a file to upload. This function
 * detects the right method to use in various browsers and caches the result.
 *
 * @return {Blob}
 */
function fileSlice(file, startAt, endAt) {
  let sliceMethodName = fileSlice.cachedMethodName;

  if (!sliceMethodName) {
    sliceMethodName = ['slice', 'mozSlice', 'webkitSlice'].find((name) => {
      return typeof file[name] === 'function';
    });

    if (!sliceMethodName) {
      throw new Error('Could not find a File.slice() method to use');
    }

    fileSlice.cachedMethodName = sliceMethodName;
  }

  return file[sliceMethodName](startAt, endAt);
}

fileSlice.cachedMethodName = null;

/**
 * Given an error object, determine whether we should retry the request or
 * give up and move on to the next file.
 *
 * @param {Error} - An object representing an error
 * @return {Boolean}
 */
function isUnrecoverableError(error) {
  return (typeof error === 'object' && error.status >= 400 && error.status < 500) ||
         (error instanceof RequestAbortedError);
}


/* General Utility Functions */

function resolveLater(timeout) {
  return new RSVP.Promise((resolve) => run.later(undefined, resolve, timeout));
}

function removeFirst(array, item) {
  let index = array.indexOf(item);

  if (index !== -1) {
    array.splice(index, 1);
  }

  return array;
}
