import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest';
import {Map} from '../map.ts';
import {DOM} from '../../util/dom.ts';
import simulate from '../../../test/unit/lib/simulate_interaction.ts';
import {beforeMapTest} from '../../util/test/util.ts';

function createMap() {
    return new Map({interactive: true, container: DOM.create('div', '', window.document.body)});
}

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

describe('map events', () => {
    test('MapEvent handler fires touch events with correct values', () => {
        const map = createMap();
        const target = map.getCanvas();

        const touchstart = vi.fn();
        const touchmove = vi.fn();
        const touchend = vi.fn();

        map.on('touchstart', touchstart);
        map.on('touchmove', touchmove);
        map.on('touchend', touchend);

        const touchesStart = [{target, identifier: 1, clientX: 0, clientY: 50}];
        const touchesMove = [{target, identifier: 1, clientX: 0, clientY: 60}];
        const touchesEnd = [{target, identifier: 1, clientX: 0, clientY: 60}];

        simulate.touchstart(map.getCanvas(), {touches: touchesStart, targetTouches: touchesStart});
        expect(touchstart).toHaveBeenCalledTimes(1);
        expect(touchstart.mock.calls[0][0].point).toEqual({x: 0, y: 50});
        expect(touchmove).toHaveBeenCalledTimes(0);
        expect(touchend).toHaveBeenCalledTimes(0);

        simulate.touchmove(map.getCanvas(), {touches: touchesMove, targetTouches: touchesMove});
        expect(touchstart).toHaveBeenCalledTimes(1);
        expect(touchmove).toHaveBeenCalledTimes(1);
        expect(touchmove.mock.calls[0][0].point).toEqual({x: 0, y: 60});
        expect(touchend).toHaveBeenCalledTimes(0);

        simulate.touchend(map.getCanvas(), {touches: [], targetTouches: [], changedTouches: touchesEnd});
        expect(touchstart).toHaveBeenCalledTimes(1);
        expect(touchmove).toHaveBeenCalledTimes(1);
        expect(touchend).toHaveBeenCalledTimes(1);
        expect(touchend.mock.calls[0][0].point).toEqual({x: 0, y: 60});

        map.remove();
    });

    test('MapEvent handler fires touchmove even while drag handler is active', () => {
        const map = createMap();
        const target = map.getCanvas();
        map.dragPan.enable();

        const touchstart = vi.fn();
        const touchmove = vi.fn();
        const touchend = vi.fn();
        const drag = vi.fn();

        map.on('touchstart', touchstart);
        map.on('touchmove', touchmove);
        map.on('touchend', touchend);
        map.on('drag', drag);

        const touchesStart = [{target, identifier: 1, clientX: 0, clientY: 50}];
        const touchesMove = [{target, identifier: 1, clientX: 0, clientY: 60}];
        const touchesEnd = [{target, identifier: 1, clientX: 0, clientY: 60}];

        simulate.touchstart(map.getCanvas(), {touches: touchesStart, targetTouches: touchesStart});
        expect(touchstart).toHaveBeenCalledTimes(1);
        expect(touchstart.mock.calls[0][0].point).toEqual({x: 0, y: 50});
        expect(touchmove).toHaveBeenCalledTimes(0);
        expect(touchend).toHaveBeenCalledTimes(0);

        simulate.touchmove(map.getCanvas(), {touches: touchesMove, targetTouches: touchesMove});
        expect(touchstart).toHaveBeenCalledTimes(1);
        expect(touchmove).toHaveBeenCalledTimes(1);
        expect(touchmove.mock.calls[0][0].point).toEqual({x: 0, y: 60});
        expect(touchend).toHaveBeenCalledTimes(0);

        simulate.touchend(map.getCanvas(), {touches: [], targetTouches: [], changedTouches: touchesEnd});
        expect(touchstart).toHaveBeenCalledTimes(1);
        expect(touchmove).toHaveBeenCalledTimes(1);
        expect(touchend).toHaveBeenCalledTimes(1);
        expect(touchend.mock.calls[0][0].point).toEqual({x: 0, y: 60});

        map._renderTaskQueue.run();
        expect(drag).toHaveBeenCalledTimes(1);

        map.remove();
    });

    test('MapEvent handler fires contextmenu on MacOS/Linux, but only at mouseup', () => {
        const map = createMap();
        const relatedTarget = map.getCanvas();
        map.dragPan.enable();

        const contextmenu = vi.fn();

        map.on('contextmenu', contextmenu);

        simulate.mousedown(map.getCanvas(), {relatedTarget, button: 2, clientX: 10, clientY: 10});
        simulate.contextmenu(map.getCanvas(), {relatedTarget}); // triggered immediately after mousedown
        expect(contextmenu).toHaveBeenCalledTimes(0);
        simulate.mouseup(map.getCanvas(), {relatedTarget, button: 2, clientX: 10, clientY: 10});
        expect(contextmenu).toHaveBeenCalledTimes(1);
    });

    test('MapEvent handler does not fire contextmenu on MacOS/Linux, when moved', () => {
        const map = createMap();
        const relatedTarget = map.getCanvas();
        map.dragPan.enable();

        const contextmenu = vi.fn();

        map.on('contextmenu', contextmenu);

        simulate.mousedown(map.getCanvas(), {relatedTarget, button: 2, clientX: 10, clientY: 10});
        simulate.contextmenu(map.getCanvas(), {relatedTarget}); // triggered immediately after mousedown
        simulate.mousemove(map.getCanvas(), {relatedTarget, buttons: 2, clientX: 50, clientY: 10});
        simulate.mouseup(map.getCanvas(), {relatedTarget, button: 2, clientX: 70, clientY: 10});
        expect(contextmenu).toHaveBeenCalledTimes(0);
    });

    test('MapEvent handler fires contextmenu on Windows', () => {
        const map = createMap();
        const relatedTarget = map.getCanvas();
        map.dragPan.enable();

        const contextmenu = vi.fn();

        map.on('contextmenu', contextmenu);

        simulate.mousedown(map.getCanvas(), {relatedTarget, button: 2, clientX: 10, clientY: 10});
        simulate.mouseup(map.getCanvas(), {relatedTarget, button: 2, clientX: 10, clientY: 10});
        expect(contextmenu).toHaveBeenCalledTimes(0);
        simulate.contextmenu(map.getCanvas(), {relatedTarget, button: 2, clientX: 10, clientY: 10}); // triggered only after mouseup
        expect(contextmenu).toHaveBeenCalledTimes(1);
    });

    test('MapEvent handler does not fire contextmenu on Windows, when moved', () => {
        const map = createMap();
        const relatedTarget = map.getCanvas();
        map.dragPan.enable();

        const contextmenu = vi.fn();

        map.on('contextmenu', contextmenu);

        simulate.mousedown(map.getCanvas(), {relatedTarget, button: 2, clientX: 10, clientY: 10});
        simulate.mousemove(map.getCanvas(), {relatedTarget, buttons: 2, clientX: 50, clientY: 10});
        simulate.mouseup(map.getCanvas(), {relatedTarget, button: 2, clientX: 50, clientY: 10});
        simulate.contextmenu(map.getCanvas(), {relatedTarget, button: 2, clientX: 10, clientY: 10}); // triggered only after mouseup
        expect(contextmenu).toHaveBeenCalledTimes(0);
    });

    describe('touch long press', () => {
        beforeEach(() => {
            vi.useFakeTimers();
        });

        afterEach(() => {
            vi.useRealTimers();
        });

        test('MapEvent handler fires a contextmenu after a single finger is held in place', () => {
            const map = createMap();
            const contextmenu = vi.fn();
            map.on('contextmenu', contextmenu);
            const touches = [{target: map.getCanvas(), clientX: 10, clientY: 10}];

            simulate.touchstart(map.getCanvas(), {touches, targetTouches: touches});
            expect(contextmenu).not.toHaveBeenCalled(); // still holding
            vi.advanceTimersByTime(500);
            expect(contextmenu).toHaveBeenCalledTimes(1); // long press elapsed
            expect(contextmenu.mock.calls[0][0].point).toBeTruthy();
        });

        test('MapEvent handler suppresses a native contextmenu during a touch so the long press does not double-fire', () => {
            const map = createMap();
            const relatedTarget = map.getCanvas();
            const contextmenu = vi.fn();
            map.on('contextmenu', contextmenu);
            const touches = [{target: map.getCanvas(), clientX: 10, clientY: 10}];

            simulate.touchstart(map.getCanvas(), {touches, targetTouches: touches});
            // Android fires a native contextmenu mid-press; it must be suppressed so
            // only the timer fires the map event.
            simulate.contextmenu(map.getCanvas(), {relatedTarget});
            vi.advanceTimersByTime(500);
            simulate.touchend(map.getCanvas(), {touches: [], targetTouches: [], changedTouches: touches});
            expect(contextmenu).toHaveBeenCalledTimes(1); // exactly once, from the timer
        });

        test('MapEvent handler does not fire a contextmenu on a short tap', () => {
            const map = createMap();
            const contextmenu = vi.fn();
            map.on('contextmenu', contextmenu);
            const touches = [{target: map.getCanvas(), clientX: 10, clientY: 10}];

            simulate.touchstart(map.getCanvas(), {touches, targetTouches: touches});
            simulate.touchend(map.getCanvas(), {touches: [], targetTouches: [], changedTouches: touches}); // lifts before the delay
            vi.advanceTimersByTime(500);
            expect(contextmenu).not.toHaveBeenCalled();
        });

        test('MapEvent handler cancels the long press when the finger moves further than the click tolerance (a pan)', () => {
            const map = createMap();
            const contextmenu = vi.fn();
            map.on('contextmenu', contextmenu);
            const start = [{target: map.getCanvas(), clientX: 10, clientY: 10}];
            const moved = [{target: map.getCanvas(), clientX: 40, clientY: 40}];

            simulate.touchstart(map.getCanvas(), {touches: start, targetTouches: start});
            simulate.touchmove(map.getCanvas(), {touches: moved, targetTouches: moved});
            vi.advanceTimersByTime(500);
            expect(contextmenu).not.toHaveBeenCalled();
        });

        test('MapEvent handler keeps the long press alive when the finger drifts within the click tolerance', () => {
            const map = createMap();
            const contextmenu = vi.fn();
            map.on('contextmenu', contextmenu);
            const start = [{target: map.getCanvas(), clientX: 10, clientY: 10}];
            const drifted = [{target: map.getCanvas(), clientX: 11, clientY: 11}]; // ~1.4px, under the default tolerance of 3

            simulate.touchstart(map.getCanvas(), {touches: start, targetTouches: start});
            simulate.touchmove(map.getCanvas(), {touches: drifted, targetTouches: drifted});
            vi.advanceTimersByTime(500);
            expect(contextmenu).toHaveBeenCalledTimes(1);
        });

        test('MapEvent handler fires the contextmenu once when the press is held past the delay and then dragged', () => {
            const map = createMap();
            const contextmenu = vi.fn();
            map.on('contextmenu', contextmenu);
            const start = [{target: map.getCanvas(), clientX: 10, clientY: 10}];
            const moved = [{target: map.getCanvas(), clientX: 40, clientY: 40}];

            simulate.touchstart(map.getCanvas(), {touches: start, targetTouches: start});
            vi.advanceTimersByTime(500);
            expect(contextmenu).toHaveBeenCalledTimes(1); // fired at the delay, before the drag
            // the drag afterwards pans as usual and must not fire a second contextmenu
            simulate.touchmove(map.getCanvas(), {touches: moved, targetTouches: moved});
            simulate.touchend(map.getCanvas(), {touches: [], targetTouches: [], changedTouches: moved});
            vi.advanceTimersByTime(500);
            expect(contextmenu).toHaveBeenCalledTimes(1);
        });

        test('MapEvent handler cancels the long press on touchcancel, and does not leak to the next tap', () => {
            const map = createMap();
            const contextmenu = vi.fn();
            map.on('contextmenu', contextmenu);
            const touches = [{target: map.getCanvas(), clientX: 10, clientY: 10}];

            simulate.touchstart(map.getCanvas(), {touches, targetTouches: touches});
            simulate.touchcancel(map.getCanvas(), {touches: [], targetTouches: [], changedTouches: touches});
            vi.advanceTimersByTime(500);
            expect(contextmenu).not.toHaveBeenCalled();
            // a following quick tap must not fire anything either
            simulate.touchstart(map.getCanvas(), {touches, targetTouches: touches});
            simulate.touchend(map.getCanvas(), {touches: [], targetTouches: [], changedTouches: touches});
            vi.advanceTimersByTime(500);
            expect(contextmenu).not.toHaveBeenCalled();
        });

        test('MapEvent handler does not start a long press for a two-finger touch (pinch)', () => {
            const map = createMap();
            const contextmenu = vi.fn();
            map.on('contextmenu', contextmenu);
            const two = [{target: map.getCanvas(), clientX: 10, clientY: 10}, {target: map.getCanvas(), clientX: 60, clientY: 60}];

            simulate.touchstart(map.getCanvas(), {touches: two, targetTouches: two});
            vi.advanceTimersByTime(500);
            expect(contextmenu).not.toHaveBeenCalled();
        });
    });

    test('MapMouseEvent constructor does not throw error with Event instance instead of MouseEvent as originalEvent param', () => {
        const map = createMap();
        const target = map.getCanvasContainer();

        expect(()=> {
            target.dispatchEvent(new Event('mousedown'));
            target.dispatchEvent(new Event('mouseup'));
            target.dispatchEvent(new Event('click'));
            target.dispatchEvent(new Event('dblclick'));
            target.dispatchEvent(new Event('mousemove'));
            target.dispatchEvent(new Event('mouseover'));
            target.dispatchEvent(new Event('mouseenter'));
            target.dispatchEvent(new Event('mouseleave'));
            target.dispatchEvent(new Event('mouseout'));
            target.dispatchEvent(new Event('contextmenu'));
            target.dispatchEvent(new Event('wheel'));

            target.dispatchEvent(new Event('touchstart'));
            target.dispatchEvent(new Event('touchmove'));
            target.dispatchEvent(new Event('touchmoveWindow'));
            target.dispatchEvent(new Event('touchend'));
            target.dispatchEvent(new Event('touchcancel'));
        }).not.toThrow();
    });
});
