'use strict';

import TimeoutError from '../clients/timeoutError';
import LoggerManager from '../logger';
import {PromiseOrNot} from '../types/util';
import _ from 'lodash';

const realSetTimeout = setTimeout;
const realDateNow = Date.now.bind(Date);

/** An extended promise with additional properties and methods */
export interface HandlePromise<T> extends Promise<T> {
  /** Whether the promise is resolved or rejected */
  completed: boolean,
  /** Whether the promise is resolved */
  resolved?: boolean,
  /** Whether the promise is rejected */
  rejected?: boolean,
  /** Result value the promise resolved with */
  result?: T,
  /** Error the promise rejected with */
  error?: Error,
  /**
   * Resolves the promise with specified value
   * @param result Value to resolve the promise with
   */
  resolve(result?: T): void,
  /**
   * Rejects the promise with specified error
   * @param err Error to reject the promise with
   */
  reject(err: Error): void,
  /**
   * Adds a timeout to reject the promise with `TimeoutError`
   * @param milliseconds timeout in milliseconds
   * @param errorMessage error message
   * @returns self
   */
  timeout(milliseconds: number, errorMessage: string): HandlePromise<T>
}

/**
 * Creates a promise that can be used as a handle. It will not raise errors when rejected until it is explicitly
 * awaited or catch is set
 * @returns modified handle promise
 */
export function createHandlePromise<T>(): HandlePromise<T> {
  let resolve, reject;
  let promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  }) as HandlePromise<T>;
  promise.completed = false;
  promise.resolve = (result) => {
    if (!promise.completed) {
      promise.completed = true;
      promise.resolved = true;
      promise.result = result;
      resolve(result);
    }
  };
  promise.reject = (err) => {
    if (!promise.completed) {
      promise.completed = true;
      promise.rejected = true;
      promise.error = err;
      reject(err);
    }
  };
  promise.timeout = (milliseconds, errorMessage) => {
    if (!promise.completed) {
      let timeout = setTimeout(() => promise.reject(new TimeoutError(errorMessage)), milliseconds);
      promise.finally(() => clearTimeout(timeout)).catch(() => {});
    }
    return promise;
  };
  promise.catch(() => {});
  return promise;
}

/**
 * Wraps a promise into a handle promise
 * @param promise native promise
 * @returns handle promise
 */
export function wrapHandlePromise<T>(promise: Promise<T>): HandlePromise<T> {
  let result = createHandlePromise<T>();
  promise.then(result.resolve).catch(() => {});
  promise.catch(result.reject);
  return result;
}

/**
 * This function ensures that a promise is returned
 * @param call call
 * @returns promise
 */
export async function ensurePromise<T>(call: () => PromiseOrNot<T>): Promise<T> {
  return call();
}

/** Additional delay options */
export type DelayOptions = {
  /** Whether to delay real time, if a stubbed frozen `sinon` clock is used */
  ignoreSinonClock?: boolean
};

/**
 * Waits specified delay
 * @param ms Milliseconds to wait
 * @param options Additional options
 * @return promise resolving when the delay has ended
 */
export function delay(ms: number, options?: DelayOptions): DelayPromise {
  let resolve: () => void;
  let timeout: NodeJS.Timeout;
  let canceled = false;
  let result = new Promise<void>(res => {
    timeout = options?.ignoreSinonClock ? realSetTimeout(res, ms) : setTimeout(res, ms);
    resolve = res;
  }) as DelayPromise;
  Object.defineProperty(result, 'canceled', {
    get: () => canceled,
    enumerable: true,
    configurable: true
  });
  result.cancel = () => {
    canceled = true;
    clearTimeout(timeout);
    resolve();
  };
  return result;
}

/**
 * Delay promise
 */
export interface DelayPromise extends Promise<void> {
  /**
   * Returns whether the promise is canceled
   * @returns whether canceled
   */
  get canceled(): boolean;
  /**
   * Cancels waiting and resolves the promise immediately
   */
  cancel(): void;
}

/**
 * Assembles log4js config from logging level map
 * @param {Object} [config] log4js config
 * @param {String} [config.defaultLevel = 'INFO'] Default logging level
 * @param {Object} [config.levels] Logging levels
 * @return {Object} Log4js config
 */
export function assembleLog4jsConfig(config: any = {}) {
  let appenders = {console: {type: 'console'}};
  let categories = {
    default: {
      appenders: Object.keys(appenders),
      level: config.defaultLevel || 'INFO'
    }
  };
  Object.keys(config.levels || {}).forEach((category) => {
    categories[category] = {
      appenders: Object.keys(appenders),
      level: config.levels[category]
    };
  });
  return {appenders, categories};
}

/** Options for `wait*` functions */
export type WaitOptions = {
  /** Wait timeout in milliseconds. Defaults to `30000` */
  timeoutInMs?: number
};

/**
 * Waits untill specified callable will pass successfully and return true. Uses log4js logger named `helpers.wait`
 * @param {() => boolean|Promise<boolean>} callable Callable to call until it returns true
 * @param {Number} [intervalInMs = 25] Interval in milliseconds between the checks
 * @param {WaitOptions & DelayOptions} [options] Additional wait options
 * @return {Promise} Promise resolving with callable return value when waited
 * @throws {Error|TimeoutError} Error from the callable or timeout error when timed out
 */
// eslint-disable-next-line complexity
export async function wait(callable, intervalInMs = 25, options?: WaitOptions & DelayOptions) {
  const logger = LoggerManager.getLogger('helpers.wait');
  if (typeof intervalInMs === 'object') {
    // for backward compatibility
    options = intervalInMs;
    intervalInMs = _.defaultTo((options as any).intervalInMs, 1000);
  }
  const dateNow = options?.ignoreSinonClock ? realDateNow : () => Date.now();
  let result = false, lastError;
  let timesAt = dateNow() + _.defaultTo(options?.timeoutInMs, 30000);
  while (!result && dateNow() < timesAt) {
    try {
      result = await callable();
    } catch (err) {
      lastError = err;
      logger.debug('The executor failed', err);
      if (dateNow() >= timesAt) {
        throw err;
      }
    } finally {
      if (!result) {
        logger.debug('Waiting because the result is', result);
        await delay(intervalInMs, options);
      }
    }
  }
  if (dateNow() >= timesAt) {
    if (lastError) {
      throw lastError;
    }
    throw new TimeoutError('Timed out till specified callable returns true');
  }
  return result;
}

/**
 * Waits untill specified callable will pass successfully and return true. Uses log4js logger named `helpers.wait`
 * @param {() => boolean|Promise<boolean>} callable Callable to call until it returns true
 * @param {Number} [intervalInMs = 25] Interval in milliseconds between the checks
 * @param {WaitOptions & DelayOptions} [options] Additional wait options
 * @return {Promise} Promise resolving with callable return value when waited
 * @throws {Error|TimeoutError} Error from the callable or timeout error when timed out
 */
export function waitTrue(callable, intervalInMs = 25, options?: WaitOptions & DelayOptions) {
  return wait(callable, intervalInMs, options);
}

/**
 * Waits untill specified callable will pass successfully. Uses log4js logger named `helpers.wait`
 * @param {() => boolean|Promise<boolean>} callable Callable to call
 * @param {Number} [intervalInMs = 25] Interval in milliseconds between the checks
 * @param {WaitOptions & DelayOptions} [options] Additional wait options
 * @return {Promise} Promise resolving with callable return value when waited
 * @throws {Error|TimeoutError} Error from the callable or timeout error when timed out
 */
export async function waitPass<T = void>(
  callable: () => PromiseOrNot<T>,
  intervalInMs = 25,
  options?: WaitOptions & DelayOptions
): Promise<T> {
  let result;
  await wait(async () => {
    result = await callable();
    return true;
  }, intervalInMs, options);
  return result;
}

/**
 * Waits untill specified callable successfully returns any non-undefined value. Uses log4js logger named `helpers.wait`
 * @param callable Callable to call
 * @param intervalInMs Interval in milliseconds between the checks
 * @param options Additional wait options
 * @return Promise resolving with callable return value when waited
 * @throws {Error|TimeoutError} Error from the callable or timeout error when timed out
 */
export async function waitAny<T>(
  callable: () => PromiseOrNot<T>, intervalInMs = 25, options?: WaitOptions
): Promise<T> {
  let result: T;
  await waitTrue(async () => {
    result = await callable();
    if (result !== undefined) {
      return true;
    }
  }, intervalInMs, options);
  return result;
}

/**
 * Calculates exponential backoff delay. At the initial iteration, there is no delay. At the next iteration, the delay
 * is `startDelay`. Further, at the every next iteration the previous delay multiplies to 2
 * @param iteration current iteration, where 0 is initial iteration without delaying
 * @param startDelay start delay
 * @param maxDelay maximum delay
 */
export function expBackoffDelay(iteration: number, startDelay: number, maxDelay: number) {
  if (iteration === 0) {
    return 0;
  }
  return Math.min(startDelay * Math.pow(2, iteration - 1), maxDelay);
}
