/*
 * Copyright (c) 2005-2010 Trident Kirill Grouchnikov. All Rights Reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *  o Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 *  o Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 *  o Neither the name of Trident Kirill Grouchnikov nor the names of
 *    its contributors may be used to endorse or promote products derived
 *    from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
 * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
 * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
 * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
 * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package org.pushingpixels.trident;

import java.util.*;

import org.pushingpixels.trident.TimelineEngine.FullObjectID;
import org.pushingpixels.trident.TimelineEngine.TimelineOperationKind;
import org.pushingpixels.trident.TimelinePropertyBuilder.AbstractFieldInfo;
import org.pushingpixels.trident.callback.*;
import org.pushingpixels.trident.ease.Linear;
import org.pushingpixels.trident.ease.TimelineEase;
import org.pushingpixels.trident.interpolator.KeyFrames;

public class Timeline implements TimelineScenario.TimelineScenarioActor {
	Object mainObject;

	Comparable<?> secondaryId;

	FullObjectID fullObjectID;

	long duration;

	long initialDelay;

	long cycleDelay;

	boolean isLooping;

	int repeatCount;

	RepeatBehavior repeatBehavior;

	UIToolkitHandler uiToolkitHandler;

	Chain callback;

	String name;

	List<AbstractFieldInfo> propertiesToInterpolate;

	/**
	 * Is used to create unique value for the {@link #id} field.
	 */
	static long counter;

	/**
	 * Unique ID.
	 */
	protected long id;

	/**
	 * Timeline position.
	 */
	float durationFraction;

	/**
	 * Timeline position.
	 */
	float timelinePosition;

	long timeUntilPlay;

	/**
	 * Indication whether the looping timeline should stop at reaching the end
	 * of the cycle. Relevant only when {@link #isLooping} is <code>true</code>.
	 */
	boolean toCancelAtCycleBreak;

	Stack<TimelineState> stateStack;

	TimelineEase ease;

	private int doneCount;

	public enum RepeatBehavior {
		LOOP, REVERSE
	}

	public enum TimelineState {
		IDLE(false), READY(false), PLAYING_FORWARD(true), PLAYING_REVERSE(true), SUSPENDED(
				false), CANCELLED(false), DONE(false);

		private boolean isActive;

		private TimelineState(boolean isActive) {
			this.isActive = isActive;
		}
	}

	private class Setter extends TimelineCallbackAdapter {
		@Override
		public void onTimelineStateChanged(TimelineState oldState,
				TimelineState newState, float durationFraction,
				float timelinePosition) {
			if (newState == TimelineState.READY) {
				for (AbstractFieldInfo fInfo : propertiesToInterpolate) {
					// check whether the object is in the ready state
					if ((uiToolkitHandler != null)
							&& !uiToolkitHandler.isInReadyState(fInfo.object))
						continue;
					fInfo.onStart();
				}
			}

			// Fix for issue 5 - update field values only when
			// either old or new state (or both) are active. Otherwise
			// it's a transition between inactive states (such as from
			// DONE to IDLE) that shouldn't trigger the property changes
			if (oldState.isActive || newState.isActive) {
				for (AbstractFieldInfo fInfo : propertiesToInterpolate) {
					// check whether the object is in the ready state
					if ((uiToolkitHandler != null)
							&& !uiToolkitHandler.isInReadyState(fInfo.object))
						continue;
					fInfo.updateFieldValue(timelinePosition);
				}
			}
		}

		@Override
		public void onTimelinePulse(float durationFraction,
				float timelinePosition) {
			for (AbstractFieldInfo fInfo : propertiesToInterpolate) {
				// check whether the object is in the ready state
				if ((uiToolkitHandler != null)
						&& !uiToolkitHandler.isInReadyState(fInfo.object))
					continue;
				// System.err.println("Timeline @" + Timeline.this.hashCode()
				// + " at position " + timelinePosition);
				fInfo.updateFieldValue(timelinePosition);
			}
		}
	}

	@RunOnUIThread
	private class UISetter extends Setter {
	}

	class Chain implements TimelineCallback {
		private List<TimelineCallback> callbacks;

		public Chain(TimelineCallback... callbacks) {
			this.callbacks = new ArrayList<TimelineCallback>();
			for (TimelineCallback callback : callbacks)
				this.callbacks.add(callback);
		}

		public void addCallback(TimelineCallback callback) {
			this.callbacks.add(callback);
		}

		public void removeCallback(TimelineCallback callback) {
			this.callbacks.remove(callback);
		}

		@Override
		public void onTimelineStateChanged(final TimelineState oldState,
				final TimelineState newState, final float durationFraction,
				final float timelinePosition) {
			if ((uiToolkitHandler != null)
					&& !uiToolkitHandler.isInReadyState(mainObject))
				return;
			for (int i = this.callbacks.size() - 1; i >= 0; i--) {
				final TimelineCallback callback = this.callbacks.get(i);
				// special handling for chained callbacks not running on UI
				// thread
				boolean shouldRunOnUIThread = false;
				Class<?> clazz = callback.getClass();
				while ((clazz != null) && !shouldRunOnUIThread) {
					shouldRunOnUIThread = clazz
							.isAnnotationPresent(RunOnUIThread.class);
					clazz = clazz.getSuperclass();
				}
				if (shouldRunOnUIThread
						&& (Timeline.this.uiToolkitHandler != null)) {
					Timeline.this.uiToolkitHandler.runOnUIThread(mainObject,
							new Runnable() {
								public void run() {
									callback.onTimelineStateChanged(oldState,
											newState, durationFraction,
											timelinePosition);
								}
							});
				} else {
					callback.onTimelineStateChanged(oldState, newState,
							durationFraction, timelinePosition);
				}
			}
		}

		@Override
		public void onTimelinePulse(final float durationFraction,
				final float timelinePosition) {
			if ((uiToolkitHandler != null)
					&& !uiToolkitHandler.isInReadyState(mainObject))
				return;
			for (int i = this.callbacks.size() - 1; i >= 0; i--) {
				final TimelineCallback callback = this.callbacks.get(i);
				// special handling for chained callbacks not running on UI
				// thread
				boolean shouldRunOnUIThread = false;
				Class<?> clazz = callback.getClass();
				while ((clazz != null) && !shouldRunOnUIThread) {
					shouldRunOnUIThread = clazz
							.isAnnotationPresent(RunOnUIThread.class);
					clazz = clazz.getSuperclass();
				}
				if (shouldRunOnUIThread
						&& (Timeline.this.uiToolkitHandler != null)) {
					Timeline.this.uiToolkitHandler.runOnUIThread(mainObject,
							new Runnable() {
								public void run() {
									if (Timeline.this.getState() == TimelineState.CANCELLED)
										return;
									// System.err.println("Timeline @"
									// + Timeline.this.hashCode());
									callback.onTimelinePulse(durationFraction,
											timelinePosition);
								}
							});
				} else {
					// System.err.println("Timeline @" +
					// Timeline.this.hashCode());
					callback
							.onTimelinePulse(durationFraction, timelinePosition);
				}
			}
		}
	}

	public Timeline() {
		this(null);
	}

	public Timeline(Object mainTimelineObject) {
		this.mainObject = mainTimelineObject;

		for (UIToolkitHandler uiToolkitHandler : TridentConfig.getInstance()
				.getUIToolkitHandlers()) {
			if (uiToolkitHandler.isHandlerFor(mainTimelineObject)) {
				this.uiToolkitHandler = uiToolkitHandler;
				break;
			}
		}

		// if the main timeline object is handled by a UI toolkit handler,
		// the setters registered with the different addProperty
		// APIs need to run with the matching threading policy
		TimelineCallback setterCallback = (this.uiToolkitHandler != null) ? new UISetter()
				: new Setter();
		this.callback = new Chain(setterCallback);

		this.duration = 500;
		this.propertiesToInterpolate = new ArrayList<AbstractFieldInfo>();
		this.id = Timeline.getId();
		// this.loopsToLive = -1;

		this.stateStack = new Stack<TimelineState>();
		this.stateStack.push(TimelineState.IDLE);
		this.doneCount = 0;

		this.ease = new Linear();
	}

	public final void setSecondaryID(Comparable<?> secondaryId) {
		if (this.getState() != TimelineState.IDLE) {
			throw new IllegalArgumentException(
					"Cannot change state of non-idle timeline ["
							+ this.toString() + "]");
		}
		this.secondaryId = secondaryId;
	}

	public final void setDuration(long durationMs) {
		if (this.getState() != TimelineState.IDLE) {
			throw new IllegalArgumentException(
					"Cannot change state of non-idle timeline ["
							+ this.toString() + "]");
		}
		this.duration = durationMs;
	}

	public final void setInitialDelay(long initialDelay) {
		if (this.getState() != TimelineState.IDLE) {
			throw new IllegalArgumentException(
					"Cannot change state of non-idle timeline ["
							+ this.toString() + "]");
		}
		this.initialDelay = initialDelay;
	}

	public final void setCycleDelay(long cycleDelay) {
		if (this.getState() != TimelineState.IDLE) {
			throw new IllegalArgumentException(
					"Cannot change state of non-idle timeline ["
							+ this.toString() + "]");
		}
		this.cycleDelay = cycleDelay;
	}

	public final void addCallback(TimelineCallback callback) {
		if (this.getState() != TimelineState.IDLE) {
			throw new IllegalArgumentException(
					"Cannot change state of non-idle timeline ["
							+ this.toString() + "]");
		}
		this.callback.addCallback(callback);
	}

	public final void removeCallback(TimelineCallback callback) {
		if (this.getState() != TimelineState.IDLE) {
			throw new IllegalArgumentException(
					"Cannot change state of non-idle timeline ["
							+ this.toString() + "]");
		}
		this.callback.removeCallback(callback);
	}

	public static <T> TimelinePropertyBuilder<T> property(String propertyName) {
		return new TimelinePropertyBuilder<T>(propertyName);
	}

	public final <T> void addPropertyToInterpolate(
			TimelinePropertyBuilder<T> propertyBuilder) {
		this.propertiesToInterpolate.add(propertyBuilder.getFieldInfo(this));
	}

	public final <T> void addPropertyToInterpolate(String propName,
			KeyFrames<T> keyFrames) {
		this.addPropertyToInterpolate(Timeline.<T> property(propName)
				.goingThrough(keyFrames));
	}

	public final <T> void addPropertyToInterpolate(String propName, T from, T to) {
		this.addPropertyToInterpolate(Timeline.<T> property(propName)
				.from(from).to(to));
	}

	public void play() {
		this.playSkipping(0);
	}

	public void playSkipping(final long msToSkip) {
		if ((this.initialDelay + this.duration) < msToSkip) {
			throw new IllegalArgumentException(
					"Required skip longer than initial delay + duration");
		}
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.PLAY, new Runnable() {
					@Override
					public void run() {
						Timeline.this.isLooping = false;
						TimelineEngine.getInstance().play(Timeline.this, false,
								msToSkip);
					}
				});
	}

	public void playReverse() {
		playReverseSkipping(0);
	}

	public void playReverseSkipping(final long msToSkip) {
		if ((this.initialDelay + this.duration) < msToSkip) {
			throw new IllegalArgumentException(
					"Required skip longer than initial delay + duration");
		}
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.PLAY, new Runnable() {
					@Override
					public void run() {
						Timeline.this.isLooping = false;
						TimelineEngine.getInstance().playReverse(Timeline.this,
								false, msToSkip);
					}
				});
	}

	public void replay() {
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.PLAY, new Runnable() {
					@Override
					public void run() {
						Timeline.this.isLooping = false;
						TimelineEngine.getInstance().play(Timeline.this, true,
								0);
					}
				});
	}

	public void replayReverse() {
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.PLAY, new Runnable() {
					@Override
					public void run() {
						Timeline.this.isLooping = false;
						TimelineEngine.getInstance().playReverse(Timeline.this,
								true, 0);
					}
				});
	}

	public void playLoop(RepeatBehavior repeatBehavior) {
		this.playLoop(-1, repeatBehavior);
	}

	public void playLoopSkipping(RepeatBehavior repeatBehavior,
			final long msToSkip) {
		this.playLoopSkipping(-1, repeatBehavior, msToSkip);
	}

	public void playLoop(int loopCount, RepeatBehavior repeatBehavior) {
		this.playLoopSkipping(loopCount, repeatBehavior, 0);
	}

	public void playLoopSkipping(final int loopCount,
			final RepeatBehavior repeatBehavior, final long msToSkip) {
		if ((this.initialDelay + this.duration) < msToSkip) {
			throw new IllegalArgumentException(
					"Required skip longer than initial delay + duration");
		}
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.PLAY, new Runnable() {
					@Override
					public void run() {
						Timeline.this.isLooping = true;
						Timeline.this.repeatCount = loopCount;
						Timeline.this.repeatBehavior = repeatBehavior;
						TimelineEngine.getInstance().playLoop(Timeline.this,
								msToSkip);
					}
				});
	}

	/**
	 * Cancels this timeline. The timeline transitions to the
	 * {@link TimelineState#CANCELLED} state, preserving its current timeline
	 * position. After application callbacks and field interpolations are done
	 * on the {@link TimelineState#CANCELLED} state, the timeline transitions to
	 * the {@link TimelineState#IDLE} state. Application callbacks and field
	 * interpolations are done on this state as well.
	 * 
	 * @see #end()
	 * @see #abort()
	 */
	public void cancel() {
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.CANCEL, null);
	}

	/**
	 * Ends this timeline. The timeline transitions to the
	 * {@link TimelineState#DONE} state, with the timeline position set to 0.0
	 * or 1.0 - based on the direction of the timeline. After application
	 * callbacks and field interpolations are done on the
	 * {@link TimelineState#DONE} state, the timeline transitions to the
	 * {@link TimelineState#IDLE} state. Application callbacks and field
	 * interpolations are done on this state as well.
	 * 
	 * @see #cancel()
	 * @see #abort()
	 */
	public void end() {
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.END, null);
	}

	/**
	 * Aborts this timeline. The timeline transitions to the
	 * {@link TimelineState#IDLE} state. No application callbacks or field
	 * interpolations are done.
	 * 
	 * @see #cancel()
	 * @see #end()
	 */
	public void abort() {
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.ABORT, null);
	}

	public void suspend() {
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.SUSPEND, null);
	}

	public void resume() {
		TimelineEngine.getInstance().runTimelineOperation(this,
				TimelineOperationKind.RESUME, null);
	}

	/**
	 * Requests that the specified timeline should stop at the end of the cycle.
	 * This method should be called only on looping timelines.
	 */
	public void cancelAtCycleBreak() {
		if (!this.isLooping)
			throw new IllegalArgumentException(
					"Can only be called on looping timelines");
		this.toCancelAtCycleBreak = true;
	}

	/**
	 * Returns a unique ID.
	 * 
	 * @return Unique ID.
	 */
	protected static synchronized long getId() {
		return counter++;
	}

	public final float getTimelinePosition() {
		return this.timelinePosition;
	}

	public final float getDurationFraction() {
		return this.durationFraction;
	}

	public final TimelineState getState() {
		return this.stateStack.peek();
	}

	public final void setEase(TimelineEase ease) {
		if (this.getState() != TimelineState.IDLE) {
			throw new IllegalArgumentException(
					"Cannot change state of non-idle timeline");
		}
		this.ease = ease;
	}

	@Override
	public boolean isDone() {
		return (this.doneCount > 0);
	}

	@Override
	public boolean supportsReplay() {
		return true;
	}

	@Override
	public void resetDoneFlag() {
		this.doneCount = 0;
	}

	@Override
	public String toString() {
		StringBuffer res = new StringBuffer();
		if (this.name != null) {
			res.append(this.name);
		}
		if (this.mainObject != null) {
			res.append(":" + this.mainObject.getClass().getName());
		}
		if (this.secondaryId != null) {
			res.append(":" + this.secondaryId.toString());
		}

		res.append(" " + this.getState().name());
		res.append(":" + this.timelinePosition);

		return res.toString();
	}

	void replaceState(TimelineState state) {
		this.stateStack.pop();
		this.pushState(state);
	}

	void pushState(TimelineState state) {
		if (state == TimelineState.DONE)
			this.doneCount++;
		this.stateStack.add(state);
	}

	TimelineState popState() {
		return this.stateStack.pop();
	}

	public final long getDuration() {
		return this.duration;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public Object getMainObject() {
		return this.mainObject;
	}
}