/**
 * Tracks what records are currently selected in a data-bound component.
 *
 * This is an abstract class and is not meant to be directly used. Data-bound UI widgets such as
 * {@link Ext.grid.Panel Grid} and {@link Ext.tree.Panel Tree} should subclass Ext.selection.Model
 * and provide a way to binding to the component.
 *
 * The abstract method `onSelectChange` should be implemented in these
 * subclasses to update the UI widget.
 */
Ext.define('Ext.selection.Model', {
    extend: 'Ext.mixin.Observable',
    alternateClassName: 'Ext.AbstractSelectionModel',
    alias: 'selection.abstract',
    
    requires: [
        'Ext.util.Bag'
    ],
    
    mixins: [
        'Ext.util.StoreHolder',
        'Ext.mixin.Factoryable'
    ],
 
    factoryConfig: {
        // Need to override the defaultType, otherwise this class would be the default,
        // and it is an abstract base.
        defaultType: 'dataviewmodel'
    },
 
    // We do not want "_hidden" style backing properties.
    $configPrefixed: false,
    
    // We also want non-config system properties to go to the instance.
    $configStrict: false,
 
    config: {
        /**
         * @private
         * The {@link Ext.data.Store store} in which this selection model represents
         * the selected subset.
         */
        store: null,
 
        /**
         * @private
         * The {@link Ext.util.Collection collection} or {@link Ext.util.Bag bag} of
         * selected records.
         */
        selected: {}
    },
 
    // lastSelected
 
    /**
     * @property {Boolean} isSelectionModel
     * `true` in this class to identify an object as an instantiated
     * {@link Ext.selection.Model selection model}, or subclass thereof.
     */
    isSelectionModel: true,
 
    /**
     * @cfg {"SINGLE"/"SIMPLE"/"MULTI"} mode
     * Mode of selection.  Valid values are:
     *
     * - **"SINGLE"** - Only allows selecting one item at a time.  Use {@link #allowDeselect}
     *   to allow deselecting that item.  Also see {@link #toggleOnClick}. This is the default.
     * - **"SIMPLE"** - Allows simple selection of multiple items one-by-one. Each click in grid
     *   will either select or deselect an item.
     * - **"MULTI"** - Allows complex selection of multiple items using Ctrl and Shift keys.
     */
 
    /**
     * @cfg {Boolean} allowDeselect
     * Allow users to deselect a record in a DataView, List or Grid.
     * Only applicable when the {@link #mode} is 'SINGLE'.
     */
    allowDeselect: undefined,
    
    /**
     * @cfg {Boolean} toggleOnClick
     * `true` to toggle the selection state of an item when clicked.
     * Only applicable when the {@link #mode} is 'SINGLE'.
     * Only applicable when the {@link #allowDeselect} is 'true'.
     */
    toggleOnClick: true,
 
    /**
     * @cfg {Boolean} ordered
     * If this is set to `true`, the selected items will be tracked in an instance of
     * {Ext.util.Collection} instead of {Ext.util.Bag} to maintain insertion order.
     * @private
     */
    ordered: false,
 
    /**
     * @property {Ext.util.Bag/Ext.util.Collection} selected
     * A collection that maintains all of the currently selected records. The type
     * of collection class depends on the `ordered` config.
     * @readonly
     * @private
     */
    selected: null,
 
    /**
     * @cfg {Boolean} [pruneRemoved=true]
     * Remove records from the selection when they are removed from the store.
     *
     * **Important:** When using {@link Ext.toolbar.Paging paging} or a
     * {@link Ext.data.BufferedStore}, records which are cached in the Store's
     * {@link Ext.data.Store#property-data data collection} may be removed from the Store
     * when pages change, or when rows are scrolled out of view. For this reason `pruneRemoved`
     * should be set to `false` when using a buffered Store.
     *
     * Also, when previously pruned pages are returned to the cache, the records objects in the page
     * will be *new instances*, and will not match the instances in the selection model's
     * collection. For this reason, you MUST ensure that the Model definition's
     * {@link Ext.data.Model#idProperty idProperty} references a unique key because
     * in this situation, records in the Store have their **IDs** compared to records
     * in the SelectionModel in order to re-select a record which is scrolled back into view.
     */
    pruneRemoved: true,
    
    suspendChange: 0,
 
    /**
     * @cfg {Boolean} [ignoreRightMouseSelection=false]
     * True to ignore selections that are made when using the right mouse button if there are
     * records that are already selected. If no records are selected, selection will continue
     * as normal
     */
    ignoreRightMouseSelection: false,
 
    /**
     * @event selectionchange
     * Fired after a selection change has occurred
     * @param {Ext.selection.Model} this 
     * @param {Ext.data.Model[]} selected The selected records
     */
 
    /**
     * @event focuschange
     * Fired when a row is focused
     * @param {Ext.selection.Model} this 
     * @param {Ext.data.Model} oldFocused The previously focused record
     * @param {Ext.data.Model} newFocused The newly focused record
     */
    
    constructor: function(cfg) {
        var me = this;
        
        me.modes = {
            SINGLE: true,
            SIMPLE: true,
            MULTI: true
        };
 
        me.callParent([cfg]);
 
        // sets this.selectionMode
        me.setSelectionMode(me.mode);
        
        if (me.selectionMode !== 'SINGLE') {
            me.allowDeselect = true;
        }
    },
 
    updateStore: function(store, oldStore) {
        this.bindStore(store, !oldStore);
    },
 
    applySelected: function(selected) {
        if (!selected.isBag && !selected.isCollection) {
            selected = new Ext.util[this.ordered ? 'Collection' : 'Bag'](Ext.apply({
                rootProperty: 'data'
            }, selected));
        }
        
        return selected;
    },
 
    getStoreListeners: function() {
        var me = this;
        
        return {
            add: me.onStoreAdd,
            clear: me.onStoreClear,
            remove: me.onStoreRemove,
            update: me.onStoreUpdate,
            idchanged: me.onIdChanged,
            load: me.onStoreLoad,
            refresh: me.onStoreRefresh,
 
            // BufferedStore events
            pageadd: me.onPageAdd,
            pageremove: me.onPageRemove
        };
    },
 
    onBindStore: function(store, oldStore, initial) {
        if (!initial) {
            this.updateSelectedInstances(this.selected);
        }
    },
 
    suspendChanges: function() {
        ++this.suspendChange;
    },
 
    resumeChanges: function() {
        if (this.suspendChange) {
            --this.suspendChange;
        }
    },
 
    /**
     * Selects all records in the view.
     * @param {Boolean} suppressEvent True to suppress any select events
     */
    selectAll: function(suppressEvent) {
        var me = this,
            selections = me.store.getRange(),
            start = me.getSelection().length;
 
        me.suspendChanges();
        me.doSelect(selections, true, suppressEvent);
        
        if (!me.destroyed) {
            me.resumeChanges();
            
            // fire selection change only if the number of selections differs
            if (!suppressEvent) {
                me.maybeFireSelectionChange(me.getSelection().length !== start);
            }
        }
    },
 
    /**
     * Deselects all records in the view.
     * @param {Boolean} [suppressEvent] True to suppress any deselect events
     */
    deselectAll: function(suppressEvent) {
        var me = this,
            selections = me.getSelection(),
            selIndexes = {},
            store = me.store,
            start = selections.length,
            i, l, rec;
 
        // Cache selection records' indexes first to avoid
        // looking them up on every sort comparison below.
        // We can't rely on store.indexOf being fast because
        // for whatever reason the Store in question may force
        // sequential index lookup, which will result in O(n^2)
        // sort performance below.
        for (= 0, l = selections.length; i < l; i++) {
            rec = selections[i];
            
            selIndexes[rec.id] = store.indexOf(rec);
        }
        
        // Sort the selections so that the events fire in
        // a predictable order like selectAll
        selections = Ext.Array.sort(selections, function(r1, r2) {
            var idx1 = selIndexes[r1.id],
                idx2 = selIndexes[r2.id];
            
            // Don't check for equality since indexes will be unique
            return idx1 < idx2 ? -1 : 1;
        });
        
        me.suspendChanges();
        me.doDeselect(selections, suppressEvent);
        
        if (!me.destroyed) {
            me.resumeChanges();
            
            // fire selection change only if the number of selections differs
            if (!suppressEvent) {
                me.maybeFireSelectionChange(me.getSelection().length !== start);
            }
        }
    },
 
    getSelectionStart: function() {
        return this.selectionStart;
    },
 
    setSelectionStart: function(selection) {
        this.selectionStart = selection;
    },
 
    // Provides differentiation of logic between MULTI, SIMPLE and SINGLE
    // selection modes. Requires that an event be passed so that we can know
    // if user held ctrl or shift.
    selectWithEvent: function(record, e) {
        var me = this,
            isSelected = me.isSelected(record),
            shift = e.shiftKey;
 
        switch (me.selectionMode) {
            case 'MULTI':
                me.selectWithEventMulti(record, e, isSelected);
                break;
            
            case 'SIMPLE':
                me.selectWithEventSimple(record, e, isSelected);
                break;
            
            case 'SINGLE':
                me.selectWithEventSingle(record, e, isSelected);
                break;
        }
        
        // Event handlers could have destroyed the selection model
        if (me.destroyed) {
            return;
        }
 
        // selectionStart is a start point for shift/mousedown to create a range from.
        // If the mousedowned record was not already selected, then it becomes the
        // start of any range created from now on.
        // If we drop to no records selected, then there is no range start any more.
        if (!shift) {
            if (me.isSelected(record)) {
                me.selectionStart = record;
            }
            else {
                me.selectionStart = null;
            }
        }
    },
 
    /**
     * Checks whether a selection should proceed based on the ignoreRightMouseSelection
     * option.
     * @private
     * @param {Ext.event.Event} e The event
     * @return {Boolean} `true` if the selection should not proceed.
     */
    vetoSelection: function(e) {
        // Flag can be stamped into the event at any time.
        // This is used by ActionColumn and CheckColumn to implement their stopSelection config
        if (e.stopSelection) {
            return true;
        }
        else if (e.type !== 'keydown' && e.button !== 0) {
            if (this.ignoreRightMouseSelection || this.isSelected(e.record)) {
                return true;
            }
        }
        else {
            return e.type === 'mousedown';
        }
    },
 
    // Private
    // Called in response to a FocusModel's navigate event when a new record has been navigated to.
    // Event is passed so that shift and ctrl can be handled.
    onNavigate: function(e) {
        // Enforce the ignoreRightMouseSelection setting.
        // Enforce presence of a record.
        // Enforce selection upon click, not mousedown.
        if (!e.record || this.vetoSelection(e.keyEvent)) {
            return;
        }
 
        this.onBeforeNavigate(e);
 
        // eslint-disable-next-line vars-on-top
        var me = this,
            keyEvent = e.keyEvent,
            // ctrlKey may be set on the event if we want to treat it like a ctrlKey so
            // we don't mutate the original event object
            ctrlKey = keyEvent.ctrlKey || e.ctrlKey,
            recIdx = e.recordIndex,
            record = e.record,
            lastFocused = e.previousRecord,
            isSelected = me.isSelected(record),
            from = (me.selectionStart && me.isSelected(e.previousRecord))
                ? me.selectionStart
                : (me.selectionStart = e.previousRecord),
            fromIdx = e.previousRecordIndex,
            key = keyEvent.getCharCode(),
            isSpace = key === keyEvent.SPACE,
            changedRec = e.record !== e.previousRecord,
            direction;
            
        direction =
            key === keyEvent.UP || key === keyEvent.PAGE_UP || key === keyEvent.HOME ||
                    (key === keyEvent.LEFT && changedRec)
                ? 'up'
                : (key === keyEvent.DOWN || key === keyEvent.PAGE_DOWN ||
                   key === keyEvent.END || (key === keyEvent.RIGHT && changedRec)
                    ? 'down'
                    : null);
 
        switch (me.selectionMode) {
            case 'MULTI':
                me.setSelectionStart(e.selectionStart);
 
                if (key === keyEvent.A && ctrlKey) {
                    // Listening to endUpdate on the Collection will be more efficient
                    me.selected.beginUpdate();
                    me.selectRange(0, me.store.getCount() - 1);
                    me.selected.endUpdate();
                }
                else if (isSpace) {
                    // SHIFT+SPACE, select range
                    if (keyEvent.shiftKey) {
                        me.selectRange(from, record, ctrlKey);
                    }
                    else {
                        // SPACE pressed on a selected item: deselect.
                        if (isSelected) {
                            if (me.allowDeselect) {
                                me.doDeselect(record);
                            }
                        }
                        // SPACE on an unselected item: select it
                        // keyEvent.ctrlKey means "keep existing"
                        else {
                            me.doSelect(record, ctrlKey);
                        }
                    }
                }
 
                // SHIFT-navigate selects intervening rows from the last selected
                // (or last focused) item and target item
                else if (keyEvent.shiftKey && from) {
                    // If we are heading back TOWARDS the start rec - deselect skipped range...
                    if (direction === 'up' && fromIdx <= recIdx) {
                        me.deselectRange(lastFocused, recIdx + 1);
                    }
                    else if (direction === 'down' && fromIdx >= recIdx) {
                        me.deselectRange(lastFocused, recIdx - 1);
                    }
 
                    // If we are heading AWAY from start point, or no CTRL key,
                    // so just select the range and let the CTRL control "keepExisting"...
                    else if (from !== record) {
                        me.selectRange(from, record, ctrlKey);
                    }
                    
                    me.lastSelected = record;
                }
 
                else if (key) {
                    if (!ctrlKey) {
                        me.doSelect(record, false);
                    }
                }
                else {
                    me.selectWithEvent(record, keyEvent);
                }
                
                break;
            
            case 'SIMPLE':
                if (key === keyEvent.A && ctrlKey) {
                    // Listening to endUpdate on the Collection will be more efficient
                    me.selected.beginUpdate();
                    me.selectRange(0, me.store.getCount() - 1);
                    me.selected.endUpdate();
                }
                else if (isSelected) {
                    me.doDeselect(record);
                }
                else {
                    me.doSelect(record, true);
                }
                
                break;
            
            case 'SINGLE':
                // CTRL-navigation does not select
                if (!ctrlKey) {
                    // Arrow movement
                    if (direction) {
                        me.doSelect(record, false);
                    }
                    // Space or click
                    else if (isSpace || !key) {
                        me.selectWithEvent(record, keyEvent);
                    }
                }
                
                break;
        }
 
        // selectionStart is a start point for shift/mousedown to create a range from.
        // If the mousedowned record was not already selected, then it becomes the
        // start of any range created from now on.
        // If we drop to no records selected, then there is no range start any more.
        if (!keyEvent.shiftKey && !me.destroyed && me.isSelected(record)) {
            me.selectionStart = record;
            me.selectionStartIdx = recIdx;
        }
    },
 
    /**
     * Selects a range of rows if the selection model {@link #isLocked is not locked}.
     * All rows in between startRow and endRow are also selected.
     * @param {Ext.data.Model/Number} startRow The record or index of the first row in the range
     * @param {Ext.data.Model/Number} endRow The record or index of the last row in the range
     * @param {Boolean} keepExisting (optional) True to retain existing selections
     */
    selectRange: function(startRow, endRow, keepExisting) {
        var me = this,
            store = me.store,
            selected = me.selected.items,
            result, i, len, toSelect, toDeselect, idx, rec;
 
        if (me.isLocked()) {
            return;
        }
 
        result = me.normalizeRowRange(startRow, endRow);
        startRow = result[0];
        endRow = result[1];
 
        toSelect = [];
        
        for (= startRow; i <= endRow; i++) {
            if (!me.isSelected(store.getAt(i))) {
                toSelect.push(store.getAt(i));
            }
        }
        
        if (!keepExisting) {
            // prevent selectionchange from firing
            toDeselect = [];
            me.suspendChanges();
            
            for (= 0, len = selected.length; i < len; ++i) {
                rec = selected[i];
                idx = store.indexOf(rec);
                
                if (idx < startRow || idx > endRow) {
                    toDeselect.push(rec);
                }
            }
            
            for (= 0, len = toDeselect.length; i < len; ++i) {
                me.doDeselect(toDeselect[i]);
                
                // We could have brought destruction upon ourselves via handlers;
                // no point in continuing if that happened
                if (me.destroyed) {
                    break;
                }
            }
            
            if (!me.destroyed) {
                me.resumeChanges();
            }
        }
        
        if (!me.destroyed) {
            if (toSelect.length) {
                me.doMultiSelect(toSelect, true);
            }
            else if (toDeselect) {
                me.maybeFireSelectionChange(toDeselect.length > 0);
            }
        }
    },
 
    /**
     * Deselects a range of rows if the selection model {@link #isLocked is not locked}.
     * @param {Ext.data.Model/Number} startRow The record or index of the first row in the range
     * @param {Ext.data.Model/Number} endRow The record or index of the last row in the range
     */
    deselectRange: function(startRow, endRow) {
        var me = this,
            store = me.store,
            result, i, toDeselect, record;
 
        if (me.isLocked()) {
            return;
        }
 
        result = me.normalizeRowRange(startRow, endRow);
        startRow = result[0];
        endRow = result[1];
 
        toDeselect = [];
        
        for (= startRow; i <= endRow; i++) {
            record = store.getAt(i);
 
            if (me.isSelected(record)) {
                toDeselect.push(record);
            }
        }
        
        if (toDeselect.length) {
            me.doDeselect(toDeselect);
        }
    },
    
    normalizeRowRange: function(startRow, endRow) {
        var store = this.store,
            tmp;
        
        if (!Ext.isNumber(startRow)) {
            startRow = store.indexOf(startRow);
        }
        
        startRow = Math.max(0, startRow);
        
        if (!Ext.isNumber(endRow)) {
            endRow = store.indexOf(endRow);
        }
        
        endRow = Math.min(endRow, store.getCount() - 1);
        
        // swap values
        if (startRow > endRow) {
            tmp = endRow;
            endRow = startRow;
            startRow = tmp;
        }
        
        return [startRow, endRow];
    },
 
    /**
     * Selects a record instance by record instance or index.
     * @param {Ext.data.Model[]/Number} records An array of records or an index
     * @param {Boolean} [keepExisting=false] True to retain existing selections
     * @param {Boolean} [suppressEvent=false] True to not fire a select event
     */
    select: function(records, keepExisting, suppressEvent) {
        // Automatically selecting eg store.first() or store.last() will pass undefined,
        // so that must just return;
        if (Ext.isDefined(records) && !(Ext.isArray(records) && !records.length)) {
            this.doSelect(records, keepExisting, suppressEvent);
        }
    },
 
    /**
     * Deselects a record instance by record instance or index.
     * @param {Ext.data.Model[]/Number} records An array of records or an index
     * @param {Boolean} [suppressEvent=false] True to not fire a deselect event
     */
    deselect: function(records, suppressEvent) {
        this.doDeselect(records, suppressEvent);
    },
 
    doSelect: function(records, keepExisting, suppressEvent) {
        var me = this,
            record;
 
        if (me.locked || records == null) {
            return;
        }
 
        if (typeof records === "number") {
            record = me.store.getAt(records);
            
            // No matching record, jump out.
            if (!record) {
                return;
            }
            
            records = [record];
        }
 
        if (me.selectionMode === "SINGLE") {
            if (records.isModel) {
                records = [records];
            }
 
            if (records.length) {
                me.doSingleSelect(records[0], suppressEvent);
            }
        }
        else {
            me.doMultiSelect(records, keepExisting, suppressEvent);
        }
    },
 
    doMultiSelect: function(records, keepExisting, suppressEvent) {
        var me = this,
            selected = me.selected,
            change = false,
            result, i, len, record, commit;
 
        if (me.locked) {
            return;
        }
 
        records = !Ext.isArray(records) ? [records] : records;
        len = records.length;
        
        if (!keepExisting && selected.getCount() > 0) {
            result = me.deselectDuringSelect(records, suppressEvent);
            
            if (me.destroyed) {
                return;
            }
            
            if (result[0]) {
                // We had a failure during selection, so jump out
                // Fire selection change if we did deselect anything
                me.maybeFireSelectionChange(result[1] > 0 && !suppressEvent);
                
                return;
            }
            else {
                // Means something has been deselected, so we've had a change
                change = result[1] > 0;
            }
        }
 
        commit = function() {
            if (!selected.getCount()) {
                me.selectionStart = record;
            }
            
            if (!suppressEvent) {
                selected.add(record);
            }
            
            change = true;
        };
 
        for (= 0; i < len; i++) {
            record = records[i];
            
            if (me.isSelected(record)) {
                continue;
            }
 
            me.onSelectChange(record, true, suppressEvent, commit);
            
            if (me.destroyed) {
                return;
            }
        }
 
        me.lastSelected = record;
 
        if (suppressEvent) {
            selected.add(records);
        }
 
        // fire selchange if there was a change and there is no suppressEvent flag
        me.maybeFireSelectionChange(change && !suppressEvent);
    },
    
    deselectDuringSelect: function(toSelect, suppressEvent) {
        var me = this,
            selected = me.selected.getRange(),
            len = selected.length,
            changed = 0,
            failed = false,
            item, i;
            
        // Prevent selection change events from firing, will happen during select
        me.suspendChanges();
        me.deselectingDuringSelect = true;
        
        for (= 0; i < len; ++i) {
            item = selected[i];
            
            if (!Ext.Array.contains(toSelect, item)) {
                if (me.doDeselect(item, suppressEvent)) {
                    ++changed;
                }
                else {
                    failed = true;
                }
            }
            
            if (me.destroyed) {
                failed = true;
                changed = 0;
                
                break;
            }
        }
        
        me.deselectingDuringSelect = false;
        
        if (!me.destroyed) {
            me.resumeChanges();
        }
        
        return [failed, changed];
    },
 
    // records can be an index, a record or an array of records
    doDeselect: function(records, suppressEvent) {
        var me = this,
            selected = me.selected,
            i = 0,
            len, record,
            attempted = 0,
            accepted = 0,
            commit;
 
        if (me.locked || !me.store) {
            return false;
        }
 
        if (typeof records === "number") {
            record = me.store.getAt(records);
            
            // No matching record, jump out
            if (!record) {
                return false;
            }
            
            records = [record];
        }
        else if (!Ext.isArray(records)) {
            records = [records];
        }
 
        commit = function() {
            ++accepted;
            
            if (!suppressEvent) {
                selected.remove(record);
            }
            
            if (record === me.selectionStart) {
                me.selectionStart = null;
            }
        };
 
        len = records.length;
 
        me.suspendChanges();
        
        for (; i < len; i++) {
            record = records[i];
            
            if (me.isSelected(record)) {
                if (me.lastSelected === record) {
                    me.lastSelected = selected.last();
                }
                
                ++attempted;
                me.onSelectChange(record, false, suppressEvent, commit);
                
                if (me.destroyed) {
                    return false;
                }
            }
        }
        
        me.resumeChanges();
 
        // If we have been suppressing events, we've not been removing individual records
        // Remove them all in one shot.
        if (suppressEvent) {
            selected.remove(records);
        }
 
        // fire selchange if there was a change and there is no suppressEvent flag
        me.maybeFireSelectionChange(accepted > 0 && !suppressEvent);
        
        return accepted === attempted;
    },
 
    doSingleSelect: function(record, suppressEvent) {
        var me = this,
            changed = false,
            selected = me.selected,
            commit;
 
        if (me.locked) {
            return;
        }
        
        // already selected.
        // should we also check beforeselect?
        if (me.isSelected(record)) {
            return;
        }
 
        commit = function() {
            var result;
            
            // Deselect previous selection.
            if (selected.getCount()) {
                me.suspendChanges();
                
                result = me.deselectDuringSelect([record], suppressEvent);
                
                if (me.destroyed) {
                    return;
                }
                
                me.resumeChanges();
                
                if (result[0]) {
                    // Means deselection failed, so abort
                    return false;
                }
            }
 
            me.lastSelected = record;
            
            if (!selected.getCount()) {
                me.selectionStart = record;
            }
            
            selected.add(record);
            changed = true;
        };
 
        me.onSelectChange(record, true, suppressEvent, commit);
 
        if (changed && !me.destroyed) {
            me.maybeFireSelectionChange(!suppressEvent);
        }
    },
 
    // fire selection change as long as true is not passed
    // into maybeFireSelectionChange
    maybeFireSelectionChange: function(fireEvent) {
        var me = this;
        
        if (fireEvent && !me.suspendChange) {
            me.fireEvent('selectionchange', me, me.getSelection());
        }
    },
 
    /**
     * Returns an array of the currently selected records.
     * @return {Ext.data.Model[]} The selected records
     */
    getSelection: function() {
        return this.selected.getRange();
    },
 
    /**
     * Returns the current selectionMode.
     * @return {String} The selectionMode: 'SINGLE', 'MULTI' or 'SIMPLE'.
     */
    getSelectionMode: function() {
        return this.selectionMode;
    },
 
    /**
     * Sets the current selectionMode.
     * @param {String} selMode 'SINGLE', 'MULTI' or 'SIMPLE'.
     */
    setSelectionMode: function(selMode) {
        selMode = selMode ? selMode.toUpperCase() : 'SINGLE';
        
        // set to mode specified unless it doesnt exist, in that case use single.
        this.selectionMode = this.modes[selMode] ? selMode : 'SINGLE';
    },
 
    /**
     * Returns true if the selections are locked.
     * @return {Boolean} 
     */
    isLocked: function() {
        return this.locked;
    },
 
    /**
     * Locks the current selection and disables any changes from happening to the selection.
     * @param {Boolean} locked  True to lock, false to unlock.
     */
    setLocked: function(locked) {
        this.locked = !!locked;
    },
 
    /**
     * Returns true if the specified row is selected.
     * @param {Ext.data.Model/Number} startRow The start of the range to check.
     * @param {Ext.data.Model/Number} endRow The end of the range to check.
     * @return {Boolean} 
     */
    isRangeSelected: function(startRow, endRow) {
        var me = this,
            store = me.store,
            i, result;
 
        result = me.normalizeRowRange(startRow, endRow);
        startRow = result[0];
        endRow = result[1];
 
        // Loop through. If any of the range is not selected, the answer is false.
        for (= startRow; i <= endRow; i++) {
            if (!me.isSelected(store.getAt(i))) {
                return false;
            }
        }
        
        return true;
    },
 
    /**
     * Returns true if the specified row is selected.
     * @param {Ext.data.Model/Number} record The record or index of the record to check
     * @return {Boolean} 
     */
    isSelected: function(record) {
        record = Ext.isNumber(record) ? this.store.getAt(record) : record;
        
        return this.selected ? this.selected.contains(record) : false;
    },
 
    /**
     * Returns true if there are any a selected records.
     * @return {Boolean} 
     */
    hasSelection: function() {
        var selected = this.getSelected();
        
        return !!(selected && selected.getCount());
    },
 
    refresh: function() {
        var me = this,
            store = me.store,
            toBeRemoved = [],
            oldSelections = me.getSelection(),
            len = oldSelections.length,
 
            // Will be a Collection in this and DataView classes.
            // Will be an Ext.grid.selection.Rows instance for Spreadsheet
            // (does not callParent for other modes).
            // API used in here, getCount() and add() are common.
            selected = me.getSelected(),
            change, d, storeData, selection, rec, i;
 
        // Not been bound yet, or we have never selected anything.
        if (!store || !(selected.isCollection || selected.isBag || selected.isRows) ||
            !selected.getCount()) {
            return;
        }
 
        // We need to look beneath any filtering to see if the selected records
        // are still owned by the store
        storeData = store.getData();
 
        // Attempt to get the underlying source collection to avoid filtering
        if (storeData.getSource) {
            d = storeData.getSource();
            
            if (d) {
                storeData = d;
            }
        }
        
        me.refreshing = true;
 
        // Inhibit update notifications during refresh of the selected collection.
        selected.beginUpdate();
        me.suspendChanges();
 
        // Add currently records to the toBeSelected list if present in the Store
        // If they are not present, and pruneRemoved is false, we must still retain the record
        for (= 0; i < len; i++) {
            selection = oldSelections[i];
            rec = storeData.get(selection.getId());
            
            if (rec) {
                // If the record instance referenced by that ID has changed,
                // silently replace it in the collection
                if (rec !== selection) {
                    if (selected.replace) {
                        selected.replace(rec);
                    }
                    else {
                        selected.add(rec);
                    }
                }
 
                if (!me.lastSelected) {
                    me.lastSelected = rec;
                }
            }
            // Selected records no longer represented in Store must be retained
            else if (me.pruneRemoved) {
                toBeRemoved.push(selection);
            }
        }
 
        // there was a change from the old selected and
        // the new selection
        if (toBeRemoved.length) {
            change = true;
            selected.remove(toBeRemoved);
        }
 
        me.resumeChanges();
 
        // If the new data caused the selection to change, announce the update using endUpdate,
        // Otherwise, end the update silently.
        // Bindings may be attached to selection - we need to coalesce changes.
        if (change) {
            selected.endUpdate();
        }
        else {
            selected.updating--;
        }
        
        me.refreshing = false;
 
        me.maybeFireSelectionChange(change);
    },
 
    /**
     * A fast reset of the selections without firing events, updating the ui, etc.
     * For private usage only.
     * @private
     */
    clearSelections: function() {
        // Will be a Collection in this and DataView classes.
        // Will be an Ext.grid.selection.Selection instance for Spreadsheet.
        // API used in here, clear() is common.
        var selected = this.getSelected();
 
        // reset the entire selection to nothing
        if (selected) {
            selected.clear();
        }
        
        this.lastSelected = null;
    },
 
    // when a record is added to a store
    onStoreAdd: Ext.emptyFn,
 
    // when a store is cleared remove all selections
    // (if there were any)
    onStoreClear: function() {
        // When the store is clearing for reload and there is outstanding selection,
        // make sure to clear it! Otherwise we might end up with incosistent state.
        if ((!this.store.isLoading() || this.store.clearing) && this.hasSelection()) {
            this.clearSelections();
            this.maybeFireSelectionChange(true);
        }
    },
 
    // prune records from the SelectionModel if
    // they were selected at the time they were
    // removed.
    onStoreRemove: function(store, records, index, isMove) {
        var me = this,
            toDeselect = records,
            i, len, rec, moveMap;
 
        // If the selection start point is among records being removed,
        // we no longer have a selection start point.
        if (me.selectionStart && Ext.Array.contains(records, me.selectionStart)) {
            me.selectionStart = null;
        }
 
        if (isMove || me.locked || !me.pruneRemoved) {
            return;
        }
 
        // Do a cheap check to see if the store is doing any moves before we branch into here
        moveMap = store.isMoving(null, true);
        
        if (moveMap) {
            toDeselect = null;
            
            for (= 0, len = records.length; i < len; ++i) {
                rec = records[i];
                
                if (!moveMap[rec.id]) {
                    (toDeselect || (toDeselect = [])).push(rec);
                }
            }
        }
 
        if (toDeselect) {
            me.deselect(toDeselect);
        }
    },
 
    // Page evicted from BufferedStore.
    // Remove any selections in that page unless pruneRemoved is false
    onPageRemove: function(pageMap, pageNumber, records) {
        this.onStoreRemove(this.store, records);
    },
 
    // Page added to BufferedStore.
    // Check for return of already selected records
    onPageAdd: function(pageMap, pageNumber, records) {
        var len = records.length,
            i, record;
 
        for (= 0; i < len; i++) {
            record = records[i];
            
            if (this.selected.get(record.id)) {
                this.selected.replace(record);
            }
        }
    },
 
    /**
     * Returns the count of selected records.
     * @return {Number} The number of selected records
     */
    getCount: function() {
        return this.selected.getCount();
    },
 
    // Called when the contents of the node are updated, perform any processing here.
    onUpdate: Ext.emptyFn,
 
    // cleanup.
    destroy: function() {
        var me = this;
        
        me.clearSelections();
        me.bindStore(null);
        me.selected = Ext.destroy(me.selected);
        
        me.callParent();
    },
 
    // if records are updated
    onStoreUpdate: Ext.emptyFn,
 
    onIdChanged: function(store, rec, oldId, newId) {
        this.selected.updateKey(rec, oldId);
    },
 
    onStoreRefresh: function() {
        this.updateSelectedInstances(this.selected);
    },
 
    /**
     * @private
     * Called when the store is refreshed.
     * Selected records which are no longer present in the store are removed
     * if {@link #pruneRemoved} is `true`.
     * 
     * Selected records which are still present have their instances in the passed collection
     * updated.
     * @param {Ext.util.Collection/Ext.util.Bag} selected A Collection representing
     * the currently selected records.
     */
    updateSelectedInstances: function(selected) {
        var me = this,
            store = me.getStore(),
            lastSelected = me.lastSelected,
            removeCount = 0,
            prune = me.pruneRemovedOnRefresh(),
            items, length, i, selectedRec, rec,
            lastSelectedChanged;
 
        if (store && store.isBufferedStore) {
            return;
        }
 
        items = selected.getRange();
        length = items.length;
         
        if (lastSelected) {
            me.lastSelected = store.getById(lastSelected.id);
            lastSelectedChanged = me.lastSelected !== lastSelected;
        }
 
        // Flag so that reactors to collectionEndUpdate know that the collection
        // is not really changing
        me.refreshing = true;
        
        if (store) {
            for (= 0; i < length; ++i) {
                selectedRec = items[i];
 
                // Is the selected record ID still present in the store?
                rec = store.getById(selectedRec.id);
 
                // Yes, ensure the instance is correct
                if (rec) {
                    if (rec !== selectedRec) {
                        // Silently replace the stale record instance with the new record
                        // by the same ID
                        selected.add(rec);
                    }
                }
                // No, remove it from the selection if we are configured to prune removed records
                else if (prune) {
                    selected.remove(selectedRec);
                    ++removeCount;
                }
            }
        }
        else {
            removeCount = selected.getCount();
            selected.removeAll();
        }
        
        me.refreshing = false;
        me.maybeFireSelectionChange(removeCount > 0);
        
        if (lastSelectedChanged) {
            // Private event for now
            me.fireEvent('lastselectedchanged', me, me.getSelection(), lastSelected);
        }
    },
 
    // onStoreRefresh asks if it should remove from the selection any selected records which are no
    // longer findable in the store after the refresh.
    // Subclasses may override this.
    // TreeModel does not use the pruneRemoved flag because records are being added and removed
    // from TreeStores on expand and collapse. It uses the pruneRemovedNodes flag.
    pruneRemovedOnRefresh: function() {
        return this.pruneRemoved;
    },
 
    /**
     * @method
     * @abstract
     * @private
     */
    onStoreLoad: Ext.emptyFn,
 
    /**
     * @abstract
     */
    onSelectChange: function(record, isSelected, suppressEvent, commitFn) {
        var me = this,
            eventName = isSelected ? 'select' : 'deselect';
 
        if ((suppressEvent || me.fireEvent('before' + eventName, me, record)) !== false &&
           commitFn() !== false) {
 
            // Could be destroyed in the handler
            if (!suppressEvent && !me.destroyed) {
                me.fireEvent(eventName, me, record);
            }
        }
    },
 
    /**
     * @method
     * @abstract
     */
    onEditorKey: Ext.emptyFn,
 
    /**
     * @protected
     * @template
     * Allows multiple views to be controlled by one selection model.
     * Called by AbstractView's beforeRender method.
     * @param {Ext.view.View} view The View passes itself
     */
    beforeViewRender: function(view) {
        Ext.Array.include(this.views || (this.views = []), view);
    },
    
    /**
     * @method
     * @protected
     * @template
     * Called by the owning grid's {@link Ext.grid.header.Container header container}
     * when a column header is activated by the UI
     * (clicked, or receives a `SPACE` or `ENTER` key event).
     */
    onHeaderClick: Ext.emptyFn,
 
    resolveListenerScope: function(defaultScope) {
        var view = this.view,
            scope;
            
        if (view) {
            scope = view.resolveSatelliteListenerScope(this, defaultScope);
        }
        
        return scope || this.callParent([defaultScope]);
    },
 
    /**
     * @method
     * @abstract
     */
    bindComponent: Ext.emptyFn,
 
    privates: {
        onBeforeNavigate: Ext.privateFn,
 
        /**
         * @private
         * Called by {@link Ext.panel.Table#updateBindSelection} when publishing the `selection`
         * property. It should yield the last record selected.
         * @return {Ext.data.Model} The last record selected. This is only available
         * if the current selection type is cells or rows.
         * In the case of multiple selection, the *last* record added to the selection is returned.
         */
        getLastSelected: function() {
            return this.lastSelected;
        },
 
        selectWithEventMulti: function(record, e, isSelected) {
            var me = this,
                shift = e.shiftKey,
                ctrl = e.ctrlKey,
                start = shift ? (me.getSelectionStart()) : null,
                selected = me.getSelection(),
                len = selected.length,
                toDeselect, i, item;
 
            if (shift && start) {
                me.selectRange(start, record, ctrl);
            }
            else if (ctrl && isSelected) {
                if (me.allowDeselect) {
                    me.doDeselect(record, false);
                }
            }
            else if (ctrl) {
                me.doSelect(record, true, false);
            }
            else if (isSelected && !shift && !ctrl && len > 1) {
                if (me.allowDeselect) {
                    toDeselect = [];
 
                    for (= 0; i < len; ++i) {
                        item = selected[i];
                        
                        if (item !== record) {
                            toDeselect.push(item);
                        }
                    }
 
                    me.doDeselect(toDeselect);
                }
            }
            else if (!isSelected) {
                me.doSelect(record, false);
            }
        },
 
        selectWithEventSimple: function(record, e, isSelected) {
            if (isSelected) {
                this.doDeselect(record);
            }
            else {
                this.doSelect(record, true);
            }
        },
 
        selectWithEventSingle: function(record, e, isSelected) {
            var me = this,
                allowDeselect = me.allowDeselect;
 
            if (allowDeselect && !e.ctrlKey) {
                allowDeselect = me.toggleOnClick;
            }
            
            if (allowDeselect && isSelected) {
                me.doDeselect(record);
            }
            else {
                me.doSelect(record, false);
            }
        }
    }
});