import {
  AbortError, IWaitForOptions, PollUntil, waitFor,
} from '../src';

describe('Unit: Wait Until Factory', () => {
  let options: IWaitForOptions = {
    interval: 30,
    timeout: 100,
  };
  let promiseTimeout = 10;
  let tryingAttemptsRemaining = 0;
  let shouldHaltPromiseResolve = false;
  let shouldRejectAfterHalt = false;

  const someRandPromise = (timeout = promiseTimeout) => new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldHaltPromiseResolve && tryingAttemptsRemaining > 0) {
        resolve(false);
        tryingAttemptsRemaining -= 1;
      } else if (shouldRejectAfterHalt) {
        reject(new Error('rejected'));
      } else {
        resolve(true);
      }
    }, timeout);
  });

  beforeEach(() => {
    promiseTimeout = 10;
    tryingAttemptsRemaining = 2;
    shouldHaltPromiseResolve = false;
    shouldRejectAfterHalt = false;
    options = {
      interval: 30,
      timeout: 100,
    };
  });

  it('should create the default wait params', () => {
    const pollUntil = new PollUntil();
    expect(pollUntil._interval).toEqual(100);
    expect(pollUntil._timeout).toEqual(1000);
  });

  it('should apply options with pre defined option object', () => {
    const pollUntil = new PollUntil(options);
    expect(pollUntil._interval).toEqual(options.interval);
    expect(pollUntil._timeout).toEqual(options.timeout);
  });

  it('should apply options by functional insert', () => {
    const pollUntil = new PollUntil()
      .tryEvery(options.interval!)
      .stopAfter(options.timeout!);

    expect(pollUntil._interval).toEqual(options.interval);
    expect(pollUntil._timeout).toEqual(options.timeout);
  });

  it('should execute runFunctions', () => {
    const pollUntil = new PollUntil();
    jest.spyOn(pollUntil, '_runFunction');

    pollUntil
      .tryEvery(options.interval!)
      .stopAfter(options.timeout!)
      .execute(someRandPromise);

    expect(pollUntil._runFunction).toHaveBeenCalled();
  });

  it('should resolve the promise', (done) => {
    const pollUntil = new PollUntil();

    pollUntil
      .tryEvery(options.interval!)
      .stopAfter(options.timeout!)
      .execute(someRandPromise)
      .then((value) => {
        expect(value).toEqual(true);
        done();
      });
  });
  it('should resolve the promise with waitFor', (done) => {
    waitFor(someRandPromise, options)
      .then((value) => {
        expect(value).toEqual(true);
        done();
      });
  });

  it('should resolve the promise', (done) => {
    const pollUntil = new PollUntil();

    pollUntil
      .tryEvery(options.interval!)
      .stopAfter(options.timeout!)
      .execute(someRandPromise)
      .then((value) => {
        expect(value).toEqual(true);
        done();
      });
  });

  it('should get the promise', (done) => {
    const pollUntil = new PollUntil();

    pollUntil
      .tryEvery(options.interval!)
      .stopAfter(options.timeout!)
      .execute(someRandPromise);

    pollUntil
      .getPromise()
      .then((value) => {
        expect(value).toEqual(true);
        done();
      });
  });

  it('should resolve a stubborn promise after few attempts', (done) => {
    const pollUntil = new PollUntil({ verbose: true });
    shouldHaltPromiseResolve = true;

    pollUntil
      .tryEvery(1)
      .stopAfter(options.timeout!)
      .execute(someRandPromise)
      .then((value) => {
        expect(value).toEqual(true);
        done();
      });
  });

  it('should reject a failed promise after timeout', (done) => {
    const pollUntil = new PollUntil();
    shouldHaltPromiseResolve = true;

    jest.spyOn(pollUntil, '_shouldStopTrying').mockReturnValue(true);

    pollUntil
      .tryEvery(1)
      .stopAfter(5)
      .execute(someRandPromise)
      .catch((error) => {
        expect(error.message).toContain('Failed to wait');
        done();
      });
  });

  it('should reject a failed promise when stopOnFailure is true', (done) => {
    const pollUntil = new PollUntil();

    pollUntil
      .tryEvery(options.interval!)
      .stopAfter(options.timeout!)
      .stopOnFailure(true)
      .execute(() => new Promise((resolve, reject) => {
        reject(new Error('wow'));
      }))
      .catch((error) => {
        expect(error.message).toContain('wow');
        done();
      });
  });

  it('should try again until rejected for a failed promise when stopOnFailure is true', (done) => {
    const pollUntil = new PollUntil();
    shouldHaltPromiseResolve = true;
    shouldRejectAfterHalt = true;

    pollUntil
      .tryEvery(1)
      .stopAfter(options.timeout!)
      .stopOnFailure(true)
      .execute(someRandPromise)
      .catch((error) => {
        expect(error.message).toContain('rejected');
        done();
      });
  });

  it('should fail wait after timeout when stopOnFailure is false', async () => {
    const pollUntil = new PollUntil();
    const errorContent = 'error abcdefg';
    const specificFailedError = new Error(errorContent);
    const mockPromise = jest.fn(() => Promise.reject(specificFailedError));

    expect.assertions(3);
    try {
      await pollUntil
        .tryEvery(1)
        .stopAfter(50)
        .stopOnFailure(false)
        .execute(mockPromise);
    } catch (err) {
      const error = err as Error;
      expect(error.message).toContain('Failed to wait');
      expect(error.message).toContain(errorContent);
      expect(mockPromise.mock.calls.length).toBeGreaterThan(1);
    }
  });

  it('should fail immediately for AbortError when stopOnFailure is false -- passing Error arg', async () => {
    const pollUntil = new PollUntil();
    const errorContent = 'error abcdefg';
    const specificFailedError = new Error(errorContent);
    const abortError = new AbortError(specificFailedError);
    const mockPromise = jest.fn(() => Promise.reject(abortError));

    expect.assertions(3);
    try {
      await pollUntil
        .tryEvery(1)
        .stopAfter(50)
        .stopOnFailure(false)
        .execute(mockPromise);
    } catch (err) {
      const error = err as Error;
      expect(error.message).not.toContain('Failed to wait');
      expect(error.message).toContain(errorContent);
      expect(mockPromise.mock.calls.length).toBe(1);
    }
  });

  it('should fail immediately for AbortError when stopOnFailure is false -- passing string arg', async () => {
    const pollUntil = new PollUntil();
    const errorContent = 'error abcdefg';
    const abortError = new AbortError(errorContent);
    const mockPromise = jest.fn(() => Promise.reject(abortError));

    expect.assertions(3);
    try {
      await pollUntil
        .tryEvery(1)
        .stopAfter(50)
        .stopOnFailure(false)
        .execute(mockPromise);
    } catch (err) {
      const error = err as Error;
      expect(error.message).not.toContain('Failed to wait');
      expect(error.message).toContain(errorContent);
      expect(mockPromise.mock.calls.length).toBe(1);
    }
  });

  it('should execute a second waiting when waiting is done (exceeded timeout) but not resolved', (done) => {
    const pollUntil = new PollUntil();
    pollUntil
      .tryEvery(5)
      .stopAfter(10)
      .execute(() => Promise.resolve(false))
      .catch(() => {
        expect(pollUntil.isWaiting()).toEqual(false);
        expect(pollUntil.isResolved()).toEqual(false);
      });

    pollUntil
      .tryEvery(5)
      .stopAfter(10)
      .execute(() => Promise.resolve(true))
      .then((value) => {
        expect(value).toEqual(true);
        expect(pollUntil.isWaiting()).toEqual(false);
        expect(pollUntil.isResolved()).toEqual(true);
        done();
      });
  });

  it('should throw an error if the execute function is not a function', (done) => {
    const pollUntil = new PollUntil();
    try {
      pollUntil
        .execute(5);
    } catch (e: Error | any) {
      expect(e.message).toContain('executor is not a function.');
      done();
    }
  });

  it('should convert a static function to a promise', (done) => {
    const pollUntil = new PollUntil();

    pollUntil
      .execute(() => 5)
      .then((value) => {
        expect(value).toEqual(5);
        done();
      });
  });

  it('should convert a static function that sometimes return undefined to a promise', (done) => {
    const pollUntil = new PollUntil();
    let counter = 0;

    pollUntil
      .tryEvery(2)
      .stopAfter(10)
      .execute(() => {
        if (counter > 0) {
          return 5;
        }
        counter += 1;
        return false;
      })
      .then((value) => {
        expect(value).toEqual(5);
        done();
      });
  });

  it('wait for within wait for should throw a single error', async () => {
    const options1 = {
      ...options,
      message: 'waiting for something',
    };
    const options2 = {
      ...options,
      message: 'waiting for another thing',
    };
    try {
      await waitFor(() => waitFor(async () => {
        function alon() {
          throw new Error('some error message');
        }

        alon();
      }, options2), options1);
    } catch (e: Error | any) {
      expect(e.message).toMatch(/Failed to wait after \d+ms \(total of \d+ attempts\): waiting for something\nFailed to wait after \d+ms \(total of \d+ attempts\): waiting for another thing/);
      expect(e.stack).toMatch(/alon/);
    }
  });

  it('wait for should show the user message on failure', async () => {
    options.message = 'waiting for something';
    let error: Error | any = null;
    try {
      await waitFor(async () => {
        throw new Error('some error message');
      }, options);
    } catch (e) {
      error = e;
    }
    expect(error?.message).toMatch(/^Failed to wait after \d+ms \(total of \d+ attempts\): waiting for something\nsome error message$/);
  });

  it('wait for should save the original stacktrace', async () => {
    options.message = 'waiting for something';
    let error: Error | any = null;
    async function customFunction() {
      await waitFor(() => false, options);
    }
    try {
      await customFunction();
    } catch (e) {
      error = e;
    }
    expect(error?.message).toMatch(/^Failed to wait after \d+ms \(total of \d+ attempts\): waiting for something$/);
    expect(error?.stack).toMatch(/customFunction/);
  });

  it('should show stack if thrown inside a function', async () => {
    let counter = 100;
    let error: Error | any = null;
    function functionA() {
      return waitFor(async () => {
        if (counter !== 0) {
          counter -= 1;
          throw new Error('try again');
        }
      }, { timeout: 20, interval: 2, verbose: true });
    }
    async function functionB() {
      await functionA();
    }
    try {
      await functionB();
    } catch (e) {
      error = e;
    }
    expect(error?.message).toMatch(/try again/);
    expect(error?.stack).toMatch(/functionA/);
    expect(error?.stack).toMatch(/functionB/);
  });

  it('should backoff if factor defined', async () => {
    const baseInterval = 100;
    const backoffFactor = 2;

    shouldHaltPromiseResolve = true;
    tryingAttemptsRemaining = 1;

    const pollUntil = new PollUntil({ backoffFactor });

    const mockPromise = jest.fn(() => someRandPromise(0));

    return pollUntil
      .tryEvery(baseInterval)
      .execute(mockPromise)
      .then(() => {
        expect(pollUntil._interval).toEqual(baseInterval * backoffFactor);
      });
  });

  it('should respect max interval during backoff if defined', async () => {
    const baseInterval = 10;
    const backoffFactor = 2;
    const backoffMaxInterval = 24;

    shouldHaltPromiseResolve = true;
    tryingAttemptsRemaining = 4;

    const pollUntil = new PollUntil({ backoffFactor, backoffMaxInterval });

    const mockPromise = jest.fn(() => someRandPromise(0));

    return pollUntil
      .tryEvery(baseInterval)
      .execute(mockPromise)
      .then(() => {
        expect(pollUntil._interval).toEqual(backoffMaxInterval);
      });
  });

  it('wait for should retry in sync function that throws errors', async () => {
    let counter = 0;
    let error: Error | any = null;
    try {
      await waitFor(() => {
        counter += 1;
        throw new Error('some error message');
      }, options);
    } catch (e) {
      error = e;
    }
    expect(counter).toBeGreaterThan(1);
    expect(error).not.toBeNull();
  });

  it('should fail if max attempts exceeded', async () => {
    const pollUntil = new PollUntil({ maxAttempts: 3, interval: 1 });
    const error = new Error('whoops');

    const mockPromise = jest.fn().mockRejectedValue(error);

    await expect(pollUntil.execute(mockPromise)).rejects.toThrow(/Operation unsuccessful after 3 attempts \(total of \d+ms\)\nwhoops/);
  });

  it('should not fail if max attempts not exceeded', async () => {
    const pollUntil = new PollUntil({ maxAttempts: 3, interval: 1 });
    const error = new Error('whoops');

    const mockPromise = jest.fn()
      .mockRejectedValueOnce(error)
      .mockRejectedValueOnce(error)
      .mockResolvedValue({ fake: 'result' });

    expect(await pollUntil.execute(mockPromise)).toEqual({ fake: 'result' });
  });
});
