/**
* ESPromiseScheduler.ts
* @author Nev Wylie (newylie)
* @copyright Microsoft 2019
*/

import dynamicProto from "@microsoft/dynamicproto-js";
import ESPromise from "./ESPromise";
import { IDiagnosticLogger, _warnToConsole, getGlobal } from "@microsoft/applicationinsights-core-js";
import { ResolverRejectFunc, ResolverResolveFunc } from "./ESPromise";

/** This is a default timeout that will cause outstanding running promises to be removed/rejected to avoid filling up memory with blocked events */
const LazyRejectPeriod = 600000;            // 10 Minutes

export type ESPromiseSchedulerEvent<T> = (eventId?: string) => ESPromise<T>;

export interface IScheduledEventDetails {
    evt: ESPromise<any>;
    tm: number;
    wTm?: number;
    id: string;
    to?: any;
    isRunning: boolean;
    isAborted: boolean;
    abort?: (message: string) => void;
}

// These are global variables that are shared across ALL instances of the scheduler
/**
 * @ignore
 */
let _schedulerId = 0;

/**
 * @ignore
 */
let _running: IScheduledEventDetails[] = [];

/**
 * @ignore
 */
let _waiting: IScheduledEventDetails[] = [];

/**
 * @ignore
 */
let _timedOut: IScheduledEventDetails[] = [];

/**
 * @ignore
 */
function _getTime() {
    return new Date().getTime();
}

/**
 * Provides a simple mechanism queueing mechanism for scheduling events based on the ESPromise callbacks, this is used to ensure
 * order of async operations that are required to be executed in a specific order.
 */
export default class ESPromiseScheduler {

    public static incomplete(): IScheduledEventDetails[] {
        return _running;
    }

    public static waitingToStart(): IScheduledEventDetails[] {
        return _waiting;
    }

    constructor(name?: string, diagLog?: IDiagnosticLogger) {
        let _promiseId = 0;
        let _scheduledName = (name || "<unnamed>") + "." + _schedulerId;
        _schedulerId++;

        dynamicProto(ESPromiseScheduler, this, (_this) => {
            let _lastEvent: IScheduledEventDetails = null;
            let _eventCount = 0;

            _this.scheduleEvent = <T>(startEventAction: ESPromiseSchedulerEvent<T>, eventName?: string, timeout?: number): ESPromise<T> => {
                let uniqueId: string = _scheduledName + "." + _eventCount;
                _eventCount++;
                if (eventName) {
                    uniqueId += "-(" + eventName + ")";
                }

                let uniqueEventId = uniqueId + "{" + _promiseId + "}";
                _promiseId++;

                // Create the next scheduled event details
                let newScheduledEvent: IScheduledEventDetails = {
                    evt: null,
                    tm: _getTime(),
                    id: uniqueEventId,
                    isRunning: false,
                    isAborted: false
                };

                if (!_lastEvent) {
                    // We don't have any currently running event, so just start the next event
                    newScheduledEvent.evt = _startWaitingEvent(newScheduledEvent);
                } else {
                    // Start a new promise which will wait until all current active events are complete before starting
                    // the new event, it does not resolve this scheduled event until after the new event is resolve to
                    // ensure that all scheduled events are completed in the correct order
                    newScheduledEvent.evt = _waitForPreviousEvent(newScheduledEvent, _lastEvent);
                }

                // Set this new event as the last one, so that any future events will wait for this one
                _lastEvent = newScheduledEvent;
                (_lastEvent.evt as any)._schId = uniqueEventId;

                return newScheduledEvent.evt;

                function _abortAndRemoveOldEvents(eventQueue: IScheduledEventDetails[]) {
                    let now = _getTime();
                    let expired = now - LazyRejectPeriod;
                    let len = eventQueue.length;
                    let lp = 0;
                    while (lp < len) {
                        let evt = eventQueue[lp];
                        if (evt && evt.tm < expired) {
                            let message = null;
                            if (evt.abort) {
                                message = "Aborting [" + evt.id + "] due to Excessive runtime (" + (now - evt.tm) + " ms)";
                                evt.abort(message);
                            } else {
                                message = "Removing [" + evt.id + "] due to Excessive runtime (" + (now - evt.tm) + " ms)";
                            }
                            _warnLog(message);
                            eventQueue.splice(lp, 1);
                            len--;
                        } else {
                            lp++;
                        }
                    }
                }

                function _cleanup(eventId: string, completed: boolean) {
                    let toQueue = false;
                    let removed = _removeQueuedEvent(_running, eventId);
                    if (!removed) {
                        removed = _removeQueuedEvent(_timedOut, eventId);
                        toQueue = true;
                    }

                    if (removed) {
                        if (removed.to) {
                            // If there was a timeout stop it
                            clearTimeout(removed.to);
                            removed.to = null;
                        }

                        // TODO (newylie): Convert this into reportable metrics
                        let tm = _getTime() - removed.tm;
                        if (completed) {
                            if (!toQueue) {
                                _debugLog("Promise [" + eventId + "] Complete -- " + tm + " ms");
                            } else {
                                _warnLog("Timed out event [" + eventId + "] finally complete -- " + tm + " ms");
                            }
                        } else {
                            _timedOut.push(removed);
                            _warnLog("Event [" + eventId + "] Timed out and removed -- " + tm + " ms");
                        }
                    } else {
                        _debugLog("Failed to remove [" + eventId + "] from running queue");
                    }

                    // Also if the last scheduled event was this event then clear it as we are now finished
                    if (_lastEvent && _lastEvent.id === eventId) {
                        _lastEvent = null;
                    }

                    _abortAndRemoveOldEvents(_running);
                    _abortAndRemoveOldEvents(_waiting);
                    _abortAndRemoveOldEvents(_timedOut);
                }

                // Return a callback function that will be called when the waiting promise is resolved or rejected to ensure
                // that any outer promise is also resolved or rejected
                function _removeScheduledEvent(eventId: string, callback?: (value: any) => void) {
                    return (value: any) => {
                        _cleanup(eventId, true);
                        callback && callback(value);

                        return value;
                    };
                }

                function _waitForFinalResult(eventId: string, startResult: ESPromise<T>, schEventResolve: ResolverResolveFunc<T>, schEventReject: ResolverRejectFunc<T>) {
                    startResult.then((value) => {
                        if (value instanceof ESPromise) {
                            // If the result is a promise then this appears to be a chained result, so wait for this promise to complete
                            _debugLog("Event [" + eventId + "] returned a promise -- waiting");
                            _waitForFinalResult(eventId, value, schEventResolve, schEventReject);
                            return value;
                        } else {
                            return _removeScheduledEvent(eventId, schEventResolve)(value);
                        }
                    }, _removeScheduledEvent(eventId, schEventReject));
                }

                // Add the passed event to the active event list with resolve and reject callbacks that will remove
                // it from the active event list
                function _createScheduledEvent(eventDetails: IScheduledEventDetails, startEvent: ESPromiseSchedulerEvent<T>): ESPromise<T> {
                    let eventId = eventDetails.id;
                    return new ESPromise((schEventResolve, schEventReject) => {
                        _debugLog("Event [" + eventId + "] Starting -- waited for " + (eventDetails.wTm || "--") + " ms");
                        eventDetails.isRunning = true;
                        eventDetails.abort = (message: string) => {
                            eventDetails.abort = null;
                            eventDetails.isAborted = true;
                            _cleanup(eventId, false);
                            schEventReject(new Error(message));
                        };

                        let startResult = startEvent(eventId);
                        if (startResult instanceof ESPromise) {
                            if (timeout) {
                                // Note: Only starting a timer if a timeout was specified
                                eventDetails.to = setTimeout(() => {
                                    _cleanup(eventId, false);

                                    // Cause the listeners to reject (Note: We can't actually reject the waiting event)
                                    schEventReject(new Error("Timed out after [" + timeout + "] ms"));
                                }, timeout);
                            }

                            _waitForFinalResult(eventId, startResult, (theResult) => {
                                _debugLog("Event [" + eventId + "] Resolving after " + (_getTime() - eventDetails.tm) + " ms");
                                schEventResolve(theResult);
                            }, schEventReject);
                        } else {
                            // The startEvent didn't return a promise so just return a resolved promise
                            _debugLog("Promise [" + eventId + "] Auto completed as the start action did not return a promise");
                            schEventResolve();
                        }
                    });
                }

                function _startWaitingEvent(eventDetails: IScheduledEventDetails): ESPromise<T> {
                    let now = _getTime();
                    eventDetails.wTm = now - eventDetails.tm;
                    eventDetails.tm = now;

                    if (eventDetails.isAborted) {
                        return ESPromise.reject<T>(new Error("[" + uniqueId + "] was aborted"));
                    }

                    _running.push(eventDetails);

                    return _createScheduledEvent(eventDetails, startEventAction);
                }

                // Start a new promise which will wait until all current active events are complete before starting
                // the new event, it does not resolve this scheduled event until after the new event is resolve to
                // ensure that all scheduled events are completed in the correct order
                function _waitForPreviousEvent(eventDetails: IScheduledEventDetails, waitForEvent: IScheduledEventDetails): ESPromise<T> {
                    let waitEvent = new ESPromise<T>((waitResolve, waitReject) => {
                        let runTime = _getTime() - waitForEvent.tm;
                        let prevId = waitForEvent.id;
                        _debugLog("[" + uniqueId + "] is waiting for [" + prevId + ":" + runTime + " ms] to complete before starting -- [" + _waiting.length + "] waiting and [" + _running.length + "] running");

                        eventDetails.abort = (message: string) => {
                            eventDetails.abort = null;
                            _removeQueuedEvent(_waiting, uniqueId);
                            eventDetails.isAborted = true;
                            waitReject(new Error(message));
                        };

                        // Wait for the previous event to complete
                        waitForEvent.evt.then((value) => {
                            _removeQueuedEvent(_waiting, uniqueId);
                            // Wait for the last event to complete before starting the new one, this ensures the execution
                            // order so that we don't try and remove events that havn't been committed yet
                            _startWaitingEvent(eventDetails).then(waitResolve, waitReject);
                        }, (reason) => {
                            _removeQueuedEvent(_waiting, uniqueId);
                            // Wait for the last event to complete before starting the new one, this ensures the execution
                            // order so that we don't try and remove events that havn't been committed yet
                            _startWaitingEvent(eventDetails).then(waitResolve, waitReject);
                        });
                    });

                    _waiting.push(eventDetails);

                    return waitEvent;
                }
            };

            function _removeQueuedEvent(queue: IScheduledEventDetails[], eventId: string): IScheduledEventDetails|void {
                for (let lp = 0; lp < queue.length; lp++) {
                    if (queue[lp].id === eventId) {
                        return queue.splice(lp, 1)[0];
                    }
                }

                return null;
            }
        });

        function _debugLog(message: string) {
            // Only log if running within test harness
            let global = getGlobal();
            if (global && global["QUnit"]) {
                // tslint:disable-next-line:no-console
                console && console.log("ESPromiseScheduler[" + _scheduledName + "] " + message);
            }
        }

        function _warnLog(message: string) {
            _warnToConsole(diagLog, "ESPromiseScheduler[" + _scheduledName + "] " + message);
        }
    }

    /**
     * Schedule an event that will execute the startEvent after all outstanding events are resolved or rejected. This is used to ensure
     * order of async operations that are required to be executed in a specific order.
     * The returned promise will be resolve or rejected based on the values returned from the doAction.
     * @param startEventAction The function to execute to start the event after all outstanding events have completed, will be called asynchronously.
     * @param eventName An [Optional] event name to assist with debbuging to understand what events are either waiting to start or still running (incomplete).
     * @param timeout An [Optional] timeout
     */
    public scheduleEvent<T>(startEventAction: ESPromiseSchedulerEvent<T>, eventName?: string, timeout?: number): ESPromise<T> {
        // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging
        return;
    }
}
