import {describe, beforeEach, test, expect, vi} from 'vitest';
import {Map, type MapOptions} from '../map';
import {createMap, beforeMapTest, createStyle, createStyleSource, sleep} from '../../util/test/util';
import {Tile} from '../../tile/tile';
import {OverscaledTileID} from '../../tile/tile_id';
import {fixedLngLat} from '../../../test/unit/lib/fixed';
import {type RequestTransformFunction, ResourceType} from '../../util/request_manager';
import {type MapSourceDataEvent} from '../events';
import {MessageType} from '../../util/actor_messages';
import {Style} from '../../style/style';

beforeEach(() => {
    beforeMapTest();
    global.fetch = null;
});

describe('Map', () => {

    test('version', () => {
        const map = createMap({interactive: true, style: null});

        expect(typeof map.version === 'string').toBeTruthy();

        // Semver regex: https://gist.github.com/jhorsman/62eeea161a13b80e39f5249281e17c39
        // Backslashes are doubled to escape them
        const regexp = new RegExp('^([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+[0-9A-Za-z-]+)?$');
        expect(regexp.test(map.version)).toBeTruthy();
    });

    test('constructor', () => {
        const map = createMap({interactive: true, style: null});
        expect(map.getContainer()).toBeTruthy();
        expect(map.getStyle()).toBeUndefined();
        expect(map.boxZoom.isEnabled()).toBeTruthy();
        expect(map.doubleClickZoom.isEnabled()).toBeTruthy();
        expect(map.dragPan.isEnabled()).toBeTruthy();
        expect(map.dragRotate.isEnabled()).toBeTruthy();
        expect(map.keyboard.isEnabled()).toBeTruthy();
        expect(map.scrollZoom.isEnabled()).toBeTruthy();
        expect(map.touchZoomRotate.isEnabled()).toBeTruthy();
        expect(() => {
            new Map({
                container: 'anElementIdWhichDoesNotExistInTheDocument'
            } as any as MapOptions);
        }).toThrow(
            new Error('Container \'anElementIdWhichDoesNotExistInTheDocument\' not found.')
        );
    });

    test('bad map-specific token breaks map', () => {
        const container = window.document.createElement('div');
        Object.defineProperty(container, 'offsetWidth', {value: 512});
        Object.defineProperty(container, 'offsetHeight', {value: 512});
        createMap();
        //t.error();
    });

    describe('setTransformRequest', () => {
        test('returns self', () => {
            const transformRequest = (() => {}) as any as RequestTransformFunction;
            const map = new Map({container: window.document.createElement('div')} as any as MapOptions);
            expect(map.setTransformRequest(transformRequest)).toBe(map);
            expect(map._requestManager._transformRequestFn).toBe(transformRequest);
        });

        test('can be called more than once', () => {
            const map = createMap();

            const transformRequest = (() => {}) as any as RequestTransformFunction;
            map.setTransformRequest(transformRequest);
            map.setTransformRequest(transformRequest);
        });

        test('removes function when called with null', () => {
            const map = createMap();

            const transformRequest = vi.fn();
            map.setTransformRequest(transformRequest);
            map.setTransformRequest(null);
            map._requestManager.transformRequest('', ResourceType.Unknown);
            expect(transformRequest).not.toHaveBeenCalled();
        });
    });

    describe('is_Loaded', () => {

        test('Map.isSourceLoaded', async () => {
            const style = createStyle();
            const map = createMap({style});

            await map.once('load');
            const promise = new Promise<void>((resolve) => {
                map.on('data', (e) => {
                    if (e.dataType === 'source' && e.sourceDataType === 'idle') {
                        expect(map.isSourceLoaded('geojson')).toBe(true);
                        resolve();
                    }
                });
            });
            map.addSource('geojson', createStyleSource());
            expect(map.isSourceLoaded('geojson')).toBe(false);
            await promise;
        });

        test('Map.isSourceLoaded (equivalent to event.isSourceLoaded)', async () => {
            const style = createStyle();
            const map = createMap({style});

            await map.once('load');
            const promise = new Promise<void>((resolve) => {
                map.on('data', (e: MapSourceDataEvent) => {
                    if (e.dataType === 'source' && 'source' in e) {
                        expect(map.isSourceLoaded('geojson')).toBe(e.isSourceLoaded);
                        if (e.sourceDataType === 'idle') {
                            resolve();
                        }
                    }
                });
            });
            map.addSource('geojson', createStyleSource());
            expect(map.isSourceLoaded('geojson')).toBe(false);
            await promise;
        });

        test('Map.isStyleLoaded', async () => {
            const style = createStyle();
            const map = createMap({style});

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

        test('Map.areTilesLoaded', async () => {
            const style = createStyle();
            const map = createMap({style});
            expect(map.areTilesLoaded()).toBe(true);
            await map.once('load');
            const fakeTileId = new OverscaledTileID(0, 0, 0, 0, 0);
            map.addSource('geojson', createStyleSource());
            map.style.tileManagers.geojson._inViewTiles.setTile(fakeTileId.key, new Tile(fakeTileId, undefined));
            expect(map.areTilesLoaded()).toBe(false);
            map.style.tileManagers.geojson._inViewTiles.getTileById(fakeTileId.key).state = 'loaded';
            expect(map.areTilesLoaded()).toBe(true);
        });
    });

    test('remove', () => {
        const map = createMap();
        const spyWorkerPoolRelease = vi.spyOn(map.style.dispatcher.workerPool, 'release');
        expect(map.getContainer().childNodes).toHaveLength(2);
        map.remove();
        expect(spyWorkerPoolRelease).toHaveBeenCalledTimes(1);
        expect(map.getContainer().childNodes).toHaveLength(0);

        // Cleanup
        spyWorkerPoolRelease.mockClear();
    });
    
    test('remove while style is loading via URL does not crash', async () => {
        global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(createStyle())));
        let loadURLPromise: Promise<void>;
        const originalLoadURL = Style.prototype.loadURL;
        const loadURLSpy = vi.spyOn(Style.prototype, 'loadURL').mockImplementation(function (...args) {
            loadURLPromise = originalLoadURL.apply(this, args);
            return loadURLPromise;
        });
        const map = createMap({style: 'https://example.com/style.json'});
        const onErrorFired = vi.fn();
        map.on('error', onErrorFired);
        map.remove();
        await loadURLPromise;
        expect(onErrorFired).not.toHaveBeenCalled();
        loadURLSpy.mockRestore();
    });

    test('remove while setStyle is fetching a new style via URL does not crash', async () => {
        const style = createStyle();
        let resolveFetch: (value: Response) => void;
        const fetchPromise = new Promise<Response>(resolve => { resolveFetch = resolve; });
        global.fetch = vi.fn()
            .mockResolvedValueOnce(new Response(JSON.stringify(style)))
            .mockReturnValueOnce(fetchPromise);
        const map = createMap({style});
        await map.once('style.load');
        const onError = vi.fn();
        map.on('error', onError);
        map.setStyle('https://example.com/style.json');
        map.remove();
        resolveFetch(new Response(JSON.stringify(style)));
        await fetchPromise;
        expect(onError).not.toHaveBeenCalled();
    });

    test('second setStyle with URL aborts the first', async () => {
        const style = createStyle();
        const map = createMap({style});
        await map.once('style.load');
        const abortControllers: AbortController[] = [];
        const getJSONSpy = vi.spyOn(await import('../../util/ajax'), 'getJSON')
            .mockImplementation((_req, abortController) => {
                abortControllers.push(abortController);
                return Promise.resolve({data: style, cacheControl: null, expires: null});
            });
        map.setStyle('https://example.com/style1.json');
        const firstDiffRequest = map._diffStyleRequest;
        map.setStyle('https://example.com/style2.json');
        expect(firstDiffRequest.signal.aborted).toBe(true);
        await sleep(0);
        expect(abortControllers).toHaveLength(1);
        expect(abortControllers[0].signal.aborted).toBe(false);
        getJSONSpy.mockRestore();
    });

    test('setStyle with object aborts a pending diff URL fetch', async () => {
        const style = createStyle();
        let resolveFetch: (value: Response) => void;
        const fetchPromise = new Promise<Response>(resolve => { resolveFetch = resolve; });
        global.fetch = vi.fn()
            .mockResolvedValueOnce(new Response(JSON.stringify(style)))
            .mockReturnValueOnce(fetchPromise);
        const map = createMap({style});
        await map.once('style.load');
        const onError = vi.fn();
        map.on('error', onError);
        map.setStyle('https://example.com/style.json');
        const diffRequest = map._diffStyleRequest;
        map.setStyle(createStyle());
        expect(diffRequest.signal.aborted).toBe(true);
        resolveFetch(new Response(JSON.stringify(style)));
        await fetchPromise;
        expect(onError).not.toHaveBeenCalled();
    });

    test('setStyle with diff:false aborts a pending diff fetch', async () => {
        const style = createStyle();
        let resolveFetch: (value: Response) => void;
        const fetchPromise = new Promise<Response>(resolve => { resolveFetch = resolve; });
        global.fetch = vi.fn()
            .mockResolvedValueOnce(new Response(JSON.stringify(style)))
            .mockReturnValueOnce(fetchPromise);
        const map = createMap({style});
        await map.once('style.load');
        const onError = vi.fn();
        map.on('error', onError);
        map.setStyle('https://example.com/style.json');
        const diffRequest = map._diffStyleRequest;
        map.setStyle(createStyle(), {diff: false});
        expect(diffRequest.signal.aborted).toBe(true);
        resolveFetch(new Response(JSON.stringify(style)));
        await fetchPromise;
        expect(onError).not.toHaveBeenCalled();
    });

    test('setStyle with null aborts a pending diff fetch', async () => {
        const style = createStyle();
        let resolveFetch: (value: Response) => void;
        const fetchPromise = new Promise<Response>(resolve => { resolveFetch = resolve; });
        global.fetch = vi.fn()
            .mockResolvedValueOnce(new Response(JSON.stringify(style)))
            .mockReturnValueOnce(fetchPromise);
        const map = createMap({style});
        await map.once('style.load');
        const onError = vi.fn();
        map.on('error', onError);
        map.setStyle('https://example.com/style.json');
        const diffRequest = map._diffStyleRequest;
        map.setStyle(null);
        expect(diffRequest.signal.aborted).toBe(true);
        resolveFetch(new Response(JSON.stringify(style)));
        await fetchPromise;
        expect(onError).not.toHaveBeenCalled();
    });

    test('remove calls onRemove on added controls', () => {
        const map = createMap();
        const control = {
            onRemove: vi.fn(),
            onAdd(_) {
                return window.document.createElement('div');
            }
        };
        map.addControl(control);
        map.remove();
        expect(control.onRemove).toHaveBeenCalledTimes(1);
    });

    test('remove calls onRemove on added controls before style is destroyed', async () => {
        const map = createMap();
        let onRemoveCalled = 0;
        let style = null;
        const control = {
            onRemove(map) {
                onRemoveCalled++;
                expect(map.getStyle()).toEqual(style);
            },
            onAdd(_) {
                return window.document.createElement('div');
            }
        };

        map.addControl(control);

        map.once('style.load');
        style = map.getStyle();
        map.remove();
        expect(onRemoveCalled).toBe(1);
    });

    test('remove broadcasts removeMap to worker', () => {
        const map = createMap();
        const _broadcastSpyOn = vi.spyOn(map.style.dispatcher, 'broadcast');
        map.remove();
        expect(_broadcastSpyOn).toHaveBeenCalledWith(MessageType.removeMap, undefined);
    });

    test('project', () => {
        const map = createMap();
        expect(map.project([0, 0])).toEqual({x: 100, y: 100});
    });

    test('unproject', () => {
        const map = createMap();
        expect(fixedLngLat(map.unproject([100, 100]))).toEqual({lng: 0, lat: 0});
    });

    describe('cooperativeGestures option', () => {
        test('cooperativeGesture container element is hidden from a11y tree', () => {
            const map = createMap({cooperativeGestures: true});
            expect(map.getContainer().querySelector('.maplibregl-cooperative-gesture-screen').getAttribute('aria-hidden')).toBeTruthy();
        });

        test('cooperativeGesture container element is not available when cooperativeGestures not initialized', () => {
            const map = createMap({cooperativeGestures: false});
            expect(map.getContainer().querySelector('.maplibregl-cooperative-gesture-screen')).toBeFalsy();
        });

        test('cooperativeGesture container element is not available when cooperativeGestures disabled', () => {
            const map = createMap({cooperativeGestures: true});
            map.cooperativeGestures.disable();
            expect(map.getContainer().querySelector('.maplibregl-cooperative-gesture-screen')).toBeFalsy();
        });
    });
});
