/**
 * A mixin for components that need to interact with the keyboard. The primary config
 * for this class is the `{@link #keyMap keyMap}` config. This config is an object
 * with key names as its properties and with values that describe how the key event
 * should be handled.
 *
 * Key names may key name as documented in `Ext.event.Event`, numbers (which are treated
 * as `keyCode` values), single characters (for those that are not defined in
 * `Ext.event.Event`) or `charCode` values prefixed by '#' (e.g., "#65" for `charCode=65`).
 *
 * Entries that use a `keyCode` will be processed in a `keydown` event listener, while
 * those that use a `charCode` will be processed in `keypress`. This can be overridden
 * if the `keyMap` entry specifies an `event` property.
 *
 * Key names may be preceded by key modifiers. The modifier keys can be specified
 * by prepending the modifier name to the key name separated by `+` or `-` (e.g.,
 * "Ctrl+A" or "Ctrl-A"). Only one of these delimiters can be used in a given
 * entry.
 *
 * Valid modifier names are:
 *
 *  - Alt
 *  - Shift
 *  - Control (or "Ctrl" for short)
 *  - Command (or "Cmd" or "Meta")
 *  - CommandOrControl (or "CmdOrCtrl") for Cmd on Mac, Ctrl otherwise.
 *
 * *All these names are case insensitive and will be stored in upper case internally.*
 *
 * For example:
 *
 *      Ext.define('MyChartPanel', {
 *          extend: 'Ext.panel.Panel',
 *
 *          mixins: [
 *              'Ext.mixin.Keyboard'
 *          ],
 *
 *          controller: 'mycontroller',
 *
 *          // Map keys to methods (typically in a ViewController):
 *          keyMap: {
 *              ENTER: 'onEnterKey',
 *
 *              "ALT+PRINT_SCREEN": 'doScreenshot',
 *
 *              // Cmd on Mac OS X, Ctrl on Windows/Linux.
 *              "CmdOrCtrl+C": 'doCopy',
 *
 *              // This one is handled by a class method.
 *              ESC: {
 *                  handler: 'destroy',
 *                  scope: 'this',
 *                  event: 'keypress'  // default would be keydown
 *              },
 *
 *              "ALT+DOWN": 'openExpander',
 *
 *              // Match any key modifiers and invoke before any other DOWN keys
 *              // handlers with lower or default priority.
 *              "*+DOWN": {
 *                  handler: 'preprocessDownKey',
 *                  priority: 100
 *              }
 *          }
 *      });
 *
 * The method names are interpreted in the same way that event listener names are
 * interpreted.
 *
 * @since 6.2.0
 */
Ext.define('Ext.mixin.Keyboard', function(Keyboard) { return { // eslint-disable-line brace-style
    extend: 'Ext.Mixin',
    
    mixinConfig: {
        id: 'keyboard'
    },
 
    /**
     * @property {Ext.event.Event} lastKeyMapEvent
     * The last key event processed is cached on the component for use in subsequent
     * event handlers.
     * @since 6.6.0
     */
 
    config: {
        /**
         * @cfg {Object} keyMap
         * An object containing handlers for keyboard events. The property names of this
         * object are the key name and any modifiers. The values of the properties are the
         * descriptors of how to handle each event.
         *
         * The handler descriptor can be simply the handler function(either the
         * literal function or the method name), or it can be an object with these
         * properties:
         *
         *  - `handler`: The function or its name to call to handle the event.
         *  - `scope`: The this pointer context (can be "this" or "controller").
         *  - `event`: An optional override of the key event to which to listen.
         *
         * **Important:** Calls to `setKeyMap` do not replace the entire `keyMap` but
         * instead update the provided mappings. That is, unless `null` is passed as the
         * value of the `keyMap` which will clear the `keyMap` of all entries.
         *
         * @cfg {String} [keyMap.scope] The default scope to apply to key handlers
         * which do not specify a scope. This is processed the same way as the scope of
         * {@link #cfg-listeners}. It defaults to the `"controller"`, but using `'this'`
         * means that an instance method will be used.
         */
        keyMap: {
            $value: null,
            cached: true,
 
            merge: function(value, baseValue, cls, mixin) {
                var ret, key, ucKey, v, vs;
                
                // Allow nulling out parent class config
                if (value === null) {
                    return value;
                }
 
                // We promote all values into objects but these objects do not get
                // merged with base class values. Further, the keys get toUpperCased
                // to normalize this aspect ('esc' vs 'ESC' vs 'Esc'). We do not want
                // to overwrite a class baseValue with an instances value since those
                // are additive (in applyKeyMap/combineKeyMaps).
                ret = (baseValue && !cls.isInstance) ? Ext.Object.chain(baseValue) : {};
                
                for (key in value) {
                    if (key !== 'scope') {
                        ucKey = key.toUpperCase();
 
                        if (!mixin || ret[ucKey] === undefined) {
                            // Promote to an object so we can always store the scope.
                            v = value[key];
                            
                            if (v) {
                                if (typeof v === 'string' || typeof v === 'function') {
                                    v = {
                                        handler: v
                                    };
                                }
                                else {
                                    v = Ext.apply({
                                        handler: v.fn // overwritten by v.handler
                                    }, v);
                                }
 
                                vs = v.scope || value.scope || 'self';
 
                                v.scope = (vs === 'controller') ? 'self.controller' : vs;
                            }
 
                            ret[ucKey] = v;
                        }
                    }
                }
 
                return ret;
            }
        },
 
        /**
         * @cfg {Boolean} keyMapEnabled
         * Enables or disables processing keys in the `keyMap`. This value starts as
         * `null` and if it is `null` when `initKeyMap` is called, it will automatically
         * be set to `true`. Since `initKeyMap` is called by `Ext.Component` at the
         * proper time, this is not something application code normally handles.
         */
        keyMapEnabled: null
    },
 
    /**
     * @cfg {String} keyMapTarget
     * The name of the member that should be used to listen for keydown/keypress events.
     * This is intended to be controlled at the class level not per instance.
     * @protected
     */
    keyMapTarget: 'el',
 
    applyKeyMap: function(keyMap, existingKeyMap) {
        var me = this,
            // During cached config setup, we don't yet have our own (instance) "config"
            // so we can tell from that being present that we need our own keyMap.
            own = me.hasOwnProperty('config');
 
        if (own && existingKeyMap && existingKeyMap.$owner !== me) {
            // As a cached config, we can be created with an existing value, but
            // we do not want to modify that shared instance, so make a copy.
            existingKeyMap = Ext.apply({}, existingKeyMap);
        }
 
        keyMap = keyMap ? Keyboard.combineKeyMaps(existingKeyMap, keyMap, own && me) : null;
 
        if (me._keyMapReady) {
            me.setKeyMapListener(keyMap && me.getKeyMapEnabled());
        }
 
        return keyMap;
    },
 
    /**
     * This method should be called when the instance is ready to start listening for
     * keyboard events. This is called automatically for `Ext.Component` and derived
     * classes. This is done after the component is rendered.
     * @protected
     */
    initKeyMap: function() {
        var me = this,
            enabled = me.getKeyMapEnabled();
 
        me._keyMapReady = true;
 
        if (enabled === null) {
            me.setKeyMapEnabled(true);
        }
        else {
            me.setKeyMapListener(enabled && me.getKeyMap());
        }
    },
 
    disableKeyMapGroup: function(group) {
        this.setKeyMapGroupEnabled(group, false);
    },
 
    enableKeyMapGroup: function(group) {
        this.setKeyMapGroupEnabled(group, true);
    },
 
    setKeyMapGroupEnabled: function(group, state) {
        var me = this,
            disabledGroups = me.disabledKeyMapGroups || (me.disabledKeyMapGroups = {});
 
        disabledGroups[group] = !state;
    },
 
    updateKeyMapEnabled: function(enabled) {
        this.setKeyMapListener(enabled && this._keyMapReady && this.getKeyMap());
    },
 
    privates: {
        //<debug>
        _keyMapListenCount: 0,
        //</debug>
        _keyMapReady: false,
 
        // Descending priority sort
        comparePriorities: function(lhs, rhs) {
            return (rhs.priority || 0) - (lhs.priority || 0);
        },
 
        findKeyMapEntries: function(e) {
            var me = this,
                disabledGroups = me.disabledKeyMapGroups,
                keyMap = me.getKeyMap(),
                entries = keyMap && Keyboard.getKeyName(e),
                result = [],
                entry, len, i;
 
            entries = entries && keyMap[entries];
 
            if (entries) {
                // Ensure that the entries are in priority order
                if (!entries.sorted) {
                    Ext.Array.sort(entries, me.comparePriorities);
                    entries.sorted = true;
                }
                
                len = entries.length;
 
                for (= 0; i < len; i++) {
                    entry = entries[i];
 
                    // If the key code and the modifier flags match, add entry
                    // to invocation list.
                    if (!disabledGroups || !disabledGroups[entry.group]) {
                        if (Keyboard.matchEntry(entry, e)) {
                            result.push(entry);
                        }
                    }
                }
            }
 
            return result;
        },
 
        onKeyMapEvent: function(e) {
            var me = this,
                entries = me.getKeyMapEnabled() ? me.findKeyMapEntries(e) : null,
                len = entries && entries.length,
                i, entry, result;
 
            me.lastKeyMapEvent = e;
 
            for (= 0; i < len && result !== false; i++) {
                entry = entries[i];
                result = Ext.callback(entry.handler, entry.scope, [e, this], 0, this);
            }
            
            return result;
        },
 
        setKeyMapListener: function(enabled) {
            var me = this,
                listener = me._keyMapListener,
                eventSource;
 
            if (listener) {
                // We always destroy the old listener since the eventSource could be
                // different now...
                listener.destroy();
                listener = null;
            }
 
            if (enabled) {
                //<debug>
                ++me._keyMapListenCount;
                //</debug>
 
                if (enabled) {
                    eventSource = me[me.keyMapTarget];
                    
                    if (typeof eventSource === 'function') {
                        eventSource = eventSource.call(me); // eg, 'getFocusEl'
                    }
 
                    listener = eventSource.on({
                        destroyable: true,
                        scope: me,
                        keydown: 'onKeyMapEvent',
                        keypress: 'onKeyMapEvent'
                    });
                }
            }
 
            me._keyMapListener = listener || null;
        },
 
        statics: {
            _charCodeRe: /^#([\d]+)$/,
            // eslint-disable-next-line max-len, no-useless-escape
            _keySpecRe: /^(?:(?:(\*)[\+\-])|(?:([a-z\+\-]*)[\+\-]))?(?:([a-z0-9_]+|[\+\-]|(?:#?\d+))(?:\:([a-z]+))?)$/i,
            _delimiterRe: /-|\+/,
 
            _keyMapEvents: {
                charCode: 'keypress',
                keyCode: 'keydown'
            },
 
            combineKeyMaps: function(existingKeyMap, keyMap, owner) {
                var defaultScope = keyMap.scope || 'controller',
                    entry, key, mapping, existingMapping;
 
                for (key in keyMap) {
                    if (key === 'scope') {
                        continue;
                    }
 
                    if (!(mapping = keyMap[key])) {
                        //<debug>
                        if (mapping === undefined) {
                            Ext.raise('keyMap entry "' + key + '" is undefined');
                        }
                        //</debug>
 
                        // if we have no mapping (eg, "ESC: null") and no mappings to
                        // overwrite, we can skip over it.
                        if (!existingKeyMap) {
                            continue;
                        }
                    }
                    else {
                        if (typeof mapping === 'string' || typeof mapping === 'function') {
                            // Direct calls to setKeyMap() can get here because
                            // instance and class configs go through merge
                            mapping = {
                                handler: mapping,
                                scope: defaultScope
                            };
                        }
                        else if (mapping) {
                            mapping = Ext.apply({
                                handler: mapping.fn, // mapping.handler will override
                                scope: defaultScope  // mapping.scope will override
                                // all other properties of mapping are kept
                            }, mapping);
                        }
 
                        existingKeyMap = existingKeyMap || {}; // we'll need a keyMap
                    }
 
                    if (Keyboard.parseEntry(key, entry = mapping || {})) {
                        // Key modifiers are stripped off the key name
                        // so we end up with an object like this:
                        //
                        //  "PRINT_SCREEN": {
                        //      handler: 'doSummat',
                        //      scope: 'controller',
                        //      altKey: true
                        //  }
                        //
                        // or
                        //
                        //  "UP": {
                        //      handler: 'doSummat'
                        //      scope: 'controller',
                        //      ignoreModifiers: true
                        //  }
                        //
                        existingMapping = existingKeyMap[entry.name];
 
                        if (existingMapping) {
                            if (owner && existingMapping.$owner !== owner) {
                                existingKeyMap[entry.name] = existingMapping =
                                    existingMapping.slice();
                                existingMapping.$owner = owner;
                            }
 
                            existingMapping.push(mapping);
 
                            existingMapping.sorted = false;
                        }
                        else {
                            existingMapping = existingKeyMap[entry.name] = [ mapping ];
                            existingMapping.$owner = owner;
                            existingMapping.sorted = true;
                        }
                    }
                    //<debug>
                    else {
                        Ext.raise('Invalid keyMap key specification "' + key + '"');
                    }
                    //</debug>
                }
 
                if (existingKeyMap && owner) {
                    existingKeyMap.$owner = owner;
                }
 
                return existingKeyMap || null;
            },
 
            getKeyName: function(event) {
                var keyCode;
 
                if (event.isEvent) {
                    keyCode = event.keyCode || event.charCode;
                    event = event.browserEvent;
 
                    // If it's the combination code, 229, then use the W3C code property.
                    // https://developer.mozilla.org/en/docs/Web/API/KeyboardEvent/code
                    if (keyCode === 229 && 'code' in event) {
                        if (Ext.String.startsWith(event.code, 'Key')) {
                            return event.key.substr(3);
                        }
                        
                        if (Ext.String.startsWith(event.code, 'Digit')) {
                            return event.key.substr(5);
                        }
                    }
                }
                else {
                    keyCode = event;
                }
 
                // We are in a position of having a numeric key code, attempt to translate it
                // to a name.
                return Ext.event.Event.keyCodes[keyCode] || String.fromCharCode(keyCode);
            },
 
            matchEntry: function(entry, e) {
                var ev = e.browserEvent,
                    code;
 
                if (e.type !== entry.event) {
                    return false;
                }
 
                if (!(code = entry.charCode)) {
                    if (entry.keyCode !== e.keyCode ||
                        (!entry.ignoreModifiers && !entry.shiftKey !== !ev.shiftKey)) {
                        // when using keyCode, SHIFT must match too
                        return false;
                    }
                }
                else if (e.getCharCode() !== code) {
                    return false;
                }
 
                // NOTE: All modifier key properties are !-ed to ensure boolean-ness since
                // they can be undefined...
                // Entry can be flagged to ignore modifiers and invoke purely on key match.
                return entry.ignoreModifiers ||
                        (!entry.ctrlKey === !ev.ctrlKey &&
                         !entry.altKey === !ev.altKey &&
                         !entry.metaKey === !ev.metaKey &&
                         !entry.shiftKey === !ev.shiftKey);
            },
 
            parseEntry: function(key, entry) {
                key = key.toUpperCase();
 
                // eslint-disable-next-line vars-on-top
                var me = this,
                    Event = Ext.event.Event,
                    keyFlags = Event.keyFlags,
                    parts = me._keySpecRe.exec(key),
                    type = 'keyCode',
                    name, code, i, match, n;
 
                // The _keySpecRe will split up a string thus:
                //
                // 'ALT+CTRL+A:GROUP' -> [.., undefined, "ALT+CTRL", "A", "GROUP"]
                //
                // '*+A:GROUP' -> [.., "*", undefined, "A", "GROUP"]
                //
                // 'ALT+CTRL+A' -> [.., undefined, "ALT+CTRL", "A", undefined]
                //
                // So parts is:
                // [0] - Whole matched string
                // [1] - All modifiers indicator, ie: '*'
                // [2] - Delimited modifiers list, eg: 'ctrl+alt'
                // [3] - The key name
                // [4] - The optional group name
 
                if (parts) {
                    name = parts[3];
                    
                    if (parts[4]) {
                        entry.group = parts[4];
                    }
 
                    // If "*" modifier used, then means ignore modifiers and invoke
                    // on raw key match.
                    if (!(entry.ignoreModifiers = !!parts[1]) && parts[2]) {
                        // Otherwise set flags according to modifer names if any.
                        parts = parts[2].split(me._delimiterRe);
                        n = parts.length;
                        
                        for (= 0; i < n; i++) {
                            //<debug>
                            if (!keyFlags[parts[i]]) {
                                return false;
                            }
                            //</debug>
 
                            entry[keyFlags[parts[i]]] = true;
                        }
                    }
 
                    // Entry is named by the unmodified key name.
                    // Entries for the same key are kept as a prioritized array.
                    entry.name = name;
 
                    // Set the keyCode from the 'PRINT_SCREEN' key name.
                    if (isNaN(code = Event[name])) {
                        // Support charCode from a single letter or '#65' format.
                        if (!(match = me._charCodeRe.exec(name))) {
                            if (name.length === 1) {
                                code = name.charCodeAt(0);
                            }
                        }
                        else {
                            code = +match[1]; // #42
                        }
 
                        if (code) {
                            type = 'charCode';
                        }
                        else {
                            // Last chance! Just a number (keyCode) like "27: 'onEscape'"?
                            code = +name;
                        }
 
                        entry.name = Keyboard.getKeyName(code);
                    }
 
                    entry.event = entry.event || me._keyMapEvents[type];
                    
                    return !isNaN(code) && (entry[type] = code);
                }
            }
        } // statics
    } // privates
};
});