var _ = require('lodash'),
    asyncEach = require('async/each'),
    sdk = require('postman-collection'),
    runtime = require('postman-runtime'),
    request = require('postman-request'),
    EventEmitter = require('eventemitter3'),
    SecureFS = require('./secure-fs'),
    RunSummary = require('./summary'),
    getOptions = require('./options'),
    exportFile = require('./export-file'),
    util = require('../util'),

    /**
     * This object describes the various events raised by Newman, and what each event argument contains.
     * Error and cursor are present in all events.
     *
     * @type {Object}
     */
    runtimeEvents = {
        beforeIteration: [],
        beforeItem: ['item'],
        beforePrerequest: ['events', 'item'],
        prerequest: ['executions', 'item'],
        beforeRequest: ['request', 'item'],
        request: ['response', 'request', 'item', 'cookies', 'history'],
        beforeTest: ['events', 'item'],
        test: ['executions', 'item'],
        item: ['item'],
        iteration: [],
        beforeScript: ['script', 'event', 'item'],
        script: ['execution', 'script', 'event', 'item']
    },

    /**
     * load all the default reporters here. if you have new reporter, add it to this list
     * we know someone, who does not like dynamic requires
     *
     * @type {Object}
     */
    defaultReporters = {
        cli: require('../reporters/cli'),
        json: require('../reporters/json'),
        junit: require('../reporters/junit'),
        progress: require('../reporters/progress'),
        emojitrain: require('../reporters/emojitrain')
    },

    /**
     * The object of known reporters and their install instruction in case the reporter is not loaded.
     * Pad message with two spaces since its a follow-up message for reporter warning.
     *
     * @private
     * @type {Object}
     */
    knownReporterErrorMessages = {
        html: '  run `npm install newman-reporter-html`\n',
        teamcity: '  run `npm install newman-reporter-teamcity`\n'
    },

    /**
     * Multiple ids or names entrypoint lookup strategy.
     *
     * @private
     * @type {String}
     */
    MULTIENTRY_LOOKUP_STRATEGY = 'multipleIdOrName';

/**
 * Runs the collection, with all the provided options, returning an EventEmitter.
 *
 * @param {Object} options - The set of wrapped options, passed by the CLI parser.
 * @param {Collection|Object|String} options.collection - A JSON / Collection / String representing the collection.
 * @param {Object|String} options.environment - An environment JSON / file path for the current collection run.
 * @param {Object|String} options.globals - A globals JSON / file path for the current collection run.
 * @param {String} options.workingDir - Path of working directory that contains files needed for the collection run.
 * @param {String} options.insecureFileRead - If true, allow reading files outside of working directory.
 * @param {Object|String} options.iterationData - An iterationData JSON / file path for the current collection run.
 * @param {Object|String} options.reporters - A set of reporter names and their associated options for the current run.
 * @param {Object|String} options.cookieJar - A tough-cookie cookieJar / file path for the current collection run.
 * @param {String} options.exportGlobals - The relative path to export the globals file from the current run to.
 * @param {String} options.exportEnvironment - The relative path to export the environment file from the current run to.
 * @param {String} options.exportCollection - The relative path to export the collection from the current run to.
 * @param {String} options.exportCookieJar - The relative path to export the cookie jar from the current run to.
 * @param {Function} callback - The callback function invoked to mark the end of the collection run.
 * @returns {EventEmitter} - An EventEmitter instance with done and error event attachments.
 */
module.exports = function (options, callback) {
    // validate all options. it is to be noted that `options` parameter is option and is polymorphic
    (!callback && _.isFunction(options)) && (
        (callback = options),
        (options = {})
    );
    !_.isFunction(callback) && (callback = _.noop);

    var emitter = new EventEmitter(), // @todo: create a new inherited constructor
        runner = new runtime.Runner(),
        stopOnFailure,
        entrypoint;

    // get the configuration from various sources
    getOptions(options, function (err, options) {
        if (err) {
            return callback(err);
        }

        // ensure that the collection option is present before starting a run
        if (!_.isObject(options.collection)) {
            return callback(new Error('expecting a collection to run'));
        }

        // use client certificate list to allow different ssl certificates for
        // different URLs
        var sslClientCertList = options.sslClientCertList || [],
            // allow providing custom cookieJar
            cookieJar = options.cookieJar || request.jar();

        // if sslClientCert option is set, put it at the end of the list to
        // match all URLs that didn't match in the list
        if (options.sslClientCert) {
            sslClientCertList.push({
                name: 'client-cert',
                matches: [sdk.UrlMatchPattern.MATCH_ALL_URLS],
                key: { src: options.sslClientKey },
                cert: { src: options.sslClientCert },
                passphrase: options.sslClientPassphrase
            });
        }

        // iterates over the bail array and sets each item as an obj key with a value of boolean true
        // [item1, item2] => {item1: true, item2: true}
        if (_.isArray(options.bail)) {
            options.bail = _.transform(options.bail, function (result, value) {
                result[value] = true;
            }, {});
        }

        // sets entrypoint to execute if options.folder is specified.
        if (options.folder) {
            entrypoint = { execute: options.folder };

            // uses `multipleIdOrName` lookupStrategy in case of multiple folders.
            _.isArray(entrypoint.execute) && (entrypoint.lookupStrategy = MULTIENTRY_LOOKUP_STRATEGY);
        }

        // sets stopOnFailure to true in case bail is used without any modifiers or with failure
        // --bail => stopOnFailure = true
        // --bail failure => stopOnFailure = true
        (typeof options.bail !== 'undefined' &&
            (options.bail === true || (_.isObject(options.bail) && options.bail.failure))) ?
            stopOnFailure = true : stopOnFailure = false;

        // store summary object and other relevant information inside the emitter
        emitter.summary = new RunSummary(emitter, options);

        // to store the exported content from reporters
        emitter.exports = [];

        // expose the runner object for reporter and programmatic use
        emitter.runner = runner;

        // now start the run!
        runner.run(options.collection, {
            stopOnFailure: stopOnFailure, // LOL, you just got trolled ¯\_(ツ)_/¯
            abortOnFailure: options.abortOnFailure, // used in integration tests, to be considered for a future release
            abortOnError: _.get(options, 'bail.folder'),
            iterationCount: options.iterationCount,
            environment: options.environment,
            globals: options.globals,
            entrypoint: entrypoint,
            data: options.iterationData,
            delay: {
                item: options.delayRequest
            },
            timeout: {
                global: options.timeout || 0,
                request: options.timeoutRequest || 0,
                script: options.timeoutScript || 0
            },
            fileResolver: new SecureFS(options.workingDir, options.insecureFileRead),
            requester: {
                useWhatWGUrlParser: true,
                cookieJar: cookieJar,
                followRedirects: _.has(options, 'ignoreRedirects') ? !options.ignoreRedirects : undefined,
                strictSSL: _.has(options, 'insecure') ? !options.insecure : undefined,
                timings: Boolean(options.verbose),
                extendedRootCA: options.sslExtraCaCerts,
                agents: _.isObject(options.requestAgents) ? options.requestAgents : undefined
            },
            certificates: sslClientCertList.length && new sdk.CertificateList({}, sslClientCertList)
        }, function (err, run) {
            if (err) { return callback(err); }

            var callbacks = {},
                // ensure that the reporter option type polymorphism is handled
                reporters = _.isString(options.reporters) ? [options.reporters] : options.reporters,
                // keep a track of start assertion indices of legacy assertions
                legacyAssertionIndices = {};

            // emit events for all the callbacks triggered by the runtime
            _.forEach(runtimeEvents, function (definition, eventName) {
                // intercept each runtime.* callback and expose a global object based event
                callbacks[eventName] = function (err, cursor) {
                    var args = arguments,
                        obj = { cursor };

                    // convert the arguments into an object by taking the key name reference from the definition
                    // object
                    _.forEach(definition, function (key, index) {
                        obj[key] = args[index + 2]; // first two are err, cursor
                    });

                    args = [eventName, err, obj];
                    emitter.emit.apply(emitter, args); // eslint-disable-line prefer-spread
                };
            });

            // add non generic callback handling
            _.assignIn(callbacks, {

                /**
                 * Emits event for start of the run. It injects/exposes additional objects useful for
                 * programmatic usage and reporters
                 *
                 * @param {?Error} err - An Error instance / null object.
                 * @param {Object} cursor - The run cursor instance.
                 * @returns {*}
                 */
                start (err, cursor) {
                    emitter.emit('start', err, {
                        cursor,
                        run
                    });
                },

                /**
                 * Bubbles up console messages.
                 *
                 * @param {Object} cursor - The run cursor instance.
                 * @param {String} level - The level of console logging [error, silent, etc].
                 * @returns {*}
                 */
                console (cursor, level) {
                    emitter.emit('console', null, {
                        cursor: cursor,
                        level: level,
                        messages: _.slice(arguments, 2)
                    });
                },

                /**
                 * The exception handler for the current run instance.
                 *
                 * @todo Fix bug of arg order in runtime.
                 * @param {Object} cursor - The run cursor.
                 * @param {?Error} err - An Error instance / null object.
                 * @returns {*}
                 */
                exception (cursor, err) {
                    emitter.emit('exception', null, {
                        cursor: cursor,
                        error: err
                    });
                },

                assertion (cursor, assertions) {
                    _.forEach(assertions, function (assertion) {
                        var errorName = _.get(assertion, 'error.name', 'AssertionError');

                        !assertion && (assertion = {});

                        // store the legacy assertion index
                        assertion.index && (legacyAssertionIndices[cursor.ref] = assertion.index);

                        emitter.emit('assertion', (assertion.passed ? null : {
                            name: errorName,
                            index: assertion.index,
                            test: assertion.name,
                            message: _.get(assertion, 'error.message', assertion.name || ''),

                            stack: errorName + ': ' + _.get(assertion, 'error.message', '') + '\n' +
                                '   at Object.eval sandbox-script.js:' + (assertion.index + 1) + ':' +
                                ((cursor && cursor.position || 0) + 1) + ')'
                        }), {
                            cursor: cursor,
                            assertion: assertion.name,
                            skipped: assertion.skipped,
                            error: assertion.error,
                            item: run.resolveCursor(cursor)
                        });
                    });
                },

                /**
                 * Custom callback to override the `done` event to fire the end callback.
                 *
                 * @todo Do some memory cleanup here?
                 * @param {?Error} err - An error instance / null passed from the done event handler.
                 * @param {Object} cursor - The run instance cursor.
                 * @returns {*}
                 */
                done (err, cursor) {
                    // in case runtime faced an error during run, we do not process any other event and emit `done`.
                    // we do it this way since, an error in `done` callback would have anyway skipped any intermediate
                    // events or callbacks
                    if (err) {
                        emitter.emit('done', err, emitter.summary);
                        callback(err, emitter.summary);

                        return;
                    }

                    // we emit a `beforeDone` event so that reporters and other such addons can do computation before
                    // the run is marked as done
                    emitter.emit('beforeDone', null, {
                        cursor: cursor,
                        summary: emitter.summary
                    });

                    _.forEach(['environment', 'globals', 'collection', 'cookie-jar'], function (item) {
                        // fetch the path name from options if one is provided
                        var path = _.get(options, _.camelCase(`export-${item}`));

                        // if the options have an export path, then add the item to export queue
                        path && emitter.exports.push({
                            name: item,
                            default: `newman-${item}.json`,
                            path: path,
                            content: item === 'cookie-jar' ?
                                cookieJar.toJSON() :
                                _(emitter.summary[item].toJSON())
                                    .defaults({
                                        name: item
                                    })
                                    .merge({
                                        _postman_variable_scope: item,
                                        _postman_exported_at: (new Date()).toISOString(),
                                        _postman_exported_using: util.userAgent
                                    })
                                    .value()
                        });
                    });

                    asyncEach(emitter.exports, exportFile, function (err) {
                        // we now trigger actual done event which we had overridden
                        emitter.emit('done', err, emitter.summary);
                        callback(err, emitter.summary);
                    });
                }
            });

            emitter.on('script', function (err, o) {
                // bubble special script name based events
                o && o.event && emitter.emit(o.event.listen + 'Script', err, o);
            });

            emitter.on('beforeScript', function (err, o) {
                // bubble special script name based events
                o && o.event && emitter.emit(_.camelCase('before-' + o.event.listen + 'Script'), err, o);
            });

            // initialise all the reporters
            !emitter.reporters && (emitter.reporters = {});
            _.isArray(reporters) && _.forEach(reporters, function (reporterName) {
                // disallow duplicate reporter initialisation
                if (_.has(emitter.reporters, reporterName)) { return; }

                var Reporter;

                try {
                    // check if the reporter is an external reporter
                    Reporter = require((function (name) { // ensure scoped packages are loaded
                        var prefix = '',
                            scope = (name.charAt(0) === '@') && name.substr(0, name.indexOf('/') + 1);

                        if (scope) {
                            prefix = scope;
                            name = name.substr(scope.length);
                        }

                        return prefix + 'newman-reporter-' + name;
                    }(reporterName)));
                }
                // @todo - maybe have a debug mode and log error there
                catch (error) {
                    if (!defaultReporters[reporterName]) {
                        // @todo: route this via print module to respect silent flags
                        console.warn(`newman: could not find "${reporterName}" reporter`);
                        console.warn('  ensure that the reporter is installed in the same directory as newman');

                        // print install instruction in case a known reporter is missing
                        if (knownReporterErrorMessages[reporterName]) {
                            console.warn(knownReporterErrorMessages[reporterName]);
                        }
                        else {
                            console.warn('  please install reporter using npm\n');
                        }
                    }
                }

                // load local reporter if its not an external reporter
                !Reporter && (Reporter = defaultReporters[reporterName]);

                try {
                    // we could have checked _.isFunction(Reporter), here, but we do not do that so that the nature of
                    // reporter error can be bubbled up
                    Reporter && (emitter.reporters[reporterName] = new Reporter(emitter,
                        _.get(options, ['reporter', reporterName], {}), options));
                }
                catch (error) {
                    // if the reporter errored out during initialisation, we should not stop the run simply log
                    // the error stack trace for debugging
                    console.warn(`newman: could not load "${reporterName}" reporter`);

                    if (!defaultReporters[reporterName]) {
                        // @todo: route this via print module to respect silent flags
                        console.warn(`  this seems to be a problem in the "${reporterName}" reporter.\n`);
                    }
                    console.warn(error);
                }
            });

            // raise warning when more than one dominant reporters are used
            (function (reporters) {
                // find all reporters whose `dominant` key is set to true
                var conflicts = _.keys(_.transform(reporters, function (conflicts, reporter, name) {
                    reporter.dominant && (conflicts[name] = true);
                }));

                (conflicts.length > 1) && // if more than one dominant, raise a warning
                    console.warn(`newman: ${conflicts.join(', ')} reporters might not work well together.`);
            }(emitter.reporters));

            // we ensure that everything is async to comply with event paradigm and start the run
            setImmediate(function () {
                run.start(callbacks);
            });
        });
    });

    return emitter;
};
