/**
 * @callback OnTick
 * @memberOf PSVAnimation
 * @param {Object[]} properties - current values
 * @param {float} progress - 0 to 1
 */

/**
 * @summary Interpolation helper for animations
 * @description
 * Implements the Promise API with an additional "cancel" method.
 * The promise is resolved when the animation is complete and rejected if the animation is cancelled.
 * @param {Object} options
 * @param {Object[]} options.properties
 * @param {number} options.properties[].start
 * @param {number} options.properties[].end
 * @param {int} options.duration
 * @param {int} [options.delay=0]
 * @param {string} [options.easing='linear']
 * @param {PSVAnimation.OnTick} options.onTick - called on each frame
 * @constructor
 */
function PSVAnimation(options) {
  if (!(this instanceof PSVAnimation)) {
    return new PSVAnimation(options);
  }

  this._cancelled = false;
  this._resolved = false;

  var self = this;

  this._promise = new Promise(function(resolve, reject) {
    self._resolve = resolve;
    self._reject = reject;
  });

  if (options) {
    if (!options.easing || typeof options.easing === 'string') {
      options.easing = PSVAnimation.easings[options.easing || 'linear'];
    }
    this._start = null;
    this._options = options;

    if (options.delay) {
      this._delayTimeout = window.setTimeout(function() {
        this._delayTimeout = null;
        window.requestAnimationFrame(this._run.bind(this));
      }.bind(this), options.delay);
    }
    else {
      window.requestAnimationFrame(this._run.bind(this));
    }
  }
}

/**
 * @summary Collection of easing functions
 * {@link https://gist.github.com/frederickk/6165768}
 * @type {Object.<string, Function>}
 */
// @formatter:off
// jscs:disable
/* jshint ignore:start */
PSVAnimation.easings = {
  linear: function(t) { return t; },

  inQuad: function(t) { return t*t; },
  outQuad: function(t) { return t*(2-t); },
  inOutQuad: function(t) { return t<.5 ? 2*t*t : -1+(4-2*t)*t; },

  inCubic: function(t) { return t*t*t; },
  outCubic: function(t) { return (--t)*t*t+1; },
  inOutCubic: function(t) { return t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1; },

  inQuart: function(t) { return t*t*t*t; },
  outQuart: function(t) { return 1-(--t)*t*t*t; },
  inOutQuart: function(t) { return t<.5 ? 8*t*t*t*t : 1-8*(--t)*t*t*t; },

  inQuint: function(t) { return t*t*t*t*t; },
  outQuint: function(t) { return 1+(--t)*t*t*t*t; },
  inOutQuint: function(t) { return t<.5 ? 16*t*t*t*t*t : 1+16*(--t)*t*t*t*t; },

  inSine: function(t) { return 1-Math.cos(t*(Math.PI/2)); },
  outSine: function(t) { return Math.sin(t*(Math.PI/2)); },
  inOutSine: function(t) { return .5-.5*Math.cos(Math.PI*t); },

  inExpo: function(t) { return Math.pow(2, 10*(t-1)); },
  outExpo: function(t) { return 1-Math.pow(2, -10*t); },
  inOutExpo: function(t) { t=t*2-1; return t<0 ? .5*Math.pow(2, 10*t) : 1-.5*Math.pow(2, -10*t); },

  inCirc: function(t) { return 1-Math.sqrt(1-t*t); },
  outCirc: function(t) { t--; return Math.sqrt(1-t*t); },
  inOutCirc: function(t) { t*=2; return t<1 ? .5-.5*Math.sqrt(1-t*t) : .5+.5*Math.sqrt(1-(t-=2)*t); }
};
/* jshint ignore:end */
// jscs:enable
// @formatter:on

/**
 * @summary Main loop for the animation
 * @param {int} timestamp
 * @private
 */
PSVAnimation.prototype._run = function(timestamp) {
  // the animation has been cancelled
  if (this._cancelled) {
    return;
  }

  // first iteration
  if (this._start === null) {
    this._start = timestamp;
  }

  // compute progress
  var progress = (timestamp - this._start) / this._options.duration;
  var current = {};
  var name;

  if (progress < 1.0) {
    // interpolate properties
    for (name in this._options.properties) {
      if (this._options.properties[name]) {
        current[name] = this._options.properties[name].start + (this._options.properties[name].end - this._options.properties[name].start) * this._options.easing(progress);
      }
    }

    this._options.onTick(current, progress);

    window.requestAnimationFrame(this._run.bind(this));
  }
  else {
    // call onTick one last time with final values
    for (name in this._options.properties) {
      if (this._options.properties[name]) {
        current[name] = this._options.properties[name].end;
      }
    }

    this._options.onTick(current, 1.0);

    window.requestAnimationFrame(function() {
      this._resolved = true;
      this._resolve();
    }.bind(this));
  }
};

/**
 * @summary Animation chaining
 * @param {function} onFulfilled - Called when the animation is complete, can return a new animation
 * @param {function} onRejected - Called when the animation is cancelled
 * @returns {PSVAnimation}
 */
PSVAnimation.prototype.then = function(onFulfilled, onRejected) {
  var p = new PSVAnimation();

  // Allow cancellation to climb up the promise chain
  p._promise.then(null, this.cancel.bind(this));

  this._promise.then(function() {
    p._resolve(onFulfilled ? onFulfilled() : undefined);
  }, function() {
    p._reject(onRejected ? onRejected() : undefined);
  });

  return p;
};

/**
 * @summary Alias to `.then(null, onRejected)`
 * @param {function} onRejected - Called when the animation has been cancelled
 * @returns {PSVAnimation}
 */
PSVAnimation.prototype.catch = function(onRejected) {
  return this.then(undefined, onRejected);
};

/**
 * @summary Alias to `.then(onFinally, onFinally)`
 * @param {function} onFinally - Called when the animation is either complete or cancelled
 * @returns {PSVAnimation}
 */
PSVAnimation.prototype.finally = function(onFinally) {
  return this.then(onFinally, onFinally);
};

/**
 * @summary Cancels the animation
 */
PSVAnimation.prototype.cancel = function() {
  if (!this._cancelled && !this._resolved) {
    this._cancelled = true;
    this._reject();

    if (this._delayTimeout) {
      window.cancelAnimationFrame(this._delayTimeout);
      this._delayTimeout = null;
    }
  }
};