/* jshint esnext: true */
// Copyright PCGroups (http://www.pconline.com.cn) 2014
//
// R-System of PCGroups for NodeJS, port from java
// Direct http access realIp by read configuration from network switch.
//
// author: chen_xiao_hu@qq.com

var dns     = require('dns');
var fs      = require('fs');
var http    = require('http');
var parse   = require('url').parse;
var debug   = require('debug')('r');
var SimpleCache = require('simple-lru-cache');
var localCache   = new SimpleCache({'maxSize':10000});

var DEBUG = process.env.DEBUG;

var HTTP_START_POS     = 7;//7表示http://
var ROUTE_FILE         = './pc_route/route.txt';
var ROUTE_FILE_MD5     = './pc_route/route.txt.md5';
var R_DNS_SERVER       = '192.168.238.75';

var defReqTo = 10000;
var defResTo = 20000;

var failErrorCount     = 6;
var failDisableSeconds = 300;

//var env = 'product';
var env = 'dev_direct';
//var env = 'dev_proxy';

var rPosMap    = {};
var rStatusMap = {};

var d2vMap = {};
var v2rMap = null;

var lastReloadAt = 0;
var routeUrl     = 'http://192.168.237.61/route.txt';
var httpProxy    = 'proxy.pconline.com.cn:8080';
var routeOverwrite;

var AGENT        = new http.Agent();
AGENT.maxSockets = 8000;

// Get host from url
// @Sync
// e.g. 'http://bbs.pcauto.com.cn'                  => 'bbs.pcauto.com.cn'
//      'http://bbs.pcauto.com.cn/topic-12345.html' => 'bbs.pcauto.com.cn'
//      'http://bbs.pcauto.com.cn:8080/topic-12345.html'
//                                                  => 'bbs.pcauto.com.cn:80'
function getHost(url) {
    if (DEBUG) debug('into getHost(' + url + '...');

    if (!url) return url;

    var pos = url.indexOf('/', HTTP_START_POS);
    if (pos > HTTP_START_POS) {
        return url.substring( HTTP_START_POS, pos);
    }
    return url.substring(HTTP_START_POS);
}

// Parse vip to rip string config
// @Sync
// multi-line string e.g.
// ---------------------------------------
// # comment
// 61.145.113.7:80,192.168.239.89:8080
// 61.145.113.7:80,192.168.239.15:8080
// 59.42.240.101:80,192.168.237.79:80
// 59.42.240.240:80,192.168.236.242:8080
// ...
// ---------------------------------------
// to object:
// {
//      "61.145.113.7:80": ["192.168.239.89:8080", "192.168.239.15:8080"],
//      "59.42.240.101:80": ["192.168.237.79:80", "192.168.236.242:8080"],
//      ...
// }
function parseV2r(str) {
    if (DEBUG) debug('into parseV2r(...');

    var lines = str.split('\n');
    var result = {};
    for (var i = 0; i < lines.length; i ++) {
        var line = lines[i];
        if (line.charAt(0) === '#') {
            continue;
        }
        var items = line.split(',');
        if (items.length != 2) {
            continue;
        }
        var vip  = items[0].trim();
        var rip  = items[1].trim();
        var rips = result[vip];
        if (rips) {
            rips.push(rip);
        } else {
            result[vip] = [rip];
            rPosMap[vip] = 0;
        }
    }
    return result;
}

// Load route file to memory into var `v2rMap`
// @Sync
function loadRouteFile() {
    if (DEBUG) debug('into loadRouteFile()');

    try {
        var bytes = fs.readFileSync(ROUTE_FILE);
        v2rMap = parseV2r(bytes.toString());
        console.log('use local file ' + ROUTE_FILE + ' ok.');
    } catch(e) {
        console.error(ROUTE_FILE + ' load fail with: ' + e.message);
    }
}


// Http get with request timeout and response timeout, core function
// @Async
//
// @param url       : url to request
// @param opts      : object with attribute `headers`, `reqTo` and `resTo`
//                    `headers` is Http Header to request
//                    `reqTo` is request time in milli seconds
//                    `resTo` is response time in milli seconds
// @param callback  : function(err, res) {...}
//                    res is response of standard http module, also add url to 
//                    res object and add body (Buffer of Http body) to it
//
function httpGet(url, opts, callback) {
    httpRequest('GET', url, opts, null, callback);
}

function httpPost(url, opts, body, callback) {
    httpRequest('POST', url, opts, body, callback);
}

// Http execute request
//
function httpRequest(method, url, opts, body, callback) {
    if (DEBUG) debug('into httpRequst([%s] %s ...', method, url);
    if (DEBUG) debug('opts: ' + JSON.stringify(opts));

    // prepare opts
    _opts   = opts || {};
    headers = _opts.headers || {};
    reqTo   = _opts.reqTo || defReqTo;
    resTo   = _opts.resTo || defResTo;

    // parse url
    var info = null;
    try {
        info = parse(url);
    } catch (urle) {
        console.trace(urle);
        callback(new Error('Url parse error: ' + url), res);
        return;
    }
    var path = info.pathname + (info.search || '');

    var overWrite = routeOverwrite[info.hostname];

    if (overWrite) {
        // TODO need change common_session_id to common_session_id1???
        if (DEBUG) debug('Need Overwrite: ' + info.hostname + ' to: ' + overWrite);
        info = parse('http://' + overWrite + path);
      //  path = url;
        headers.host = getHost(url);
        if (headers.cookie && headers.cookie.indexOf('common_session_id=') > -1) {
            headers.cookie = headers.cookie.replace(/common_session_id=/, 'common_session_id1=');
            if (DEBUG) debug(headers.cookie);
        }
    } else {
        // for http proxy
        if (env == 'dev_proxy') {
            info = parse('http://' + httpProxy + path);
            path = url;
            headers.host = getHost(url);
        }
    }

    // for nginx blow 1.3.9 not support chunked-encoding
    if ('POST' === method) {
        if (body) {
            headers['Content-Length'] = body.length;
        } else {
            headers['Content-Length'] = 0;
        }
    }

    var options = {
            hostname : info.hostname,
            port     : info.port || 80,
            path     : path,
            method   : method,
            agent    : AGENT,
            headers  : headers };

    var req            = null;
    var requestTimeout = null;

    // init request timout object
    requestTimeout = setTimeout(function() {
        requestTimeout = null; 
        req.abort();
        callback(new Error('Request timeout for url: ' + url));
    }, reqTo);

    var startAt = process.hrtime();
    req = http.request(options, function(res) {
        if (requestTimeout) {
            clearTimeout(requestTimeout);
            requestTimeout = null;
        }

        var chunks          = [];
        var length          = 0;
        var responseTimeout = null;

        // init response timeout object
        responseTimeout = setTimeout(function() {
            responseTimeout = null;
            req.abort();//req.abort表中止
            var tu = process.hrtime(startAt);
            debug('http %s response timeout error in %d ms with url: %s\n', method, (tu[0]*1e9 + tu[1])/1e6,  url);
            callback(new Error('Response timeout for url: ' + url));
        }, resTo);//resTo表响应时间

        // use http/1.0 to simplify connection management   !!!not support as v0.11.x:(
        //req._implicitHeader = function() {
        //    this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.0\r\n',
        //            this._renderHeaders());
        //};

        res.on('data', function(chunk) {
            length += chunk.length;
            chunks.push(chunk);
        }).on('end', function() {
            if(responseTimeout) {
                clearTimeout(responseTimeout);
                responseTimeout = null;

                var data  = new Buffer(length);
                var pos   = 0;
                var l     = chunks.length;
                for(var i = 0; i < l; i++) {
                    chunks[i].copy(data, pos);
                    pos += chunks[i].length;
                }

                // set url to response for debug reason
                res.url = url;
                res.body = data;
                var tu = process.hrtime(startAt);
                if (DEBUG) debug('http %s ok in %d ms with url: %s\n', method, (tu[0]*1e9 + tu[1])/1e6,  url);
                callback(null, res);
            }
        }).on('error', function(err) {
            var tu = process.hrtime(startAt);
            if (DEBUG) debug('http %s response error in %d ms with url: %s\n', method, (tu[0]*1e9 + tu[1])/1e6,  url);
            callback(err, res);
        }).on('aborted', function() {
            if(responseTimeout) {
                var tu = process.hrtime(startAt);
                if (DEBUG) debug('http %s response timeout in %d ms with url: %s\n', method, (tu[0]*1e9 + tu[1])/1e6,  url);
                callback(new Error('Response aborted'), res);
            }
        });

    }).on('error', function(err) {
        var tu;
        if(requestTimeout) {
            clearTimeout(requestTimeout);
            requestTimeout = null;
            tu = process.hrtime(startAt);
            if (DEBUG) debug('http %s request error in %d ms with url: %s, error: %s\n', method, (tu[0]*1e9 + tu[1])/1e6,  url, err.message);
            callback(err);
        } else {
            tu = process.hrtime(startAt);
            if (DEBUG) debug('http %s request timeout error in %d ms with url: %s\n', method, (tu[0]*1e9 + tu[1])/1e6,  url);
        }
    });

    if (method == 'POST' && body) {
        req.write(body);
    }
    req.end();
}

// Get url, core exported method of this module
// @Async
//
// @param url       : url to request
// @param opts      : object with attribute `headers`, `reqTo` and `resTo`
//                    `headers` is Http Header to request
//                    `reqTo` is request time in milli seconds
//                    `resTo` is response time in milli seconds
// @param callback  : function(err, res) {...}
//                    res is response of standard http module, also add url to 
//                    res object and add body (Buffer of Http body) to it
//
function get(url, opts, callback) {
    if (DEBUG) debug('into get(' + url + ' ...');

    if (env != 'product') {
        httpGet(url, opts, callback);
        return;
    }

    // get url with route, use 7 level error retry like nginx upstream
    //
    // @Async
    //
    // @err         : error object
    // @opts        : follow input parameter `opts`
    // @rUrls       : array of realUrl for input parameter `url`
    // @step        : retry times
    // @callback    : follow input parameter `callback`
    //
    var getRouted = function(err, opts, rUrls, body, step, callback) {
        if (DEBUG) debug('into getRouted(... step: ' + step);
        
        var _opts = opts || {};
        if (!_opts.headers) {
            _opts.headers = {};
        }
        // make header 'Host' and 'User-Agent'
        _opts.headers.Host = getHost(url);
        _opts.headers['User-Agent'] = 'R-1.0-NodeJS';
        
        // http get to realIp by realUrl
        httpGet(rUrls[step], _opts, function(err, res) {
            if (err) {
                reportRipError(getHost(rUrls[step]));
                if (step > 0 || rUrls.length === 1) {
                    // the last realIp error
                    callback(err);
                } else {
                    // try next realIp
                    getRouted(null, opts, rUrls, null, 1, callback);
                    if (DEBUG) debug('getRouted step ' + step + ', ' + err);
                }
            } else {
                reportRipOk(getHost(res.url));
                var sc = res.statusCode;
                if (sc === 200) {
                    // sucess
                    callback(null, res);
                } else if (sc === 301 || sc === 302 || sc ===303 || sc===307) {
                    // redirect
                    var loc = res.headers.location;
                    var redirect = _opts._redirect_ || 0;
                    if (loc && redirect < 3) { // max redirect 3
                        opts._redirect_ = redirect + 1;
                        get(loc, opts, callback);
                        return;
                    }
                    console.error('redirect times > ' + redirect);
                    callback(new Error('redirect times > 3'));
                } else {
                    if (step > 0 || rUrls.length === 1 || sc < 500) {
                        // the last server or client error (sc < 500)
                        callback(null, res);
                    } else {
                        // retry next realIp
                        getRouted(null, opts, rUrls, null, 1, callback);
                    }
                }
            }
        });
    };

    // route and get the url
    route(url, opts, null, callback, getRouted);

}


function post(url, opts, body, callback) {
    if (DEBUG) debug('into post(' + url + ' ...');

    if (env != 'product') {
        httpPost(url, opts, body, callback);
        return;
    }

    // post url with route
    //
    // @Async
    //
    // @err         : error object
    // @opts        : follow input parameter `opts`
    // @body        : body for post
    // @rUrls       : array of realUrl for input parameter `url`
    // @callback    : follow input parameter `callback`
    //
    var postRouted = function(err, opts, rUrls, body, step, callback) {
        if (DEBUG) debug('into postRouted(...');
        
        var _opts = opts || {};
        if (!_opts.headers) {
            _opts.headers = {};
        }
        // make header 'Host' and 'User-Agent'
        _opts.headers.Host = getHost(url);
        _opts.headers['User-Agent'] = 'R-1.0-NodeJS';
        
        // http post to realIp by realUrl
        httpPost(rUrls[0], _opts, body, function(err, res) {
            if (err) {
                reportRipError(getHost(rUrls[0]));
                callback(err);
            } else {
                reportRipOk(getHost(res.url));
                callback(null, res);
            }
        });
    };

    // route and get the url
    route(url, opts, body, callback, postRouted);

}

// Thunfified function for lib 'co'
//
function coPost(url, opts, body) {
    var _opts = opts || {};
    return function(callback) {
        post(url, _opts, body, callback);
    };
}

// Thunfified function for lib 'co'
//
function coGet(url, opts) {
    var _opts = opts || {};
    var handler = _opts.handler;
    if (handler) {
        delete _opts.handler;
        return coGet2(url, _opts, handler);
    } else {
        return function(callback) {
            get(url, _opts, callback);
        };
    }
}


function loadFromCache(url) {
    return localCache.get(url);
}

function cacheStale(entry) {
    if (entry && entry.date && entry.maxAge) {
        return (new Date()).getTime() - entry.date > entry.maxAge * 1000;
    }
    return true;
}

function writeToCache(url, data, maxAge) {
    var entry = {
        'date': (new Date()).getTime(),
        'maxAge': maxAge,
        'data': data
    };
    localCache.set(url, entry);
}

// Thunfified function for lib 'co' with cache wraped
//
function coGet2(url, opts, handler) {
    var entry = loadFromCache(url);
    if (entry) {
        if (cacheStale(entry)) {
            get(url, opts, function(err, result){
                // TODO, use old or new when error?
                if (err) {
                    return;
                }
                entry = handler(err, result);
                if (entry.maxAge) {
                    writeToCache(url, entry.data, entry.maxAge);
                }
            });
        }
        return function(callback) {
            callback(null, entry.data);
        };
    }

    return function(callback) {
        get(url, opts, function(err, result){
            try {
                var entry = handler(err, result);
                if (entry.maxAge) {
                    writeToCache(url, entry.data, entry.maxAge);
                }
                callback(null, entry.data);
            } catch (ex) {
                callback(ex);
            }
        });
    };
}

// load route from local file and network
// use md5 check to reduce network access
// @Async but use old local file @Sync
//
function loadRoute() {
    lastReloadAt = (new Date()).getTime();

    // Test route only
    //v2rMap = parseV2r('61.145.113.115:80,127.0.0.1:8972\n'+
    //          '61.145.113.115:80,127.0.0.1:8973');
    //return;

    if (! v2rMap) {
        console.log('init v2rMap from file.');
        loadRouteFile();
    }

    httpGet(routeUrl + '.md5', null, function(err, res) {

        if (err) {
            console.error('Http get: ' + routeUrl + '.md5 failed with: ' + 
                    err.message);
        } else {
            var md5 = res.body.toString();
            var oldmd5 = null;

            try {
                oldmd5 = fs.readFileSync(ROUTE_FILE_MD5);
                oldmd5 = oldmd5.toString();
            } catch (e) {
                console.error('File read ' + ROUTE_FILE_MD5 + ' failed with:' + 
                        e.message);
            }

            if (md5 === oldmd5) {
                // console.log('md5 not changed, do not need load');
            } else {
                httpGet(routeUrl, null, function(err, res) {
                    if (err) {
                        log.error(err);    
                    } else {
                        try {
                            fs.writeFileSync(ROUTE_FILE_MD5, md5);
                            fs.writeFileSync(ROUTE_FILE, res.body);
                            v2rMap = parseV2r(res.body.toString());
                        } catch(e) {
                            console.error(e);
                        }
                    }
                });
            }
        }
    });
}

// get vip for host
// @Async
//
// @param host      : host of url
// @param callback  : fn(err, string)
function getVip(host, callback) {
    if (DEBUG) debug('into getVip(' + host + '...');

    var domain = host;
    var port   = ':80';
    var pos    = host.indexOf(':');

    if (pos > -1) {
        domain = host.substring(0, pos);
        port   = host.substring(pos);
    }

    var ch = domain.charAt(domain.length - 1);
    if (ch >= '0' && ch <= '9') {
        if (DEBUG) debug('domain is ip addr, use direct');
        callback(null, domain + port, true);
        return;
    }

    var vip = d2vMap[domain];
    var now = (new Date()).getTime();

    // vip exists, then expiry check, ttl 10 mintues
    if (vip && now - vip[1] < 600000) {
        callback(null, vip[0]);
        return;
    }

    dns.resolve4(domain, function (err, addresses) {
        if (err) {
            // use old vip when error and old vip if exists
            // for sometimes dns server problem.
            if (vip) {
                callback(null, vip[0]);
                console.info('resolve dns failed for domain: ' +
                    domain + ', error: ' + err.message +
                    ', use old stale vip: ' + vip[0]);
                return;
            }
            callback(err);
            return;
        }
        var vip2 = addresses[0] + port;
        d2vMap[domain] = [vip2, now];
        callback(null, vip2); 
    });

}

// check if realIp is enable, consecutive failures cause disable
// @Sync
function isRipEnable(rip) {
    var rStatus = rStatusMap[rip];
    if (rStatus) {
        if (rStatus[0] < failErrorCount) {
            return true;
        }
        if ((new Date()).getTime() - rStatus[1] > failDisableSeconds * 1000) {
            return true;
        }
        return false; 
    } else {
        return true;
    }
}

// report realIp Ok, and clear the failures status
// @Sync
function reportRipOk(rip) {
    if (DEBUG) debug('into reportRipOk(' + rip + ')');

    delete rStatusMap[rip];
}

// report realIp error, add the failures status
// @Sync
function reportRipError(rip) {
    if (DEBUG) debug('into reportRipError(' + rip + ')');

    var rStatus = rStatusMap[rip] || [0, 0];
    rStatus[0] ++;
    rStatus[1] = (new Date()).getTime();
    rStatusMap[rip] = rStatus;
}

// route and get url
// @Async
// 
// @param url       : url to request
// @param opts      : object with attribute `headers`, `reqTo` and `resTo`
//                    `headers` is Http Header to request
//                    `reqTo` is request time in milli seconds
//                    `resTo` is response time in milli seconds
// @param body      : body param for method POST
// @param cb        : fn(err, response) to handle result
// @param callback  : fn(err, opts, rUrls, step, callback), see: `getRouted`
//
function route(url, opts, body, cb, callback) {
    if (DEBUG) debug('into route(' + url + '...');

    var host = getHost(url);
    getVip(host, function(err, vip, ipDirect) {
        if (err) {
            cb(err);
            return;
        }

        if ((new Date()).getTime() - lastReloadAt > 600000) {
            loadRoute();
        }

        var rips = v2rMap[vip];
        if (!rips) {
            if (ipDirect) {
                rips = [vip];
                v2rMap[vip] = rips;
                rPosMap[vip] = 0;
            } else {
                cb(new Error('Can not find any rip for url: ' + url));
                return;
            }
        }

        var rUrls = [];
        var rPos  = rPosMap[vip];
        var size  = rips.length;
        var pos   = url.indexOf('/', HTTP_START_POS);

        if (DEBUG) debug('rPos: ' + rPosMap[vip]);
        if (size > 1) {
            // size > 1 need round robin
            rPosMap[vip] = ++rPos;
            if (rPos > 0xFFFFFFFF) { 
                // process int overflow
                rPosMap[vip] = rPos = 0;
            }
        }

        for (var i = 0, j = 0; i < size && j < 2; i ++) {
            var rip = rips[(rPos + i) % size];
            if (isRipEnable(rip)) {
                if (DEBUG) debug('Rip: [' + rip + '] used at pos: ' + j);
                rUrls[j] = 'http://' + rip + url.substring(pos);
                j ++;
            } else {
                if (DEBUG) debug('Rip disabled: [' + rip + '] for url: ' + url);
            }
        }

        if (rUrls.length === 0) {
            cb(new Error('All rips(' + size + 
                            ') are disabled for url: ' + url));
            return;
        }

        callback(null, opts, rUrls, body, 0, cb);
    });

}


// transfer header for filtered names
//
function transHeaders(names, headers) {
    var result = {};
    for (var i = 0; i < names.length; i ++) {
        var name = names[i].toLowerCase();
        var value = headers[name];
        if (value) {
            result[name] = value;
        }
    }
    return result;
}

// MUST set env before call init()
//
function setEnv(e) {
    env = e;
    if (env === 'product') {
        dns.setServers([R_DNS_SERVER]);
    }
    if (DEBUG) debug('setEnv(' + env);
}

function setDefaultRequestTimeout(to) {
    defReqTo = to;
    if (DEBUG) debug('setDefaultRequestTimeout(' + to);
}

function setDefaultResponseTimeout(to) {
    defResTo = to;
    if (DEBUG) debug('setDefaultResponseTimeout('+ to);
}

function setFailErrorCount(c) {
    failErrorCount = c;
    if (DEBUG) debug('setFailErrorCount(' + c);
}

function setFailDisableSeconds(s) {
    failDisableSeconds = s;
    if (DEBUG) debug('setFailDisableSeconds(' + s);
}

function setRouteOverwrite(ro) {
    routeOverwrite = ro;
    if (DEBUG) debug('setRouteOverwrite(...');
}

// init R system before use it
// @Sync
//
function init() {

    // only product env need init :)
    if (env === 'product') {

        loadRouteFile();

        // only need load file once just after install :)
        if (!v2rMap) {
            var co = require('co');
            co(function *() {
                var f = function(url, opts) {
                    return function(callback) {
                        httpGet(url, opts, callback);
                    };
                };
                var x = yield f(routeUrl + '.md5', {});
                var y = yield f(routeUrl, {});
                try {
                    fs.writeFileSync(ROUTE_FILE_MD5, x.body);
                    fs.writeFileSync(ROUTE_FILE, y.body);
                    v2rMap = parseV2r(y.body.toString());
                } catch(e) {
                    console.error(e);
                }
            })();
        }
    }
}

module.exports.transHeaders = transHeaders;

module.exports.setEnv = setEnv;
module.exports.setRouteOverwrite = setRouteOverwrite;
module.exports.setDefaultRequestTimeout = setDefaultRequestTimeout;
module.exports.setDefaultResponseTimeout = setDefaultResponseTimeout;
module.exports.setFailErrorCount = setFailErrorCount;
module.exports.setFailDisableSeconds = setFailDisableSeconds;

module.exports.init   = init;

module.exports.get  = get;
module.exports.post = post;
module.exports.coGet  = coGet;
module.exports.coPost = coPost;
module.exports.writeToCache=writeToCache;
module.exports.loadFromCache=loadFromCache;
