import { describe, expect, it, jest } from '@jest/globals';
import { PromiseManager } from '../js/async/promise.js';
import * as p from '../js/async/promise.js';
import * as Math from 'mathjs';
import '../js/ext/polyfills.js';

function expectInTime(promise, waitTime, tolerance=10) {

    const start = process.hrtime.bigint()
    const output = {}

    const timed = promise.then(
        value => {
            output.value = value;
            output.resolved = true;
            return process.hrtime.bigint();
        },
        error => {
            output.value = error;
            output.resolved = false;
            return process.hrtime.bigint();
        }                 
    ).then(end => Math.abs(Number(end-start)/1000000 - waitTime) < tolerance);

    return {
        get resolves() {
            return expect(
                expect(timed).resolves.toBeTruthy().then(() => {
                    expect(output.resolved).toBeTruthy();
                    return output.value;
                })
            ).resolves;
        },
        get rejects() {
            return expect(
                expect(timed).resolves.toBeTruthy()
                .then(() => expect(output.resolved).toBeFalsy())
                .then(
                    () => { throw output.value; },
                    err => { err, output }
                )
            ).rejects;
        },
        get stillRuns() {
            return expect(timed).resolves.toBeFalsy();
        }
    }
}


describe('internal test: expectInTime(<promise>, <time>)', () => {

    const timedPromise = (ms, resolve, value) => new Promise(
        (res, rej) => setTimeout(() => resolve ? res(value) : rej(value), ms)
    );

    const resolveTest = (promise, expect) => {
        const value = 42;
        return expectInTime(timedPromise(promise, true, value), expect).resolves.toStrictEqual(value);
    };

    const rejectTest = (promise, expect) => {
        const error = new Error('test');
        return expectInTime(timedPromise(promise, false, error), expect).rejects.toThrow(error);
    };

    test('resolves when <promise> is settled around <time>', async () => {
        await expect(resolveTest(50, 50)).resolves.toBeUndefined();
        await expect(rejectTest(50, 50)).resolves.toBeUndefined();
    })

    test('rejects when <promise> is settled much before <time>', async () => {
        await expect(resolveTest(20, 50)).rejects.toThrow();
        await expect(rejectTest(20, 50)).rejects.toThrow();
    })

    test('rejects when <promise> is settled much after <time>', async () => {
        await expect(resolveTest(50, 20)).rejects.toThrow();
        await expect(rejectTest(50, 20)).rejects.toThrow();
    })

})


describe('wait(ms, { value, signal })', () => {

    it('resolves with <value> after <ms> miliseconds', async () => {
        await expectInTime(p.wait(30), 30, 15).resolves.toBeUndefined();
        await expectInTime(p.wait(30, { value: 42 }), 30, 15).resolves.toEqual(42);
    })

    it('can receive an AbortSignal to cancel the wait', async () => {
        const signalCtrl = new AbortController();
        setTimeout(() => signalCtrl.abort('test'), 20);
        const promise = p.wait(50, { signal: signalCtrl.signal })
        await expectInTime(promise, 20).rejects.toThrowError(p.AbortError)
    })

    it('rejects immediately if the signal is already aborted', async () => {
        const promise = p.wait(30, { signal: AbortSignal.abort('test') })
        await expectInTime(promise, 0).rejects.toThrowError(p.AbortError)
    })

})

describe('Promise status checks', () => {

    const pending = new Promise(() => {});
    const resolved = Promise.resolve();
    const rejected = Promise.reject();
    // to prevent exception leaks
    rejected.catch(() => false);

    it('promiseStatus(<promise>) resolves with the status of the promise as a string pending|resolved|rejected', async () => {
        await expect(p.promiseStatus(resolved)).resolves.toEqual('resolved');
        await expect(p.promiseStatus(rejected)).resolves.toEqual('rejected');
        await expect(p.promiseStatus(pending)).resolves.toEqual('pending');
    })

    it('isSettled(<promise>) is true only when the promise is rejected or resolved', async () => {
        await expect(p.isSettled(resolved)).resolves.toBeTruthy();
        await expect(p.isSettled(rejected)).resolves.toBeTruthy();
        await expect(p.isSettled(pending)).resolves.toBeFalsy();
    })

    it('isResolved(<promise>) is true only when the promise is resolved', async () => {
        await expect(p.isResolved(resolved)).resolves.toBeTruthy();
        await expect(p.isResolved(rejected)).resolves.toBeFalsy();
        await expect(p.isResolved(pending)).resolves.toBeFalsy();
    })

    it('isRejected(<promise>) is true only when the promise is rejected', async () => {
        await expect(p.isRejected(resolved)).resolves.toBeFalsy();
        await expect(p.isRejected(rejected)).resolves.toBeTruthy();
        await expect(p.isRejected(pending)).resolves.toBeFalsy();
    })

})


describe('createPromise(callback, options={})', () => {

    it('create a promise from the callback', async () => {
        const value = { num: 42 };
        const error = Error('test');

        const resolving = p.createPromise((res, rej) => res(value));
        const rejecting = p.createPromise((res, rej) => rej(error));

        expect(resolving).toBeInstanceOf(Promise);
        await expect(resolving).resolves.toStrictEqual(value);
        await expect(rejecting).rejects.toThrow(error);
    })

    it('options.timeout set the timeout after which the promise rejects with TimeoutError',async  () => {
        const promise = p.createPromise(() => {}, { timeout: 20 });
        await expectInTime(promise, 20).rejects.toThrowError(p.TimeoutError)
    })

    it('options.signal can be used to reject the promise with AbortError', async () => {
        const ctl = new AbortController();
        const delayed = p.createPromise(() => {}, { signal: ctl.signal });
        const immediate = p.createPromise(() => {}, { signal: AbortSignal.abort() });
        await expect(p.promiseStatus(immediate)).resolves.toEqual('rejected');
        await expect(p.promiseStatus(delayed)).resolves.toEqual('pending');
        await expect(immediate).rejects.toThrowError(p.AbortError);
        ctl.abort();
        await expect(delayed).rejects.toThrowError(p.AbortError);
    })

    it('callback is not called if signal is already aborted', async () => {
        const callback = jest.fn();
        await p.createPromise(callback, { signal: AbortSignal.abort() }
        ).catch(() => false);
        expect(callback).not.toBeCalled();
    })

    it('callback is passed a tear down signal as its third argument', async () => {

        const handler = jest.fn();

        const testHandler = async (expectedReason, promise) => {
            await promise.catch(() => false);
            expect(handler).toBeCalled()
            // AbortSignal.reason is not supported in this version of jsdom
            // expect(handler.mock.calls[0][0].target.reason).toBe(expectedReason);
            handler.mockClear();
        }

        await testHandler('timeout', p.createPromise(
            (res, rej, signal) => { signal.addEventListener('abort', handler); },
            { timeout: 10 }
        ));

        const ctl = new AbortController();
        await testHandler('abort', p.createPromise(
            (res, rej, signal) => { signal.addEventListener('abort', handler); ctl.abort(); },
            { signal: ctl.signal }
        ));

        await testHandler('resolved', p.createPromise(
            (res, rej, signal) => { signal.addEventListener('abort', handler); res(); ctl.abort(); }
        ));

        await testHandler('rejected', p.createPromise(
            (res, rej, signal) => { signal.addEventListener('abort', handler); rej(); }
        ));
    })

})


describe('waitFor(<promise>, <time>, { signal })', () => {

    it('settles with the promise when it is settled within <time>', async () => {
        const resolving = p.wait(10, { value: 42 })
        expect(p.waitFor(resolving, 50)).resolves.toStrictEqual(42)

        const rejecting = p.wait(10).then(() => Promise.reject(new Error('test')));
        expect(p.waitFor(rejecting, 50)).rejects.toThrow()
    })

    it('rejects with TimeoutError if the promise is not settled within <time>', async () => {
        const forever = Promise.race([])
        expect(p.waitFor(forever, 30)).rejects.toThrowError(p.TimeoutError)
    })

    it('rejects with AbortError if the signal is used to abort the wait', async () => {
        const forever = Promise.race([])

        expect(p.waitFor(forever, 30, { signal: AbortSignal.abort() })).rejects.toThrowError(p.AbortError)

        const ctrl = new AbortController()
        setTimeout(() => ctrl.abort(), 10)
        expect(p.waitFor(forever, 50, { signal: ctrl.signal })).rejects.toThrowError(p.AbortError)
    })

})

describe('waitForEvents(<target>, <events>, { timeout, signal })', () => {

    it('resolves if <target> dispatches any of the events in <events>', async () => {
        const target = new EventTarget()
        const eventA = new Event('eventA'),
              eventB = new Event('eventB');

        setTimeout(() => target.dispatchEvent(eventA), 0)
        await expect(p.waitForEvents(target, ['eventA', 'eventB'])).resolves.toStrictEqual(eventA)

        setTimeout(() => target.dispatchEvent(eventB), 0)
        await expect(p.waitForEvents(target, ['eventA', 'eventB'])).resolves.toStrictEqual(eventB)
    })

    it('does not resolve if <target> dispatches other events', async () => {
        const target = new EventTarget()
        const eventA = new Event('eventA'),
              eventB = new Event('eventB');

        const promise = p.waitForEvents(target, ['eventA']);
        await expect(p.promiseStatus(promise)).resolves.toEqual('pending')
        
        target.dispatchEvent(eventB)
        await expect(p.promiseStatus(promise)).resolves.toEqual('pending')

        target.dispatchEvent(eventA)
        await expect(promise).resolves.toStrictEqual(eventA)
    })

    it('rejects when aborted with a signal', async () => {
        const target = new EventTarget()
        const ctrl = new AbortController()
        const promise = p.waitForEvents(target, ['eventA'], { signal: ctrl.signal })
        setTimeout(() => ctrl.abort(), 10)
        await expect(promise).rejects.toThrowError(p.AbortError)
    })

    it('rejects immediately when the signal is already aborted', async () => {
        const target = new EventTarget()
        const promise = p.waitForEvents(target, ['eventA'], { signal: AbortSignal.abort() })
        await expect(promise).rejects.toThrowError(p.AbortError)
    })

    it('rejects with TimeoutError if takes longer than <timeout> to settle', async () => {
        const target = new EventTarget()
        const promise = p.waitForEvents(target, ['eventA'], { timeout: 10 })
        await expect(promise).rejects.toThrowError(p.TimeoutError)
    })

})


describe.only('PromiseManager', () => {

    describe('create(<signature>)', () => {

        it('creates a promise with the given signature', () => {
            const mgr = new PromiseManager()
            const result = mgr.create('promise');
            expect(result).toBeInstanceOf(Promise);
        })

        it('returns the same promise instance if called twice', () => {
            const mgr = new PromiseManager()
            const promise1 = mgr.create('promise');
            const promise2 = mgr.create('promise');
            expect(promise2).toStrictEqual(promise1);
        })

    })


    describe('resolve(<signature>, <value>)', () => {

        it('resolves a promise with the provided value', async () => {
            const mgr = new PromiseManager()
            const promise = mgr.create('promise');
            const value = { num: 42 };
            mgr.resolve('promise', value);
            expect(promise).resolves.toStrictEqual(value);
        })

        it('returns true if a promise has been found', async () => {
            const mgr = new PromiseManager()
            const promise = mgr.create('promise');
            expect(mgr.resolve('promise')).toBeTruthy();
            // clear promises
            await promise;
        })

        it('returns false if no pending promise has been found', async () => {
            const mgr = new PromiseManager()
            expect(mgr.resolve('not-created')).toBeFalsy();
            const resolved = mgr.create('resolved', true);
            const rejected = mgr.create('rejected').catch(() => true);
            mgr.resolve('resolved');
            mgr.reject('rejected');
            expect(mgr.resolve('resolved')).toBeFalsy();
            expect(mgr.resolve('rejected')).toBeFalsy();
            await resolved;
            await rejected;
        })

    })


    describe('reject(<signature>, <error>)', () => {

        it('rejects a promise with the provided error', async () => {
            const mgr = new PromiseManager()
            const promise = mgr.create('promise');
            const error = Error('test');
            mgr.reject('promise', error);
            expect(promise).rejects.toThrow(error);
        })

        it('returns true if a promise has been found', async () => {
            const mgr = new PromiseManager()
            const promise = mgr.create('promise').catch(() => false);
            expect(mgr.reject('promise')).toBeTruthy();
            // clear promises
            await promise;
        })

        it('returns false if no pending promise has been found', async () => {
            const mgr = new PromiseManager()
            const resolved = mgr.create('resolved');
            const rejected = mgr.create('rejected').catch(() => false);
            mgr.resolve('resolved');
            mgr.reject('rejected');

            expect(mgr.reject('not-created')).toBeFalsy();
            expect(mgr.reject('resolved')).toBeFalsy();
            expect(mgr.reject('rejected')).toBeFalsy();

            // clear promises
            await resolved;
            await rejected;
        })

    })


    describe('rejectAll(<error>)', () => {

        it('rejects all unsettled promises with the provided error', async () => {
            const mgr = new PromiseManager()
            const promise1 = mgr.create('promise1');
            const promise2 = mgr.create('promise2');
            const error = Error('test');

            mgr.rejectAll(error);
            await expect(promise1).rejects.toThrow(error);
            await expect(promise2).rejects.toThrow(error);
        })

    })


    describe('delete(<signature>)', () => {

        it('deletes a previously created promise', async () => {
            const mgr = new PromiseManager()
            const promise1 = mgr.create('promise');
            mgr.delete('promise');
            const promise2 = mgr.create('promise');
            mgr.resolve('promise')
            expect(p.promiseStatus(promise1)).resolves.toEqual('pending');
            expect(p.promiseStatus(promise2)).resolves.toEqual('resolved');
        })

        it('returns true if a promise has been found', () => {
            const mgr = new PromiseManager()
            mgr.create('promise');
            expect(mgr.delete('promise')).toBeTruthy();
        })

        it('returns false if no pending promise has been found', async () => {
            const mgr = new PromiseManager()
            expect(mgr.delete('not-created')).toBeFalsy();
            const resolved = mgr.create('resolved');
            const rejected = mgr.create('rejected').catch(() => false);
            mgr.resolve('resolved');
            mgr.reject('rejected');
            expect(mgr.delete('resolved')).toBeFalsy();
            expect(mgr.delete('rejected')).toBeFalsy();
            await resolved;
            await rejected;
        })

    })

})
