'use strict';

var { PassThrough } = require('stream');
var debug = require('debug')('retry-request');
var extend = require('extend');

var DEFAULTS = {
  objectMode: false,
  retries: 2,

  /*
    The maximum time to delay in seconds. If retryDelayMultiplier results in a
    delay greater than maxRetryDelay, retries should delay by maxRetryDelay
    seconds instead.
  */
  maxRetryDelay: 64, 

  /*
    The multiplier by which to increase the delay time between the completion of
    failed requests, and the initiation of the subsequent retrying request.
  */
  retryDelayMultiplier: 2,

  /*
    The length of time to keep retrying in seconds. The last sleep period will
    be shortened as necessary, so that the last retry runs at deadline (and not
    considerably beyond it).  The total time starting from when the initial
    request is sent, after which an error will be returned, regardless of the
    retrying attempts made meanwhile.
   */
  totalTimeout: 600,

  noResponseRetries: 2,
  currentRetryAttempt: 0,
  shouldRetryFn: function (response) {
    var retryRanges = [
      // https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
      // 1xx - Retry (Informational, request still processing)
      // 2xx - Do not retry (Success)
      // 3xx - Do not retry (Redirect)
      // 4xx - Do not retry (Client errors)
      // 429 - Retry ("Too Many Requests")
      // 5xx - Retry (Server errors)
      [100, 199],
      [429, 429],
      [500, 599]
    ];

    var statusCode = response.statusCode;
    debug(`Response status: ${statusCode}`);

    var range;
    while ((range = retryRanges.shift())) {
      if (statusCode >= range[0] && statusCode <= range[1]) {
        // Not a successful status or redirect.
        return true;
      }
    }
  }
};

function retryRequest(requestOpts, opts, callback) {
  var streamMode = typeof arguments[arguments.length - 1] !== 'function';

  if (typeof opts === 'function') {
    callback = opts;
  }

  var manualCurrentRetryAttemptWasSet = opts && typeof opts.currentRetryAttempt === 'number';
  opts = extend({}, DEFAULTS, opts);

  if (typeof opts.request === 'undefined') {
    try {
      opts.request = require('request');
    } catch (e) {
      throw new Error('A request library must be provided to retry-request.');
    }
  }

  var currentRetryAttempt = opts.currentRetryAttempt;

  var numNoResponseAttempts = 0;
  var streamResponseHandled = false;

  var retryStream;
  var requestStream;
  var delayStream;

  var activeRequest;
  var retryRequest = {
    abort: function () {
      if (activeRequest && activeRequest.abort) {
        activeRequest.abort();
      }
    }
  };

  if (streamMode) {
    retryStream = new PassThrough({ objectMode: opts.objectMode });
    retryStream.abort = resetStreams;
  }

  var timeOfFirstRequest = Date.now();
  if (currentRetryAttempt > 0) {
    retryAfterDelay(currentRetryAttempt);
  } else {
    makeRequest();
  }

  if (streamMode) {
    return retryStream;
  } else {
    return retryRequest;
  }

  function resetStreams() {
    delayStream = null;

    if (requestStream) {
      requestStream.abort && requestStream.abort();
      requestStream.cancel && requestStream.cancel();

      if (requestStream.destroy) {
        requestStream.destroy();
      } else if (requestStream.end) {
        requestStream.end();
      }
    }
  }

  function makeRequest() {
    currentRetryAttempt++;
    debug(`Current retry attempt: ${currentRetryAttempt}`);

    if (streamMode) {
      streamResponseHandled = false;

      delayStream = new PassThrough({ objectMode: opts.objectMode });
      requestStream = opts.request(requestOpts);

      setImmediate(function () {
        retryStream.emit('request');
      });

      requestStream
        // gRPC via google-cloud-node can emit an `error` as well as a `response`
        // Whichever it emits, we run with-- we can't run with both. That's what
        // is up with the `streamResponseHandled` tracking.
        .on('error', function (err) {
          if (streamResponseHandled) {
            return;
          }

          streamResponseHandled = true;
          onResponse(err);
        })
        .on('response', function (resp, body) {
          if (streamResponseHandled) {
            return;
          }

          streamResponseHandled = true;
          onResponse(null, resp, body);
        })
        .on('complete', retryStream.emit.bind(retryStream, 'complete'));

      requestStream.pipe(delayStream);
    } else {
      activeRequest = opts.request(requestOpts, onResponse);
    }
  }

  function retryAfterDelay(currentRetryAttempt) {
    if (streamMode) {
      resetStreams();
    }

    var nextRetryDelay = getNextRetryDelay({
      maxRetryDelay: opts.maxRetryDelay,
      retryDelayMultiplier: opts.retryDelayMultiplier,
      retryNumber: currentRetryAttempt,
      timeOfFirstRequest,
      totalTimeout: opts.totalTimeout,
    });
    debug(`Next retry delay: ${nextRetryDelay}`);

    setTimeout(makeRequest, nextRetryDelay);
  }

  function onResponse(err, response, body) {
    // An error such as DNS resolution.
    if (err) {
      numNoResponseAttempts++;

      if (numNoResponseAttempts <= opts.noResponseRetries) {
        retryAfterDelay(numNoResponseAttempts);
      } else {
        if (streamMode) {
          retryStream.emit('error', err);
          retryStream.end();
        } else {
          callback(err, response, body);
        }
      }

      return;
    }

    // Send the response to see if we should try again.
    // NOTE: "currentRetryAttempt" isn't accurate by default, as it counts
    // the very first request sent as the first "retry". It is only accurate
    // when a user provides their own "currentRetryAttempt" option at
    // instantiation.
    var adjustedCurrentRetryAttempt = manualCurrentRetryAttemptWasSet ? currentRetryAttempt : currentRetryAttempt - 1;
    if (adjustedCurrentRetryAttempt < opts.retries && opts.shouldRetryFn(response)) {
      retryAfterDelay(currentRetryAttempt);
      return;
    }

    // No more attempts need to be made, just continue on.
    if (streamMode) {
      retryStream.emit('response', response);
      delayStream.pipe(retryStream);
      requestStream.on('error', function (err) {
        retryStream.destroy(err);
      });
    } else {
      callback(err, response, body);
    }
  }
}

module.exports = retryRequest;

function getNextRetryDelay(config) {
  var {
    maxRetryDelay,
    retryDelayMultiplier,
    retryNumber,
    timeOfFirstRequest,
    totalTimeout,
  } = config;

  var maxRetryDelayMs = maxRetryDelay * 1000;
  var totalTimeoutMs = totalTimeout * 1000;

  var jitter = Math.floor(Math.random() * 1000);
  var calculatedNextRetryDelay = Math.pow(retryDelayMultiplier, retryNumber) * 1000 + jitter;

  var maxAllowableDelayMs = totalTimeoutMs - (Date.now() - timeOfFirstRequest);

  return Math.min(calculatedNextRetryDelay, maxAllowableDelayMs, maxRetryDelayMs);
}

module.exports.getNextRetryDelay = getNextRetryDelay;
