import {Color} from '@maplibre/maplibre-gl-style-spec';
import {drawTerrain} from './draw/draw_terrain.ts';
import {setFrameUniformWorldSize} from './frame_uniform_buffer.ts';
import {ImageSource} from '../source/image_source.ts';
import {RTT_DIFFERENCES, RTTFingerprint, type RTTDifference} from './rtt_fingerprint.ts';
import {isCustomStyleLayer} from '../style/style_layer/custom_style_layer.ts';

import type {Tile} from '../tile/tile.ts';
import type {OverscaledTileID} from '../tile/tile_id.ts';
import type {Style} from '../style/style.ts';
import type {Terrain} from '../render/terrain.ts';
import type {Texture} from './texture.ts';
import type {StyleLayer} from '../style/style_layer.ts';
import type {Painter} from '../render/painter.ts';
import type {FrameRenderContext} from '../render/frame_render_context.ts';

/**
 * lookup table which layers should rendered to texture
 */
const LAYERS_TO_TEXTURES: { [keyof in StyleLayer['type']]?: boolean } = {
    background: true,
    fill: true,
    line: true,
    raster: true,
    hillshade: true,
    'color-relief': true
};

/**
 * Returns the key under which a tile keeps the fingerprint of the layer, which is the layer's source, or its id for a custom layer.
 */
function rttFingerprintKey(layer: StyleLayer): string | undefined {
    return isCustomStyleLayer(layer) ? `custom-layer:${layer.id}` : layer.source;
}

/**
 * Whether the layer is drawn into the terrain tiles' textures rather than onto the map: the layer types above, and
 * custom layers that implement `renderToTerrainTile`.
 */
function isRenderedToTexture(layer: StyleLayer): boolean {
    if (isCustomStyleLayer(layer)) return layer.implementation.renderToTerrainTile !== undefined;
    return LAYERS_TO_TEXTURES[layer.type] === true;
}

/**
 * Returns the ids of the layers in each render stack, a run of layers rendered to texture that ends at a layer
 * that is not.
 */
function getRenderStacks(style: Style, layerIds: string[]): string[][] {
    const stacks: string[][] = [];
    let prevRenderedToTexture = false;
    for (const layerId of layerIds) {
        const renderedToTexture = isRenderedToTexture(style._layers[layerId]);
        if (renderedToTexture && !prevRenderedToTexture) stacks.push([]);
        if (renderedToTexture) stacks[stacks.length - 1].push(layerId);
        prevRenderedToTexture = renderedToTexture;
    }
    return stacks;
}

/**
 * @internal
 * Renders RTT-eligible layers into per-tile cached textures, then drapes
 * them onto the terrain mesh. Slots live on each Tile so their lifetime
 * tracks the tile itself; the underlying FBO+texture handles are recycled
 * via the painter's RTT pool.
 */
export class RenderToTexture {
    painter: Painter;
    terrain: Terrain;
    /** RTT texture dimension in pixels (tile size × terrain quality factor). */
    rttSize: number;
    /**
     * coordsAscending contains a list of all tiles which should be rendered for one render-to-texture tile
     * e.g. render 4 raster-tiles with size 256px to the 512px render-to-texture tile
     */
    _coordsAscending: {[_: string]: {[_:string]: OverscaledTileID[]}};
    /**
     * This frame's fingerprint for each render-to-texture tile, keyed by source id
     * then tile key. A tile whose stored fingerprint differs has stale textures.
     */
    _rttFingerprints: Record<string, Record<string, RTTFingerprint>>;
    /**
     * store for render-stacks
     * a render stack is a set of layers which should be rendered into one texture
     * every stylesheet can have multiple stacks. A new stack is created if layers which should
     * not rendered to texture sit between layers which should rendered to texture. e.g. hillshading or symbols
     */
    _stacks: string[][];
    /**
     * whether the previous processed layer was rendered to texture, to check if a new stack is needed
     */
    _prevRenderedToTexture: boolean;
    /**
     * a list of tiles that can potentially rendered
     */
    _renderableTiles: Tile[];
    /**
     * a list of tiles that should be rendered to screen in the next render-call
     */
    _rttTiles: Tile[];
    /**
     * a list of all layer-ids which should be rendered
     */
    _renderableLayerIds: string[];
    /**
     * the zoom of the previous prepareForRender call
     */
    _lastPrepareZoom: number;
    /**
     * whether the render loop needs a follow-up frame to refresh cached textures that were kept this frame:
     * textures rendered at another zoom, and stale textures beyond the per-frame budget.
     */
    needsFollowUpFrame: boolean = false;
    constructor(painter: Painter, terrain: Terrain) {
        this.painter = painter;
        this.terrain = terrain;
        this.rttSize = terrain.tileManager.tileSize * terrain.qualityFactor;
    }

    getTexture(tile: Tile): Texture {
        return tile.getRTT(this._stacks.length - 1).texture;
    }

    /**
     * Collects the frame's tiles, layers and source fingerprints and releases the textures that need
     * re-rendering. Textures that differ only by zoom are kept while the zoom is changing or the map is moving
     * and re-rendered one tile per frame once it has stopped, nearest to the camera first; textures with other
     * source tiles under them are re-rendered one per frame too, `needsFollowUpFrame` bringing in the rest.
     * Textures whose visible layer set changed (a layer entered or left its zoom range) are kept while the zoom
     * is changing and then all re-rendered in the same frame, since a tile-by-tile change would show; a source
     * data change re-renders immediately.
     */
    prepareForRender(style: Style, zoom: number, isMoving: boolean): void {
        const zoomChanged = zoom !== this._lastPrepareZoom;
        this._lastPrepareZoom = zoom;
        this._stacks = [];
        this._prevRenderedToTexture = false;
        this._rttTiles = [];
        this._renderableTiles = this.terrain.tileManager.getRenderableTiles();
        this._renderableLayerIds = style._order.filter(id => !style._layers[id].isHidden(zoom));
        const stacks = getRenderStacks(style, this._renderableLayerIds);
        const visibleLayerIds = this._renderableLayerIds.join();

        const rttSourceIds = new Set<string>();
        for (const layerId of this._renderableLayerIds) {
            const layer = style._layers[layerId];
            const source = layer.source;
            if (source && LAYERS_TO_TEXTURES[layer.type]) rttSourceIds.add(source);
        }

        this._coordsAscending = {};
        this._rttFingerprints = {};
        for (const sourceId of rttSourceIds) {
            const tileManager = style.tileManagers[sourceId];
            if (!tileManager) continue;

            this._coordsAscending[sourceId] = {};
            const coordsAscending = this._coordsAscending[sourceId];
            const source = tileManager.getSource();
            const terrainTileRanges = source instanceof ImageSource ? source.terrainTileRanges : null;
            for (const tileID of tileManager.getVisibleCoordinates()) {
                const keys = this.terrain.tileManager.getTerrainCoords(tileID, terrainTileRanges);
                for (const key in keys) {
                    coordsAscending[key] ||= [];
                    coordsAscending[key].push(keys[key]);
                }
            }

            this._rttFingerprints[sourceId] = {};
            const fingerprints = this._rttFingerprints[sourceId];
            const revision = tileManager.getState().revision;
            for (const key in coordsAscending)
                fingerprints[key] = new RTTFingerprint(coordsAscending[key], revision, zoom, visibleLayerIds);
        }

        for (const layerId of this._renderableLayerIds) {
            const layer = style._layers[layerId];
            if (!isCustomStyleLayer(layer) || !isRenderedToTexture(layer)) continue;
            const revision = layer.implementation.terrainTileRevision ?? 0;
            const fingerprints: Record<string, RTTFingerprint> = {};
            for (const tile of this._renderableTiles) {
                fingerprints[tile.tileID.key] = new RTTFingerprint([tile.tileID], revision, zoom, visibleLayerIds);
            }
            this._rttFingerprints[rttFingerprintKey(layer)] = fingerprints;
        }

        // check tiles to render
        this.needsFollowUpFrame = false;
        const moving = zoomChanged || isMoving;
        let staleTileReleased = false;
        for (const tile of this._renderableTiles) {
            const difference = this._textureDifference(tile);
            if (difference === 'none') continue;
            if ((difference === 'zoom' && moving) || (difference === 'visibleLayers' && zoomChanged)) {
                this.needsFollowUpFrame = true;
            } else if (difference === 'zoom' || difference === 'sourceTiles') {
                if (staleTileReleased) this.needsFollowUpFrame = true;
                else this._releaseChangedTextures(tile, style, stacks);
                staleTileReleased = true;
            } else {
                this._releaseChangedTextures(tile, style, stacks);
            }
        }
    }

    /**
     * Releases the tile's textures of the stacks whose layers changed, or all of its textures when every stack
     * changed, which also drops the textures of stacks that the layers no longer form.
     */
    _releaseChangedTextures(tile: Tile, style: Style, stacks: string[][]): void {
        const changedStacks = stacks.map(layerIds => this._hasStackChanged(tile, style, layerIds));
        if (!changedStacks.includes(false)) {
            tile.releaseRTT(this.painter);
            return;
        }
        for (const [stack, changed] of changedStacks.entries()) {
            if (changed) tile.releaseRTTStack(this.painter, stack);
        }
    }

    /**
     * Whether the tile's texture of the stack differs from what this frame would render into it. A stack whose
     * layers have no fingerprint, background layers only, counts as changed.
     */
    _hasStackChanged(tile: Tile, style: Style, layerIds: string[]): boolean {
        let fingerprinted = false;
        for (const layerId of layerIds) {
            const fingerprintKey = rttFingerprintKey(style._layers[layerId]);
            const frameFingerprint = this._rttFingerprints[fingerprintKey]?.[tile.tileID.key];
            const tileFingerprint = tile.rttFingerprint[fingerprintKey];
            if (!frameFingerprint && !tileFingerprint) continue;
            if (frameFingerprint?.difference(tileFingerprint) !== 'none') return true;
            fingerprinted = true;
        }
        return !fingerprinted;
    }

    /**
     * The most severe difference, over the sources rendered to texture, between the tile's cached textures
     * and what this frame would render into them.
     */
    _textureDifference(tile: Tile): RTTDifference {
        let worst: RTTDifference = 'none';
        for (const source in this._rttFingerprints) {
            const frameFingerprint = this._rttFingerprints[source][tile.tileID.key];
            if (!frameFingerprint) continue;
            const difference = frameFingerprint.difference(tile.rttFingerprint[source]);
            if (RTT_DIFFERENCES.indexOf(difference) > RTT_DIFFERENCES.indexOf(worst)) worst = difference;
        }
        return worst;
    }

    /**
     * due that switching textures is relatively slow, the render
     * layer-by-layer context is not practicable. To bypass this problem
     * this lines of code stack all layers and later render all at once.
     * Because of the stylesheet possibility to mixing render-to-texture layers
     * and 'live'-layers (f.e. symbols) it is necessary to create more stacks. For example
     * a symbol-layer is in between of fill-layers.
     * @param layer - the layer to render
     * @param style - the style the stacked layers and their tile managers come from
     * @param frameRenderContext - shared state for the current render
     * @returns if true layer is rendered to texture, otherwise false
     */
    renderLayer(layer: StyleLayer, style: Style, frameRenderContext: FrameRenderContext): boolean {
        if (layer.isHidden(frameRenderContext.transform.zoom)) return false;

        const renderedToTexture = isRenderedToTexture(layer);
        const painter = this.painter;
        const isLastLayer = this._renderableLayerIds[this._renderableLayerIds.length - 1] === layer.id;

        // remember background, fill, line & raster layer to render into a stack
        if (renderedToTexture) {
            // create a new stack if previous layer was not rendered to texture (f.e. symbols)
            if (!this._prevRenderedToTexture) this._stacks.push([]);
            // push current render-to-texture layer to render-stack
            this._prevRenderedToTexture = true;
            this._stacks[this._stacks.length - 1].push(layer.id);
            // rendering is done later, all in once
            if (!isLastLayer) return true;
        }

        // in case a stack is finished render all collected stack-layers into a texture
        if (this._prevRenderedToTexture || (renderedToTexture && isLastLayer)) {
            this._prevRenderedToTexture = renderedToTexture;
            const stack = this._stacks.length - 1, layers = this._stacks[stack] || [];
            frameRenderContext.isRenderingToTexture = true;
            setFrameUniformWorldSize(painter.context.frameUniformBuffer, this.rttSize, this.rttSize);
            for (const tile of this._renderableTiles) {
                this._rttTiles.push(tile);
                // Cache hit: this tile already has a RTT object for this stack from a previous frame.
                if (tile.getRTT(stack)) continue;
                const obj = tile.acquireRTT(painter, stack, this.rttSize);
                painter.bindRTT(obj);
                painter.context.clear({color: Color.transparent, stencil: 0});
                frameRenderContext.invalidateTileClippingMasks();
                for (const layerId of layers) {
                    const layer = style._layers[layerId];
                    const coords = layer.source ? this._coordsAscending[layer.source][tile.tileID.key] : [tile.tileID];
                    painter.context.viewport.set([0, 0, this.rttSize, this.rttSize]);
                    frameRenderContext.renderTileClippingMasks(layer, coords);
                    painter.renderLayer(painter, style.tileManagers[layer.source], layer, coords, frameRenderContext);
                    const fingerprintKey = rttFingerprintKey(layer);
                    if (fingerprintKey) tile.rttFingerprint[fingerprintKey] = this._rttFingerprints[fingerprintKey][tile.tileID.key];
                }
                obj.texture.generateMipmap();
            }
            frameRenderContext.isRenderingToTexture = false;
            setFrameUniformWorldSize(painter.context.frameUniformBuffer, painter.context.gl.drawingBufferWidth, painter.context.gl.drawingBufferHeight);
            drawTerrain(this.painter, this.terrain, this._rttTiles, frameRenderContext);
            this._rttTiles = [];

            return renderedToTexture;
        }

        return false;
    }

}
