import {Deferred} from '#core/data-structures/promise';

import {Services} from '#service';

/** @const {function()} */
const NOOP_CALLBACK_ = function () {};

/** @const {number} */
const MIN_VELOCITY_ = 0.02;

/** @const {number} */
const FRAME_CONST_ = 16.67;

/** @const {number} */
const EXP_FRAME_CONST_ = Math.round(-FRAME_CONST_ / Math.log(0.95));

/**
 * Depreciation factor of 1/100 of a millisecond. This is how much previous
 * velocity is depreciated when calculating the new velocity.
 * @const {number}
 */
const VELOCITY_DEPR_FACTOR_ = FRAME_CONST_ * 2;

/**
 * Calculates velocity for an object traveling the distance deltaV in the
 * time deltaTime given the previous velocity prevVelocity. The calculation
 * assumes a basic informational depreciation of previous velocity.
 * @param {number} deltaV
 * @param {time} deltaTime
 * @param {number} prevVelocity
 * @return {number}
 */
export function calcVelocity(deltaV, deltaTime, prevVelocity) {
  if (deltaTime < 1) {
    deltaTime = 1;
  }

  // Calculate speed and speed depreciation.
  const speed = deltaV / deltaTime;

  // Depreciation is simply an informational quality. It basically means:
  // we can't ignore the velocity we knew recently, but we'd only consider
  // it proportionally to how long ago we've seen it. Currently, this
  // depreciation factor is 1/100 of a millisecond. New average velocity is
  // calculated by weighing toward the new velocity and away from old
  // velocity based on the depreciation.
  const depr = 0.5 + Math.min(deltaTime / VELOCITY_DEPR_FACTOR_, 0.5);
  return speed * depr + prevVelocity * (1 - depr);
}

/**
 * Returns a motion process that will yield when the velocity has run down to
 * zerp. For each iteration, the velocity is depreciated and the coordinates
 * are advanced from start X/Y to the destination according to velocity
 * vectors. For each such iteration the callback is called with the new x and y.
 * @param {!Node} contextNode
 * @param {number} startX Start X coordinate.
 * @param {number} startY Start Y coordinate.
 * @param {number} veloX Starting X velocity.
 * @param {number} veloY Starting Y velocity.
 * @param {function(number, number):boolean} callback The callback for each
 *   step of the deceleration motion.
 * @param {!./service/vsync-impl.Vsync=} opt_vsync Mostly for testing only.
 * @return {!Motion}
 */
export function continueMotion(
  contextNode,
  startX,
  startY,
  veloX,
  veloY,
  callback,
  opt_vsync
) {
  return new Motion(
    contextNode,
    startX,
    startY,
    veloX,
    veloY,
    callback,
    opt_vsync
  ).start();
}

/**
 * Motion process that allows tracking and monitoring of the running motion.
 * Most importantly it exposes methods "then" and "thenAlways" that have the
 * semantics of a Promise and signal when the motion has completed or failed.
 * Additionally, it exposes the method "halt" which allows to stop/reset the
 * motion.
 * @implements {IThenable}
 */
export class Motion {
  /**
   * @param {!Node} contextNode Context node.
   * @param {number} startX Start X coordinate.
   * @param {number} startY Start Y coordinate.
   * @param {number} veloX Starting X velocity.
   * @param {number} veloY Starting Y velocity.
   * @param {function(number, number):boolean} callback The callback for each
   *   step of the deceleration motion.
   * @param {!./service/vsync-impl.Vsync=} opt_vsync
   */
  constructor(contextNode, startX, startY, veloX, veloY, callback, opt_vsync) {
    /** @private @const {!./service/vsync-impl.Vsync} */
    this.vsync_ = opt_vsync || Services.vsyncFor(self);

    /** @private @const {!Node} */
    this.contextNode_ = contextNode;

    /** @private @const */
    this.callback_ = callback;

    /** @private {number} */
    this.lastX_ = startX;

    /** @private {number} */
    this.lastY_ = startY;

    /** @private {number} */
    this.maxVelocityX_ = veloX;

    /** @private {number} */
    this.maxVelocityY_ = veloY;

    /** @private {number} */
    this.velocityX_ = 0;

    /** @private {number} */
    this.velocityY_ = 0;

    const deferred = new Deferred();

    /** @private {!Promise} */
    this.promise_ = deferred.promise;

    /** @private {!Function} */
    this.resolve_ = deferred.resolve;

    /** @private {!Function} */
    this.reject_ = deferred.reject;

    /** @private {boolean} */
    this.continuing_ = false;
  }

  /** */
  start() {
    this.continuing_ = true;
    if (
      Math.abs(this.maxVelocityX_) <= MIN_VELOCITY_ &&
      Math.abs(this.maxVelocityY_) <= MIN_VELOCITY_
    ) {
      this.fireMove_();
      this.completeContinue_(true);
    } else {
      this.runContinuing_();
    }
    return this;
  }

  /**
   * Halts the motion. The motion promise will be rejected since the motion
   * has been interrupted.
   */
  halt() {
    if (this.continuing_) {
      this.completeContinue_(false);
    }
  }

  /**
   * Chains to the motion's promise that will resolve when the motion has
   * completed or will reject if motion has failed or was interrupted.
   * @override
   */
  then(opt_resolve, opt_reject) {
    if (!opt_resolve && !opt_reject) {
      return this.promise_;
    }
    return this.promise_.then(opt_resolve, opt_reject);
  }

  /**
   * Callback for regardless whether the motion succeeds or fails.
   * @param {function()=} opt_callback
   * @return {!Promise}
   */
  thenAlways(opt_callback) {
    const callback = opt_callback || NOOP_CALLBACK_;
    return /** @type {!Promise} */ (this.then(callback, callback));
  }

  /**
   * @return {!Promise}
   * @private
   */
  runContinuing_() {
    this.velocityX_ = this.maxVelocityX_;
    this.velocityY_ = this.maxVelocityY_;
    const boundStep = this.stepContinue_.bind(this);
    const boundComplete = this.completeContinue_.bind(this, true);
    return this.vsync_
      .runAnimMutateSeries(this.contextNode_, boundStep, 5000)
      .then(boundComplete, boundComplete);
  }

  /**
   * Returns "true" to continue and "false" to stop motion process.
   * @param {time} timeSinceStart
   * @param {time} timeSincePrev
   * @return {boolean}
   * @private
   */
  stepContinue_(timeSinceStart, timeSincePrev) {
    if (!this.continuing_) {
      return false;
    }

    this.lastX_ += timeSincePrev * this.velocityX_;
    this.lastY_ += timeSincePrev * this.velocityY_;
    if (!this.fireMove_()) {
      return false;
    }

    const decel = Math.exp(-timeSinceStart / EXP_FRAME_CONST_);
    this.velocityX_ = this.maxVelocityX_ * decel;
    this.velocityY_ = this.maxVelocityY_ * decel;
    return (
      Math.abs(this.velocityX_) > MIN_VELOCITY_ ||
      Math.abs(this.velocityY_) > MIN_VELOCITY_
    );
  }

  /**
   * @param {boolean} success
   * @private
   */
  completeContinue_(success) {
    if (!this.continuing_) {
      return;
    }
    this.continuing_ = false;
    this.fireMove_();
    if (success) {
      this.resolve_();
    } else {
      this.reject_();
    }
  }

  /** @private */
  fireMove_() {
    return this.callback_(this.lastX_, this.lastY_);
  }
}
