/* eslint-disable jsdoc/require-param-type,jsdoc/require-param-description */
var _ = require('lodash'),
    core = require('./core'),
    EventEmitter = require('events'),
    now = require('performance-now'),
    sdk = require('postman-collection'),
    requests = require('./request-wrapper'),
    dryRun = require('./dry-run'),

    RESPONSE_START_EVENT_BASE = 'response.start.',
    RESPONSE_END_EVENT_BASE = 'response.end.',

    RESPONSE_START = 'responseStart',
    RESPONSE_END = 'response',

    ERROR_RESTRICTED_ADDRESS = 'NETERR: getaddrinfo ENOTFOUND ',

    /**
     * Headers which get overwritten by the requester.
     *
     * @private
     * @const
     * @type {Object}
     */
    OVERWRITTEN_HEADERS = {
        cookie: true, // cookies get appended with `;`
        'content-length': true
    },

    /**
     * Creates a sdk compatible cookie from a tough-cookie compatible cookie.
     *
     * @param cookie
     * @returns {Object}
     */
    toPostmanCookie = function (cookie) {
        var expires = cookie.expiryTime();

        cookie.toJSON && (cookie = cookie.toJSON());

        return new sdk.Cookie({
            name: cookie.key,
            value: cookie.value,
            expires: Number.isFinite(expires) ? new Date(expires) : null,
            maxAge: cookie.maxAge,
            domain: cookie.domain,
            path: cookie.path,
            secure: cookie.secure,
            httpOnly: cookie.httpOnly,
            hostOnly: cookie.hostOnly,
            extensions: cookie.extensions
        });
    },

    /**
     * This method is used in conjunction with _.transform method to convert multi-value headers to multiple single
     * value headers
     *
     * @param  {Array} acc
     * @param  {Array|String} val
     * @param  {String} key
     * @return {Object}
     */
    transformMultiValueHeaders = function (acc, val, key) {
        var i, ii;

        if (Array.isArray(val)) {
            for (i = 0, ii = val.length; i < ii; i++) {
                acc.push({
                    key: key,
                    value: val[i]
                });
            }
        }
        else {
            acc.push({
                key: key,
                value: val
            });
        }
    },

    /**
     * Calculate request timings offset by adding runtime overhead which
     * helps to determine request prepare and process time taken.
     *
     * @param {Number} runtimeTimer - Runtime request start HR time
     * @param {Number} requestTimer - Request start HR time
     * @param {Object} timings - Request timings offset
     * @returns {Object}
     */
    calcTimingsOffset = function (runtimeTimer, requestTimer, timings) {
        if (!(runtimeTimer && requestTimer && timings)) { return; }

        // runtime + postman-request initialization time
        var initTime = requestTimer - runtimeTimer,
            offset = {
                request: initTime
            };

        // add initialization overhead to request offsets
        _.forOwn(timings, function (value, key) {
            offset[key] = value + initTime;
        });

        // total time taken by runtime to get the response
        // @note if offset.end is missing, that means request is not complete.
        //       this is used to calculate timings on responseStart.
        if (offset.end) {
            offset.done = now() - runtimeTimer;
        }

        return offset;
    };

class Requester extends EventEmitter {
    /**
     * Creates a new Requester, which is used to make HTTP(s) requests.
     *
     * @param trace
     * @param options
     * @param {Boolean} [options.keepAlive=true] Optimizes HTTP connections by keeping them alive, so that new requests
     * to the same host are made over the same underlying TCP connection.
     * @param {CookieJar} [options.cookieJar] A cookie jar to use with Node requests.
     * @param {Boolean} [options.strictSSL]
     * @param {Boolean} [options.followRedirects=true] If false, returns a 301/302 as the response code
     * instead of following the redirect
     * @note `options.keepAlive` is only supported in Node.
     * @note `options.cookieJar` is only supported in Node.
     */
    constructor (trace, options) {
        super();

        this.trace = trace;
        this.options = options || {};

        // protect the timeout value from being non-numeric or infinite
        if (!_.isFinite(this.options.timeout)) {
            this.options.timeout = undefined;
        }
    }

    /**
     * Perform an HTTP request.
     *
     * @param {String} id
     * @param {Request} request
     * @param {Object} protocolProfileBehavior
     * @param {Function} callback
     */
    request (id, request, protocolProfileBehavior, callback) {
        var self = this,
            hostname,
            cookieJar,
            requestOptions,
            networkOptions = self.options.network || {},
            startTime = Date.now(),
            startTimer = now(), // high-resolution time
            cookies = [],
            responseHeaders = [],
            responseJSON = {},

            // keep track of `responseStart` and `response` triggers
            _responseStarted = false,
            _responseEnded = false,
            _responseData = {},

            // Refer: https://github.com/postmanlabs/postman-runtime/blob/v7.14.0/docs/history.md
            getExecutionHistory = function (debugInfo) {
                var history = {
                        execution: {
                            verbose: Boolean(requestOptions.verbose),
                            sessions: {},
                            data: []
                        }
                    },
                    executionData = [],
                    requestSessions = {};

                if (!Array.isArray(debugInfo)) {
                    return history;
                }

                // prepare history from request debug data
                debugInfo.forEach(function (debugData) {
                    if (!debugData) { return; }

                    // @todo cache connection sessions and fetch reused session
                    // from the requester pool.
                    if (debugData.session && !requestSessions[debugData.session.id]) {
                        requestSessions[debugData.session.id] = debugData.session.data;
                    }

                    executionData.push({
                        request: debugData.request,
                        response: debugData.response,
                        timings: debugData.timings && {
                            // runtime start time
                            start: startTime,
                            // request start time
                            requestStart: debugData.timingStart,
                            // offsets calculated are relative to runtime start time
                            offset: calcTimingsOffset(startTimer, debugData.timingStartTimer, debugData.timings)
                        },
                        session: debugData.session && {
                            id: debugData.session.id,
                            // is connection socket reused
                            reused: debugData.session.reused
                        }
                    });
                });

                // update history object
                history.execution.data = executionData;
                history.execution.sessions = requestSessions;

                return history;
            },

            /**
             * Add the missing/system headers in the request object
             *
             * @param {Object[]} headers
             */
            addMissingRequestHeaders = function (headers) {
                _.forEach(headers, function (header) {
                    var lowerCasedKey = header.key.toLowerCase();

                    // update headers which gets overwritten by the requester
                    if (OVERWRITTEN_HEADERS[lowerCasedKey]) {
                        if (Array.isArray(_.get(request.headers, ['reference', lowerCasedKey]))) {
                            request.headers.remove(header.key);
                        }

                        request.headers.upsert({
                            key: header.key,
                            value: header.value,
                            system: true
                        });
                    }
                });
            },

            /**
             * Helper function to trigger `callback` and complete the request function
             *
             * @param {Error} error - error while requesting
             * @param {Response} response - SDK Response instance
             * @param {Object} history - Request-Response History
             */
            onEnd = function (error, response, history) {
                self.emit(RESPONSE_END_EVENT_BASE + id, error, self.trace.cursor,
                    self.trace, response, request, cookies, history);

                return callback(error, response, request, cookies, history);
            },

            /**
             * Helper function to keep track of `responseStart` and `response`
             * triggers to make they are emitted in correct order.
             *
             * @todo fix requester control flow to remove this hack!
             *  this is required because CookieJar.getCookies is async method
             *  and by that time postman-request ends the request, which affects
             *  request post-send helpers because `response.start` event is not
             *  emitted on time and shared variables `cookies`, `responseJSON`,
             *  and, `responseHeaders` are initialized in onStart function.
             *
             * @param {String} trigger - trigger name
             * @param {Response} response - SDK Response instance
             * @param {Object} history - Request-Response History
             */
            onComplete = function (trigger, response, history) {
                if (trigger === RESPONSE_START) {
                    // set flag for responseStart callback
                    _responseStarted = true;

                    // if response is ended, end the response using cached data
                    if (_responseEnded) {
                        onEnd(null, _responseData.response, _responseData.history);
                    }

                    // bail out and wait for response end if not ended already
                    return;
                }

                // if response started, don't wait and end the response
                if (_responseStarted) {
                    onEnd(null, response, history);

                    return;
                }

                // wait for responseStart and cache response callback data
                _responseEnded = true;
                _responseData = {
                    response,
                    history
                };
            },

            /**
             * Helper function to trigger `responseStart` callback and
             * - transform postman-request response instance to SDK Response
             * - filter cookies
             * - filter response headers
             * - add missing request headers
             *
             * @param {Object} response - Postman-Request response instance
             */
            onStart = function (response) {
                var responseStartEventName = RESPONSE_START_EVENT_BASE + id,
                    executionData,
                    initialRequest,
                    finalRequest,
                    sdkResponse,
                    history,
                    done = function () {
                        // emit the response.start event which eventually
                        // triggers responseStart callback
                        self.emit(responseStartEventName, null, sdkResponse, request, cookies, history);

                        // trigger completion of responseStart
                        onComplete(RESPONSE_START);
                    };

                // @todo get rid of jsonifyResponse
                responseJSON = core.jsonifyResponse(response, requestOptions);

                // transform response headers to SDK compatible HeaderList
                responseHeaders = _.transform(responseJSON.headers, transformMultiValueHeaders, []);

                // initialize SDK Response instance
                sdkResponse = new sdk.Response({
                    status: response && response.statusMessage,
                    code: responseJSON.statusCode,
                    header: responseHeaders
                });

                // prepare history from request debug data
                history = getExecutionHistory(_.get(response, 'request._debug'));

                // get the initial and final (on redirect) request from history
                executionData = _.get(history, 'execution.data') || [];
                initialRequest = _.get(executionData, '[0].request') || {};
                finalRequest = executionData.length > 1 ?
                    // get final redirect
                    _.get(executionData, [executionData.length - 1, 'request']) :
                    // no redirects
                    initialRequest;

                // add missing request headers so that they get bubbled up into the UI
                addMissingRequestHeaders(initialRequest.headers);

                // pull out cookies from the cookie jar, and make them chrome compatible.
                if (cookieJar && _.isFunction(cookieJar.getCookies)) {
                    // get cookies set for the final request URL
                    cookieJar.getCookies(finalRequest.href, function (err, cookiesFromJar) {
                        if (err) {
                            return done();
                        }

                        cookies = _.transform(cookiesFromJar, function (acc, cookie) {
                            acc.push(toPostmanCookie(cookie));
                        }, []);

                        cookies = new sdk.CookieList(null, cookies);

                        done();
                    });
                }
                else {
                    cookies = new sdk.CookieList(null, []);
                    done();
                }
            };

        // at this point the request could have come from collection, auth or sandbox
        // we can't trust the integrity of this request
        // bail out if request url is empty
        if (!(request && request.url && request.url.toString && request.url.toString())) {
            return onEnd(new Error('runtime:extensions~request: request url is empty'));
        }

        cookieJar = self.options.cookieJar;
        requestOptions = core.getRequestOptions(request, self.options, protocolProfileBehavior);

        // update url with the final encoded url
        // @note this mutates the request object which will be passed in request
        // and response callbacks
        request.url.update(requestOptions.url.href);
        hostname = request.url.getHost();

        // check if host is on the `restrictedAddresses`
        if (networkOptions.restrictedAddresses && core.isAddressRestricted(hostname, networkOptions)) {
            return onEnd(new Error(ERROR_RESTRICTED_ADDRESS + hostname));
        }

        return requests(request, requestOptions, onStart, function (err, res, resBody, debug) {
            // prepare history from request debug data
            var history = getExecutionHistory(debug),
                responseTime,
                response;

            if (err) {
                // bubble up http errors
                // @todo - Should we send an empty sdk Response here?
                //
                // Sending `history` object even in case of error
                return onEnd(err, undefined, history);
            }


            // Calculate the time taken for us to get the response.
            responseTime = Date.now() - startTime;

            if (res && res.timings) {
                // update response time to actual response end time
                // of the final request in the redirect chain.
                responseTime = Math.ceil(res.timings.end);
            }

            if (resBody && resBody instanceof ArrayBuffer) {
                resBody = Buffer.from(resBody);
            }

            // Response in the SDK format
            // @todo reuse same response instance used for responseStart callback
            response = new sdk.Response({
                code: responseJSON.statusCode,
                status: res && res.statusMessage,
                header: responseHeaders,
                stream: resBody,
                responseTime: responseTime
            });

            onComplete(RESPONSE_END, response, history);
        });
    }

    /**
     * Removes all current event listeners on the requester, and makes it ready for garbage collection :).
     *
     * @param {Function=} cb - Optional callback to be called on disposal
     *
     * @todo - In the future, when the requester manages its own connections etc, close them all here.
     */
    dispose (cb) {
        // This is safe for us, because we do not use wait on events. (i.e, no part of Runtime ever waits on
        // any event to occur). We rely on callbacks for that, only choosing to use events as a way of streaming
        // information outside runtime.
        this.removeAllListeners();

        _.isFunction(cb) && cb();
    }

    /**
     * Asynchronously create a new requester.
     *
     * @param trace
     * @param trace.type - type of requester to return (for now, just http)
     * @param trace.source - information about who needs this requester, e.g Auth, etc.
     * @param trace.cursor - the cursor
     * @param options
     * @param callback
     * @returns {*}
     */
    static create (trace, options, callback) {
        return callback(null, new Requester(trace, options));
    }

    /**
     * A helper method to dry run the given request instance.
     * It returns the cloned request instance with the system added properties.
     *
     * @param {Request} request
     * @param {Object} options
     * @param {Object} options.cookieJar
     * @param {Object} options.protocolProfileBehavior
     * @param {Object} options.implicitCacheControl
     * @param {Object} options.implicitTraceHeader
     * @param {Function} done
     */
    static dryRun (request, options, done) {
        return dryRun(request, options, done);
    }
}

module.exports.Requester = Requester;
