# Source: src/main/js/common/HttpClient.js

    const GenUtils = require("../common/GenUtils");
    const LibraryUtils = require("./LibraryUtils");
    const MoneroUtils = require("./MoneroUtils");
    const ThreadPool = require("./ThreadPool");
    const PromiseThrottle = require("promise-throttle");
    const Request = require("request-promise");

    /**
     * Handle HTTP requests with a uniform interface.
     *
     * @hideconstructor
     */
    class HttpClient {

      /**
       * <p>Make a HTTP request.<p>
       *
       * @param {object} request - configures the request to make
       * @param {string} request.method - HTTP method ("GET", "PUT", "POST", "DELETE", etc)
       * @param {string} request.uri - uri to request
       * @param {string|object|Uint8Array} request.body - request body
       * @param {string} request.username - username to authenticate the request (optional)
       * @param {string} request.password - password to authenticate the request (optional)
       * @param {object} request.headers - headers to add to the request (optional)
       * @param {string} request.requestApi - one of "fetch" or "xhr" (default "fetch")
       * @param {boolean} request.resolveWithFullResponse - return full response if true, else body only (default false)
       * @param {boolean} request.rejectUnauthorized - whether or not to reject self-signed certificates (default true)
       * @param {number} request.timeout - maximum time allowed in milliseconds
       * @param {number} request.proxyToWorker - proxy request to worker thread
       * @returns {object} response - the response object
       * @returns {string|object|Uint8Array} response.body - the response body
       * @returns {number} response.statusCode - the response code
       * @returns {String} response.statusText - the response message
       * @returns {object} response.headers - the response headers
       */
      static async request(request) {

        // proxy to worker if configured
        if (request.proxyToWorker) {
          try {
            return await LibraryUtils.invokeWorker(GenUtils.getUUID(), "httpRequest", request);
          } catch (err) {
            if (err.message.length > 0 && err.message.charAt(0) === "{") {
              let parsed = JSON.parse(err.message);
              err.message = parsed.statusMessage;
              err.statusCode = parsed.statusCode;
              throw err;
            }
          }
        }

        // assign defaults
        request = Object.assign(HttpClient._DEFAULT_REQUEST, request);

        // validate request
        try { request.host = new URL(request.uri).host; } // hostname:port
        catch (err) { throw new Error("Invalid request URL: " + request.uri); }
        if (request.body && !(typeof request.body === "string" || typeof request.body === "object")) {
          throw new Error("Request body type is not string or object");
        }

        // initialize one task queue per host
        if (!HttpClient._TASK_QUEUES[request.host]) HttpClient._TASK_QUEUES[request.host] = new ThreadPool(1);

        // initialize one promise throttle per host
        if (!HttpClient._PROMISE_THROTTLES[request.host]) {
          HttpClient._PROMISE_THROTTLES[request.host] = new PromiseThrottle({
            requestsPerSecond: MoneroUtils.MAX_REQUESTS_PER_SECOND, // TODO: HttpClient should not depend on MoneroUtils for configuration
            promiseImplementation: Promise
          });
        }

        // request using fetch or xhr with timeout
        let timeout = request.timeout === undefined ? HttpClient._DEFAULT_TIMEOUT : request.timeout === 0 ? HttpClient.MAX_TIMEOUT : request.timeout;
        let requestPromise = request.requestApi === "fetch" ? HttpClient._requestFetch(request) : HttpClient._requestXhr(request);
        let timeoutPromise = new Promise((resolve, reject) => {
          let id = setTimeout(() => {
            clearTimeout(id);
            reject('Request timed out in '+ timeout + ' milliseconds')
          }, timeout);
        });
        return Promise.race([requestPromise, timeoutPromise]);
      }

      // ----------------------------- PRIVATE HELPERS ----------------------------

      static async _requestFetch(req) {

        // build request options
        let opts = {
          method: req.method,
          uri: req.uri,
          body: req.body,
          agent: req.uri.startsWith("https") ? HttpClient._getHttpsAgent() : HttpClient._getHttpAgent(),
          rejectUnauthorized: req.rejectUnauthorized,
          resolveWithFullResponse: req.resolveWithFullResponse,
          requestCert: true // TODO: part of config?
        };
        if (req.username) {
          opts.forever = true;
          opts.auth = {
            user: req.username,
            pass: req.password,
            sendImmediately: false
          }
        }
        if (req.body instanceof Uint8Array) opts.encoding = null;

        // queue and throttle request to execute in serial and rate limited
        let host = req.host;
        let resp = await HttpClient._TASK_QUEUES[host].submit(async function() {
          return HttpClient._PROMISE_THROTTLES[host].add(function(opts) { return Request(opts); }.bind(this, opts));
        });

        // normalize response
        let normalizedResponse = {};
        if (req.resolveWithFullResponse) {
          normalizedResponse.statusCode = resp.statusCode;
          normalizedResponse.statusText = resp.statusMessage;
          normalizedResponse.headers = resp.headers;
          normalizedResponse.body = resp.body;
        } else {
          normalizedResponse.body = resp;
        }
        return normalizedResponse;
      }

      static async _requestXhr(req) {
        if (req.headers) throw new Error("Custom headers not implemented in XHR request");  // TODO

        // collect params from request which change on await
        let method = req.method;
        let uri = req.uri;
        let host = req.host;
        let username = req.username;
        let password = req.password;
        let body = req.body;
        let isBinary = body instanceof Uint8Array;

        // queue and throttle requests to execute in serial and rate limited per host
        let resp = await HttpClient._TASK_QUEUES[host].submit(async function() {
          return HttpClient._PROMISE_THROTTLES[host].add(function() {
            return new Promise(function(resolve, reject) {
              let digestAuthRequest = new HttpClient.digestAuthRequest(method, uri, username, password);
              digestAuthRequest.request(function(resp) {
                resolve(resp);
              }, function(resp) {
                if (resp.status) resolve(resp);
                else reject(new Error("Request failed without response: " + method + " " + uri));
              }, body);
            });
          }.bind(this));
        });

        // normalize response
        let normalizedResponse = {};
        normalizedResponse.statusCode = resp.status;
        normalizedResponse.statusText = resp.statusText;
        normalizedResponse.headers = HttpClient._parseXhrResponseHeaders(resp.getAllResponseHeaders());
        normalizedResponse.body = isBinary ? new Uint8Array(resp.response) : resp.response;
        if (normalizedResponse.body instanceof ArrayBuffer) normalizedResponse.body = new Uint8Array(normalizedResponse.body);  // handle empty binary request
        return normalizedResponse;
      }

      /**
       * Get a singleton instance of an HTTP client to share.
       *
       * @return {http.Agent} a shared agent for network requests among library instances
       */
      static _getHttpAgent() {
        if (!HttpClient.HTTP_AGENT) {
          let http = require('http');
          HttpClient.HTTP_AGENT = new http.Agent({keepAlive: true});
        }
        return HttpClient.HTTP_AGENT;
      }

      /**
       * Get a singleton instance of an HTTPS client to share.
       *
       * @return {https.Agent} a shared agent for network requests among library instances
       */
      static _getHttpsAgent() {
        if (!HttpClient.HTTPS_AGENT) {
          let https = require('https');
          HttpClient.HTTPS_AGENT = new https.Agent({keepAlive: true});
        }
        return HttpClient.HTTPS_AGENT;
      }

      static _parseXhrResponseHeaders(headersStr) {
        let headerMap = {};
        let headers = headersStr.trim().split(/[\r\n]+/);
        for (let header of headers) {
          let headerVals = header.split(": ");
          headerMap[headerVals[0]] = headerVals[1];
        }
        return headerMap;
      }
    }

    /**
     * Modification of digest auth request by @inorganik.
     *
     * Dependent on CryptoJS MD5 hashing: http://crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/md5.js
     *
     * MIT licensed.
     */
    HttpClient.digestAuthRequest = function(method, url, username, password) {
      var self = this;

      if (typeof CryptoJS === 'undefined' && typeof require === 'function') {
        var CryptoJS = require('crypto-js');
      }

      this.scheme = null; // we just echo the scheme, to allow for 'Digest', 'X-Digest', 'JDigest' etc
      this.nonce = null; // server issued nonce
      this.realm = null; // server issued realm
      this.qop = null; // "quality of protection" - '' or 'auth' or 'auth-int'
      this.response = null; // hashed response to server challenge
      this.opaque = null; // hashed response to server challenge
      this.nc = 1; // nonce count - increments with each request used with the same nonce
      this.cnonce = null; // client nonce

      // settings
      this.timeout = 60000; // timeout
      this.loggingOn = false; // toggle console logging

      // determine if a post, so that request will send data
      this.post = false;
      if (method.toLowerCase() === 'post' || method.toLowerCase() === 'put') {
        this.post = true;
      }

      // start here
      // successFn - will be passed JSON data
      // errorFn - will be passed the failed authenticatedRequest
      // data - optional, for POSTS
      this.request = function(successFn, errorFn, data) {

        // stringify json
        if (data) {
          try {
            self.data = data instanceof Uint8Array || typeof data === "string" ? data : JSON.stringify(data);
          } catch (err) {
            console.error(err);
            throw err;
          }
        }
        self.successFn = successFn;
        self.errorFn = errorFn;

        if (!self.nonce) {
          self.makeUnauthenticatedRequest(self.data);
        } else {
          self.makeAuthenticatedRequest();
        }
      }
      this.makeUnauthenticatedRequest = function(data) {
        self.firstRequest = new XMLHttpRequest();
        self.firstRequest.open(method, url, true);
        self.firstRequest.timeout = self.timeout;
        // if we are posting, add appropriate headers
        if (self.post && data) {
          if (typeof data === "string") {
            self.firstRequest.setRequestHeader('Content-type', 'text/plain');
          } else {
            self.firstRequest.responseType = "arraybuffer";
          }
        }

        self.firstRequest.onreadystatechange = function() {

          // 2: received headers,  3: loading, 4: done
          if (self.firstRequest.readyState === 2) {

            var responseHeaders = self.firstRequest.getAllResponseHeaders();
            responseHeaders = responseHeaders.split('\n');
            // get authenticate header
            var digestHeaders;
            for(var i = 0; i < responseHeaders.length; i++) {
              if (responseHeaders[i].match(/www-authenticate/i) != null) {
                digestHeaders = responseHeaders[i];
              }
            }

            if (digestHeaders != null) {
              // parse auth header and get digest auth keys
              digestHeaders = digestHeaders.slice(digestHeaders.indexOf(':') + 1, -1);
              digestHeaders = digestHeaders.split(',');
              self.scheme = digestHeaders[0].split(/\s/)[1];
              for (var i = 0; i < digestHeaders.length; i++) {
                var equalIndex = digestHeaders[i].indexOf('='),
                  key = digestHeaders[i].substring(0, equalIndex),
                  val = digestHeaders[i].substring(equalIndex + 1);
                val = val.replace(/['"]+/g, '');
                // find realm
                if (key.match(/realm/i) != null) {
                  self.realm = val;
                }
                // find nonce
                if (key.match(/nonce/i) != null) {
                  self.nonce = val;
                }
                // find opaque
                if (key.match(/opaque/i) != null) {
                  self.opaque = val;
                }
                // find QOP
                if (key.match(/qop/i) != null) {
                  self.qop = val;
                }
              }
              // client generated keys
              self.cnonce = self.generateCnonce();
              self.nc++;
              // if logging, show headers received:
              self.log('received headers:');
              self.log('  realm: '+self.realm);
              self.log('  nonce: '+self.nonce);
              self.log('  opaque: '+self.opaque);
              self.log('  qop: '+self.qop);
              // now we can make an authenticated request
              self.makeAuthenticatedRequest();
            }
          }
          if (self.firstRequest.readyState === 4) {
            if (self.firstRequest.status === 200) {
              self.log('Authentication not required for '+url);
              if (data instanceof Uint8Array) {
                self.successFn(self.firstRequest);
              } else {
                if (self.firstRequest.responseText !== 'undefined') {
                  if (self.firstRequest.responseText.length > 0) {
                    // If JSON, parse and return object
                    if (self.isJson(self.firstRequest.responseText)) {  // TODO: redundant
                      self.successFn(self.firstRequest);
                    } else {
                      self.successFn(self.firstRequest);
                    }
                  }
                } else {
                  self.successFn();
                }
              }
            }
          }
        }
        // send
        if (self.post) {
          // in case digest auth not required
          self.firstRequest.send(self.data);
        } else {
          self.firstRequest.send();
        }
        self.log('Unauthenticated request to '+url);

        // handle error
        self.firstRequest.onerror = function() {
          if (self.firstRequest.status !== 401) {
            self.log('Error ('+self.firstRequest.status+') on unauthenticated request to '+url);
            self.errorFn(self.firstRequest);
          }
        }
      }
      this.makeAuthenticatedRequest= function() {

        self.response = self.formulateResponse();
        self.authenticatedRequest = new XMLHttpRequest();
        self.authenticatedRequest.open(method, url, true);
        self.authenticatedRequest.timeout = self.timeout;
        var digestAuthHeader = self.scheme+' '+
          'username="'+username+'", '+
          'realm="'+self.realm+'", '+
          'nonce="'+self.nonce+'", '+
          'uri="'+url+'", '+
          'response="'+self.response+'", '+
          'opaque="'+self.opaque+'", '+
          'qop='+self.qop+', '+
          'nc='+('00000000' + self.nc).slice(-8)+', '+
          'cnonce="'+self.cnonce+'"';
        self.authenticatedRequest.setRequestHeader('Authorization', digestAuthHeader);
        self.log('digest auth header response to be sent:');
        self.log(digestAuthHeader);
        // if we are posting, add appropriate headers
        if (self.post && self.data) {
          if (typeof self.data === "string") {
            self.authenticatedRequest.setRequestHeader('Content-type', 'text/plain');
          } else {
            self.authenticatedRequest.responseType = "arraybuffer";
          }
        }
        self.authenticatedRequest.onload = function() {
          // success
          if (self.authenticatedRequest.status >= 200 && self.authenticatedRequest.status < 400) {
            // increment nonce count
            self.nc++;
            // return data
            if (self.data instanceof Uint8Array) {
              self.successFn(self.authenticatedRequest);
            } else {
              if (self.authenticatedRequest.responseText !== 'undefined' && self.authenticatedRequest.responseText.length > 0 ) {
                // If JSON, parse and return object
                if (self.isJson(self.authenticatedRequest.responseText)) {  // TODO: redundant from not parsing
                  self.successFn(self.authenticatedRequest);
                } else {
                  self.successFn(self.authenticatedRequest);
                }
              } else {
               self.successFn();
              }
            }
          }
          // failure
          else {
            self.nonce = null;
            self.errorFn(self.authenticatedRequest);
          }
        }
        // handle errors
        self.authenticatedRequest.onerror = function() {
          self.log('Error ('+self.authenticatedRequest.status+') on authenticated request to '+url);
          self.nonce = null;
          self.errorFn(self.authenticatedRequest);
        };
        // send
        if (self.post) {
          self.authenticatedRequest.send(self.data);
        } else {
          self.authenticatedRequest.send();
        }
        self.log('Authenticated request to '+url);
      }
      // hash response based on server challenge
      this.formulateResponse = function() {
        var HA1 = CryptoJS.MD5(username+':'+self.realm+':'+password).toString();
        var HA2 = CryptoJS.MD5(method+':'+url).toString();
        var response = CryptoJS.MD5(HA1+':'+
          self.nonce+':'+
          ('00000000' + self.nc).slice(-8)+':'+
          self.cnonce+':'+
          self.qop+':'+
          HA2).toString();
        return response;
      }
      // generate 16 char client nonce
      this.generateCnonce = function() {
        var characters = 'abcdef0123456789';
        var token = '';
        for (var i = 0; i < 16; i++) {
          var randNum = Math.round(Math.random() * characters.length);
          token += characters.substr(randNum, 1);
        }
        return token;
      }
      this.abort = function() {
        self.log('[digestAuthRequest] Aborted request to '+url);
        if (self.firstRequest != null) {
          if (self.firstRequest.readyState != 4) self.firstRequest.abort();
        }
        if (self.authenticatedRequest != null) {
          if (self.authenticatedRequest.readyState != 4) self.authenticatedRequest.abort();
        }
      }
      this.isJson = function(str) {
        try {
          JSON.parse(str);
        } catch (err) {
          return false;
        }
        return true;
      }
      this.log = function(str) {
        if (self.loggingOn) {
          console.log('[digestAuthRequest] '+str);
        }
      }
      this.version = function() { return '0.8.0' }
    }

    // default request config
    HttpClient._DEFAULT_REQUEST = {
      method: "GET",
      requestApi: "fetch",
      resolveWithFullResponse: false,
      rejectUnauthorized: true
    }

    // rate limit requests per host
    HttpClient._PROMISE_THROTTLES = [];
    HttpClient._TASK_QUEUES = [];
    HttpClient._DEFAULT_TIMEOUT = 60000;
    HttpClient.MAX_TIMEOUT = 2147483647; // max 32-bit signed number

    module.exports = HttpClient;