'''
Kinetic effect
==============

.. versionadded:: 1.7.0

The :class:`KineticEffect` is the base class that is used to compute the
velocity out of a movement. When the movement is finished, the effect will
compute the position of the movement according to the velocity, and reduce the
velocity with a friction. The movement stop until the velocity is 0.

Conceptually, the usage could be::

    >>> effect = KineticEffect()
    >>> effect.start(10)
    >>> effect.update(15)
    >>> effect.update(30)
    >>> effect.stop(48)

Over the time, you will start a movement of a value, update it, and stop the
movement. At this time, you'll get the movement value into
:attr:`KineticEffect.value`. On the example i've typed manually, the computed
velocity will be::

    >>> effect.velocity
    3.1619100231163046

After multiple clock interaction, the velocity will decrease according to
:attr:`KineticEffect.friction`. The computed value will be stored in
:attr:`KineticEffect.value`. The output of this `value` could be::

    46.30038145219605
    54.58302451968686
    61.9229016256196
    # ...

'''

__all__ = ('KineticEffect', )


from time import time
from collections import deque
from kivy.event import EventDispatcher
from kivy.properties import NumericProperty, BooleanProperty
from kivy.clock import Clock


class KineticEffect(EventDispatcher):
    '''Kinetic effect class. See module documentation for more information.
    '''

    velocity = NumericProperty(0)
    '''Velocity of the movement.

    :attr:`velocity` is a :class:`~kivy.properties.NumericProperty` and
    defaults to 0.
    '''

    friction = NumericProperty(0.05)
    '''Friction to apply on the velocity

    :attr:`friction` is a :class:`~kivy.properties.NumericProperty` and
    defaults to 0.05.
    '''

    value = NumericProperty(0)
    '''Value (during the movement and computed) of the effect.

    :attr:`value` is a :class:`~kivy.properties.NumericProperty` and
    defaults to 0.
    '''

    is_manual = BooleanProperty(False)
    '''Indicate if a movement is in progress (True) or not (False).

    :attr:`is_manual` is a :class:`~kivy.properties.BooleanProperty` and
    defaults to False.
    '''

    max_history = NumericProperty(5)
    '''Save up to `max_history` movement value into the history. This is used
    for correctly calculating the velocity according to the movement.

    :attr:`max_history` is a :class:`~kivy.properties.NumericProperty` and
    defaults to 5.
    '''

    min_distance = NumericProperty(.1)
    '''The minimal distance for a movement to have nonzero velocity.

    .. versionadded:: 1.8.0

    :attr:`min_distance` is :class:`~kivy.properties.NumericProperty` and
    defaults to 0.1.
    '''

    min_velocity = NumericProperty(.5)
    '''Velocity below this quantity is normalized to 0. In other words,
    any motion whose velocity falls below this number is stopped.

    .. versionadded:: 1.8.0

    :attr:`min_velocity` is a :class:`~kivy.properties.NumericProperty` and
    defaults to 0.5.
    '''

    std_dt = NumericProperty(0.017)
    ''' std_dt
        correction update_velocity if dt is not constant

    .. versionadded:: 2.0.0

    :attr:`std_dt` is a :class:`~kivy.properties.NumericProperty` and
    defaults to 0.017.
    '''

    def __init__(self, **kwargs):
        self.history = deque(maxlen=kwargs.get("max_history", self.max_history))
        self.trigger_velocity_update = Clock.create_trigger(
            self.update_velocity, 0)
        super(KineticEffect, self).__init__(**kwargs)
        self.bind(max_history=self._resize_history)

    def _resize_history(self, *args):
        self.history = deque(self.history, maxlen=self.max_history)

    def apply_distance(self, distance):
        if abs(distance) < self.min_distance:
            self.velocity = 0
        self.value += distance

    def start(self, val, t=None):
        '''Start the movement.

        :Parameters:
            `val`: float or int
                Value of the movement
            `t`: float, defaults to None
                Time when the movement happen. If no time is set, it will use
                time.time()
        '''
        self.is_manual = True
        t = t or time()
        self.velocity = 0
        self.history.clear()
        self.history.append((t, val))

    def update(self, val, t=None):
        '''Update the movement.

        See :meth:`start` for the arguments.
        '''
        t = t or time()
        distance = val - self.history[-1][1]
        self.apply_distance(distance)
        self.history.append((t, val))

    def stop(self, val, t=None):
        '''Stop the movement.

        See :meth:`start` for the arguments.
        '''
        self.is_manual = False
        t = t or time()
        distance = val - self.history[-1][1]
        self.apply_distance(distance)
        newest_sample = (t, val)
        old_sample = self.history[0]
        for sample in self.history:
            if (newest_sample[0] - sample[0]) < 10. / 60.:
                break
            old_sample = sample
        distance = newest_sample[1] - old_sample[1]
        # Why abs()? Needs a comment.
        duration = abs(newest_sample[0] - old_sample[0])
        self.velocity = (distance / max(duration, 0.0001))
        self.trigger_velocity_update()

    def cancel(self):
        '''Cancel a movement. This can be used in case :meth:`stop` cannot be
        called. It will reset :attr:`is_manual` to False, and compute the
        movement if the velocity is > 0.
        '''
        self.is_manual = False
        self.trigger_velocity_update()

    def update_velocity(self, dt):
        '''(internal) Update the velocity according to the frametime and
        friction.
        '''
        if abs(self.velocity) <= self.min_velocity:
            self.velocity = 0
            return

        self.velocity -= self.velocity * self.friction * dt / self.std_dt
        self.apply_distance(self.velocity * dt)
        self.trigger_velocity_update()

    def halt(self):
        '''Stop the movement immediately without momentum.
        This method immediately stops any ongoing kinetic scrolling effect by:
        - Cancelling any scheduled velocity updates to prevent further movement
        - Setting is_manual to False to indicate no manual interaction is in progress

        Unlike the stop() method which calculates final velocity for momentum
        scrolling, this method provides instant cessation of movement without
        any residual animation.

        This is useful when you need to programmatically stop scrolling in
        response to user actions or when the scroll position needs to be fixed
        immediately.  The halt() method is called by ScrollView.scroll_to to
        stop scrolling animation prior to moving to the target widget.
        '''
        self.is_manual = False
        self.velocity = 0
        self.trigger_velocity_update.cancel()
