var _ = require('lodash'),
    uuid = require('uuid'),
    async = require('async'),

    util = require('../util'),
    sdk = require('postman-collection'),
    sandbox = require('postman-sandbox'),
    serialisedError = require('serialised-error'),
    ToughCookie = require('tough-cookie').Cookie,

    createItemContext = require('../create-item-context'),

    ASSERTION_FAILURE = 'AssertionFailure',
    SAFE_CONTEXT_VARIABLES = ['_variables', 'environment', 'globals', 'collectionVariables', 'cookies', 'data',
        'request', 'response'],

    EXECUTION_REQUEST_EVENT_BASE = 'execution.request.',
    EXECUTION_RESPONSE_EVENT_BASE = 'execution.response.',
    EXECUTION_ASSERTION_EVENT_BASE = 'execution.assertion.',
    EXECUTION_ERROR_EVENT_BASE = 'execution.error.',
    EXECUTION_COOKIES_EVENT_BASE = 'execution.cookies.',

    COOKIES_EVENT_STORE_ACTION = 'store',
    COOKIE_STORE_PUT_METHOD = 'putCookie',
    COOKIE_STORE_UPDATE_METHOD = 'updateCookie',

    FILE = 'file',

    REQUEST_BODY_MODE_FILE = 'file',
    REQUEST_BODY_MODE_FORMDATA = 'formdata',

    getCookieDomain, // fn
    postProcessContext, // fn
    sanitizeFiles; // fn

postProcessContext = function (execution, failures) { // function determines whether the event needs to abort
    var error;

    if (failures && failures.length) {
        error = new Error(failures.join(', '));
        error.name = ASSERTION_FAILURE;
    }

    return error ? serialisedError(error, true) : undefined;
};

/**
 * Removes files in Request body if any.
 *
 * @private
 *
 * @param {Request~definition} request Request JSON representation to be sanitized
 * @param {Function} callback function invoked with error, request and sanitisedFiles.
 * sanitisedFiles is the list of files removed from request.
 *
 * @note this function mutates the request
 * @todo remove files path from request.certificate
 */
sanitizeFiles = function (request, callback) {
    if (!request) {
        return callback(new Error('Could not complete pm.sendRequest. Request is empty.'));
    }

    var sanitisedFiles = [];

    // do nothing if request body is empty
    if (!request.body) {
        // send request as such
        return callback(null, request, sanitisedFiles);
    }

    // in case of request body mode is file, we strip it out
    if (request.body.mode === REQUEST_BODY_MODE_FILE) {
        sanitisedFiles.push(_.get(request, 'body.file.src'));
        request.body = null; // mutate the request for body
    }

    // if body is form-data then we deep dive into the data items and remove the entries that have file data
    else if (request.body.mode === REQUEST_BODY_MODE_FORMDATA) {
        // eslint-disable-next-line lodash/prefer-immutable-method
        _.remove(request.body.formdata, function (param) {
            // blank param and non-file param is removed
            if (!param || param.type !== FILE) { return false; }

            // at this point the param needs to be removed
            sanitisedFiles.push(param.src);

            return true;
        });
    }

    return callback(null, request, sanitisedFiles);
};

/**
 * Fetch domain name from CookieStore event arguments.
 *
 * @private
 * @param {String} fnName - CookieStore method name
 * @param {Array} args - CookieStore method arguments
 * @returns {String|Undefined} - Domain name
 */
getCookieDomain = function (fnName, args) {
    if (!(fnName && args)) {
        return;
    }

    var domain;

    switch (fnName) {
        case 'findCookie':
        case 'findCookies':
        case 'removeCookie':
        case 'removeCookies':
            domain = args[0];
            break;
        case 'putCookie':
        case 'updateCookie':
            domain = args[0] && args[0].domain;
            break;
        default:
            return;
    }

    return domain;
};

/**
 * Script execution extension of the runner.
 * This module exposes processors for executing scripts before and after requests. Essentially, the processors are
 * itself not aware of other processors and simply allow running of a script and then queue a procesor as defined in
 * payload.
 *
 * Adds options
 * - stopOnScriptError:Boolean [false]
 * - host:Object [undefined]
 */
module.exports = {
    init: function (done) {
        var run = this;

        // if this run object already has a host, we do not need to create one.
        if (run.host) {
            return done();
        }

        // @todo - remove this when chrome app and electron host creation is offloaded to runner
        // @todo - can this be removed now in runtime v4?
        if (run.options.host && run.options.host.external === true) {
            run.host = run.options.host.instance;

            return done();
        }

        sandbox.createContext(_.merge({
            timeout: _(run.options.timeout).pick(['script', 'global']).values().min()
            // debug: true
        }, run.options.host), function (err, context) {
            if (err) { return done(err); }
            // store the host in run object for future use and move on
            run.host = context;

            context.on('console', function () {
                run.triggers.console(...arguments);
            });

            context.on('error', function () {
                run.triggers.error(...arguments);
            });

            context.on('execution.error', function () {
                run.triggers.exception(...arguments);
            });

            context.on('execution.assertion', function () {
                run.triggers.assertion(...arguments);
            });

            done();
        });
    },

    /**
     * This lists the name of the events that the script processors are likely to trigger
     *
     * @type {Array}
     */
    triggers: ['beforeScript', 'script', 'assertion', 'exception', 'console'],

    process: {
        /**
         * This processors job is to do the following:
         * - trigger event by its name
         * - execute all scripts that the event listens to and return execution results
         *
         * @param {Object} payload -
         * @param {String} payload.name -
         * @param {Item} payload.item -
         * @param {Object} [payload.context] -
         * @param {Cursor} [payload.coords] -
         * @param {Number} [payload.scriptTimeout] - The millisecond timeout for the current running script.
         * @param {Array.<String>} [payload.trackContext] -
         * @param {Boolean} [payload.stopOnScriptError] - if set to true, then a synchronous error encountered during
         * execution of a script will stop executing any further scripts
         * @param {Boolean} [payload.abortOnFailure] -
         * @param {Boolean} [payload.stopOnFailure] -
         * @param {Function} next -
         *
         * @note - in order to raise trigger for the entire event, ensure your extension has registered the triggers
         */
        event (payload, next) {
            var item = payload.item,
                eventName = payload.name,
                cursor = payload.coords,
                // the payload can have a list of variables to track from the context post execution, ensure that
                // those are accurately set
                track = _.isArray(payload.trackContext) && _.isObject(payload.context) &&
                    // ensure that only those variables that are defined in the context are synced
                    payload.trackContext.filter(function (variable) {
                        return _.isObject(payload.context[variable]);
                    }),
                stopOnScriptError = (_.has(payload, 'stopOnScriptError') ? payload.stopOnScriptError :
                    this.options.stopOnScriptError),
                abortOnError = (_.has(payload, 'abortOnError') ? payload.abortOnError : this.options.abortOnError),

                // @todo: find a better home for this option processing
                abortOnFailure = payload.abortOnFailure,
                stopOnFailure = payload.stopOnFailure,

                events;

            // @todo: find a better place to code this so that event is not aware of such options
            if (abortOnFailure) {
                abortOnError = true;
            }

            // validate the payload
            if (!eventName) {
                return next(new Error('runner.extension~events: event payload is missing the event name.'));
            }
            if (!item) {
                return next(new Error('runner.extension~events: event payload is missing the triggered item.'));
            }

            // get the list of events to be executed
            // includes events in parent as well
            events = item.events.listeners(eventName, { excludeDisabled: true });

            // call the "before" event trigger by its event name.
            // at this point, the one who queued this event, must ensure that the trigger for it is defined in its
            // 'trigger' interface
            this.triggers[_.camelCase('before-' + eventName)](null, cursor, events, item);

            // with all the event listeners in place, we now iterate on them and execute its scripts. post execution,
            // we accumulate the results in order to be passed on to the event callback trigger.
            async.mapSeries(events, function (event, next) {
                // in case the event has no script we bail out early
                if (!event.script) {
                    return next(null, { event });
                }

                // get access to the script from the event.
                var script = event.script,
                    executionId = uuid.v4(),
                    assertionFailed = [],
                    asyncScriptError,

                    // create copy of cursor so we don't leak script ids outside `event.command`
                    // and across scripts
                    scriptCursor = _.clone(cursor);

                // store the execution id in script
                script._lastExecutionId = executionId; // please don't use it anywhere else!

                // if we can find an id on script or event we add them to the cursor
                // so logs and errors can be traced back to the script they came from
                event.id && (scriptCursor.eventId = event.id);
                event.script.id && (scriptCursor.scriptId = event.script.id);

                // trigger the "beforeScript" callback
                this.triggers.beforeScript(null, scriptCursor, script, event, item);

                // add event listener to trap all assertion events, but only if needed. to avoid needlessly accumulate
                // stuff in memory.
                (abortOnFailure || stopOnFailure) &&
                    this.host.on(EXECUTION_ASSERTION_EVENT_BASE + executionId, function (scriptCursor, assertions) {
                        _.forEach(assertions, function (assertion) {
                            assertion && !assertion.passed && assertionFailed.push(assertion.name);
                        });
                    });

                // To store error event, but only if needed. Because error in callback of host.execute()
                // don't show execution errors for async scripts
                (abortOnError || stopOnScriptError) &&
                    // only store first async error in case of multiple errors
                    this.host.once(EXECUTION_ERROR_EVENT_BASE + executionId, function (scriptCursor, error) {
                        if (error && !(error instanceof Error)) {
                            error = new Error(error.message || error);
                        }

                        asyncScriptError = error;

                        // @todo: Figure out a way to abort the script execution here as soon as we get an error.
                        //        We can send `execution.abort.` event to sandbox for this, but currently it silently
                        //        terminates the script execution without triggering the callback.
                    });

                this.host.on(EXECUTION_COOKIES_EVENT_BASE + executionId,
                    function (eventId, action, fnName, args) {
                        // only store action is supported, might need to support
                        // more cookie actions in next 2 years ¯\_(ツ)_/¯
                        if (action !== COOKIES_EVENT_STORE_ACTION) { return; }

                        var self = this,
                            dispatchEvent = EXECUTION_COOKIES_EVENT_BASE + executionId,
                            cookieJar = _.get(self, 'requester.options.cookieJar'),
                            cookieStore = cookieJar && cookieJar.store,
                            cookieDomain;

                        if (!cookieStore) {
                            return self.host.dispatch(dispatchEvent, eventId, 'CookieStore: no store found');
                        }

                        if (typeof cookieStore[fnName] !== 'function') {
                            return self.host.dispatch(dispatchEvent, eventId,
                                `CookieStore: invalid method name '${fnName}'`);
                        }

                        !Array.isArray(args) && (args = []);

                        // set expected args length to make sure callback is always called
                        args.length = cookieStore[fnName].length - 1;

                        // there's no way cookie store can identify the difference
                        // between regular and programmatic access. So, for now
                        // we check for programmatic access using the cookieJar
                        // helper method and emit the default empty value for that
                        // method.
                        // @note we don't emit access denied error here because
                        // that might blocks users use-case while accessing
                        // cookies for a sub-domain.
                        cookieDomain = getCookieDomain(fnName, args);
                        if (cookieJar && typeof cookieJar.allowProgrammaticAccess === 'function' &&
                            !cookieJar.allowProgrammaticAccess(cookieDomain)) {
                            return self.host.dispatch(dispatchEvent, eventId,
                                `CookieStore: programmatic access to "${cookieDomain}" is denied`);
                        }

                        // serialize cookie object
                        if (fnName === COOKIE_STORE_PUT_METHOD && args[0]) {
                            args[0] = ToughCookie.fromJSON(args[0]);
                        }

                        if (fnName === COOKIE_STORE_UPDATE_METHOD && args[0] && args[1]) {
                            args[0] = ToughCookie.fromJSON(args[0]);
                            args[1] = ToughCookie.fromJSON(args[1]);
                        }

                        // add store method's callback argument
                        args.push(function (err, res) {
                            // serialize error message
                            if (err && err instanceof Error) {
                                err = err.message || String(err);
                            }

                            self.host.dispatch(dispatchEvent, eventId, err, res);
                        });

                        try {
                            cookieStore[fnName](...args);
                        }
                        catch (error) {
                            self.host.dispatch(dispatchEvent, eventId,
                                `runtime~CookieStore: error executing "${fnName}"`);
                        }
                    }.bind(this));

                this.host.on(EXECUTION_REQUEST_EVENT_BASE + executionId,
                    function (scriptCursor, id, requestId, request) {
                        // remove files in request body if any
                        sanitizeFiles(request, function (err, request, sanitisedFiles) {
                            if (err) {
                                return this.host.dispatch(EXECUTION_RESPONSE_EVENT_BASE + id, requestId, err);
                            }

                            var nextPayload;

                            // if request is sanitized send a warning
                            if (!_.isEmpty(sanitisedFiles)) {
                                this.triggers.console(scriptCursor, 'warn',
                                    'uploading files from scripts is not allowed');
                            }

                            nextPayload = {
                                item: new sdk.Item({
                                    request: request,
                                    // Allow request body in methods like GET, HEAD, etc.
                                    // when explicitly provided via script
                                    // @todo make protocolProfileBehavior configurable via scripts
                                    protocolProfileBehavior: {
                                        disableBodyPruning: true
                                    }
                                }),
                                coords: scriptCursor,
                                // @todo - get script type from the sandbox
                                source: 'script',
                                // abortOnError makes sure request command bubbles errors
                                // so we can pass it on to the callback
                                abortOnError: true
                            };

                            // create context for executing this request
                            nextPayload.context = createItemContext(nextPayload);

                            this.immediate('httprequest', nextPayload).done(function (result) {
                                this.host.dispatch(EXECUTION_RESPONSE_EVENT_BASE + id,
                                    requestId,
                                    null,
                                    result && result.response,

                                    // @todo get cookies from result.history or pass PostmanHistory
                                    // instance once it is fully supported
                                    result && { cookies: result.cookies });
                            }).catch(function (err) {
                                this.host.dispatch(EXECUTION_RESPONSE_EVENT_BASE + id, requestId, err);
                            });
                        }.bind(this));
                    }.bind(this));

                // finally execute the script
                this.host.execute(event, {
                    id: executionId,
                    // debug: true,
                    timeout: payload.scriptTimeout, // @todo: Expose this as a property in Collection SDK's Script
                    cursor: scriptCursor,
                    context: _.pick(payload.context, SAFE_CONTEXT_VARIABLES),
                    serializeLogs: _.get(this, 'options.script.serializeLogs'),

                    // legacy options
                    legacy: {
                        _itemId: item.id,
                        _itemName: item.name
                    }
                }, function (err, result) {
                    this.host.removeAllListeners(EXECUTION_REQUEST_EVENT_BASE + executionId);
                    this.host.removeAllListeners(EXECUTION_ASSERTION_EVENT_BASE + executionId);
                    this.host.removeAllListeners(EXECUTION_RESPONSE_EVENT_BASE + executionId);
                    this.host.removeAllListeners(EXECUTION_COOKIES_EVENT_BASE + executionId);
                    this.host.removeAllListeners(EXECUTION_ERROR_EVENT_BASE + executionId);

                    // Handle async errors as well.
                    // If there was an error running the script itself, that takes precedence
                    if (!err && asyncScriptError) {
                        err = asyncScriptError;
                    }

                    // electron IPC does not bubble errors to the browser process, so we serialize it here.
                    err && (err = serialisedError(err, true));

                    // if it is defined that certain variables are to be synced back to result, we do the same
                    track && result && track.forEach(function (variable) {
                        if (!(_.isObject(result[variable]) && payload.context[variable])) { return; }

                        var contextVariable = payload.context[variable],
                            mutations = result[variable].mutations;

                        // bail out if there are no mutations
                        if (!mutations) {
                            return;
                        }

                        // ensure that variable scope is treated accordingly
                        if (_.isFunction(contextVariable.applyMutation)) {
                            mutations = new sdk.MutationTracker(result[variable].mutations);

                            mutations.applyOn(contextVariable);
                        }

                        // @todo: unify the non variable scope flows and consume diff always
                        // and drop sending the full variable scope from sandbox
                        else {
                            util.syncObject(contextVariable, result[variable]);
                        }
                    });

                    // Get the failures. If there was an error running the script itself, that takes precedence
                    if (!err && (abortOnFailure || stopOnFailure)) {
                        err = postProcessContext(result, assertionFailed); // also use async assertions
                    }

                    // Ensure that we have SDK instances, not serialized plain objects.
                    // @todo - should this be handled by the sandbox?
                    result && result._variables && (result._variables = new sdk.VariableScope(result._variables));
                    result && result.environment && (result.environment = new sdk.VariableScope(result.environment));
                    result && result.globals && (result.globals = new sdk.VariableScope(result.globals));
                    result && result.collectionVariables &&
                        (result.collectionVariables = new sdk.VariableScope(result.collectionVariables));
                    result && result.request && (result.request = new sdk.Request(result.request));

                    // @note Since postman-sandbox@3.5.2, response object is not included in the execution result.
                    // Refer: https://github.com/postmanlabs/postman-sandbox/pull/512
                    // Adding back here to avoid breaking change in `script` callback.
                    // @todo revisit script callback args in runtime v8.
                    result && payload.context && payload.context.response &&
                        (result.response = new sdk.Response(payload.context.response));

                    // persist the pm.variables for the next script
                    result && result._variables &&
                        (payload.context._variables = new sdk.VariableScope(result._variables));

                    // persist the pm.variables for the next request
                    result && result._variables && (this.state._variables = new sdk.VariableScope(result._variables));

                    // persist the mutated request in payload context,
                    // @note this will be used for the next prerequest script or
                    // upcoming commands(request, httprequest).
                    result && result.request && (payload.context.request = result.request);

                    // now that this script is done executing, we trigger the event and move to the next script
                    this.triggers.script(err || null, scriptCursor, result, script, event, item);

                    // move to next script and pass on the results for accumulation
                    next(((stopOnScriptError || abortOnError || stopOnFailure) && err) ? err : null, _.assign({
                        event,
                        script,
                        result
                    }, err && { error: err })); // we use assign here to avoid needless error property
                }.bind(this));
            }.bind(this), function (err, results) {
                // trigger the event completion callback
                this.triggers[eventName](null, cursor, results, item);
                next((abortOnError && err) ? err : null, results, err);
            }.bind(this));
        }
    }
};
