import {uniqueId, parseCacheControl} from '../util/util.ts';
import {deserialize as deserializeBucket} from '../data/bucket.ts';
import {GEOJSON_TILE_LAYER_NAME, type FeatureIndex, type QueryResults} from '../data/feature_index.ts';
import {GeoJSONFeature} from '../util/vectortile_to_geojson.ts';
import {featureFilter} from '@maplibre/maplibre-gl-style-spec';
import {SymbolBucket} from '../data/bucket/symbol_bucket.ts';
import {CollisionBoxArray} from '../data/array_types.g.ts';
import {Texture} from '../webgl/texture.ts';
import {now} from '../util/time_control.ts';
import {toEvaluationFeature} from '../data/evaluation_feature.ts';
import {EvaluationParameters} from '../style/evaluation_parameters.ts';
import {rtlMainThreadPluginFactory} from '../source/rtl_text_plugin_main_thread.ts';

import type {SourceFeatureState} from '../source/source_state.ts';
import type {Bucket} from '../data/bucket.ts';
import type {StyleLayer} from '../style/style_layer.ts';
import type {TileEncoding, WorkerTileResult} from '../source/worker_source.ts';
import type {Actor} from '../util/actor.ts';
import type {DEMData} from '../data/dem_data.ts';
import type {AlphaImage} from '../util/image.ts';
import type {ImageAtlas} from '../render/image_atlas.ts';
import type {ImageManager} from '../render/image_manager.ts';
import type {Context} from '../webgl/context.ts';
import type {OverscaledTileID} from './tile_id.ts';
import type {Framebuffer} from '../webgl/framebuffer.ts';
import type {IReadonlyTransform} from '../geo/transform_interface.ts';
import type {LayerFeatureStates} from '../source/source_state.ts';
import type Point from '@mapbox/point-geometry';
import type {mat4} from 'gl-matrix';
import type {ExpiryData} from '../util/ajax.ts';
import type {QueryRenderedFeaturesOptionsStrict, QuerySourceFeatureOptionsStrict} from '../source/query_features.ts';
import type {DashEntry} from '../render/line_atlas.ts';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
import type {Painter, RTTObject} from '../render/painter.ts';

const CLOCK_SKEW_RETRY_TIMEOUT = 30000;

/**
 * The tile's state, can be:
 *
 * - `loading` Tile data is in the process of loading.
 * - `loaded` Tile data has been loaded. Tile can be rendered.
 * - `reloading` Tile data has been loaded and is being updated. Tile can be rendered.
 * - `unloaded` Tile data has been deleted.
 * - `errored` Tile data was not loaded because of an error.
 * - `expired` Tile data was previously loaded, but has expired per its HTTP headers and is in the process of refreshing.
 */
export type TileState = 'loading' | 'loaded' | 'reloading' | 'unloaded' | 'errored' | 'expired';

/** @internal */
type CrossFadeArgs = {
    fadingRole: FadingRoles;
    fadingDirection: FadingDirections;
    fadingParentID?: OverscaledTileID;
    fadeEndTime: number;
};

export enum FadingRoles {
    Base, Parent
}
export enum FadingDirections {
    Departing, Incoming
}

/**
 * A tile object is the combination of a Coordinate, which defines
 * its place, as well as a unique ID and data tracking for its content
 */
export class Tile {
    tileID: OverscaledTileID;
    uid: number;
    uses: number;
    tileSize: number;
    buckets: {[_: string]: Bucket};
    latestFeatureIndex: FeatureIndex | null;
    latestRawTileData: ArrayBuffer;
    latestEncoding: TileEncoding;
    imageAtlas: ImageAtlas;
    imageAtlasTexture: Texture;
    dashPositions: {[_: string]: DashEntry};
    glyphAtlasImage: AlphaImage;
    glyphAtlasTexture: Texture;
    etag?: string;
    expirationTime: any;
    expiredRequestCount: number;
    state: TileState;
    fadingRole: FadingRoles;
    fadingDirection: FadingDirections;
    fadingParentID: OverscaledTileID;
    selfFading: boolean;
    timeAdded: number = 0;
    fadeEndTime: number = 0;
    fadeOpacity: number = 1;
    collisionBoxArray: CollisionBoxArray;
    redoWhenDone: boolean;
    showCollisionBoxes: boolean;
    placementSource: any;
    actor: Actor;
    vtLayers: {[_: string]: VectorTileLayerLike};

    neighboringTiles: Record<string, {backfilled: boolean}>;
    dem: DEMData;
    demMatrix: mat4;
    aborted: boolean;
    needsHillshadePrepare: boolean;
    needsTerrainPrepare: boolean;
    abortController: AbortController;
    texture: any;
    fbo: Framebuffer;
    demTexture: Texture;
    refreshedUponExpiration: boolean;
    reloadPromise: {resolve: () => void; reject: () => void};
    resourceTiming: PerformanceResourceTiming[];
    queryPadding: number;

    symbolFadeHoldUntil: number;
    hasSymbolBuckets: boolean;
    hasRTLText: boolean;
    dependencies: any;
    /**
     * @internal
     * Caches the result of rendering this 2D tile into a texture so it can be
     * draped over 3D terrain. Indexed by stack index, where a stack is a
     * contiguous run of RTT-eligible layers (fill, line, raster, etc.) split
     * by non-RTT layers like symbols. So `_rttObjects[0]` holds the texture
     * for layers below the first symbol break, `_rttObjects[1]` for layers
     * between the first and second symbol breaks, and so on. Each entry
     * survives across frames until the tile is unloaded or its source data
     * changes.
     */
    rttObjects: Array<RTTObject | undefined>;
    rttFingerprint: {[sourceId:string]: string};

    featureStateRevision: number;

    /**
     * @param tileID - the tile ID
     * @param size - The tile size
     */
    constructor(tileID: OverscaledTileID, size: number) {
        this.tileID = tileID;
        this.uid = uniqueId();
        this.uses = 0;
        this.tileSize = size;
        this.buckets = {};
        this.expirationTime = null;
        this.queryPadding = 0;
        this.hasSymbolBuckets = false;
        this.hasRTLText = false;
        this.dependencies = {};
        this.rttObjects = [];
        this.rttFingerprint = {};

        // Counts the number of times a response was already expired when
        // received. We're using this to add a delay when making a new request
        // so we don't have to keep retrying immediately in case of a server
        // serving expired tiles.
        this.expiredRequestCount = 0;

        this.state = 'loading';
        this.featureStateRevision = -1;
    }

    isRenderable(symbolLayer: boolean): boolean {
        return (
            this.hasData() &&
            (!this.fadeEndTime || this.fadeOpacity > 0) &&  // raster fading
            (symbolLayer || !this.holdingForSymbolFade())   // symbol fading
        );
    }

    /**
     * @internal
     * Many-to-one crossfade between a base tile and parent/ancestor tile (when zooming)
     */
    setCrossFadeLogic({fadingRole, fadingDirection, fadingParentID, fadeEndTime}: CrossFadeArgs): void {
        this.resetFadeLogic();

        this.fadingRole = fadingRole;
        this.fadingDirection = fadingDirection;
        this.fadingParentID = fadingParentID;
        this.fadeEndTime = fadeEndTime;
    }

    /**
     * Self fading for edge tiles (when panning map)
     */
    setSelfFadeLogic(fadeEndTime: number): void {
        this.resetFadeLogic();
        this.selfFading = true;
        this.fadeEndTime = fadeEndTime;
    }

    resetFadeLogic(): void {
        this.fadingRole = null;
        this.fadingDirection = null;
        this.fadingParentID = null;
        this.selfFading = false;

        this.timeAdded = now();
        this.fadeEndTime = 0;
        this.fadeOpacity = 1;
    }

    wasRequested(): boolean {
        return this.state === 'errored' || this.state === 'loaded' || this.state === 'reloading';
    }

    clearTextures(painter: Painter): void {
        if (this.demTexture) painter.saveTileTexture(this.demTexture);
        this.demTexture = null;
    }

    /**
     * @internal
     * Returns the cached RTT object for this stack, or undefined on a cache miss.
     */
    getRTT(stack: number): RTTObject | undefined {
        return this.rttObjects[stack];
    }

    /**
     * @internal
     * Allocates a fresh RTT object from the painter's pool and stores it at the
     * given stack slot. Callers should check {@link getRTT} first; calling this
     * over an existing slot leaks the previous object.
     */
    acquireRTT(painter: Painter, stack: number, size: number): RTTObject {
        return this.rttObjects[stack] = painter.acquireRTT(size);
    }

    /**
     * @internal
     * Returns all cached RTT slots to the painter's pool.
     */
    releaseRTT(painter: Painter): void {
        if (this.rttObjects.length === 0) return;
        for (const obj of this.rttObjects) {
            // Release RTT only if defined (undefined for holes in sparse array)
            if (obj) painter.releaseRTT(obj);
        }
        this.rttObjects.length = 0;
    }

    /**
     * Given a data object with a 'buffers' property, load it into
     * this tile's elementGroups and buffers properties and set loaded
     * to true. If the data is null, like in the case of an empty
     * GeoJSON tile, no-op but still set loaded to true.
     * @param data - The data from the worker
     * @param painter - the painter
     * @param justReloaded - `true` to just reload
     */
    loadVectorData(data: WorkerTileResult, painter: Painter, justReloaded?: boolean | null): void {
        if (data?.etagUnmodified === true) {
            this.state = 'loaded';
            return;
        }

        if (this.hasData()) {
            this.unloadVectorData();
        }

        this.state = 'loaded';

        // empty GeoJSON tile
        if (!data) {
            this.collisionBoxArray = new CollisionBoxArray();
            return;
        }

        if (data.featureIndex) {
            this.latestFeatureIndex = data.featureIndex;
            if (data.rawTileData) {
                // Only vector tiles have rawTileData, and they won't update it for
                // 'reloadTile'
                this.latestRawTileData = data.rawTileData;
                this.latestEncoding = data.encoding;
                this.latestFeatureIndex.rawTileData = data.rawTileData;
                this.latestFeatureIndex.encoding = data.encoding;
            } else if (this.latestRawTileData) {
                // If rawTileData hasn't updated, hold onto a pointer to the last
                // one we received
                this.latestFeatureIndex.rawTileData = this.latestRawTileData;
                this.latestFeatureIndex.encoding = this.latestEncoding;
            }
        }
        this.collisionBoxArray = data.collisionBoxArray;
        this.buckets = deserializeBucket(data.buckets, painter?.style);

        this.hasSymbolBuckets = false;
        for (const id in this.buckets) {
            const bucket = this.buckets[id];
            if (bucket instanceof SymbolBucket) {
                this.hasSymbolBuckets = true;
                if (justReloaded) {
                    bucket.justReloaded = true;
                } else {
                    break;
                }
            }
        }

        this.hasRTLText = false;
        if (this.hasSymbolBuckets) {
            for (const id in this.buckets) {
                const bucket = this.buckets[id];
                if (bucket instanceof SymbolBucket) {
                    if (bucket.hasRTLText) {
                        this.hasRTLText = true;
                        rtlMainThreadPluginFactory().lazyLoad();
                        break;
                    }
                }
            }
        }

        this.queryPadding = 0;
        for (const id in this.buckets) {
            const bucket = this.buckets[id];
            this.queryPadding = Math.max(this.queryPadding, painter.style.getLayer(id).queryRadius(bucket));
        }

        if (data.imageAtlas) {
            this.imageAtlas = data.imageAtlas;
        }
        if (data.glyphAtlasImage) {
            this.glyphAtlasImage = data.glyphAtlasImage;
        }
        this.dashPositions = data.dashPositions;
    }

    /**
     * Release any data or WebGL resources referenced by this tile.
     */
    unloadVectorData(): void {
        for (const id in this.buckets) {
            this.buckets[id].destroy();
        }
        this.buckets = {};

        if (this.imageAtlasTexture) {
            this.imageAtlasTexture.destroy();
        }

        if (this.glyphAtlasTexture) {
            this.glyphAtlasTexture.destroy();
        }

        this.imageAtlas = null;
        this.dashPositions = null;
        this.latestFeatureIndex = null;
        this.state = 'unloaded';
    }

    getBucket(layer: StyleLayer): Bucket {
        return this.buckets[layer.id];
    }

    upload(context: Context): void {
        for (const id in this.buckets) {
            const bucket = this.buckets[id];
            if (bucket.uploadPending()) {
                bucket.upload(context);
            }
        }

        const gl = context.gl;
        if (this.imageAtlas && !this.imageAtlas.uploaded) {
            this.imageAtlasTexture = new Texture(context, this.imageAtlas.image, gl.RGBA);
            this.imageAtlas.uploaded = true;
        }

        if (this.glyphAtlasImage) {
            this.glyphAtlasTexture = new Texture(context, this.glyphAtlasImage, gl.ALPHA);
            this.glyphAtlasImage = null;
        }
    }

    prepare(imageManager: ImageManager): void {
        if (this.imageAtlas) {
            this.imageAtlas.patchUpdatedImages(imageManager, this.imageAtlasTexture);
        }
    }

    // Queries non-symbol features rendered for this tile.
    // Symbol features are queried globally
    queryRenderedFeatures(
        layers: {[_: string]: StyleLayer},
        serializedLayers: {[_: string]: any},
        sourceFeatureState: SourceFeatureState,
        queryGeometry: Point[],
        cameraQueryGeometry: Point[],
        scale: number,
        params: Pick<QueryRenderedFeaturesOptionsStrict, 'filter' | 'layers' | 'availableImages'> | undefined,
        transform: IReadonlyTransform,
        maxPitchScaleFactor: number,
        pixelPosMatrix: mat4,
        getElevation: undefined | ((x: number, y: number) => number)
    ): QueryResults {
        if (!this.latestFeatureIndex?.rawTileData)
            return {};

        return this.latestFeatureIndex.query({
            queryGeometry,
            cameraQueryGeometry,
            scale,
            tileSize: this.tileSize,
            pixelPosMatrix,
            transform,
            params,
            queryPadding: this.queryPadding * maxPitchScaleFactor,
            getElevation
        }, layers, serializedLayers, sourceFeatureState);
    }

    querySourceFeatures(result: GeoJSONFeature[], params?: QuerySourceFeatureOptionsStrict): void {
        const featureIndex = this.latestFeatureIndex;
        if (!featureIndex?.rawTileData) return;

        const vtLayers = featureIndex.loadVTLayers();

        const sourceLayer = params?.sourceLayer ? params.sourceLayer : '';
        const layer = vtLayers[GEOJSON_TILE_LAYER_NAME] || vtLayers[sourceLayer];

        if (!layer) return;

        const filter = featureFilter(params?.filter, `querySourceFeatures[${sourceLayer}].filter`, params?.globalState);
        const {z, x, y} = this.tileID.canonical;
        const coord = {z, x, y};

        for (let i = 0; i < layer.length; i++) {
            const feature = layer.feature(i);
            if (filter.needGeometry) {
                const evaluationFeature = toEvaluationFeature(feature, true);
                if (!filter.filter(new EvaluationParameters(this.tileID.overscaledZ), evaluationFeature, this.tileID.canonical)) continue;
            } else if (!filter.filter(new EvaluationParameters(this.tileID.overscaledZ), feature)) {
                continue;
            }
            const id = featureIndex.getId(feature, sourceLayer);
            const geojsonFeature = new GeoJSONFeature(feature, z, x, y, id);
            (geojsonFeature as any).tile = coord;
            result.push(geojsonFeature);
        }
    }

    hasData(): boolean {
        return this.state === 'loaded' || this.state === 'reloading' || this.state === 'expired';
    }

    patternsLoaded(): boolean {
        return this.imageAtlas && !!Object.keys(this.imageAtlas.patternPositions).length;
    }

    setExpiryData(data: ExpiryData): void {
        const prior = this.expirationTime;

        if (data.cacheControl) {
            const parsedCC = parseCacheControl(data.cacheControl);
            if (parsedCC['max-age']) this.expirationTime = Date.now() + parsedCC['max-age'] * 1000;
        } else if (data.expires) {
            this.expirationTime = new Date(data.expires).getTime();
        }

        if (this.expirationTime) {
            const now = Date.now();
            let isExpired = false;

            if (this.expirationTime > now) {
                isExpired = false;
            } else if (!prior) {
                isExpired = true;
            } else if (this.expirationTime < prior) {
                // Expiring date is going backwards:
                // fall back to exponential backoff
                isExpired = true;

            } else {
                const delta = this.expirationTime - prior;

                if (!delta) {
                    // Server is serving the same expired resource over and over: fall
                    // back to exponential backoff.
                    isExpired = true;

                } else {
                    // Assume that either the client or the server clock is wrong and
                    // try to interpolate a valid expiration date (from the client POV)
                    // observing a minimum timeout.
                    this.expirationTime = now + Math.max(delta, CLOCK_SKEW_RETRY_TIMEOUT);

                }
            }

            if (isExpired) {
                this.expiredRequestCount++;
                this.state = 'expired';
            } else {
                this.expiredRequestCount = 0;
            }
        }
    }

    getExpiryTimeout(): number {
        if (this.expirationTime) {
            if (this.expiredRequestCount) {
                return 1000 * (1 << Math.min(this.expiredRequestCount - 1, 31));
            } else {
                // Max value for `setTimeout` implementations is a 32 bit integer; cap this accordingly
                return Math.min(this.expirationTime - new Date().getTime(), Math.pow(2, 31) - 1);
            }
        }
    }

    setFeatureState(states: LayerFeatureStates, painter: Painter, revision: number): void {
        if (!this.latestFeatureIndex?.rawTileData ||
            Object.keys(states).length === 0) {
            return;
        }

        // Skip bucket updates if we already processed this revision
        if (this.featureStateRevision === revision) {
            return;
        }
        this.featureStateRevision = revision;

        const vtLayers = this.latestFeatureIndex.loadVTLayers();

        for (const id in this.buckets) {
            if (!painter.style.hasLayer(id)) continue;

            const bucket = this.buckets[id];
            // Buckets are grouped by common source-layer
            const sourceLayerId = bucket.layers[0]['sourceLayer'] || GEOJSON_TILE_LAYER_NAME;
            const sourceLayer = vtLayers[sourceLayerId];
            const sourceLayerStates = states[sourceLayerId];
            if (!sourceLayer || !sourceLayerStates || sourceLayerStates.length === 0) continue;

            bucket.update(sourceLayerStates, sourceLayer, this.imageAtlas?.patternPositions || {}, this.dashPositions || {});
            const layer = painter?.style?.getLayer(id);
            if (layer) {
                this.queryPadding = Math.max(this.queryPadding, layer.queryRadius(bucket));
            }
        }
    }

    holdingForSymbolFade(): boolean {
        return this.symbolFadeHoldUntil !== undefined;
    }

    symbolFadeFinished(): boolean {
        return !this.symbolFadeHoldUntil || this.symbolFadeHoldUntil < now();
    }

    clearSymbolFadeHold(): void {
        this.symbolFadeHoldUntil = undefined;
    }

    setSymbolHoldDuration(duration: number): void {
        this.symbolFadeHoldUntil = now() + duration;
    }

    setDependencies(namespace: string, dependencies: string[]): void {
        const index = {};
        for (const dep of dependencies) {
            index[dep] = true;
        }
        this.dependencies[namespace] = index;
    }

    hasDependency(namespaces: string[], keys: string[]): boolean {
        for (const namespace of namespaces) {
            const dependencies = this.dependencies[namespace];
            if (dependencies) {
                for (const key of keys) {
                    if (dependencies[key]) {
                        return true;
                    }
                }
            }
        }
        return false;
    }
}
