import { IPlatform, resolve } from '@aurelia/kernel';
import { HttpClient } from '../http-client';
import { IFetchInterceptor } from '../interfaces';
import { ErrorNames, createMappedError } from '../errors';

/**
 * The strategy to use when retrying requests.
 */
export const RetryStrategy = /*@__PURE__*/Object.freeze({
  fixed: 0,
  incremental: 1,
  exponential: 2,
  random: 3
});

const defaultRetryConfig: IRetryConfiguration = {
  maxRetries: 3,
  interval: 1000,
  strategy: RetryStrategy.fixed
};

/**
 * Interceptor that retries requests on error, based on a given RetryConfiguration.
 */
export class RetryInterceptor implements IFetchInterceptor {
  /** @internal */
  private readonly p = resolve(IPlatform);
  public retryConfig: IRetryConfiguration;

  /**
   * Creates an instance of RetryInterceptor.
   */
  public constructor(retryConfig?: IRetryConfiguration) {
    this.retryConfig = {...defaultRetryConfig, ...(retryConfig ?? {})};

    if (this.retryConfig.strategy === RetryStrategy.exponential
      && (this.retryConfig.interval as number) <= 1000) {
      throw createMappedError(ErrorNames.retry_interceptor_invalid_exponential_interval, this.retryConfig.interval);
    }
  }

  /**
   * Called with the request before it is sent. It remembers the request so it can be retried on error.
   *
   * @param request - The request to be sent.
   * @returns The existing request, a new request or a response; or a Promise for any of these.
   */
  public request(request: IRetryableRequest): IRetryableRequest {
    if (!request.retryConfig) {
      request.retryConfig = {...this.retryConfig};
      request.retryConfig.counter = 0;
    }

    // do this on every request
    request.retryConfig.requestClone = request.clone();

    return request;
  }

  /**
   * Called with the response after it is received. Clears the remembered request, as it was succesfull.
   *
   * @param response - The response.
   * @returns The response; or a Promise for one.
   */
  public response(response: Response, request: IRetryableRequest): Response {
    // retry was successful, so clean up after ourselves
    delete request.retryConfig;
    return response;
  }

  /**
   * Handles fetch errors and errors generated by previous interceptors. This
   * function acts as a Promise rejection handler. It wil retry the remembered request based on the
   * configured RetryConfiguration.
   *
   * @param error - The rejection value from the fetch request or from a
   * previous interceptor.
   * @returns The response of the retry; or a Promise for one.
   */
  public responseError(error: Response, request: IRetryableRequest, httpClient: HttpClient): Response | Promise<Response> {
    const { retryConfig } = request as { retryConfig: Required<IRetryConfiguration> };
    const { requestClone } = retryConfig;
    return Promise.resolve().then(() => {
      if (retryConfig.counter < retryConfig.maxRetries) {
        const result = retryConfig.doRetry != null ? retryConfig.doRetry(error, request) : true;

        return Promise.resolve(result).then(doRetry => {
          if (doRetry) {
            retryConfig.counter++;
            const delay = calculateDelay(retryConfig);
            return new Promise(resolve => this.p.setTimeout(resolve, !isNaN(delay) ? delay : 0))
              .then(() => {
                const newRequest = requestClone.clone();
                if (typeof (retryConfig.beforeRetry) === 'function') {
                  return retryConfig.beforeRetry(newRequest, httpClient);
                }
                return newRequest;
              })
              .then(newRequest => {
                const retryableRequest: IRetryableRequest = {...newRequest, retryConfig };
                return httpClient.fetch(retryableRequest);
              });
          }

          // no more retries, so clean up
          delete request.retryConfig;
          throw error;
        });
      }
      // no more retries, so clean up
      delete request.retryConfig;
      throw error;
    });
  }
}

function calculateDelay(retryConfig: IRetryConfiguration): number {
  const { interval, strategy, minRandomInterval, maxRandomInterval, counter } = retryConfig as Required<IRetryConfiguration>;

  if (typeof (strategy) === 'function') {
    return (retryConfig.strategy as (retryCount: number) => number)(counter);
  }

  switch (strategy) {
    case (RetryStrategy.fixed):
      return retryStrategies[RetryStrategy.fixed](interval);
    case (RetryStrategy.incremental):
      return retryStrategies[RetryStrategy.incremental](counter, interval);
    case (RetryStrategy.exponential):
      return retryStrategies[RetryStrategy.exponential](counter, interval);
    case (RetryStrategy.random):
      return retryStrategies[RetryStrategy.random](counter, interval, minRandomInterval, maxRandomInterval);
    default:
      throw createMappedError(ErrorNames.retry_interceptor_invalid_strategy, strategy);
  }
}

const retryStrategies = [
  // fixed
  interval => interval,

  // incremental
  (retryCount, interval) => interval * retryCount,

  // exponential
  (retryCount, interval) => retryCount === 1 ? interval : interval ** retryCount / 1000,

  // random
  (retryCount, interval, minRandomInterval = 0, maxRandomInterval = 60000) => {
    return Math.random() * (maxRandomInterval - minRandomInterval) + minRandomInterval;
  }
] as [
  (interval: number) => number,
  (retryCount: number, interval: number) => number,
  (retryCount: number, interval: number) => number,
  (retryCount: number, interval: number, minRandomInterval?: number, maxRandomInterval?: number) => number
];

export type IRetryableRequest = Request & { retryConfig?: IRetryConfiguration };

export interface IRetryConfiguration {
  maxRetries: number;
  interval?: number;
  strategy?: number | ((retryCount: number) => number);
  minRandomInterval?: number;
  maxRandomInterval?: number;
  counter?: number;
  requestClone?: Request;
  doRetry?(response: Response, request: Request): boolean | Promise<boolean>;
  beforeRetry?(request: Request, client: HttpClient): Request | Promise<Request>;
}

