import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest';
import {Map, type MapOptions} from '../map';
import {createMap, beforeMapTest, createStyle, createStyleSource, sleep} from '../../util/test/util';
import {Event as EventedEvent} from '../../util/evented';
import {fixedLngLat, fixedNum} from '../../../test/unit/lib/fixed';
import {extend} from '../../util/util';
import {fakeServer, type FakeServer} from 'nise';
import {Style} from '../../style/style';
import {type GeoJSONSourceSpecification, type LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
import {LngLatBounds} from '../../geo/lng_lat_bounds';

let server: FakeServer;

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

afterEach(() => {
    server.restore();
});

describe('setStyle', () => {
    test('returns self', () => {
        const map = new Map({container: window.document.createElement('div')} as any as MapOptions);
        expect(map.setStyle({
            version: 8,
            sources: {},
            layers: []
        })).toBe(map);
    });

    test('sets up event forwarding', async () => {
        const map = createMap();
        await map.once('load');

        const events = [];
        function recordEvent(event) { events.push(event.type); }

        map.on('error', recordEvent);
        map.on('data', recordEvent);
        map.on('dataloading', recordEvent);

        map.style.fire(new EventedEvent('error'));
        map.style.fire(new EventedEvent('data'));
        map.style.fire(new EventedEvent('dataloading'));

        expect(events).toEqual([
            'error',
            'data',
            'dataloading',
        ]);
    });

    test('fires *data and *dataloading events', async () => {
        const map = createMap();
        await map.once('load');

        const events = [];
        function recordEvent(event) { events.push(event.type); }

        map.on('styledata', recordEvent);
        map.on('styledataloading', recordEvent);
        map.on('sourcedata', recordEvent);
        map.on('sourcedataloading', recordEvent);
        map.on('tiledata', recordEvent);
        map.on('tiledataloading', recordEvent);

        map.style.fire(new EventedEvent('data', {dataType: 'style'}));
        map.style.fire(new EventedEvent('dataloading', {dataType: 'style'}));
        map.style.fire(new EventedEvent('data', {dataType: 'source'}));
        map.style.fire(new EventedEvent('dataloading', {dataType: 'source'}));
        map.style.fire(new EventedEvent('data', {dataType: 'tile'}));
        map.style.fire(new EventedEvent('dataloading', {dataType: 'tile'}));

        expect(events).toEqual([
            'styledata',
            'styledataloading',
            'sourcedata',
            'sourcedataloading',
            'tiledata',
            'tiledataloading'
        ]);
    });

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

        map.setStyle({version: 8, sources: {}, layers: []}, {diff: false});
        map.setStyle({version: 8, sources: {}, layers: []}, {diff: false});

    });

    test('setStyle back to the first style should work', async () => {
        const redStyle = {version: 8 as const, sources: {}, layers: [
            {id: 'background', type: 'background' as const, paint: {'background-color': 'red'}},
        ]};
        const blueStyle = {version: 8 as const, sources: {}, layers: [
            {id: 'background', type: 'background' as const, paint: {'background-color': 'blue'}},
        ]};
        const map = createMap({style: redStyle});
        const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
        map.setStyle(blueStyle);
        await map.once('style.load');
        map.setStyle(redStyle);
        const serializedStyle =  map.style.serialize();
        expect(serializedStyle.layers[0].paint['background-color']).toBe('red');
        spy.mockRestore();
    });

    test('style transform overrides unmodified map transform', async () => {
        const map = new Map({container: window.document.createElement('div')} as any as MapOptions);
        map.transform.setMaxBounds(new LngLatBounds([-120, -60], [140, 80]));
        map.transform.resize(600, 400, true);
        expect(map.transform.zoom).toBe(0.6983039737971013);
        expect(map.transform.unmodified).toBeTruthy();
        map.setStyle(createStyle());
        await map.once('style.load');
        expect(fixedLngLat(map.transform.center)).toEqual(fixedLngLat({lng: -73.9749, lat: 40.7736}));
        expect(fixedNum(map.transform.zoom)).toBe(12.5);
        expect(fixedNum(map.transform.bearing)).toBe(29);
        expect(fixedNum(map.transform.pitch)).toBe(50);
    });

    test('style transform does not override map transform modified via options', async () => {
        const map = new Map({container: window.document.createElement('div'), zoom: 10, center: [-77.0186, 38.8888]} as any as MapOptions);
        expect(map.transform.unmodified).toBeFalsy();
        map.setStyle(createStyle());
        await map.once('style.load');
        expect(fixedLngLat(map.transform.center)).toEqual(fixedLngLat({lng: -77.0186, lat: 38.8888}));
        expect(fixedNum(map.transform.zoom)).toBe(10);
        expect(fixedNum(map.transform.bearing)).toBe(0);
        expect(fixedNum(map.transform.pitch)).toBe(0);
    });

    test('style transform does not override map transform modified via setters', async () => {
        const map = new Map({container: window.document.createElement('div')} as any as MapOptions);
        expect(map.transform.unmodified).toBeTruthy();
        map.setZoom(10);
        map.setCenter([-77.0186, 38.8888]);
        expect(map.transform.unmodified).toBeFalsy();
        map.setStyle(createStyle());
        map.once('style.load');
        expect(fixedLngLat(map.transform.center)).toEqual(fixedLngLat({lng: -77.0186, lat: 38.8888}));
        expect(fixedNum(map.transform.zoom)).toBe(10);
        expect(fixedNum(map.transform.bearing)).toBe(0);
        expect(fixedNum(map.transform.pitch)).toBe(0);
    });

    test('passing null removes style', () => {
        const map = createMap();
        const style = map.style;
        expect(style).toBeTruthy();
        vi.spyOn(style, '_remove');
        map.setStyle(null);
        expect(style._remove).toHaveBeenCalledTimes(1);
    });

    test('passing null releases the worker', () => {
        const map = createMap();
        const spyWorkerPoolAcquire = vi.spyOn(map.style.dispatcher.workerPool, 'acquire');
        const spyWorkerPoolRelease = vi.spyOn(map.style.dispatcher.workerPool, 'release');

        map.setStyle({version: 8, sources: {}, layers: []}, {diff: false});
        expect(spyWorkerPoolAcquire).toHaveBeenCalledTimes(1);
        expect(spyWorkerPoolRelease).toHaveBeenCalledTimes(0);

        spyWorkerPoolAcquire.mockClear();
        map.setStyle(null);
        expect(spyWorkerPoolAcquire).toHaveBeenCalledTimes(0);
        expect(spyWorkerPoolRelease).toHaveBeenCalledTimes(1);

        // Cleanup
        spyWorkerPoolAcquire.mockClear();
        spyWorkerPoolRelease.mockClear();
    });

    test('transformStyle should copy the source and the layer into next style', async () => {
        const style = extend(createStyle(), {
            sources: {
                maplibre: {
                    type: 'vector',
                    minzoom: 1,
                    maxzoom: 10,
                    tiles: ['http://example.com/{z}/{x}/{y}.png']
                }
            },
            layers: [{
                id: 'layerId0',
                type: 'circle',
                source: 'maplibre',
                'source-layer': 'sourceLayer'
            }, {
                id: 'layerId1',
                type: 'circle',
                source: 'maplibre',
                'source-layer': 'sourceLayer'
            }]
        });

        const map = createMap({style});
        map.setStyle(createStyle(), {
            diff: false,
            transformStyle: (prevStyle, nextStyle) => ({
                ...nextStyle,
                sources: {
                    ...nextStyle.sources,
                    maplibre: prevStyle.sources.maplibre
                },
                layers: [
                    ...nextStyle.layers,
                    prevStyle.layers[0]
                ]
            })
        });

        await map.once('style.load');
        const loadedStyle = map.style.serialize();
        expect('maplibre' in loadedStyle.sources).toBeTruthy();
        expect(loadedStyle.layers[0].id).toBe(style.layers[0].id);
        expect(loadedStyle.layers).toHaveLength(1);
    });

    test('delayed setStyle with transformStyle should copy the source and the layer into next style with diffing', async () => {
        const style = extend(createStyle(), {
            sources: {
                maplibre: {
                    type: 'vector',
                    minzoom: 1,
                    maxzoom: 10,
                    tiles: ['http://example.com/{z}/{x}/{y}.png']
                }
            },
            layers: [{
                id: 'layerId0',
                type: 'circle',
                source: 'maplibre',
                'source-layer': 'sourceLayer'
            }, {
                id: 'layerId1',
                type: 'circle',
                source: 'maplibre',
                'source-layer': 'sourceLayer'
            }]
        });

        const map = createMap({style});
        await sleep(100);

        map.setStyle(createStyle(), {
            diff: true,
            transformStyle: (prevStyle, nextStyle) => ({
                ...nextStyle,
                sources: {
                    ...nextStyle.sources,
                    maplibre: prevStyle.sources.maplibre
                },
                layers: [
                    ...nextStyle.layers,
                    prevStyle.layers[0]
                ]
            })
        });

        const loadedStyle = map.style.serialize();
        expect('maplibre' in loadedStyle.sources).toBeTruthy();
        expect(loadedStyle.layers[0].id).toBe(style.layers[0].id);
        expect(loadedStyle.layers).toHaveLength(1);
    });

    // in this special case, retrieval of the style JSON is not done by style but by map
    test('can asynchronously transform style JSON request specified to setStyle with diffing', async () => {
        server.respondWith('style.json', JSON.stringify(createStyle()));

        const style = extend(createStyle(), {
            sources: {
                maplibre: {
                    type: 'vector',
                    minzoom: 1,
                    maxzoom: 10,
                    tiles: ['http://example.com/{z}/{x}/{y}.png']
                }
            },
            layers: [{
                id: 'layerId0',
                type: 'circle',
                source: 'maplibre',
                'source-layer': 'sourceLayer'
            }, {
                id: 'layerId1',
                type: 'circle',
                source: 'maplibre',
                'source-layer': 'sourceLayer'
            }]
        });

        const map = createMap({style});
        const transformRequestSpy = vi.fn(async (url) => ({
            url,
            headers: {Authorization: 'Bearer token'}
        }));
        map.setTransformRequest(transformRequestSpy);
        await map.once('style.load');

        map.setStyle('style.json', {diff: true});
        await sleep(0);
        server.respond();
        await map.once('style.load');

        expect(transformRequestSpy).toHaveBeenCalledWith('style.json', 'Style');
        expect(server.requests[0].url).toBe('style.json');
        expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token');
    });

    test('transformStyle should get called when passed to setStyle after the map is initialised without a style', async () => {
        const map = createMap({deleteStyle: true});
        map.setStyle(createStyle(), {
            diff: true,
            transformStyle: (prevStyle, nextStyle) => {
                expect(prevStyle).toBeUndefined();

                return {
                    ...nextStyle,
                    sources: {
                        maplibre: {
                            type: 'vector',
                            minzoom: 1,
                            maxzoom: 10,
                            tiles: ['http://example.com/{z}/{x}/{y}.png']
                        }
                    },
                    layers: [{
                        id: 'layerId0',
                        type: 'circle',
                        source: 'maplibre',
                        'source-layer': 'sourceLayer'
                    }]
                };
            }
        });

        await map.once('style.load');
        const loadedStyle = map.style.serialize();
        expect('maplibre' in loadedStyle.sources).toBeTruthy();
        expect(loadedStyle.layers[0].id).toBe('layerId0');
    });

    test('stale style URL load does not complete after style is cleared during async transformRequest', async () => {
        server.respondWith('style.json', JSON.stringify(createStyle()));

        let resolveTransformRequest: (value: {url: string}) => void;
        const transformRequest = new Promise<{url: string}>((resolve) => {
            resolveTransformRequest = resolve;
        });

        const map = createMap({deleteStyle: true});
        const initialTransform = map.transform;
        const initialPainterTransform = map.painter.transform;
        const projectionTransitionSpy = vi.fn();
        map.on('projectiontransition', projectionTransitionSpy);
        map.setTransformRequest(() => transformRequest);

        map.setStyle('style.json', {diff: false});
        map.setStyle(null, {diff: false});

        resolveTransformRequest({url: 'style.json'});
        await sleep(0);
        server.respond();
        await sleep(0);

        expect(map.style).toBeUndefined();
        expect(projectionTransitionSpy).not.toHaveBeenCalled();
        expect(map.transform).toBe(initialTransform);
        expect(map.painter.transform).toBe(initialPainterTransform);
    });

    test('map load should be fired when transformStyle is used on setStyle after the map is initialised without a style', async () => {
        const map = createMap({deleteStyle: true});
        map.setStyle({version: 8, sources: {}, layers: []}, {
            diff: true,
            transformStyle: (prevStyle, nextStyle) => {
                expect(prevStyle).toBeUndefined();
                expect(nextStyle).toBeDefined();
                return createStyle();
            }
        });
        await map.once('load');
    });

    test('Override default style validation', () => {
        let validationOption = true;
        vi.spyOn(Style.prototype, 'loadJSON').mockImplementationOnce((styleJson, options) => {
            validationOption = options.validate;
        });
        const map = createMap({style: null});
        map.setStyle({version: 8, sources: {}, layers: []}, {validate: false});

        expect(validationOption).toBeFalsy();
    });
});

describe('getStyle', () => {
    test('returns undefined if the style has not loaded yet', () => {
        const style = createStyle();
        const map = createMap({style});
        expect(map.getStyle()).toBeUndefined();
    });

    test('returns the style', async () => {
        const style = createStyle();
        const map = createMap({style});

        await map.once('load');
        expect(map.getStyle()).toEqual(style);
    });

    test('returns the previous style even if modified', async () => {
        const style = {
            version: 8 as const,
            sources: {},
            layers: [
                {
                    id: 'background',
                    type: 'background' as const,
                    paint: {'background-color': 'blue'}
                },
            ]
        };

        const map = createMap({style});

        await map.once('load');
        const newStyle = map.getStyle();
        newStyle.layers[0].paint = {'background-color': 'red'};

        // map.getStyle() should still equal the original style since
        // we have not yet called map.setStyle(...).
        expect(map.getStyle()).toEqual(style);
    });

    test('returns the style with added sources', async () => {
        const style = createStyle();
        const map = createMap({style});

        await map.once('load');
        map.addSource('geojson', createStyleSource());
        expect(map.getStyle()).toEqual(extend(createStyle(), {
            sources: {geojson: createStyleSource()}
        }));
    });

    test('fires an error on checking if non-existent source is loaded', async () => {
        const style = createStyle();
        const map = createMap({style});

        await map.once('load');
        const errorPromise = map.once('error');
        map.isSourceLoaded('geojson');
        const error = await errorPromise;
        expect(error.error.message).toMatch(/There is no tile manager with ID/);
    });

    test('returns the style with added layers', async () => {
        const style = createStyle();
        const map = createMap({style});
        const layer = {
            id: 'background',
            type: 'background'
        } as LayerSpecification;

        await map.once('load');
        map.addLayer(layer);
        expect(map.getStyle()).toEqual(extend(createStyle(), {
            layers: [layer]
        }));
    });

    test('a layer can be added even if a map is created without a style', () => {
        const map = createMap({deleteStyle: true});
        const layer = {
            id: 'background',
            type: 'background'
        } as LayerSpecification;
        map.addLayer(layer);
    });

    test('a source can be added even if a map is created without a style', () => {
        const map = createMap({deleteStyle: true});
        const source = createStyleSource();
        map.addSource('fill', source);
    });

    test('a layer can be added with an embedded source specification', () => {
        const map = createMap({deleteStyle: true});
        const source: GeoJSONSourceSpecification = {
            type: 'geojson',
            data: {type: 'Point', coordinates: [0, 0]}
        };
        map.addLayer({
            id: 'foo',
            type: 'symbol',
            source
        });
    });

    test('returns the style with added source and layer', async () => {
        const style = createStyle();
        const map = createMap({style});
        const source = createStyleSource();
        const layer = {
            id: 'fill',
            type: 'fill',
            source: 'fill'
        } as LayerSpecification;

        await map.once('load');
        map.addSource('fill', source);
        map.addLayer(layer);
        expect(map.getStyle()).toEqual(extend(createStyle(), {
            sources: {fill: source},
            layers: [layer]
        }));
    });

    test('creates a new Style if diff fails', () => {
        const style = createStyle();
        const map = createMap({style});
        vi.spyOn(map.style, 'setState').mockImplementation(() => {
            throw new Error('Dummy error');
        });
        vi.spyOn(console, 'warn').mockImplementation(() => {});

        const previousStyle = map.style;
        map.setStyle(style);
        expect(map.style && map.style !== previousStyle).toBeTruthy();
    });

    test('creates a new Style if diff option is false', () => {
        const style = createStyle();
        const map = createMap({style});
        const spy = vi.spyOn(map.style, 'setState');

        const previousStyle = map.style;
        map.setStyle(style, {diff: false});
        expect(map.style && map.style !== previousStyle).toBeTruthy();
        expect(spy).not.toHaveBeenCalled();
    });

    describe('setSky', () => {
        test('calls style setSky when set', () => {
            const map = createMap();
            const spy = vi.fn();
            map.style.setSky = spy;
            map.setSky({'horizon-fog-blend': 0.5});

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

    describe('getSky', () => {
        test('returns undefined when not set', () => {
            const map = createMap();
            expect(map.getSky()).toBeUndefined();
        });
    });

    describe('setLight', () => {
        test('calls style setLight when set', () => {
            const map = createMap();
            const spy = vi.fn();
            map.style.setLight = spy;
            map.setLight({anchor: 'viewport'});

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

    describe('getLight', () => {
        test('calls style getLight when invoked', () => {
            const map = createMap();
            const spy = vi.fn();
            map.style.getLight = spy;
            map.getLight();

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

});
