'use strict';

const axios = require('@axios');

import {
  UnauthorizedError, ForbiddenError, ApiError, ValidationError, InternalError, 
  NotFoundError, TooManyRequestsError, ConflictError
} from './errorHandler';
import OptionsValidator from './optionsValidator';
import TimeoutError from './timeoutError';
import LoggerManager, {Logger} from '../logger';
import _ from 'lodash';

/**
 * HTTP client library based on axios
 */
export default class HttpClient {
  
  private _timeout: number;
  private _retries: any;
  private _minRetryDelay: number;
  private _maxRetryDelay: number;
  private _longRunningRequestTimeout: number;
  private _logger: Logger;
  
  /**
   * Constructs HttpClient class instance
   * @param {Number} timeout request timeout in seconds
   * @param {RetryOptions} [retryOpts] retry options
   */
  constructor(timeout = 60, retryOpts: RetryOptions = {}) {
    const validator = new OptionsValidator();

    this._timeout = timeout * 1000;
    this._retries = validator.validateNumber(retryOpts.retries, 5, 'retryOpts.retries');
    this._minRetryDelay = validator.validateNonZero(retryOpts.minDelayInSeconds, 1,
      'retryOpts.minDelayInSeconds') * 1000;
    this._maxRetryDelay = validator.validateNonZero(retryOpts.maxDelayInSeconds, 30,
      'retryOpts.maxDelayInSeconds') * 1000;
    this._longRunningRequestTimeout = validator.validateNumber(retryOpts.longRunningRequestTimeoutInMinutes, 10,
      'retryOpts.longRunningRequestTimeoutInMinutes') * 60 * 1000;
    this._logger = LoggerManager.getLogger('HttpClient');
  }

  /**
   * Performs a request. Response errors are returned as ApiError or subclasses.
   * @param {Object} options request options
   * @returns {Object|String|any} request result
   */
  async request<T = any>(
    options, type = '', retryCounter = 0, endTime = Date.now() + this._maxRetryDelay * this._retries,
    isLongRunning = false
  ): Promise<T> {
    options.timeout = this._timeout;
    
    let retryAfterSeconds = 0;
    options.callback = (e, res) => {
      this._logger.debug(`${type}: received request response with status ${res?.status}`);
      if (res?.status === 202) {
        retryAfterSeconds = res.headers['retry-after'] ?? res.data?.metadata?.recommendedRetryTime;
        this._logger.debug(`${type}: retry after value is ${retryAfterSeconds}`);

        if (isNaN(retryAfterSeconds)) {
          retryAfterSeconds = Math.max((new Date(retryAfterSeconds).getTime() - Date.now()) / 1000, 1);
        }
        if (!isLongRunning) {
          endTime = Date.now() + this._longRunningRequestTimeout;
          isLongRunning = true;
        }
      }
    };

    let body;

    try {
      const response = await this._makeRequest(options, type);
      options.callback(null, response);
      body = (response && response.data) || undefined;
    } catch (err) {
      retryCounter = await this._handleError(err, type, retryCounter, endTime);
      return this.request(options, type, retryCounter, endTime);
    }

    if (retryAfterSeconds) {
      if (body && body.message) {
        this._logger.info(`Retrying request in ${Math.floor(retryAfterSeconds)} seconds because request ` +
          'returned message:', body.message);
      }
      await this._handleRetry(endTime, retryAfterSeconds * 1000);
      body = await this.request(options, type, retryCounter, endTime, isLongRunning);
    }

    return body;
  }

  _makeRequest(options, type) {
    let optionsToLog = _.cloneDeep(options);
    if (optionsToLog.headers?.['auth-token']) {
      optionsToLog.headers['auth-token'] = '...';
    }
    this._logger.debug(`${type}: sending a request with options`, JSON.stringify(optionsToLog));
    return axios({
      transitional: {
        clarifyTimeoutError: true
      },
      ...options
    });
  }
  
  async _wait(pause) {
    await new Promise(res => setTimeout(res, pause));
  }
  
  async _handleRetry(endTime, retryAfter) {
    if (endTime > Date.now() + retryAfter) {
      await this._wait(retryAfter);
    } else {
      throw new TimeoutError('Timed out waiting for the response');
    }
  }
  
  async _handleError(err, type, retryCounter, endTime) {
    const error = this._convertError(err);

    if (
      ['ConflictError', 'InternalError', 'ApiError', 'TimeoutError'].includes(error.name) && 
      retryCounter < this._retries
    ) {
      const pause = Math.min(Math.pow(2, retryCounter) * this._minRetryDelay, this._maxRetryDelay);
      await this._wait(pause);

      return retryCounter + 1;
    } else if (error.name === 'TooManyRequestsError') {
      const retryTime = new Date((error as TooManyRequestsError).metadata.recommendedRetryTime).getTime();
      if (retryTime < endTime) {
        this._logger.debug(`${type} request has failed with TooManyRequestsError (HTTP status code 429). ` +
          `Will retry request in ${Math.ceil((retryTime - Date.now()) / 1000)} seconds`);
        await this._wait(retryTime - Date.now());

        return retryCounter;
      }
    }

    throw error;
  }

  // eslint-disable-next-line complexity
  _convertError(err) {
    const errorResponse = err.response || {};
    const errorData = errorResponse.data || {};
    const status = errorResponse.status || err.status;
    const url = err?.config?.url;

    const errMsg = errorData.message || err.message;
    const errMsgDefault = errorData.message || err.code || err.message;

    switch (status) {
    case 400:
      return new ValidationError(errMsg, errorData.details || err.details, url);
    case 401:
      return new UnauthorizedError(errMsg, url);
    case 403:
      return new ForbiddenError(errMsg, url);
    case 404:
      return new NotFoundError(errMsg, url);
    case 409:
      return new ConflictError(errMsg, url);
    case 429:
      return new TooManyRequestsError(errMsg, errorData.metadata || err.metadata, url);
    case 500:
      return new InternalError(errMsg, url);
    default:
      return new ApiError(ApiError, errMsgDefault, status, url);
    }
  }
}

/**
 * HTTP client service mock for tests
 */
export class HttpClientMock extends HttpClient {
  _requestFn: any;
  /**
   * Constructs HTTP client mock
   * @param {Function(options:Object):Promise} requestFn mocked request function
   * @param {Number} timeout request timeout in seconds
   * @param {RetryOptions} retryOpts retry options
   */
  constructor(requestFn, timeout?, retryOpts?) {
    super(timeout, retryOpts);
    this._requestFn = requestFn;
  }

  _makeRequest(...args) {
    return this._requestFn(...args);
  }

}

/**
 * retry options
 */
export declare type RetryOptions = {

  /**
   * the number of attempts to retry failed request, default 5
   */
  retries?: number,

  /**
   * minimum delay in seconds before retrying, default 1
   */
  minDelayInSeconds?: number,
  
  /**
   * maximum delay in seconds before retrying, default 30
   */
  maxDelayInSeconds?: number,

  /**
   * timeout in minutes for long running requests, default 10
   */
  longRunningRequestTimeoutInMinutes?: number,

  /**
   * time to disable new subscriptions for
   */
  subscribeCooldownInSeconds?: number
}
