/*
 * Copyright 2016-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.mwt.animation;

import ej.annotation.Nullable;
import ej.basictool.ArrayTools;
import ej.basictool.ThreadUtils;
import ej.bon.Constants;
import ej.bon.Util;
import ej.microui.MicroUI;
import ej.microui.display.Display;

/**
 * An animator executes animations as fast as possible.
 * <p>
 * The animator loops on:
 * <ul>
 * <li>retrieve the current time with {@link ej.bon.Util#platformTimeMillis()},</li>
 * <li>call the {@link Animation#tick(long)} of all the registered animations,</li>
 * <li>wait for the end of the flush,</li>
 * <li>start again while at least one animation is running.</li>
 * </ul>
 * <p>
 * Only one animator can be running at any time.
 * <p>
 * In the same way, {@link Display#callOnFlushCompleted(Runnable)} must not be called when an animator is running
 */
public class Animator {

	/**
	 * {@link Constants#getBoolean(String) BON boolean constant} to enable/disable the animator debug.
	 * <p>
	 * If enabled, {@value #DEBUG_ANIMATOR_MONITOR_CONSTANT} must also be set.
	 */
	public static final String DEBUG_ANIMATOR_ENABLED_CONSTANT = "ej.mwt.debug.animator.enabled";

	/**
	 * {@link Constants#getClass(String) BON Class constant} to set the animator monitor.
	 * <p>
	 * Set it to the FQN of an implementation of {@link AnimatorListener} to monitor the animator.
	 */
	public static final String DEBUG_ANIMATOR_MONITOR_CONSTANT = "ej.mwt.debug.animator.monitor";

	private static final @Nullable AnimatorListener ANIMATOR_LISTENER;

	private final Object monitor;
	private Animation[] animations;
	private boolean started;
	private Runnable tickingRunnable;
	private boolean ticking;
	private boolean renderRequested;

	static {
		if (Constants.getBoolean(DEBUG_ANIMATOR_ENABLED_CONSTANT)) {
			Class<?> animatorListenerClass = Constants.getClass(DEBUG_ANIMATOR_MONITOR_CONSTANT);
			if (!AnimatorListener.class.isAssignableFrom(animatorListenerClass)) {
				throw new IllegalArgumentException(
						"Class set to " + DEBUG_ANIMATOR_MONITOR_CONSTANT + " ( " + animatorListenerClass.getName()
								+ " ) does not implement " + AnimatorListener.class.getSimpleName());
			}

			Object animatorListener;
			try {
				animatorListener = animatorListenerClass.newInstance();
			} catch (InstantiationException | IllegalAccessException e) {
				throw new IllegalArgumentException(
						"Class set to " + DEBUG_ANIMATOR_MONITOR_CONSTANT + " cannot be instantiated", e);
			}

			assert (animatorListener instanceof AnimatorListener);
			ANIMATOR_LISTENER = (AnimatorListener) animatorListener;
		} else {
			ANIMATOR_LISTENER = null;
		}
	}

	/**
	 * Creates an animator.
	 * <p>
	 * Multiple animators may be created but only one can be running at any time.
	 */
	public Animator() {
		this.monitor = new Object();
		this.animations = new Animation[0];
		this.started = false;
		this.ticking = false;
	}

	/**
	 * Starts an animation.
	 * <p>
	 * The animation tick method is then called every period.
	 * <p>
	 * If the animation is already started, nothing is done.
	 *
	 * @param animation
	 *            the animation to start.
	 * @see Animation#tick(long)
	 */
	public void startAnimation(Animation animation) {
		synchronized (this.monitor) {
			Animation[] animations = this.animations;
			if (!ArrayTools.contains(animations, animation)) {
				this.animations = ArrayTools.add(animations, animation);

				if (!this.started) {
					this.started = true;
					this.tickingRunnable = createTickRunnable();
					callOnFlushCompleted(this.tickingRunnable);
				}
			}
		}
	}

	/**
	 * Stops an animation.
	 * <p>
	 * The tick method of the given animation will not be called anymore by this animator.
	 * <p>
	 * This method must be called in the MicroUI thread.
	 *
	 * @param animation
	 *            the animation to stop.
	 * @throws IllegalStateException
	 *             if this method is called in an other thread than the MicroUI thread or during an animation tick.
	 */
	public void stopAnimation(Animation animation) {
		if (!MicroUI.isUIThread() || this.ticking) {
			throw new IllegalStateException();
		}

		stopAnimationNoCheck(animation);
	}

	/**
	 * Stops all the animations.
	 * <p>
	 * The tick method of every animation will not be called anymore by this animator.
	 * <p>
	 * This method must be called in the MicroUI thread.
	 *
	 * @throws IllegalStateException
	 *             if this method is called in an other thread than the MicroUI thread or during an animation tick.
	 */
	public void stopAllAnimations() {
		if (!MicroUI.isUIThread() || this.ticking) {
			throw new IllegalStateException();
		}

		synchronized (this.monitor) {
			if (this.animations.length > 0) {
				this.animations = new Animation[0];
				this.started = false;
				this.tickingRunnable = null;
				Display.getDisplay().cancelCallOnFlushCompleted();
			}
		}
	}

	private void stopAnimationNoCheck(Animation animation) {
		synchronized (this.monitor) {
			Animation[] newAnimations = ArrayTools.remove(this.animations, animation);
			if (newAnimations != this.animations) {
				this.animations = newAnimations;

				if (newAnimations.length == 0) {
					this.started = false;
					this.tickingRunnable = null;
					Display.getDisplay().cancelCallOnFlushCompleted();
				}
			}
		}
	}

	private void tick(Animation[] animations) {
		long currentTimeMillis = Util.platformTimeMillis();
		for (Animation animation : animations) {
			assert animation != null;
			try {
				if (!animation.tick(currentTimeMillis)) {
					stopAnimationNoCheck(animation);
				}
			} catch (Exception e) {
				ThreadUtils.handleUncaughtException(e);
				stopAnimationNoCheck(animation);
			}
		}
	}

	private void runTick(Runnable runnable) {
		// Get the array in a locale to avoid synchronization.
		Animation[] animations = this.animations;

		if (Constants.getBoolean(DEBUG_ANIMATOR_ENABLED_CONSTANT)) {
			this.renderRequested = false;
		}

		this.ticking = true;
		tick(animations);
		this.ticking = false;

		if (this.started) {
			if (Constants.getBoolean(DEBUG_ANIMATOR_ENABLED_CONSTANT)) {
				if (!this.renderRequested) { // NOSONAR do not merge `if` to let optimizer remove the code
					assert (ANIMATOR_LISTENER != null);
					ANIMATOR_LISTENER.onVoidTick(animations);
				}
			}
			// The ticking runnable may have changed if the animator has been stopped during this tick (or if it was too
			// late to stop it from running) and restarted right away.
			if (this.tickingRunnable == runnable) {
				callOnFlushCompleted(runnable);
			}
		}
	}

	private void callOnFlushCompleted(Runnable runnable) {
		Display display = Display.getDisplay();
		display.callOnFlushCompleted(runnable);
		// Make sure that the callOnFlushCompleted will be executed.
		display.requestFlush();
	}

	private Runnable createTickRunnable() {
		return new Runnable() {
			@Override
			public void run() {
				runTick(this);
			}
		};
	}

	/**
	 * Indicates to this animator that a render has been requested.
	 * <p>
	 * Calling this method is only useful if the {@link #DEBUG_ANIMATOR_ENABLED_CONSTANT} constant is enabled.
	 * <p>
	 * This method is called automatically when a render of a widget or of a desktop is requested. This method can be
	 * called manually if the animator is used to render something else.
	 */
	public void indicateRenderRequested() {
		this.renderRequested = true;
	}

	/**
	 * Listener for animator events.
	 */
	public interface AnimatorListener {

		/**
		 * Handles the case when none of the animations has requested a render during an animator tick.
		 *
		 * @param animations
		 *            the animations of the animator.
		 */
		void onVoidTick(Animation[] animations);
	}
}
