import {describe, test, expect, vi} from 'vitest';
import {Event, Evented} from './evented.ts';

class StubbedEvent extends Event<'a' | 'b'> {
    foo?: string;
    foz?: string;
}

// `Evented` is abstract, so use a minimal concrete subclass to exercise its behavior.
class StubbedEvented extends Evented<Record<StubbedEvent['type'], StubbedEvent>> {}

describe('Evented', () => {

    test('calls listeners added with "on"', () => {
        const evented = new StubbedEvented();
        const listener = vi.fn();
        evented.on('a', listener);
        evented.fire(new StubbedEvent('a'));
        evented.fire(new StubbedEvent('a'));
        expect(listener).toHaveBeenCalledTimes(2);
    });

    test('calls listeners added with "once" once', () => {
        const evented = new StubbedEvented();
        const listener = vi.fn();
        evented.once('a', listener);
        evented.fire(new StubbedEvent('a'));
        evented.fire(new StubbedEvent('a'));
        expect(listener).toHaveBeenCalledTimes(1);
        expect(evented.listens('a')).toBeFalsy();
    });

    test('calls listeners added with "on" and allows to unsubscribe', () => {
        const evented = new StubbedEvented();
        const listener = vi.fn();
        const subscription = evented.on('a', listener);
        evented.fire(new StubbedEvent('a'));
        subscription.unsubscribe();
        evented.fire(new StubbedEvent('a'));
        expect(listener).toHaveBeenCalledTimes(1);
    });

    test('returns a promise when no listener is provided to "once" method', async () => {
        const evented = new StubbedEvented();
        const promise = evented.once('a');
        evented.fire(new StubbedEvent('a'));
        evented.fire(new StubbedEvent('a'));
        await promise;
        expect(evented.listens('a')).toBeFalsy();

    });

    test('passes data to listeners', () => {
        const evented = new StubbedEvented();
        evented.on('a', (data) => {
            expect(data.foo).toBe('bar');
        });
        evented.fire(new StubbedEvent('a', {foo: 'bar'}));

    });

    test('passes "target" to listeners', () => {
        const evented = new StubbedEvented();
        evented.on('a', (data) => {
            expect(data.target).toBe(evented);
        });
        evented.fire(new StubbedEvent('a'));

    });

    test('passes "type" to listeners', () => {
        const evented = new StubbedEvented();
        evented.on('a', (data) => {
            expect(data.type).toBe('a');
        });
        evented.fire(new StubbedEvent('a'));

    });

    test('removes listeners with "off"', () => {
        const evented = new StubbedEvented();
        const listener = vi.fn();
        evented.on('a', listener);
        evented.off('a', listener);
        evented.fire(new StubbedEvent('a'));
        expect(listener).not.toHaveBeenCalled();
    });

    test('removes one-time listeners with "off"', () => {
        const evented = new StubbedEvented();
        const listener = vi.fn();
        evented.once('a', listener);
        evented.off('a', listener);
        evented.fire(new StubbedEvent('a'));
        expect(listener).not.toHaveBeenCalled();
    });

    test('once listener is removed prior to call', () => {
        const evented = new StubbedEvented();
        const listener = vi.fn();
        evented.once('a', () => {
            listener();
            evented.fire(new StubbedEvent('a'));
        });
        evented.fire(new StubbedEvent('a'));
        expect(listener).toHaveBeenCalledTimes(1);
    });

    test('reports if an event has listeners with "listens"', () => {
        const evented = new StubbedEvented();
        evented.on('a', () => {});
        expect(evented.listens('a')).toBeTruthy();
        expect(evented.listens('b')).toBeFalsy();

    });

    test('does not report true to "listens" if all listeners have been removed', () => {
        const evented = new StubbedEvented();
        const listener = () => {};
        evented.on('a', listener);
        evented.off('a', listener);
        expect(evented.listens('a')).toBeFalsy();

    });

    test('does not immediately call listeners added within another listener', () => {
        const evented = new StubbedEvented();
        const nestedListener = vi.fn();
        evented.on('a', () => {
            evented.on('a', nestedListener);
        });
        evented.fire(new StubbedEvent('a'));

        expect(nestedListener).not.toHaveBeenCalled();
    });

    test('has backward compatibility for fire(string, object) API', () => {
        const evented = new StubbedEvented();
        const listener = vi.fn(x => x);
        evented.on('a', listener);
        evented.fire('a', {foo: 'bar'});
        expect(listener).toHaveBeenCalledTimes(1);
        expect(listener.mock.calls[0][0].foo).toBe('bar');

    });

    test('on is idempotent', () => {
        const evented = new StubbedEvented();
        const order = [];
        const listenerA = vi.fn(() => order.push('A'));
        const listenerB = vi.fn(() => order.push('B'));
        evented.on('a', listenerA);
        evented.on('a', listenerB);
        evented.on('a', listenerA);
        evented.fire(new StubbedEvent('a'));
        expect(listenerA).toHaveBeenCalledTimes(1);
        expect(order).toEqual(['A', 'B']);

    });
});

describe('evented parents', () => {

    test('adds parents with "setEventedParent"', () => {
        const listener = vi.fn();
        const eventedSource = new StubbedEvented();
        const eventedSink = new StubbedEvented();
        eventedSource.setEventedParent(eventedSink);
        eventedSink.on('a', listener);
        eventedSource.fire(new StubbedEvent('a'));
        eventedSource.fire(new StubbedEvent('a'));
        expect(listener).toHaveBeenCalledTimes(2);
    });

    test('passes original data to parent listeners', () => {
        const eventedSource = new StubbedEvented();
        const eventedSink = new StubbedEvented();
        eventedSource.setEventedParent(eventedSink);
        eventedSink.on('a', (data) => {
            expect(data.foo).toBe('bar');
        });
        eventedSource.fire(new StubbedEvent('a', {foo: 'bar'}));

    });

    test('attaches parent data to parent listeners', () => {
        const eventedSource = new StubbedEvented();
        const eventedSink = new StubbedEvented();
        eventedSource.setEventedParent(eventedSink, {foz: 'baz'});
        eventedSink.on('a', (data) => {
            expect(data.foz).toBe('baz');
        });
        eventedSource.fire(new StubbedEvent('a', {foo: 'bar'}));

    });

    test('attaches parent data from a function to parent listeners', () => {
        const eventedSource = new StubbedEvented();
        const eventedSink = new StubbedEvented();
        eventedSource.setEventedParent(eventedSink, () => ({foz: 'baz'}));
        eventedSink.on('a', (data) => {
            expect(data.foz).toBe('baz');
        });
        eventedSource.fire(new StubbedEvent('a', {foo: 'bar'}));

    });

    test('passes original "target" to parent listeners', () => {
        const eventedSource = new StubbedEvented();
        const eventedSink = new StubbedEvented();
        eventedSource.setEventedParent(eventedSink);
        eventedSource.setEventedParent(null);
        eventedSink.on('a', (data) => {
            expect(data.target).toBe(eventedSource);
        });
        eventedSource.fire(new StubbedEvent('a'));

    });

    test('removes parents with "setEventedParent(null)"', () => {
        const listener = vi.fn();
        const eventedSource = new StubbedEvented();
        const eventedSink = new StubbedEvented();
        eventedSink.on('a', listener);
        eventedSource.setEventedParent(eventedSink);
        eventedSource.setEventedParent(null);
        eventedSource.fire(new StubbedEvent('a'));
        expect(listener).not.toHaveBeenCalled();
    });

    test('reports if an event has parent listeners with "listens"', () => {
        const eventedSource = new StubbedEvented();
        const eventedSink = new StubbedEvented();
        eventedSink.on('a', () => {});
        eventedSource.setEventedParent(eventedSink);
        expect(eventedSink.listens('a')).toBeTruthy();

    });

    test('eventedParent data function is evaluated on every fire', () => {
        const eventedSource = new StubbedEvented();
        const eventedParent = new StubbedEvented();
        let i = 0;
        eventedSource.setEventedParent(eventedParent, () => ({counter: i++}));
        eventedSource.on('a', () => {});
        eventedSource.fire(new StubbedEvent('a'));
        expect(i).toBe(1);
        eventedSource.fire(new StubbedEvent('a'));
        expect(i).toBe(2);

    });
});
