const _ = require('lodash');
const request = require('request');
const logger = require('../lib/logger');
const sanitizeRequest = require('../lib/sanitizeRequest');

const MODIFYING_METHODS = ['post', 'patch', 'put', 'del'];
const RETRIABLE_ERRORS = [
    'ECONNRESET',
    'ENOTFOUND',
    'ESOCKETTIMEDOUT',
    'ETIMEDOUT',
    'ECONNREFUSED',
    'EHOSTUNREACH',
    'ENETUNREACH',
    'EPIPE',
    'EAI_AGAIN',
];
const MAX_ATTEMPT_COUNT = 3;
const MAX_TIMEOUT = 30000;

function generateId() {
    return String(Date.now()) + String(Math.floor(1e3 * Math.random()));
}

/**
 * Проверяет является ли метод модифицирующим
 * @param {String} method
 * @returns {Boolean}
 */
function isModifyingMethod(method) {
    return MODIFYING_METHODS.indexOf(method) !== -1;
}

/**
 * Проставляет тайминги
 * @param {Request} req
 * @param {Response} res
 * @param {Object} originalOptions
 * @param {Number} attempt
 */
function setRequestTimings(req, res, originalOptions, attempt) {
    const startTime = _.get(res, 'request.startTime', 0);

    const logItem = {
        tag: originalOptions.label || 'unknown',
        duration: res.elapsedTime,
        relative_timestamp: startTime - req.context.timing.timestamp,
        timestamp: startTime,
        attempt,
    };

    req.context.timing.log.push(logItem);
}

/**
 * Возвращает дефолтные опциии запроса
 * @param {Request} req
 * @param {Object} originalOptions
 * @returns {Object}
 */
function getDefaultRequestOptions(req, originalOptions) {
    const requestOptions = {};
    const modifying = isModifyingMethod(originalOptions.method);

    const requestHeaders = originalOptions.headers || _.extend({}, req.context.headers);

    if (modifying && !requestHeaders['Content-Type'] && !originalOptions.formData) {
        requestHeaders['Content-Type'] = 'application/json';
    }

    if (!requestHeaders['x-request-id']) {
        // x-request-id должен быть не более 60 символов
        requestHeaders['x-request-id'] = `${requestHeaders['x-request-id']}-${generateId()}`.substr(0, 60);
    }

    requestOptions.headers = requestHeaders;
    requestOptions.encoding = originalOptions.encoding;
    requestOptions.qs = originalOptions.query || req.query;

    if (modifying) {
        const requestBody = originalOptions.body === undefined ? req.body : originalOptions.body;

        if (typeof requestBody === 'string' || requestBody === null || requestBody instanceof Buffer) {
            requestOptions.body = requestBody;
        } else {
            requestOptions.body = JSON.stringify(requestBody);
        }

        if (originalOptions.formData) {
            requestOptions.formData = originalOptions.formData;
        }
    }

    return requestOptions;
}

/**
 * Проверяет является ли ошибка сетевой или http-ой
 * @param {err} error
 * @param {Response|undefined} response
 * @returns {*|Boolean}
 */
function isHTTPOrNetworkError(error, response) {
    response = response || {};
    const isHTTPError = response.statusCode >= 500 && response.statusCode < 600;
    const isNetworkError = error && _.includes(RETRIABLE_ERRORS, error.code || error.message);

    return isHTTPError || isNetworkError;
}

function getRequestInfo(originalOptions, requestOptions) {
    return [
        originalOptions.method.toUpperCase(),
        originalOptions.url,
        originalOptions.label ? `#${originalOptions.label}` : null,
        _.get(requestOptions, 'headers.x-request-id') ||
        _.get(originalOptions, 'source.context.headers.x-request-id', ''),
    ].filter(Boolean).join(' ');
}

/**
 * Делает запрос с парамеметрами
 * В случаем ошибки, если необходимо делает перезапрос
 * @param {Object} params
 * @returns {Promise}
 */
function sendRequest(params) {
    const originalOptions = params.originalOptions;
    const req = originalOptions.source;
    const url = originalOptions.url;
    const method = originalOptions.requestMethodName;
    const requestOptions = params.requestOptions;
    const requestInfo = getRequestInfo(originalOptions, requestOptions);

    params.attempt += 1;

    let customTimeout = originalOptions.timeout || req.get('x-timeout');

    if (customTimeout) {
        customTimeout = Number(customTimeout);
    }

    params.requestOptions.timeout = Math.max(customTimeout || 0, MAX_TIMEOUT);

    logger.info(`Sent ${requestInfo}`, req, { options: requestOptions });

    return new Promise((resolve, reject) => {
        let hasError;

        request[method](url, requestOptions, (error, response, body) => {
            response = response || {};
            hasError = error || response.statusCode >= 400;

            const loggerOptions = {
                options: requestOptions,
                time: response.elapsedTime,
                attempt: params.attempt,
                method,
                url,
            };

            if (typeof body === 'string') {
                try {
                    body = body ? JSON.parse(body) : {};
                } catch (e) {
                    logger.error(
                        `[Error] Failed to parse ${requestInfo}`,
                        req, { body, loggerOptions }
                    );
                }
            }

            setRequestTimings(req, response, originalOptions, params.attempt);

            if (hasError) {
                _.extend(loggerOptions, {
                    statusCode: _.get(response, 'statusCode'),
                    error: {
                        code: _.get(error, 'code'),
                        message: _.get(error, 'message'),
                        stack: _.get(error, 'stack'),
                    },
                });

                logger.info(
                    `[Error] Resolved ${requestInfo} ${response.elapsedTime}ms`,
                    req, _.extend({ body }, loggerOptions)
                );

                if (isHTTPOrNetworkError(error, response) && !isModifyingMethod(method) &&
                    params.attempt < MAX_ATTEMPT_COUNT) {
                    logger.info(
                        `[Error] Retry ${requestInfo} ${response.elapsedTime}ms`,
                        req, loggerOptions
                    );

                    resolve(sendRequest(params));

                    return;
                }

                reject(_.extend(error || response, { body }));

                return;
            }

            logger.info(
                `Resolved ${requestInfo} ${response.elapsedTime}ms`,
                req, _.extend({ body }, loggerOptions)
            );

            if (params.returnResponse) {
                resolve({
                    body,
                    response,
                });
            } else {
                resolve(body);
            }
        })
            .on('error', error => {
                if (!hasError) {
                    logger.error('[Error] Failed', req, {
                        message: _.get(error, 'message'), stack: _.get(error, 'stack'),
                    });
                }
                reject(error);
            });
    });
}

function rejectUnsafeRequest(req, options) {
    logger.error(`Failed to send ${getRequestInfo(options)}: Potentially unsafe.`, req);

    _.set(req, 'context.state.unsafe', true);

    return Promise.reject();
}

// { method, url, [source], [headers], [query], [body] }
// { method, url, [source], [options: { headers, qs, body }] }
function sendHandledRequest(originalOptions, returnResponse) {
    originalOptions = sanitizeRequest(originalOptions);

    const req = originalOptions.source || {};
    const requestOptions = originalOptions.options || getDefaultRequestOptions(req, originalOptions);

    if (originalOptions.unsafe) {
        return rejectUnsafeRequest(req, originalOptions);
    }

    if (req.config.app.env === 'ui-test') {
        const __hash__ = req.cookies.__hash__;

        requestOptions.qs = _.extend(
            requestOptions.qs || {},
            {
                'dumps-test-path': req.cookies['dumps-test-path'],
            },
            __hash__ && {
                __hash__,
            }
        );
    }

    req.context = req.context || {};
    req.context.timing = req.context.timing || { timestamp: Date.now(), log: [] };

    //  If true, the request-response cycle (including all redirects) is timed at millisecond resolution,
    // and the result provided on the response.elapsedTime property.
    // The response.startTime property is also available to indicate the timestamp when the response begins
    requestOptions.time = true;

    return sendRequest({
        originalOptions,
        requestOptions,
        attempt: 0,
        returnResponse,
    });
}

module.exports = sendHandledRequest;
