import {Event, ErrorEvent, Evented} from '../util/evented';
import {ensureError, extend, warnOnce, type ExactlyOne} from '../util/util';
import {EXTENT} from '../data/extent';
import {ResourceType} from '../util/request_manager';
import {browser} from '../util/browser';
import {applySourceDiff, mergeSourceDiffs, toUpdateable} from './geojson_source_diff';
import {getGeoJSONBounds} from '../util/geojson_bounds';
import {MessageType} from '../util/actor_messages';
import {tileIdToLngLatBounds} from '../tile/tile_id_to_lng_lat_bounds';

import type {LngLatBounds} from '../geo/lng_lat_bounds';
import type {Source} from './source';
import type {Map} from '../ui/map';
import type {Dispatcher} from '../util/dispatcher';
import type {Tile} from '../tile/tile';
import type {Actor} from '../util/actor';
import type {GeoJSONWorkerSourceLoadDataResult} from '../util/actor_messages';
import type {GeoJSONSourceSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {GeoJSONFeatureId, GeoJSONSourceDiff} from './geojson_source_diff';
import type {GeoJSONWorkerOptions, LoadGeoJSONParameters} from './geojson_worker_source';
import type {WorkerTileParameters} from './worker_source';

/**
 * Options object for GeoJSONSource.
 */
export type GeoJSONSourceOptions = GeoJSONSourceSpecification & {
    workerOptions?: GeoJSONWorkerOptions;
    collectResourceTiming?: boolean;
    data: GeoJSON.GeoJSON | string;
};

export type GeoJSONSourceInternalOptions = {
    data?: GeoJSON.GeoJSON | string | undefined;
    cluster?: boolean;
    clusterMaxZoom?: number;
    clusterRadius?: number;
    clusterMinPoints?: number;
    generateId?: boolean;
};

/**
 * @internal
 */
export type GeoJSONSourceShouldReloadTileOptions = {
    /**
     * Refresh all tiles that WILL contain these bounds.
     */
    affectedBounds: LngLatBounds[];
};

/**
 * The cluster options to set
 */
export type SetClusterOptions = {
    /**
     * Whether or not to cluster
     */
    cluster?: boolean;
    /**
     * The cluster's max zoom.
     * Non-integer values are rounded to the closest integer due to supercluster integer value requirements.
     */
    clusterMaxZoom?: number;
    /**
     * The cluster's radius
     */
    clusterRadius?: number;
};

/**
 * A source containing GeoJSON.
 * (See the [Style Specification](https://maplibre.org/maplibre-style-spec/#sources-geojson) for detailed documentation of options.)
 *
 * @group Sources
 *
 * @example
 * ```ts
 * map.addSource('some id', {
 *     type: 'geojson',
 *     data: 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_ports.geojson'
 * });
 * ```
 *
 * @example
 * ```ts
 * map.addSource('some id', {
 *    type: 'geojson',
 *    data: {
 *        "type": "FeatureCollection",
 *        "features": [{
 *            "type": "Feature",
 *            "properties": {},
 *            "geometry": {
 *                "type": "Point",
 *                "coordinates": [
 *                    -76.53063297271729,
 *                    39.18174077994108
 *                ]
 *            }
 *        }]
 *    }
 * });
 * ```
 *
 * @example
 * ```ts
 * map.getSource('some id').setData({
 *   "type": "FeatureCollection",
 *   "features": [{
 *       "type": "Feature",
 *       "properties": { "name": "Null Island" },
 *       "geometry": {
 *           "type": "Point",
 *           "coordinates": [ 0, 0 ]
 *       }
 *   }]
 * });
 * ```
 * @see [Draw GeoJSON points](https://maplibre.org/maplibre-gl-js/docs/examples/draw-geojson-points/)
 * @see [Add a GeoJSON line](https://maplibre.org/maplibre-gl-js/docs/examples/add-a-geojson-line/)
 * @see [Create a heatmap from points](https://maplibre.org/maplibre-gl-js/docs/examples/create-a-heatmap-layer/)
 * @see [Create and style clusters](https://maplibre.org/maplibre-gl-js/docs/examples/create-and-style-clusters/)
 */
export class GeoJSONSource extends Evented implements Source {
    type: 'geojson';
    id: string;
    minzoom: number;
    maxzoom: number;
    tileSize: number;
    attribution: string;
    promoteId: PromoteIdSpecification;

    isTileClipped: boolean;
    reparseOverscaled: boolean;
    _data: ExactlyOne<{
        url: string;
        geojson: GeoJSON.GeoJSON;
        updateable: globalThis.Map<GeoJSONFeatureId, GeoJSON.Feature>;
    }>;
    _options: GeoJSONSourceInternalOptions;
    workerOptions: GeoJSONWorkerOptions;
    map: Map;
    actor: Actor;
    _isUpdatingWorker: boolean;
    _pendingWorkerUpdate: {
        data?: GeoJSON.GeoJSON | string;
        diff?: GeoJSONSourceDiff;
        updateCluster?: boolean;
    };
    _collectResourceTiming: boolean;
    _removed: boolean;

    /** @internal */
    constructor(id: string, options: GeoJSONSourceOptions, dispatcher: Dispatcher, eventedParent: Evented) {
        super();

        this.id = id;

        // `type` is a property rather than a constant to make it easy for 3rd
        // parties to use GeoJSONSource to build their own source types.
        this.type = 'geojson';

        this.minzoom = 0;
        this.maxzoom = 18;
        this.tileSize = 512;
        this.isTileClipped = true;
        this.reparseOverscaled = true;
        this._removed = false;
        this._isUpdatingWorker = false;
        this._pendingWorkerUpdate = {data: options.data};

        this.actor = dispatcher.getActor();
        this.setEventedParent(eventedParent);

        this._data = typeof options.data === 'string' ? {url: options.data} : {geojson: options.data};
        this._options = extend({}, options);

        this._collectResourceTiming = options.collectResourceTiming;

        if (options.maxzoom !== undefined) this.maxzoom = options.maxzoom;
        if (options.type) this.type = options.type;
        if (options.attribution) this.attribution = options.attribution;
        this.promoteId = options.promoteId;

        if (options.clusterMaxZoom !== undefined && this.maxzoom <= options.clusterMaxZoom) {
            warnOnce(`The maxzoom value "${this.maxzoom}" is expected to be greater than the clusterMaxZoom value "${options.clusterMaxZoom}".`);
        }

        // sent to the worker, along with `url: ...` or `data: literal geojson`,
        // so that it can load/parse/index the geojson data
        // extending with `options.workerOptions` helps to make it easy for
        // third-party sources to hack/reuse GeoJSONSource.
        this.workerOptions = extend({
            source: this.id,
            geojsonVtOptions: {
                buffer: this._pixelsToTileUnits(options.buffer !== undefined ? options.buffer : 128),
                tolerance: this._pixelsToTileUnits(options.tolerance !== undefined ? options.tolerance : 0.375),
                extent: EXTENT,
                maxZoom: this.maxzoom,
                lineMetrics: options.lineMetrics || false,
                generateId: options.generateId || false,
                promoteId: typeof options.promoteId === 'string' ? options.promoteId : undefined,
                cluster: options.cluster || false,
                clusterOptions: {
                    maxZoom: this._getClusterMaxZoom(options.clusterMaxZoom),
                    minPoints: Math.max(2, options.clusterMinPoints || 2),
                    extent: EXTENT,
                    radius: this._pixelsToTileUnits(options.clusterRadius || 50),
                    log: false,
                    generateId: options.generateId || false
                },
            },
            clusterProperties: options.clusterProperties,
            filter: options.filter
        }, options.workerOptions);
    }

    private _hasPendingWorkerUpdate(): boolean {
        return this._pendingWorkerUpdate.data !== undefined || this._pendingWorkerUpdate.diff !== undefined || this._pendingWorkerUpdate.updateCluster;
    }

    private _pixelsToTileUnits(pixelValue: number): number {
        return pixelValue * (EXTENT / this.tileSize);
    }

    private _getClusterMaxZoom(clusterMaxZoom: number): number {
        const effectiveClusterMaxZoom = clusterMaxZoom ? Math.round(clusterMaxZoom) : this.maxzoom - 1;
        if (!(Number.isInteger(clusterMaxZoom) || clusterMaxZoom === undefined)) {
            warnOnce(`Integer expected for option 'clusterMaxZoom': provided value "${clusterMaxZoom}" rounded to "${effectiveClusterMaxZoom}"`);
        }
        return effectiveClusterMaxZoom;
    }

    async load() {
        await this._updateWorkerData();
    }

    onAdd(map: Map) {
        this.map = map;
        this.load();
    }

    /**
     * Sets the GeoJSON data and re-renders the map.
     *
     * @param data - A GeoJSON data object or a URL to one. The latter is preferable in the case of large GeoJSON files.
     * @param waitForCompletion - If true, the method will return a promise that resolves when set data is complete.
     */
    setData(data: GeoJSON.GeoJSON | string, waitForCompletion: true): Promise<void>;
    setData(data: GeoJSON.GeoJSON | string, waitForCompletion?: false): this;
    setData(data: GeoJSON.GeoJSON | string, waitForCompletion?: boolean): this | Promise<void> {
        this._data = typeof data === 'string' ? {url: data} : {geojson: data};
        this._pendingWorkerUpdate = {data};
        const updatePromise = this._updateWorkerData();
        if (waitForCompletion) return updatePromise;
        return this;
    }

    /**
     * Updates the source's GeoJSON, and re-renders the map.
     *
     * For sources with lots of features, this method can be used to make updates more quickly.
     *
     * This approach requires unique IDs for every feature in the source. The IDs can either be specified on the feature,
     * or by using the promoteId option to specify which property should be used as the ID.
     *
     * It is an error to call updateData on a source that did not have unique IDs for each of its features already.
     *
     * Updates are applied on a best-effort basis, updating an ID that does not exist will not result in an error.
     *
     * @param diff - The changes that need to be applied.
     * @param waitForCompletion - If true, the method will return a promise that resolves when the update is complete.
     */
    updateData(diff: GeoJSONSourceDiff, waitForCompletion: true): Promise<void>;
    updateData(diff: GeoJSONSourceDiff, waitForCompletion?: false): this;
    updateData(diff: GeoJSONSourceDiff, waitForCompletion?: boolean): this | Promise<void> {
        this._pendingWorkerUpdate.diff = mergeSourceDiffs(this._pendingWorkerUpdate.diff, diff);
        const updatePromise = this._updateWorkerData();
        if (waitForCompletion) return updatePromise;
        return this;
    }

    /**
     * Allows to get the source's actual GeoJSON data.
     *
     * @returns a promise which resolves to the source's actual GeoJSON data
     */
    async getData(): Promise<GeoJSON.GeoJSON> {
        if (this._data.url) {
            await this.once('data'); // wait for loading to complete
        }
        if (this._data.geojson) {
            return this._data.geojson;
        }
        return {
            type: 'FeatureCollection',
            features: Array.from(this._data.updateable.values())
        };
    }

    /**
     * Allows getting the source's boundaries.
     * If there's a problem with the source's data, it will return an empty {@link LngLatBounds}.
     * @returns a promise which resolves to the source's boundaries
     */
    async getBounds(): Promise<LngLatBounds> {
        return getGeoJSONBounds(await this.getData());
    }

    /**
     * To disable/enable clustering on the source options
     * @param options - The options to set
     * @example
     * ```ts
     * map.getSource('some id').setClusterOptions({cluster: false});
     * map.getSource('some id').setClusterOptions({cluster: false, clusterRadius: 50, clusterMaxZoom: 14});
     * ```
     */
    setClusterOptions(options: SetClusterOptions): this {
        this.workerOptions.geojsonVtOptions.cluster = options.cluster;
        if (options.clusterRadius !== undefined) {
            this.workerOptions.geojsonVtOptions.clusterOptions.radius = this._pixelsToTileUnits(options.clusterRadius);
        }
        if (options.clusterMaxZoom !== undefined) {
            this.workerOptions.geojsonVtOptions.clusterOptions.maxZoom = this._getClusterMaxZoom(options.clusterMaxZoom);
        }
        this._pendingWorkerUpdate.updateCluster = true;
        this._updateWorkerData();
        return this;
    }

    /**
     * For clustered sources, fetches the zoom at which the given cluster expands.
     *
     * @param clusterId - The value of the cluster's `cluster_id` property.
     * @returns a promise that is resolved with the zoom number
     */
    getClusterExpansionZoom(clusterId: number): Promise<number> {
        return this.actor.sendAsync({type: MessageType.getClusterExpansionZoom, data: {type: this.type, clusterId, source: this.id}});
    }

    /**
     * For clustered sources, fetches the children of the given cluster on the next zoom level (as an array of GeoJSON features).
     *
     * @param clusterId - The value of the cluster's `cluster_id` property.
     * @returns a promise that is resolved when the features are retrieved
     */
    getClusterChildren(clusterId: number): Promise<GeoJSON.Feature[]> {
        return this.actor.sendAsync({type: MessageType.getClusterChildren, data: {type: this.type, clusterId, source: this.id}});
    }

    /**
     * For clustered sources, fetches the original points that belong to the cluster (as an array of GeoJSON features).
     *
     * @param clusterId - The value of the cluster's `cluster_id` property.
     * @param limit - The maximum number of features to return.
     * @param offset - The number of features to skip (e.g. for pagination).
     * @returns a promise that is resolved when the features are retrieved
     * @example
     * Retrieve cluster leaves on click
     * ```ts
     * map.on('click', 'clusters', (e) => {
     *   let features = map.queryRenderedFeatures(e.point, {
     *     layers: ['clusters']
     *   });
     *
     *   let clusterId = features[0].properties.cluster_id;
     *   let pointCount = features[0].properties.point_count;
     *   let clusterSource = map.getSource('clusters');
     *
     *   const features = await clusterSource.getClusterLeaves(clusterId, pointCount);
     *   // Print cluster leaves in the console
     *   console.log('Cluster leaves:', features);
     * });
     * ```
     */
    getClusterLeaves(clusterId: number, limit: number, offset: number): Promise<GeoJSON.Feature[]> {
        return this.actor.sendAsync({type: MessageType.getClusterLeaves, data: {
            type: this.type,
            source: this.id,
            clusterId,
            limit,
            offset
        }});
    }

    /**
     * Responsible for invoking WorkerSource's geojson.loadData target, which
     * handles loading the geojson data and preparing to serve it up as tiles,
     * using geojson-vt or supercluster as appropriate.
     */
    async _updateWorkerData(): Promise<void> {
        if (this._isUpdatingWorker) return;

        if (!this._hasPendingWorkerUpdate()) {
            warnOnce(`No pending worker updates for GeoJSONSource ${this.id}.`);
            return;
        }

        const {data, diff, updateCluster} = this._pendingWorkerUpdate;
        // delay awaiting params until _isUpdatingWorker is set, otherwise, a race condition could happen
        const params = this._getLoadGeoJSONParameters(data, diff, updateCluster);

        if (data !== undefined) {
            this._pendingWorkerUpdate.data = undefined;
        } else if (diff) {
            this._pendingWorkerUpdate.diff = undefined;
        } else if (updateCluster) {
            this._pendingWorkerUpdate.updateCluster = undefined;
        }

        await this._dispatchWorkerUpdate(params);
    }

    /**
     * Create the parameters object that will be sent to the worker and used to load GeoJSON.
     */
    private async _getLoadGeoJSONParameters(data: string | GeoJSON.GeoJSON<GeoJSON.Geometry>, diff: GeoJSONSourceDiff, updateCluster: boolean): Promise<LoadGeoJSONParameters | undefined> {
        const params: LoadGeoJSONParameters = extend({type: this.type}, this.workerOptions);

        // Data comes from a remote url
        if (typeof data === 'string') {
            params.request = await this.map._requestManager.transformRequest(browser.resolveURL(data), ResourceType.Source);
            params.request.collectResourceTiming = this._collectResourceTiming;
            return params;
        }

        // Data is a geojson object
        if (data !== undefined) {
            params.data = data;
            return params;
        }

        // Data is a differential update
        if (diff) {
            params.dataDiff = diff;
            return params;
        }

        // Update supercluster with the latest worker cluster options
        if (updateCluster) {
            params.updateCluster = true;
            return params;
        }
    }

    /**
     * Send the worker update data from the main thread to the worker
     */
    private async _dispatchWorkerUpdate(optionsPromise: Promise<LoadGeoJSONParameters>) {
        this._isUpdatingWorker = true;
        this.fire(new Event('dataloading', {dataType: 'source'}));

        try {
            const options = await optionsPromise;
            const result = await this.actor.sendAsync({type: MessageType.loadData, data: options});
            this._isUpdatingWorker = false;

            if (this._removed || result.abandoned) {
                this.fire(new Event('dataabort', {dataType: 'source'}));
                return;
            }

            // Update the copy of the data in this source with the worker result. (only sent for url based geojson data)
            if (result.data) {
                this._data = {geojson: result.data};
            }

            const affectedGeometries = this._applyDiffToSource(options.dataDiff);
            const shouldReloadTileOptions = this._getShouldReloadTileOptions(affectedGeometries);

            const eventData = {dataType: 'source'};
            this._applyResourceTiming(eventData, result);

            // Fire the metadata event to let the TileManager know it's ok to start requesting tiles.
            this.fire(new Event('data', {...eventData, sourceDataType: 'metadata'}));
            this.fire(new Event('data', {...eventData, sourceDataType: 'content', shouldReloadTileOptions}));
        } catch (err) {
            this._isUpdatingWorker = false;

            if (this._removed) {
                this.fire(new Event('dataabort', {dataType: 'source'}));
                return;
            }

            this.fire(new ErrorEvent(ensureError(err)));
        } finally {
            // If there is more pending data, update the worker again.
            if (this._hasPendingWorkerUpdate()) {
                this._updateWorkerData();
            }
        }
    }

    /**
     * Apply resource timing data to the event object.
     */
    private _applyResourceTiming(eventData: {dataType: string}, result: GeoJSONWorkerSourceLoadDataResult) {
        if (!this._collectResourceTiming) return;

        const timingData = result.resourceTiming?.[this.id];
        if (!timingData) return;

        const resourceTiming = timingData.slice(0);
        if (!resourceTiming?.length) return;

        extend(eventData, {resourceTiming});
    }

    /**
     * Apply a diff to this source's data and return the affected feature geometries.
     * @param diff - The {@link GeoJSONSourceDiff} to apply.
     * @returns The affected geometries, or undefined if the diff is not applicable or all geometries are affected.
     */
    private _applyDiffToSource(diff: GeoJSONSourceDiff): GeoJSON.Geometry[] | undefined {
        if (!diff) {
            return undefined;
        }

        const promoteId = typeof this.promoteId === 'string' ? this.promoteId : undefined;

        // Lazily convert `this._data` to updateable if it's not already
        if (!this._data.url && !this._data.updateable) {
            const updateable = toUpdateable(this._data.geojson, promoteId);
            if (!updateable) throw new Error(`GeoJSONSource "${this.id}": GeoJSON data is not compatible with updateData`);
            this._data = {updateable};
        }

        if (!this._data.updateable) {
            return undefined;
        }
        const affectedGeometries = applySourceDiff(this._data.updateable, diff, promoteId);

        if (diff.removeAll || this._options.cluster) {
            return undefined;
        }

        return affectedGeometries;
    }

    /**
     * Get options for use in determining whether to reload a tile based on the modified features.
     * @param affectedGeometries - The feature geometries affected by the update.
     * @returns A {@link GeoJSONSourceShouldReloadTileOptions} object which contains an array of affected bounds caused by the update.
     */
    private _getShouldReloadTileOptions(affectedGeometries: GeoJSON.Geometry[]): GeoJSONSourceShouldReloadTileOptions | undefined {
        if (!affectedGeometries) return undefined;

        const affectedBounds = affectedGeometries
            .filter(Boolean)
            .map(g => getGeoJSONBounds(g));

        return {affectedBounds};
    }

    /**
     * Determine whether a tile should be reloaded based on a set of options associated with a {@link MapSourceDataChangedEvent}.
     * @internal
     */
    shouldReloadTile(tile: Tile, {affectedBounds}: GeoJSONSourceShouldReloadTileOptions) : boolean {
        if (tile.state === 'loading') {
            return true;
        }
        if (tile.state === 'unloaded') {
            return false;
        }

        // Update the tile if contained or will contain an updated feature.
        const {buffer, extent} = this.workerOptions.geojsonVtOptions;
        const tileBounds = tileIdToLngLatBounds(
            tile.tileID.canonical,
            buffer / extent
        );
        for (const bounds of affectedBounds) {
            if (tileBounds.intersects(bounds)) {
                return true;
            }
        }

        return false;
    }

    loaded(): boolean {
        return !this._isUpdatingWorker && !this._hasPendingWorkerUpdate();
    }

    async loadTile(tile: Tile): Promise<void> {
        const message = !tile.actor ?  MessageType.loadTile :  MessageType.reloadTile;
        tile.actor = this.actor;
        const params: WorkerTileParameters = {
            type: this.type,
            uid: tile.uid,
            tileID: tile.tileID,
            zoom: tile.tileID.overscaledZ,
            maxZoom: this.maxzoom,
            tileSize: this.tileSize,
            source: this.id,
            pixelRatio: this.map.getPixelRatio(),
            showCollisionBoxes: this.map.showCollisionBoxes,
            promoteId: this.promoteId,
            subdivisionGranularity: this.map.style.projection.subdivisionGranularity
        };

        tile.abortController = new AbortController();
        const data = await this.actor.sendAsync({type: message, data: params}, tile.abortController);
        delete tile.abortController;
        tile.unloadVectorData();

        if (!tile.aborted) {
            tile.loadVectorData(data, this.map.painter, message ===  MessageType.reloadTile);
        }
    }

    async abortTile(tile: Tile) {
        if (tile.abortController) {
            tile.abortController.abort();
            delete tile.abortController;
        }
        tile.aborted = true;
    }

    async unloadTile(tile: Tile) {
        tile.unloadVectorData();
        await this.actor.sendAsync({type: MessageType.removeTile, data: {uid: tile.uid, type: this.type, source: this.id}});
    }

    onRemove() {
        this._removed = true;
        this.actor.sendAsync({type: MessageType.removeSource, data: {type: this.type, source: this.id}});
    }

    serialize(): GeoJSONSourceSpecification {
        return extend({}, this._options, {
            type: this.type,
            data: this._data.updateable ?
                {
                    type: 'FeatureCollection',
                    features: Array.from(this._data.updateable.values())
                } :
                this._data.url || this._data.geojson
        });
    }

    hasTransition() {
        return false;
    }
}
