var _ = require('lodash'),
    fs = require('fs'),
    async = require('async'),
    Collection = require('postman-collection').Collection,
    VariableScope = require('postman-collection').VariableScope,
    CookieJar = require('tough-cookie').CookieJar,
    transformer = require('postman-collection-transformer'),
    liquidJSON = require('liquid-json'),
    parseCsv = require('csv-parse'),
    util = require('../util'),
    config = require('../config'),

    /**
     * The message displayed when the specified collection file can't be loaded.
     *
     * @const
     * @type {String}
     */
    COLLECTION_LOAD_ERROR_MESSAGE = 'collection could not be loaded',

    /**
     * The message displayed when the specified iteration data file can't be loaded.
     *
     * @const
     * @type {String}
     */
    ITERATION_DATA_LOAD_ERROR_MESSAGE = 'iteration data could not be loaded',

    /**
     * The message displayed when the specified environment or globals file can't be loaded.
     *
     * @const
     * @type {String}
     */
    LOAD_ERROR_MESSAGE = 'could not load ',

    /**
     * The set of postman collection transformer options, to convert collection v1 to collection v2.
     *
     * @const
     * @type {Object}
     */
    COLLECTION_TRANSFORMER_OPTION = { inputVersion: '1.0.0', outputVersion: '2.1.0' },

    /**
     * Accepts an object, and extracts the property inside an object which is supposed to contain the required data.
     * In case of variables, it also extracts them into plain JS objects.
     *
     * @param {Object} source - The source wrapper object that may or may not contain inner wrapped properties.
     * @param {String} type - "environment" or "globals", etc.
     * @returns {Object} - The object representation of the current extracted property.
     */
    extractModel = function (source, type) {
        source = source[type] || source; // extract object that holds variable. these usually come from cloud API
        if (!_.isObject(source)) {
            return undefined;
        }

        // ensure we un-box the JSON if it comes from cloud-api or similar sources
        !source.values && _.isObject(source[type]) && (source = source[type]);

        // we ensure that environment passed as array is converted to plain object. runtime does this too, but we do it
        // here for consistency of options passed to reporters
        return source;
    },

    /**
     * Loads the given data of type from a specified external location
     *
     * @param {String} type - The type of data to load.
     * @param {String} location - The location to load from (file path or URL).
     * @param {Object} options - The set of wrapped options.
     * @param {function} cb - The callback function whose invocation marks the end of the external load routine.
     * @returns {*}
     */
    externalLoader = function (type, location, options, cb) {
        return _.isString(location) ? util.fetchJson(type, location, options, function (err, data) {
            if (err) {
                return cb(err);
            }

            return cb(null, extractModel(data, type));
        }) : cb(null, extractModel(location, type));
    },

    /**
     * A helper method to process a collection and convert it to a V2 equivalent if necessary, and return it.
     *
     * @todo Drop support for the v1 collection format in Newman v5.
     * Reference: https://github.com/postmanlabs/newman/pull/1660
     *
     * @param {Object} collection The input collection, specified as a JSON object.
     * @param {Function} callback A handler function that consumes an error object and the processed collection.
     * @returns {*}
     */
    processCollection = function (collection, callback) {
        if (util.isV1Collection(collection)) {
            // @todo: route this via print module to respect silent flags
            console.warn('newman: Newman v4 deprecates support for the v1 collection format');
            console.warn('  Use the Postman Native app to export collections in the v2 format\n');

            return transformer.convert(collection, COLLECTION_TRANSFORMER_OPTION, callback);
        }

        callback(null, collection);
    },

    /**
     * Helper function that manages the load of environments and globals
     *
     * @private
     * @param {String} type - The type of resource to load: collection, environment, etc.
     * @param {String|Object} value - The value derived from the CLI or run command.
     * @param {Object} options - The set of wrapped options.
     * @param {Function} callback - The function invoked when the scope has been loaded.
     */
    loadScopes = function (type, value, options, callback) {
        var done = function (err, scope) {
            if (err) { return callback(new Error(LOAD_ERROR_MESSAGE + `${type}\n  ${err.message || err}`)); }

            if (!_.isObject(scope)) {
                return done(new Error(LOAD_ERROR_MESSAGE + type));
            }

            callback(null, new VariableScope(VariableScope.isVariableScope(scope) ? scope.toJSON() : scope));
        };

        if (_.isObject(value)) {
            return done(null, value);
        }

        externalLoader(type, value, options, done);
    },

    /**
     * Custom method to auto parse CSV values
     *
     * @private
     * @param {String} value - CSV field value
     * @param {Object} context - Context of field value
     * @param {Boolean} context.quoting - A boolean indicating if the field was surrounded by quotes.
     * @returns {String|Number|Date}
     */
    csvAutoParse = function (value, context) {
        if (context.quoting) {
            // avoid parsing quoted values
            return value;
        }

        if (util.isInt(value)) {
            return parseInt(value, 10);
        }

        if (util.isFloat(value)) {
            return parseFloat(value);
        }

        return value;
    },

    /**
     * Custom configuration loaders for the required configuration keys.
     *
     * @type {Object}
     */
    configLoaders = {

        /**
         * The collection file load helper for the current run.
         *
         * @param {Object|String} value - The collection, specified as a JSON object, or the path to it's file.
         * @param {Object} options - The set of wrapped options.
         * @param {Function} callback - The callback function invoked to mark the end of the collection load routine.
         * @returns {*}
         */
        collection: function (value, options, callback) {
            /**
             * The post collection load handler.
             *
             * @param {?Error} err - An Error instance / null, passed from the collection loader.
             * @param {Object} collection - The collection / raw JSON object, passed from the collection loader.
             * @returns {*}
             */
            var done = function (err, collection) {
                if (err) {
                    return callback(err);
                }

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

                // ensure that the collection reference is an SDK instance
                // @todo - should this be handled by config loaders?
                collection = new Collection(Collection.isCollection(collection) ?
                    // if the option contain an instance of collection, we simply clone it for future use
                    // create a collection in case it is not one. user can send v2 JSON as a source and that will be
                    // converted to a collection
                    collection.toJSON() : collection);

                callback(null, collection);
            };

            // if the collection has been specified as an object, convert to V2 if necessary and return the result
            if (_.isObject(value)) {
                return processCollection(value, done);
            }

            externalLoader('collection', value, options, function (err, data) {
                if (err) {
                    return done(new Error(COLLECTION_LOAD_ERROR_MESSAGE +
                        (err.help ? `\n  ${err.help}` : '') +
                        `\n  ${err.message || err}`));
                }
                if (!_.isObject(data)) {
                    return done(new Error(COLLECTION_LOAD_ERROR_MESSAGE));
                }

                return processCollection(data, done);
            });
        },

        /**
         * The environment configuration object, loaded for the current collection run.
         *
         * @type {Object}
         */
        environment: loadScopes.bind(this, 'environment'),

        /**
         * The object of globals, loaded for the collection run.
         *
         * @type {Object}
         */
        globals: loadScopes.bind(this, 'globals'),

        /**
         * Helper function to sanitize folder option.
         *
         * @param {String[]|String} value - The list of folders to execute
         * @param {Object} options - The set of wrapped options.
         * @param {Function} callback - The callback function invoked to mark the end of the folder load routine.
         * @returns {*}
         */
        folder: function (value, options, callback) {
            if (!value.length) {
                return callback(); // avoids empty string or array
            }

            if (Array.isArray(value) && value.length === 1) {
                return callback(null, value[0]); // avoids using multipleIdOrName strategy for a single item array
            }

            callback(null, value);
        },

        /**
         * The iterationData loader module, with support for JSON or CSV data files.
         *
         * @param {String|Object[]} location - The path to the iteration data file for the current collection run, or
         * the array of iteration data objects.
         * @param {Object} options - The set of wrapped options.
         * @param {Function} callback - The function invoked to indicate the end of the iteration data loading routine.
         * @returns {*}
         */
        iterationData: function (location, options, callback) {
            if (_.isArray(location)) { return callback(null, location); }

            util.fetch(location, function (err, data) {
                if (err) {
                    return callback(new Error(ITERATION_DATA_LOAD_ERROR_MESSAGE + `\n  ${err.message || err}`));
                }

                // Try loading as a JSON, fall-back to CSV.
                async.waterfall([
                    (cb) => {
                        try {
                            return cb(null, liquidJSON.parse(data.trim()));
                        }
                        catch (e) {
                            return cb(null, undefined); // e masked to avoid displaying JSON parse errors for CSV files
                        }
                    },
                    (json, cb) => {
                        if (json) {
                            return cb(null, json);
                        }
                        // Wasn't JSON
                        parseCsv(data, {
                            columns: true, // infer the columns names from the first row
                            escape: '"', // escape character
                            cast: csvAutoParse, // function to cast values of individual fields
                            trim: true, // ignore whitespace immediately around the delimiter
                            relax: true, // allow using quotes without escaping inside unquoted string
                            relax_column_count: true, // ignore inconsistent columns count
                            bom: true // strip the byte order mark (BOM) from the input string
                        }, cb);
                    }
                ], (err, parsed) => {
                    if (err) {
                        return callback(new Error(ITERATION_DATA_LOAD_ERROR_MESSAGE + `\n ${err.message || err}`));
                    }

                    callback(null, parsed);
                });
            });
        },

        sslClientCertList: function (location, options, callback) {
            if (Array.isArray(location)) {
                return callback(null, location);
            }

            if (typeof location !== 'string') {
                return callback(new Error('path for ssl client certificates list file must be a string'));
            }

            fs.readFile(location, function (err, value) {
                if (err) {
                    return callback(new Error(`unable to read the ssl client certificates file "${location}"`));
                }

                try {
                    value = liquidJSON.parse(value.toString(util.detectEncoding(value)).trim());
                }
                catch (e) {
                    return callback(new Error(`the file at ${location} does not contain valid JSON data.`));
                }

                // ensure that `sslClientCertList` is an array
                if (!Array.isArray(value)) {
                    return callback(new Error('expected ssl client certificates list to be an array.'));
                }

                return callback(null, value);
            });
        },

        cookieJar: function (location, options, callback) {
            if (_.isObject(location) && location.constructor.name === 'CookieJar') {
                return callback(null, location);
            }

            if (typeof location !== 'string') {
                return callback(new Error('cookieJar must be a path to a JSON file or a CookieJar instance'));
            }

            fs.readFile(location, function (err, value) {
                if (err) {
                    return callback(new Error(`unable to read the cookie jar file "${location}"`));
                }

                try {
                    value = CookieJar.fromJSON(value.toString());
                }
                catch (e) {
                    return callback(new Error(`the file at ${location} does not contain valid JSON data.`));
                }

                return callback(null, value);
            });
        }
    };

/**
 * The helper function to load all file based information for the current collection run.
 *
 * @param {Object} options - The set of generic collection run options.
 * @param {Function} callback - The function called to mark the completion of the configuration load routine.
 * @returns {*}
 */
module.exports = function (options, callback) {
    // set newman version used for collection run
    options.newmanVersion = util.version;

    // set working directory if not provided
    options.workingDir = options.workingDir || process.cwd();

    // allow insecure file read by default
    options.insecureFileRead = Boolean(_.get(options, 'insecureFileRead', true));

    config.get(options, { loaders: configLoaders, command: 'run' }, function (err, result) {
        if (err) { return callback(err); }

        !_.isEmpty(options.globalVar) && _.forEach(options.globalVar, function (variable) {
            variable && (result.globals.set(variable.key, variable.value));
        });

        !_.isEmpty(options.envVar) && _.forEach(options.envVar, function (variable) {
            variable && (result.environment.set(variable.key, variable.value));
        });

        callback(null, result);
    });
};
