/**
 * This type of matrix does all calculations in the browser.
 *
 * You need to provide at least a store that contains the records
 * that need to be processed.
 *
 * The store records are processed in batch jobs to avoid freezing the browser.
 * You can also configure how many records should be processed per job
 * and time to wait between jobs.
 *
 *
 * Example:
 *
 *      {
 *          xtype: 'pivotgrid',
 *          matrix: {
 *              type: 'local',
 *              store: 'yourStore',
 *              leftAxis: [...],
 *              topAxis: [...],
 *              aggregate: [...]
 *          }
 *      }
 *
 */
Ext.define('Ext.pivot.matrix.Local', {
    alternateClassName: [
        'Mz.aggregate.matrix.Local'
    ],

    extend: 'Ext.pivot.matrix.Base',

    alias: 'pivotmatrix.local',

    requires: [
        'Ext.pivot.matrix.Base',
        'Ext.pivot.axis.Local',
        'Ext.pivot.result.Local'
    ],

    isLocalMatrix: true,

    resultType: 'local',
    leftAxisType: 'local',
    topAxisType: 'local',

    /**
     * Fires before updating the matrix data due to a change in the bound store.
     *
     * @event beforeupdate
     * @param {Ext.pivot.matrix.Base} matrix Reference to the Matrix object
     * @private
     */

    /**
     * Fires after updating the matrix data due to a change in the bound store.
     *
     * @event afterupdate
     * @param {Ext.pivot.matrix.Base} matrix Reference to the Matrix object
     * @private
     */

    /**
     * @cfg {Ext.data.Store/String} store (required)
     *
     * This is the store that needs to be processed. The store should contain all records
     * and cannot be paginated or buffered.
     */
    store: null,

    /**
     * @cfg {Number} recordsPerJob
     *
     * The matrix processes the records in multiple jobs.
     * Specify here how many records should be processed in a single job.
     */
    recordsPerJob: 1000,

    /**
     * @cfg {Number} timeBetweenJobs
     *
     * How many milliseconds between processing jobs?
     */
    timeBetweenJobs: 2,

    onInitialize: function() {
        var me = this;

        me.localDelayedTask = new Ext.util.DelayedTask(me.delayedProcess, me);
        me.storeCleanDelayedTask = new Ext.util.DelayedTask(me.onOriginalStoreCleanDelayed, me);
        me.storeChangedDelayedTask = new Ext.util.DelayedTask(me.onOriginalStoreChangedDelayed, me);

        me.initializeStore({ store: me.store });

        me.callParent(arguments);
    },

    initializeStore: function(config) {
        var me = this,
            store, newStore;

        me.processedRecords = {};

        if (config.store) {
            // a new store was passed to
            newStore = config.store;
        }
        else {
            if (me.store) {
                if (me.store.isStore && !me.storeListeners) {
                    // we have a store but no listeners were attached to it
                    store = me.store;
                }
                else {
                    // we need to initialize the store that we got
                    newStore = me.store;
                }
            }
        }

        if (newStore) {
            store = Ext.getStore(newStore || '');

            if (Ext.isEmpty(store) && Ext.isString(newStore)) {
                store = Ext.create(newStore);
            }
        }

        if (store && store.isStore) {
            Ext.destroy(me.storeListeners);

            if (me.store && me.store.autoDestroy && store !== me.store) {
                Ext.destroy(me.store);
            }

            // let's initialize the store (if needed)
            me.store = store;
            // add listeners to the store
            me.storeListeners = me.store.on({
                refresh: me.startProcess,
                beforeload: me.onOriginalStoreBeforeLoad,
                add: me.onOriginalStoreAdd,
                update: me.onOriginalStoreUpdate,
                remove: me.onOriginalStoreRemove,
                commit: me.onOriginalStoreClean,
                reject: me.onOriginalStoreClean,
                clear: me.startProcess,
                scope: me,
                destroyable: true
            });

            if (store.isLoaded()) {
                me.startProcess();
            }
        }
    },

    onReconfigure: function(config) {
        this.initializeStore(config);
        this.callParent(arguments);
    },

    onDestroy: function() {
        var me = this;

        me.localDelayedTask.cancel();
        me.localDelayedTask = null;
        me.storeCleanDelayedTask.cancel();
        me.storeCleanDelayedTask = null;
        me.storeChangedDelayedTask.cancel();
        me.storeChangedDelayedTask = null;

        if (Ext.isArray(me.records)) {
            me.records.length = 0;
        }

        me.records = me.changedRecords = null;

        Ext.destroy(me.storeListeners);

        if (me.store && me.store.isStore && me.store.autoDestroy) {
            Ext.destroy(me.store);
        }

        me.store = me.storeListeners = me.processedRecords = null;

        me.callParent(arguments);
    },

    /**
     * @private
     */
    onOriginalStoreBeforeLoad: function(store) {
        this.fireEvent('start', this);
    },

    /**
     * @private
     */
    createStoreChangesQueue: function() {
        var me = this;

        me.changedRecords = me.changedRecords || {};
        me.changedRecords.add = me.changedRecords.add || [];
        me.changedRecords.update = me.changedRecords.update || [];
        me.changedRecords.remove = me.changedRecords.remove || [];
    },

    /**
     * @private
     */
    dropStoreChangesQueue: function() {
        var me = this;

        if (me.changedRecords) {
            me.changedRecords.add.length = 0;
            me.changedRecords.update.length = 0;
            me.changedRecords.remove.length = 0;
        }
    },

    /**
     * @private
     */
    onOriginalStoreAdd: function(store, records) {
        var me = this;

        me.createStoreChangesQueue();
        Ext.Array.insert(me.changedRecords.add, me.changedRecords.add.length, records);
        me.storeChangedDelayedTask.delay(100);
    },

    /**
     * @private
     */
    onOriginalStoreUpdate: function(store, record) {
        var me = this;

        me.createStoreChangesQueue();

        // if this record was previously added to the store then we don't do anything
        if (Ext.Array.indexOf(me.changedRecords.add, record) < 0) {
            Ext.Array.insert(me.changedRecords.update, me.changedRecords.update.length, [record]);
        }

        me.storeChangedDelayedTask.delay(100);
    },

    /**
     * @private
     */
    onOriginalStoreRemove: function(store, records, index, isMove) {
        var me = this,
            len = records.length,
            i;

        if (isMove) {
            // don't do anything. nothing changed in the data
            return;
        }

        me.createStoreChangesQueue();

        for (i = 0; i < len; i++) {
            // if this record was previously updated then remove it from the update queue
            Ext.Array.remove(me.changedRecords.update, records[i]);
            // if this record was previously added then remove it from the add queue
            Ext.Array.remove(me.changedRecords.add, records[i]);
        }

        Ext.Array.insert(me.changedRecords.remove, me.changedRecords.remove.length, records);
        me.storeChangedDelayedTask.delay(100);
    },

    onOriginalStoreChangedDelayed: function() {
        var me = this,
            records = me.changedRecords;

        if (me.isDestroyed) {
            return;
        }

        me.storeChanged = !!(records.add.length || records.update.length || records.remove.length);

        if (me.storeChanged) {
            me.onOriginalStoreAddDelayed();
            me.onOriginalStoreUpdateDelayed();
            me.onOriginalStoreRemoveDelayed();
        }
    },

    /**
     * @private
     */
    onOriginalStoreAddDelayed: function() {
        var me = this,
            items = [],
            changed = false,
            len, i, records, record, obj;

        records = me.changedRecords.add;
        len = records.length;

        if (!len) {
            return;
        }

        for (i = 0; i < len; i++) {
            record = records[i];
            me.processRecord(record, i, len);

            obj = me.processedRecords[record.internalId];

            changed = changed || obj.left.length || obj.top.length;

            if (obj.left.length) {
                Ext.Array.insert(items, items.length, obj.left);
            }
        }

        records.length = 0;

        if (changed) {
            me.leftAxis.rebuildTree();
            me.topAxis.rebuildTree();
        }

        // the new records might have created new groups on left axis
        // which means that we need to create subtotals for them
        len = items.length;

        if (len) {
            for (i = 0; i < len; i++) {
                obj = items[i];

                if ((obj.children && !obj.records) || (!obj.children && !obj.record)) {
                    me.addRecordToPivotStore(obj);
                }
            }
        }

        me.recalculateResults(me.store, records, changed);
    },

    /**
     * @private
     */
    onOriginalStoreUpdateDelayed: function() {
        var me = this,
            items = [],
            changed = false,
            len, i, records, record, obj, prev, current, sameLeft, sameTop;

        records = me.changedRecords.update;
        len = records.length;

        if (!len) {
            return;
        }

        for (i = 0; i < len; i++) {
            record = records[i];

            prev = me.processedRecords[record.internalId];
            me.removeRecordFromResults(record);
            me.processRecord(record, i, len);
            current = me.processedRecords[record.internalId];

            // check if the record changes the top/left axis structure
            if (prev && current) {
                sameLeft = Ext.Array.equals(prev.left, current.left);
                sameTop = Ext.Array.equals(prev.top, current.top);

                changed = changed || !sameLeft || !sameTop;

                if (!sameLeft) {
                    Ext.Array.insert(items, items.length, current.left);
                }
            }
        }

        records.length = 0;

        if (changed) {
            me.leftAxis.rebuildTree();
            me.topAxis.rebuildTree();
        }

        // the updated records might have created new groups on left axis
        // which means that we need to create subtotals for them
        len = items.length;

        for (i = 0; i < len; i++) {
            obj = items[i];

            if ((obj.children && !obj.records) || (!obj.children && !obj.record)) {
                me.addRecordToPivotStore(obj);
            }
        }

        me.recalculateResults(me.store, records, changed);
    },

    /**
     * @private
     */
    onOriginalStoreRemoveDelayed: function() {
        var me = this,
            len, i, records, changed;

        records = me.changedRecords.remove;
        len = records.length;

        if (!len) {
            return;
        }

        for (i = 0; i < len; i++) {
            changed = me.removeRecordFromResults(records[i]) || changed;
        }

        records.length = 0;

        if (changed) {
            me.leftAxis.rebuildTree();
            me.topAxis.rebuildTree();
        }

        me.recalculateResults(me.store, records, changed);
    },

    /**
     * @private
     */
    onOriginalStoreClean: function() {
        var me = this;

        if (me.localDelayedTask.id) {
            // a complete recalculation has been started and the task is queued
            // which means that we need to drop queued store changes
            me.dropStoreChangesQueue();
            me.storeChanged = false;
        }
        else {
            me.storeCleanDelayedTask.delay(100);
        }
    },

    /**
     * @private
     */
    onOriginalStoreCleanDelayed: function() {
        var me = this,
            records, length, i;

        if (me.isDestroyed) {
            return;
        }

        records = me.pivotStore.getRange();
        length = records.length;

        for (i = 0; i < length; i++) {
            records[i].commit(true);
        }

        me.storeChanged = false;
        me.fireEvent('afterupdate', me, false);
    },

    /**
     * @private
     */
    removeRecordFromResults: function(record) {
        var me = this,
            obj = me.processedRecords[record.internalId],
            grandTotalKey = me.grandTotalKey,
            changed = false,
            result, item, i, j, len, length;

        if (!obj) {
            // something's wrong here; the record should be there
            return changed;
        }

        result = me.results.get(grandTotalKey, grandTotalKey);

        if (result) {
            result.removeRecord(record);

            if (result.records.length === 0) {
                me.results.remove(grandTotalKey, grandTotalKey);
            }
        }

        len = obj.top.length;

        for (i = 0; i < len; i++) {
            item = obj.top[i];
            result = me.results.get(grandTotalKey, item.key);

            if (result) {
                result.removeRecord(record);

                if (result.records.length === 0) {
                    me.results.remove(grandTotalKey, item.key);
                    // the item needs to be removed
                    me.topAxis.items.remove(item);
                    changed = true;
                }
            }
        }

        len = obj.left.length;

        for (i = 0; i < len; i++) {
            item = obj.left[i];
            result = me.results.get(item.key, grandTotalKey);

            if (result) {
                result.removeRecord(record);

                if (result.records.length === 0) {
                    me.results.remove(item.key, grandTotalKey);
                    // the item needs to be removed
                    me.leftAxis.items.remove(item);
                    changed = true;
                }
            }

            length = obj.top.length;

            for (j = 0; j < length; j++) {
                result = me.results.get(obj.left[i].key, obj.top[j].key);

                if (result) {
                    result.removeRecord(record);

                    if (result.records.length === 0) {
                        me.results.remove(obj.left[i].key, obj.top[j].key);
                    }
                }
            }
        }

        return changed;
    },

    /**
     * @private
     */
    recalculateResults: function(store, records, changed) {
        var me = this;

        me.fireEvent('beforeupdate', me, changed);

        me.buildModelAndColumns();
        // recalculate all results
        me.results.calculate();
        // now update the pivot store records
        Ext.Array.each(me.leftAxis.getTree(), me.updateRecordToPivotStore, me);
        // update all grand totals
        me.updateGrandTotalsToPivotStore();

        // 'changed' means that the structure of left/top axis has changed
        me.fireEvent('afterupdate', me, changed);
    },

    /**
     * @private
     */
    updateGrandTotalsToPivotStore: function() {
        var me = this,
            totals = [],
            i;

        if (me.totals.length <= 0) {
            return;
        }

        totals.push({
            title: me.textGrandTotalTpl,
            values: me.preparePivotStoreRecordData({ key: me.grandTotalKey })
        });

        // additional grand totals can be added. collect these using events or 
        if (Ext.isFunction(me.onBuildTotals)) {
            me.onBuildTotals(totals);
        }

        me.fireEvent('buildtotals', me, totals);

        // update records to the pivot store for each grand total
        if (me.totals.length === totals.length) {
            for (i = 0; i < me.totals.length; i++) {
                if (Ext.isObject(totals[i]) && Ext.isObject(totals[i].values) &&
                    (me.totals[i].record instanceof Ext.data.Model)) {
                    delete(totals[i].values.id);
                    me.totals[i].record.set(totals[i].values);
                }
            }
        }
    },

    /**
     * @private
     */
    updateRecordToPivotStore: function(item) {
        var me = this,
            dataIndex, data;

        if (!item.children) {
            if (item.record) {
                data = me.preparePivotStoreRecordData(item);
                delete(data.id);
                item.record.set(data);
            }
        }
        else {
            // update all pivot store records of this item
            if (item.records) {
                dataIndex =
                    (me.viewLayoutType === 'compact' ? me.compactViewKey : item.dimensionId);
                data = me.preparePivotStoreRecordData(item);
                delete(data.id);
                delete(data[dataIndex]);

                item.records.collapsed.set(data);

                if (item.records.expanded) {
                    item.records.expanded.set(data);
                }

                if (item.records.footer) {
                    item.records.footer.set(data);
                }
            }

            Ext.Array.each(item.children, me.updateRecordToPivotStore, me);
        }
    },

    startProcess: function() {
        var me = this;

        // if we don't have a store then do nothing
        if (!me.store || (me.store && !me.store.isStore) || me.isDestroyed ||
            me.store.isLoading()) {
            // nothing to do
            return;
        }

        // if there are queued changes then drop them because we will recalculate everything
        me.dropStoreChangesQueue();

        me.clearData();

        me.localDelayedTask.delay(50);
    },

    delayedProcess: function() {
        var me = this;

        if (me.isDestroyed) {
            return;
        }

        // let's start the process
        me.fireEvent('start', me);

        me.records = me.store.getRange();

        if (me.records.length === 0) {
            me.endProcess();

            return;
        }

        me.statusInProgress = false;

        me.processRecords(0);
    },

    processRecords: function(position) {
        var me = this,
            i = position,
            totalLength;

        // don't do anything if the matrix was destroyed while doing calculations.
        if (me.isDestroyed) {
            return;
        }

        totalLength = me.records.length;

        me.statusInProgress = true;

        while (i < totalLength && i < position + me.recordsPerJob && me.statusInProgress) {
            me.processRecord(me.records[i], i, totalLength);
            i++;
        }

        // if we reached the last record then stop the process
        if (i >= totalLength) {
            me.statusInProgress = false;

            // now that the cells matrix was built let's calculate the aggregates
            me.results.calculate();

            // let's build the trees and apply value filters
            me.leftAxis.buildTree();
            me.topAxis.buildTree();

            // recalculate everything after applying the value filters
            if (me.filterApplied) {
                me.results.calculate();
            }

            me.records = null;
            me.endProcess();

            return;
        }

        // if the matrix was not reconfigured meanwhile then start a new job
        if (me.statusInProgress && totalLength > 0) {
            Ext.defer(me.processRecords, me.timeBetweenJobs, me, [i]);
        }
    },

    /**
     * Process the specified record and fire the 'progress' event
     *
     * @private
     */
    processRecord: function(record, index, total) {
        var me = this,
            grandTotalKey = me.grandTotalKey,
            leftItems, topItems, i, j, len, length, records, item;

        // we keep track of processed records so that if they are changed we could
        // adjust the matrix calculations/tree
        me.processedRecords[record.internalId] = records = {
            left: [],
            top: []
        };

        // if null is returned that means it was filtered out
        // if array was returned that means it is valid
        leftItems = me.leftAxis.processRecord(record);
        topItems = me.topAxis.processRecord(record);

        // left and top items are added to their respective axis if the record
        // is not filtered out on both axis
        if (leftItems && topItems) {
            me.results.add(grandTotalKey, grandTotalKey).addRecord(record);

            len = topItems.length;

            for (i = 0; i < len; i++) {
                item = topItems[i];
                me.topAxis.addItem(item);
                records.top.push(me.topAxis.items.map[item.key]);
                me.results.add(grandTotalKey, item.key).addRecord(record);
            }

            length = leftItems.length;

            for (i = 0; i < length; i++) {
                item = leftItems[i];
                me.leftAxis.addItem(item);
                records.left.push(me.leftAxis.items.map[item.key]);

                me.results.add(item.key, grandTotalKey).addRecord(record);

                for (j = 0; j < len; j++) {
                    me.results.add(item.key, topItems[j].key).addRecord(record);
                }
            }
        }

        me.fireEvent('progress', me, index + 1, total);
    },

    /**
     * Fetch all records that belong to the specified row group
     *
     * @param {String} key Row group key
     */
    getRecordsByRowGroup: function(key) {
        var results = this.results.getByLeftKey(key),
            length = results.length,
            records = [],
            i;

        for (i = 0; i < length; i++) {
            Ext.Array.insert(records, records.length, results[i].records || []);
        }

        return records;
    },

    /**
     * Fetch all records that belong to the specified col group
     *
     * @param {String} key Col group key
     */
    getRecordsByColGroup: function(key) {
        var results = this.results.getByTopKey(key),
            length = results.length,
            records = [],
            i;

        for (i = 0; i < length; i++) {
            Ext.Array.insert(records, records.length, results[i].records || []);
        }

        return records;
    },

    /**
     * Fetch all records that belong to the specified row/col group
     *
     * @param {String} rowKey Row group key
     * @param {String} colKey Col group key
     */
    getRecordsByGroups: function(rowKey, colKey) {
        var result = this.results.get(rowKey, colKey);

        return (result ? result.records || [] : []);
    }
});
