import {LngLat, type LngLatLike} from './lng_lat.ts';
import {LngLatBounds} from './lng_lat_bounds.ts';
import Point from '@mapbox/point-geometry';
import {wrap, clamp, degreesToRadians, radiansToDegrees, zoomScale, MAX_VALID_LATITUDE, scaleZoom, warnOnce} from '../util/util.ts';
import {mat4, mat2} from 'gl-matrix';
import {EdgeInsets} from './edge_insets.ts';
import {MercatorCoordinate, mercatorWorldCoordinateHelper} from './mercator_coordinate.ts';
import {cameraDirectionFromPitchBearing, maxMercatorHorizonAngle} from './projection/mercator_utils.ts';
import {EXTENT} from '../data/extent.ts';
import {Bounds} from './bounds.ts';

import type {WorldCoordinateHelper} from './transform_interface.ts';
import type {PaddingOptions} from './edge_insets.ts';
import type {IReadonlyTransform, ITransformGetters, TransformConstrainFunction} from './transform_interface.ts';
/**
 * If a path crossing the antimeridian would be shorter, extend the final coordinate so that
 * interpolating between the two endpoints will cross it. A world that does not wrap has no antimeridian to cross.
 * @param center - The LngLat object of the desired center. This object will be mutated.
 */
export function normalizeCenter(tr: IReadonlyTransform, center: LngLat): void {
    if (!tr.renderWorldCopies || !tr.worldCoordinateHelper.wraps || tr.lngRange) return;
    const delta = center.lng - tr.center.lng;
    center.lng +=
        delta > 180 ? -360 :
            delta < -180 ? 360 : 0;
}

export type UnwrappedTileIDType = {
    /**
     * Tile wrap: 0 for the "main" world,
     * negative values for worlds left of the main,
     * positive values for worlds right of the main.
     */
    wrap?: number;
    canonical: {
        /**
         * Tile X coordinate, in range 0..(z^2)-1
         */
        x: number;
        /**
         * Tile Y coordinate, in range 0..(z^2)-1
         */
        y: number;
        /**
         * Tile zoom level.
         */
        z: number;
    };
};

export type TransformHelperCallbacks = {
    /**
     * The transform's default getter of center lngLat and zoom to ensure that
     * 1) everything beyond the bounds is excluded
     * 2) a given lngLat is as near the center as possible
     * Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian.
     */
    defaultConstrain: TransformConstrainFunction;

    /**
     * Updates the underlying transform's internal matrices.
     */
    calcMatrices: () => void;
};

export type TransformOptions = {
    /**
     * The minimum zoom level of the map. Users cannot zoom out beyond this level. (0–24)
     * @defaultValue 0
     */
    minZoom?: number;
    /**
     * The maximum zoom level of the map. Users cannot zoom in beyond this level. (0–24)
     * @defaultValue 22
     */
    maxZoom?: number;
    /**
     * The minimum pitch of the map.
     */
    minPitch?: number;
    /**
     * The maximum pitch of the map.
     */
    maxPitch?: number;
    /**
     * Whether to render multiple copies of the world side by side in the map.
     */
    renderWorldCopies?: boolean;
    /**
     * An override of the transform's default constraining function for respecting its longitude and latitude bounds.
     */
    constrainOverride?: TransformConstrainFunction | null;
};

function getTileZoom(zoom: number): number {
    return Math.max(0, Math.floor(zoom));
}

/** The most passes {@link TransformHelper._centerAlongView} takes to agree on the mercator scale at the center it finds. */
const MAX_CENTER_PASSES = 10;

/**
 * @internal
 * This class stores all values that define a transform's state,
 * such as center, zoom, minZoom, etc.
 * This can be used as a helper for implementing the ITransform interface.
 */
export class TransformHelper implements ITransformGetters {
    private _callbacks: TransformHelperCallbacks;

    _tileSize: number; // constant
    _tileZoom: number; // integer zoom level for tiles
    _lngRange: [number, number];
    _latRange: [number, number];
    _scale: number; // computed based on zoom
    _width: number;
    _height: number;
    /**
     * Vertical field of view in radians.
     */
    _fovInRadians: number;
    /**
     * This transform's bearing in radians.
     */
    _bearingInRadians: number;
    /**
     * Pitch in radians.
     */
    _pitchInRadians: number;
    /**
     * Roll in radians.
     */
    _rollInRadians: number;
    _zoom: number;
    _renderWorldCopies: boolean;
    _minZoom: number;
    _maxZoom: number;
    _minPitch: number;
    _maxPitch: number;
    _center: LngLat;
    _elevation: number;
    _minElevationForCurrentTile: number;
    _pixelPerMeter: number;
    _edgeInsets: EdgeInsets;
    /**
     * Whether the camera was never deliberately positioned, in which case a loading style may apply its own camera.
     * Cleared by the setters that move the camera, but not by ones that only snap it into a valid range.
     */
    _unmodified: boolean;

    _constraining: boolean;
    _rotationMatrix: mat2;
    _pixelsToGLUnits: [number, number];
    _pixelsToClipSpaceMatrix: mat4;
    _clipSpaceToPixelsMatrix: mat4;
    _cameraToCenterDistance: number;

    _nearZ: number;
    _farZ: number;
    _autoCalculateNearFarZ: boolean;

    _constrainOverride: TransformConstrainFunction;

    _worldCoordinateHelper: WorldCoordinateHelper;

    constructor(callbacks: TransformHelperCallbacks, options?: TransformOptions) {
        this._callbacks = callbacks;
        this._tileSize = 512; // constant
        this._worldCoordinateHelper = mercatorWorldCoordinateHelper;

        this._renderWorldCopies = options?.renderWorldCopies === undefined ? true : !!options?.renderWorldCopies;
        this._minZoom = options?.minZoom || 0;
        this._maxZoom = options?.maxZoom || 22;

        this._minPitch = (options?.minPitch === undefined || options?.minPitch === null) ? 0 : options?.minPitch;
        this._maxPitch = (options?.maxPitch === undefined || options?.maxPitch === null) ? 60 : options?.maxPitch;

        this._constrainOverride = options?.constrainOverride ?? null;

        this.setMaxBounds();

        this._width = 0;
        this._height = 0;
        this._center = new LngLat(0, 0);
        this._elevation = 0;
        this._zoom = 0;
        this._tileZoom = getTileZoom(this._zoom);
        this._scale = zoomScale(this._zoom);
        this._bearingInRadians = 0;
        this._fovInRadians = 0.6435011087932844;
        this._pitchInRadians = 0;
        this._rollInRadians = 0;
        this._unmodified = true;
        this._edgeInsets = new EdgeInsets();
        this._minElevationForCurrentTile = 0;
        this._autoCalculateNearFarZ = true;
    }

    public apply(thatI: ITransformGetters, constrain: boolean): void {
        this._constrainOverride = thatI.constrainOverride;
        this._latRange = thatI.latRange;
        this._lngRange = thatI.lngRange;
        this._width = thatI.width;
        this._height = thatI.height;
        this._center = thatI.center;
        this._elevation = thatI.elevation;
        this._minElevationForCurrentTile = thatI.minElevationForCurrentTile;
        this._zoom = thatI.zoom;
        this._tileZoom = getTileZoom(this._zoom);
        this._scale = zoomScale(this._zoom);
        this._bearingInRadians = thatI.bearingInRadians;
        this._fovInRadians = thatI.fovInRadians;
        this._pitchInRadians = thatI.pitchInRadians;
        this._rollInRadians = thatI.rollInRadians;
        this._unmodified = thatI.unmodified;
        this._edgeInsets = new EdgeInsets(thatI.padding.top, thatI.padding.bottom, thatI.padding.left, thatI.padding.right);
        this._minZoom = thatI.minZoom;
        this._maxZoom = thatI.maxZoom;
        this._minPitch = thatI.minPitch;
        this._maxPitch = thatI.maxPitch;
        this._renderWorldCopies = thatI.renderWorldCopies;
        this._cameraToCenterDistance = thatI.cameraToCenterDistance;
        this._nearZ = thatI.nearZ;
        this._farZ = thatI.farZ;
        this._autoCalculateNearFarZ = thatI.autoCalculateNearFarZ;
        if (constrain) {
            this.constrainInternal();
        }
        this._calcMatrices();
    }

    get pixelsToClipSpaceMatrix(): mat4 { return this._pixelsToClipSpaceMatrix; }
    get clipSpaceToPixelsMatrix(): mat4 { return this._clipSpaceToPixelsMatrix; }

    get minElevationForCurrentTile(): number { return this._minElevationForCurrentTile; }
    setMinElevationForCurrentTile(ele: number): void {
        if (ele === this._minElevationForCurrentTile) return;
        this._minElevationForCurrentTile = ele;
        this._calcMatrices();
    }

    get tileSize(): number { return this._tileSize; }
    get tileZoom(): number { return this._tileZoom; }
    get scale(): number { return this._scale; }

    /**
     * Gets the transform's width in pixels. Use {@link resize} to set the transform's size.
     */
    get width(): number { return this._width; }

    /**
     * Gets the transform's height in pixels. Use {@link resize} to set the transform's size.
     */
    get height(): number { return this._height; }

    /**
     * Gets the transform's bearing in radians.
     */
    get bearingInRadians(): number { return this._bearingInRadians; }

    get lngRange(): [number, number] { return this._lngRange; }
    get latRange(): [number, number] { return this._latRange; }
    get worldCoordinateHelper(): WorldCoordinateHelper { return this._worldCoordinateHelper; }

    get pixelsToGLUnits(): [number, number] { return this._pixelsToGLUnits; }

    get minZoom(): number { return this._minZoom; }
    setMinZoom(zoom: number): void {
        if (this._minZoom === zoom) return;
        this._minZoom = zoom;
        const unmodified = this._unmodified;
        this.setZoom(this.applyConstrain(this._center, this.zoom).zoom);
        this._unmodified = unmodified;
    }

    get maxZoom(): number { return this._maxZoom; }
    setMaxZoom(zoom: number): void {
        if (this._maxZoom === zoom) return;
        this._maxZoom = zoom;
        const unmodified = this._unmodified;
        this.setZoom(this.applyConstrain(this._center, this.zoom).zoom);
        this._unmodified = unmodified;
    }

    get minPitch(): number { return this._minPitch; }
    setMinPitch(pitch: number): void {
        if (this._minPitch === pitch) return;
        this._minPitch = pitch;
        const unmodified = this._unmodified;
        this.setPitch(Math.max(this.pitch, pitch));
        this._unmodified = unmodified;
    }

    get maxPitch(): number { return this._maxPitch; }
    setMaxPitch(pitch: number): void {
        if (this._maxPitch === pitch) return;
        this._maxPitch = pitch;
        const unmodified = this._unmodified;
        this.setPitch(Math.min(this.pitch, pitch));
        this._unmodified = unmodified;
    }

    get renderWorldCopies(): boolean { return this._renderWorldCopies; }
    setRenderWorldCopies(renderWorldCopies: boolean): void {
        if (renderWorldCopies === undefined) {
            renderWorldCopies = true;
        } else if (renderWorldCopies === null) {
            renderWorldCopies = false;
        }

        if (renderWorldCopies && !this._worldCoordinateHelper.wraps) {
            warnOnce('renderWorldCopies has no effect in a projection whose world does not wrap.');
        }
        this._renderWorldCopies = renderWorldCopies;
    }

    get constrainOverride(): TransformConstrainFunction { return this._constrainOverride; }
    setConstrainOverride(constrain?: TransformConstrainFunction | null): void {
        if (constrain === undefined) constrain = null;
        if (this._constrainOverride === constrain) return;
        this._constrainOverride = constrain;
        this.constrainInternal();
        this._calcMatrices();
    }

    get worldSize(): number {
        return this._tileSize * this._scale;
    }

    get centerOffset(): Point {
        return this.centerPoint._sub(this.size._div(2));
    }

    /**
     * Gets the transform's dimensions packed into a Point object.
     */
    get size(): Point {
        return new Point(this._width, this._height);
    }

    get bearing(): number {
        return this._bearingInRadians / Math.PI * 180;
    }
    setBearing(bearing: number): void {
        const b = wrap(bearing, -180, 180) * Math.PI / 180;
        if (this._bearingInRadians === b) return;
        this._unmodified = false;
        this._bearingInRadians = b;
        this._calcMatrices();

        // 2x2 matrix for rotating points
        this._rotationMatrix = mat2.create();
        mat2.rotate(this._rotationMatrix, this._rotationMatrix, -this._bearingInRadians);
    }

    get rotationMatrix(): mat2 { return this._rotationMatrix; }

    get pitchInRadians(): number {
        return this._pitchInRadians;
    }
    get pitch(): number {
        return this._pitchInRadians / Math.PI * 180;
    }
    setPitch(pitch: number): void {
        const p = clamp(pitch, this.minPitch, this.maxPitch) / 180 * Math.PI;
        if (this._pitchInRadians === p) return;
        this._unmodified = false;
        this._pitchInRadians = p;
        this._calcMatrices();
    }

    get rollInRadians(): number {
        return this._rollInRadians;
    }
    get roll(): number {
        return this._rollInRadians / Math.PI * 180;
    }
    setRoll(roll: number): void {
        const r = roll / 180 * Math.PI;
        if (this._rollInRadians === r) return;
        this._unmodified = false;
        this._rollInRadians = r;
        this._calcMatrices();
    }

    get fovInRadians(): number {
        return this._fovInRadians;
    }
    get fov(): number {
        return radiansToDegrees(this._fovInRadians);
    }
    setFov(fov: number): void {
        fov = clamp(fov, 0.1, 150);
        if (this.fov === fov) return;
        this._unmodified = false;
        this._fovInRadians = degreesToRadians(fov);
        this._calcMatrices();
    }

    get zoom(): number { return this._zoom; }
    setZoom(zoom: number): void {
        const constrainedZoom = this.applyConstrain(this._center, zoom).zoom;
        if (this._zoom === constrainedZoom) return;
        this._unmodified = false;
        this._zoom = constrainedZoom;
        this._tileZoom = Math.max(0, Math.floor(constrainedZoom));
        this._scale = zoomScale(constrainedZoom);
        this.constrainInternal();
        this._calcMatrices();
    }

    get center(): LngLat { return this._center; }
    setCenter(center: LngLat): void {
        if (center.lat === this._center.lat && center.lng === this._center.lng) return;
        this._unmodified = false;
        this._center = center;
        this.constrainInternal();
        this._calcMatrices();
    }

    /**
     * Elevation at current center point, meters above sea level
     */
    get elevation(): number { return this._elevation; }
    setElevation(elevation: number): void {
        if (elevation === this._elevation) return;
        this._elevation = elevation;
        this.constrainInternal();
        this._calcMatrices();
    }

    get padding(): PaddingOptions { return this._edgeInsets.toJSON(); }
    setPadding(padding: PaddingOptions): void {
        if (this._edgeInsets.equals(padding)) return;
        this._unmodified = false;
        // Update edge-insets in-place
        this._edgeInsets.interpolate(this._edgeInsets, padding, 1);
        this.constrainInternal();
        this._calcMatrices();
    }

    /**
     * The center of the screen in pixels with the top-left corner being (0,0)
     * and +y axis pointing downwards. This accounts for padding.
     */
    get centerPoint(): Point {
        return this._edgeInsets.getCenter(this._width, this._height);
    }

    /**
     * @internal
     */
    get pixelsPerMeter(): number { return this._pixelPerMeter; }

    get unmodified(): boolean { return this._unmodified; }

    get cameraToCenterDistance(): number { return this._cameraToCenterDistance; }

    get nearZ(): number { return this._nearZ; }
    get farZ(): number { return this._farZ; }
    get autoCalculateNearFarZ(): boolean { return this._autoCalculateNearFarZ; }
    overrideNearFarZ(nearZ: number, farZ: number): void {
        this._autoCalculateNearFarZ = false;
        this._nearZ = nearZ;
        this._farZ = farZ;
        this._calcMatrices();
    }
    clearNearFarZOverride(): void {
        this._autoCalculateNearFarZ = true;
        this._calcMatrices();
    }

    /**
     * Returns if the padding params match
     *
     * @param padding - the padding to check against
     * @returns true if they are equal, false otherwise
     */
    isPaddingEqual(padding: PaddingOptions): boolean {
        return this._edgeInsets.equals(padding);
    }

    /**
     * Helper method to update edge-insets in place
     *
     * @param start - the starting padding
     * @param target - the target padding
     * @param t - the step/weight
     */
    interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void {
        this._unmodified = false;
        this._edgeInsets.interpolate(start, target, t);
        this.constrainInternal();
        this._calcMatrices();
    }

    resize(width: number, height: number, constrain: boolean = true): void {
        this._width = width;
        this._height = height;
        if (constrain) this.constrainInternal();
        this._calcMatrices();
    }

    /**
     * Returns the maximum geographical bounds the map is constrained to, or `null` if none set.
     * @returns max bounds
     */
    getMaxBounds(): LngLatBounds | null {
        if (this._latRange?.length !== 2 ||
            this._lngRange?.length !== 2) return null;

        return new LngLatBounds([this._lngRange[0], this._latRange[0]], [this._lngRange[1], this._latRange[1]]);
    }

    /**
     * Replaces the lng/lat to world coordinate mapping, mercator by default. The projection factory calls this on a
     * transform it just built for a registered CRS, and `clone` calls it to keep the mapping.
     */
    setWorldCoordinateHelper(worldCoordinateHelper: WorldCoordinateHelper): void {
        this._worldCoordinateHelper = worldCoordinateHelper;
    }

    /**
     * Sets or clears the map's geographical constraints.
     * @param bounds - A {@link LngLatBounds} object describing the new geographic boundaries of the map.
     */
    setMaxBounds(bounds?: LngLatBounds | null): void {
        if (bounds) {
            this._lngRange = [bounds.getWest(), bounds.getEast()];
            this._latRange = [bounds.getSouth(), bounds.getNorth()];
            this.constrainInternal();
        } else {
            this._lngRange = null;
            this._latRange = [-MAX_VALID_LATITUDE, MAX_VALID_LATITUDE];
        }
    }

    /**
     * When the map is pitched, some of the 3D features that intersect a query will not intersect
     * the query at the surface of the earth. Instead the feature may be closer and only intersect
     * the query because it extrudes into the air.
     * @param queryGeometry - For point queries, the line from the query point to the "camera point",
     * for other geometries, the envelope of the query geometry and the "camera point"
     * @returns a geometry that includes all of the original query as well as all possible ares of the
     * screen where the *base* of a visible extrusion could be.
     *
     */
    getCameraQueryGeometry(cameraPoint: Point, queryGeometry: Point[]): Point[] {
        if (queryGeometry.length === 1) {
            return [queryGeometry[0], cameraPoint];
        } else {
            const {minX, minY, maxX, maxY} = Bounds.fromPoints(queryGeometry).extend(cameraPoint);
            return [
                new Point(minX, minY),
                new Point(maxX, minY),
                new Point(maxX, maxY),
                new Point(minX, maxY),
                new Point(minX, minY)
            ];
        }
    }

    applyConstrain: TransformConstrainFunction = (lngLat, zoom) => {
        if (this._constrainOverride !== null) {
            return this._constrainOverride(lngLat, zoom);
        } else {
            return this._callbacks.defaultConstrain(lngLat, zoom);
        }
    };

    /**
     * @internal
     * Snaps the transform's center, zoom, etc. into the valid range.
     */
    private constrainInternal(): void {
        if (!this.center || !this._width || !this._height || this._constraining) return;
        this._constraining = true;
        const unmodified = this._unmodified;
        const {center, zoom} = this.applyConstrain(this.center, this.zoom);
        this.setCenter(center);
        this.setZoom(zoom);
        this._unmodified = unmodified;
        this._constraining = false;
    }

    /**
     * This function is called every time one of the transform's defining properties (center, pitch, etc.) changes.
     * This function should update the transform's internal data, such as matrices.
     * Any derived `_calcMatrices` function should also call the base function first. The base function only depends on the `_width` and `_height` fields.
     * While either dimension is zero there is no view to build, so the derived function is not called at all.
     */
    private _calcMatrices(): void {
        this._pixelPerMeter = this.worldCoordinateHelper.worldZFromAltitude(1, this.center) * this.worldSize;
        if (!this._width || !this._height) {
            return;
        }
        this._pixelsToGLUnits = [2 / this._width, -2 / this._height];

        let m = mat4.identity(new Float64Array(16));
        mat4.scale(m, m, [this._width / 2, -this._height / 2, 1]);
        mat4.translate(m, m, [1, -1, 0]);
        this._clipSpaceToPixelsMatrix = m;

        m = mat4.identity(new Float64Array(16));
        mat4.scale(m, m, [1, -1, 1]);
        mat4.translate(m, m, [-1, -1, 0]);
        mat4.scale(m, m, [2 / this._width, 2 / this._height, 1]);
        this._pixelsToClipSpaceMatrix = m;
        const halfFov = this.fovInRadians / 2;
        this._cameraToCenterDistance = 0.5 / Math.tan(halfFov) * this._height;
        this._callbacks.calcMatrices();
    }

    calculateCenterFromCameraLngLatAlt(lnglat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} {
        const cameraBearing = bearing !== undefined ? bearing : this.bearing;
        const cameraPitch = pitch = pitch !== undefined ? pitch : this.pitch;

        const {distanceToCenter, clampedElevation} = this._distanceToCenterFromAltElevationPitch(alt, this.elevation, cameraPitch);
        const {x, y} = cameraDirectionFromPitchBearing(cameraPitch, cameraBearing);
        
        const worldCoordinateHelper = this.worldCoordinateHelper;
        const cameraLngLat = LngLat.convert(lnglat);
        const camMercator = worldCoordinateHelper.worldFromLngLat(cameraLngLat.lng, cameraLngLat.lat, alt);
        const {centerMercator, dMercator} = this._centerAlongView(camMercator, x, y, distanceToCenter, worldCoordinateHelper.metersPerWorldUnit(camMercator.x, camMercator.y));
        const center = worldCoordinateHelper.lngLatFromWorld(centerMercator.x, centerMercator.y);
        const zoom = scaleZoom(this.height / 2 / Math.tan(this.fovInRadians / 2) / dMercator / this.tileSize);
        return {center, elevation: clampedElevation, zoom};
    }

    /**
     * Moves the center along the view to the given elevation with the camera where it is, and sets the zoom to match.
     * The center is found along the view from the camera's position, which is kept, by {@link _centerAlongView},
     * starting from the old center's mercator scale. The matrices are recomputed even where `setZoom` leaves the zoom
     * as it was, as at a zoom bound, since the center and its elevation have moved.
     * @param elevation - the elevation in meters for the center
     */
    recalculateZoomAndCenter(elevation: number): void {
        if (this.elevation - elevation === 0) return;

        const cameraPitch = this.pitch;
        const cameraBearing = this.bearing;
        const {x, y, z} = cameraDirectionFromPitchBearing(cameraPitch, cameraBearing);
        const worldCoordinateHelper = this.worldCoordinateHelper;
        const originalCenterMercator = worldCoordinateHelper.worldFromLngLat(this.center.lng, this.center.lat, this.elevation);
        const originalMetersPerMercUnit = 1 / worldCoordinateHelper.worldZFromAltitude(1, this.center);
        const dCamMercator = this.cameraToCenterDistance / this.worldSize;
        const camMercator = new MercatorCoordinate(originalCenterMercator.x - x * dCamMercator, originalCenterMercator.y - y * dCamMercator, originalCenterMercator.z + z * dCamMercator);

        const {distanceToCenter, clampedElevation} = this._distanceToCenterFromAltElevationPitch(camMercator.z * originalMetersPerMercUnit, elevation, cameraPitch);
        const {centerMercator, dMercator} = this._centerAlongView(camMercator, x, y, distanceToCenter, originalMetersPerMercUnit);
        const center = worldCoordinateHelper.lngLatFromWorld(centerMercator.x, centerMercator.y);
        const zoom = scaleZoom(this.cameraToCenterDistance / dMercator / this.tileSize);

        // Update matrices
        this._elevation = clampedElevation;
        this._center = center;
        const previousZoom = this._zoom;
        this.setZoom(zoom);
        if (this._zoom === previousZoom) {
            this._unmodified = false;
            this.constrainInternal();
            this._calcMatrices();
        }
    }

    /**
     * The center `distanceToCenter` meters from the camera along the view, in world units, and that distance in
     * world units. The mercator scale changes with latitude and the center's latitude is unknown until the center
     * is: each pass places the center with the scale at the center the last pass found, from `metersPerMercUnit`,
     * and stops once two passes agree. Zoomed far out at a high latitude the scale changes faster along the view than
     * a pass follows; a pass that would move the center further than the last one did ends the search at the last
     * stable center instead of oscillating (#6775).
     * @param cameraMercator - the camera's position
     * @param x - the view direction's x component, see {@link cameraDirectionFromPitchBearing}
     * @param y - the view direction's y component
     * @param distanceToCenter - the camera's distance to the center in meters
     * @param metersPerMercUnit - the mercator scale to start from
     */
    private _centerAlongView(cameraMercator: MercatorCoordinate, x: number, y: number, distanceToCenter: number, metersPerMercUnit: number): {centerMercator: MercatorCoordinate; dMercator: number} {
        let dMercator = distanceToCenter / metersPerMercUnit;
        let centerMercator = new MercatorCoordinate(cameraMercator.x + x * dMercator, cameraMercator.y + y * dMercator);
        let step = Infinity;
        for (let pass = 1; pass < MAX_CENTER_PASSES; pass++) {
            const centerMetersPerMercUnit = this.worldCoordinateHelper.metersPerWorldUnit(centerMercator.x, centerMercator.y);
            const nextStep = Math.abs(centerMetersPerMercUnit - metersPerMercUnit);
            if (nextStep <= 1e-12 * metersPerMercUnit || nextStep >= step) break;
            step = nextStep;
            metersPerMercUnit = centerMetersPerMercUnit;
            dMercator = distanceToCenter / metersPerMercUnit;
            centerMercator = new MercatorCoordinate(cameraMercator.x + x * dMercator, cameraMercator.y + y * dMercator);
        }
        return {centerMercator, dMercator};
    }

    _distanceToCenterFromAltElevationPitch(alt: number, elevation: number, pitch: number): {distanceToCenter: number; clampedElevation: number} {
        const dzNormalized = -Math.cos(degreesToRadians(pitch));
        const altitudeAGL = alt - elevation;
        let distanceToCenter: number;
        let clampedElevation = elevation;
        if (dzNormalized * altitudeAGL >= 0.0 || Math.abs(dzNormalized) < Math.cos(degreesToRadians(maxMercatorHorizonAngle))) {
            distanceToCenter = 10000;
            clampedElevation = alt + distanceToCenter * dzNormalized;
        } else {
            distanceToCenter = -altitudeAGL / dzNormalized;
        }
        return {distanceToCenter, clampedElevation};
    }

    getCameraPoint(): Point {
        const pitch = this.pitchInRadians;
        const offset = Math.tan(pitch) * (this.cameraToCenterDistance || 1);
        return this.centerPoint.add(new Point(offset * Math.sin(this.rollInRadians), offset * Math.cos(this.rollInRadians)));
    }

    getMercatorTileCoordinates(overscaledTileID?: { canonical: {x: number; y: number; z: number}} | null): [number, number, number, number] {
        if (!overscaledTileID) {
            return [0, 0, 1, 1];
        }
        const scale = (overscaledTileID.canonical.z >= 0) ? (1 << overscaledTileID.canonical.z) : Math.pow(2.0, overscaledTileID.canonical.z);
        return [
            overscaledTileID.canonical.x / scale,
            overscaledTileID.canonical.y / scale,
            1.0 / scale / EXTENT,
            1.0 / scale / EXTENT
        ];
    }
}
