/**
 * Handles mapping key events to handling functions for an element or a Component.
 * One KeyMap can be used for multiple actions.
 *
 * A KeyMap must be configured with a {@link #target} as an event source which may be
 * an Element or a Component.
 *
 * If the target is an element, then the `keydown` event will trigger the invocation
 * of {@link #binding}s.
 *
 * It is possible to configure the KeyMap with a custom {@link #eventName} to listen for.
 * This may be useful when the {@link #target} is a Component.
 *
 * The KeyMap's event handling requires that the first parameter passed is a key event.
 * So if the Component's event signature is different, specify a {@link #processEvent}
 * configuration which accepts the event's parameters and returns a key event.
 *
 * Functions specified in {@link #binding}s are called with this signature:
 * `(String key, Ext.event.Event e)` (if the match is a multi-key combination
 * the callback will still be called only once). A KeyMap can also handle a string
 * representation of keys. By default KeyMap starts enabled.
 *
 * Usage:
 *
 *      // map one key by key code
 *      var map = new Ext.util.KeyMap({
 *          target: "my-element",
 *          key: 13, // or Ext.event.Event.ENTER
 *          handler: myHandler,
 *          scope: myObject
 *      });
 *
 *      // map multiple keys to one action by string
 *      var map = new Ext.util.KeyMap({
 *          target: "my-element",
 *          key: "a\r\n\t",
 *          handler: myHandler,
 *          scope: myObject
 *      });
 *
 *      // map multiple keys to multiple actions by strings and array of codes
 *      var map = new Ext.util.KeyMap({
 *          target: "my-element",
 *          binding: [{
 *              key: [10, 13],
 *              handler: function() {
 *                  alert("Return was pressed");
 *              }
 *          }, {
 *              key: "abc",
 *              handler: function() {
 *                  alert('a, b or c was pressed');
 *              }
 *          }, {
 *              key: "\t",
 *              ctrl: true,
 *              shift: true,
 *              handler: function() {
 *                  alert('Control + shift + tab was pressed.');
 *              }
 *          }]
 *      });
 *
 * KeyMaps can also bind to Components and process key-based events fired by Components.
 *
 * To bind to a Component, include the Component event name to listen for, and a `processEvent`
 * implementation which returns the key event for further processing by the KeyMap:
 *
 *      var map = new Ext.util.KeyMap({
 *          target: myGridView,
 *          eventName: 'itemkeydown',
 *          processEvent: function(view, record, node, index, event) {
 *              // Load the event with the extra information needed by the mappings
 *              event.view = view;
 *              event.store = view.getStore();
 *              event.record = record;
 *              event.index = index;
 *              return event;
 *          },
 *          binding: {
 *              key: Ext.event.Event.DELETE,
 *              handler: function(keyCode, e) {
 *                  e.store.remove(e.record);
 *                  
 *                  // Attempt to select the record that's now in its place
 *                  e.view.getSelectionModel().select(e.index);
 *              }
 *          }
 *      });
 */
Ext.define('Ext.util.KeyMap', {
    alternateClassName: 'Ext.KeyMap',
 
    /**
     * @property {Ext.event.Event} lastKeyEvent
     * The last key event that this KeyMap handled.
     */
 
    /**
     * @cfg {Ext.Component/Ext.dom.Element/HTMLElement/String} target
     * The object on which to listen for the event specified by the {@link #eventName}
     * config option.
     */
 
    /**
     * @cfg {Object/Object[][]} binding
     * Either a single object describing a handling function for s specified key (or set of keys),
     * or an array of such objects.
     * @cfg {String/String[]} binding.key A single keycode or an array of keycodes to handle, or
     * a RegExp which specifies characters to handle, eg `/[a-z]/`.
     * @cfg {Boolean} binding.shift `true` to handle key only when shift is pressed, `false`
     * to handle the key only when shift is not pressed (defaults to undefined)
     * @cfg {Boolean} binding.ctrl `true` to handle key only when ctrl is pressed, `false`
     * to handle the key only when ctrl is not pressed (defaults to undefined)
     * @cfg {Boolean} binding.alt `true` to handle key only when alt is pressed, `false`
     * to handle the key only when alt is not pressed (defaults to undefined)
     * @cfg {Function} binding.handler The function to call when KeyMap finds the expected
     * key combination
     * @cfg {Function} binding.fn Alias of handler (for backwards-compatibility)
     * @cfg {Object} binding.scope The scope (`this` context) in which the handler function
     * is executed.
     * @cfg {String} binding.defaultEventAction A default action to apply to the event
     * *when the handler returns `true`*. Possible values are: stopEvent, stopPropagation,
     * preventDefault. If no value is set no action is performed.
     */
 
    /**
     * @cfg {Object} [processEventScope=this]
     * The scope (`this` context) in which the {@link #processEvent} method is executed.
     */
 
    /**
     * @cfg {Boolean} [ignoreInputFields=false]
     * Configure this as `true` if there are any input fields within the {@link #target},
     * and this KeyNav should not process events from input fields
     * (`<input>`,`<textarea> and elements with `contentEditable="true"`)
     */
 
    /**
     * @cfg {Number} [priority]
     * The priority to set on this KeyMap's listener. Listeners with a higher priority
     * are fired before those with lower priority.
     */
 
    /**
     * @cfg {String} eventName
     * The event to listen for to pick up key events.
     */
    eventName: 'keydown',
 
    constructor: function(config) {
        var me = this;
 
        // Handle legacy arg list in which the first argument is the target.
        // TODO: Deprecate in V5
        //<debug>
        if ((arguments.length !== 1) || (typeof config === 'string') || config.dom ||
            config.tagName || config === document || config.isComponent) {
            Ext.raise("Legacy multi-argument KeyMap constructor is removed. " +
                      "Use a config object instead.");
        }
        //</debug>
 
        Ext.apply(me, config);
        me.bindings = [];
 
        if (!me.target.isComponent) {
            me.target = Ext.get(me.target);
        }
 
        if (me.binding) {
            me.addBinding(me.binding);
        }
        else if (config.key) {
            me.addBinding(config);
        }
 
        me.enable();
    },
 
    /**
     * Add a new binding to this KeyMap.
     *
     * Usage:
     *
     *      // Create a KeyMap
     *      var map = new Ext.util.KeyMap({
     *          target: Ext.getDoc(),
     *          key: Ext.event.Event.ENTER,
     *          handler: handleKey
     *      });
     *
     *      // Add a new binding to the existing KeyMap later
     *      map.addBinding({
     *          key: 'abc',
     *          shift: true,
     *          handler: handleKey
     *      });
     *
     * @param {Object/Object[]} binding A single KeyMap config or an array of configs.
     * The following config object properties are supported:
     *
     * @param {String/Array} binding.key A single keycode or an array of keycodes to handle,
     * or a RegExp which specifies characters to handle, eg `/[a-z]/`.
     * @param {Boolean} binding.shift `true` to handle key only when shift is pressed,
     * `false` to handle the key only when shift is not pressed (defaults to undefined).
     * @param {Boolean} binding.ctrl `true` to handle key only when ctrl is pressed,
     * `false` to handle the key only when ctrl is not pressed (defaults to undefined).
     * @param {Boolean} binding.alt `true` to handle key only when alt is pressed,
     * `false` to handle the key only when alt is not pressed (defaults to undefined).
     * @param {Function} binding.handler The function to call when KeyMap finds the
     * expected key combination.
     * @param {Function} binding.fn Alias of handler (for backwards-compatibility).
     * @param {Object} binding.scope The scope (`this` context) in which the handler function
     * is executed.
     * @param {String} binding.defaultEventAction A default action to apply to the event
     * *when the handler returns `true`*. Possible values are: stopEvent, stopPropagation,
     * preventDefault. If no value is set no action is performed.
     */
    addBinding: function(binding) {
        var me = this,
            keyCode = binding.key,
            i, len;
 
        if (me.processing) {
            me.bindings = me.bindings.slice(0);
        }
 
        if (Ext.isArray(binding)) {
            for (= 0, len = binding.length; i < len; i++) {
                me.addBinding(binding[i]);
            }
 
            return;
        }
 
        me.bindings.push(Ext.apply({
            keyCode: me.processKeys(keyCode)
        }, binding));
    },
 
    /**
     * Remove a binding from this KeyMap.
     * @param {Object} binding See {@link #addBinding for options}
     */
    removeBinding: function(binding) {
        var me = this,
            bindings = me.bindings,
            len = bindings.length,
            i, item, keys;
 
        if (me.processing) {
            me.bindings = bindings.slice(0);
        }
 
        keys = me.processKeys(binding.key);
 
        for (= 0; i < len; i++) {
            item = bindings[i];
 
            if ((item.fn || item.handler) === (binding.fn || binding.handler) &&
                item.scope === binding.scope) {
                if (binding.alt === item.alt && binding.ctrl === item.ctrl &&
                    binding.shift === item.shift) {
                    if (Ext.Array.equals(item.keyCode, keys)) {
                        Ext.Array.erase(me.bindings, i, 1);
 
                        return;
                    }
                }
            }
        }
    },
 
    processKeys: function(keyCode) {
        var processed = false,
            key, keys, keyString, len, i;
 
        // A RegExp to match typed characters
        if (keyCode.test) {
            return keyCode;
        }
 
        // A String of characters to match
        if (Ext.isString(keyCode)) {
            keys = [];
            keyString = keyCode.toUpperCase();
 
            for (= 0, len = keyString.length; i < len; i++) {
                keys.push(keyString.charCodeAt(i));
            }
 
            keyCode = keys;
            processed = true;
        }
 
        // Numeric key code
        if (!Ext.isArray(keyCode)) {
            keyCode = [keyCode];
        }
 
        if (!processed) {
            for (= 0, len = keyCode.length; i < len; i++) {
                key = keyCode[i];
 
                if (Ext.isString(key)) {
                    keyCode[i] = key.toUpperCase().charCodeAt(0);
                }
            }
        }
 
        return keyCode;
    },
 
    /**
     * Process the {@link #eventName event} from the {@link #target}.
     * @private
     * @param {Ext.event.Event} event 
     */
    handleTargetEvent: function(event) {
        var me = this,
            bindings, i, len, result;
 
        if (me.enabled) {
            bindings = me.bindings;
 
            // Process the event
            event = me.processEvent.apply(me.processEventScope || me, arguments);
 
            // A custom processEvent implementation may return falsy to stop the KeyMap's processing
            if (event) {
                me.lastKeyEvent = event;
 
                // Ignore events from input fields if configured to do so
                if (me.ignoreInputFields && Ext.fly(event.target).isInputField()) {
                    return;
                }
 
                // If the processor does not return a keyEvent, we can't process it.
                // Allow them to return false to cancel processing of the event
                if (!event.getKey) {
                    return event;
                }
 
                me.processing = true;
 
                for (= 0, len = bindings.length; i < len; i++) {
                    result = me.processBinding(bindings[i], event);
 
                    if (result === false) {
                        me.processing = false;
 
                        return result;
                    }
                }
 
                me.processing = false;
            }
        }
    },
 
    /**
     * @cfg {Function} processEvent
     * An optional event processor function which accepts the argument list provided by the
     * {@link #eventName configured event} of the {@link #target}, and returns a keyEvent
     * for processing by the KeyMap.
     *
     * This may be useful when the {@link #target} is a Component with a complex event signature,
     * where the event is not the first parameter. Extra information from the event arguments
     * may be injected into the event for use by the handler functions before returning it.
     *
     * If `null` is returned the KeyMap stops processing the event.
     */
    processEvent: Ext.identityFn,
 
    /**
     * Process a particular binding and fire the handler if necessary.
     * @private
     * @param {Object} binding The binding information
     * @param {Ext.event.Event} event 
     */
    processBinding: function(binding, event) {
        if (this.checkModifiers(binding, event)) {
            // eslint-disable-next-line vars-on-top
            var key = event.getKey(),
                handler = binding.fn || binding.handler,
                scope = binding.scope || this,
                keyCode = binding.keyCode,
                defaultEventAction = binding.defaultEventAction,
                i, len, result;
 
            // keyCode is a regExp specifying acceptable characters. eg /[a-z]/
            if (keyCode.test) {
                if (keyCode.test(String.fromCharCode(event.getCharCode()))) {
                    result = handler.call(scope, key, event);
 
                    if (result !== true && defaultEventAction) {
                        event[defaultEventAction]();
                    }
 
                    if (result === false) {
                        return result;
                    }
                }
            }
            // Array of key codes
            else if (keyCode.length) {
                for (= 0, len = keyCode.length; i < len; i++) {
                    if (key === keyCode[i]) {
                        result = handler.call(scope, key, event);
 
                        if (result !== true && defaultEventAction) {
                            event[defaultEventAction]();
                        }
 
                        if (result === false) {
                            return result;
                        }
 
                        break;
                    }
                }
            }
        }
    },
 
    /**
     * Check if the modifiers on the event match those on the binding
     * @private
     * @param {Object} binding 
     * @param {Ext.event.Event} event 
     * @return {Boolean} True if the event matches the binding
     */
    checkModifiers: function(binding, event) {
        var keys = ['shift', 'ctrl', 'alt'],
            i, len, val, key;
 
        for (= 0, len = keys.length; i < len; i++) {
            key = keys[i];
            val = binding[key];
 
            if (!(val === undefined || (val === event[key + 'Key']))) {
                return false;
            }
        }
 
        return true;
    },
 
    /**
     * Shorthand for adding a single key listener.
     *
     * @param {Number/Number[]/Object} key Either the numeric key code, array of key codes
     * or an object with the following options: `{key: (number or array), shift: (true/false),
     * ctrl: (true/false), alt: (true/false)}`
     * @param {Function} fn The function to call
     * @param {Object} [scope] The scope (`this` reference) in which the function is executed.
     * Defaults to the browser window.
     */
    on: function(key, fn, scope) {
        var keyCode, shift, ctrl, alt;
 
        if (Ext.isObject(key) && !Ext.isArray(key)) {
            keyCode = key.key;
            shift = key.shift;
            ctrl = key.ctrl;
            alt = key.alt;
        }
        else {
            keyCode = key;
        }
 
        this.addBinding({
            key: keyCode,
            shift: shift,
            ctrl: ctrl,
            alt: alt,
            fn: fn,
            scope: scope
        });
    },
 
    /**
     * Shorthand for removing a single key listener.
     *
     * @param {Number/Number[]/Object} key Either the numeric key code, array of key codes
     * or an object with the following options: `{key: (number or array), shift: (true/false),
     * ctrl: (true/false), alt: (true/false)}`
     * @param {Function} fn The function to call
     * @param {Object} [scope] The scope (`this` reference) in which the function is executed.
     * Defaults to the browser window.
     */
    un: function(key, fn, scope) {
        var keyCode, shift, ctrl, alt;
 
        if (Ext.isObject(key) && !Ext.isArray(key)) {
            keyCode = key.key;
            shift = key.shift;
            ctrl = key.ctrl;
            alt = key.alt;
        }
        else {
            keyCode = key;
        }
 
        this.removeBinding({
            key: keyCode,
            shift: shift,
            ctrl: ctrl,
            alt: alt,
            fn: fn,
            scope: scope
        });
    },
 
    /**
     * Returns true if this KeyMap is enabled
     * @return {Boolean} 
     */
    isEnabled: function() {
        return !!this.enabled;
    },
 
    /**
     * Enables this KeyMap
     */
    enable: function() {
        var me = this;
 
        if (!me.enabled) {
            me.target.on(me.eventName, me.handleTargetEvent, me, {
                capture: me.capture,
                priority: me.priority
            });
 
            me.enabled = true;
        }
    },
 
    /**
     * Disable this KeyMap
     */
    disable: function() {
        var me = this;
 
        if (me.enabled) {
            if (!me.target.destroyed) {
                me.target.removeListener(me.eventName, me.handleTargetEvent, me);
            }
 
            me.enabled = false;
        }
    },
 
    /**
     * Convenience function for setting disabled/enabled by boolean.
     * @param {Boolean} disabled 
     */
    setDisabled: function(disabled) {
        if (disabled) {
            this.disable();
        }
        else {
            this.enable();
        }
    },
 
    /**
     * Destroys the KeyMap instance and removes all handlers.
     * @param {Boolean} removeTarget True to also remove the {@link #target}
     */
    destroy: function(removeTarget) {
        var me = this,
            target = me.target;
 
        me.bindings = [];
        me.disable();
        me.target = null;
 
        if (removeTarget) {
            target.destroy();
            Ext.raise("Using removeTarget argument in KeyMap destructor is not supported.");
        }
 
        me.callParent();
    }
});