import { TimeClass } from "../../core/type/Time";
import { PlaybackState } from "../../core/util/StateTimeline";
import { TimelineValue } from "../../core/util/TimelineValue";
import { Signal } from "../../signal/Signal";
import { onContextClose, onContextInit } from "../context/ContextInitialization";
import { Gain } from "../context/Gain";
import { ToneWithContext, ToneWithContextOptions } from "../context/ToneWithContext";
import { TicksClass } from "../type/Ticks";
import { TransportTimeClass } from "../type/TransportTime";
import {
	BarsBeatsSixteenths, BPM, NormalRange, Seconds,
	Subdivision, Ticks, Time, TimeSignature, TransportTime
} from "../type/Units";
import { optionsFromArguments } from "../util/Defaults";
import { Emitter } from "../util/Emitter";
import { readOnly, writable } from "../util/Interface";
import { IntervalTimeline } from "../util/IntervalTimeline";
import { Timeline } from "../util/Timeline";
import { isArray, isDefined } from "../util/TypeCheck";
import { Clock } from "./Clock";
import { TickParam } from "./TickParam";
import { TransportEvent } from "./TransportEvent";
import { TransportRepeatEvent } from "./TransportRepeatEvent";

interface TransportOptions extends ToneWithContextOptions {
	bpm: BPM;
	swing: NormalRange;
	swingSubdivision: Subdivision;
	timeSignature: number;
	loopStart: Time;
	loopEnd: Time;
	ppq: number;
}

type TransportEventNames = "start" | "stop" | "pause" | "loop" | "loopEnd" | "loopStart" | "ticks";

interface SyncedSignalEvent {
	signal: Signal;
	initial: number;
	ratio: Gain;
}

type TransportCallback = (time: Seconds) => void;

/**
 * Transport for timing musical events.
 * Supports tempo curves and time changes. Unlike browser-based timing (setInterval, requestAnimationFrame)
 * Transport timing events pass in the exact time of the scheduled event
 * in the argument of the callback function. Pass that time value to the object
 * you're scheduling. <br><br>
 * A single transport is created for you when the library is initialized.
 * <br><br>
 * The transport emits the events: "start", "stop", "pause", and "loop" which are
 * called with the time of that event as the argument.
 *
 * @example
 * const osc = new Tone.Oscillator().toDestination();
 * // repeated event every 8th note
 * Tone.Transport.scheduleRepeat((time) => {
 * 	// use the callback time to schedule events
 * 	osc.start(time).stop(time + 0.1);
 * }, "8n");
 * // transport must be started before it starts invoking events
 * Tone.Transport.start();
 * @category Core
 */
export class Transport extends ToneWithContext<TransportOptions> implements Emitter<TransportEventNames> {

	readonly name: string = "Transport";

	//-------------------------------------
	// 	LOOPING
	//-------------------------------------

	/**
	 * If the transport loops or not.
	 */
	private _loop: TimelineValue<boolean> = new TimelineValue(false);

	/**
	 * The loop start position in ticks
	 */
	private _loopStart: Ticks = 0;

	/**
	 * The loop end position in ticks
	 */
	private _loopEnd: Ticks = 0;

	//-------------------------------------
	// 	CLOCK/TEMPO
	//-------------------------------------

	/**
	 * Pulses per quarter is the number of ticks per quarter note.
	 */
	private _ppq: number;

	/**
	 * watches the main oscillator for timing ticks
	 * initially starts at 120bpm
	 */
	private _clock: Clock<"bpm">;

	/**
	 * The Beats Per Minute of the Transport.
	 * @example
	 * const osc = new Tone.Oscillator().toDestination();
	 * Tone.Transport.bpm.value = 80;
	 * // start/stop the oscillator every quarter note
	 * Tone.Transport.scheduleRepeat(time => {
	 * 	osc.start(time).stop(time + 0.1);
	 * }, "4n");
	 * Tone.Transport.start();
	 * // ramp the bpm to 120 over 10 seconds
	 * Tone.Transport.bpm.rampTo(120, 10);
	 */
	bpm: TickParam<"bpm">;

	/**
	 * The time signature, or more accurately the numerator
	 * of the time signature over a denominator of 4.
	 */
	private _timeSignature: number;

	//-------------------------------------
	// 	TIMELINE EVENTS
	//-------------------------------------

	/**
	 * All the events in an object to keep track by ID
	 */
	private _scheduledEvents = {};

	/**
	 * The scheduled events.
	 */
	private _timeline: Timeline<TransportEvent> = new Timeline();

	/**
	 * Repeated events
	 */
	private _repeatedEvents: IntervalTimeline = new IntervalTimeline();

	/**
	 * All of the synced Signals
	 */
	private _syncedSignals: SyncedSignalEvent[] = [];

	//-------------------------------------
	// 	SWING
	//-------------------------------------

	/**
	 * The subdivision of the swing
	 */
	private _swingTicks: Ticks;

	/**
	 * The swing amount
	 */
	private _swingAmount: NormalRange = 0;

	constructor(options?: Partial<TransportOptions>);
	constructor() {

		super(optionsFromArguments(Transport.getDefaults(), arguments));
		const options = optionsFromArguments(Transport.getDefaults(), arguments);

		// CLOCK/TEMPO
		this._ppq = options.ppq;
		this._clock = new Clock({
			callback: this._processTick.bind(this),
			context: this.context,
			frequency: 0,
			units: "bpm",
		});
		this._bindClockEvents();
		this.bpm = this._clock.frequency as unknown as TickParam<"bpm">;
		this._clock.frequency.multiplier = options.ppq;
		this.bpm.setValueAtTime(options.bpm, 0);
		readOnly(this, "bpm");
		this._timeSignature = options.timeSignature;

		// SWING
		this._swingTicks = options.ppq / 2; // 8n
	}

	static getDefaults(): TransportOptions {
		return Object.assign(ToneWithContext.getDefaults(), {
			bpm: 120,
			loopEnd: "4m" as Subdivision,
			loopStart: 0,
			ppq: 192,
			swing: 0,
			swingSubdivision: "8n" as Subdivision,
			timeSignature: 4,
		});
	}

	//-------------------------------------
	// 	TICKS
	//-------------------------------------

	/**
	 * called on every tick
	 * @param  tickTime clock relative tick time
	 */
	private _processTick(tickTime: Seconds, ticks: Ticks): void {
		// do the loop test
		if (this._loop.get(tickTime)) {
			if (ticks >= this._loopEnd) {
				this.emit("loopEnd", tickTime);
				this._clock.setTicksAtTime(this._loopStart, tickTime);
				ticks = this._loopStart;
				this.emit("loopStart", tickTime, this._clock.getSecondsAtTime(tickTime));
				this.emit("loop", tickTime);
			}
		}
		// handle swing
		if (this._swingAmount > 0 &&
			ticks % this._ppq !== 0 && // not on a downbeat
			ticks % (this._swingTicks * 2) !== 0) {
			// add some swing
			const progress = (ticks % (this._swingTicks * 2)) / (this._swingTicks * 2);
			const amount = Math.sin((progress) * Math.PI) * this._swingAmount;
			tickTime += new TicksClass(this.context, this._swingTicks * 2 / 3).toSeconds() * amount;
		}
		// invoke the timeline events scheduled on this tick
		this._timeline.forEachAtTime(ticks, event => event.invoke(tickTime));
	}

	//-------------------------------------
	// 	SCHEDULABLE EVENTS
	//-------------------------------------

	/**
	 * Schedule an event along the timeline.
	 * @param callback The callback to be invoked at the time.
	 * @param time The time to invoke the callback at.
	 * @return The id of the event which can be used for canceling the event.
	 * @example
	 * // schedule an event on the 16th measure
	 * Tone.Transport.schedule((time) => {
	 * 	// invoked on measure 16
	 * 	console.log("measure 16!");
	 * }, "16:0:0");
	 */
	schedule(callback: TransportCallback, time: TransportTime | TransportTimeClass): number {
		const event = new TransportEvent(this, {
			callback,
			time: new TransportTimeClass(this.context, time).toTicks(),
		});
		return this._addEvent(event, this._timeline);
	}

	/**
	 * Schedule a repeated event along the timeline. The event will fire
	 * at the `interval` starting at the `startTime` and for the specified
	 * `duration`.
	 * @param  callback   The callback to invoke.
	 * @param  interval   The duration between successive callbacks. Must be a positive number.
	 * @param  startTime  When along the timeline the events should start being invoked.
	 * @param  duration How long the event should repeat.
	 * @return  The ID of the scheduled event. Use this to cancel the event.
	 * @example
	 * const osc = new Tone.Oscillator().toDestination().start();
	 * // a callback invoked every eighth note after the first measure
	 * Tone.Transport.scheduleRepeat((time) => {
	 * 	osc.start(time).stop(time + 0.1);
	 * }, "8n", "1m");
	 */
	scheduleRepeat(
		callback: TransportCallback,
		interval: Time | TimeClass,
		startTime?: TransportTime | TransportTimeClass,
		duration: Time = Infinity,
	): number {
		const event = new TransportRepeatEvent(this, {
			callback,
			duration: new TimeClass(this.context, duration).toTicks(),
			interval: new TimeClass(this.context, interval).toTicks(),
			time: new TransportTimeClass(this.context, startTime).toTicks(),
		});
		// kick it off if the Transport is started
		// @ts-ignore
		return this._addEvent(event, this._repeatedEvents);
	}

	/**
	 * Schedule an event that will be removed after it is invoked.
	 * @param callback The callback to invoke once.
	 * @param time The time the callback should be invoked.
	 * @returns The ID of the scheduled event.
	 */
	scheduleOnce(callback: TransportCallback, time: TransportTime | TransportTimeClass): number {
		const event = new TransportEvent(this, {
			callback,
			once: true,
			time: new TransportTimeClass(this.context, time).toTicks(),
		});
		return this._addEvent(event, this._timeline);
	}

	/**
	 * Clear the passed in event id from the timeline
	 * @param eventId The id of the event.
	 */
	clear(eventId: number): this {
		if (this._scheduledEvents.hasOwnProperty(eventId)) {
			const item = this._scheduledEvents[eventId.toString()];
			item.timeline.remove(item.event);
			item.event.dispose();
			delete this._scheduledEvents[eventId.toString()];
		}
		return this;
	}

	/**
	 * Add an event to the correct timeline. Keep track of the
	 * timeline it was added to.
	 * @returns the event id which was just added
	 */
	private _addEvent(event: TransportEvent, timeline: Timeline<TransportEvent>): number {
		this._scheduledEvents[event.id.toString()] = {
			event,
			timeline,
		};
		timeline.add(event);
		return event.id;
	}

	/**
	 * Remove scheduled events from the timeline after
	 * the given time. Repeated events will be removed
	 * if their startTime is after the given time
	 * @param after Clear all events after this time.
	 */
	cancel(after: TransportTime = 0): this {
		const computedAfter = this.toTicks(after);
		this._timeline.forEachFrom(computedAfter, event => this.clear(event.id));
		this._repeatedEvents.forEachFrom(computedAfter, event => this.clear(event.id));
		return this;
	}

	//-------------------------------------
	// 	START/STOP/PAUSE
	//-------------------------------------

	/**
	 * Bind start/stop/pause events from the clock and emit them.
	 */
	private _bindClockEvents(): void {
		this._clock.on("start", (time, offset) => {
			offset = new TicksClass(this.context, offset).toSeconds();
			this.emit("start", time, offset);
		});

		this._clock.on("stop", (time) => {
			this.emit("stop", time);
		});

		this._clock.on("pause", (time) => {
			this.emit("pause", time);
		});
	}

	/**
	 * Returns the playback state of the source, either "started", "stopped", or "paused"
	 */
	get state(): PlaybackState {
		return this._clock.getStateAtTime(this.now());
	}

	/**
	 * Start the transport and all sources synced to the transport.
	 * @param  time The time when the transport should start.
	 * @param  offset The timeline offset to start the transport.
	 * @example
	 * // start the transport in one second starting at beginning of the 5th measure.
	 * Tone.Transport.start("+1", "4:0:0");
	 */
	start(time?: Time, offset?: TransportTime): this {
		let offsetTicks;
		if (isDefined(offset)) {
			offsetTicks = this.toTicks(offset);
		}
		// start the clock
		this._clock.start(time, offsetTicks);
		return this;
	}

	/**
	 * Stop the transport and all sources synced to the transport.
	 * @param time The time when the transport should stop.
	 * @example
	 * Tone.Transport.stop();
	 */
	stop(time?: Time): this {
		this._clock.stop(time);
		return this;
	}

	/**
	 * Pause the transport and all sources synced to the transport.
	 */
	pause(time?: Time): this {
		this._clock.pause(time);
		return this;
	}

	/**
	 * Toggle the current state of the transport. If it is
	 * started, it will stop it, otherwise it will start the Transport.
	 * @param  time The time of the event
	 */
	toggle(time?: Time): this {
		time = this.toSeconds(time);
		if (this._clock.getStateAtTime(time) !== "started") {
			this.start(time);
		} else {
			this.stop(time);
		}
		return this;
	}

	//-------------------------------------
	// 	SETTERS/GETTERS
	//-------------------------------------

	/**
	 * The time signature as just the numerator over 4.
	 * For example 4/4 would be just 4 and 6/8 would be 3.
	 * @example
	 * // common time
	 * Tone.Transport.timeSignature = 4;
	 * // 7/8
	 * Tone.Transport.timeSignature = [7, 8];
	 * // this will be reduced to a single number
	 * Tone.Transport.timeSignature; // returns 3.5
	 */
	get timeSignature(): TimeSignature {
		return this._timeSignature;
	}
	set timeSignature(timeSig: TimeSignature) {
		if (isArray(timeSig)) {
			timeSig = (timeSig[0] / timeSig[1]) * 4;
		}
		this._timeSignature = timeSig;
	}

	/**
	 * When the Transport.loop = true, this is the starting position of the loop.
	 */
	get loopStart(): Time {
		return new TimeClass(this.context, this._loopStart, "i").toSeconds();
	}
	set loopStart(startPosition: Time) {
		this._loopStart = this.toTicks(startPosition);
	}

	/**
	 * When the Transport.loop = true, this is the ending position of the loop.
	 */
	get loopEnd(): Time {
		return new TimeClass(this.context, this._loopEnd, "i").toSeconds();
	}
	set loopEnd(endPosition: Time) {
		this._loopEnd = this.toTicks(endPosition);
	}

	/**
	 * If the transport loops or not.
	 */
	get loop(): boolean {
		return this._loop.get(this.now());
	}
	set loop(loop) {
		this._loop.set(loop, this.now());
	}

	/**
	 * Set the loop start and stop at the same time.
	 * @example
	 * // loop over the first measure
	 * Tone.Transport.setLoopPoints(0, "1m");
	 * Tone.Transport.loop = true;
	 */
	setLoopPoints(startPosition: TransportTime, endPosition: TransportTime): this {
		this.loopStart = startPosition;
		this.loopEnd = endPosition;
		return this;
	}

	/**
	 * The swing value. Between 0-1 where 1 equal to the note + half the subdivision.
	 */
	get swing(): NormalRange {
		return this._swingAmount;
	}
	set swing(amount: NormalRange) {
		// scale the values to a normal range
		this._swingAmount = amount;
	}

	/**
	 * Set the subdivision which the swing will be applied to.
	 * The default value is an 8th note. Value must be less
	 * than a quarter note.
	 */
	get swingSubdivision(): Subdivision {
		return new TicksClass(this.context, this._swingTicks).toNotation();
	}
	set swingSubdivision(subdivision: Subdivision) {
		this._swingTicks = this.toTicks(subdivision);
	}

	/**
	 * The Transport's position in Bars:Beats:Sixteenths.
	 * Setting the value will jump to that position right away.
	 */
	get position(): BarsBeatsSixteenths | Time {
		const now = this.now();
		const ticks = this._clock.getTicksAtTime(now);
		return new TicksClass(this.context, ticks).toBarsBeatsSixteenths();
	}
	set position(progress: Time) {
		const ticks = this.toTicks(progress);
		this.ticks = ticks;
	}

	/**
	 * The Transport's position in seconds
	 * Setting the value will jump to that position right away.
	 */
	get seconds(): Seconds {
		return this._clock.seconds;
	}
	set seconds(s: Seconds) {
		const now = this.now();
		const ticks = this._clock.frequency.timeToTicks(s, now);
		this.ticks = ticks;
	}

	/**
	 * The Transport's loop position as a normalized value. Always
	 * returns 0 if the transport if loop is not true.
	 */
	get progress(): NormalRange {
		if (this.loop) {
			const now = this.now();
			const ticks = this._clock.getTicksAtTime(now);
			return (ticks - this._loopStart) / (this._loopEnd - this._loopStart);
		} else {
			return 0;
		}
	}

	/**
	 * The transports current tick position.
	 */
	get ticks(): Ticks {
		return this._clock.ticks;
	}
	set ticks(t: Ticks) {
		if (this._clock.ticks !== t) {
			const now = this.now();
			// stop everything synced to the transport
			if (this.state === "started") {
				const ticks = this._clock.getTicksAtTime(now);
				// schedule to start on the next tick, #573
				const remainingTick = this._clock.frequency.getDurationOfTicks(Math.ceil(ticks) - ticks, now);
				const time = now + remainingTick;
				this.emit("stop", time);
				this._clock.setTicksAtTime(t, time);
				// restart it with the new time
				this.emit("start", time, this._clock.getSecondsAtTime(time));
			} else {
				this.emit("ticks", now);
				this._clock.setTicksAtTime(t, now);
			}
		}
	}

	/**
	 * Get the clock's ticks at the given time.
	 * @param  time  When to get the tick value
	 * @return The tick value at the given time.
	 */
	getTicksAtTime(time?: Time): Ticks {
		return this._clock.getTicksAtTime(time);
	}

	/**
	 * Return the elapsed seconds at the given time.
	 * @param  time  When to get the elapsed seconds
	 * @return  The number of elapsed seconds
	 */
	getSecondsAtTime(time: Time): Seconds {
		return this._clock.getSecondsAtTime(time);
	}

	/**
	 * Pulses Per Quarter note. This is the smallest resolution
	 * the Transport timing supports. This should be set once
	 * on initialization and not set again. Changing this value
	 * after other objects have been created can cause problems.
	 */
	get PPQ(): number {
		return this._clock.frequency.multiplier;
	}
	set PPQ(ppq: number) {
		this._clock.frequency.multiplier = ppq;
	}

	//-------------------------------------
	// 	SYNCING
	//-------------------------------------

	/**
	 * Returns the time aligned to the next subdivision
	 * of the Transport. If the Transport is not started,
	 * it will return 0.
	 * Note: this will not work precisely during tempo ramps.
	 * @param  subdivision  The subdivision to quantize to
	 * @return  The context time of the next subdivision.
	 * @example
	 * // the transport must be started, otherwise returns 0
	 * Tone.Transport.start(); 
	 * Tone.Transport.nextSubdivision("4n");
	 */
	nextSubdivision(subdivision?: Time): Seconds {
		subdivision = this.toTicks(subdivision);
		if (this.state !== "started") {
			// if the transport's not started, return 0
			return 0;
		} else {
			const now = this.now();
			// the remainder of the current ticks and the subdivision
			const transportPos = this.getTicksAtTime(now);
			const remainingTicks = subdivision - transportPos % subdivision;
			return this._clock.nextTickTime(remainingTicks, now);
		}
	}

	/**
	 * Attaches the signal to the tempo control signal so that
	 * any changes in the tempo will change the signal in the same
	 * ratio.
	 *
	 * @param signal
	 * @param ratio Optionally pass in the ratio between the two signals.
	 * 			Otherwise it will be computed based on their current values.
	 */
	syncSignal(signal: Signal<any>, ratio?: number): this {
		if (!ratio) {
			// get the sync ratio
			const now = this.now();
			if (signal.getValueAtTime(now) !== 0) {
				const bpm = this.bpm.getValueAtTime(now);
				const computedFreq = 1 / (60 / bpm / this.PPQ);
				ratio = signal.getValueAtTime(now) / computedFreq;
			} else {
				ratio = 0;
			}
		}
		const ratioSignal = new Gain(ratio);
		// @ts-ignore
		this.bpm.connect(ratioSignal);
		// @ts-ignore
		ratioSignal.connect(signal._param);
		this._syncedSignals.push({
			initial: signal.value,
			ratio: ratioSignal,
			signal,
		});
		signal.value = 0;
		return this;
	}

	/**
	 * Unsyncs a previously synced signal from the transport's control.
	 * See Transport.syncSignal.
	 */
	unsyncSignal(signal: Signal<any>): this {
		for (let i = this._syncedSignals.length - 1; i >= 0; i--) {
			const syncedSignal = this._syncedSignals[i];
			if (syncedSignal.signal === signal) {
				syncedSignal.ratio.dispose();
				syncedSignal.signal.value = syncedSignal.initial;
				this._syncedSignals.splice(i, 1);
			}
		}
		return this;
	}

	/**
	 * Clean up.
	 */
	dispose(): this {
		super.dispose();
		this._clock.dispose();
		writable(this, "bpm");
		this._timeline.dispose();
		this._repeatedEvents.dispose();
		return this;
	}

	//-------------------------------------
	// EMITTER MIXIN TO SATISFY COMPILER
	//-------------------------------------

	on!: (event: TransportEventNames, callback: (...args: any[]) => void) => this;
	once!: (event: TransportEventNames, callback: (...args: any[]) => void) => this;
	off!: (event: TransportEventNames, callback?: ((...args: any[]) => void) | undefined) => this;
	emit!: (event: any, ...args: any[]) => this;
}

Emitter.mixin(Transport);

//-------------------------------------
// 	INITIALIZATION
//-------------------------------------

onContextInit(context => {
	context.transport = new Transport({ context });
});

onContextClose(context => {
	context.transport.dispose();
});
