import { Tone } from "../Tone";
import { Seconds } from "../type/Units";
import { optionsFromArguments } from "./Defaults";
import { assert } from "./Debug";
import { EQ, GT, GTE, LT } from "./Math";

type TimelineSearchParam = "ticks" | "time";

/**
 * The options object for Timeline
 */
interface TimelineOptions {
	memory: number;
	increasing: boolean;
}

/**
 * An event must have a time number
 */
export interface TimelineEvent {
	time: number;
}

/**
 * A Timeline class for scheduling and maintaining state
 * along a timeline. All events must have a "time" property.
 * Internally, events are stored in time order for fast
 * retrieval.
 * @internal
 */
export class Timeline<GenericEvent extends TimelineEvent> extends Tone {

	readonly name: string = "Timeline";

	/**
	 * The memory of the timeline, i.e.
	 * how many events in the past it will retain
	 */
	memory: number;

	/**
	 * The array of scheduled timeline events
	 */
	protected _timeline: GenericEvent[] = [];

	/**
	 * If the time value must always be greater than or equal to the last 
	 * element on the list. 
	 */
	increasing: boolean;

	/**
	 * @param memory The number of previous events that are retained.
	 */
	constructor(memory?: number);
	constructor(options?: Partial<TimelineOptions>);
	constructor() {
		super();
		const options = optionsFromArguments(Timeline.getDefaults(), arguments, ["memory"]);

		this.memory = options.memory;
		this.increasing = options.increasing;
	}

	static getDefaults(): TimelineOptions {
		return {
			memory: Infinity,
			increasing: false,
		};
	}

	/**
	 * The number of items in the timeline.
	 */
	get length(): number {
		return this._timeline.length;
	}

	/**
	 * Insert an event object onto the timeline. Events must have a "time" attribute.
	 * @param event  The event object to insert into the timeline.
	 */
	add(event: GenericEvent): this {
		// the event needs to have a time attribute
		assert(Reflect.has(event, "time"), "Timeline: events must have a time attribute");
		event.time = event.time.valueOf();
		if (this.increasing && this.length) {
			const lastValue = this._timeline[this.length - 1] as GenericEvent;
			assert(GTE(event.time, lastValue.time), "The time must be greater than or equal to the last scheduled time");
			this._timeline.push(event);
		} else {
			const index = this._search(event.time);
			this._timeline.splice(index + 1, 0, event);
		}
		// if the length is more than the memory, remove the previous ones
		if (this.length > this.memory) {
			const diff = this.length - this.memory;
			this._timeline.splice(0, diff);
		}
		return this;
	}

	/**
	 * Remove an event from the timeline.
	 * @param  {Object}  event  The event object to remove from the list.
	 * @returns {Timeline} this
	 */
	remove(event: GenericEvent): this {
		const index = this._timeline.indexOf(event);
		if (index !== -1) {
			this._timeline.splice(index, 1);
		}
		return this;
	}

	/**
	 * Get the nearest event whose time is less than or equal to the given time.
	 * @param  time  The time to query.
	 */
	get(time: number, param: TimelineSearchParam = "time"): GenericEvent | null {
		const index = this._search(time, param);
		if (index !== -1) {
			return this._timeline[index];
		} else {
			return null;
		}
	}

	/**
	 * Return the first event in the timeline without removing it
	 * @returns {Object} The first event object
	 */
	peek(): GenericEvent | undefined {
		return this._timeline[0];
	}

	/**
	 * Return the first event in the timeline and remove it
	 */
	shift(): GenericEvent | undefined {
		return this._timeline.shift();
	}

	/**
	 * Get the event which is scheduled after the given time.
	 * @param  time  The time to query.
	 */
	getAfter(time: number, param: TimelineSearchParam = "time"): GenericEvent | null {
		const index = this._search(time, param);
		if (index + 1 < this._timeline.length) {
			return this._timeline[index + 1];
		} else {
			return null;
		}
	}

	/**
	 * Get the event before the event at the given time.
	 * @param  time  The time to query.
	 */
	getBefore(time: number): GenericEvent | null {
		const len = this._timeline.length;
		// if it's after the last item, return the last item
		if (len > 0 && this._timeline[len - 1].time < time) {
			return this._timeline[len - 1];
		}
		const index = this._search(time);
		if (index - 1 >= 0) {
			return this._timeline[index - 1];
		} else {
			return null;
		}
	}

	/**
	 * Cancel events at and after the given time
	 * @param  after  The time to query.
	 */
	cancel(after: number): this {
		if (this._timeline.length > 1) {
			let index = this._search(after);
			if (index >= 0) {
				if (EQ(this._timeline[index].time, after)) {
					// get the first item with that time
					for (let i = index; i >= 0; i--) {
						if (EQ(this._timeline[i].time, after)) {
							index = i;
						} else {
							break;
						}
					}
					this._timeline = this._timeline.slice(0, index);
				} else {
					this._timeline = this._timeline.slice(0, index + 1);
				}
			} else {
				this._timeline = [];
			}
		} else if (this._timeline.length === 1) {
			// the first item's time
			if (GTE(this._timeline[0].time, after)) {
				this._timeline = [];
			}
		}
		return this;
	}

	/**
	 * Cancel events before or equal to the given time.
	 * @param  time  The time to cancel before.
	 */
	cancelBefore(time: number): this {
		const index = this._search(time);
		if (index >= 0) {
			this._timeline = this._timeline.slice(index + 1);
		}
		return this;
	}

	/**
	 * Returns the previous event if there is one. null otherwise
	 * @param  event The event to find the previous one of
	 * @return The event right before the given event
	 */
	previousEvent(event: GenericEvent): GenericEvent | null {
		const index = this._timeline.indexOf(event);
		if (index > 0) {
			return this._timeline[index - 1];
		} else {
			return null;
		}
	}

	/**
	 * Does a binary search on the timeline array and returns the
	 * nearest event index whose time is after or equal to the given time.
	 * If a time is searched before the first index in the timeline, -1 is returned.
	 * If the time is after the end, the index of the last item is returned.
	 */
	protected _search(time: number, param: TimelineSearchParam = "time"): number {
		if (this._timeline.length === 0) {
			return -1;
		}
		let beginning = 0;
		const len = this._timeline.length;
		let end = len;
		if (len > 0 && this._timeline[len - 1][param] <= time) {
			return len - 1;
		}
		while (beginning < end) {
			// calculate the midpoint for roughly equal partition
			let midPoint = Math.floor(beginning + (end - beginning) / 2);
			const event = this._timeline[midPoint];
			const nextEvent = this._timeline[midPoint + 1];
			if (EQ(event[param], time)) {
				// choose the last one that has the same time
				for (let i = midPoint; i < this._timeline.length; i++) {
					const testEvent = this._timeline[i];
					if (EQ(testEvent[param], time)) {
						midPoint = i;
					} else {
						break;
					}
				}
				return midPoint;
			} else if (LT(event[param], time) && GT(nextEvent[param], time)) {
				return midPoint;
			} else if (GT(event[param], time)) {
				// search lower
				end = midPoint;
			} else {
				// search upper
				beginning = midPoint + 1;
			}
		}
		return -1;
	}

	/**
	 * Internal iterator. Applies extra safety checks for
	 * removing items from the array.
	 */
	private _iterate(
		callback: (event: GenericEvent) => void,
		lowerBound = 0, upperBound = this._timeline.length - 1,
	): void {
		this._timeline.slice(lowerBound, upperBound + 1).forEach(callback);
	}

	/**
	 * Iterate over everything in the array
	 * @param  callback The callback to invoke with every item
	 */
	forEach(callback: (event: GenericEvent) => void): this {
		this._iterate(callback);
		return this;
	}

	/**
	 * Iterate over everything in the array at or before the given time.
	 * @param  time The time to check if items are before
	 * @param  callback The callback to invoke with every item
	 */
	forEachBefore(time: Seconds, callback: (event: GenericEvent) => void): this {
		// iterate over the items in reverse so that removing an item doesn't break things
		const upperBound = this._search(time);
		if (upperBound !== -1) {
			this._iterate(callback, 0, upperBound);
		}
		return this;
	}

	/**
	 * Iterate over everything in the array after the given time.
	 * @param  time The time to check if items are before
	 * @param  callback The callback to invoke with every item
	 */
	forEachAfter(time: Seconds, callback: (event: GenericEvent) => void): this {
		// iterate over the items in reverse so that removing an item doesn't break things
		const lowerBound = this._search(time);
		this._iterate(callback, lowerBound + 1);
		return this;
	}

	/**
	 * Iterate over everything in the array between the startTime and endTime.
	 * The timerange is inclusive of the startTime, but exclusive of the endTime.
	 * range = [startTime, endTime).
	 * @param  startTime The time to check if items are before
	 * @param  endTime The end of the test interval.
	 * @param  callback The callback to invoke with every item
	 */
	forEachBetween(startTime: number, endTime: number, callback: (event: GenericEvent) => void): this {
		let lowerBound = this._search(startTime);
		let upperBound = this._search(endTime);
		if (lowerBound !== -1 && upperBound !== -1) {
			if (this._timeline[lowerBound].time !== startTime) {
				lowerBound += 1;
			}
			// exclusive of the end time
			if (this._timeline[upperBound].time === endTime) {
				upperBound -= 1;
			}
			this._iterate(callback, lowerBound, upperBound);
		} else if (lowerBound === -1) {
			this._iterate(callback, 0, upperBound);
		}
		return this;
	}

	/**
	 * Iterate over everything in the array at or after the given time. Similar to
	 * forEachAfter, but includes the item(s) at the given time.
	 * @param  time The time to check if items are before
	 * @param  callback The callback to invoke with every item
	 */
	forEachFrom(time: number, callback: (event: GenericEvent) => void): this {
		// iterate over the items in reverse so that removing an item doesn't break things
		let lowerBound = this._search(time);
		// work backwards until the event time is less than time
		while (lowerBound >= 0 && this._timeline[lowerBound].time >= time) {
			lowerBound--;
		}
		this._iterate(callback, lowerBound + 1);
		return this;
	}

	/**
	 * Iterate over everything in the array at the given time
	 * @param  time The time to check if items are before
	 * @param  callback The callback to invoke with every item
	 */
	forEachAtTime(time: number, callback: (event: GenericEvent) => void): this {
		// iterate over the items in reverse so that removing an item doesn't break things
		const upperBound = this._search(time);
		if (upperBound !== -1 && EQ(this._timeline[upperBound].time, time)) {
			let lowerBound = upperBound;
			for (let i = upperBound; i >= 0; i--) {
				if (EQ(this._timeline[i].time, time)) {
					lowerBound = i;
				} else {
					break;
				}
			}
			this._iterate(event => {
				callback(event);
			}, lowerBound, upperBound);
		}
		return this;
	}

	/**
	 * Clean up.
	 */
	dispose(): this {
		super.dispose();
		this._timeline = [];
		return this;
	}
}
