import {describe, expect, test, vi} from 'vitest';
import {EXTENT} from '../../data/extent.ts';
import Point from '@mapbox/point-geometry';
import {LngLat} from '../lng_lat.ts';
import {GlobeTransform} from './globe_transform.ts';
import {CanonicalTileID, OverscaledTileID, UnwrappedTileID} from '../../tile/tile_id.ts';
import {angularCoordinatesRadiansToVector, mercatorCoordinatesToAngularCoordinatesRadians, sphereSurfacePointToCoordinates, versorSetLocationAtPoint} from './globe_utils.ts';
import {expectToBeCloseToArray} from '../../util/test/util.ts';
import {MercatorCoordinate} from '../mercator_coordinate.ts';
import {tileCoordinatesToLocation} from './mercator_utils.ts';
import {MercatorTransform} from './mercator_transform.ts';
import {differenceOfAnglesDegrees, MAX_VALID_LATITUDE} from '../../util/util.ts';

function testPlaneAgainstLngLat(lngDegrees: number, latDegrees: number, plane: number[]) {
    const lat = latDegrees / 180.0 * Math.PI;
    const lng = lngDegrees / 180.0 * Math.PI;
    const len = Math.cos(lat);
    const pointOnSphere = [
        Math.sin(lng) * len,
        Math.sin(lat),
        Math.cos(lng) * len
    ];
    return planeDistance(pointOnSphere, plane);
}

function planeDistance(point: number[], plane: number[]) {
    return point[0] * plane[0] + point[1] * plane[1] + point[2] * plane[2] + plane[3];
}

function createGlobeTransform() {
    const globeTransform = new GlobeTransform();
    globeTransform.resize(640, 480);
    globeTransform.setFov(45);
    return globeTransform;
}

describe('GlobeTransform', () => {
    describe('getProjectionData', () => {
        const globeTransform = createGlobeTransform();
        test('mercator tile extents are set', () => {
            const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0)});
            expectToBeCloseToArray(projectionData.tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]);
        });

        test('Globe transition is 0 when not applying the globe matrix', () => {
            const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0)});
            expect(projectionData.projectionTransition).toBe(0);
        });

        test('Applying the globe matrix sets transition to something different than 0', () => {
            const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0), applyGlobeMatrix: true});
            expect(projectionData.projectionTransition).not.toBe(0);
        });
    });

    describe('getProjectionDataForCustomLayer', () => {
        test('transition is the in-progress globe transition state', () => {
            const globeTransform = createGlobeTransform();
            globeTransform.setTransitionState(0.5);
            expect(globeTransform.getProjectionDataForCustomLayer(true).projectionTransition).toBe(0.5);
        });
    });

    describe('clipping plane', () => {
        const globeTransform = createGlobeTransform();

        describe('general plane properties', () => {
            const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(0, 0, 0, 0, 0)});

            test('plane vector length <= 1 so they are not clipped by the near plane.', () => {
                const len = Math.sqrt(
                    projectionData.clippingPlane[0] * projectionData.clippingPlane[0] +
                    projectionData.clippingPlane[1] * projectionData.clippingPlane[1] +
                    projectionData.clippingPlane[2] * projectionData.clippingPlane[2]
                );
                expect(len).toBeLessThanOrEqual(1);
            });

            test('camera is in positive halfspace', () => {
                expect(planeDistance(globeTransform.cameraPosition as [number, number, number], projectionData.clippingPlane)).toBeGreaterThan(0);
            });

            test('coordinates 0E,0N are in positive halfspace', () => {
                expect(testPlaneAgainstLngLat(0, 0, projectionData.clippingPlane)).toBeGreaterThan(0);
            });

            test('coordinates 40E,0N are in positive halfspace', () => {
                expect(testPlaneAgainstLngLat(40, 0, projectionData.clippingPlane)).toBeGreaterThan(0);
            });

            test('coordinates 0E,90N are in negative halfspace', () => {
                expect(testPlaneAgainstLngLat(0, 90, projectionData.clippingPlane)).toBeLessThan(0);
            });

            test('coordinates 90E,0N are in negative halfspace', () => {
                expect(testPlaneAgainstLngLat(90, 0, projectionData.clippingPlane)).toBeLessThan(0);
            });

            test('coordinates 180E,0N are in negative halfspace', () => {
                expect(testPlaneAgainstLngLat(180, 0, projectionData.clippingPlane)).toBeLessThan(0);
            });
        });
    });

    describe('projection', () => {
        test('mercator coordinate to sphere point', () => {
            const precisionDigits = 10;

            let projectedAngles;
            let projected;

            projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.5, 0.5);
            expectToBeCloseToArray(projectedAngles, [0, 0], precisionDigits);
            projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
            expectToBeCloseToArray(projected, [0, 0, 1], precisionDigits);

            projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0, 0.5);
            expectToBeCloseToArray(projectedAngles, [Math.PI, 0], precisionDigits);
            projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
            expectToBeCloseToArray(projected, [0, 0, -1], precisionDigits);

            projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.75, 0.5);
            expectToBeCloseToArray(projectedAngles, [Math.PI / 2.0, 0], precisionDigits);
            projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
            expectToBeCloseToArray(projected, [1, 0, 0], precisionDigits);

            projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.5, 0);
            expectToBeCloseToArray(projectedAngles, [0, 1.4844222297453324], precisionDigits); // ~0.47pi
            projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
            expectToBeCloseToArray(projected, [0, 0.99627207622075, 0.08626673833405434], precisionDigits);
        });

        test('camera position', () => {
            const precisionDigits = 10;

            const globeTransform = createGlobeTransform();
            expectToBeCloseToArray(globeTransform.cameraPosition as number[], [0, 0, 8.110445867263898], precisionDigits);

            globeTransform.resize(512, 512);
            globeTransform.setZoom(-0.5);
            globeTransform.setCenter(new LngLat(0, 80));
            expectToBeCloseToArray(globeTransform.cameraPosition as number[], [0, 2.2818294674820794, 0.40234810049271963], precisionDigits);

            globeTransform.setPitch(35);
            globeTransform.setBearing(70);
            expectToBeCloseToArray(globeTransform.cameraPosition as number[], [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);

            globeTransform.setPitch(35);
            globeTransform.setBearing(70);
            globeTransform.setRoll(40);
            expectToBeCloseToArray(globeTransform.cameraPosition as number[], [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);

            globeTransform.setPitch(35);
            globeTransform.setBearing(70);
            globeTransform.setRoll(180);
            expectToBeCloseToArray(globeTransform.cameraPosition as number[], [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);

            globeTransform.setCenter(new LngLat(-10, 42));
            expectToBeCloseToArray(globeTransform.cameraPosition as number[], [-3.8450970996236364, 2.9368285470351516, 4.311953269048194], precisionDigits);
        });

        test('sphere point to coordinate', () => {
            const precisionDigits = 10;
            let unprojected = sphereSurfacePointToCoordinates([0, 0, 1]);
            expect(unprojected.lng).toBeCloseTo(0, precisionDigits);
            expect(unprojected.lat).toBeCloseTo(0, precisionDigits);
            unprojected = sphereSurfacePointToCoordinates([0, 1, 0]);
            expect(unprojected.lng).toBeCloseTo(0, precisionDigits);
            expect(unprojected.lat).toBeCloseTo(90, precisionDigits);
            unprojected = sphereSurfacePointToCoordinates([1, 0, 0]);
            expect(unprojected.lng).toBeCloseTo(90, precisionDigits);
            expect(unprojected.lat).toBeCloseTo(0, precisionDigits);
        });

        const screenCenter = new Point(640 / 2, 480 / 2); // We need the exact screen center
        const screenTopEdgeCenter = new Point(640 / 2, 0);

        describe('project location to coordinates', () => {
            const precisionDigits = 10;
            const globeTransform = createGlobeTransform();

            test('basic test', () => {
                globeTransform.setCenter(new LngLat(0, 0));
                let projected = globeTransform.locationToScreenPoint(globeTransform.center);
                expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
                expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);

                globeTransform.setCenter(new LngLat(70, 50));
                projected = globeTransform.locationToScreenPoint(globeTransform.center);
                expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
                expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);

                globeTransform.setCenter(new LngLat(0, 84));
                projected = globeTransform.locationToScreenPoint(globeTransform.center);
                expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
                expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);
            });

            test('project a location that is slightly above and below map\'s center point', () => {
                globeTransform.setCenter(new LngLat(0, 0));
                let projected = globeTransform.locationToScreenPoint(new LngLat(0, 1));
                expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
                expect(projected.y).toBeLessThan(screenCenter.y);

                projected = globeTransform.locationToScreenPoint(new LngLat(0, -1));
                expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
                expect(projected.y).toBeGreaterThan(screenCenter.y);
            });
        });

        describe('unproject', () => {
            test('unproject screen center', () => {
                const precisionDigits = 10;
                const globeTransform = createGlobeTransform();
                let unprojected = globeTransform.screenPointToLocation(screenCenter);
                expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);

                globeTransform.setCenter(new LngLat(90.0, 0.0));
                unprojected = globeTransform.screenPointToLocation(screenCenter);
                expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);

                globeTransform.setCenter(new LngLat(0.0, 60.0));
                unprojected = globeTransform.screenPointToLocation(screenCenter);
                expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);
            });

            test('unproject point to the side', () => {
                const precisionDigits = 10;
                const globeTransform = createGlobeTransform();
                let coords: LngLat;
                let projected: Point;
                let unprojected: LngLat;

                coords = new LngLat(0, 0);
                projected = globeTransform.locationToScreenPoint(coords);
                unprojected = globeTransform.screenPointToLocation(projected);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);

                coords = new LngLat(10, 20);
                projected = globeTransform.locationToScreenPoint(coords);
                unprojected = globeTransform.screenPointToLocation(projected);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);

                coords = new LngLat(15, -2);
                projected = globeTransform.locationToScreenPoint(coords);
                unprojected = globeTransform.screenPointToLocation(projected);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
            });

            test('unproject behind the pole', () => {
                // This test tries to unproject a point that is beyond the north pole
                // from the camera's point of view.
                // This particular case turned out to be problematic, hence this test.

                const precisionDigits = 10;
                const globeTransform = createGlobeTransform();
                // Transform settings from the render test projection/globe/fill-planet-pole
                // See the expected result for how the globe should look with this transform.
                globeTransform.resize(512, 512);
                globeTransform.setZoom(-0.5);
                globeTransform.setCenter(new LngLat(0, 80));

                let coords: LngLat;
                let projected: Point;
                let unprojected: LngLat;

                coords = new LngLat(179.9, 71);
                projected = globeTransform.locationToScreenPoint(coords);
                unprojected = globeTransform.screenPointToLocation(projected);
                expect(projected.x).toBeCloseTo(256.2434702034287, precisionDigits);
                expect(projected.y).toBeCloseTo(48.27080146399297, precisionDigits);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);

                // Near the pole
                coords = new LngLat(179.9, 89.0);
                projected = globeTransform.locationToScreenPoint(coords);
                unprojected = globeTransform.screenPointToLocation(projected);
                expect(projected.x).toBeCloseTo(256.0140972925064, precisionDigits);
                expect(projected.y).toBeCloseTo(167.69159699932908, precisionDigits);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
            });

            test('unproject outside of sphere', () => {
                const precisionDigits = 10;
                const globeTransform = createGlobeTransform();
                // Try unprojection a point somewhere above the western horizon
                globeTransform.setPitch(60);
                globeTransform.setBearing(-90);
                const unprojected = globeTransform.screenPointToLocation(screenTopEdgeCenter);
                expect(unprojected.lng).toBeCloseTo(-28.990298145461963, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(0.0, precisionDigits);
            });

            test('unproject further outside of sphere clamps to horizon', () => {
                const globeTransform = createGlobeTransform();
                globeTransform.setPitch(60);
                globeTransform.setBearing(-90);
                const screenPointAboveWesternHorizon = screenTopEdgeCenter;
                const screenPointFurtherAboveWesternHorizon = screenTopEdgeCenter.sub(new Point(0, -100));
                const unprojected = globeTransform.screenPointToLocation(screenPointAboveWesternHorizon);
                const unprojected2 = globeTransform.screenPointToLocation(screenPointFurtherAboveWesternHorizon);
                expect(unprojected.lat).toBeCloseTo(unprojected2.lat, 10);
                expect(unprojected.lng).toBeCloseTo(unprojected2.lng, 10);
            });
        });

        describe('setLocationAtPoint', () => {
            const precisionDigits = 10;
            const globeTransform = createGlobeTransform();
            globeTransform.setZoom(1);
            let coords: LngLat;
            let point: Point;
            let projected: Point;
            let unprojected: LngLat;

            test('identity', () => {
                // Should do nothing
                coords = new LngLat(0, 0);
                point = new Point(320, 240);
                globeTransform.setLocationAtPoint(coords, point);
                unprojected = globeTransform.screenPointToLocation(point);
                projected = globeTransform.locationToScreenPoint(coords);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
                expect(projected.x).toBeCloseTo(point.x, precisionDigits);
                expect(projected.y).toBeCloseTo(point.y, precisionDigits);
            });

            test('offset lnglat', () => {
                coords = new LngLat(5, 10);
                point = new Point(320, 240);
                globeTransform.setLocationAtPoint(coords, point);
                unprojected = globeTransform.screenPointToLocation(point);
                projected = globeTransform.locationToScreenPoint(coords);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
                expect(projected.x).toBeCloseTo(point.x, precisionDigits);
                expect(projected.y).toBeCloseTo(point.y, precisionDigits);
            });

            test('offset pixel + lnglat', () => {
                coords = new LngLat(5, 10);
                point = new Point(330, 240); // 10 pixels to the right
                globeTransform.setLocationAtPoint(coords, point);
                unprojected = globeTransform.screenPointToLocation(point);
                projected = globeTransform.locationToScreenPoint(coords);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
                expect(projected.x).toBeCloseTo(point.x, precisionDigits);
                expect(projected.y).toBeCloseTo(point.y, precisionDigits);
            });

            test('larger offset', () => {
                coords = new LngLat(30, -2);
                point = new Point(250, 180);
                globeTransform.setLocationAtPoint(coords, point);
                unprojected = globeTransform.screenPointToLocation(point);
                projected = globeTransform.locationToScreenPoint(coords);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
                expect(projected.x).toBeCloseTo(point.x, precisionDigits);
                expect(projected.y).toBeCloseTo(point.y, precisionDigits);
            });

            describe('rotated', () => {
                globeTransform.setBearing(90);

                test('identity', () => {
                    // Should do nothing
                    coords = new LngLat(0, 0);
                    point = new Point(320, 240);
                    globeTransform.setLocationAtPoint(coords, point);
                    unprojected = globeTransform.screenPointToLocation(point);
                    projected = globeTransform.locationToScreenPoint(coords);
                    expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                    expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
                    expect(projected.x).toBeCloseTo(point.x, precisionDigits);
                    expect(projected.y).toBeCloseTo(point.y, precisionDigits);
                });
                test('offset lnglat', () => {
                    coords = new LngLat(5, 0);
                    point = new Point(320, 240);
                    globeTransform.setLocationAtPoint(coords, point);
                    unprojected = globeTransform.screenPointToLocation(point);
                    projected = globeTransform.locationToScreenPoint(coords);
                    expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                    expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
                    expect(projected.x).toBeCloseTo(point.x, precisionDigits);
                    expect(projected.y).toBeCloseTo(point.y, precisionDigits);
                });
                test('offset pixel + lnglat', () => {
                    coords = new LngLat(0, 10);
                    point = new Point(350, 240); // 30 pixels to the right
                    globeTransform.setLocationAtPoint(coords, point);
                    unprojected = globeTransform.screenPointToLocation(point);
                    projected = globeTransform.locationToScreenPoint(coords);
                    expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                    expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
                    expect(projected.x).toBeCloseTo(point.x, precisionDigits);
                    expect(projected.y).toBeCloseTo(point.y, precisionDigits);
                    expect(globeTransform.center.lat).toBeCloseTo(20.659450722109348, precisionDigits);
                });
            });

            test('ignores the elevation parameter when rendering the globe', () => {
                const transform = createGlobeTransform();
                transform.setZoom(1);
                coords = new LngLat(5, 10);
                point = new Point(320, 240);
                transform.setLocationAtPoint(coords, point, 1000);
                unprojected = transform.screenPointToLocationAtElevation(point, 1000);
                expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
                expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
            });

            test('solves on the plane at the given elevation when rendering mercator', () => {
                const transform = createGlobeTransform();
                transform.setZoom(5);
                transform.setPitch(40);
                transform.setTransitionState(0); // rendering mercator
                coords = new LngLat(5, 10);
                point = new Point(320, 200);
                transform.setLocationAtPoint(coords, point, 500);
                unprojected = transform.screenPointToLocationAtElevation(point, 500);
                // exact up to the center-latitude change the solve itself causes (~1e-5 deg)
                expect(unprojected.lng).toBeCloseTo(coords.lng, 3);
                expect(unprojected.lat).toBeCloseTo(coords.lat, 3);
            });
        });
    });

    describe('isPointOnMapSurface', () => {
        const globeTransform = new GlobeTransform();
        globeTransform.resize(640, 480);
        globeTransform.setZoom(1);

        test('Top screen edge', () => {
            expect(globeTransform.isPointOnMapSurface(new Point(320, 0))).toBe(false);
        });

        test('Screen center', () => {
            expect(globeTransform.isPointOnMapSurface(new Point(320, 240))).toBe(true);
        });

        test('Top', () => {
            expect(globeTransform.isPointOnMapSurface(new Point(320, 104))).toBe(false);
            expect(globeTransform.isPointOnMapSurface(new Point(320, 105))).toBe(true);
        });

        test('Bottom', () => {
            expect(globeTransform.isPointOnMapSurface(new Point(320, 480 - 105))).toBe(true);
            expect(globeTransform.isPointOnMapSurface(new Point(320, 480 - 104))).toBe(false);
        });

        test('Left', () => {
            expect(globeTransform.isPointOnMapSurface(new Point(184, 240))).toBe(false);
            expect(globeTransform.isPointOnMapSurface(new Point(185, 240))).toBe(true);
        });

        test('Right', () => {
            expect(globeTransform.isPointOnMapSurface(new Point(640 - 185, 240))).toBe(true);
            expect(globeTransform.isPointOnMapSurface(new Point(640 - 184, 240))).toBe(false);
        });

        test('Diagonal', () => {
            expect(globeTransform.isPointOnMapSurface(new Point(223, 147))).toBe(true);
            expect(globeTransform.isPointOnMapSurface(new Point(221, 144))).toBe(false);
        });
    });

    test('pointCoordinate', () => {
        const precisionDigits = 10;
        const globeTransform = createGlobeTransform();
        let coords: LngLat;
        let coordsMercator: MercatorCoordinate;
        let projected: Point;
        let unprojectedCoordinates: MercatorCoordinate;

        coords = new LngLat(0, 0);
        coordsMercator = MercatorCoordinate.fromLngLat(coords);
        projected = globeTransform.locationToScreenPoint(coords);
        unprojectedCoordinates = globeTransform.screenPointToMercatorCoordinate(projected);
        expect(unprojectedCoordinates.x).toBeCloseTo(coordsMercator.x, precisionDigits);
        expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits);

        coords = new LngLat(10, 20);
        coordsMercator = MercatorCoordinate.fromLngLat(coords);
        projected = globeTransform.locationToScreenPoint(coords);
        unprojectedCoordinates = globeTransform.screenPointToMercatorCoordinate(projected);
        expect(unprojectedCoordinates.x).toBeCloseTo(coordsMercator.x, precisionDigits);
        expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits);
    });

    describe('getBounds', () => {
        const precisionDigits = 10;

        const globeTransform = new GlobeTransform();
        globeTransform.resize(640, 480);

        test('basic', () => {
            globeTransform.setCenter(new LngLat(0, 0));
            globeTransform.setZoom(1);
            const bounds = globeTransform.getBounds();
            expect(bounds._ne.lat).toBeCloseTo(79.3636705287052, precisionDigits);
            expect(bounds._ne.lng).toBeCloseTo(79.36367052870514, precisionDigits);
            expect(bounds._sw.lat).toBeCloseTo(-79.3636705287052, precisionDigits);
            expect(bounds._sw.lng).toBeCloseTo(-79.3636705287052, precisionDigits);
        });

        test('zoomed in', () => {
            globeTransform.setCenter(new LngLat(0, 0));
            globeTransform.setZoom(4);
            const bounds = globeTransform.getBounds();
            expect(bounds._ne.lat).toBeCloseTo(11.76627084591695, precisionDigits);
            expect(bounds._ne.lng).toBeCloseTo(16.124697669965144, precisionDigits);
            expect(bounds._sw.lat).toBeCloseTo(-11.76627084591695, precisionDigits);
            expect(bounds._sw.lng).toBeCloseTo(-16.124697669965144, precisionDigits);
        });

        test('looking at south pole', () => {
            globeTransform.setCenter(new LngLat(0, -84));
            globeTransform.setZoom(-2);
            const bounds = globeTransform.getBounds();
            expect(bounds._ne.lat).toBeCloseTo(-6.299534770946991, precisionDigits);
            expect(bounds._ne.lng).toBeCloseTo(180, precisionDigits);
            expect(bounds._sw.lat).toBeCloseTo(-90, precisionDigits);
            expect(bounds._sw.lng).toBeCloseTo(-180, precisionDigits);
        });

        test('looking at south edge of mercator', () => {
            globeTransform.setCenter(new LngLat(-163, -83));
            globeTransform.setZoom(3);
            const bounds = globeTransform.getBounds();
            expect(bounds._ne.lat).toBeCloseTo(-79.75570418234764, precisionDigits);
            expect(bounds._ne.lng).toBeCloseTo(-124.19771985801174, precisionDigits);
            expect(bounds._sw.lat).toBeCloseTo(-85.59109073899032, precisionDigits);
            expect(bounds._sw.lng).toBeCloseTo(-201.80228014198985, precisionDigits);
        });
    });

    describe('projectTileCoordinates', () => {
        const precisionDigits = 10;
        const transform = new GlobeTransform();
        transform.resize(512, 512);
        transform.setCenter(new LngLat(10.0, 50.0));
        transform.setZoom(-1);

        test('basic', () => {

            const projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
            expect(projection.point.x).toBeCloseTo(0.008635590705360347, precisionDigits);
            expect(projection.point.y).toBeCloseTo(0.16970500709841846, precisionDigits);
            expect(projection.signedDistanceFromCamera).toBeCloseTo(781.0549201758624, precisionDigits);
            expect(projection.isOccluded).toBe(false);
        });

        test('rotated', () => {
            transform.setBearing(12);
            transform.setPitch(10);

            const projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
            expect(projection.point.x).toBeCloseTo(-0.026585319983152694, precisionDigits);
            expect(projection.point.y).toBeCloseTo(0.15506884411121183, precisionDigits);
            expect(projection.signedDistanceFromCamera).toBeCloseTo(788.4423931260653, precisionDigits);
            expect(projection.isOccluded).toBe(false);
        });

        test('occluded by planet', () => {
            transform.setBearing(-90);
            transform.setPitch(60);

            const projection = transform.projectTileCoordinates(8192, 8192, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
            expect(projection.point.x).toBeCloseTo(0.22428309892086878, precisionDigits);
            expect(projection.point.y).toBeCloseTo(-0.4462620847133465, precisionDigits);
            expect(projection.signedDistanceFromCamera).toBeCloseTo(822.280942015371, precisionDigits);
            expect(projection.isOccluded).toBe(true);
        });
    });

    describe('isLocationOccluded', () => {
        const transform = new GlobeTransform();
        transform.resize(512, 512);
        transform.setCenter(new LngLat(0.0, 0.0));
        transform.setZoom(-1);

        test('center', () => {
            expect(transform.isLocationOccluded(new LngLat(0, 0))).toBe(false);
        });

        test('center from tile', () => {
            expect(transform.isLocationOccluded(tileCoordinatesToLocation(0, 0, new CanonicalTileID(1, 1, 1)))).toBe(false);
        });

        test('backside', () => {
            expect(transform.isLocationOccluded(new LngLat(179.9, 0))).toBe(true);
        });

        test('backside from tile', () => {
            expect(transform.isLocationOccluded(tileCoordinatesToLocation(0, 0, new CanonicalTileID(1, 0, 1)))).toBe(true);
        });

        test('barely visible', () => {
            expect(transform.isLocationOccluded(new LngLat(84.49, 0))).toBe(false);
        });

        test('barely hidden', () => {
            expect(transform.isLocationOccluded(new LngLat(84.50, 0))).toBe(true);
        });
    });

    describe('render world copies', () => {
        test('change projection and make sure render world copies is kept', () => {
            const globeTransform = createGlobeTransform();
            globeTransform.setRenderWorldCopies(true);
            
            expect(globeTransform.renderWorldCopies).toBeTruthy();
        });

        test('change transform and make sure render world copies is kept', () => {
            const globeTransform = createGlobeTransform();
            globeTransform.setRenderWorldCopies(true);
            const mercator = new MercatorTransform({minZoom: 0, maxZoom: 1, minPitch: 2, maxPitch: 3, renderWorldCopies: false});
            mercator.apply(globeTransform, false);

            expect(mercator.renderWorldCopies).toBeTruthy();
        });
    });

    test('recalculateZoomAndCenter does not jump center on globe + terrain (#7025)', () => {
        vi.spyOn(console, 'warn').mockImplementation(() => {});
        const globeTransform = createGlobeTransform();
        globeTransform.setTransitionState(1);
        globeTransform.setCenter(new LngLat(10, 50));
        const originalLng = globeTransform.center.lng;
        const originalLat = globeTransform.center.lat;

        const terrain = {
            getElevationForLngLatZoom: () => 200,
            pointCoordinate: () => null
        } as any;

        globeTransform.recalculateZoomAndCenter(terrain);

        expect(globeTransform.center.lng).toBeCloseTo(originalLng, 10);
        expect(globeTransform.center.lat).toBeCloseTo(originalLat, 10);
    });

    describe('versorSetLocationAtPoint', () => {
        const precisionDigits = 4;
        const globeTransform = createGlobeTransform();
        globeTransform.setZoom(1);
        globeTransform.setTransitionState(1);
        let coords: LngLat;
        let point: Point;
        let unprojected: LngLat;

        test('round-trip accuracy', () => {
            coords = new LngLat(20, 30);
            point = new Point(280, 200);
            versorSetLocationAtPoint(globeTransform, coords, point, undefined, false);
            unprojected = globeTransform.screenPointToLocation(point);
            expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
            expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
        });

        test('round-trip accuracy at center', () => {
            coords = new LngLat(10, 15);
            point = new Point(320, 240);
            versorSetLocationAtPoint(globeTransform, coords, point);
            unprojected = globeTransform.screenPointToLocation(point);
            expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
            expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
        });

        test('near-pole panning stays finite', () => {
            globeTransform.setCenter(new LngLat(0, 80));
            coords = new LngLat(10, 85);
            point = new Point(300, 230);
            versorSetLocationAtPoint(globeTransform, coords, point);
            expect(isNaN(globeTransform.center.lng)).toBe(false);
            expect(isNaN(globeTransform.center.lat)).toBe(false);
            expect(isNaN(globeTransform.bearing)).toBe(false);
        });

        test('identical source and target points leave the center unchanged', () => {
            const centerBefore = globeTransform.center;
            point = new Point(320, 240);
            coords = globeTransform.screenPointToLocation(point);
            versorSetLocationAtPoint(globeTransform, coords, point);
            expect(globeTransform.center.lng).toBeCloseTo(centerBefore.lng, precisionDigits);
            expect(globeTransform.center.lat).toBeCloseTo(centerBefore.lat, precisionDigits);
        });

        test('bearing changes when panning off-center without a fixed bearing', () => {
            const bearingBefore = globeTransform.bearing;
            coords = new LngLat(20, 30);
            point = new Point(250, 180);
            versorSetLocationAtPoint(globeTransform, coords, point, undefined, false);
            expect(globeTransform.bearing).not.toBeCloseTo(bearingBefore, 1);
        });

        test('target points that miss the globe are ignored without a pan delta', () => {
            const freshTransform = createGlobeTransform();
            freshTransform.setZoom(1);
            freshTransform.setTransitionState(1);
            freshTransform.setCenter(new LngLat(5, 10));
            const lngBefore = freshTransform.center.lng;
            const latBefore = freshTransform.center.lat;
            const bearingBefore = freshTransform.bearing;
            point = new Point(620, 240);
            expect(freshTransform.isPointOnMapSurface(point)).toBe(false);
            coords = freshTransform.screenPointToLocation(point);
            versorSetLocationAtPoint(freshTransform, coords, point);
            expect(freshTransform.center.lng).toBe(lngBefore);
            expect(freshTransform.center.lat).toBe(latBefore);
            expect(freshTransform.bearing).toBe(bearingBefore);
        });

        test('panning continues once the cursor leaves the globe', () => {
            const freshTransform = createGlobeTransform();
            freshTransform.setZoom(1);
            freshTransform.setTransitionState(1);
            freshTransform.setCenter(new LngLat(5, 10));
            const lngBefore = freshTransform.center.lng;
            const bearingBefore = freshTransform.bearing;
            point = new Point(620, 240);
            expect(freshTransform.isPointOnMapSurface(point)).toBe(false);
            coords = freshTransform.screenPointToLocation(point);
            versorSetLocationAtPoint(freshTransform, coords, point, new Point(20, 0));
            expect(freshTransform.center.lng).not.toBe(lngBefore);
            expect(isNaN(freshTransform.center.lng)).toBe(false);
            expect(isNaN(freshTransform.center.lat)).toBe(false);
            expect(freshTransform.bearing).toBe(bearingBefore);
        });

        test('panning does not freeze near a centred pole', () => {
            // With the pole centred the dial supplies all of the longitude change, and the center
            // latitude is already clamped, so if the dial gives up the drag stops moving entirely.
            const tr = createGlobeTransform();
            tr.setZoom(1);
            tr.setTransitionState(1);
            tr.setCenter(new LngLat(0, MAX_VALID_LATITUDE));
            const pole = tr.locationToScreenPoint(new LngLat(0, 90));
            const cursor = new Point(pole.x + 10, pole.y);
            const panDelta = new Point(0, 8); // tangential, so it sweeps around the pole
            const lngBefore = tr.center.lng;
            const location = tr.screenPointToLocation(cursor.sub(panDelta));
            versorSetLocationAtPoint(tr, location, cursor, panDelta);
            expect(Math.abs(differenceOfAnglesDegrees(lngBefore, tr.center.lng))).toBeGreaterThan(1);
        });

        test('panning off the globe is slower than on it', () => {
            const travel = (screenPoint: Point) => {
                const tr = createGlobeTransform();
                tr.setZoom(1);
                tr.setTransitionState(1);
                tr.setCenter(new LngLat(0, 0));
                const panDelta = new Point(20, 0);
                const location = tr.screenPointToLocation(screenPoint.sub(panDelta));
                versorSetLocationAtPoint(tr, location, screenPoint, panDelta);
                return Math.abs(differenceOfAnglesDegrees(0, tr.center.lng));
            };
            const onGlobe = new Point(340, 240);
            const offGlobe = new Point(620, 240);
            const reference = createGlobeTransform();
            reference.setZoom(1);
            reference.setTransitionState(1);
            reference.setCenter(new LngLat(0, 0));
            expect(reference.isPointOnMapSurface(onGlobe)).toBe(true);
            expect(reference.isPointOnMapSurface(offGlobe)).toBe(false);
            expect(travel(offGlobe)).toBeLessThan(travel(onGlobe));
        });
    });
});
