import {describe, beforeEach, test, expect, vi} from 'vitest';
import simulate from '../../../test/unit/lib/simulate_interaction.ts';
import {type StyleLayer} from '../../style/style_layer.ts';
import {createMap, beforeMapTest, createStyle, sleep, createTerrain} from '../../util/test/util.ts';
import {type MapGeoJSONFeature} from '../../util/vectortile_to_geojson.ts';
import {type MapLibreEvent, MapSourceDataEvent} from '../events.ts';
import {Map} from '../map.ts';
import {ErrorEvent} from '../../util/evented.ts';
import {type StyleSpecification} from '@maplibre/maplibre-gl-style-spec';

type IsAny<T> = 0 extends T & 1 ? T : never;
type NotAny<T> = T extends IsAny<T> ? never : T;
function assertNotAny<T>(_x: NotAny<T>) { }

beforeEach(() => {
    beforeMapTest();
});

describe('map events', () => {

    test('Map.on adds a non-delegated event listener', () => {
        const map = createMap();
        const spy = vi.fn(function (e) {
            expect(this).toBe(map);
            expect(e.type).toBe('click');
        });

        map.on('click', spy);
        simulate.click(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.off removes a non-delegated event listener', () => {
        const map = createMap();
        const spy = vi.fn();

        map.on('click', spy);
        map.off('click', spy);
        simulate.click(map.getCanvas());

        expect(spy).not.toHaveBeenCalled();

    });

    test('Map.on adds a listener for an event on a given layer', () => {
        const map = createMap();
        const features = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((_point, options) => {
            expect(options).toEqual({layers: ['layer']});
            return features;
        });

        const spy = vi.fn(function (e) {
            expect(this).toBe(map);
            expect(e.type).toBe('click');
            expect(e.features).toBe(features);
        });

        map.on('click', 'layer', spy);
        simulate.click(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.on adds a listener for an event on multiple layers', () => {
        const map = createMap();
        const features = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures')
            .mockImplementationOnce((_point, options) => {
                expect(options).toEqual({layers: ['layer1', 'layer2']});
                return features;
            });

        const spy = vi.fn((e) => {
            expect(e.type).toBe('click');
            expect(e.features).toBe(features);
        });
        map.on('click', ['layer1', 'layer2'], spy);
        simulate.click(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.on adds a listener for an event on multiple layers and allows to unsubscribe', () => {
        const map = createMap();
        const features = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures')
            .mockImplementationOnce((_point, options) => {
                expect(options).toEqual({layers: ['layer1', 'layer2']});
                return features;
            });

        const spy = vi.fn();

        const subscription = map.on('click', ['layer1', 'layer2'], spy);
        subscription.unsubscribe();
        simulate.click(map.getCanvas());

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

    test('Map.on adds listener which calls queryRenderedFeatures only for existing layers', () => {
        const map = createMap();
        const features = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockImplementation((id: string) => {
            if (id === 'nonExistingLayer') {
                return undefined;
            }
            return {} as StyleLayer;
        });
        vi.spyOn(map, 'queryRenderedFeatures')
            .mockImplementationOnce((_point, options) => {
                expect(options).toEqual({layers: ['layer1', 'layer2']});
                return features;
            });

        const spy = vi.fn(function (e) {
            expect(this).toBe(map);
            expect(e.type).toBe('click');
            expect(e.features).toBe(features);
        });

        map.on('click', ['layer1', 'layer2', 'nonExistingLayer'], spy);
        simulate.click(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.on adds a listener not triggered for events not matching any features', () => {
        const map = createMap();
        const features = [];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((point, options) => {
            expect(options).toEqual({layers: ['layer']});
            return features;
        });

        const spy = vi.fn();

        map.on('click', 'layer', spy);
        simulate.click(map.getCanvas());

        expect(spy).not.toHaveBeenCalled();

    });

    test('Map.on adds a listener not triggered when the specified layer does not exist', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue(null);

        const spy = vi.fn();

        map.on('click', 'layer', spy);
        simulate.click(map.getCanvas());

        expect(spy).not.toHaveBeenCalled();

    });

    test('Map.on distinguishes distinct event types', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spyDown = vi.fn((e) => {
            expect(e.type).toBe('mousedown');
        });

        const spyUp = vi.fn((e) => {
            expect(e.type).toBe('mouseup');
        });

        map.on('mousedown', 'layer', spyDown);
        map.on('mouseup', 'layer', spyUp);
        simulate.click(map.getCanvas());

        expect(spyDown).toHaveBeenCalledTimes(1);
        expect(spyUp).toHaveBeenCalledTimes(1);
    });

    test('Map.on distinguishes distinct layers', () => {
        const map = createMap();
        const featuresA = [{} as MapGeoJSONFeature];
        const featuresB = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((_point, options) => {
            return (options as any).layers[0] === 'A' ? featuresA : featuresB;
        });

        const spyA = vi.fn((e) => {
            expect(e.features).toBe(featuresA);
        });

        const spyB = vi.fn((e) => {
            expect(e.features).toBe(featuresB);
        });

        map.on('click', 'A', spyA);
        map.on('click', 'B', spyB);
        simulate.click(map.getCanvas());

        expect(spyA).toHaveBeenCalledTimes(1);
        expect(spyB).toHaveBeenCalledTimes(1);
    });

    test('Map.on distinguishes distinct listeners', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spyA = vi.fn();
        const spyB = vi.fn();

        map.on('click', 'layer', spyA);
        map.on('click', 'layer', spyB);
        simulate.click(map.getCanvas());

        expect(spyA).toHaveBeenCalledTimes(1);
        expect(spyB).toHaveBeenCalledTimes(1);
    });

    test('Map.on calls an event listener with no type arguments, defaulting to \'unknown\' originalEvent type', () => {
        const map = createMap();

        const handler = {
            onMove: function onMove(_event: MapLibreEvent) {}
        };

        vi.spyOn(handler, 'onMove');

        map.on('move', (event) => handler.onMove(event));
        map.jumpTo({center: {lng: 10, lat: 10}});

        expect(handler.onMove).toHaveBeenCalledTimes(1);
    });

    test('Map.on allows a listener to infer the event type', () => {
        const map = createMap();

        const spy = vi.fn();
        map.on('mousemove', (event) => {
            assertNotAny(event);
            const {lng, lat} = event.lngLat;
            spy({lng, lat});
        });

        simulate.mousemove(map.getCanvas());
        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.off removes a delegated event listener', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.on('click', 'layer', spy);
        map.off('click', 'layer', spy);
        simulate.click(map.getCanvas());

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

    test('Map.off removes a delegated event listener for multiple layers', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.on('click', ['layer1', 'layer2'], spy);
        map.off('click', ['layer1', 'layer2'], spy);
        simulate.click(map.getCanvas());

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

    test('Map.off distinguishes distinct event types', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn((e) => {
            expect(e.type).toBe('mousedown');
        });

        map.on('mousedown', 'layer', spy);
        map.on('mouseup', 'layer', spy);
        map.off('mouseup', 'layer', spy);
        simulate.click(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.off distinguishes distinct layers', () => {
        const map = createMap();
        const featuresA = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((point, options) => {
            expect(options).toEqual({layers: ['A']});
            return featuresA;
        });

        const spy = vi.fn((e) => {
            expect(e.features).toBe(featuresA);
        });

        map.on('click', 'A', spy);
        map.on('click', 'B', spy);
        map.off('click', 'B', spy);
        simulate.click(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.off distinguishes distinct layer arrays', () => {
        const map = createMap();
        const featuresAB = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((point, options) => {
            expect(options).toEqual({layers: ['A', 'B']});
            return featuresAB;
        });

        const spy = vi.fn((e) => {
            expect(e.features).toBe(featuresAB);
        });

        map.on('click', ['A', 'B'], spy);
        map.on('click', ['A', 'C'], spy);
        map.off('click', ['A', 'C'], spy);
        simulate.click(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.off compares full layer array list, including layers missing in style', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockImplementation((id: string) => {
            if (id === 'nonExistingLayer') {
                return undefined;
            }
            return {} as StyleLayer;
        });
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.on('click', ['A', 'C', 'nonExistingLayer'], spy);
        map.off('click', ['A', 'C'], spy);

        simulate.click(map.getCanvas());

        map.off('click', ['A', 'C', 'nonExistingLayer'], spy);

        simulate.click(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.off distinguishes distinct listeners', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spyA = vi.fn();
        const spyB = vi.fn();

        map.on('click', 'layer', spyA);
        map.on('click', 'layer', spyB);
        map.off('click', 'layer', spyB);
        simulate.click(map.getCanvas());

        expect(spyA).toHaveBeenCalledTimes(1);
        expect(spyB).not.toHaveBeenCalled();
    });

    test('Map.off calls an event listener with no type arguments, defaulting to \'unknown\' originalEvent type', () => {
        const map = createMap();

        const handler = {
            onMove: function onMove(_event: MapLibreEvent) {}
        };

        vi.spyOn(handler, 'onMove');

        map.off('move', (event) => handler.onMove(event));
        map.jumpTo({center: {lng: 10, lat: 10}});

        expect(handler.onMove).toHaveBeenCalledTimes(0);
    });

    test('Map.off allows a listener to infer the event type', () => {
        const map = createMap();

        const spy = vi.fn();
        map.off('mousemove', (event) => {
            assertNotAny(event);
            const {lng, lat} = event.lngLat;
            spy({lng, lat});
        });

        simulate.mousemove(map.getCanvas());
        expect(spy).toHaveBeenCalledTimes(0);
    });

    test('Map.once calls an event listener with no type arguments, defaulting to \'unknown\' originalEvent type', () => {
        const map = createMap();

        const handler = {
            onMoveOnce: function onMoveOnce(_event: MapLibreEvent) {}
        };

        vi.spyOn(handler, 'onMoveOnce');

        map.once('move', (event) => handler.onMoveOnce(event));
        map.jumpTo({center: {lng: 10, lat: 10}});

        expect(handler.onMoveOnce).toHaveBeenCalledTimes(1);
    });

    test('Map.once allows a listener to infer the event type', () => {
        const map = createMap();

        const spy = vi.fn();
        map.once('mousemove', (event) => {
            assertNotAny(event);
            const {lng, lat} = event.lngLat;
            spy({lng, lat});
        });

        simulate.mousemove(map.getCanvas());
        expect(spy).toHaveBeenCalledTimes(1);
    });

    test('Map.off removes listener registered with Map.once', () => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.once('click', 'layer', spy);
        map.off('click', 'layer', spy);
        simulate.click(map.getCanvas());

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

    const mouseInteractionEvents = ['mouseenter', 'mouseover'] as const;
    test.each(mouseInteractionEvents)('Map.on %s does not fire if the specified layer does not exist', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue(null);

        const spy = vi.fn();

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

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

    test.each(mouseInteractionEvents)('Map.on %s fires when entering the specified layer', (event) => {
        const map = createMap();
        const features = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((_point, options) => {
            expect(options).toEqual({layers: ['layer']});
            return features;
        });

        const spy = vi.fn(function (e) {
            expect(this).toBe(map);
            expect(e.type).toBe(event);
            expect(e.target).toBe(map);
            expect(e.features).toBe(features);
        });

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test.each(mouseInteractionEvents)('Map.on %s does not fire on mousemove within the specified layer', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test.each(mouseInteractionEvents)('Map.on %s fires when reentering the specified layer', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures')
            .mockReturnValueOnce([{} as MapGeoJSONFeature])
            .mockReturnValueOnce([])
            .mockReturnValueOnce([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(2);
    });

    test.each(mouseInteractionEvents)('Map.on %s fires when reentering the specified layer after leaving the canvas', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mouseout(map.getCanvas());
        simulate.mousemove(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(2);
    });

    test.each(mouseInteractionEvents)('Map.on %s distinguishes distinct layers', (event) => {
        const map = createMap();
        const featuresA = [{} as MapGeoJSONFeature];
        const featuresB = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((_point, options) => {
            return (options as any).layers[0] === 'A' ? featuresA : featuresB;
        });

        const spyA = vi.fn((e) => {
            expect(e.features).toBe(featuresA);
        });

        const spyB = vi.fn((e) => {
            expect(e.features).toBe(featuresB);
        });

        map.on(event, 'A', spyA);
        map.on(event, 'B', spyB);

        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

        expect(spyA).toHaveBeenCalledTimes(1);
        expect(spyB).toHaveBeenCalledTimes(1);
    });

    test.each(mouseInteractionEvents)('Map.on %s distinguishes distinct layers when multiple layers provided', (event) => {
        const map = createMap();

        const nonEmptyFeatures = [{} as MapGeoJSONFeature];
        const emptyFeatures = [];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((_point, options) => {
            const layers = (options as any).layers as string[];
            if (layers.includes('A')) {
                return nonEmptyFeatures;
            }
            return emptyFeatures;
        });

        const spyA = vi.fn();
        const spyAB = vi.fn();
        const spyC = vi.fn();

        map.on(event, 'A', spyA);
        map.on(event, ['A', 'B'], spyAB);
        map.on(event, 'C', spyC);

        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

        expect(spyA).toHaveBeenCalledTimes(1);
        expect(spyAB).toHaveBeenCalledTimes(1);
        expect(spyC).not.toHaveBeenCalled();
    });

    test.each(mouseInteractionEvents)('Map.on %s filters non-existing layers', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockImplementation((id: string) => id === 'B' ? undefined : {} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((_point, options) => {
            expect((options as any).layers).toStrictEqual(['A', 'C']);
            return [{} as MapGeoJSONFeature];
        });

        const spyAC = vi.fn();

        map.on(event, ['A', 'B', 'C'], spyAC);

        simulate.mousemove(map.getCanvas());

        expect(map.queryRenderedFeatures).toHaveBeenCalled();
        expect(spyAC).toHaveBeenCalledTimes(1);
    });

    test.each(mouseInteractionEvents)('Map.on %s distinguishes distinct listeners', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spyA = vi.fn();
        const spyB = vi.fn();

        map.on(event, 'layer', spyA);
        map.on(event, 'layer', spyB);
        simulate.mousemove(map.getCanvas());

        expect(spyA).toHaveBeenCalledTimes(1);
        expect(spyB).toHaveBeenCalledTimes(1);
    });

    test.each(mouseInteractionEvents)('Map.off %s removes a delegated event listener', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.on(event, 'layer', spy);
        map.off(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());

        expect(spy).not.toHaveBeenCalled();

    });

    test.each(mouseInteractionEvents)('Map.off %s distinguishes distinct layers', (event) => {
        const map = createMap();
        const featuresA = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((_point, options) => {
            expect(options).toEqual({layers: ['A']});
            return featuresA;
        });

        const spy = vi.fn((e) => {
            expect(e.features).toBe(featuresA);
        });

        map.on(event, 'A', spy);
        map.on(event, 'B', spy);
        map.off(event, 'B', spy);
        simulate.mousemove(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test.each(mouseInteractionEvents)('Map.off %s distinguishes distinct layers when multiple layers provided', (event) => {
        const map = createMap();
        const featuresAB = [{} as MapGeoJSONFeature];

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockImplementation((_point, options) => {
            expect(options).toEqual({layers: ['A', 'B']});
            return featuresAB;
        });

        const spy = vi.fn((e) => {
            expect(e.features).toBe(featuresAB);
        });

        map.on(event, ['A', 'B'], spy);
        map.on(event, ['B', 'C'], spy);
        map.off(event, ['B', 'C'], spy);
        simulate.mousemove(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
        expect(map.queryRenderedFeatures).toHaveBeenCalledTimes(1);
    });

    test.each(mouseInteractionEvents)('Map.off %s distinguishes distinct listeners', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spyA = vi.fn();
        const spyB = vi.fn();

        map.on(event, 'layer', spyA);
        map.on(event, 'layer', spyB);
        map.off(event, 'layer', spyB);
        simulate.mousemove(map.getCanvas());

        expect(spyA).toHaveBeenCalledTimes(1);
        expect(spyB).not.toHaveBeenCalled();
    });

    const mouseoutEvents = ['mouseleave', 'mouseout'] as const;
    test.each(mouseoutEvents)('Map.on %s does not fire if the specified layer does not exist', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue(undefined);
        vi.spyOn(map, 'queryRenderedFeatures');

        const spy = vi.fn();

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

        expect(spy).not.toHaveBeenCalled();
        expect(map.queryRenderedFeatures).not.toHaveBeenCalled();
    });

    test.each(mouseoutEvents)('Map.on %s fires if one of specified layers exists', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockImplementation((id: string) => id === 'A' ? {} as StyleLayer : undefined);
        vi.spyOn(map, 'queryRenderedFeatures')
            .mockReturnValueOnce([{} as MapGeoJSONFeature])
            .mockReturnValueOnce([]);

        const spy = vi.fn();

        map.on(event, ['A', 'B'], spy);
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test.each(mouseoutEvents)('Map.on %s does not fire on mousemove when entering or within the specified layer', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn();

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

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

    test.each(mouseoutEvents)('Map.on %s fires when exiting the specified layer', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures')
            .mockReturnValueOnce([{} as MapGeoJSONFeature])
            .mockReturnValueOnce([]);

        const spy = vi.fn(function (e) {
            expect(this).toBe(map);
            expect(e.type).toBe(event);
            expect(e.features).toBeUndefined();
        });

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test.each(mouseoutEvents)('Map.on %s fires when exiting the canvas', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures').mockReturnValue([{} as MapGeoJSONFeature]);

        const spy = vi.fn(function (e) {
            expect(this).toBe(map);
            expect(e.type).toBe(event);
            expect(e.features).toBeUndefined();
        });

        map.on(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mouseout(map.getCanvas());

        expect(spy).toHaveBeenCalledTimes(1);
    });

    test.each(mouseoutEvents)('Map.off %s removes a delegated event listener', (event) => {
        const map = createMap();

        vi.spyOn(map, 'getLayer').mockReturnValue({} as StyleLayer);
        vi.spyOn(map, 'queryRenderedFeatures')
            .mockReturnValueOnce([{} as MapGeoJSONFeature])
            .mockReturnValueOnce([]);

        const spy = vi.fn();

        map.on(event, 'layer', spy);
        map.off(event, 'layer', spy);
        simulate.mousemove(map.getCanvas());
        simulate.mousemove(map.getCanvas());
        simulate.mouseout(map.getCanvas());

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

    test('Map.on mousedown can have default behavior prevented and still fire subsequent click event', () => {
        const map = createMap();

        map.on('mousedown', e => { e.preventDefault(); });

        const click = vi.fn();
        map.on('click', click);

        simulate.click(map.getCanvas());
        expect(click).toHaveBeenCalled();

        map.remove();
    });

    test('Map.on mousedown doesn\'t fire subsequent click event if mousepos changes', () => {
        const map = createMap();

        map.on('mousedown', e => { e.preventDefault(); });

        const click = vi.fn();
        map.on('click', click);
        const canvas = map.getCanvas();

        simulate.drag(canvas, {}, {clientX: 100, clientY: 100});
        expect(click).not.toHaveBeenCalled();

        map.remove();
    });

    test('Map.on mousedown fires subsequent click event if mouse position changes less than click tolerance', () => {
        const map = createMap({clickTolerance: 4});

        map.on('mousedown', e => { e.preventDefault(); });

        const click = vi.fn();
        map.on('click', click);
        const canvas = map.getCanvas();

        simulate.drag(canvas, {clientX: 100, clientY: 100}, {clientX: 100, clientY: 103});
        expect(click).toHaveBeenCalled();

        map.remove();
    });

    test('Map.on mousedown does not fire subsequent click event if mouse position changes more than click tolerance', () => {
        const map = createMap({clickTolerance: 4});

        map.on('mousedown', e => { e.preventDefault(); });

        const click = vi.fn();
        map.on('click', click);
        const canvas = map.getCanvas();

        simulate.drag(canvas, {clientX: 100, clientY: 100}, {clientX: 100, clientY: 104});
        expect(click).not.toHaveBeenCalled();

        map.remove();
    });

    test('Map.on click fires subsequent click event if there is no corresponding mousedown/mouseup event', () => {
        const map = createMap({clickTolerance: 4});

        const click = vi.fn();
        map.on('click', click);
        const canvas = map.getCanvas();

        const event = new MouseEvent('click', {bubbles: true, clientX: 100, clientY: 100});
        canvas.dispatchEvent(event);
        expect(click).toHaveBeenCalled();

        map.remove();
    });

    test('Map.isMoving() returns false in mousedown/mouseup/click with no movement', () => {
        const map = createMap({interactive: true, clickTolerance: 4});
        let mousedown, mouseup, click;
        map.on('mousedown', () => mousedown = map.isMoving());
        map.on('mouseup', () => mouseup = map.isMoving());
        map.on('click', () => click = map.isMoving());

        const canvas = map.getCanvas();

        canvas.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, clientX: 100, clientY: 100}));
        expect(mousedown).toBe(false);
        map._renderTaskQueue.run();
        expect(mousedown).toBe(false);

        canvas.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, clientX: 100, clientY: 100}));
        expect(mouseup).toBe(false);
        map._renderTaskQueue.run();
        expect(mouseup).toBe(false);

        canvas.dispatchEvent(new MouseEvent('click', {bubbles: true, clientX: 100, clientY: 100}));
        expect(click).toBe(false);
        map._renderTaskQueue.run();
        expect(click).toBe(false);

        map.remove();
    });

    test('emits load event after a style is set', async () => {
        const map = new Map({container: window.document.createElement('div')});

        const failSpy = vi.fn();

        map.on('load', failSpy);
        await sleep(1);
        map.off('load', failSpy);
        const promise = map.once('load');
        map.setStyle(createStyle());

        await promise;
        expect(failSpy).not.toHaveBeenCalled();
    });

    test('errors inside load event are not suppressed', async () => {
        const map = new Map({container: window.document.createElement('div')});

        const loadHandler = vi.fn(() => {
            throw new Error('Error in load handler');
        });

        map.on('load', loadHandler);
        await sleep(1);

        expect(loadHandler).toThrow('Error in load handler');
    });

    test('no idle event during move', async () => {
        const style = createStyle();
        const map = createMap({style, fadeDuration: 0});
        await map.once('idle');
        map.zoomTo(0.5, {duration: 100});
        expect(map.isMoving()).toBeTruthy();
        await map.once('idle');
        expect(map.isMoving()).toBeFalsy();
    });

    test('fires sourcedataabort event on dataabort event', async () => {
        const map = createMap();
        const sourcePromise = map.once('sourcedataabort');
        map.fire(new MapSourceDataEvent('dataabort'));
        await expect(sourcePromise).resolves.toBeDefined();
    });

    test('getZoom on moveend is the same as after the map end moving, with terrain on', async () => {
        const map = createMap({interactive: true, clickTolerance: 4});
        await map.once('style.load');
        map.terrain = createTerrain();
        let actualZoom: number;
        map.on('moveend', () => {
            // this can't use a promise due to race condition
            actualZoom = map.getZoom();
        });
        const canvas = map.getCanvas();
        simulate.dragWithMove(canvas, {x: 100, y: 100}, {x: 100, y: 150});
        map._renderTaskQueue.run();

        expect(actualZoom).toBe(map.getZoom());
    });

    test('drag from center', () => {
        const map = createMap({interactive: true, clickTolerance: 4});
        map.on('moveend', () => {
            expect(map.getCenter().lng).toBeCloseTo(0, 10);
            expect(map.getCenter().lat).toBeCloseTo(33.13755119234696, 10);
            expect(map.getCenterElevation()).toBeCloseTo(0, 10);
        });
        const canvas = map.getCanvas();
        simulate.dragWithMove(canvas, {x: 100, y: 100}, {x: 100, y: 150});
        map._renderTaskQueue.run();
    });

    test('drag from off center', () => {
        const map = createMap({interactive: true, clickTolerance: 4});
        map.on('moveend', () => {
            expect(map.getCenter().lng).toBeCloseTo(0, 10);
            expect(map.getCenter().lat).toBeCloseTo(33.13755119234696, 10);
            expect(map.getCenterElevation()).toBeCloseTo(0, 10);
        });
        const canvas = map.getCanvas();
        simulate.dragWithMove(canvas, {x: 50, y: 50}, {x: 50, y: 100});
        map._renderTaskQueue.run();
    });

    describe('error event', () => {
        test('logs errors to console when it has NO listeners', () => {
            // to avoid seeing error in the console in Jest
            let stub = vi.spyOn(console, 'error').mockImplementation(() => {});
            const map = createMap();
            stub.mockReset();
            stub = vi.spyOn(console, 'error').mockImplementation(() => {});
            const error = new Error('test');
            map.fire(new ErrorEvent(error));
            expect(stub).toHaveBeenCalledTimes(1);
            expect(stub.mock.calls[0][0]).toBe(error);
        });

        test('calls listeners', async () => {
            const map = createMap();
            const error = new Error('test');
            const promise = map.once('error');
            map.fire(new ErrorEvent(error));
            const event = await promise;
            expect(event.error).toBe(error);
        });

        test('does not call listeners after unsubscribe', async () => {
            const map = createMap();
            const error = new Error('test');
            const spy = vi.fn();
            const subscription = map.on('error', spy);
            subscription.unsubscribe();
            map.fire(new ErrorEvent(error));
            expect(spy).not.toHaveBeenCalled();
        });

        test('throws error when request fails', async () => {
            const style: StyleSpecification = {
                ...createStyle(),
                sources: {
                    'source': {
                        type: 'vector',
                        url: 'maplibre://nonexistent'
                    }
                },
                layers: [
                    {
                        id: 'layer',
                        source: 'source',
                        type: 'fill',
                        'source-layer': 'test'
                    }
                ]
            };
            const map = createMap();
            const originalWarn = console.warn;
            console.warn = vi.fn();
            map.setStyle(style);

            const errorHandler = vi.fn();
            map.on('error', errorHandler);

            map.triggerRepaint();
            await sleep(100);

            expect(errorHandler).toHaveBeenCalledTimes(1);
            console.warn = originalWarn;
        });
    });

    test('emits load event when source TileJSON fails to load', async () => {
        const style: StyleSpecification = {
            ...createStyle(),
            sources: {
                'source': {
                    type: 'vector',
                    url: 'maplibre://nonexistent'
                }
            },
            layers: [
                {
                    id: 'layer',
                    source: 'source',
                    type: 'fill',
                    'source-layer': 'test'
                }
            ]
        };
        const map = createMap();
        map.setStyle(style);

        await map.once('load');
        expect(map.isStyleLoaded()).toBe(true);

        map.triggerRepaint();
        await map.once('idle');
    });

    describe('projectiontransition event', () => {
        test('projectiontransition events is fired when setProjection is called', async () => {
            const map = createMap();

            await map.once('load');

            const spy = vi.fn();
            map.on('projectiontransition', (e) => spy(e.newProjection));
            map.setProjection({
                type: 'globe',
            });
            map.setProjection({
                type: 'mercator',
            });
            expect(spy).toHaveBeenCalledTimes(2);
            expect(spy).toHaveBeenNthCalledWith(1, 'globe');
            expect(spy).toHaveBeenNthCalledWith(2, 'mercator');
        });
        test('projectiontransition is fired when globe transitions to mercator', async () => {
            const map = createMap();
            await map.once('load');

            const spy = vi.fn();
            map.on('projectiontransition', (e) => spy(e.newProjection));

            map.setProjection({
                type: 'globe',
            });
            map.setZoom(18);
            map.redraw();
            await sleep(550);
            map.redraw();
            map.setZoom(0);
            map.redraw();
            await sleep(550);
            map.redraw();
            map.setProjection({
                type: 'mercator',
            });

            expect(spy).toHaveBeenCalledTimes(2);
            expect(spy).toHaveBeenNthCalledWith(1, 'globe');
            expect(spy).toHaveBeenNthCalledWith(2, 'mercator');
        });
    });
});
