Source: src/sequence.js

"use strict";

var isPlainObject = require('is-plain-object');

/**
 * @public
 * @readonly
 * @author Steele Parker
 * @module ssync/sequence
 * @param {Object|Array} config|sequence object containing keys ['sequence', 'args', 'callback'] or array of {Function|Array}.<br /><br />
 * If a config object is passed, only 1 argument must be passed. If arguments are implied, only 3 must be passed.
 * @param {Object} args <i>(optional when config object is passed otherwise required)</i><br /><br />
 * args object containing args to be made available to each function within the sequence
 * @param {Function} callback <i>(optional)</i><br /><br />
 * callback to execute after the sequence has completed.
 * @returns {Sequencer}
 * @description
 * Instantiates, Configures and begins execution of a new {@link Sequencer}. <br/><br />
 *
 * The purpose of this function is to operate as a synchronous scaffolding for asynchronous functionality. An
 * array of independent aSynchronous events that rely on the results passed between each other is passed, then
 * executed in the order specified in the sequence registry.<br/><br />
 *
 * Each function touched by the sequencer should be written such that they can take the same arguments passed
 * to any other function in the sequencer for the purposes of convention over configuration, including shared config.
 * Functioned consumed by the sequencer should follow the convention of function(opts, callback) and callback (err, data)
 * as per http://blog.gvm-it.eu/post/22040726249/callback-conventions-in-nodejs-how-and-why and other resources.<br/><br />
 *
 * Each instance of a sequence should be thought of as a contained application with its own config, passed in the form of args.<br/><br />
 *
 * The data returned from each function in the sequence will be made available to the following function in the sequence.<br/><br />
 *
 * A portion of the sequencer itself will be passed into each stage of the sequence so that it can track it's current execution stage
 * In the analogy of a close application, the sequencer will be the application context.<br/><br />
 *
 * The sequencer must be instantiated, multiple sequencers can be instantiated but the developer must handle
 * the asynchronous nature of them as you would any other asynchronous functionality.
 * @example
 * //Return the sequencer for use elsewhere
 * //Implied args
 * var ssync = require('bsync')
 *
 * var sequence1 = bsync.sequence([
 *     function1,
 *     function2
 * ],{
 *     arg1 : "String",
 *     arg2 : true
 * },function(err, data) {
 *     //Do something with the result of the final output
 * });
 * @example
 * //Explicit args
 * ssync.sequence({
 *     sequence:[
 *         function1,
 *         function2
 *     ],
 *     args: {
 *         arg1 : "String",
 *         arg2 : true
 *     },
 *     callback: function(err, data) {
 *         //Do something with the result of the final output
 *     }
 * });
 * @example
 * //Concurrent functions
 * ssync.sequence([
 *      [ function1, function2 ],
 *  ],
 *  null,
 *  function(err, data) {
 *      // the merged results of function1 and function2 will be
 *      // passed here instead of just the output of function2
 *  });
 */

/**
 * Primary Sequencer Class
 * @constructor
 * @example
 *  var sequencer = new Sequencer({
 *      sequence : [
 *          function1,
 *          function2
 *      ],
 *      args : {
 *          arg1 : "String",
 *          arg2 : true
 *      },
 *      callback: function(err, data) {
 *          //Do something with the result of the final output
 *      }
 *  });
 *
 *  sequencer.go();
 */

class Sequencer {
    objConstructor(argObj /*Object*/) {
        this.sequence = argObj.sequence ? argObj.sequence : null;
        this.args = argObj.args ? argObj.args : null;
        this.callback = argObj.callback ? argObj.callback : null;

        if( argObj.callback ) {
            if( Function.isFunction(argObj.callback) ) {
                this.callback = argObj.callback;
            } else {
                var invalidCallback = "Invalid callback function passed to Sequencer.construct. A function or null must be passed.";
                console.error("FATAL EXCEPTION", invalidCallback);
                throw(invalidCallback);
            }
        }


        if(this.startupErrorText) { return; }

        if( Array.isArray(argObj.sequence) || Function.isFunction(argObj.sequence) ) {
            this.sequence = argObj.sequence
        } else {
            this.startupErrorText = "Invalid sequence passed to Sequencer.construct. An array of functions (and arrays) or a single function must be passed.";

            return
        }


        if( argObj.args ) {
            if( isPlainObject(argObj.args) ) {
                this.args = argObj.args;
            } else {
                this.startupErrorText = "Invalid function args passed to Sequencer.construct. An object<k, v> containing arguments must be passed.";

                return;
            }
        }
    }
    arrConstructor(argArray /*Array*/){
        if( !this.startupErrorText && argArray.length > 3 ) {
            this.startupErrorText = "No more than 3 arguments can be passed to Sequencer.construct.";
        }

        if( arguments[2] ) {
            if( Function.isFunction(arguments[2]) ) {
                this.callback = arguments[2];
            } else {
                var invalidCallback = "Invalid callback function passed to Sequencer.construct. A function or null must be passed.";
                console.error("FATAL EXCEPTION", invalidCallback);
                throw(invalidCallback);
            }
        }

        if(this.startupErrorText) { return; }

        if( Array.isArray(argArray[0]) || Function.isFunction(argArray[0]) ) {
            this.sequence = argArray[0];
        } else {
            this.startupErrorText = "Invalid sequence passed to Sequencer.construct. An array of functions (and arrays) or a single function must be passed.";

            return;
        }

        if( arguments[1] ) {
            if( isPlainObject(arguments[1]) ) {
                this.args = arguments[1];
            } else {
                this.startupErrorText = "Invalid function args passed to Sequencer.construct. An object<k, v> containing arguments must be passed.";

                return;
            }
        }
    }
    construct() {
        this.sequence = [];
        this.callback = null;
        this.args = null;
        this.startupErrorText = null;
        this.currentSequenceIdx = 0;
        this.pendingData = {};

        if( !arguments || !arguments.length ) {

            //If this is the init of the function we can ignore null args;
            if( this.isConstructing ) return;

            var errNullArguments = "arguments passed to Sequencer.constructor were null. Nothing to run. Returning";
            console.warn(errNullArguments);

            this.startupErrorText = errNullArguments;
        }

        if( !Function.isFunction(arguments[0]) && arguments.length == 1 && isPlainObject(arguments[0]) ) {
            this.objConstructor(arguments[0]);
        } else {
            this.arrConstructor(arguments);
        }

        if( this.startupErrorText ) {
            if( this.callback ) this.callback(this.startupErrorText, null);
            return this;
        }
    }
    constructor() {
        this.isConstructing = true;

        this.construct.apply(this, arguments);

        this.isConstructing = false;
    }

    processNextSequenceFunction() {
        var prop;
        var opts = {
            sequence : this
        };

        if( this.args ) {
            for( prop in this.args ) {
                opts[prop] = this.args[prop];
            }
        }

        if( this.pendingData ) {
            for( prop in this.pendingData ) {
                if( opts[prop] != null ) {
                    console.warn("Existing property [" + prop + "] in opts to be passed to next sequence. Old value was [" + opts[prop] + "].");
                }

                opts[prop] = this.pendingData[prop];
            }

            this.pendingData = {};
        }

        var whatToExecute = this.sequence[this.currentSequenceIdx];
        if( Array.isArray(whatToExecute) ) {
            this.concurrentFunctions = whatToExecute.length;

            for( var fIdx in whatToExecute ) {
                whatToExecute[fIdx](opts, this.handlePartialSequenceComplete.bind(this));
            }
        } else {
            whatToExecute(opts, this.handlePartialSequenceComplete.bind(this));
        }
    }

    handlePartialSequenceComplete(err, data) {
        if( this.concurrentFunctions ) this.concurrentFunctions--;

        if( data ) {
            for( var prop in data ) {
                if( this.pendingData[prop] != null ) {
                    console.warn("Existing property [" + prop + "] in opts to be passed to next sequence. Overwritten value was [" + this.pendingData[prop] + "].");
                }

                this.pendingData[prop] = data[prop]
            }
        }

        if( err ) {
            this.pendingErr = this.pendingErr + " " + err || err;
        }

        if( this.concurrentFunctions == 0 ) {
            this.handleSequenceFunctionComplete();
        }
    }

    handleSequenceFunctionComplete() {
        if( this.sequence.length <= (this.currentSequenceIdx+1) ) {
            if( this.callback ) {
                this.callback(this.pendingErr, this.pendingData);
            }
            return;
        }


        this.currentSequenceIdx++;
        this.processNextSequenceFunction();
    }

    terminate() {
        this.currentSequenceIdx = this.sequence.length;
    }

    /**
     * Convenience Method
     */
    go() {
        this.processNextSequenceFunction();
    }
}

function sequence() {

    //Department of redundancy department
    var sequencer = new Sequencer();

    sequencer.construct.apply( sequencer, arguments );

    sequencer.go();

    return sequencer;
};

module.exports = sequence;