const {
  LIMITER_TYPES,
  ERR_UNKNOWN_LIMITER_TYPE_MESSAGE,
} = require('./constants');
const crypto = require('crypto');
const {
  RateLimiterMemory,
  RateLimiterCluster,
  RateLimiterMemcache,
  RateLimiterMongo,
  RateLimiterMySQL,
  RateLimiterPostgres,
  RateLimiterRedis,
  RateLimiterValkey,
  RateLimiterValkeyGlide,
} = require('../index');

function getDelayMs(count, delays, maxWait) {
  let msDelay = maxWait;
  const delayIndex = count - 1;
  if (delayIndex >= 0 && delayIndex < delays.length) {
    msDelay = delays[delayIndex];
  }

  return msDelay;
}

const ExpressBruteFlexible = function (limiterType, options) {
  ExpressBruteFlexible.instanceCount++;
  this.name = `brute${ExpressBruteFlexible.instanceCount}`;

  this.options = Object.assign({}, ExpressBruteFlexible.defaults, options);
  if (this.options.minWait < 1) {
    this.options.minWait = 1;
  }

  const validLimiterTypes = Object.keys(ExpressBruteFlexible.LIMITER_TYPES).map(k => ExpressBruteFlexible.LIMITER_TYPES[k]);
  if (!validLimiterTypes.includes(limiterType)) {
    throw new Error(ERR_UNKNOWN_LIMITER_TYPE_MESSAGE);
  }
  this.limiterType = limiterType;

  this.delays = [this.options.minWait];
  while (this.delays[this.delays.length - 1] < this.options.maxWait) {
    const nextNum = this.delays[this.delays.length - 1] + (this.delays.length > 1 ? this.delays[this.delays.length - 2] : 0);
    this.delays.push(nextNum);
  }
  this.delays[this.delays.length - 1] = this.options.maxWait;

  // set default lifetime
  if (typeof this.options.lifetime === 'undefined') {
    this.options.lifetime = Math.ceil((this.options.maxWait / 1000) * (this.delays.length + this.options.freeRetries));
  }

  this.prevent = this.getMiddleware({
    prefix: this.options.prefix,
  });
};

ExpressBruteFlexible.prototype.getMiddleware = function (options) {
  const opts = Object.assign({}, options);
  const commonKeyPrefix = opts.prefix || '';
  const freeLimiterOptions = {
    storeClient: this.options.storeClient,
    storeType: this.options.storeType,
    keyPrefix: `${commonKeyPrefix}free`,
    dbName: this.options.dbName,
    tableName: this.options.tableName,
    points: this.options.freeRetries > 0 ? this.options.freeRetries - 1 : 0,
    duration: this.options.lifetime,
  };

  const blockLimiterOptions = {
    storeClient: this.options.storeClient,
    storeType: this.options.storeType,
    keyPrefix: `${commonKeyPrefix}block`,
    dbName: this.options.dbName,
    tableName: this.options.tableName,
    points: 1,
    duration: Math.min(this.options.lifetime, Math.ceil((this.options.maxWait / 1000))),
  };

  const counterLimiterOptions = {
    storeClient: this.options.storeClient,
    storeType: this.options.storeType,
    keyPrefix: `${commonKeyPrefix}counter`,
    dbName: this.options.dbName,
    tableName: this.options.tableName,
    points: 1,
    duration: this.options.lifetime,
  };

  switch (this.limiterType) {
    case 'memory':
      this.freeLimiter = new RateLimiterMemory(freeLimiterOptions);
      this.blockLimiter = new RateLimiterMemory(blockLimiterOptions);
      this.counterLimiter = new RateLimiterMemory(counterLimiterOptions);
      break;
    case 'cluster':
      this.freeLimiter = new RateLimiterCluster(freeLimiterOptions);
      this.blockLimiter = new RateLimiterCluster(blockLimiterOptions);
      this.counterLimiter = new RateLimiterCluster(counterLimiterOptions);
      break;
    case 'memcache':
      this.freeLimiter = new RateLimiterMemcache(freeLimiterOptions);
      this.blockLimiter = new RateLimiterMemcache(blockLimiterOptions);
      this.counterLimiter = new RateLimiterMemcache(counterLimiterOptions);
      break;
    case 'mongo':
      this.freeLimiter = new RateLimiterMongo(freeLimiterOptions);
      this.blockLimiter = new RateLimiterMongo(blockLimiterOptions);
      this.counterLimiter = new RateLimiterMongo(counterLimiterOptions);
      break;
    case 'mysql':
      this.freeLimiter = new RateLimiterMySQL(freeLimiterOptions);
      this.blockLimiter = new RateLimiterMySQL(blockLimiterOptions);
      this.counterLimiter = new RateLimiterMySQL(counterLimiterOptions);
      break;
    case 'postgres':
      this.freeLimiter = new RateLimiterPostgres(freeLimiterOptions);
      this.blockLimiter = new RateLimiterPostgres(blockLimiterOptions);
      this.counterLimiter = new RateLimiterPostgres(counterLimiterOptions);
      break;
    case 'valkey-glide':
      this.freeLimiter = new RateLimiterValkeyGlide(freeLimiterOptions);
      this.blockLimiter = new RateLimiterValkeyGlide(blockLimiterOptions);
      this.counterLimiter = new RateLimiterValkeyGlide(counterLimiterOptions);
      break;
    case 'valkey':
      this.freeLimiter = new RateLimiterValkey(freeLimiterOptions);
      this.blockLimiter = new RateLimiterValkey(blockLimiterOptions);
      this.counterLimiter = new RateLimiterValkey(counterLimiterOptions);
      break;
    case 'redis':
      this.freeLimiter = new RateLimiterRedis(freeLimiterOptions);
      this.blockLimiter = new RateLimiterRedis(blockLimiterOptions);
      this.counterLimiter = new RateLimiterRedis(counterLimiterOptions);
      break;
    default:
      throw new Error(ERR_UNKNOWN_LIMITER_TYPE_MESSAGE);
  }

  let keyFunc = opts.key;
  if (typeof keyFunc !== 'function') {
    keyFunc = function (req, res, next) {
      next(opts.key);
    };
  }

  const getFailCallback = (() => (typeof opts.failCallback === 'undefined' ? this.options.failCallback : opts.failCallback));

  return (req, res, next) => {
    const cannotIncrementErrorObjectBase = {
      req,
      res,
      next,
      message: 'Cannot increment request count',
    };

    keyFunc(req, res, (key) => {
      if (!opts.ignoreIP) {
        key = ExpressBruteFlexible._getKey([req.ip, this.name, key]);
      } else {
        key = ExpressBruteFlexible._getKey([this.name, key]);
      }

      // attach a simpler "reset" function to req.brute.reset
      if (this.options.attachResetToRequest) {
        let reset = ((callback) => {
          Promise.all([
            this.freeLimiter.delete(key),
            this.blockLimiter.delete(key),
            this.counterLimiter.delete(key),
          ]).then(() => {
            if (typeof callback === 'function') {
              process.nextTick(() => {
                callback();
              });
            }
          }).catch((err) => {
            if (typeof callback === 'function') {
              process.nextTick(() => {
                callback(err);
              });
            }
          });
        });

        if (req.brute && req.brute.reset) {
          // wrap existing reset if one exists
          const oldReset = req.brute.reset;
          const newReset = reset;
          reset = function (callback) {
            oldReset(() => {
              newReset(callback);
            });
          };
        }
        req.brute = {
          reset,
        };
      }

      this.freeLimiter.consume(key)
        .then(() => {
          if (typeof next === 'function') {
            next();
          }
        })
        .catch(() => {
          Promise.all([
            this.blockLimiter.get(key),
            this.counterLimiter.get(key),
          ])
            .then((allRes) => {
              const [blockRes, counterRes] = allRes;

              if (blockRes === null) {
                const msDelay = getDelayMs(
                  counterRes ? counterRes.consumedPoints + 1 : 1,
                  this.delays,
                  // eslint-disable-next-line
                  this.options.maxWait
                );

                this.blockLimiter.penalty(key, 1, { customDuration: Math.ceil(msDelay / 1000) })
                  .then((blockPenaltyRes) => {
                    if (blockPenaltyRes.consumedPoints === 1) {
                      this.counterLimiter.penalty(key)
                        .then(() => {
                          if (typeof next === 'function') {
                            next();
                          }
                        })
                        .catch((err) => {
                          this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err }));
                        });
                    } else {
                      const nextValidDate = new Date(Date.now() + blockPenaltyRes.msBeforeNext);

                      const failCallback = getFailCallback();
                      if (typeof failCallback === 'function') {
                        failCallback(req, res, next, nextValidDate);
                      }
                    }
                  })
                  .catch((err) => {
                    this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err }));
                  });
              } else {
                const nextValidDate = new Date(Date.now() + blockRes.msBeforeNext);

                const failCallback = getFailCallback();
                if (typeof failCallback === 'function') {
                  failCallback(req, res, next, nextValidDate);
                }
              }
            })
            .catch((err) => {
              this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err }));
            });
        });
    });
  };
};

ExpressBruteFlexible.prototype.reset = function (ip, key, callback) {
  let keyArgs = [];
  if (ip) {
    keyArgs.push(ip)
  }
  keyArgs.push(this.name);
  keyArgs.push(key);
  const ebKey = ExpressBruteFlexible._getKey(keyArgs);

  Promise.all([
    this.freeLimiter.delete(ebKey),
    this.blockLimiter.delete(ebKey),
    this.counterLimiter.delete(ebKey),
  ]).then(() => {
    if (typeof callback === 'function') {
      process.nextTick(() => {
        callback();
      });
    }
  }).catch((err) => {
    this.options.handleStoreError({
      message: 'Cannot reset request count',
      parent: err,
      key,
      ip,
    });
  });
};

ExpressBruteFlexible._getKey = function (arr) {
  let key = '';

  arr.forEach((part) => {
    if (part) {
      key += crypto.createHash('sha256').update(part).digest('base64');
    }
  });

  return crypto.createHash('sha256').update(key).digest('base64');
};

const setRetryAfter = function (res, nextValidRequestDate) {
  const secondUntilNextRequest = Math.ceil((nextValidRequestDate.getTime() - Date.now()) / 1000);
  res.header('Retry-After', secondUntilNextRequest);
};
ExpressBruteFlexible.FailTooManyRequests = function (req, res, next, nextValidRequestDate) {
  setRetryAfter(res, nextValidRequestDate);
  res.status(429);
  res.send({
    error: {
      text: 'Too many requests in this time frame.',
      nextValidRequestDate,
    },
  });
};
ExpressBruteFlexible.FailForbidden = function (req, res, next, nextValidRequestDate) {
  setRetryAfter(res, nextValidRequestDate);
  res.status(403);
  res.send({
    error: {
      text: 'Too many requests in this time frame.',
      nextValidRequestDate,
    },
  });
};
ExpressBruteFlexible.FailMark = function (req, res, next, nextValidRequestDate) {
  res.status(429);
  setRetryAfter(res, nextValidRequestDate);
  res.nextValidRequestDate = nextValidRequestDate;
  next();
};

ExpressBruteFlexible.defaults = {
  freeRetries: 2,
  attachResetToRequest: true,
  minWait: 500,
  maxWait: 1000 * 60 * 15,
  failCallback: ExpressBruteFlexible.FailTooManyRequests,
  handleStoreError(err) {
    // eslint-disable-next-line
    throw {
      message: err.message,
      parent: err.parent,
    };
  },
};

ExpressBruteFlexible.LIMITER_TYPES = LIMITER_TYPES;

ExpressBruteFlexible.instanceCount = 0;


module.exports = ExpressBruteFlexible;
