const debug = require('debug')('wait-port');
const net = require('net');
const outputFunctions = require('./output-functions');
const validateParameters = require('./validate-parameters');
const ConnectionError = require('./errors/connection-error');

let IPv6enabled = true;

function createConnectionWithTimeout({ host, port, ipVersion }, timeout, callback) {
  //  Variable to hold the timer we'll use to kill the socket if we don't
  //  connect in time.
  let timer = null;

  //  Try and open the socket, with the params and callback.
  const socket = net.createConnection({ host, port, family: ipVersion, autoSelectFamily: true }, (err) => {
    if (!err) clearTimeout(timer);
    return callback(err);
  });

  //  TODO: Check for the socket ECONNREFUSED event.
  socket.on('error', (error) => {
    debug(`Socket error: ${error}`);
    clearTimeout(timer);
    socket.destroy();
    callback(error);
  });

  //  Kill the socket if we don't open in time.
  timer = setTimeout(() => {
    socket.destroy();
    const error = new Error(`Timeout trying to open socket to ${host}:${port}, IPv${ipVersion}`);
    error.code = 'ECONNTIMEOUT';
    callback(error);
  }, timeout);

  //  Return the socket.
  return socket;
}

function checkHttp(socket, params, timeout, callback) {
  //  Create the HTTP request.
  const request = `GET ${params.path} HTTP/1.1\r\nHost: ${params.host}\r\n\r\n`;

  let timer = null;
  timer = setTimeout(() => {
    socket.destroy();
    const error = new Error(`Timeout waiting for data from ${params.host}:${params.port}, IPv${params.ipVersion}`);
    error.code = 'EREQTIMEOUT';
    callback(error);
  }, timeout);

  //  Get ready for a response.
  socket.on('data', function(data) {
    //  Get the response as text.
    const response = data.toString();
    const statusLine = response.split('\n')[0];

    //  Stop the timer.
    clearTimeout(timer);

    //  Check the data. Remember an HTTP response is:
    //  HTTP/1.1 XXX Stuff
    const statusLineParts = statusLine.split(' ');
    if (statusLineParts < 2 || statusLineParts[1].startsWith('2') === false) {
      debug(`Invalid HTTP status line: ${statusLine}`);
      const error = new Error('Invalid response from server');
      error.code = 'ERESPONSE';
      callback(error);
    }

    //  ALL good!
    debug(`Successful HTTP status line: ${statusLine}`);
    callback();
  });

  //  Send the request.
  socket.write(request);
}

//  This function attempts to open a connection, given a limited time window.
//  This is the function which we will run repeatedly until we connect.
function tryConnect(options, timeout) {
  return new Promise((resolve, reject) => {
    try {
      const socket = createConnectionWithTimeout(options, timeout, (err) => {
        if (err) {
          if (err.code === 'ECONNREFUSED') {
            //  We successfully *tried* to connect, so resolve with false so
            //  that we try again.
            debug('Socket not open: ECONNREFUSED');
            socket.destroy();
            return resolve(false);
          } else if (err.code === 'ECONNTIMEOUT') {
            //  We've successfully *tried* to connect, but we're timing out
            //  establishing the connection. This is not ideal (either
            //  the port is open or it ain't).
            debug('Socket not open: ECONNTIMEOUT');
            socket.destroy();
            return resolve(false);
          } else if (err.code === 'ECONNRESET') {
            //  This can happen if the target server kills its connection before
            //  we can read from it, we can normally just try again.
            debug('Socket not open: ECONNRESET');
            socket.destroy();
            return resolve(false);
          } else if (options.ipVersion === 6 && (err.code === 'EADDRNOTAVAIL' || err.code === 'ENOTFOUND')) {
            //  This will occur if the IP address we are trying to connect to does not exist
            //  This can happen for ::1 or other IPv6 addresses if the IPv6 stack is not enabled.
            //  In this case we disable the IPv6 lookup
            debug(`Socket cannot be opened for IPv6: ${err.code}`);
            debug('Disabling IPv6 lookup');
            IPv6enabled = false;
            socket.destroy();
            return resolve(false);
          } else if (err.code === 'ENOTFOUND') {
            //  This will occur if the address is not found, i.e. due to a dns
            //  lookup fail (normally a problem if the domain is wrong).
            debug('Socket cannot be opened: ENOTFOUND');
            socket.destroy();

            //  If we are going to wait for DNS records, we can actually just try
            //  again...
            if (options.waitForDns === true) return resolve(false);

            // ...otherwise, we will explicitly fail with a meaningful error for
            //  the user.
            return reject(new ConnectionError(`The address '${options.host}' cannot be found`));
          }

          //  Trying to open the socket has resulted in an error we don't
          //  understand. Better give up.
          debug(`Unexpected error trying to open socket: ${err}`);
          socket.destroy();

          // If we are currently checking for IPv6 we ignore this error and disable IPv6
          if (options.ipVersion === 6) {
            IPv6enabled = false;
            return resolve(false);
          }

          return reject(err);
        }

        //  Boom, we connected!
        debug('Socket connected!');

        //  If we are not dealing with http, we're done.
        if (options.protocol !== 'http') {
          //  Disconnect, stop the timer and resolve.
          socket.destroy();
          return resolve(true);
        }

        //  TODO: we should only use the portion of the timeout for this interval which is still left to us.

        //  Now we've got to wait for a HTTP response.
        checkHttp(socket, options, timeout, (err) => {
          if (err) {
            if (err.code === 'EREQTIMEOUT') {
              debug('HTTP error: EREQTIMEOUT');
              socket.destroy();
              return resolve(false);
            } else if (err.code === 'ERESPONSE') {
              debug('HTTP error: ERESPONSE');
              socket.destroy();
              return resolve(false);
            }
            debug(`Unexpected error checking http response: ${err}`);
            socket.destroy();
            return reject(err);
          }

          socket.destroy();
          return resolve(true);
        });
      });
    } catch (err) {
      //  Trying to open the socket has resulted in an exception we don't
      //  understand. Better give up.
      debug(`Unexpected exception trying to open socket: ${err}`);
      return reject(err);
    }
  });
}

function waitPort(params) {
  return new Promise((resolve, reject) => {
    const {
      protocol,
      host,
      port,
      path,
      interval,
      timeout,
      output,
      waitForDns,
    } = validateParameters(params);

    //  Keep track of the start time (needed for timeout calcs).
    const startTime = new Date();

    //  Don't wait for more than connectTimeout to try and connect.
    const connectTimeout = 1000;

    //  Grab the object for output.
    const outputFunction = outputFunctions[output];
    outputFunction.starting({ host, port });

    //  Start trying to connect.
    const loop = (ipVersion = 4) => {
      outputFunction.tryConnect();
      tryConnect({ protocol, host, port, path, waitForDns, ipVersion }, connectTimeout)
        .then((open) => {
          debug(`Socket status is: ${open}`);

          //  The socket is open, we're done.
          if (open) {
            outputFunction.connected();
            return resolve({ open: true, ipVersion });
          }

          //  If we have a timeout, and we've passed it, we're done.
          if (timeout && (new Date() - startTime) > timeout) {
            outputFunction.timeout();
            return resolve({ open: false });
          }

          // Check for IPv6 next
          if (IPv6enabled && ipVersion === 4 && !net.isIP(host)) {
            return loop(6);
          }

          //  Run the loop again.
          return setTimeout(loop, interval);
        })
        .catch((err) => {
          debug(`Unhandled error occured trying to connect: ${err}`);
          return reject(err);
        });
    };

    //  Start the loop.
    loop();
  });
}

module.exports = waitPort;
