import { Volume } from "../component/channel/Volume";
import "../core/context/Destination";
import "../core/clock/Transport";
import { Param } from "../core/context/Param";
import { OutputNode, ToneAudioNode, ToneAudioNodeOptions } from "../core/context/ToneAudioNode";
import { Decibels, Seconds, Time } from "../core/type/Units";
import { defaultArg } from "../core/util/Defaults";
import { noOp, readOnly } from "../core/util/Interface";
import { BasicPlaybackState, StateTimeline, StateTimelineEvent } from "../core/util/StateTimeline";
import { isDefined, isUndef } from "../core/util/TypeCheck";
import { assert, assertContextRunning } from "../core/util/Debug";
import { GT } from "../core/util/Math";

type onStopCallback = (source: Source<any>) => void;

export interface SourceOptions extends ToneAudioNodeOptions {
	volume: Decibels;
	mute: boolean;
	onstop: onStopCallback;
}

/**
 * Base class for sources. 
 * start/stop of this.context.transport.
 * 
 * ```
 * // Multiple state change events can be chained together,
 * // but must be set in the correct order and with ascending times
 * // OK
 * state.start().stop("+0.2");
 * // OK
 * state.start().stop("+0.2").start("+0.4").stop("+0.7")
 * // BAD
 * state.stop("+0.2").start();
 * // BAD
 * state.start("+0.3").stop("+0.2");
 * ```
 */
export abstract class Source<Options extends SourceOptions> extends ToneAudioNode<Options> {

	/**
	 * The output volume node
	 */
	private _volume: Volume;

	/**
	 * The output note
	 */
	output: OutputNode;

	/**
	 * Sources have no inputs
	 */
	input = undefined;

	/**
	 * The volume of the output in decibels.
	 * @example
	 * import { PWMOscillator } from "tone";
	 * const source = new PWMOscillator().toDestination();
	 * source.volume.value = -6;
	 */
	volume: Param<"decibels">;

	/**
	 * The callback to invoke when the source is stopped.
	 */
	onstop: onStopCallback;

	/**
	 * Keep track of the scheduled state.
	 */
	protected _state: StateTimeline<{
		duration?: Seconds;
		offset?: Seconds;
		/**
		 * Either the buffer is explicitly scheduled to end using the stop method,
		 * or it's implicitly ended when the buffer is over.
		 */
		implicitEnd?: boolean;
	}> = new StateTimeline("stopped");

	/**
	 * The synced `start` callback function from the transport
	 */
	protected _synced = false;

	/**
	 * Keep track of all of the scheduled event ids
	 */
	private _scheduled: number[] = [];

	/**
	 * Placeholder functions for syncing/unsyncing to transport
	 */
	private _syncedStart: (time: Seconds, offset: Seconds) => void = noOp;
	private _syncedStop: (time: Seconds) => void = noOp;

	constructor(options: SourceOptions) {
		super(options);
		this._state.memory = 100;
		this._state.increasing = true;

		this._volume = this.output = new Volume({
			context: this.context,
			mute: options.mute,
			volume: options.volume,
		});
		this.volume = this._volume.volume;
		readOnly(this, "volume");
		this.onstop = options.onstop;
	}

	static getDefaults(): SourceOptions {
		return Object.assign(ToneAudioNode.getDefaults(), {
			mute: false,
			onstop: noOp,
			volume: 0,
		});
	}

	/**
	 * Returns the playback state of the source, either "started" or "stopped".
	 * @example
	 * import { Player } from "tone";
	 * const player = new Player("https://tonejs.github.io/examples/audio/FWDL.mp3", () => {
	 * 	player.start();
	 * 	console.log(player.state);
	 * }).toDestination();
	 */
	get state(): BasicPlaybackState {
		if (this._synced) {
			if (this.context.transport.state === "started") {
				return this._state.getValueAtTime(this.context.transport.seconds) as BasicPlaybackState;
			} else {
				return "stopped";
			}
		} else {
			return this._state.getValueAtTime(this.now()) as BasicPlaybackState;
		}
	}

	/**
	 * Mute the output.
	 * @example
	 * import { Oscillator } from "tone";
	 * const osc = new Oscillator().toDestination().start();
	 * // mute the output
	 * osc.mute = true;
	 */
	get mute(): boolean {
		return this._volume.mute;
	}
	set mute(mute: boolean) {
		this._volume.mute = mute;
	}

	// overwrite these functions
	protected abstract _start(time: Time, offset?: Time, duration?: Time): void;
	protected abstract _stop(time: Time): void;
	protected abstract _restart(time: Seconds, offset?: Time, duration?: Time): void;

	/**
	 * Ensure that the scheduled time is not before the current time.
	 * Should only be used when scheduled unsynced.
	 */
	private _clampToCurrentTime(time: Seconds): Seconds {
		if (this._synced) {
			return time;
		} else {
			return Math.max(time, this.context.currentTime);
		}
	}

	/**
	 * Start the source at the specified time. If no time is given,
	 * start the source now.
	 * @param  time When the source should be started.
	 * @example
	 * import { Oscillator } from "tone";
	 * const source = new Oscillator().toDestination();
	 * source.start("+0.5"); // starts the source 0.5 seconds from now
	 */
	start(time?: Time, offset?: Time, duration?: Time): this {
		let computedTime = isUndef(time) && this._synced ? this.context.transport.seconds : this.toSeconds(time);
		computedTime = this._clampToCurrentTime(computedTime);
		// if it's started, stop it and restart it
		if (this._state.getValueAtTime(computedTime) === "started") {
			// time should be strictly greater than the previous start time
			assert(GT(computedTime, (this._state.get(computedTime) as StateTimelineEvent).time), "Start time must be strictly greater than previous start time");
			this._state.cancel(computedTime);
			this._state.setStateAtTime("started", computedTime);
			this.log("restart", computedTime);
			this.restart(computedTime, offset, duration);
		} else {
			this.log("start", computedTime);
			this._state.setStateAtTime("started", computedTime);
			if (this._synced) {
				// add the offset time to the event
				const event = this._state.get(computedTime);
				if (event) {
					event.offset = this.toSeconds(defaultArg(offset, 0));
					event.duration = duration ? this.toSeconds(duration) : undefined;
				}
				const sched = this.context.transport.schedule(t => {
					this._start(t, offset, duration);
				}, computedTime);
				this._scheduled.push(sched);

				// if it's already started
				if (this.context.transport.state === "started") {
					this._syncedStart(this.now(), this.context.transport.seconds);
				}
			} else {
				this._start(computedTime, offset, duration);
			}
			assertContextRunning(this.context);
		}
		return this;
	}

	/**
	 * Stop the source at the specified time. If no time is given,
	 * stop the source now.
	 * @param  time When the source should be stopped.
	 * @example
	 * import { Oscillator } from "tone";
	 * const source = new Oscillator().toDestination();
	 * source.start();
	 * source.stop("+0.5"); // stops the source 0.5 seconds from now
	 */
	stop(time?: Time): this {
		let computedTime = isUndef(time) && this._synced ? this.context.transport.seconds : this.toSeconds(time);
		computedTime = this._clampToCurrentTime(computedTime);
		if (this._state.getValueAtTime(computedTime) === "started" || isDefined(this._state.getNextState("started", computedTime))) {
			this.log("stop", computedTime);
			if (!this._synced) {
				this._stop(computedTime);
			} else {
				const sched = this.context.transport.schedule(this._stop.bind(this), computedTime);
				this._scheduled.push(sched);
			}
			this._state.cancel(computedTime);
			this._state.setStateAtTime("stopped", computedTime);
		}
		return this;
	}

	/**
	 * Restart the source.
	 */
	restart(time?: Time, offset?: Time, duration?: Time): this {
		time = this.toSeconds(time);
		if (this._state.getValueAtTime(time) === "started") {
			this._state.cancel(time);
			this._restart(time, offset, duration);
		}
		return this;
	}

	/**
	 * Sync the source to the Transport so that all subsequent
	 * calls to `start` and `stop` are synced to the TransportTime
	 * instead of the AudioContext time.
	 *
	 * @example
	 * import { Oscillator, Transport } from "tone";
	 * const osc = new Oscillator().toDestination();
	 * // sync the source so that it plays between 0 and 0.3 on the Transport's timeline
	 * osc.sync().start(0).stop(0.3);
	 * // start the transport.
	 * Transport.start();
	 * // set it to loop once a second
	 * Transport.loop = true;
	 * Transport.loopEnd = 1;
	 */
	sync(): this {
		if (!this._synced) {
			this._synced = true;
			this._syncedStart = (time, offset) => {
				if (offset > 0) {
					// get the playback state at that time
					const stateEvent = this._state.get(offset);
					// listen for start events which may occur in the middle of the sync'ed time
					if (stateEvent && stateEvent.state === "started" && stateEvent.time !== offset) {
						// get the offset
						const startOffset = offset - this.toSeconds(stateEvent.time);
						let duration;
						if (stateEvent.duration) {
							duration = this.toSeconds(stateEvent.duration) - startOffset;
						}
						this._start(time, this.toSeconds(stateEvent.offset) + startOffset, duration);
					}
				}
			};
			this._syncedStop = time => {
				const seconds = this.context.transport.getSecondsAtTime(Math.max(time - this.sampleTime, 0));
				if (this._state.getValueAtTime(seconds) === "started") {
					this._stop(time);
				}
			};
			this.context.transport.on("start", this._syncedStart);
			this.context.transport.on("loopStart", this._syncedStart);
			this.context.transport.on("stop", this._syncedStop);
			this.context.transport.on("pause", this._syncedStop);
			this.context.transport.on("loopEnd", this._syncedStop);
		}
		return this;
	}

	/**
	 * Unsync the source to the Transport. See Source.sync
	 */
	unsync(): this {
		if (this._synced) {
			this.context.transport.off("stop", this._syncedStop);
			this.context.transport.off("pause", this._syncedStop);
			this.context.transport.off("loopEnd", this._syncedStop);
			this.context.transport.off("start", this._syncedStart);
			this.context.transport.off("loopStart", this._syncedStart);
		}
		this._synced = false;
		// clear all of the scheduled ids
		this._scheduled.forEach(id => this.context.transport.clear(id));
		this._scheduled = [];
		this._state.cancel(0);
		return this;
	}

	/**
	 * Clean up.
	 */
	dispose(): this {
		super.dispose();
		this.onstop = noOp;
		this.unsync();
		this._volume.dispose();
		this._state.dispose();
		return this;
	}
}