/*
 * Copyright 2017-2024 MicroEJ Corp. All rights reserved.
 * This library is provided in source code for use, modification and test, subject to license terms.
 * Any modification of the source code will break MicroEJ Corp. warranties on the whole library.
 */
package ej.widget.swipe;

import ej.annotation.Nullable;
import ej.bon.Util;
import ej.bon.XMath;
import ej.microui.MicroUI;
import ej.motion.Function;
import ej.motion.Motion;
import ej.motion.quad.QuadEaseOutFunction;
import ej.mwt.Widget;
import ej.mwt.animation.Animation;
import ej.mwt.animation.Animator;
import ej.widget.event.PointerEventHandler;
import ej.widget.motion.MotionAnimation;
import ej.widget.motion.MotionAnimationListener;

/**
 * The swipe event handler responsibility is to detect pointer events (press, drag, release) and help moving over some
 * element(s).
 * <p>
 * When the pointer is pressed and dragged, the content follows the pointer.
 * <p>
 * On pointer release, the moved content stops progressively (depending on the speed of the movement).
 * <p>
 * The content may be cyclic, that means that when a bound is reached (beginning or end), the other bound is displayed.
 *
 * @since 2.3.0
 */
public class SwipeEventHandler extends PointerEventHandler {

	/**
	 * Minimal delay between the last drag event and the release event to consider that the user stops moving before
	 * releasing: not a swipe.
	 */
	private static final int RELEASE_WITH_NO_MOVE_DELAY = 200;
	private static final float HALF = 0.5f;
	/**
	 * Default animation duration.
	 */
	public static final int DEFAULT_DURATION = 800;

	private final int size;
	private final boolean cyclic;
	private final boolean horizontal;
	private final Swipeable swipeable;
	private final int itemInterval;
	@Nullable
	private final int[] itemsSize;
	private final Animator animator;

	@Nullable
	private Animation draggedAnimation;
	@Nullable
	private MotionAnimation releasedAnimation;

	@Nullable
	private SwipeListener swipeListener;

	private long duration;
	private Function motionFunction;

	private boolean pressed;
	private int initialValue;
	private int pressX;
	private int pressY;
	private int pressCoordinate;
	private long pressTime;
	private int previousCoordinate;
	private long lastTime;
	private int totalShift;
	private boolean forward;
	private int currentValue;
	private int draggedValue;
	private boolean dragged;

	private boolean swipeStarted;

	/**
	 * Creates a swipe event handler on an element.
	 *
	 * @param widget
	 *            the attached widget.
	 * @param size
	 *            the available size.
	 * @param cyclic
	 *            <code>true</code> if the element to swipe is cyclic, <code>false</code> otherwise.
	 * @param horizontal
	 *            <code>true</code> if the element to swipe is horizontal, <code>false</code> otherwise.
	 * @param swipeable
	 *            the swipeable.
	 * @param animator
	 *            the animator to use to execute the release animation.
	 * @throws IllegalArgumentException
	 *             if the given size is lesser than or equal to 0.
	 */
	public SwipeEventHandler(Widget widget, int size, boolean cyclic, boolean horizontal, Swipeable swipeable,
			Animator animator) {
		this(widget, checkSize(size), cyclic, 0, null, horizontal, swipeable, animator);
	}

	/**
	 * Creates a swipe event handler on a collection.
	 * <p>
	 * Equivalent to {@link #SwipeEventHandler(Widget, int, int, boolean, boolean, boolean, Swipeable, Animator)}
	 * without snapping to the collection items.
	 *
	 * @param widget
	 *            the attached widget.
	 * @param itemCount
	 *            the number of items.
	 * @param itemInterval
	 *            the interval between items center.
	 * @param cyclic
	 *            <code>true</code> if the collection to swipe is cyclic, <code>false</code> otherwise.
	 * @param horizontal
	 *            <code>true</code> if the collection to swipe is horizontal, <code>false</code> otherwise.
	 * @param swipeable
	 *            the swipeable.
	 * @param animator
	 *            the animator to use to execute the release animation.
	 * @throws NullPointerException
	 *             if the given swipeable is <code>null</code>.
	 * @throws IllegalArgumentException
	 *             if the given item count or interval is lesser than or equal to 0.
	 */
	public SwipeEventHandler(Widget widget, int itemCount, int itemInterval, boolean cyclic, boolean horizontal,
			Swipeable swipeable, Animator animator) {
		this(widget, itemCount, itemInterval, cyclic, false, horizontal, swipeable, animator);
	}

	/**
	 * Creates a swipe event handler on a collection.
	 * <p>
	 * If the snap parameter is set, when the pointer is released, the swipeable is snapped to the closest snapping
	 * point (i.e. the closest multiple of interval). See {@link #moveTo(int, long)} for more information.
	 *
	 * @param widget
	 *            the attached widget.
	 * @param itemCount
	 *            the number of items.
	 * @param itemInterval
	 *            the interval between items center.
	 * @param cyclic
	 *            <code>true</code> if the collection to swipe is cyclic, <code>false</code> otherwise.
	 * @param snapToItem
	 *            <code>true</code> if the items are snapped, <code>false</code> otherwise.
	 * @param horizontal
	 *            <code>true</code> if the collection to swipe is horizontal, <code>false</code> otherwise.
	 * @param swipeable
	 *            the swipeable.
	 * @param animator
	 *            the animator to use to execute the release animation.
	 * @throws NullPointerException
	 *             if the given swipeable is <code>null</code>.
	 * @throws IllegalArgumentException
	 *             if the given item count or interval is lesser than or equal to 0.
	 */
	public SwipeEventHandler(Widget widget, int itemCount, int itemInterval, boolean cyclic, boolean snapToItem,
			boolean horizontal, Swipeable swipeable, Animator animator) {
		this(widget, computeSize(itemCount, itemInterval, cyclic), cyclic, snapToItem ? itemInterval : 0, null,
				horizontal, swipeable, animator);
	}

	/**
	 * Creates a swipe event handler on a collection with heterogeneous sizes.
	 * <p>
	 * If the snap parameter is set, when the pointer is released, the swipeable is snapped to the closest snapping
	 * point (i.e. the closest item offset). See {@link #moveTo(int, long)} for more information.
	 *
	 * @param widget
	 *            the attached widget.
	 * @param itemsSize
	 *            the size of the items.
	 * @param cyclic
	 *            <code>true</code> if the collection to swipe is cyclic, <code>false</code> otherwise.
	 * @param snapToItem
	 *            <code>true</code> if the items are snapped, <code>false</code> otherwise.
	 * @param horizontal
	 *            <code>true</code> if the collection to swipe is horizontal, <code>false</code> otherwise.
	 * @param swipeable
	 *            the swipeable.
	 * @param animator
	 *            the animator to use to execute the release animation.
	 * @throws NullPointerException
	 *             if the given swipeable is <code>null</code>.
	 * @throws IllegalArgumentException
	 *             if there is no item size or if one of the given item size is lesser than or equal to 0.
	 */
	public SwipeEventHandler(Widget widget, int[] itemsSize, boolean cyclic, boolean snapToItem, boolean horizontal,
			Swipeable swipeable, Animator animator) {
		this(widget, computeSize(itemsSize, cyclic), cyclic, snapToItem ? 1 : 0, itemsSize, horizontal, swipeable,
				animator);
	}

	private SwipeEventHandler(Widget widget, int size, boolean cyclic, int itemInterval, @Nullable int[] itemsSize,
			boolean horizontal, Swipeable swipeable, Animator animator) {
		super(widget);
		assert swipeable != null;
		this.size = size;
		this.cyclic = cyclic;
		this.itemInterval = itemInterval;
		if (itemsSize != null) {
			this.itemsSize = itemsSize.clone();
		} else {
			this.itemsSize = null;
		}
		this.horizontal = horizontal;
		this.swipeable = swipeable;
		this.animator = animator;
		this.duration = DEFAULT_DURATION;
		this.motionFunction = QuadEaseOutFunction.INSTANCE;
	}

	/**
	 * Checks whether the given size is strictly positive or not.
	 * 
	 * @param size
	 *            the size to check
	 * @throws IllegalArgumentException
	 *             if the given size is lower or equal to zero
	 * @return the given size
	 */
	private static int checkSize(int size) {
		if (size <= 0) {
			throw new IllegalArgumentException();
		}
		return size;
	}

	/**
	 * Computes the size necessary to swipe a collection of items.
	 *
	 * @param itemCount
	 *            the number of items.
	 * @param itemInterval
	 *            the interval between items center.
	 * @param cyclic
	 *            <code>true</code> if the element to swipe is cyclic, <code>false</code> otherwise.
	 * @return the size to swipe.
	 */
	private static int computeSize(int itemCount, int itemInterval, boolean cyclic) {
		return (checkSize(itemCount) - (cyclic ? 0 : 1)) * checkSize(itemInterval);
	}

	/**
	 * Computes the size necessary to swipe a collection of heterogeneous items.
	 *
	 * @param itemsSize
	 *            the size of the items.
	 * @param cyclic
	 *            <code>true</code> if the element to swipe is cyclic, <code>false</code> otherwise.
	 * @return the size to swipe.
	 */
	private static int computeSize(int[] itemsSize, boolean cyclic) {
		checkSize(itemsSize.length);
		int totalSize = 0;
		for (int size : itemsSize) {
			totalSize += checkSize(size);
		}
		if (!cyclic) {
			totalSize -= itemsSize[itemsSize.length - 1];
		}
		return totalSize;
	}

	/**
	 * Gets the current position of this swipe event handler.
	 *
	 * @return the current position of this swipe event handler.
	 */
	public int getCurrentPosition() {
		return this.currentValue;
	}

	/**
	 * Sets the duration.
	 *
	 * @param duration
	 *            the duration to set.
	 */
	public void setDuration(long duration) {
		this.duration = duration;
	}

	/**
	 * Sets the motion function.
	 *
	 * @param motionFunction
	 *            the motion function to set.
	 */
	public void setMotionFunction(Function motionFunction) {
		this.motionFunction = motionFunction;
	}

	/**
	 * Sets the swipe listener.
	 *
	 * @param swipeListener
	 *            the swipe listener to set or <code>null</code>.
	 */
	public void setSwipeListener(@Nullable SwipeListener swipeListener) {
		this.swipeListener = swipeListener;
	}

	/**
	 * Forces the end of the animation.
	 */
	public void stop() {
		stopAnimations();
		notifyStopSwipe();
	}

	private int getCoordinate(int x, int y) {
		if (this.horizontal) {
			return x;
		} else {
			return y;
		}
	}

	private void updateCurrentStep(int currentStep) {
		if (this.cyclic) {
			currentStep = modulo(currentStep, this.size);
		}
		if (this.currentValue != currentStep) {
			this.currentValue = currentStep;
			this.swipeable.onMove(currentStep);
		}
	}

	@Override
	protected boolean onPressed(int pointerX, int pointerY) {
		stop();

		this.pressed = true;
		this.pressTime = Util.platformTimeMillis();
		this.pressX = pointerX;
		this.pressY = pointerY;
		int pressCoordinate = getCoordinate(pointerX, pointerY);
		this.pressCoordinate = pressCoordinate;
		this.previousCoordinate = pressCoordinate;
		this.initialValue = this.currentValue;
		this.totalShift = 0;
		this.dragged = false;

		return false;
	}

	@Override
	protected boolean onDragged(int pointerX, int pointerY) {
		int pointerCoordinate = getCoordinate(pointerX, pointerY);
		if (!this.pressed) {
			return onPressed(pointerX, pointerY);
		} else {
			boolean dragged;
			if (!this.dragged) {
				dragged = initializeDragging(pointerX, pointerY, pointerCoordinate);
			} else {
				dragged = true;
				updateDragging(pointerCoordinate);
			}

			this.dragged |= dragged;
			return dragged;
		}
	}

	private boolean initializeDragging(final int pointerX, final int pointerY, final int pointerCoordinate) {
		boolean dragged;
		// The first time, we need to check that the move is in the right direction.
		// Otherwise, a scroll included in another one (with a different direction) can prevent the other
		// one to work.
		int shiftX = Math.abs(pointerX - this.pressX);
		int shiftY = Math.abs(pointerY - this.pressY);
		dragged = this.horizontal ? (shiftX > shiftY) : (shiftY > shiftX);

		if (dragged) {
			startDragging(pointerCoordinate);
		} else {
			// If not dragged, reset press position to allow the user to change dragged direction and the swipe
			// to follow this change.
			if (this.horizontal) {
				this.pressY = pointerY;
			} else {
				this.pressX = pointerX;
			}
		}
		return dragged;
	}

	private void startDragging(final int pointerCoordinate) {
		notifyStartSwipe();
		// Update the dragging info before starting the animation that uses them.
		updateDragging(pointerCoordinate);
		Animation draggedAnimation = new Animation() {
			@Override
			public boolean tick(long platformTimeMillis) {
				int target = SwipeEventHandler.this.draggedValue;
				int current = SwipeEventHandler.this.currentValue;
				if (target != current) {
					int next = Math.abs(target - current) <= 1 ? target : (target + current) / 2;
					updateCurrentStep(next);
					return SwipeEventHandler.this.pressed;
				} else {
					pauseDragging();
					return false;
				}
			}
		};
		this.draggedAnimation = draggedAnimation;
		this.animator.startAnimation(draggedAnimation);
	}

	private void pauseDragging() {
		// Stop the animation when the dragging is paused.
		this.dragged = false;
		this.draggedAnimation = null;
		int previousCoordinate = this.previousCoordinate;
		if (this.horizontal) {
			this.pressX = previousCoordinate;
		} else {
			this.pressY = previousCoordinate;
		}
		this.pressCoordinate = previousCoordinate;
		this.initialValue = this.currentValue;
		this.totalShift = 0;
	}

	private void updateDragging(final int pointerCoordinate) {
		this.lastTime = Util.platformTimeMillis();
		int shift = pointerCoordinate - this.previousCoordinate;

		this.totalShift += shift;
		this.draggedValue = this.initialValue - this.totalShift;
		this.previousCoordinate = pointerCoordinate;

		boolean currentForward = shift > 0;
		if (this.totalShift != 0 && currentForward != this.forward) {
			// Change way.
			this.pressCoordinate = pointerCoordinate;
			this.pressTime = this.lastTime;
			this.forward = currentForward;
		}
	}

	@Override
	protected boolean onReleased(int pointerX, int pointerY) {
		int currentValue = this.currentValue;
		long duration = this.duration;
		int pointerCoordinate = getCoordinate(pointerX, pointerY);
		int stop;
		long delay;
		if (this.dragged) {
			long currentTime = Util.platformTimeMillis();

			int limitedStep = limit(currentValue);
			if (!this.cyclic && limitedStep != currentValue) {
				// Limits exceeded.
				stop = limitedStep;
				delay = duration / 2;
			} else if (currentTime - this.lastTime < RELEASE_WITH_NO_MOVE_DELAY) {
				// Launch it!
				float speed = -(float) (pointerCoordinate - this.pressCoordinate)
						/ (2 * (currentTime - this.pressTime));
				stop = (int) (currentValue + speed * duration);
				delay = duration;
			} else {
				// Avoid moving if the user pauses after dragging.
				stop = currentValue;
				delay = duration / 2;
			}
		} else {
			// The user has potentially interrupted an animation. The onStop() has not been triggered.
			stop = currentValue;
			delay = duration / 2;
		}
		if (!this.cyclic) {
			// Bound value.
			stop = limit(stop);
		}
		this.pressed = false;
		moveTo(stop, delay);
		return this.dragged;
	}

	@Override
	protected void onExited() {
		if (!this.dragged && (this.currentValue < 0 || this.currentValue > this.size)) {
			// Ensures that the swipeable is not stuck outside the limits.
			// (Use case: swipe outside the limit, then release and re-press quickly to stop the animation, then drag
			// outside the widget or captured by another widget.)
			moveTo(limit(this.currentValue), this.duration / 2);
		}
	}

	/**
	 * Limits a position between 0 and the content size.
	 *
	 * @param position
	 *            the position to limit.
	 * @return the limited position.
	 */
	public int limit(int position) {
		return XMath.limit(position, 0, this.size);
	}

	/**
	 * Moves to a position.
	 * <p>
	 * If the snap parameter is set, the given position is snapped to the closest multiple of the interval size between
	 * elements.
	 * <p>
	 * Should be called in the UI thread to avoid concurrency issues.
	 *
	 * @param position
	 *            the position to set.
	 * @see MicroUI#isUIThread()
	 */
	public void moveTo(int position) {
		stopAnimations();
		updateCurrentStep(snap(position));
	}

	/**
	 * Snaps a position to an item if the snap parameter has been set.
	 *
	 * @param position
	 *            the position to snap.
	 * @return the snapped position.
	 */
	private int snap(int position) {
		int itemInterval = this.itemInterval;
		int[] itemsSize = this.itemsSize;
		if (itemsSize != null && itemInterval != 0) {
			// Find the nearest item.
			int currentSize = position / this.size * this.size;
			for (int itemSize : itemsSize) {
				int nextSize = itemSize + currentSize;
				if (position < nextSize) {
					if (position - currentSize < nextSize - position) {
						// Nearer to the current position.
						return currentSize;
					} else {
						// Nearer to the next position.
						return nextSize;
					}
				}
				currentSize = nextSize;
			}
		} else if (itemInterval != 0) {
			// Snap the given position.
			return (int) ((float) position / itemInterval + HALF) * itemInterval;
		}
		// Not snapped.
		return position;
	}

	/**
	 * Animates the move to a position.
	 * <p>
	 * If the snap parameter is set, the given position is snapped to the closest multiple of the interval size between
	 * elements.
	 * <p>
	 * Should be called in the UI thread to avoid concurrency issues.
	 *
	 * @param stop
	 *            the expected end position.
	 * @param duration
	 *            the duration of the animation.
	 * @see MicroUI#isUIThread()
	 */
	public void moveTo(int stop, long duration) {
		stopAnimations();
		stop = snap(stop);

		if (stop == this.currentValue) {
			// Nothing to animate…
			notifyStopSwipe();
			return;
		}

		Motion moveMotion = new Motion(this.motionFunction, this.currentValue, stop, duration);
		MotionAnimationListener motionListener = new MotionAnimationListener() {
			@Override
			public void tick(int value, boolean finished) {
				updateCurrentStep(value);
				if (finished) {
					notifyStopSwipe();
					SwipeEventHandler.this.releasedAnimation = null;
				}
			}
		};
		notifyStartSwipe();
		this.releasedAnimation = new MotionAnimation(this.animator, moveMotion, motionListener);
		this.releasedAnimation.start();
	}

	/**
	 * Moves to the beginning.
	 * 
	 * @see #moveTo(int)
	 */
	public void moveToBeginning() {
		moveTo(0);
	}

	/**
	 * Moves to the beginning.
	 *
	 * @see #moveTo(int,long)
	 */
	public void moveToBeginning(long duration) {
		moveTo(0, duration);
	}

	/**
	 * Moves to the end.
	 *
	 * @see #moveTo(int)
	 */
	public void moveToEnd() {
		moveTo(this.size);
	}

	/**
	 * Moves to the end.
	 *
	 * @see #moveTo(int,long)
	 */
	public void moveToEnd(long duration) {
		moveTo(this.size, duration);
	}

	private void stopAnimations() {
		Animation draggedAnimation = this.draggedAnimation;
		if (draggedAnimation != null) {
			this.animator.stopAnimation(draggedAnimation);
			this.draggedAnimation = null;
		}
		MotionAnimation releasedAnimation = this.releasedAnimation;
		if (releasedAnimation != null) {
			releasedAnimation.stop();
			this.releasedAnimation = null;
		}
	}

	private int modulo(int index, int length) {
		index = index % length;
		if (index < 0) {
			index += length;
		}
		return index;
	}

	private void notifyStartSwipe() {
		if (!this.swipeStarted) {
			this.swipeStarted = true;

			SwipeListener swipeListener = this.swipeListener;
			if (swipeListener != null) {
				swipeListener.onSwipeStarted();
			}
		}
	}

	private void notifyStopSwipe() {
		if (this.swipeStarted) {
			SwipeListener swipeListener = this.swipeListener;
			if (swipeListener != null) {
				swipeListener.onSwipeStopped();
			}

			this.swipeStarted = false;
		}
	}
}
