/**
 * @private
 * A collection containing the result of applying grouping to the records in the store.
 */
Ext.define('Ext.util.GroupCollection', {
    extend: 'Ext.util.Collection',
 
    requires: [
        'Ext.util.Group',
 
        // Since Collection uses sub-collections of various derived types we step up to
        // list all the requirements of Collection. The idea being that instead of a
        // "requires" of Ext.util.Collection (which cannot pull everything) you instead
        // do a "requires" of Ext.util.GroupCollection and it will.
        'Ext.util.SorterCollection',
        'Ext.util.FilterCollection',
        'Ext.util.GrouperCollection'
    ],
 
    isGroupCollection: true,
 
    config: {
        grouper: null,
        groupConfig: null,
        itemRoot: null
    },
 
    observerPriority: -100,
 
    emptyGroupRetainTime: 300000, // Private timer to hang on to emptied groups. Milliseconds.
 
    rootProperty: '_data', // this is required by the sorter
 
    constructor: function(config) {
        this.emptyGroups = {};
        this.callParent([config]);
        this.on('remove', 'onGroupRemove', this);
    },
 
    /**
     * Returns the `Ext.util.Group` associated with the given record.
     *
     * @param {Object} item The item for which the group is desired.
     * @return {Ext.util.Group} 
     * @since 6.5.0
     */
    getItemGroup: function(item) {
        var grouper = this.lastMonitoredGrouper,
            key, group;
 
        if (!grouper && this.items.length) {
            grouper = this.items[0].getGrouper();
        }
 
        if (grouper) {
            key = grouper.getGroupString(item);
            group = this.get(key);
        }
 
        return group;
    },
 
    /**
     * Find the group that matches the specified path or `false` if not found.
     *
     * @param {String} path Path to the group
     * @return {Ext.util.Group} 
     */
    getByPath: function(path) {
        var paths = path ? String(path).split(Ext.util.Group.prototype.pathSeparator) : [],
            len = paths.length,
            items = this,
            group = false,
            i;
 
        if (!len) {
            group = items.get(path);
        }
 
        for (= 0; i < len; i++) {
            if (!items || items.length === 0) {
                break;
            }
 
            group = items.get(paths[i]);
 
            if (group) {
                items = group.getGroups();
            }
        }
 
        return group || false;
    },
 
    /**
     * Find all groups that contain the specified item
     *
     * @param {Object} item 
     * @return {Ext.util.Group[]} 
     */
    getGroupsByItem: function(item) {
        var me = this,
            groups = [],
            length = me.items.length,
            i, group, children;
 
        if (item) {
            for (= 0; i < length; i++) {
                group = me.items[i];
 
                if (group.indexOf(item) >= 0) {
                    groups.push(group);
                    children = group.getGroups();
 
                    if (children) {
                        /* eslint-disable-next-line max-len */
                        return Ext.Array.insert(groups, groups.length, children.getGroupsByItem(item));
                    }
                }
            }
        }
 
        return groups;
    },
 
    //-------------------------------------------------------------------------
    // Calls from the source Collection:
 
    onCollectionAdd: function(source, details) {
        if (!this.isConfiguring) {
            this.addItemsToGroups(source, details.items, details.at);
        }
    },
 
    onCollectionBeforeItemChange: function(source, details) {
        this.changeDetails = details;
    },
 
    onCollectionBeginUpdate: function() {
        this.beginUpdate();
    },
 
    onCollectionEndUpdate: function() {
        this.endUpdate();
    },
 
    onCollectionItemChange: function(source, details) {
        // Check if the change to the item caused the item to move. If it did, the group
        // ordering will be handled by virtue of being removed/added to the collection.
        // If not, check whether we're in the correct group and fix up if not.
        if (!details.indexChanged) {
            this.syncItemGrouping(source, details);
        }
 
        this.changeDetails = null;
    },
 
    onCollectionRefresh: function(source) {
        if (source.generation) {
            // eslint-disable-next-line vars-on-top
            var me = this,
                itemGroupKeys = me.itemGroupKeys = {},
                groupData, entries, groupKey, i, len, entry, j;
 
            me.groupersChanged = true;
 
            groupData = me.createEntries(source, source.items);
            entries = groupData.entries;
 
            // The magic of Collection will automatically update the group with its new
            // members.
            for (= 0, len = entries.length; i < len; ++i) {
                entry = entries[i];
 
                // Will add or replace
                entry.group.splice(0, 1e99, entry.items);
 
                // Add item key -> group mapping for every entry
                for (= 0; j < entry.items.length; j++) {
                    itemGroupKeys[source.getKey(entry.items[j])] = entry.group;
                }
            }
 
            // Remove groups to which we have not added items.
            entries = null;
 
            for (groupKey in me.map) {
                if (!(groupKey in groupData.groups)) {
                    (entries || (entries = [])).push(me.map[groupKey]);
                }
            }
 
            if (entries) {
                me.remove(entries);
            }
 
            // autoSort is disabled when adding new groups because
            // it relies on there being at least one record in the group
            me.sortItems();
 
            me.groupersChanged = false;
        }
    },
 
    onCollectionRemove: function(source, details) {
        var me = this,
            groupers = source.getGroupers(),
            changeDetails = me.changeDetails,
            itemGroupKeys = me.itemGroupKeys || (me.itemGroupKeys = {}),
            entries, entry, group, i, n, j, removeGroups, item;
 
        if (!groupers || !groupers.length) {
            return;
        }
 
        if (source.getCount()) {
            if (changeDetails) {
                // The item has changed, so the group key may be different, need
                // to look it up
                item = changeDetails.item || changeDetails.items[0];
                entries = me.createEntries(source, [item], false).entries;
                entries[0].group =
                    itemGroupKeys['oldKey' in details ? details.oldKey : source.getKey(item)];
            }
            else {
                entries = me.createEntries(source, details.items, false).entries;
            }
 
            for (= 0, n = entries.length; i < n; ++i) {
                group = (entry = entries[i]).group;
 
                if (group) {
                    group.remove(entry.items);
                }
 
                // Delete any item key -> group mapping
                for (= 0; j < entry.items.length; j++) {
                    delete itemGroupKeys[source.getKey(entry.items[j])];
                }
 
                if (group && !group.length) {
                    (removeGroups || (removeGroups = [])).push(group);
                }
            }
        }
        // Straight cleardown
        else {
            me.itemGroupKeys = {};
            removeGroups = me.items;
 
            for (= 0, n = removeGroups.length; i < n; ++i) {
                removeGroups[i].clear();
            }
        }
 
        if (removeGroups) {
            me.remove(removeGroups);
        }
    },
 
    // If the SorterCollection instance is not changing, the Group will react to
    // changes inside the SorterCollection, but if the instance changes we need
    // to sync the Group to the new SorterCollection.
    onCollectionSort: function(source) {
        // sorting the collection effectively sorts the items in each group...
        var me = this,
            sorters = source.getSorters(false),
            items, length, i, group;
 
        if (sorters) {
            items = me.items;
            length = me.length;
 
            for (= 0; i < length; ++i) {
                group = items[i];
 
                if (group.getSorters() === sorters) {
                    group.sortItems();
                }
                else {
                    group.setSorters(sorters);
                }
            }
        }
    },
 
    onCollectionUpdateKey: function(source, details) {
        if (!details.indexChanged) {
            details.oldIndex = source.indexOf(details.item);
            this.syncItemGrouping(source, details);
        }
    },
 
    onCollectionGroupersChanged: function(source) {
        var me = this,
            groupers = source.getGroupers(),
            grouper;
 
        if (groupers.length > 0) {
            grouper = groupers.items[0];
            me.changeSorterFn(grouper);
 
            if (me.lastMonitoredGrouper) {
                me.lastMonitoredGrouper.removeObserver(me);
            }
 
            me.lastMonitoredGrouper = grouper;
            grouper.addObserver(me);
        }
        else {
            me.removeAll();
        }
    },
 
    onGrouperDirectionChange: function(grouper) {
        // if the grouper changes its direction then we need to sort again our groups
        this.changeSorterFn(grouper);
        this.onEndUpdateSorters(this.getSorters());
    },
 
    onEndUpdateSorters: function(sorters) {
        var me = this,
            was = me.sorted,
            is = (me.grouped && me.getAutoGroup()) ||
                (me.getAutoSort() && sorters && sorters.length > 0);
 
        if (was || is) {
            // ensure flag property is a boolean.
            // sorters && (sorters.length > 0) may evaluate to null
            me.sorted = !!is;
            me.onSortChange(sorters);
        }
    },
 
    //-------------------------------------------------------------------------
    // Private
 
    changeSorterFn: function(grouper) {
        var me = this,
            sorters = me.getSorters(),
            // this group collection needs to be sorted
            sorter = {
                root: me.getRootProperty()
            };
 
        sorter.direction = grouper.getDirection();
 
        if (grouper) {
            sorter.id = grouper.getProperty();
 
            if (grouper.initialConfig.sorterFn) {
                sorter.sorterFn = grouper.initialConfig.sorterFn;
            }
            else {
                sorter.property = grouper.getSortProperty() || grouper.getProperty();
            }
        }
 
        if (sorter.property || sorter.sorterFn) {
            if (sorters.length === 0) {
                sorters.add(sorter);
            }
            else {
                sorters.items[0].setConfig(sorter);
            }
        }
        else {
            sorters.clear();
        }
    },
 
    addItemsToGroups: function(source, items, at, oldIndex) {
        var me = this,
            itemGroupKeys = me.itemGroupKeys || (me.itemGroupKeys = {}),
            entries = me.createEntries(source, items).entries,
            index = -1,
            sourceStartIndex, entry, i, len, j, group, firstIndex, item;
 
        for (= 0, len = entries.length; i < len; ++i) {
            entry = entries[i];
            group = entry.group;
 
            // A single item moved - from onCollectionItemChange
            if (oldIndex || oldIndex === 0) {
                item = items[0];
 
                if (group.getCount() > 0 && source.getSorters().getCount() === 0) {
                    // We have items in the group & it's not sorted, so find the
                    // correct position in the group to insert.
                    firstIndex = source.indexOf(group.items[0]);
 
                    if (oldIndex < firstIndex) {
                        index = 0;
                    }
                    else {
                        index = oldIndex - firstIndex;
                    }
                }
 
                if (index === -1) {
                    group.add(item);
                }
                else {
                    group.insert(index, item);
                }
            }
            else {
                if (me.length > 1 && at) {
                    sourceStartIndex = source.indexOf(entries[0].group.getAt(0));
                    at = Math.max(at - sourceStartIndex, 0);
                }
 
                entry.group.insert(at != null ? at : group.items.length, entry.items);
 
                // Add item key -> group mapping
                for (= 0; j < entry.items.length; j++) {
                    itemGroupKeys[source.getKey(entry.items[j])] = entry.group;
                }
            }
        }
 
        // autoSort is disabled when adding new groups because
        // it relies on there being at least one record in the group
        me.sortItems();
    },
 
    createEntries: function(source, items, createGroups) {
        // Separate the items out into arrays by group
        var me = this,
            groups = {},
            entries = [],
            groupers = source.getGroupers().getRange(),
            grouper, entry, group, groupKey, i, item, len;
 
        if (groupers.length) {
            grouper = groupers.shift();
            groupers = groupers.length ? Ext.clone(groupers) : null;
 
            for (= 0, len = items.length; i < len; ++i) {
                groupKey = grouper.getGroupString(item = items[i]);
 
                if (!(entry = groups[groupKey])) {
                    group = me.getGroup(source, item, grouper, groupers, createGroups);
 
                    entries.push(groups[groupKey] = entry = {
                        group: group,
                        items: []
                    });
                }
 
                // Collect items to add/remove for each group
                // which has items in the array
                entry.items.push(item);
            }
        }
 
        return {
            groups: groups,
            entries: entries
        };
    },
 
    syncItemGrouping: function(source, details) {
        var me = this,
            itemGroupKeys = me.itemGroupKeys || (me.itemGroupKeys = {}),
            item = details.item,
            groupers = source.getGroupers().getRange(),
            grouper,
            oldKey,
            itemKey,
            oldGroup,
            group;
 
        if (!groupers.length) {
            return;
        }
 
        grouper = groupers.shift();
        groupers = groupers.length ? Ext.clone(groupers) : null;
 
        itemKey = source.getKey(item);
        oldKey = 'oldKey' in details ? details.oldKey : itemKey;
 
        // The group the item was in before the change took place.
        oldGroup = itemGroupKeys[oldKey];
 
        // Look up/create the group into which the item now must be added.
        group = me.getGroup(source, item, grouper, groupers);
 
        details.group = group;
        details.oldGroup = oldGroup;
        details.groupChanged = (group !== oldGroup);
 
        // The change did not cause a change in group
        if (group === oldGroup) {
            // Inform group about change
            oldGroup.itemChanged(item, details.modified, details.oldKey, details);
        }
        else {
            // Remove from its old group if there was one.
            if (oldGroup) {
                // Ensure Group knows about any unknown key changes, or item will not be removed.
                oldGroup.updateKey(item, oldKey, itemKey);
                oldGroup.remove(item);
 
                // Queue newly empy group for destruction.
                if (!oldGroup.length) {
                    me.remove(oldGroup);
                }
            }
 
            // Add to new group
            me.addItemsToGroups(source, [item], null, details.oldIndex);
        }
 
        // Keep item key -> group mapping up to date
        delete itemGroupKeys[oldKey];
        itemGroupKeys[itemKey] = group;
    },
 
    getGroup: function(source, item, grouper, groupers, createGroups) {
        var me = this,
            key = grouper.getGroupString(item),
            prop = grouper.getSortProperty(),
            root = grouper.getRoot(),
            group = me.get(key),
            autoSort = me.getAutoSort(),
            label;
 
        if (group) {
            group.setSorters(source.getSorters());
 
            if (me.groupersChanged) {
                // if the groupers changed then we need to update the groupers on the existing group
                label = group.getLabel();
                group.setLabel(null);
                group.setGroupers(groupers);
                group.setGrouper(grouper);
                group.setParent(source.isGroup ? source : me);
                group.setLabel(label);
            }
        }
        else if (createGroups !== false) {
            group = me.emptyGroups[key];
 
            if (group && group.destroyed) {
                delete me.emptyGroups[key];
                group = null;
            }
 
            if (group) {
                group.setLabel(null);
            }
            else {
                group = Ext.create(Ext.apply({
                    xclass: 'Ext.util.Group',
                    groupConfig: me.getGroupConfig()
                }, me.getGroupConfig()));
            }
 
            me.setAutoSort(false);
 
            group.setConfig({
                groupKey: key,
                grouper: grouper,
                groupers: groupers,
                label: key,
                rootProperty: me.getItemRoot(),
                sorters: source.getSorters(),
                autoSort: autoSort,
                autoGroup: me.getAutoGroup(),
                parent: source.isGroup ? source : me
            });
 
            group.ejectTime = null;
            me.add(group);
            me.setAutoSort(autoSort);
 
            if (prop) {
                group.setCustomData(prop, (root ? item[root] : item)[prop]);
            }
        }
 
        return group;
    },
 
    getKey: function(item) {
        return item.getGroupKey();
    },
 
    createSortFn: function() {
        return this.getSorters().getSortFn();
    },
 
    // override the collection fn
    getGrouper: function() {
        return this.lastMonitoredGrouper;
    },
 
    // override the collection fn
    updateGrouper: Ext.emptyFn,
 
    updateAutoGroup: function(autoGroup) {
        var len = this.length,
            i;
 
        // the group collection is not grouped but sorting could be
        // disabled when autoGroup is false in the source Collection
        this.setAutoSort(autoGroup);
 
        for (= 0; i < len; i++) {
            this.items[i].setAutoGroup(autoGroup);
        }
 
        // Important to call this so it can clear the .sorted flag
        // as needed
        this.onEndUpdateSorters(this._sorters);
    },
 
    destroy: function() {
        var me = this,
            grouper = me.lastMonitoredGrouper;
 
        if (grouper) {
            grouper.removeObserver(me);
        }
 
        me.$groupable = null;
 
        // Ensure group objects get destroyed, they may have
        // added listeners to the main collection sorters.
        me.destroyGroups(me.items);
        Ext.undefer(me.checkRemoveQueueTimer);
        me.callParent();
    },
 
    privates: {
        destroyGroups: function(groups) {
            var len = groups.length,
                i;
 
            for (= len - 1; i >= 0; i--) {
                groups[i].destroy();
            }
        },
 
        onGroupRemove: function(collection, info) {
            var me = this,
                groups = info.items,
                emptyGroups = me.emptyGroups,
                len, group, i;
 
            groups = Ext.Array.from(groups);
 
            for (= 0, len = groups.length; i < len; i++) {
                group = groups[i];
 
                group.eject();
                emptyGroups[group.getGroupKey()] = group;
            }
 
            // Removed empty groups are reclaimable by getGroup for
            // emptyGroupRetainTime milliseconds
            me.checkRemoveQueue();
        },
 
        checkRemoveQueue: function() {
            var me = this,
                emptyGroups = me.emptyGroups,
                groupKey, group, reschedule;
 
            for (groupKey in emptyGroups) {
                group = emptyGroups[groupKey];
 
                if (!group || group.destroyed) {
                    delete emptyGroups[groupKey];
                }
                // If the group's retain time has expired, destroy it.
                else if (!group.length && Ext.now() - group.ejectTime > me.emptyGroupRetainTime) {
                    Ext.destroy(group);
                    delete emptyGroups[groupKey];
                }
                else {
                    reschedule = true;
                }
            }
 
            // Still some to remove in the future. Check back in emptyGroupRetainTime
            if (reschedule) {
                Ext.undefer(me.checkRemoveQueueTimer);
                me.checkRemoveQueueTimer =
                    Ext.defer(me.checkRemoveQueue, me.emptyGroupRetainTime, me);
            }
        }
    }
});