/**
 * This class is used to bulk schedule a set of `Ext.util.Schedulable` items. The items
 * in the scheduler request time by calling their `schedule` method and when the time has
 * arrived its `react` method is called.
 *
 * The `react` methods are called in dependency order as determined by the sorting process.
 * The sorting process relies on each item to implement its own `sort` method.
 *
 * @private
 */
Ext.define('Ext.util.Scheduler', {
    mixins: [
        'Ext.mixin.Observable'
    ],
 
    requires: [
        'Ext.util.Bag'
    ],
 
    busyCounter: 0,
    lastBusyCounter: 0,
 
    destroyed: false,
 
    firing: null,
 
    notifyIndex: -1,
 
    nextId: 0,
 
    orderedItems: null,
 
    passes: 0,
 
    scheduledCount: 0,
 
    validIdRe: null,
 
    config: {
        /**
         * @cfg {Number} cycleLimit
         * The maximum number of iterations to make over the items in one `notify` call.
         * This is used to prevent run away logic from looping infinitely. If this limit
         * is exceeded, an error is thrown (in development builds).
         * @private
         */
        cycleLimit: 5,
 
        /**
         * @cfg {String/Function} preSort
         * If provided the `Schedulable` items will be pre-sorted by this function or
         * property value before the dependency sort.
         */
        preSort: null,
 
        /**
         * @cfg {Number} tickDelay
         * The number of milliseconds to delay notification after the first `schedule`
         * request.
         */
        tickDelay: 5
    },
 
    /**
     * @property {Boolean} suspendOnNotify
     * `true` to suspend layouts when the scheduler is triggering bindings. Setting this to `false`
     * may mean multiple layout runs on a single bind call which could affect performance.
     */
    suspendOnNotify: true,
 
    constructor: function(config) {
        //<debug>
        if (Ext.util.Scheduler.instances) {
            Ext.util.Scheduler.instances.push(this);
        }
        else {
            Ext.util.Scheduler.instances = [ this ];
        }
 
        this.id = Ext.util.Scheduler.count = (Ext.util.Scheduler.count || 0) + 1;
        //</debug>
 
        this.mixins.observable.constructor.call(this, config);
 
        this.items = new Ext.util.Bag();
    },
 
    destroy: function() {
        var me = this,
            timer = me.timer;
 
        if (timer) {
            window.clearTimeout(timer);
            me.timer = null;
        }
 
        me.items.destroy();
        me.items = me.orderedItems = null;
 
        me.callParent();
 
        //<debug>
        Ext.Array.remove(Ext.util.Scheduler.instances, this);
        //</debug>
    },
 
    /**
     * Adds an item to the scheduler. This is called internally by the `constructor` of
     * `{@link Ext.util.Schedulable}`.
     *
     * @param {Object} item The item to add.
     * @private
     * @since 5.0.0
     */
    add: function(item) {
        var me = this,
            items = me.items;
 
        if (items === me.firing) {
            me.items = items = items.clone();
        }
 
        item.id = item.id || ++me.nextId;
        item.scheduler = me;
 
        items.add(item);
 
        if (!me.sortMap) {
            // If we are sorting we don't want to invalidate this... we will pick up the
            // new items just fine.
            me.orderedItems = null;
        }
    },
 
    /**
     * Removes an item to the scheduler. This is called internally by the `destroy` method
     * of `{@link Ext.util.Schedulable}`.
     *
     * @param {Object} item The item to remove.
     * @private
     * @since 5.0.0
     */
    remove: function(item) {
        var me = this,
            items = me.items;
 
        if (me.destroyed) {
            return;
        }
 
        //<debug>
        if (me.sortMap) {
            Ext.raise('Items cannot be removed during sort');
        }
        //</debug>
 
        if (items === me.firing) {
            me.items = items = items.clone();
        }
 
        if (item.scheduled) {
            me.unscheduleItem(item);
            item.scheduled = false;
        }
 
        items.remove(item);
 
        me.orderedItems = null;
    },
 
    /**
     * This method is called internally as needed to sort or resort the items in their
     * proper dependency order.
     *
     * @private
     * @since 5.0.0
     */
    sort: function() {
        var me = this,
            items = me.items,
            sortMap = {},
            preSort = me.getPreSort(),
            i, item;
 
        me.orderedItems = [];
        me.sortMap = sortMap;
 
        //<debug>
        me.sortStack = [];
        //</debug>
 
        if (preSort) {
            items.sort(preSort);
        }
 
        items = items.items; // grab the items array
 
        // We reference items.length since items can be added during this loop
        for (= 0; i < items.length; ++i) {
            item = items[i];
 
            if (!sortMap[item.id]) {
                me.sortItem(item);
            }
        }
 
        me.sortMap = null;
 
        //<debug>
        me.sortStack = null;
        //</debug>
    },
 
    /**
     * Adds one item to the sorted items array. This can be called by the `sort` method of
     * `{@link Ext.util.Sortable sortable}` objects to add an item on which it depends.
     *
     * @param {Object} item The item to add.
     * @return {Ext.util.Scheduler} This instance.
     * @since 5.0.0
     */
    sortItem: function(item) {
        var me = this,
            sortMap = me.sortMap,
            orderedItems = me.orderedItems,
            itemId;
 
        if (!item.scheduler) {
            me.add(item);
        }
 
        itemId = item.id;
 
        //<debug>
        if (item.scheduler !== me) {
            Ext.raise('Item ' + itemId + ' belongs to another Scheduler');
        }
 
        me.sortStack.push(item);
 
        if (sortMap[itemId] === 0) {
            // eslint-disable-next-line vars-on-top
            for (var cycle = [], i = 0; i < me.sortStack.length; ++i) {
                cycle[i] = me.sortStack[i].getFullName();
            }
 
            Ext.raise('Dependency cycle detected: ' + cycle.join('\n --> '));
        }
        //</debug>
 
        if (!(itemId in sortMap)) {
            // In production builds the above "if" will kick out the items that have
            // already been added (which it must) but also those that are being added
            // and have created a cycle (by virtue of the setting to 0). This check
            // should not be needed if cycles were all detected and removed in dev but
            // this is better than infinite recursion.
            sortMap[itemId] = 0;
 
            if (!item.sort.$nullFn) {
                item.sort();
            }
 
            sortMap[itemId] = 1;
 
            item.order = me.orderedItems.length;
            orderedItems.push(item);
        }
 
        //<debug>
        me.sortStack.pop();
        //</debug>
 
        return me;
    },
 
    /**
     * Adds multiple items to the sorted items array. This can be called by the `sort`
     * method of `{@link Ext.util.Sortable sortable}` objects to add items on which it
     * depends.
     *
     * @param {Object/Object[]} items The items to add. If this is an object, the values
     * are considered the items and the keys are ignored.
     * @return {Ext.util.Scheduler} This instance.
     * @since 5.0.0
     */
    sortItems: function(items) {
        var me = this,
            sortItem = me.sortItem;
 
        if (items) {
            if (items instanceof Array) {
                Ext.each(items, sortItem, me);
            }
            else {
                Ext.Object.eachValue(items, sortItem, me);
            }
        }
 
        return me;
    },
 
    applyPreSort: function(preSort) {
        if (typeof preSort === 'function') {
            return preSort;
        }
 
        // eslint-disable-next-line vars-on-top
        var parts = preSort.split(','),
            direction = [],
            length = parts.length,
            c, i, s;
 
        for (= 0; i < length; ++i) {
            direction[i] = 1;
            s = parts[i];
 
            if ((= s.charAt(0)) === '-') {
                direction[i] = -1;
            }
            else if (!== '+') {
                c = 0;
            }
 
            if (c) {
                parts[i] = s.substring(1);
            }
        }
 
        return function(lhs, rhs) {
            var ret = 0,
                i, prop, v1, v2;
 
            for (= 0; !ret && i < length; ++i) {
                prop = parts[i];
                v1 = lhs[prop];
                v2 = rhs[prop];
                ret = direction[i] * ((v1 < v2) ? -1 : ((v2 < v1) ? 1 : 0));
            }
 
            return ret;
        };
    },
 
    //-------------------------------------------------------------------------
    // Callback scheduling
    // <editor-fold>
 
    /**
     * This method can be called to force the delivery of any scheduled items. This is
     * called automatically on a timer when items request service.
     *
     * @since 5.0.0
     */
    notify: function() {
        var me = this,
            timer = me.timer,
            cyclesLeft = me.getCycleLimit(),
            globalEvents = Ext.GlobalEvents,
            suspend = me.suspendOnNotify,
            busyCounter, i, item, len, queue, firedEvent;
 
        if (timer) {
            window.clearTimeout(timer);
            me.timer = null;
        }
 
        // Only process the queue if we are not already notifying
        // and there are notifications to deliver.
        if (!me.firing && me.scheduledCount) {
            if (suspend) {
                Ext.suspendLayouts();
            }
 
            while (me.scheduledCount) {
                if (cyclesLeft) {
                    --cyclesLeft;
                }
                else {
                    me.firing = null;
 
                    //<debug>
                    if (me.onCycleLimitExceeded) {
                        me.onCycleLimitExceeded();
                    }
                    //</debug>
 
                    break;
                }
 
                if (!firedEvent) {
                    firedEvent = true;
 
                    if (globalEvents.hasListeners.beforebindnotify) {
                        globalEvents.fireEvent('beforebindnotify', me);
                    }
                }
 
                ++me.passes;
 
                // We need to sort before we start firing because items can be added as we
                // loop.
                if (!(queue = me.orderedItems)) {
                    me.sort();
                    queue = me.orderedItems;
                }
 
                len = queue.length;
 
                if (len) {
                    me.firing = me.items;
 
                    for (= 0; i < len; ++i) {
                        item = queue[i];
 
                        if (item.scheduled) {
                            item.scheduled = false;
                            --me.scheduledCount;
                            me.notifyIndex = i;
 
                            // Ext.log('React: ' + item.getFullName());
                            // This sequence allows the reaction to schedule items further
                            // down the queue without a second pass but also to schedule an
                            // item that is "upstream" or even itself.
                            item.react();
 
                            if (!me.scheduledCount) {
                                break;
                            }
                        }
                    }
                }
            }
 
            me.firing = null;
            me.notifyIndex = -1;
 
            if (suspend) {
                Ext.resumeLayouts(true);
            }
        }
 
        // The last thing we do is check for idle state transition (now that whatever
        // else that was queued up has been dispatched):
        if ((busyCounter = me.busyCounter) !== me.lastBusyCounter) {
            if (!(me.lastBusyCounter = busyCounter)) {
                // Since the counters are not equal, we were busy and are not anymore,
                // so we can fire the idle event:
                me.fireEvent('idle', me);
            }
        }
    },
 
    /**
     * The method called by the timer. This cleans up the state and calls `notify`.
     * @private
     * @since 5.0.0
     */
    onTick: function() {
        this.timer = null;
        this.notify();
    },
 
    /**
     * Called to indicate that an item needs to be scheduled. This should not be called
     * directly. Call the item's `{@link Ext.util.Schedulable#schedule schedule}` method
     * instead.
     * @param {Object} item 
     * @private
     * @since 5.0.0
     */
    scheduleItem: function(item) {
        var me = this;
 
        ++me.scheduledCount;
        // Ext.log('Schedule: ' + item.getFullName());
 
        if (!me.timer && !me.firing) {
            me.scheduleTick();
        }
    },
 
    /**
     * This method starts the timer that will execute the next `notify`.
     * @param {Object} item 
     * @private
     * @since 5.0.0
     */
    scheduleTick: function() {
        var me = this;
 
        if (!me.destroyed && !me.timer) {
            me.timer = Ext.defer(me.onTick, me.getTickDelay(), me);
        }
    },
 
    /**
     * Called to indicate that an item needs to be removed from the schedule. This should
     * not be called directly. Call the item's `{@link Ext.util.Schedulable#unschedule unschedule}`
     * method instead.
     * @param {Object} item 
     * @private
     * @since 5.0.0
     */
    unscheduleItem: function(item) {
        if (this.scheduledCount) {
            --this.scheduledCount;
        }
    },
 
    // </editor-fold>
 
    //-------------------------------------------------------------------------
    // Busy/Idle state tracking
    // <editor-fold>
 
    /**
     * This method should be called when items become busy or idle. These changes are
     * useful outside to do things like update modal masks or status indicators. The
     * changes are delivered as `busy` and `idle` events.
     *
     * @param {Number} adjustment Should be `1` or `-1` only to indicate transition to
     * busy state or from busy state, respectively.
     * @since 5.0.0
     */
    adjustBusy: function(adjustment) {
        var me = this,
            busyCounter = me.busyCounter + adjustment;
 
        me.busyCounter = busyCounter;
 
        if (busyCounter) {
            // If we are now busy but were not previously, fire the busy event immediately
            // and update lastBusyCounter.
            if (!me.lastBusyCounter) {
                me.lastBusyCounter = busyCounter;
                me.fireEvent('busy', me);
            }
        }
        else if (me.lastBusyCounter && !me.timer) {
            // If we are now not busy but were previously, defer this to make sure that
            // we don't quickly start with some other activity.
            me.scheduleTick();
        }
    },
 
    /**
     * Returns `true` if this object contains one or more busy items.
     * @return {Boolean} 
     * @since 5.0.0
     */
    isBusy: function() {
        return !this.isIdle();
    },
 
    /**
     * Returns `true` if this object contains no busy items.
     * @return {Boolean} 
     * @since 5.0.0
     */
    isIdle: function() {
        return !(this.busyCounter + this.lastBusyCounter);
    },
 
    // </editor-fold>
 
    debugHooks: {
        $enabled: false, // Disable by default
 
        onCycleLimitExceeded: function() {
            Ext.raise('Exceeded cycleLimit ' + this.getCycleLimit());
        },
 
        scheduleItem: function(item) {
            if (!item) {
                Ext.raise('scheduleItem: Invalid argument');
            }
 
            Ext.log('Schedule item: ' + item.getFullName() + ' - ' + (this.scheduledCount + 1));
 
            if (item.order <= this.notifyIndex) {
                Ext.log.warn('Suboptimal order: ' + item.order + ' < ' + this.notifyIndex);
            }
 
            this.callParent([item]);
        },
 
        unscheduleItem: function(item) {
            if (!this.scheduledCount) {
                Ext.raise('Invalid scheduleCount');
            }
 
            this.callParent([item]);
            Ext.log('Unschedule item: ' + item.getFullName() + ' - ' + this.scheduledCount);
        }
    }
});