/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow strict-local
 * @format
 */

import type {PlatformConfig} from '../AnimatedPlatformConfig';
import type AnimatedInterpolation from '../nodes/AnimatedInterpolation';
import type AnimatedValue from '../nodes/AnimatedValue';
import type AnimatedValueXY from '../nodes/AnimatedValueXY';
import type {AnimationConfig, EndCallback} from './Animation';

import AnimatedColor from '../nodes/AnimatedColor';
import * as SpringConfig from '../SpringConfig';
import Animation from './Animation';
import invariant from 'invariant';

export type SpringAnimationConfig = $ReadOnly<{
  ...AnimationConfig,
  toValue:
    | number
    | AnimatedValue
    | {
        x: number,
        y: number,
        ...
      }
    | AnimatedValueXY
    | {
        r: number,
        g: number,
        b: number,
        a: number,
        ...
      }
    | AnimatedColor
    | AnimatedInterpolation<number>,
  overshootClamping?: boolean,
  restDisplacementThreshold?: number,
  restSpeedThreshold?: number,
  velocity?:
    | number
    | $ReadOnly<{
        x: number,
        y: number,
        ...
      }>,
  bounciness?: number,
  speed?: number,
  tension?: number,
  friction?: number,
  stiffness?: number,
  damping?: number,
  mass?: number,
  delay?: number,
  ...
}>;

export type SpringAnimationConfigSingle = $ReadOnly<{
  ...AnimationConfig,
  toValue: number,
  overshootClamping?: boolean,
  restDisplacementThreshold?: number,
  restSpeedThreshold?: number,
  velocity?: number,
  bounciness?: number,
  speed?: number,
  tension?: number,
  friction?: number,
  stiffness?: number,
  damping?: number,
  mass?: number,
  delay?: number,
  ...
}>;

opaque type SpringAnimationInternalState = $ReadOnly<{
  lastPosition: number,
  lastVelocity: number,
  lastTime: number,
}>;

export default class SpringAnimation extends Animation {
  _overshootClamping: boolean;
  _restDisplacementThreshold: number;
  _restSpeedThreshold: number;
  _lastVelocity: number;
  _startPosition: number;
  _lastPosition: number;
  _fromValue: number;
  _toValue: number;
  _stiffness: number;
  _damping: number;
  _mass: number;
  _initialVelocity: number;
  _delay: number;
  _timeout: ?TimeoutID;
  _startTime: number;
  _lastTime: number;
  _frameTime: number;
  _onUpdate: (value: number) => void;
  _animationFrame: ?AnimationFrameID;
  _platformConfig: ?PlatformConfig;

  constructor(config: SpringAnimationConfigSingle) {
    super(config);

    this._overshootClamping = config.overshootClamping ?? false;
    this._restDisplacementThreshold = config.restDisplacementThreshold ?? 0.001;
    this._restSpeedThreshold = config.restSpeedThreshold ?? 0.001;
    this._initialVelocity = config.velocity ?? 0;
    this._lastVelocity = config.velocity ?? 0;
    this._toValue = config.toValue;
    this._delay = config.delay ?? 0;
    this._platformConfig = config.platformConfig;

    if (
      config.stiffness !== undefined ||
      config.damping !== undefined ||
      config.mass !== undefined
    ) {
      invariant(
        config.bounciness === undefined &&
          config.speed === undefined &&
          config.tension === undefined &&
          config.friction === undefined,
        'You can define one of bounciness/speed, tension/friction, or stiffness/damping/mass, but not more than one',
      );
      this._stiffness = config.stiffness ?? 100;
      this._damping = config.damping ?? 10;
      this._mass = config.mass ?? 1;
    } else if (config.bounciness !== undefined || config.speed !== undefined) {
      // Convert the origami bounciness/speed values to stiffness/damping
      // We assume mass is 1.
      invariant(
        config.tension === undefined &&
          config.friction === undefined &&
          config.stiffness === undefined &&
          config.damping === undefined &&
          config.mass === undefined,
        'You can define one of bounciness/speed, tension/friction, or stiffness/damping/mass, but not more than one',
      );
      const springConfig = SpringConfig.fromBouncinessAndSpeed(
        config.bounciness ?? 8,
        config.speed ?? 12,
      );
      this._stiffness = springConfig.stiffness;
      this._damping = springConfig.damping;
      this._mass = 1;
    } else {
      // Convert the origami tension/friction values to stiffness/damping
      // We assume mass is 1.
      const springConfig = SpringConfig.fromOrigamiTensionAndFriction(
        config.tension ?? 40,
        config.friction ?? 7,
      );
      this._stiffness = springConfig.stiffness;
      this._damping = springConfig.damping;
      this._mass = 1;
    }

    invariant(this._stiffness > 0, 'Stiffness value must be greater than 0');
    invariant(this._damping > 0, 'Damping value must be greater than 0');
    invariant(this._mass > 0, 'Mass value must be greater than 0');
  }

  __getNativeAnimationConfig(): $ReadOnly<{
    damping: number,
    initialVelocity: number,
    iterations: number,
    mass: number,
    platformConfig: ?PlatformConfig,
    overshootClamping: boolean,
    restDisplacementThreshold: number,
    restSpeedThreshold: number,
    stiffness: number,
    toValue: number,
    type: 'spring',
    ...
  }> {
    return {
      type: 'spring',
      overshootClamping: this._overshootClamping,
      restDisplacementThreshold: this._restDisplacementThreshold,
      restSpeedThreshold: this._restSpeedThreshold,
      stiffness: this._stiffness,
      damping: this._damping,
      mass: this._mass,
      initialVelocity: this._initialVelocity ?? this._lastVelocity,
      toValue: this._toValue,
      iterations: this.__iterations,
      platformConfig: this._platformConfig,
    };
  }

  start(
    fromValue: number,
    onUpdate: (value: number) => void,
    onEnd: ?EndCallback,
    previousAnimation: ?Animation,
    animatedValue: AnimatedValue,
  ): void {
    super.start(fromValue, onUpdate, onEnd, previousAnimation, animatedValue);

    this._startPosition = fromValue;
    this._lastPosition = this._startPosition;

    this._onUpdate = onUpdate;
    this._lastTime = Date.now();
    this._frameTime = 0.0;

    if (previousAnimation instanceof SpringAnimation) {
      const internalState = previousAnimation.getInternalState();
      this._lastPosition = internalState.lastPosition;
      this._lastVelocity = internalState.lastVelocity;
      // Set the initial velocity to the last velocity
      this._initialVelocity = this._lastVelocity;
      this._lastTime = internalState.lastTime;
    }

    const start = () => {
      const useNativeDriver = this.__startAnimationIfNative(animatedValue);
      if (!useNativeDriver) {
        this.onUpdate();
      }
    };

    //  If this._delay is more than 0, we start after the timeout.
    if (this._delay) {
      this._timeout = setTimeout(start, this._delay);
    } else {
      start();
    }
  }

  getInternalState(): SpringAnimationInternalState {
    return {
      lastPosition: this._lastPosition,
      lastVelocity: this._lastVelocity,
      lastTime: this._lastTime,
    };
  }

  /**
   * This spring model is based off of a damped harmonic oscillator
   * (https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator).
   *
   * We use the closed form of the second order differential equation:
   *
   * x'' + (2ζ⍵_0)x' + ⍵^2x = 0
   *
   * where
   *    ⍵_0 = √(k / m) (undamped angular frequency of the oscillator),
   *    ζ = c / 2√mk (damping ratio),
   *    c = damping constant
   *    k = stiffness
   *    m = mass
   *
   * The derivation of the closed form is described in detail here:
   * http://planetmath.org/sites/default/files/texpdf/39745.pdf
   *
   * This algorithm happens to match the algorithm used by CASpringAnimation,
   * a QuartzCore (iOS) API that creates spring animations.
   */
  onUpdate(): void {
    // If for some reason we lost a lot of frames (e.g. process large payload or
    // stopped in the debugger), we only advance by 4 frames worth of
    // computation and will continue on the next frame. It's better to have it
    // running at faster speed than jumping to the end.
    const MAX_STEPS = 64;
    let now = Date.now();
    if (now > this._lastTime + MAX_STEPS) {
      now = this._lastTime + MAX_STEPS;
    }

    const deltaTime = (now - this._lastTime) / 1000;
    this._frameTime += deltaTime;

    const c: number = this._damping;
    const m: number = this._mass;
    const k: number = this._stiffness;
    const v0: number = -this._initialVelocity;

    const zeta = c / (2 * Math.sqrt(k * m)); // damping ratio
    const omega0 = Math.sqrt(k / m); // undamped angular frequency of the oscillator (rad/ms)
    const omega1 = omega0 * Math.sqrt(1.0 - zeta * zeta); // exponential decay
    const x0 = this._toValue - this._startPosition; // calculate the oscillation from x0 = 1 to x = 0

    let position = 0.0;
    let velocity = 0.0;
    const t = this._frameTime;
    if (zeta < 1) {
      // Under damped
      const envelope = Math.exp(-zeta * omega0 * t);
      position =
        this._toValue -
        envelope *
          (((v0 + zeta * omega0 * x0) / omega1) * Math.sin(omega1 * t) +
            x0 * Math.cos(omega1 * t));
      // This looks crazy -- it's actually just the derivative of the
      // oscillation function
      velocity =
        zeta *
          omega0 *
          envelope *
          ((Math.sin(omega1 * t) * (v0 + zeta * omega0 * x0)) / omega1 +
            x0 * Math.cos(omega1 * t)) -
        envelope *
          (Math.cos(omega1 * t) * (v0 + zeta * omega0 * x0) -
            omega1 * x0 * Math.sin(omega1 * t));
    } else {
      // Critically damped
      const envelope = Math.exp(-omega0 * t);
      position = this._toValue - envelope * (x0 + (v0 + omega0 * x0) * t);
      velocity =
        envelope * (v0 * (t * omega0 - 1) + t * x0 * (omega0 * omega0));
    }

    this._lastTime = now;
    this._lastPosition = position;
    this._lastVelocity = velocity;

    this._onUpdate(position);
    if (!this.__active) {
      // a listener might have stopped us in _onUpdate
      return;
    }

    // Conditions for stopping the spring animation
    let isOvershooting = false;
    if (this._overshootClamping && this._stiffness !== 0) {
      if (this._startPosition < this._toValue) {
        isOvershooting = position > this._toValue;
      } else {
        isOvershooting = position < this._toValue;
      }
    }
    const isVelocity = Math.abs(velocity) <= this._restSpeedThreshold;
    let isDisplacement = true;
    if (this._stiffness !== 0) {
      isDisplacement =
        Math.abs(this._toValue - position) <= this._restDisplacementThreshold;
    }

    if (isOvershooting || (isVelocity && isDisplacement)) {
      if (this._stiffness !== 0) {
        // Ensure that we end up with a round value
        this._lastPosition = this._toValue;
        this._lastVelocity = 0;
        this._onUpdate(this._toValue);
      }

      this.__notifyAnimationEnd({finished: true});
      return;
    }
    // $FlowFixMe[method-unbinding] added when improving typing for this parameters
    this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this));
  }

  stop(): void {
    super.stop();
    clearTimeout(this._timeout);
    if (this._animationFrame != null) {
      global.cancelAnimationFrame(this._animationFrame);
    }
    this.__notifyAnimationEnd({finished: false});
  }
}
