/**
 * Copyright (c) 2014 Baidu.com, Inc. All Rights Reserved
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
 * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations under the License.
 *
 * @file src/http_client.js
 * @author leeight
 */

/* eslint-env node */
/* eslint max-params:[0,10] */
/* globals ArrayBuffer */

var http = require('http');
var https = require('https');
var util = require('util');
var stream = require('stream');
var EventEmitter = require('events').EventEmitter;

var u = require('underscore');
var Q = require('q');
var debug = require('debug')('bce-sdk:HttpClient');

var H = require('./headers');
var Auth = require('./auth');

/**
 * The HttpClient
 *
 * @constructor
 * @param {Object} config The http client configuration.
 */
function HttpClient(config) {
    EventEmitter.call(this);

    this.config = config;

    /**
     * http(s) request object
     * @type {Object}
     */
    this._req = null;
}
util.inherits(HttpClient, EventEmitter);

/**
 * Send Http Request
 *
 * @param {string} httpMethod GET,POST,PUT,DELETE,HEAD
 * @param {string} path The http request path.
 * @param {(string|Buffer|stream.Readable)=} body The request body. If `body` is a
 * stream, `Content-Length` must be set explicitly.
 * @param {Object=} headers The http request headers.
 * @param {Object=} params The querystrings in url.
 * @param {function():string=} signFunction The `Authorization` signature function
 * @param {stream.Writable=} outputStream The http response body.
 * @param {number=} retry The maximum number of network connection attempts.
 *
 * @resolve {{http_headers:Object,body:Object}}
 * @reject {Object}
 *
 * @return {Q.defer}
 */
HttpClient.prototype.sendRequest = function (httpMethod, path, body, headers, params,
                                             signFunction, outputStream) {
    httpMethod = httpMethod.toUpperCase();
    var requestUrl = this._getRequestUrl(path, params);
    var options = require('url').parse(requestUrl);
    debug('httpMethod = %s, requestUrl = %s, options = %j',
        httpMethod, requestUrl, options);

    // Prepare the request headers.
    var defaultHeaders = {};
    if (typeof navigator === 'object') {
        defaultHeaders[H.USER_AGENT] = navigator.userAgent;
    }
    else {
        defaultHeaders[H.USER_AGENT] = util.format('bce-sdk-nodejs/%s/%s/%s', require('../package.json').version,
            process.platform, process.version);
    }
    defaultHeaders[H.X_BCE_DATE] = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
    defaultHeaders[H.CONNECTION] = 'close';
    defaultHeaders[H.CONTENT_TYPE] = 'application/json; charset=UTF-8';
    defaultHeaders[H.HOST] = options.host;

    headers = u.extend({}, defaultHeaders, headers);

    // if (!headers.hasOwnProperty(H.X_BCE_REQUEST_ID)) {
    //    headers[H.X_BCE_REQUEST_ID] = this._generateRequestId();
    // }

    // Check the content-length
    if (!headers.hasOwnProperty(H.CONTENT_LENGTH)) {
        var contentLength = this._guessContentLength(body);
        if (!(contentLength === 0 && /GET|HEAD/i.test(httpMethod))) {
            // 如果是 GET 或 HEAD 请求，并且 Content-Length 是 0，那么 Request Header 里面就不要出现 Content-Length
            // 否则本地计算签名的时候会计算进去，但是浏览器发请求的时候不一定会有，此时导致 Signature Mismatch 的情况
            headers[H.CONTENT_LENGTH] = contentLength;
        }
    }

    var client = this;
    options.method = httpMethod;
    options.headers = headers;

    // 通过browserify打包后，在Safari下并不能有效处理server的content-type
    // 参考ISSUE：https://github.com/jhiesey/stream-http/issues/8
    options.mode = 'prefer-fast';

    // rejectUnauthorized: If true, the server certificate is verified against the list of supplied CAs.
    // An 'error' event is emitted if verification fails.
    // Verification happens at the connection level, before the HTTP request is sent.
    options.rejectUnauthorized = false;

    if (typeof signFunction === 'function') {
        var promise = signFunction(this.config.credentials, httpMethod, path, params, headers);
        if (isPromise(promise)) {
            return promise.then(function (authorization, xbceDate) {
                headers[H.AUTHORIZATION] = authorization;
                if (xbceDate) {
                    headers[H.X_BCE_DATE] = xbceDate;
                }
                debug('options = %j', options);
                return client._doRequest(options, body, outputStream);
            });
        }
        else if (typeof promise === 'string') {
            headers[H.AUTHORIZATION] = promise;
        }
        else {
            throw new Error('Invalid signature = (' + promise + ')');
        }
    }
    else {
        headers[H.AUTHORIZATION] = createSignature(this.config.credentials, httpMethod, path, params, headers);
    }

    debug('options = %j', options);
    return client._doRequest(options, body, outputStream);
};

function createSignature(credentials, httpMethod, path, params, headers) {
    var auth = new Auth(credentials.ak, credentials.sk);
    return auth.generateAuthorization(httpMethod, path, params, headers);
}

function isPromise(obj) {
    return obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function';
}

HttpClient.prototype._isValidStatus = function (statusCode) {
    return statusCode >= 200 && statusCode < 300;
};

HttpClient.prototype._doRequest = function (options, body, outputStream) {
    var deferred = Q.defer();
    var api = options.protocol === 'https:' ? https : http;
    var client = this;

    var req = client._req = api.request(options, function (res) {
        if (client._isValidStatus(res.statusCode) && outputStream
            && outputStream instanceof stream.Writable) {
            res.pipe(outputStream);
            outputStream.on('finish', function () {
                deferred.resolve(success(client._fixHeaders(res.headers), {}));
            });
            outputStream.on('error', function (error) {
                deferred.reject(error);
            });
            return;
        }
        deferred.resolve(client._recvResponse(res));
    });

    // 设置超时10s
    if (typeof req.setTimeout === 'function') {
        req.setTimeout(10e3);

        req.on('timeout', function() {
            deferred.reject(new Error('socket Timeout!'));

            req.destroy();
        });
    } else if (req.xhr) {
        req.xhr.timeout = 10e3;
    }

    if (req.xhr && typeof req.xhr.upload === 'object') {
        u.each(['progress', 'error', 'abort', 'timeout'], function (eventName) {
            req.xhr.upload.addEventListener(eventName, function (evt) {
                client.emit(eventName, evt);
            }, false);
        });
    }

    req.on('error', function (error) {
        deferred.reject(error);
    });

    try {
        client._sendRequest(req, body);
    }
    catch (ex) {
        deferred.reject(ex);
    }
    return deferred.promise;
};

HttpClient.prototype._generateRequestId = function () {
    function chunk() {
        var v = (~~(Math.random() * 0xffff)).toString(16);
        if (v.length < 4) {
            v += new Array(4 - v.length + 1).join('0');
        }
        return v;
    }

    return util.format('%s%s-%s-%s-%s-%s%s%s',
        chunk(), chunk(), chunk(), chunk(),
        chunk(), chunk(), chunk(), chunk());
};

HttpClient.prototype._guessContentLength = function (data) {
    if (data == null) {
        return 0;
    }
    else if (typeof data === 'string') {
        return Buffer.byteLength(data);
    }
    else if (typeof data === 'object') {
        if (typeof Blob !== 'undefined' && data instanceof Blob) {
            return data.size;
        }
        if (typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer) {
            return data.byteLength;
        }
        if (Buffer.isBuffer(data)) {
            return data.length;
        }
        /**
         if (typeof FormData !== 'undefined' && data instanceof FormData) {
         }
         */
    }
    else if (Buffer.isBuffer(data)) {
        return data.length;
    }

    throw new Error('No Content-Length is specified.');
};

HttpClient.prototype._fixHeaders = function (headers) {
    var fixedHeaders = {};

    if (headers) {
        Object.keys(headers).forEach(function (key) {
            var value = headers[key].trim();
            if (value) {
                key = key.toLowerCase();
                if (key === 'etag') {
                    value = value.replace(/"/g, '');
                }
                fixedHeaders[key] = value;
            }
        });
    }

    return fixedHeaders;
};

HttpClient.prototype._recvResponse = function (res) {
    var responseHeaders = this._fixHeaders(res.headers);
    var statusCode = res.statusCode;

    function parseHttpResponseBody(raw) {
        var contentType = responseHeaders['content-type'];

        if (!raw.length) {
            return {};
        }
        else if (contentType
            && /(application|text)\/json/.test(contentType)) {
            return JSON.parse(raw.toString());
        }
        return raw;
    }

    var deferred = Q.defer();

    var payload = [];
    /* eslint-disable */
    res.on('data', function (chunk) {
        if (Buffer.isBuffer(chunk)) {
            payload.push(chunk);
        }
        else {
            // xhr2返回的内容是 string，不是 Buffer，导致 Buffer.concat 的时候报错了
            payload.push(new Buffer(chunk));
        }
    });
    res.on('error', function (e) {
        deferred.reject(e);
    });
    /* eslint-enable */
    res.on('end', function () {
        var raw = Buffer.concat(payload);
        var responseBody = null;

        try {
            debug('responseHeaders = %j', responseHeaders);
            responseBody = parseHttpResponseBody(raw);
        }
        catch (e) {
            debug('statusCode = %s, Parse response body error = %s', statusCode, e.message);
            deferred.reject(failure(statusCode, e.message));
            return;
        }

        if (statusCode >= 100 && statusCode < 200) {
            deferred.reject(failure(statusCode, 'Can not handle 1xx http status code.'));
        }
        else if (statusCode < 100 || statusCode >= 300) {
            if (responseBody.requestId) {
                deferred.reject(failure(statusCode, responseBody.message,
                    responseBody.code, responseBody.requestId, responseHeaders.date));
            }
            else {
                deferred.reject(failure(statusCode, responseBody));
            }
        }

        deferred.resolve(success(responseHeaders, responseBody));
    });

    return deferred.promise;
};

/* eslint-disable */
function isXHR2Compatible(obj) {
    if (typeof Blob !== 'undefined' && obj instanceof Blob) {
        return true;
    }
    if (typeof ArrayBuffer !== 'undefined' && obj instanceof ArrayBuffer) {
        return true;
    }
    if (typeof FormData !== 'undefined' && obj instanceof FormData) {
        return true;
    }
}
/* eslint-enable */

HttpClient.prototype._sendRequest = function (req, data) {
    /* eslint-disable */
    if (!data) {
        req.end();
        return;
    }
    if (typeof data === 'string') {
        data = new Buffer(data);
    }
    /* eslint-enable */

    if (Buffer.isBuffer(data) || isXHR2Compatible(data)) {
        req.write(data);
        req.end();
    }
    else if (data instanceof stream.Readable) {
        if (!data.readable) {
            throw new Error('stream is not readable');
        }

        data.on('data', function (chunk) {
            req.write(chunk);
        });
        data.on('end', function () {
            req.end();
        });
    }
    else {
        throw new Error('Invalid body type = ' + typeof data);
    }
};

HttpClient.prototype.buildQueryString = function (params) {
    var urlEncodeStr = require('querystring').stringify(params);
    // https://en.wikipedia.org/wiki/Percent-encoding
    return urlEncodeStr.replace(/[()'!~.*\-_]/g, function (char) {
        return '%' + char.charCodeAt().toString(16);
    });
};

HttpClient.prototype._getRequestUrl = function (path, params) {
    var uri = path;
    var qs = this.buildQueryString(params);
    if (qs) {
        uri += '?' + qs;
    }

    return this.config.endpoint + uri;
};

function success(httpHeaders, body) {
    var response = {};

    response[H.X_HTTP_HEADERS] = httpHeaders;
    response[H.X_BODY] = body;

    return response;
}

function failure(statusCode, message, code, requestId, xBceDate) {
    var response = {};

    response[H.X_STATUS_CODE] = statusCode;
    response[H.X_MESSAGE] = Buffer.isBuffer(message) ? String(message) : message;
    if (code) {
        response[H.X_CODE] = code;
    }
    if (requestId) {
        response[H.X_REQUEST_ID] = requestId;
    }
    if (xBceDate) {
        response[H.X_BCE_DATE] = xBceDate;
    }

    return response;
}

module.exports = HttpClient;

