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

/** @typedef {import('#core/types/date').NormTimeDef} NormTimeDef */
/** @typedef {import('#core/types/date').TimestampDef} TimestampDef */

import {Services} from '#service';

import {dev} from '#utils/log';

const TAG_ = 'Animation';

const NOOP_CALLBACK = function () {};

/**
 * The animation class allows construction of arbitrary animation processes.
 * The main method is "add" that adds a segment of animation at particular
 * time offset (delay) and duration. All animation segments are simply functions
 * of type Transition which are iterated from 0 to 1 in animation frames to
 * achieve the desired effect.
 */
export class Animation {
  /**
   * Creates and starts animation with a single segment. Returns AnimationPlayer
   * object that can be used to monitor or control animation.
   *
   * @param {!Node} contextNode The context node.
   * @param {!TransitionDef<?>} transition Transition to animate.
   * @param {TimestampDef} duration Duration in milliseconds.
   * @param {(!./core/data-structures/curve.CurveDef|string)=} opt_curve Optional curve to use for
   *   animation. Default is the linear animation.
   * @return {!AnimationPlayer}
   */
  static animate(contextNode, transition, duration, opt_curve) {
    return new Animation(contextNode)
      .setCurve(opt_curve)
      .add(0, transition, 1)
      .start(duration);
  }

  /**
   * @param {!Node} contextNode
   * @param {!./service/vsync-impl.Vsync=} opt_vsync
   */
  constructor(contextNode, opt_vsync) {
    /** @private @const {!Node} */
    this.contextNode_ = contextNode;

    /** @private @const {!./service/vsync-impl.Vsync} */
    this.vsync_ = opt_vsync || Services.vsyncFor(self);

    /** @private {?./core/data-structures/curve.CurveDef} */
    this.curve_ = null;

    /**
     * @private @const {!Array<!SegmentDef>}
     */
    this.segments_ = [];
  }

  /**
   * Sets the default curve for the animation. Each segment is allowed to have
   * its own curve, but this curve will be used if a segment doesn't specify
   * its own.
   * @param {!./core/data-structures/curve.CurveDef|string|undefined} curve
   * @return {!Animation}
   */
  setCurve(curve) {
    if (curve) {
      this.curve_ = getCurve(curve);
    }
    return this;
  }

  /**
   * Adds a segment to the animation. Each segment starts at offset (delay) and
   * runs for a portion of the overall animation (duration). Note that both
   * delay and duration and NormTimeDef types which accept values from 0 to 1.
   * Optionally, the time is pushed through a curve. If curve is not specified,
   * the default animation curve will be used. The specified transition is
   * animated over the specified duration from 0 to 1.
   *
   * @param {!NormTimeDef} delay
   * @param {!TransitionDef<?>} transition
   * @param {!NormTimeDef} duration
   * @param {(!./core/data-structures/curve.CurveDef|string)=} opt_curve
   * @return {!Animation}
   */
  add(delay, transition, duration, opt_curve) {
    this.segments_.push({
      delay,
      func: transition,
      duration,
      curve: getCurve(opt_curve),
    });
    return this;
  }

  /**
   * Starts the animation and returns the AnimationPlayer object that can be
   * used to monitor and control the animation.
   *
   * @param {!TimestampDef} duration Absolute time in milliseconds.
   * @return {!AnimationPlayer}
   */
  start(duration) {
    const player = new AnimationPlayer(
      this.vsync_,
      this.contextNode_,
      this.segments_,
      this.curve_,
      duration
    );
    return player;
  }
}

/**
 * AnimationPlayer allows tracking and monitoring of the running animation.
 * Most importantly it exposes methods "then" and "thenAlways" that have the
 * semantics of a Promise and signal when the animation completed or failed.
 * Additionally, it exposes the method "halt" which allows to stop/reset the
 * animation.
 * // TODO(@cramforce) Actually fully implement.
 * implements {IThenable}
 */
class AnimationPlayer {
  /**
   * @param {!./service/vsync-impl.Vsync} vsync
   * @param {!Node} contextNode
   * @param {!Array<!SegmentDef>} segments
   * @param {?./core/data-structures/curve.CurveDef} defaultCurve
   * @param {!TimestampDef} duration
   */
  constructor(vsync, contextNode, segments, defaultCurve, duration) {
    /** @private @const {!./service/vsync-impl.Vsync} */
    this.vsync_ = vsync;

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

    /** @private @const {!Array<!SegmentRuntimeDef>} */
    this.segments_ = [];
    for (let i = 0; i < segments.length; i++) {
      const segment = segments[i];
      this.segments_.push({
        delay: segment.delay,
        func: segment.func,
        duration: segment.duration,
        curve: segment.curve || defaultCurve,
        started: false,
        completed: false,
      });
    }

    /** @private @const */
    this.duration_ = duration;

    /** @private {!TimestampDef} */
    this.startTime_ = Date.now();

    /** @private {!NormTimeDef} */
    // this.normLinearTime_ = 0;

    /** @private {!NormTimeDef} */
    // this.normTime_ = 0;

    /** @private {boolean} */
    this.running_ = true;

    /** @private {!{[key: string]: *}} */
    this.state_ = {};

    const deferred = new Deferred();

    /** @const @private */
    this.promise_ = deferred.promise;

    /** @const @private */
    this.resolve_ = deferred.resolve;

    /** @const @private */
    this.reject_ = deferred.reject;

    /** @const */
    this.task_ = this.vsync_.createAnimTask(this.contextNode_, {
      mutate: this.stepMutate_.bind(this),
    });

    if (this.vsync_.canAnimate(this.contextNode_)) {
      this.task_(this.state_);
    } else {
      dev().warn(TAG_, 'cannot animate');
      this.complete_(/* success */ false, /* dir */ 0);
    }
  }

  /**
   * Chains to the animation's promise that will resolve when the animation has
   * completed or will reject if animation has failed or was interrupted.
   * @param {!Function=} opt_resolve
   * @param {!Function=} opt_reject
   * @return {!Promise}
   */
  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 animation succeeds or fails.
   * @param {!Function=} opt_callback
   * @return {!Promise}
   */
  thenAlways(opt_callback) {
    const callback = opt_callback || NOOP_CALLBACK;
    return this.then(callback, callback);
  }

  /**
   * Halts the animation. Depending on the opt_dir value, the following actions
   * can be performed:
   * 0: No action. The state will be as at the moment of halting (default)
   * 1: Final state. Transitionable will be set to state = 1.
   * -1: Reset state. Transitionable will be reset to state = 0.
   * The animation's promise will be rejected since the transition has been
   * interrupted.
   * @param {number=} opt_dir
   */
  halt(opt_dir) {
    this.complete_(/* success */ false, /* dir */ opt_dir || 0);
  }

  /**
   * @param {boolean} success
   * @param {number} dir
   * @private
   */
  complete_(success, dir) {
    if (!this.running_) {
      return;
    }
    this.running_ = false;
    if (dir != 0) {
      // Sort in the completion order.
      if (this.segments_.length > 1) {
        this.segments_.sort((s1, s2) => {
          return s1.delay + s1.duration - (s2.delay + s2.duration);
        });
      }
      try {
        if (dir > 0) {
          // Natural order - all set to 1.
          for (let i = 0; i < this.segments_.length; i++) {
            this.segments_[i].func(1, true);
          }
        } else {
          // Reverse order - all set to 0.
          for (let i = this.segments_.length - 1; i >= 0; i--) {
            this.segments_[i].func(0, false);
          }
        }
      } catch (e) {
        dev().error(TAG_, 'completion failed: ' + e, e);
        success = false;
      }
    }
    if (success) {
      this.resolve_();
    } else {
      this.reject_();
    }
  }

  /**
   * @param {!{[key: string]: *}} unusedState
   * @private
   */
  stepMutate_(unusedState) {
    if (!this.running_) {
      return;
    }
    const currentTime = Date.now();
    const normLinearTime = Math.min(
      (currentTime - this.startTime_) / this.duration_,
      1
    );

    // Start segments due to be started
    for (let i = 0; i < this.segments_.length; i++) {
      const segment = this.segments_[i];
      if (!segment.started && normLinearTime >= segment.delay) {
        segment.started = true;
      }
    }

    // Execute all pending segments.
    for (let i = 0; i < this.segments_.length; i++) {
      const segment = this.segments_[i];
      if (!segment.started || segment.completed) {
        continue;
      }
      this.mutateSegment_(segment, normLinearTime);
    }

    // Complete or start next cycle.
    if (normLinearTime == 1) {
      this.complete_(/* success */ true, /* dir */ 0);
    } else {
      if (this.vsync_.canAnimate(this.contextNode_)) {
        this.task_(this.state_);
      } else {
        dev().warn(TAG_, 'cancel animation');
        this.complete_(/* success */ false, /* dir */ 0);
      }
    }
  }

  /**
   * @param {!SegmentRuntimeDef} segment
   * @param {number} totalLinearTime
   */
  mutateSegment_(segment, totalLinearTime) {
    let normLinearTime;
    let normTime;
    if (segment.duration > 0) {
      normLinearTime = Math.min(
        (totalLinearTime - segment.delay) / segment.duration,
        1
      );
      normTime = normLinearTime;
      if (segment.curve && normTime != 1) {
        try {
          normTime = segment.curve(normLinearTime);
        } catch (e) {
          dev().error(TAG_, 'step curve failed: ' + e, e);
          this.complete_(/* success */ false, /* dir */ 0);
          return;
        }
      }
    } else {
      normLinearTime = 1;
      normTime = 1;
    }
    if (normLinearTime == 1) {
      segment.completed = true;
    }
    try {
      segment.func(normTime, segment.completed);
    } catch (e) {
      dev().error(TAG_, 'step mutate failed: ' + e, e);
      this.complete_(/* success */ false, /* dir */ 0);
      return;
    }
  }
}

/**
 * @typedef {{
 *   delay: NormTimeDef,
 *   func: !TransitionDef,
 *   duration: NormTimeDef,
 *   curve: ?./core/data-structures/curve.CurveDef
 * }}
 */
let SegmentDef;

/**
 * @typedef {{
 *   delay: NormTimeDef,
 *   func: !TransitionDef,
 *   duration: NormTimeDef,
 *   curve: ?./core/data-structures/curve.CurveDef,
 *   started: boolean,
 *   completed: boolean
 * }}
 */
let SegmentRuntimeDef;
