/**************************************************************************************************
 *
 * ADOBE SYSTEMS INCORPORATED
 * Copyright 2015 Adobe Systems Incorporated
 * All Rights Reserved.
 *
 * NOTICE:  Adobe permits you to use, modify, and distribute this file in accordance with the
 * terms of the Adobe license agreement accompanying it.  If you have received this file from a
 * source other than Adobe, then your use, modification, or distribution of it requires the prior
 * written permission of Adobe.
 *
 **************************************************************************************************/

/*jslint vars: true, node: true, plusplus: true, unparam: true, nomen: true, regexp: true */
'use strict';

var utils = require('./utils');
var util = require('util');
var EventEmitter = require('events').EventEmitter;
var dns = require('dns');
var os = require('os');
var http = require('http');
var net = require('net');
var ntlm = require('ntlm');
var fetchNativeNegotiateToken;

//Globals
//Optimization is involved. Most users will have one type of authentication over all the connections.
//It is not practical to have different type of authenticating proxies all over network stack.
//By caching this here, we can save some time setting up the connection.;
var authScheme = {};
var spnCache = {};
var passwordCache = {};
/**
 * A socket connection used to connect to the proxy server.
 * Does all the setup/teardown for the request.
 * Creates a new object that can create a new socket for connecting to
 * a proxy server.
 */
function Connection(options) {
    this.options = options || {};
    this.options.proxy = this.options.proxy || {};
    EventEmitter.call(this);

    this.options.host = this.options.host || 'localhost';
    this.options.port = this.options.port || 80;

    /**
     * We determine the authentication algorithm based on the following order.
     * 1) We pass the user supplied option first.
     * 2) If that doesn't exist and we know of a global working solution, we use that.
     * 3) We fall back to the null option to ask the server.
     * 4) We reset the known global when the proxy settings change.
     */
    this.scheme = this.options.proxy.scheme;
    this.scheme = this.scheme || authScheme[this.options.proxy.host + this.options.proxy.port];
    this.scheme = this.scheme ? this.scheme.toLowerCase().trim() : undefined;
    this.connectionSettings = {
        method: 'CONNECT',
        path: this.options.host + ':' + this.options.port,
        agent: false,
        headers: this.options.proxy.headers || {},
        host: this.options.proxy.host || this.options.host,
        port: this.options.proxy.port || this.options.port
    };

    this.setupSocket = this.setupSocket.bind(this);
    this.handleProxyAuthenticate = this.handleProxyAuthenticate.bind(this);

    this.on('error', this.resetGlobals);
    this.on('success', this.updateGlobals);
    this.createSocket();
}

util.inherits(Connection, EventEmitter);

Connection.prototype.resetGlobals = function () {
    delete authScheme[this.options.proxy.host + this.options.proxy.port];
    delete spnCache[this.options.proxy.host + this.options.proxy.port];
    delete passwordCache[this.options.proxy.host + this.options.proxy.port];
};

Connection.prototype.updateGlobals = function () {
    authScheme[this.options.proxy.host + this.options.proxy.port] = this.scheme;
    spnCache[this.options.proxy.host + this.options.proxy.port] = this.spn;
    passwordCache[this.options.proxy.host + this.options.proxy.port] = this.auth;
};

/**
 * Creates a socket connection to the server. Can reuse a socket that's already open
 * The wierd name is because of NodeJS natively having this where it always creates
 * a new socket.
 */
Connection.prototype.createSocket = function () {
    if (this.scheme) {
        this.updateAuthorizationHeader(this.setupSocket);
    } else {
        this.setupSocket();
    }
};


/**
 * This is the core of proxy authentication.
 * Here we prepare the headers to communicate to the proxy servers.
 * This is where we add support for more auth parameters as well
 * as more types of auth that we need to support.
 */
Connection.prototype.updateAuthorizationHeader = function (callback) {
    switch (this.scheme) {
    case 'basic':
        this.tryNextAuthentication(function (err) {
            if (this.auth) {
                this.connectionSettings.headers['Proxy-Authorization'] = 'Basic ' + new Buffer(this.auth.username + ':' + this.auth.password).toString('base64');
            }
            callback(err);
        }.bind(this));
        break;
    case 'negotiate':
        this.setupNegotiateToken(callback);
        break;
    case 'ntlm':
        this.performNtlm(callback);
        break;
    default:
        var header = this.lastAuthHeader ? this.lastAuthHeader.toLowerCase() : null;
        if (header && header.indexOf('negotiate') !== -1) {
            utils.log(utils.logLevel.WARNING, "Unknown authentication scheme, " + this.scheme + " falling to negotiate as we can find that in the header.");
            //See if we can fall to negotiate.
            this.scheme = 'negotiate';
            this.setupNegotiateToken(callback);
        } else if (header && header.indexOf('ntlm') !== -1) {
            utils.log(utils.logLevel.WARNING, "Unknown authentication scheme, " + this.scheme + " falling to ntlm as we can find that in the header.");
            //See if we can fall to ntlm.
            this.scheme = 'ntlm';
            this.performNtlm(callback);
        } else {
            callback('Unsupported authentication scheme: ' + this.scheme);
        }
        break;
    }
};

/**
 * Utility method to help with NTLM authentication
 */
Connection.prototype.performNtlm = function (callback) {
    var self = this;
    this.scheme = 'ntlm';
    if (!this.ntlmType2Message) {
        self.tryNextAuthentication(function (err) {
            if (err) {
                callback(err);
                return;
            }
            var auth = self.auth || {};
            auth.username = auth.username || '';
            auth.password = auth.password || '';
            auth.domain = auth.domain || '';

            if (auth.username.indexOf('\\') !== -1 && auth.domain === '') {
                //Lets get the domain from the username.
                var split = auth.username.split('\\');
                auth.domain = split[0].toUpperCase();
                auth.username = split[1];
            }
            self.auth = auth;
            self.connectionSettings.agent = false;
            self.connectionSettings.headers['Proxy-Authorization'] = ntlm.challengeHeader(os.hostname(), self.auth.domain);
            self.connectionSettings.headers['Proxy-Connection'] = 'keep-alive';
            callback();
        });
    } else {
        self.connectionSettings.agent = null;
        //We have type2 message. Now we need the type 3 message.
        var serverNonce = new Buffer(self.ntlmType2Message.match(/^NTLM\s+(.+?)$/)[1], 'base64');
        var type3Message = 'NTLM ' + ntlm.encodeType3(self.auth.username, os.hostname(), self.auth.domain, ntlm.decodeType2(serverNonce), 'obs&gETQCXmx3uWvwE}j7cza7Cj*WovGmzojuJy').toString('base64');
        self.connectionSettings.headers['Proxy-Authorization'] = type3Message;
        self.connectionSettings.headers['Proxy-Connection'] = 'keep-alive';
        delete this.ntlmType2Message;
        callback();
    }
};

/**
 * Method to query through the authorization credentials and pick the best option.
 **/
Connection.prototype.tryNextAuthentication = function (callback) {
    var self = this;

    //1. The highest priority is something that is proven to be working. So we use the cache first.
    if (passwordCache[self.options.proxy.host + self.options.proxy.port] && !self.authMethod) {
        self.authMethod = 1;
        self.auth = passwordCache[self.options.proxy.host + self.options.proxy.port];
        callback();
        return;
    }

    // 2. We clear the proven cache if it fails. The system credentials prevents the need to prompt the user
    // and fail without expensive network requests. They may not be supported, e.g. in the basic auth & NTLM use case.
    if (!self.authMethod || self.authMethod < 2) {
        delete passwordCache[self.options.proxy.host + self.options.proxy.port];
        if (self.scheme === 'negotiate') { //Basic does not support system authorization.
            self.authMethod = 2;
            self.auth = 'system';
            callback();
            return;
        }
    }

    // 3. The direct crendential supply has been left mostly for testing purposes. This can be used if the app
    // maintains a disk cache and has already done its homework and can provide the credentials beforehand.
    if ((!self.authMethod || self.authMethod < 3) && self.options.proxy.username) {
        self.auth = self.options.proxy;
        self.authMethod = 3;
        callback();
        return;
    }

    //4. We fall back to prompting via a callback.
    if (self.options.proxy.authenticate && (!self.authMethod || self.authMethod < 4)) {
        self.options.proxy.authenticate({
            proxy: self.options.proxy,
            request: self.request
        }, function (err, authenticationData) {
            if (err) {
                callback(err);
                return;
            }
            self.authMethod = 4;
            self.auth = authenticationData;
            callback();
        });
        return;
    }

    //5. Fail if we run out of options.
    delete self.auth;
    callback('Proxy Authentication Failed');

};


Connection.prototype.setupSocket = function (err) {
    if (err) {
        if (this.request && this.request.socket && !this.request.socket.destroyed) {
            this.request.socket.destroy();
        }
        this.emit('error', err);
        return;
    }

    this.request = http.request(this.connectionSettings);
    this.request.once('connect', this.onConnect.bind(this));
    this.request.once('error', this.onError.bind(this));
    this.request.end();
};

Connection.prototype.onConnect = function (res, socket, head) {
    this.request.removeAllListeners();
    var statusCode = Math.floor(res.statusCode / 100);
    if (statusCode === 2 || statusCode === 3) {
        //Success. This doesn't mean we are done.
        //There may be incomplete validation.
        if (this.negotiationIncomplete) {
            if (!res.headers || !res.headers['proxy-authenticate']) {
                var errorMsg = 'Proxy authentication complete but the validation is not complete';
                utils.log(utils.logLevel.WARNING, errorMsg);
            } else {
                //TODO: Validate this here. We might still need to quit
                utils.log(utils.logLevel.INFO, 'Server provides validation messages. Validation not implemented');
            }
        }
        this.socket = socket;
        this.emit('success');
    } else if (!this.handleProxyAuthenticate(res, socket)) {
        this.emit('error', new Error('Got error trying to establish the tunnel socket.'));
    }
};

/**
 * Handles beign supplied a proxy authenticate header.
 * @return true if it is successfully able to handle such cases.
 **/
Connection.prototype.handleProxyAuthenticate = function (res, socket) {
    if (res && (res.statusCode === 401 || res.statusCode === 407)) {
        if (!this.scheme) {
            this.lastAuthHeader = res.headers['proxy-authenticate'];
            if (!this.lastAuthHeader) {
                utils.log(utils.logLevel.INFO, 'No Auth header supplied. We can assume no authentication');
                return false;
            }
            //Parsing the authenticate header is a huge and complicated task.
            //There is a huge set of options the server has under control.
            //The entire spec is described at https://tools.ietf.org/html/rfc1945#section-10.16
            //Basic and Digest methods are described at: https://tools.ietf.org/html/rfc2617#section-2
            //Apple's implementation of the header parser is present here: https://opensource.apple.com/source/CFNetwork/CFNetwork-129.9/HTTP/CFHTTPAuthentication.c
            //We are using a very simple method
            utils.log(utils.logLevel.INFO, 'The server uses proxy credentials. The associated header is: ' + this.lastAuthHeader);
            //TODO: This parsing is very basic. Adding support for multiple challenges
            //involves parsing this in a better way
            //IE 6.0 used to do this for multiple proxy authentication schemes.
            //We don't change the scheme for continuing requests.
            this.scheme = this.scheme || this.lastAuthHeader.trim().split(/\W/)[0].toLowerCase();
        } else if (this.scheme === 'ntlm') {
            //NTLM is two step auth.
            this.ntlmType2Message = res.headers['proxy-authenticate'];
            if (!this.ntlmType2Message.match(/^NTLM\s+(.+?)$/)) {
                //We did not get type 2 response so NTLM failed. Let us restart.
                utils.log(utils.logLevel.INFO, 'Expected NTLM Type2 Response. There might be a wrong domain or password supplied: ' + this.lastAuthHeader);
                delete this.ntlmType2Message;
                this.connectionSettings.agent = false;
            }

        }

        if (socket && socket.writable && socket.readable) {
            this.connectionSettings.createConnection = function () {
                return socket;
            };
        } else {
            // Node or the server might have closed the socket.
            // so we need to create one.
            delete this.connectionSettings.createConnection;
            if (!this.request.socket.destroyed) {
                //Safe with socket destroyed. We can't reuse errenous sockets.
                this.request.socket.destroy();
            }
        }
        this.createSocket();

        return true;
    }
    if (!this.request.socket.destroyed) {
        this.request.socket.destroy();
    }
    return false;

};

Connection.prototype.onError = function (cause) {
    this.request.removeAllListeners();
    //We are more forgiving here. A mis-configured proxy can do crazy things.
    //But most of the time, we just need the headers.
    //A lot of mis-configured proxies give correct response in success cases. A malformed response in case of an error that we can recover from shouldn't stop working.
    if (!this.handleProxyAuthenticate(this.request.res)) {
        this.emit('error', 'Tunneling socket could not be established:' +
            cause.message);
    } else {
        utils.log(utils.logLevel.INFO, 'Handling authenticating malformed proxy');
    }
};


////////////////////////////////// Begin SPNEGO //////////////////////////////////

/**
 * SPNEGO token generation function.
 * @param  {Function} callback Function to call after the token has been setup
 */
Connection.prototype.setupNegotiateToken = function (callback) {
    //Step 1: Get the SPN.
    this.createSPN(function (spn) {
        this.spn = spn;
        //Step 2: Get the token using supplied credentials
        this.fetchNativeNegotiateToken(this.spn, function (err, token, negotiationIncomplete) {
            if (err) {
                callback(err);
                return;
            }
            this.connectionSettings.headers['Proxy-Authorization'] = 'Negotiate ' + token;
            if (!this.negotiationPhase) {
                this.negotiationPhase = 0;
            }
            this.negotiationPhase++;
            this.negotiationIncomplete = negotiationIncomplete;
            callback();
        }.bind(this));
    }.bind(this));
};

Connection.prototype.fetchNativeNegotiateToken = function (spn, callback) {
    //Kerberos has a difference from other authentication protocols where the password is verified
    //by the KDC server instead of being done by the proxy server.
    this.tryNextAuthentication(function (err) {
        if (err) {
            callback(err);
            return;
        }

        var auth = (this.auth === 'system' ? {} : this.auth); //Blank options imply default stored credentials.
        auth.username = auth.username || '';
        auth.password = auth.password || '';
        auth.domain = auth.domain || '';

        if (auth.username.indexOf('\\') !== -1 && auth.domain === '') {
            //Lets get the domain from the username.
            var split = auth.username.split('\\');
            auth.domain = split[0].toUpperCase();
            auth.username = split[1];
        }

        fetchNativeNegotiateToken(spn, auth.username, auth.password, auth.domain, function (err, data, negotiationIncomplete) {
            if (!err) {
                callback(null, data, negotiationIncomplete);
            } else {
                //Call again to try the next set of credentials.
                //Exits to callback if none left.
                this.fetchNativeNegotiateToken(spn, callback);
            }
        }.bind(this));
    }.bind(this));
};

/////////////////////////// Begin SPN Generation Code  //////////////////////////////

//We need to generate the SPN ourselves.
//The Kerberos token is generated based on the algorithm used chromium.
//See https://www.chromium.org/developers/design-documents/http-authentication
//Reference Code: https://github.com/adobe/chromium/blob/master/net/http/http_auth_handler_negotiate.cc#L118

Connection.prototype.convertToSPN = function convertToSPN(domain, port) {
    var spn;
    if (os.platform() === 'win32') {
        spn = 'HTTP/' + domain.toUpperCase();
    } else {
        spn = 'HTTP@' + domain.toUpperCase();
    }
    if (this.options.proxy.EnableAuthNegotiatePort) {
        spn += ':' + port;
    }
    return spn;
};

Connection.prototype.useCname = function useCname(host, port, callback) {
    if (this.options.proxy.DisableAuthNegotiateCnameLookup) {
        callback(this.convertToSPN(host, port));
        return;
    }
    dns.resolveCname(host, function (err, data) {
        if (err) {
            utils.log(utils.logLevel.INFO, 'Cname resolution failed with error: ' + err);
            callback(this.convertToSPN(host, port));
        } else if (!Array.isArray(data) || data.length < 1) {
            callback(this.convertToSPN(host, port));
        } else {
            if (data.length > 1) {
                utils.log(utils.logLevel.INFO, 'Multiple CNAME received. Trying the first one for the SPN: ' + data);
            }
            callback(this.convertToSPN(data[0], port));
        }
    }.bind(this));
};


Connection.prototype.spnFromAddress = function spnFromAddress(address, port, callback) {
    var potential = address || this.options.proxy.host;
    port = port || this.options.proxy.port;
    if (net.isIP(potential)) {
        //We have an IP address here.
        //So we need to generate the host name from it.
        dns.reverse(potential, function (err, host) {
            if (err || !Array.isArray(host) || host.length < 1) {
                utils.log(utils.logLevel.WARNING, 'Reverse lookup for SPN name failed with error: ' + err);
                //Lets put the request host
                if (address) {
                    //It seems we have failed both cases.
                    utils.log(utils.logLevel.ERROR, 'Both hosts failed, trying IP address for SPN');
                    this.useCname(this.options.host, this.options.port, callback);
                } else {
                    return this.spnFromAddress(this.options.host, this.options.port, callback);
                }
            } else {
                if (host.length > 1) {
                    utils.log(utils.logLevel.INFO, 'Multiple hosts found for SPN. Trying the first one: ' + host);
                }
                this.useCname(host[0], port, callback);
            }

        }.bind(this));
    } else {
        //No need for reverse DNS. Lets do CNAME and be done with it.
        this.useCname(potential, port, callback);
    }
};

/**
 * Gets the SPN to use for the negotiate token generation
 * @param  {Function} callback Callback receives the SPN as a string
 * @return {void}
 */
Connection.prototype.createSPN = function (callback) {
    if (this.spn) {
        callback(this.spn);
    } else if (this.options.proxy.spn) {
        callback(this.options.proxy.spn);
    } else if (spnCache[this.options.proxy.host + this.options.proxy.port]) {
        callback(spnCache[this.options.proxy.host + this.options.proxy.port]);
    } else {
        this.spnFromAddress(null, null, callback);
    }
};

/////////////////////////// End SPN Generation Code  //////////////////////////////
/////////////////////////////////// End SPNEGO ////////////////////////////////////


module.exports.Connection = Connection;
module.exports.init = function (fetchNativeNegotiateTokenFn) {
    fetchNativeNegotiateToken = fetchNativeNegotiateTokenFn;
};
