declare module InputWritr {
    export interface IInputWritrTriggerCallback {
        (eventInformation: any, event: Event): void;
    }

    export interface IInputWritrTriggerContainer {
        [i: string]: {
            [i: string]: IInputWritrTriggerCallback;
            [i: number]: IInputWritrTriggerCallback;
        }
    }

    export interface IInputWriterBooleanGetter {
        (...args: any[]): boolean;
    }

    export interface IInputWritrSettings {
        /**
         * The mapping of events to their key codes, to their callbacks.
         */
        triggers: IInputWritrTriggerContainer;

        /**
         * The first argument to be passed to event callbacks.
         */
        eventInformation?: any;

        /**
         * Function to generate a current timestamp, commonly performance.now.
         */
        getTimestamp?: () => number;

        /**
         * Known, allowed aliases for triggers.
         */
        aliases?: { [i: string]: any[] };
        
        /**
         * A quick lookup table of key aliases to their character codes.
         */
        keyAliasesToCodes?: { [i: string]: number };
        
        /**
         * A quick lookup table of character codes to their key aliases.
         */
        keyCodesToAliases?: { [i: number]: string };

        /**
         * Whether events are initially allowed to trigger (by default, true).
         */
        canTrigger?: boolean | IInputWriterBooleanGetter;

        /**
         * Whether triggered inputs are initially allowed to be written to history
         * (by default, true).
         */
        isRecording?: boolean | IInputWriterBooleanGetter;
    }

    export interface IInputWritr {
        restartHistory(keepHistory?: boolean): void;
        getAliases(): any;
        getAliasesAsKeyStrings(): { [i: string]: any };
        getAliasAsKeyStrings(alias: any): string[];
        convertAliasToKeyString(alias: any): string;
        convertKeyStringToAlias(key: number | string): number | string;
        getHistory(name?: string): any;
        getHistories(): any;
        getCanTrigger(): IInputWriterBooleanGetter;
        getIsRecording(): IInputWriterBooleanGetter;
        setCanTrigger(canTriggerNew: boolean | IInputWriterBooleanGetter): void;
        setIsRecording(isRecordingNew: boolean | IInputWriterBooleanGetter): void;
        setEventInformation(eventInformationNew: any): void;
        addAliasValues(name: any, values: any[]): void;
        removeAliasValues(name: string, values: any[]): void;
        switchAliasValues(name: string, valuesOld: any[], valuesNew: any[]): void;
        addAliases(aliasesRaw: any): void;
        addEvent(trigger: string, label: any, callback: IInputWritrTriggerCallback): void;
        removeEvent(trigger: string, label: any): void;
        saveHistory(name?: string): void;
        playHistory(): void;
        playEvents(events: any): void;
        callEvent(event: Function | string, keyCode?: number | string, sourceEvent?: Event): any;
        makePipe(trigger: string, codeLabel: string, preventDefaults?: boolean): Function;
    }
}


module InputWritr {
    "use strict";

    /**
     * A general utility for automating interactions with user-called events linked
     * with callbacks. Pipe functions are available that take in user input, switch 
     * on the event code, and call the appropriate callback. These Pipe functions 
     * can be made during runtime; further utilities allow for saving and playback 
     * of input histories in JSON format.
     */
    export class InputWritr implements IInputWritr {
        /**
         * A mapping of events to their key codes, to their callbacks.
         */
        private triggers: IInputWritrTriggerContainer;

        /**
         * Known, allowed aliases for triggers.
         */
        private aliases: { [i: string]: any[] };

        /**
         * Recording of every action that has happened, with a timestamp.
         */
        private history: any;

        /**
         * A listing of all histories, with indices set by this.saveHistory.
         */
        private histories: any;

        /**
         * Function to generate a current timestamp, commonly performance.now.
         */
        private getTimestamp: () => number;

        /**
         * A starting time used for calculating playback delays in playHistory.
         */
        private startingTime: number;

        /**
         * An Object to be passed to event calls, commonly with key information,
         * such as { "Down": 0 }.
         */
        private eventInformation: any;

        /**
         * An optional Boolean callback to disable or enable input triggers.
         */
        private canTrigger: IInputWriterBooleanGetter;

        /**
         * Whether to record events into history.
         */
        private isRecording: IInputWriterBooleanGetter;

        /**
         * A quick lookup table of key aliases to their character codes.
         */
        private keyAliasesToCodes: { [i: string]: number };

        /**
         * A quick lookup table of character codes to their key aliases.
         */
        private keyCodesToAliases: { [i: number]: string };

        /**
         * @param {IInputWritrSettings} settings
         */
        constructor(settings: IInputWritrSettings) {
            if (!settings.triggers) {
                throw new Error("No triggers given to InputWritr.");
            }
            this.triggers = settings.triggers;

            // Headless browsers like PhantomJS might not contain the performance
            // class, so Date.now is used as a backup
            if (typeof settings.getTimestamp === "undefined") {
                if (typeof performance === "undefined") {
                    this.getTimestamp = function (): number {
                        return Date.now();
                    };
                } else {
                    this.getTimestamp = (
                        performance.now
                        || (<any>performance).webkitNow
                        || (<any>performance).mozNow
                        || (<any>performance).msNow
                        || (<any>performance).oNow
                        ).bind(performance);
                }
            } else {
                this.getTimestamp = settings.getTimestamp;
            }

            this.eventInformation = settings.eventInformation;

            this.canTrigger = settings.hasOwnProperty("canTrigger")
                ? <IInputWriterBooleanGetter>settings.canTrigger
                : function (): boolean {
                    return true;
                };
            this.isRecording = settings.hasOwnProperty("isRecording")
                ? <IInputWriterBooleanGetter>settings.isRecording
                : function (): boolean {
                    return true;
                };

            this.history = {};
            this.histories = {
                "length": 0
            };
            this.aliases = {};

            this.addAliases(settings.aliases || {});

            this.keyAliasesToCodes = settings.keyAliasesToCodes || {
                "shift": 16,
                "ctrl": 17,
                "space": 32,
                "left": 37,
                "up": 38,
                "right": 39,
                "down": 40
            };

            this.keyCodesToAliases = settings.keyCodesToAliases || {
                "16": "shift",
                "17": "ctrl",
                "32": "space",
                "37": "left",
                "38": "up",
                "39": "right",
                "40": "down"
            };
        }

        /**
         * Clears the currently tracked inputs history and resets the starting time,
         * and (optionally) saves the current history.
         * 
         * @param {Boolean} [keepHistory]   Whether the currently tracked history
         *                                  of inputs should be added to the master
         *                                  Array of histories (defaults to true).
         */
        restartHistory(keepHistory: boolean = true): void {
            if (keepHistory) {
                this.saveHistory(this.history);
            }

            this.history = {};
            this.startingTime = this.getTimestamp();
        }


        /* Simple gets
        */

        /** 
         * @return {Object} The stored mapping of aliases to values.
         */
        getAliases(): any {
            return this.aliases;
        }

        /**
         * @return {Object} The stored mapping of aliases to values, with values
         *                  mapped to their equivalent key Strings.
         */
        getAliasesAsKeyStrings(): { [i: string]: any } {
            var output: any = {},
                alias: string;

            for (alias in this.aliases) {
                if (this.aliases.hasOwnProperty(alias)) {
                    output[alias] = this.getAliasAsKeyStrings(alias);
                }
            }

            return output;
        }

        /**
         * @param {Mixed} alias   An alias allowed to be passed in, typically a
         *                        character code.
         * @return {String[]}   The mapped key Strings corresponding to that alias,
         *                      typically the human-readable Strings representing 
         *                      input names, such as "a" or "left".
         */
        getAliasAsKeyStrings(alias: any): string[] {
            return this.aliases[alias].map<string>(this.convertAliasToKeyString.bind(this));
        }

        /**
         * @param {Mixed} alias   The alias of an input, typically a character 
         *                        code.
         * @return {String} The human-readable String representing the input name,
         *                  such as "a" or "left".
         */
        convertAliasToKeyString(alias: any): string {
            if (alias.constructor === String) {
                return alias;
            }

            if (alias > 96 && alias < 105) {
                return String.fromCharCode(alias - 48);
            }

            if (alias > 64 && alias < 97) {
                return String.fromCharCode(alias);
            }

            return typeof this.keyCodesToAliases[alias] !== "undefined"
                ? this.keyCodesToAliases[alias] : "?";
        }

        /**
         * @param {Mixed} key   The number code of an input.
         * @return {Number} The machine-usable character code of the input.
         * 
         */
        convertKeyStringToAlias(key: number | string): number | string {
            if (key.constructor === Number) {
                return key;
            }

            if ((<string>key).length === 1) {
                return (<string>key).charCodeAt(0) - 32;
            }

            return typeof this.keyAliasesToCodes[<string>key] !== "undefined" ? this.keyAliasesToCodes[<string>key] : -1;
        }

        /**
         * Get function for a single history, either the current or a past one.
         * 
         * @param {String} [name]   The identifier for the old history to return. If
         *                          none is provided, the current history is used.
         * @return {Object}   A history of inputs in JSON-friendly form.
         */
        getHistory(name: string = undefined): any {
            return arguments.length ? this.histories[name] : history;
        }

        /**
         * @return {Object} All previously stored histories.
         */
        getHistories(): any {
            return this.histories;
        }

        /**
         * @return {Boolean} Whether this is currently allowing inputs.
         */
        getCanTrigger(): IInputWriterBooleanGetter {
            return this.canTrigger;
        }

        /**
         * @return {Boolean} Whether this is currently recording allowed inputs.
         */
        getIsRecording(): IInputWriterBooleanGetter {
            return this.isRecording;
        }


        /* Simple sets
        */

        /**
         * @param {Mixed} canTriggerNew   Whether this is now allowing inputs. This 
         *                                may be either a Function (to be evaluated 
         *                                on each input) or a general Boolean.
         */
        setCanTrigger(canTriggerNew: boolean | IInputWriterBooleanGetter): void {
            if (canTriggerNew.constructor === Boolean) {
                this.canTrigger = function (): boolean {
                    return <boolean>canTriggerNew;
                };
            } else {
                this.canTrigger = <IInputWriterBooleanGetter>canTriggerNew;
            }
        }

        /**
         * @param {Boolean} isRecordingNew   Whether this is now recording allowed
         *                                   inputs.    
         */
        setIsRecording(isRecordingNew: boolean | IInputWriterBooleanGetter): void {
            if (isRecordingNew.constructor === Boolean) {
                this.isRecording = function (): boolean {
                    return <boolean>isRecordingNew;
                };
            } else {
                this.isRecording = <IInputWriterBooleanGetter>isRecordingNew;
            }
        }

        /**
         * @param {Mixed} eventInformationNew   A new first argument for event 
         *                                      callbacks.
         */
        setEventInformation(eventInformationNew: any): void {
            this.eventInformation = eventInformationNew;
        }


        /* Aliases
        */

        /**
         * Adds a list of values by which an event may be triggered.
         * 
         * @param {String} name   The name of the event that is being given 
         *                        aliases, such as "left".
         * @param {Array} values   An array of aliases by which the event will also
         *                         be callable.
         */
        addAliasValues(name: any, values: any[]): void {
            var triggerName: any,
                triggerGroup: any,
                i: number;

            if (!this.aliases.hasOwnProperty(name)) {
                this.aliases[name] = values;
            } else {
                this.aliases[name].push.apply(this.aliases[name], values);
            }

            // triggerName = "onkeydown", "onkeyup", ...
            for (triggerName in this.triggers) {
                if (this.triggers.hasOwnProperty(triggerName)) {
                    // triggerGroup = { "left": function, ... }, ...
                    triggerGroup = this.triggers[triggerName];

                    if (triggerGroup.hasOwnProperty(name)) {
                        // values[i] = 37, 65, ...
                        for (i = 0; i < values.length; i += 1) {
                            triggerGroup[values[i]] = triggerGroup[name];
                        }
                    }
                }
            }
        }

        /**
         * Removes a list of values by which an event may be triggered.
         * 
         * @param {String} name   The name of the event that is having aliases
         *                        removed, such as "left".
         * @param {Array} values   An array of aliases by which the event will no
         *                         longer be callable.
         */
        removeAliasValues(name: string, values: any[]): void {
            var triggerName: any,
                triggerGroup: any,
                i: number;

            if (!this.aliases.hasOwnProperty(name)) {
                return;
            }

            for (i = 0; i < values.length; i += 1) {
                this.aliases[name].splice(this.aliases[name].indexOf(values[i], 1));
            }

            // triggerName = "onkeydown", "onkeyup", ...
            for (triggerName in this.triggers) {
                if (this.triggers.hasOwnProperty(triggerName)) {
                    // triggerGroup = { "left": function, ... }, ...
                    triggerGroup = this.triggers[triggerName];

                    if (triggerGroup.hasOwnProperty(name)) {
                        // values[i] = 37, 65, ...
                        for (i = 0; i < values.length; i += 1) {
                            if (triggerGroup.hasOwnProperty(values[i])) {
                                delete triggerGroup[values[i]];
                            }
                        }
                    }
                }
            }
        }

        /**
         * Shortcut to remove old alias values and add new ones in.
         * 
         * 
         * @param {String} name   The name of the event that is having aliases
         *                        added and removed, such as "left".
         * @param {Array} valuesOld   An array of aliases by which the event will no
         *                            longer be callable.
         * @param {Array} valuesNew   An array of aliases by which the event will 
         *                            now be callable.
         */
        switchAliasValues(name: string, valuesOld: any[], valuesNew: any[]): void {
            this.removeAliasValues(name, valuesOld);
            this.addAliasValues(name, valuesNew);
        }

        /**
         * Adds a set of alises from an Object containing "name" => [values] pairs.
         * 
         * @param {Object} aliasesRaw
         */
        addAliases(aliasesRaw: any): void {
            var aliasName: string;

            for (aliasName in aliasesRaw) {
                if (aliasesRaw.hasOwnProperty(aliasName)) {
                    this.addAliasValues(aliasName, aliasesRaw[aliasName]);
                }
            }
        }


        /* Functions
        */

        /**
         * Adds a triggerable event by marking a new callback under the trigger's
         * triggers. Any aliases for the label are also given the callback.
         * 
         * @param {String} trigger   The name of the triggered event.
         * @param {Mixed} label   The code within the trigger to call within, 
         *                        typically either a character code or an alias.
         * @param {Function} callback   The callback Function to be triggered.
         */
        addEvent(trigger: string, label: any, callback: IInputWritrTriggerCallback): void {
            var i: number;

            if (!this.triggers.hasOwnProperty(trigger)) {
                throw new Error("Unknown trigger requested: '" + trigger + "'.");
            }

            this.triggers[trigger][label] = callback;

            if (this.aliases.hasOwnProperty(label)) {
                for (i = 0; i < this.aliases[label].length; i += 1) {
                    this.triggers[trigger][this.aliases[label][i]] = callback;
                }
            }
        }

        /**
         * Removes a triggerable event by deleting any callbacks under the trigger's
         * triggers. Any aliases for the label are also given the callback.
         * 
         * @param {String} trigger   The name of the triggered event.
         * @param {Mixed} label   The code within the trigger to call within, 
         *                        typically either a character code or an alias.
         */
        removeEvent(trigger: string, label: any): void {
            var i: number;

            if (!this.triggers.hasOwnProperty(trigger)) {
                throw new Error("Unknown trigger requested: '" + trigger + "'.");
            }

            delete this.triggers[trigger][label];

            if (this.aliases.hasOwnProperty(label)) {
                for (i = 0; i < this.aliases[label].length; i += 1) {
                    if (this.triggers[trigger][this.aliases[label][i]]) {
                        delete this.triggers[trigger][this.aliases[label][i]];
                    }
                }
            }
        }

        /**
         * Stores the current history in the histories listing. this.restartHistory 
         * is typically called directly after.
         */
        saveHistory(name: string = undefined): void {
            this.histories[this.histories.length] = history;
            this.histories.length += 1;

            if (arguments.length) {
                this.histories[name] = history;
            }
        }

        /**
         * Plays back the current history using this.playEvents.
         */
        playHistory(): void {
            this.playEvents(this.history);
        }

        /**
         * "Plays" back an Array of event information by simulating each keystroke
         * in a new call, timed by setTimeout.
         * 
         * @param {Object} events   The events history to play back.
         * @remarks This will execute the same actions in the same order as before,
         *          but the arguments object may be different.
         */
        playEvents(events: any): void {
            var timeouts: any = {},
                time: string,
                call: Function;

            for (time in events) {
                if (events.hasOwnProperty(time)) {
                    call = this.makeEventCall(events[time]);
                    timeouts[time] = setTimeout(call, (Number(time) - this.startingTime) | 0);
                }
            }
        }

        /**
         * Primary driver function to run an event. The event is chosen from the
         * triggers object, and called with eventInformation as the input.
         * 
         * @param {Function, String} event   The event function (or string alias of
         *                                   it) that will be called.
         * @param {Mixed} [keyCode]   The alias of the event function under
         *                            triggers[event], if event is a String.
         * @param {Event} [sourceEvent]   The raw event that caused the calling Pipe
         *                                to be triggered, such as a MouseEvent.
         * @return {Mixed}
         */
        callEvent(event: Function | string, keyCode?: number | string, sourceEvent?: Event): any {
            if (!this.canTrigger(event, keyCode)) {
                return;
            }

            if (!event) {
                throw new Error("Blank event given, ignoring it.");
            }

            if (event.constructor === String) {
                event = this.triggers[<string>event][<string>keyCode];
            }

            return (<any>event)(this.eventInformation, sourceEvent);
        }

        /**
         * Creates and returns a function to run a trigger.
         * 
         * @param {String} trigger   The label for the Array of functions that the
         *                           pipe function should choose from.
         * @param {String} codeLabel   A mapping String for the alias to get the
         *                             alias from the event.
         * @param {Boolean} [preventDefaults]   Whether the input to the pipe
         *                                       function will be an HTML-style
         *                                       event, where .preventDefault()
         *                                       should be clicked.
         * @return {Function}
         */
        makePipe(trigger: string, codeLabel: string, preventDefaults?: boolean): Function {
            var functions: any = this.triggers[trigger],
                InputWriter: InputWritr = this;

            if (!functions) {
                throw new Error("No trigger of label '" + trigger + "' defined.");
            }

            return function Pipe(event: Event): void {
                var alias: any = event[codeLabel];

                // Typical usage means alias will be an event from a key/mouse input
                if (preventDefaults && event.preventDefault instanceof Function) {
                    event.preventDefault();
                }

                // If there's a function under that alias, run it
                if (functions.hasOwnProperty(alias)) {
                    if (InputWriter.isRecording()) {
                        InputWriter.history[InputWriter.getTimestamp() | 0] = [trigger, alias];
                    }

                    InputWriter.callEvent(functions[alias], <number>alias, event);
                }
            };
        }

        /**
         * Curry utility to create a closure that runs call() when called.
         * 
         * @param {Array} info   An array containing [alias, keyCode].
         * @return {Function} A closure Function that activates a trigger
         *                    when called.
         */
        private makeEventCall(info: any[]): Function {
            return function (): void {
                this.callEvent(info[0], info[1]);
            };
        }
    }
}
