import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest';
import fs from 'fs';
import path from 'path';
import Protobuf from 'pbf';
import {VectorTileWorkerSource} from '../source/vector_tile_worker_source';
import {StyleLayerIndex} from '../style/style_layer_index';
import {fakeServer, type FakeServer} from 'nise';
import {type IActor} from '../util/actor';
import {type TileParameters, type WorkerTileParameters, type WorkerTileResult, type WorkerTileWithData} from './worker_source';
import {WorkerTile} from './worker_tile';
import {setPerformance, sleep} from '../util/test/util';
import {ABORT_ERROR} from '../util/abort_error';
import {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings';
import {OverscaledTileID, CanonicalTileID} from '../tile/tile_id';
import {VectorTile} from '@mapbox/vector-tile';
import Point from '@mapbox/point-geometry';

describe('vector tile worker source', () => {
    const actor = {sendAsync: () => Promise.resolve({})} as IActor;
    let server: FakeServer;

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

    afterEach(() => {
        server.restore();
        vi.clearAllMocks();
    });
    test('VectorTileWorkerSource.abortTile aborts pending request', async () => {
        const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);

        const loadPromise = source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/abort'}
        } as any as WorkerTileParameters);

        const abortPromise = source.abortTile({
            source: 'source',
            uid: 0
        } as any as TileParameters);

        expect(source.tileState.loading).toEqual({});
        await expect(abortPromise).resolves.toBeFalsy();
        await expect(loadPromise).rejects.toThrow(expect.objectContaining({name: ABORT_ERROR}));
    });

    test('VectorTileWorkerSource.removeTile removes loaded tile', async () => {
        const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);

        source.tileState.loaded = {
            '0': {} as WorkerTile
        };

        await source.removeTile({
            source: 'source',
            uid: 0
        } as any as TileParameters);

        expect(source.tileState.loaded).toEqual({});
    });

    test('VectorTileWorkerSource.reloadTile reloads a previously-loaded tile', async () => {
        const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
        const parse = vi.fn().mockReturnValue(Promise.resolve({} as WorkerTileResult));

        source.tileState.loaded = {
            '0': {
                status: 'done',
                vectorTile: {},
                parse
            } as any as WorkerTile
        };

        const reloadPromise = source.reloadTile({uid: 0} as any as WorkerTileParameters);
        expect(parse).toHaveBeenCalledTimes(1);
        await expect(reloadPromise).resolves.toBeTruthy();
    });

    test('VectorTileWorkerSource.loadTile reparses tile if the reloadTile has been called during parsing', async () => {
        const rawTileData = new ArrayBuffer(0);

        const layerIndex = new StyleLayerIndex([{
            id: 'test',
            source: 'source',
            'source-layer': 'test',
            type: 'symbol',
            layout: {
                'icon-image': 'hello',
                'text-font': ['StandardFont-Bold'],
                'text-field': '{name}'
            }
        }]);

        const actor = {
            sendAsync: (message: {type: string; data: unknown}, abortController: AbortController) => {
                return new Promise((resolve, _reject) => {
                    const res = setTimeout(() => {
                        const response = message.type === 'getImages' ?
                            {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} :
                            {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}};
                        resolve(response);
                    }, 100);
                    abortController.signal.addEventListener('abort', () => {
                        clearTimeout(res);
                    });
                });
            }
        };

        const source = new VectorTileWorkerSource(actor, layerIndex, ['hello']);
        source.loadVectorTile = (_params, _rawData) => {
            return {
                vectorTile: {
                    layers: {
                        test: {
                            version: 2,
                            name: 'test',
                            extent: 8192,
                            length: 1,
                            feature: (featureIndex: number) => ({
                                extent: 8192,
                                type: 1,
                                id: featureIndex,
                                properties: {
                                    name: 'test'
                                },
                                loadGeometry () {
                                    return [[new Point(0, 0)]];
                                }
                            })
                        }
                    }
                },
                rawData: rawTileData
            };
        };

        server.respondWith(request => {
            request.respond(200, {'Content-Type': 'application/pbf'}, rawTileData as any);
        });

        source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/faketile.pbf'},
            subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision,
        } as any as WorkerTileParameters).then(() => expect(false).toBeTruthy());

        server.respond();

        // allow promise to run
        await sleep(0);

        const res = await source.reloadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision,
        } as any as WorkerTileParameters) as WorkerTileWithData;
        expect(res).toBeDefined();
        expect(res.rawTileData).toBeDefined();
        expect(res.rawTileData).toStrictEqual(rawTileData);
    });

    test('VectorTileWorkerSource.loadTile reparses tile if reloadTile is called during reparsing', async () => {
        const rawTileData = new ArrayBuffer(0);
        const loadVectorData = (_params, _rawData) => {
            return {
                vectorTile: new VectorTile(new Protobuf(rawTileData)),
                rawData: rawTileData
            };
        };

        const layerIndex = new StyleLayerIndex([{
            id: 'test',
            source: 'source',
            'source-layer': 'test',
            type: 'fill'
        }]);

        const source = new VectorTileWorkerSource(actor, layerIndex, []);
        source.loadVectorTile = loadVectorData;

        const parseWorkerTileMock = vi
            .spyOn(WorkerTile.prototype, 'parse')
            .mockImplementation(function(this: WorkerTile, _data, _layerIndex, _availableImages, _actor) {
                this.status = 'parsing';
                return new Promise((resolve) => {
                    setTimeout(() => resolve({} as WorkerTileResult), 20);
                });
            });

        server.respondWith(request => {
            request.respond(200, {'Content-Type': 'application/pbf'}, rawTileData as any);
        });

        const loadPromise = source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/faketile.pbf'}
        } as any as WorkerTileParameters);

        server.respond();

        // let the promise start
        await sleep(0);

        const res = await source.reloadTile({
            source: 'source',
            uid: '0',
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
        } as any as WorkerTileParameters);
        expect(res).toBeDefined();
        expect(parseWorkerTileMock).toHaveBeenCalledTimes(2);
        await expect(loadPromise).resolves.toBeTruthy();
    });

    test('VectorTileWorkerSource loadTile uses _getOverzoomTile when overzoomParameters is provided', async () => {
        const source = new VectorTileWorkerSource({} as any, new StyleLayerIndex(), []);
        const mockVectorTile = {layers: {}} as any;

        source.loadVectorTile = vi.fn().mockReturnValue({
            vectorTile: mockVectorTile,
            rawData: new ArrayBuffer(0)
        });

        const getOverzoomTileSpy = vi.spyOn(source as any, '_getOverzoomTile').mockReturnValue({
            vectorTile: mockVectorTile,
            rawData: new ArrayBuffer(0)
        });

        server.respondWith(request => {
            request.respond(200, {'Content-Type': 'application/pbf'}, new ArrayBuffer(0) as any);
        });

        const params = {
            uid: '1',
            tileID: new OverscaledTileID(16, 0, 16, 100, 100),
            source: 'test',
            overzoomParameters: {
                maxZoomTileID: new CanonicalTileID(14, 25, 25),
                overzoomRequest: {url: ''}
            }
        } as WorkerTileParameters;

        const promise = source.loadTile(params);
        server.respond();
        await promise;

        expect(getOverzoomTileSpy).toHaveBeenCalledWith(params, mockVectorTile);
    });

    test('VectorTileWorkerSource.reloadTile does not reparse tiles with no vectorTile data but does call callback', async () => {
        const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
        const parse = vi.fn();

        source.tileState.loaded = {
            '0': {
                status: 'done',
                parse
            } as any as WorkerTile
        };

        await source.reloadTile({uid: 0} as any as WorkerTileParameters);
        expect(parse).not.toHaveBeenCalled();
    });

    test('VectorTileWorkerSource.loadTile returns null for an empty tile', async () => {
        const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
        source.loadVectorTile = (_params, _rawData) => null;
        const parse = vi.fn();

        server.respondWith(request => {
            request.respond(200, {'Content-Type': 'application/pbf'}, 'something...');
        });

        const promise = source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/faketile.pbf'}
        } as any as WorkerTileParameters);

        server.respond();

        expect(parse).not.toHaveBeenCalled();
        expect(await promise).toBeNull();
    });

    test('VectorTileWorkerSource.returns a good error message when failing to parse a tile', async () => {
        const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
        const parse = vi.fn();

        server.respondWith(request => {
            request.respond(200, {'Content-Type': 'application/pbf'}, 'something...');
        });

        const loadTilePromise = source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/faketile.pbf'}
        } as any as WorkerTileParameters);

        server.respond();

        expect(parse).not.toHaveBeenCalled();
        await expect(loadTilePromise).rejects.toThrowError(/Unable to parse the tile at/);
    });

    test('VectorTileWorkerSource.returns a good error message when failing to parse a gzipped tile', async () => {
        const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);
        const parse = vi.fn();

        server.respondWith(new Uint8Array([0x1f, 0x8b]).buffer);

        const loadTilePromise = source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/faketile.pbf'}
        } as any as WorkerTileParameters);

        server.respond();

        expect(parse).not.toHaveBeenCalled();
        await expect(loadTilePromise).rejects.toThrowError(/gzipped/);
    });

    test('VectorTileWorkerSource provides resource timing information', async () => {
        const rawTileData = fs.readFileSync(path.join(__dirname, '/../../test/unit/assets/mbsv5-6-18-23.vector.pbf')).buffer.slice(0);

        const loadVectorData = (_params, _rawData) => {
            return {
                vectorTile: new VectorTile(new Protobuf(rawTileData)),
                rawData: rawTileData,
                cacheControl: null,
                expires: null
            };
        };

        const exampleResourceTiming = {
            connectEnd: 473,
            connectStart: 473,
            decodedBodySize: 86494,
            domainLookupEnd: 473,
            domainLookupStart: 473,
            duration: 341,
            encodedBodySize: 52528,
            entryType: 'resource',
            fetchStart: 473.5,
            initiatorType: 'xmlhttprequest',
            name: 'http://localhost:2900/faketile.pbf',
            nextHopProtocol: 'http/1.1',
            redirectEnd: 0,
            redirectStart: 0,
            requestStart: 477,
            responseEnd: 815,
            responseStart: 672,
            secureConnectionStart: 0
        };

        const layerIndex = new StyleLayerIndex([{
            id: 'test',
            source: 'source',
            'source-layer': 'test',
            type: 'fill'
        }]);

        const source = new VectorTileWorkerSource(actor, layerIndex, []);
        source.loadVectorTile = loadVectorData;

        window.performance.getEntriesByName = vi.fn().mockReturnValue([exampleResourceTiming]);

        server.respondWith(request => {
            request.respond(200, {'Content-Type': 'application/pbf'}, 'ok');
        });

        const promise = source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/faketile.pbf', collectResourceTiming: true}
        } as any as WorkerTileParameters);

        await sleep(0);
        server.respond();
        const res = await promise;

        expect(res.resourceTiming[0]).toEqual(exampleResourceTiming);
    });

    test('VectorTileWorkerSource provides resource timing information (fallback method)', async () => {
        const rawTileData = fs.readFileSync(path.join(__dirname, '/../../test/unit/assets/mbsv5-6-18-23.vector.pbf')).buffer.slice(0);

        const loadVectorData = (_params, _rawData) => {
            return {
                vectorTile: new VectorTile(new Protobuf(rawTileData)),
                rawData: rawTileData,
                cacheControl: null,
                expires: null
            };
        };

        const layerIndex = new StyleLayerIndex([{
            id: 'test',
            source: 'source',
            'source-layer': 'test',
            type: 'fill'
        }]);

        const source = new VectorTileWorkerSource(actor, layerIndex, []);
        source.loadVectorTile = loadVectorData;

        const sampleMarks = [100, 350];
        const marks = {};
        const measures = {};
        window.performance.getEntriesByName = vi.fn().mockImplementation(name => (measures[name] || []));
        window.performance.mark = vi.fn().mockImplementation(name => {
            marks[name] = sampleMarks.shift();
            return null;
        });
        window.performance.measure = vi.fn().mockImplementation((name, start, end) => {
            measures[name] = measures[name] || [];
            measures[name].push({
                duration: marks[end] - marks[start],
                entryType: 'measure',
                name,
                startTime: marks[start]
            });
            return null;
        });

        server.respondWith(request => {
            request.respond(200, {'Content-Type': 'application/pbf'}, 'ok');
        });

        const promise = source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/faketile.pbf', collectResourceTiming: true}
        } as any as WorkerTileParameters);

        await sleep(0);
        server.respond();
        const res = await promise;

        expect(res.resourceTiming[0]).toEqual(
            {'duration': 250, 'entryType': 'measure', 'name': 'http://localhost:2900/faketile.pbf', 'startTime': 100}
        );
    });

    test('VectorTileWorkerSource.loadTile skips parsing and returns unmodified when e-tags match', async () => {
        const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []);

        source.loadVectorTile = () => {
            throw new Error('loadVectorTile should not be called when etag matches');
        };

        const rawTileData = new ArrayBuffer(0);
        server.respondWith(request => {
            request.respond(200, {
                'Content-Type': 'application/pbf',
                'ETag': '"v1"'
            }, rawTileData as any);
        });

        const promise = source.loadTile({
            source: 'source',
            uid: 0,
            tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
            request: {url: 'http://localhost:2900/faketile.pbf'},
            etag: '"v1"',
            subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision,
        } as any as WorkerTileParameters);

        server.respond();
        const res = await promise;

        expect(res).toBeDefined();
        expect(res.etagUnmodified).toBe(true);
    });
});
