// Copyright (c) 2011, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/**
 * Implementations can be used to simulate the deceleration of an element within
 * a certain region. To use this behavior you need to provide an initial
 * velocity that is meant to represent the gesture that is initiating this
 * deceleration. You also provide the bounds of the region that the element
 * exists in, and the current offset of the element within that region. The
 * transitions will have the element decelerate to rest, or stretch past the
 * offset boundaries and then come to rest.
 *
 * This is primarily designed to solve the problem of slow scrolling in mobile
 * safari. You can use this along with the [Scroller] behavior to make a
 * scrollable area scroll the same way it would in a native application.
 *
 * Implementations of this interface do not maintain any references to HTML
 * elements, and therefore cannot do any redrawing of elements. They only
 * calculates where the element should be on an interval. It is the delegate's
 * responsibility to redraw the element when the onDecelerate callback is
 * invoked. It is recommended that you move the element with a hardware
 * accelerated method such as using 'translate3d' on the element's
 * -webkit-transform style property.
 */
interface Momentum factory TimeoutMomentum {

  Momentum(MomentumDelegate delegate, [num defaultDecelerationFactor]);

  bool get decelerating();

  num get decelerationFactor();

 /**
  * Transition end handler. This function must be invoked after any transition
  * that occurred as a result of a call to the delegate's onDecelerate callback.
  */
  void onTransitionEnd();

  /**
   * Start decelerating.
   * The [velocity] passed should be in terms of number of pixels / millisecond.
   * [minCoord] and [maxCoord] specify the content's scrollable boundary.
   * The current offset of the element within its boundaries is specified by
   * [initialOffset].
   * Returns true if deceleration has been initiated.
   */
  bool start(Coordinate velocity, Coordinate minCoord, Coordinate maxCoord,
             Coordinate initialOffset, [num decelerationFactor]);

  /**
   * Calculate the velocity required to transition between coordinates [start]
   * and [target] optionally specifying a custom [decelerationFactor].
   */
  Coordinate calculateVelocity(Coordinate start, Coordinate target,
                               [num decelerationFactor]);

  /** Stop decelerating and return the current velocity. */
  Coordinate stop();

  /** Aborts decelerating without dispatching any notification events. */
  void abort();

  /** null if no transition is in progress. */
  Coordinate get destination();
}

/**
 * Momentum Delegate interface.
 * You are required to implement this interface in order to use the
 * Momentum behavior.
 */
interface MomentumDelegate {
  /**
   * Callback for a deceleration step. The delegate is responsible for redrawing
   * the element in its new position specified in px. The [duration] of the
   * translation is in ms.
   */
  void onDecelerate(num x, num y, [num duration, String timingFunction]);

  /**
   * Callback for end of deceleration.
   */
  void onDecelerationEnd();
}

class BouncingState {
  static final NOT_BOUNCING = 0;
  static final BOUNCING_AWAY = 1;
  static final BOUNCING_BACK = 2;
}

class _Move {
  final num x;
  final num y;
  final num vx;
  final num vy;
  final num time;

  _Move(this.x, this.y, this.vx, this.vy, this.time);
}

/**
 * Secant method root solver helper class.
 * We use http://en.wikipedia.org/wiki/Secant_method
 * falling back to the http://en.wikipedia.org/wiki/Bisection_method
 * if it doesn't appear we are converging properlty.
 * TODO(jacobr): simplify the code so we don't have to use this solver
 * class at all.
 */
class Solver {

  static num solve(num fn(num), num targetY, num startX,
                   [int maxIterations = 50]) {
    num lastX = 0;
    num lastY = fn(lastX);
    num deltaX;
    num deltaY;
    num minX = null;
    num maxX = null;
    num x = startX;
    num delta = startX;
    for (int i = 0; i < maxIterations; i++) {
      num y  = fn(x);
      if (y.round() == targetY.round()) {
        return x;
      }
      if (y > targetY) {
        maxX = x;
      } else {
        minX = x;
      }

      num errorY = targetY - y;
      deltaX = x - lastX;
      deltaY = y - lastY;
      lastX = x;
      lastY = y;
      // Avoid divide by zero and as a hack just repeat the previous delta.
      // Obviously this is a little dangerous and we might not converge.
      if (deltaY != 0) {
         delta = errorY * deltaX / deltaY;
      }
      x += delta;
      if (minX != null && maxX != null
        && (x > minX || x < maxX)) {
        // Fall back to binary search.
        x = (minX + maxX) / 2;
      }
    }
    window.console.warn('''Could not find an exact solution. LastY=${lastY},
        targetY=${targetY} lastX=$lastX delta=$delta  deltaX=$deltaX
        deltaY=$deltaY''');
    return x;
  }
}

/**
 * Helper class modeling the physics of a throwable scrollable area along a
 * single dimension.
 */
class SingleDimensionPhysics {
  /** The number of frames per second the animation should run at. */
  static final _FRAMES_PER_SECOND = 60;

  /**
   * The spring coefficient for when the element has passed a boundary and is
   * decelerating to change direction and bounce back. Each frame, the velocity
   * will be changed by x times this coefficient, where x is the current stretch
   * value of the element from its boundary. This will end when velocity reaches
   * zero.
   */
  static final _PRE_BOUNCE_COEFFICIENT = 7.0 / _FRAMES_PER_SECOND;

  /**
   * The spring coefficient for when the element is bouncing back from a
   * stretched offset to a min or max position. Each frame, the velocity will
   * be changed to x times this coefficient, where x is the current stretch
   * value of the element from its boundary. This will end when the stretch
   * value reaches 0.
   */
  static final _POST_BOUNCE_COEFFICIENT = 7.0 / _FRAMES_PER_SECOND;

  /**
   * The number of milliseconds per animation frame.
   */
  static final _MS_PER_FRAME = 1000.0 / _FRAMES_PER_SECOND;

  /**
   * The constant factor applied to velocity at each frame to simulate
   * deceleration.
   */
  static final _DECELERATION_FACTOR = 0.97;


  static final _MAX_VELOCITY_STATIC_FRICTION = 0.08 * _MS_PER_FRAME;
  static final _DECELERATION_FACTOR_STATIC_FRICTION = 0.92;

  /**
   * Minimum velocity required to start or continue deceleration, in
   * pixels/frame. This is equivalent to 0.25 px/ms.
   */
  static final _MIN_VELOCITY = 0.25 * _MS_PER_FRAME;

  /**
   * Minimum velocity during a step, in pixels/frame. This is equivalent to 0.01
   * px/ms.
   */
  static final _MIN_STEP_VELOCITY = 0.01 * _MS_PER_FRAME;

  /**
   * Boost the initial velocity by a certain factor before applying momentum.
   * This just gives the momentum a better feel.
   */
  static final _INITIAL_VELOCITY_BOOST_FACTOR = 1.25;

  /**
   * Additional deceleration factor to apply for the current move only.  This
   * is helpful for cases such as scroll wheel scrolling where the default
   * amount of deceleration is inadequate.
   */
  num customDecelerationFactor = 1;
  num _minCoord;
  num _maxCoord;

  /** The bouncing state. */
  int _bouncingState;


  num velocity;
  num _currentOffset;

  /**
   * constant used when guessing at the velocity required to throw to a specific
   * location. Chosen arbitrarily. All that really matters is that the velocity
   * is large enough that a throw gesture will occur.
   */
  static final _VELOCITY_GUESS = 20;

  SingleDimensionPhysics() : _bouncingState = BouncingState.NOT_BOUNCING {
  }

  void configure(num minCoord, num maxCoord,
            num initialOffset, num customDecelerationFactor,
            num velocity) {
    _bouncingState = BouncingState.NOT_BOUNCING;
    _minCoord = minCoord;
    _maxCoord = maxCoord;
    _currentOffset = initialOffset;
    this.customDecelerationFactor = customDecelerationFactor;
    _adjustInitialVelocityAndBouncingState(velocity);
  }

  num solve(num initialOffset, num targetOffset,
            num customDecelerationFactor) {
    initialOffset = initialOffset.round();
    targetOffset = targetOffset.round();
    if (initialOffset == targetOffset) {
      return 0;
    }
    return Solver.solve((num velocity) {
        // Don't specify min and max coordinates as we don't need to bother
        // with the simulating bouncing off the edges.
        configure(null, null, initialOffset.round(),
                  customDecelerationFactor, velocity);
        stepAll();
        return _currentOffset;
      },
      targetOffset,
      targetOffset > initialOffset ? _VELOCITY_GUESS : -_VELOCITY_GUESS);
  }

  /**
   * Helper method to calculate initial velocity.
   * The [velocity] passed here should be in terms of number of
   * pixels / millisecond. Returns the adjusted x and y velocities.
   */
  void _adjustInitialVelocityAndBouncingState(num v) {
    velocity = v * _MS_PER_FRAME * _INITIAL_VELOCITY_BOOST_FACTOR;

    if (velocity.abs() < _MIN_VELOCITY) {
      if (_minCoord !== null && _currentOffset < _minCoord) {
        velocity = (_minCoord - _currentOffset) * _POST_BOUNCE_COEFFICIENT;
        velocity = Math.max(velocity, _MIN_STEP_VELOCITY);
        _bouncingState = BouncingState.BOUNCING_BACK;
      } else if (_maxCoord !== null && _currentOffset > _maxCoord) {
        velocity = (_currentOffset - _maxCoord) * _POST_BOUNCE_COEFFICIENT;
        velocity = -Math.max(velocity, _MIN_STEP_VELOCITY);
        _bouncingState = BouncingState.BOUNCING_BACK;
      }
    }
  }

  /**
   * Apply deceleration.
   */
  void _adjustVelocity() {
    num speed = velocity.abs();
    velocity *= _DECELERATION_FACTOR;
    if (customDecelerationFactor != null) {
      velocity *= customDecelerationFactor;
    }
    // This isn't really how static friction works but it is a plausible
    // approximation.
    if (speed < _MAX_VELOCITY_STATIC_FRICTION) {
      velocity *= _DECELERATION_FACTOR_STATIC_FRICTION;
    }

    num stretchDistance;
    if (_minCoord !== null && _currentOffset < _minCoord) {
      stretchDistance = _minCoord - _currentOffset;
    } else {
      if (_maxCoord !== null && _currentOffset > _maxCoord) {
        stretchDistance = _maxCoord - _currentOffset;
      }
    }
    if (stretchDistance != null) {
      if (stretchDistance * velocity < 0) {
        _bouncingState = _bouncingState == BouncingState.BOUNCING_BACK ?
            BouncingState.NOT_BOUNCING : BouncingState.BOUNCING_AWAY;
        velocity += stretchDistance * _PRE_BOUNCE_COEFFICIENT;
      } else {
        _bouncingState = BouncingState.BOUNCING_BACK;
        velocity = stretchDistance > 0 ?
            Math.max(stretchDistance * _POST_BOUNCE_COEFFICIENT,
                     _MIN_STEP_VELOCITY) :
            Math.min(stretchDistance * _POST_BOUNCE_COEFFICIENT,
                     -_MIN_STEP_VELOCITY);
      }
    } else {
      _bouncingState = BouncingState.NOT_BOUNCING;
    }
  }

  void step() {
    // It is common for scrolling to be disabled so in these cases we want to
    // avoid needless calculations.
    if (velocity !== null) {
      _currentOffset += velocity;
      _adjustVelocity();
    }
  }

  void stepAll() {
    while(!isDone()) {
      step();
    }
  }

  /**
   * Whether or not the current velocity is above the threshold required to
   * continue decelerating.
   */
  bool isVelocityAboveThreshold(num threshold) {
    return velocity.abs() >= threshold;
  }

  bool isDone() {
    return _bouncingState == BouncingState.NOT_BOUNCING &&
        !isVelocityAboveThreshold(_MIN_STEP_VELOCITY);
  }
}

/**
 * Implementation of a momentum strategy using webkit-transforms
 * and timeouts.
 */
class TimeoutMomentum implements Momentum {

  SingleDimensionPhysics physicsX;
  SingleDimensionPhysics physicsY;
  Coordinate _previousOffset;
  Queue<_Move> _moves;
  num _stepTimeout;
  bool _decelerating;
  MomentumDelegate _delegate;
  int _nextY;
  int _nextX;
  Coordinate _minCoord;
  Coordinate _maxCoord;
  num _customDecelerationFactor;
  num _defaultDecelerationFactor;

  TimeoutMomentum(this._delegate, [this._defaultDecelerationFactor = 1])
    : _decelerating = false,
      _moves = new Queue<_Move>(),
      physicsX = new SingleDimensionPhysics(),
      physicsY = new SingleDimensionPhysics();

  /**
   * Calculate and return the moves for the deceleration motion.
   */
  void _calculateMoves() {
    _moves.clear();
    num time = TimeUtil.now();
    while (!physicsX.isDone() || !physicsY.isDone()) {
      _stepWithoutAnimation();
      time += SingleDimensionPhysics._MS_PER_FRAME;
      if (_isStepNecessary()) {
        _moves.add(new _Move(_nextX, _nextY,
                            physicsX.velocity,
                            physicsY.velocity, time));
        _previousOffset.y = _nextY;
        _previousOffset.x = _nextX;
      }
    }
  }

  bool get decelerating() => _decelerating;
  num get decelerationFactor() => _customDecelerationFactor;

  /**
   * Checks whether or not an animation step is necessary or not. Animations
   * steps are not necessary when the velocity gets so low that in several
   * frames the offset is the same.
   * Returns true if there is movement to be done in the next frame.
   */
  bool _isStepNecessary() {
    return _nextY != _previousOffset.y || _nextX != _previousOffset.x;
  }

  /**
   * The [TouchHandler] requires this function but we don't need to do
   * anything here.
   */
  void onTransitionEnd() {
  }

  Coordinate calculateVelocity(Coordinate start, Coordinate target,
                               [num decelerationFactor = null]) {
    return new Coordinate(
        physicsX.solve(start.x, target.x, decelerationFactor),
        physicsY.solve(start.y, target.y, decelerationFactor));
  }

  bool start(Coordinate velocity, Coordinate minCoord, Coordinate maxCoord,
             Coordinate initialOffset, [num decelerationFactor = null]) {
    _customDecelerationFactor = _defaultDecelerationFactor;
    if (decelerationFactor !== null) {
      _customDecelerationFactor = decelerationFactor;
    }

    if (_stepTimeout !== null) {
      Env.cancelRequestAnimationFrame(_stepTimeout);
      _stepTimeout = null;
    }

    assert (_stepTimeout === null);
    assert(minCoord.x <= maxCoord.x);
    assert(minCoord.y <= maxCoord.y);
    _previousOffset = initialOffset.clone();
    physicsX.configure(minCoord.x, maxCoord.x, initialOffset.x,
                       _customDecelerationFactor, velocity.x);
    physicsY.configure(minCoord.y, maxCoord.y, initialOffset.y,
                       _customDecelerationFactor, velocity.y);
    if (!physicsX.isDone() || !physicsY.isDone()) {
      _calculateMoves();
      if (!_moves.isEmpty()) {
        num firstTime = _moves.first().time;
        _stepTimeout = Env.requestAnimationFrame(
            _step, null, firstTime);
        _decelerating = true;
        return true;
      }
    }
    _decelerating = false;
    return false;
  }

  /**
   * Update the x, y values of the element offset without actually moving the
   * element. This is done because we store decimal values for x, y for
   * precision, but moving is only required when the offset is changed by at
   * least a whole integer.
   */
  void _stepWithoutAnimation() {
    physicsX.step();
    physicsY.step();
    // TODO(jacobr): double.round() should return an int, see b/5121907
    _nextX = physicsX._currentOffset.round().toInt();
    _nextY = physicsY._currentOffset.round().toInt();
  }

  /**
   * Calculate the next offset of the element and animate it to that position.
   */
  void _step(num timestamp) {
    _stepTimeout = null;

    // Prune moves that are more than 1 frame behind when we have more
    // available moves.
    num lastEpoch = timestamp - SingleDimensionPhysics._MS_PER_FRAME;
    while (!_moves.isEmpty() && _moves.first() !== _moves.last()
        && _moves.first().time < lastEpoch) {
      _moves.removeFirst();
    }

    if (!_moves.isEmpty()) {
      final move = _moves.removeFirst();
      _delegate.onDecelerate(move.x, move.y);
      if (!_moves.isEmpty()) {
        num nextTime = _moves.first().time;
        assert(_stepTimeout === null);
        _stepTimeout = Env.requestAnimationFrame(_step, null, nextTime);
      } else {
        stop();
      }
    }
  }

  void abort() {
    _decelerating = false;
    _moves.clear();
    if (_stepTimeout !== null) {
      Env.cancelRequestAnimationFrame(_stepTimeout);
      _stepTimeout = null;
    }
  }

  Coordinate stop() {
    final wasDecelerating = _decelerating;
    _decelerating = false;
    Coordinate velocity;
    if (!_moves.isEmpty()) {
      final move = _moves.first();
      // This is a workaround for the ugly hacks that get applied when a user
      // passed a velocity in to this Momentum implementation.
      num velocityScale = SingleDimensionPhysics._MS_PER_FRAME *
          SingleDimensionPhysics._INITIAL_VELOCITY_BOOST_FACTOR;
      velocity = new Coordinate(
          move.vx / velocityScale, move.vy / velocityScale);
    } else {
      velocity = new Coordinate(0, 0);
    }
    _moves.clear();
    if (_stepTimeout !== null) {
      Env.cancelRequestAnimationFrame(_stepTimeout);
      _stepTimeout = null;
    }
    if (wasDecelerating) {
      _delegate.onDecelerationEnd();
    }
    return velocity;
  }

  Coordinate get destination() {
    if (!_moves.isEmpty()) {
      final lastMove = _moves.last();
      return new Coordinate(lastMove.x, lastMove.y);
    } else {
      return null;
    }
  }
}
