/**
 * This type of store is a replacement for BufferedStore at least for Modern. The primary
 * use of this store is to create and manage "active ranges" of records.
 *
 * For example:
 *
 *      var range = store.createActiveRange({
 *          begin: 100,
 *          end: 200,
 *          prefetch: true,  // allow prefetching beyond range
 *          callback: 'onRangeUpdate',
 *          scope: this
 *      });
 *
 *      // Navigate to a different range. This will causes pages to load and
 *      // the onRangeUpdate method will be called as the load(s) progress.
 *      // This can change the length or number of records spanned on each
 *      // call.
 *      //
 *      range.goto(300, 400);
 *
 *      onRangeUpdate: function (range, begin, end) {
 *          // Called when records appear in the range...
 *          // We can check if all things are loaded:
 *
 *          // Or we can use range.records (sparsely populated)
 *      }
 *
 * @since 6.5.0
 */
Ext.define('Ext.data.virtual.Store', {
    extend: 'Ext.data.ProxyStore',
    alias: 'store.virtual',
 
    requires: [
        'Ext.util.SorterCollection',
        'Ext.util.FilterCollection',
 
        'Ext.data.virtual.PageMap',
        'Ext.data.virtual.Range'
    ],
 
    uses: [
        'Ext.data.virtual.Group'
    ],
 
    isVirtualStore: true,
 
    config: {
        data: null,
        totalCount: null,
        /**
         * @cfg {Number} leadingBufferZone
         * The number of records to fetch beyond the active range in the direction of
         * movement. If the range is advancing forward, the additional records are beyond
         * `end`. If advancing backwards, they are before `begin`.
         */
        leadingBufferZone: 200,
 
        /**
         * @cfg {Number} trailingBufferZone
         * The number of records to fetch beyond the active trailing the direction of
         * movement. If the range is advancing forward, the additional records are before
         * `begin`. If advancing backwards, they are beyond `end`.
         */
        trailingBufferZone: 50
    },
 
    /**
     * @cfg remoteSort
     * @inheritdoc
     */
    remoteSort: true,
 
    /**
     * @cfg remoteFilter
     * @inheritdoc
     */
    remoteFilter: true,
 
    /**
     * @cfg sortOnLoad
     * @inheritdoc
     */
    sortOnLoad: false,
 
    /**
     * @cfg trackRemoved
     * @inheritdoc
     */
    trackRemoved: false,
 
    constructor: function(config) {
        var me = this;
 
        me.sortByPage = me.sortByPage.bind(me);
 
        me.activeRanges = [];
 
        me.pageMap = new Ext.data.virtual.PageMap({
            store: me
        });
 
        me.callParent([ config ]);
    },
 
    doDestroy: function() {
        this.pageMap.destroy();
        this.callParent();
    },
 
    applyGrouper: function(grouper) {
        this.group(grouper);
 
        return this.grouper;
    },
 
    //-----------------------------------------------------------------------
 
    /**
     * @method contains
     * @inheritdoc
     */
    contains: function(record) {
        return this.indexOf(record) > -1;
    },
 
    /**
     * Create a `Range` instance to access records by their index.
     *
     * @param {Object/Ext.data.virtual.Range} [config]
     * @return {Ext.data.virtual.Range} 
     * @since 6.5.0
     */
    createActiveRange: function(config) {
        var range = Ext.apply({
            leadingBufferZone: this.getLeadingBufferZone(),
            trailingBufferZone: this.getTrailingBufferZone(),
            store: this
        }, config);
 
        return new Ext.data.virtual.Range(range);
    },
 
    /**
     * @method getAt
     * @inheritdoc
     */
    getAt: function(index) {
        var page = this.pageMap.getPageOf(index, /* autoCreate= */false),
            ret;
 
        if (page && page.records) { // if (page is loaded)
            ret = page.records[index - page.begin];
        }
 
        return ret || null;
    },
 
    /**
     * Get the Record with the specified id.
     *
     * This method is not affected by filtering, lookup will be performed from all records
     * inside the store, filtered or not.
     *
     * @param {Mixed} id The id of the Record to find.
     * @return {Ext.data.Model} The Record with the passed id. Returns null if not found.
     */
    getById: function(id) {
        return this.pageMap.byId[id] || null;
    },
 
    getCount: function() {
        return this.totalCount || 0;
    },
 
    getGrouper: function() {
        return this.grouper;
    },
 
    getGroups: function() {
        var me = this,
            groups = me.groupCollection;
 
        if (!groups) {
            me.groupCollection = groups = new Ext.util.Collection();
        }
 
        return groups;
    },
 
    getSummaryRecord: function() {
        return this.summaryRecord || null;
    },
 
    isGrouped: function() {
        return !!this.grouper;
    },
 
    group: function(grouper, direction) {
        var me = this;
 
        grouper = grouper || null;
 
        if (grouper) {
            if (typeof grouper === 'string') {
                grouper = {
                    property: grouper,
                    direction: direction || 'ASC'
                };
            }
 
            if (!grouper.isGrouper) {
                grouper = new Ext.util.Grouper(grouper);
            }
 
            grouper.setRoot('data');
 
            me.getGroups().getSorters().splice(0, 1, {
                property: 'id',
                direction: grouper.getDirection()
            });
        }
 
        me.grouper = grouper;
 
        if (!me.isConfiguring) {
            me.reloadRanges();
            me.fireGroupChange(grouper);
        }
    },
 
    getByInternalId: function(internalId) {
        return this.pageMap.getByInternalId(internalId);
    },
 
    /**
     * Get the index of the record within the virtual store. Because virtual stores only
     * load a partial set of records, not all records in the logically matching set will
     * have been loaded and will therefore return -1.
     *
     * @param {Ext.data.Model} record The record to find.
     * @return {Number} The index of the `record` or -1 if not found.
     */
    indexOf: function(record) {
        return this.pageMap.indexOf(record);
    },
 
    /**
     * Get the index within the store of the record with the passed id. Because virtual
     * stores only load a partial set of records, not all records in the logically
     * matching set will have been loaded and will therefore return -1.
     *
     * @param {String} id The id of the record to find.
     * @return {Number} The index of the record or -1 if not found.
     */
    indexOfId: function(id) {
        var rec = this.getById(id);
 
        return rec ? this.indexOf(rec) : -1;
    },
 
    /**
     * Returns `true` if the store has been loaded.
     * @return {Boolean} `true` if the store has been loaded.
     */
    isLoaded: function() {
        return Ext.isNumber(this.totalCount);
    },
 
    load: function(options) {
        options = options || {};
        // Load is a strange method here, since typically we want the view to control
        // how our data is to be loaded. However, the list won't automatically load the range
        // unless the store is autoLoad: true or has already loaded. We also want to be
        // able to support autoLoad: true. As such, the easiest thing we can do here is to
        // just jump out of the normal locking procedeure and just go and get the first page
        // since this is typically a one-off operation.
        // eslint-disable-next-line vars-on-top
        var page = options.page || 1,
            p = this.pageMap.getPage(page - 1, true);
 
        if (!p.isLoading()) {
            p.load({
                callback: options.callback,
                scope: options.scope
            });
        }
    },
 
    reload: function(options) {
        var me = this;
 
        if (typeof options === 'function') {
            options = {
                callback: options
            };
        }
 
        if (me.fireEvent('beforereload') === false) {
            return null;
        }
 
        options = Ext.apply({
            internalScope: me,
            internalCallback: me.handleReload,
            page: 1
        }, options);
 
        me.pageMap.clear();
        me.getGroups().clear();
 
        return me.loadInternal(options);
    },
 
    reloadRanges: function() {
        var activeRanges = this.activeRanges,
            i;
 
        this.pageMap.clear();
        this.getGroups().clear();
 
        for (= activeRanges.length; i-- > 0;) {
            activeRanges[i].reload();
        }
    },
 
    // TODO load?
    // TODO reload?
 
    removeAll: function() {
        var me = this,
            activeRanges = me.activeRanges,
            i;
 
        me.pageMap.clear();
 
        for (= activeRanges.length; i-- > 0;) {
            activeRanges[i].reset();
        }
 
        me.fireEvent('clear', me);
    },
 
    //---------------------------------------------------------------------
 
    applyProxy: function(proxy) {
        proxy = this.callParent([proxy]);
 
        // This store asks for pages.
        // If used with a MemoryProxy, it must work
        if (proxy && proxy.setEnablePaging) {
            proxy.setEnablePaging(true);
        }
 
        return proxy;
    },
 
    // createDataCollection: function () {
    //     var result = new Ext.data.virtual.Data({
    //             store: this
    //         });
    //
    //     return result;
    // },
 
    createFiltersCollection: function() {
        return new Ext.util.FilterCollection();
    },
 
    createSortersCollection: function() {
        return new Ext.util.SorterCollection();
    },
 
    onFilterEndUpdate: function() {
        var me = this,
            filters = me.getFilters(false);
 
        // This is not affected by suppressEvent.
        if (!me.isConfiguring) {
            me.reload();
            me.fireEvent('filterchange', me, filters.getRange());
        }
    },
 
    onSorterEndUpdate: function() {
        var me = this,
            sorters = me.getSorters().getRange(),
            fire = !me.isConfiguring;
 
        if (fire) {
            me.fireEvent('beforesort', me, sorters);
        }
 
        if (fire) {
            me.reloadRanges();
            me.fireEvent('sort', me, sorters);
        }
    },
 
    updatePageSize: function(pageSize) {
        var totalCount = this.totalCount;
 
        if (totalCount !== null) {
            this.pageMap.setPageCount(Math.ceil(totalCount / pageSize));
        }
    },
 
    updateTotalCount: function(totalCount, oldTotalCount) {
        var me = this,
            pageMap = me.pageMap;
 
        me.totalCount = totalCount;
 
        pageMap.setPageCount(Math.ceil(totalCount / me.getPageSize()));
 
        me.fireEvent('totalcountchange', me, totalCount, oldTotalCount);
    },
 
    //--------------------------------------------------------
    // Unsupported API's
 
    //<debug>
    add: function() {
        Ext.raise('Virtual stores do not support the add() method');
    },
 
    insert: function() {
        Ext.raise('Virtual stores do not support the insert() method');
    },
 
    filter: function() {
        if (!this.getRemoteFilter()) {
            Ext.raise('Virtual stores do not support local filtering');
        }
 
        // Remote filtering forces a load. load clears the store's contents.
        this.callParent(arguments);
    },
 
    filterBy: function() {
        Ext.raise('Virtual stores do not support local filtering');
    },
 
    loadData: function() {
        Ext.raise('Virtual stores do not support the loadData() method');
    },
 
    applyData: function() {
        Ext.raise('Virtual stores do not support direct data loading');
    },
 
    updateRemoteFilter: function(remoteFilter, oldRemoteFilter) {
        if (remoteFilter === false) {
            Ext.raise('Virtual stores are always remotely filtered.');
        }
 
        this.callParent([remoteFilter, oldRemoteFilter]);
    },
 
    updateRemoteSort: function(remoteSort, oldRemoteSort) {
        if (remoteSort === false) {
            Ext.raise('Virtual stores are always remotely sorted.');
        }
 
        this.callParent([remoteSort, oldRemoteSort]);
    },
 
    updateTrackRemoved: function(value) {
        if (value !== false) {
            Ext.raise('Virtual stores do not support trackRemoved.');
        }
 
        this.callParent(arguments);
    },
    //</debug>
 
    afterEdit: function(record, modifiedFieldNames) {
        var me = this;
 
        me.fireEvent('update', me, record, Ext.data.Model.EDIT, modifiedFieldNames);
        me.fireEvent('datachanged', me);
    },
 
    privates: {
        attachSummaryData: function(resultSet) {
            var me = this,
                summary = resultSet.getSummaryData(),
                grouper, len, i, data, rec;
 
            if (summary) {
                me.summaryRecord = summary;
            }
 
            summary = resultSet.getGroupData();
 
            if (summary) {
                grouper = me.getGrouper();
 
                if (grouper) {
                    me.groupSummaryData = data = {};
 
                    for (= 0, len = summary.length; i < len; ++i) {
                        rec = summary[i];
                        data[grouper.getGroupString(rec)] = rec;
                    }
                }
            }
        },
 
        handleReload: function(op) {
            var me = this,
                activeRanges = me.activeRanges,
                len = activeRanges.length,
                pageMap = me.pageMap,
                resultSet = op.getResultSet(),
                wasSuccessful = op.wasSuccessful(),
                rsRecords = [],
                i, range;
 
            if (wasSuccessful) {
                me.readTotalCount(resultSet);
                me.fireEvent('reload', me, op);
 
                for (= 0; i < len; ++i) {
                    range = activeRanges[i];
 
                    if (pageMap.canSatisfy(range)) {
                        range.reload();
                    }
                }
            }
 
            if (resultSet) {
                rsRecords = resultSet.records;
            }
 
            me.fireEvent('load', me, rsRecords, wasSuccessful, op);
        },
 
        loadInternal: function(options) {
            if (typeof options === 'function') {
                options = {
                    callback: options
                };
            }
 
            /* eslint-disable-next-line vars-on-top */
            var me = this,
                page = (options && options.page) || 1,
                pageSize = me.getPageSize(),
                operation = me.createOperation('read', Ext.apply({
                    start: (page - 1) * pageSize,
                    limit: pageSize,
                    page: page,
 
                    filters: me.getFilters().items,
                    sorters: me.getSorters().items,
                    grouper: me.getGrouper()
                }, options));
 
            if (me.fireEvent('beforeload', me, operation) !== false) {
                me.onBeforeLoad(operation);
                operation.execute();
            }
            else {
                operation.setCompleted();
            }
 
            return operation;
        },
 
        loadVirtualPage: function(page, callback, scope, loadOptions) {
            // loadOptions will only be provided in the case where a user
            // has called directly, so this will be pretty rare.
            var me = this,
                pageMapGeneration = me.pageMap.generation;
 
            return me.loadInternal(Ext.apply({
                page: page.number + 1, // store loads are 1 based
                internalCallback: function(op) {
                    var resultSet = op.getResultSet(),
                        rsRecords = [];
 
                    if (pageMapGeneration === me.pageMap.generation) {
                        if (op.wasSuccessful()) {
                            me.readTotalCount(resultSet);
 
                            me.attachSummaryData(resultSet);
                        }
 
                        callback.call(scope || page, op);
                        me.groupSummaryData = null;
 
                        if (resultSet) {
                            rsRecords = resultSet.records;
                        }
 
                        me.fireEvent('load', me, rsRecords, op.wasSuccessful(), op);
                    }
                }
            }, loadOptions));
        },
 
        lockGroups: function(grouper, page) {
            var groups = this.getGroups(),
                groupInfo = page.groupInfo = {},
                records = page.records,
                len = records.length,
                groupSummaryData = this.groupSummaryData,
                pageMap = this.pageMap,
                n = page.number,
                group, i, groupKey, summaryRec,
                rec, firstRecords, first;
 
            for (= 0; i < len; ++i) {
                rec = records[i];
                groupKey = grouper.getGroupString(rec);
 
                if (!groupInfo[groupKey]) {
                    groupInfo[groupKey] = rec;
 
                    group = groups.get(groupKey);
 
                    if (!group) {
                        group = new Ext.data.virtual.Group(groupKey);
                        groups.add(group);
                    }
 
                    // We want to track the first known record in the group.
                    // If we have a record that is before the first one we know
                    // about, add it to the front. Otherwise, we don't care about
                    // the order at this point, so just shift it on to the end.
                    firstRecords = group.firstRecords;
                    first = firstRecords[0];
 
                    if (first && n < pageMap.getPageIndex(first)) {
                        firstRecords.unshift(rec);
                    }
                    else {
                        firstRecords.push(rec);
                    }
 
                    summaryRec = groupSummaryData && groupSummaryData[groupKey];
 
                    if (summaryRec) {
                        group.summaryRecord = summaryRec;
                    }
                }
            }
        },
 
        onPageDataAcquired: function(page) {
            var grouper = this.getGrouper();
 
            if (grouper) {
                this.lockGroups(grouper, page);
            }
        },
 
        onPageDestroy: function(page) {
            var ranges = this.activeRanges,
                len = ranges.length,
                i;
 
            for (= 0; i < len; ++i) {
                ranges[i].onPageDestroy(page);
            }
        },
 
        onPageEvicted: function(page) {
            var grouper = this.getGrouper();
 
            if (grouper) {
                this.releaseGroups(grouper, page);
            }
        },
 
        readTotalCount: function(resultSet) {
            var total = resultSet.getRemoteTotal();
 
            if (!isNaN(total)) {
                this.setTotalCount(total);
            }
        },
 
        releaseGroups: function(grouper, page) {
            var groups = this.getGroups(),
                groupInfo = page.groupInfo,
                first, firstRecords, key, group;
 
            for (key in groupInfo) {
                first = groupInfo[key];
                group = groups.get(key);
                firstRecords = group.firstRecords;
 
                // If there is only 1 first record left, this must be it, which
                // means the group no longer has records
                if (firstRecords.length === 1) {
                    groups.remove(group);
                }
                else if (firstRecords[0] === first) {
                    firstRecords.shift();
                    firstRecords.sort(this.sortByPage);
                }
                else {
                    Ext.Array.remove(firstRecords, first);
                }
            }
        },
 
        sortByPage: function(rec1, rec2) {
            // Bound to this instance in the constructor
            var map = this.pageMap;
 
            return map.getPageIndex(rec1) - map.getPageIndex(rec2);
        }
    }
});