// Import the core angular services.
import { Injectable } from "@angular/core";
import { NgZone } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

type Terminal = boolean | "match";

interface ListenerOptions {
	priority: number;
	terminal?: Terminal;
	terminalWhitelist?: string[];
	inputs?: boolean;
}

interface Listener {
	priority: number;
	terminal: Terminal;
	terminalWhitelist: TerminalWhitelist;
	inputs: boolean;
	bindings: Bindings;
}

interface Handler {
	( event: KeyboardEvent ) : boolean | void;
}

interface Bindings {
	[ key: string ]: Handler;
}

interface NormalizedKeys {
	[ key: string ]: string;
}

interface TerminalWhitelist {
	[ key: string ]: boolean;
}

export interface Unlisten {
	() : void;
}

// Map to normalized keys across different browser implementations.
// --
// https://github.com/angular/angular/blob/5.0.5/packages/platform-browser/src/browser/browser_adapter.ts#L25-L42
var KEY_MAP = {
	"\b": "Backspace",
	"\t": "Tab",
	"\x7F": "Delete",
	"\x1B": "Escape",
	"Del": "Delete",
	"Esc": "Escape",
	"Left": "ArrowLeft",
	"Right": "ArrowRight",
	"Up": "ArrowUp",
	"Down": "ArrowDown",
	"Menu": "ContextMenu",
	"Scroll": "ScrollLock",
	"Win": "OS",
	" ": "Space",
	".": "Dot"
};

// NOTE: These will only be applied after the key has been lower-cased. As such, both the
// alias and the final value (in this mapping) should also be lower-case.
var KEY_ALIAS = {
	command: "meta",
	ctrl: "control",
	del: "delete",
	down: "arrowdown",
	esc: "escape",
	left: "arrowleft",
	right: "arrowright",
	up: "arrowup"
};

@Injectable()
export class KeyboardShortcuts {

	private listeners: Listener[];
	private normalizedKeys: NormalizedKeys;
	private zone: NgZone;

	// I initialize the keyboard shortcuts service.
	constructor( zone: NgZone ) {

		this.zone = zone;

		this.listeners = [];
		this.normalizedKeys = Object.create( null );

		// Since we're going to create a root event-handler for the keydown event, we're
		// gonna do this outside of the NgZone. This way, we're not constantly triggering
		// change-detection for every key event - we'll only re-enter the Angular Zone
		// when we have an event that is actually being consumed by one of our components.
		this.zone.runOutsideAngular(
			() : void => {

				window.addEventListener( "keydown", this.handleKeyboardEvent );

			}
		);

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I configure key-event listener at the given priority. Returns a Function that can
	// be used to unbind the listener.
	public listen( bindings: Bindings, options: ListenerOptions ) : Unlisten {

		var listener = this.addListener({
			priority: options.priority,
			terminal: this.normalizeTerminal( options.terminal ),
			terminalWhitelist: this.normalizeTerminalWhitelist( options.terminalWhitelist ),
			inputs: this.normalizeInputs( options.inputs ),
			bindings: this.normalizeBindings( bindings )
		});

		var unlisten = () : void => {

			this.removeListener( listener );

		};

		return( unlisten );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I add the listener to the internal collection in DESCENDING priority order.
	private addListener( listener: Listener ) : Listener {

		this.listeners.push( listener );
		this.listeners.sort(
			( a: Listener, b: Listener ) : number => {

				// We want to sort the listeners in DESCENDING priority order so that the
				// higher-priority items are at the start of the collection - this will
				// make it easier to loop over later (highest priority first).
				if ( a.priority < b.priority ) {

					return( 1 );

				} else if ( a.priority > b.priority ) {

					return( -1 );

				} else {

					return( 0 );

				}

			}
		);

		return( listener );

	}


	// I get the normalized event-key from the given event.
	// --
	// CAUTION: Most of this logic is taken from the core KeyEventsPlugin code but,
	// with some of the logic removed. This is simplified for the demo.
	private getKeyFromEvent( event: KeyboardEvent ) : string {

		var key = ( event.key || event[ "keyIdentifier" ] || "Unidentified" );

		if ( key.startsWith( "U+" ) ) {

			key = String.fromCharCode( parseInt( key.slice( 2 ), 16 ) );

		}

		var parts = [ KEY_MAP[ key ] || key ];

		if ( event.altKey ) parts.push( "Alt" );
		if ( event.ctrlKey ) parts.push( "Control" );
		if ( event.metaKey ) parts.push( "Meta" );
		if ( event.shiftKey ) parts.push( "Shift" );

		return( this.normalizeKey( parts.join( "." ) ) );

	}


	// I handle the keyboard events for the root handler (and delegate to the listeners).
	private handleKeyboardEvent = ( event: KeyboardEvent ) : void => {

		var key = this.getKeyFromEvent( event );
		var isInputEvent = this.isEventFromInput( event );
		var handler: Handler;

		// Iterate over the listeners in DESCENDING priority order.
		for ( var listener of this.listeners ) {

			if ( handler = listener.bindings[ key ] ) {

				// Execute handler if this is NOT an input event that we need to ignore.
				if ( ! isInputEvent || listener.inputs ) {

					// Right now, we're executing outside of the NgZone. As such, we
					// have to re-enter the NgZone so that we can hook back into change-
					// detection. Plus, this will also catch errors and propagate them
					// through application properly.
					var result = this.zone.runGuarded(
						() : boolean | void => {

							return( handler( event ) );

						}
					);

					// If the handler returned an explicit False, we're going to treat
					// this listener as Terminal, regardless of the original settings.
					if ( result === false ) {

						return;

					// If the handler returned an explicit True, we're going to treat
					// this listener as NOT Terminal, regardless of the original settings.
					} else if ( result === true ) {

						continue;

					}

				}

				// If this listener is terminal for matches, stop propagation.
				if ( listener.terminal === "match" ) {

					return;

				}

			}

			// If this listener is terminal for all events, stop propagation (unless the
			// event is white-listed for propagation).
			if ( ( listener.terminal === true ) && ! listener.terminalWhitelist[ key ] ) {

				return;

			}

		} // END: For-loop.

	}


	// I determine if the given event originated from a form input element.
	private isEventFromInput( event: KeyboardEvent ) : boolean {

		if ( event.target instanceof Node ) {

			switch ( event.target.nodeName ) {
				case "INPUT":
				case "SELECT":
				case "TEXTAREA":
					return( true );
					// @ts-ignore: TS7027: Unreachable code detected.
				break;
				default:
					return( false );
					// @ts-ignore: TS7027: Unreachable code detected.
				break;
			}

		}

		return( false );

	}


	// I return a bindings collection in which the keys of the given bindings have been
	// normalized into a predictable format.
	private normalizeBindings( bindings: Bindings ) : Bindings {

		var normalized = Object.create( null );

		for ( var key in bindings ) {

			normalized[ this.normalizeKey( key ) ] = bindings[ key ];

		}

		return( normalized );

	}


	// I normalize the inputs option.
	private normalizeInputs( inputs: boolean | undefined ) : boolean {

		if ( inputs === undefined ) {

			return( false );

		}

		return( inputs );

	}


	// I return the given key in a normalized, predictable format.
	private normalizeKey( key: string ) : string {

		if ( ! this.normalizedKeys[ key ] ) {

			this.normalizedKeys[ key ] = key
				.toLowerCase()
				.split( "." )
				.map(
					( segment ) : string => {

						return( KEY_ALIAS[ segment ] || segment );

					}
				)
				.sort()
				.join( "." )
			;

		}

		return( this.normalizedKeys[ key ] );

	}


	// I normalize the terminal option.
	private normalizeTerminal( terminal: Terminal | undefined ) : Terminal {

		if ( terminal === undefined ) {

			return( true );

		}

		return( terminal );

	}


	// I normalize the terminalWhitelist option.
	private normalizeTerminalWhitelist( keys: string[] | undefined ) : TerminalWhitelist {

		var normalized = Object.create( null );

		if ( keys ) {

			for ( var key of keys ) {

				normalized[ this.normalizeKey( key ) ] = true;

			}

		}

		return( normalized );

	}


	// I remove the given listener from the internal collection.
	private removeListener( listenerToRemove: Listener ) : void {

		this.listeners = this.listeners.filter(
			( listener: Listener ) : boolean => {

				return( listener !== listenerToRemove );

			}
		);

	}

}