/**
 * @module ol/reproj/DataTile
 */

import DataTile, {asArrayLike, asImageLike, toArray} from '../DataTile.js';
import TileState from '../TileState.js';
import {createCanvasContext2D} from '../dom.js';
import EventType from '../events/EventType.js';
import {listen, unlistenByKey} from '../events.js';
import {getArea, getIntersection, getWidth, wrapAndSliceX} from '../extent.js';
import {clamp} from '../math.js';
import {calculateSourceExtentResolution} from '../reproj.js';
import Triangulation from './Triangulation.js';
import {ERROR_THRESHOLD} from './common.js';
import {
  canvasGLPool,
  createCanvasContextWebGL,
  releaseGLCanvas,
  render as renderReprojected,
} from './glreproj.js';

/**
 * @typedef {function(number, number, number, number) : import("../DataTile.js").default} TileGetter
 */

/**
 * @typedef {Object} TileOffset
 * @property {DataTile} tile Tile.
 * @property {number} offset Offset.
 */

/**
 * @typedef {Object} Options
 * @property {import("../proj/Projection.js").default} sourceProj Source projection.
 * @property {import("../tilegrid/TileGrid.js").default} sourceTileGrid Source tile grid.
 * @property {import("../proj/Projection.js").default} targetProj Target projection.
 * @property {import("../tilegrid/TileGrid.js").default} targetTileGrid Target tile grid.
 * @property {import("../tilecoord.js").TileCoord} tileCoord Coordinate of the tile.
 * @property {import("../tilecoord.js").TileCoord} [wrappedTileCoord] Coordinate of the tile wrapped in X.
 * @property {number} pixelRatio Pixel ratio.
 * @property {number} gutter Gutter of the source tiles.
 * @property {TileGetter} getTileFunction Function returning source tiles (z, x, y, pixelRatio).
 * @property {boolean} [interpolate=false] Use interpolated values when resampling.  By default,
 * the nearest neighbor is used when resampling.
 * @property {number} [errorThreshold] Acceptable reprojection error (in px).
 * @property {number} [transition=250] A duration for tile opacity
 * transitions in milliseconds. A duration of 0 disables the opacity transition.
 * @property {import("../transform.js").Transform} [transformMatrix] Source transform matrix.
 * @property {boolean} [renderEdges] Render reprojection edges.
 */

/**
 * @classdesc
 * Class encapsulating single reprojected data tile.
 * See {@link module:ol/source/DataTile~DataTileSource}.
 *
 */
class ReprojDataTile extends DataTile {
  /**
   * @param {Options} options Tile options.
   */
  constructor(options) {
    super({
      tileCoord: options.tileCoord,
      loader: () => Promise.resolve(new Uint8ClampedArray(4)),
      interpolate: options.interpolate,
      transition: options.transition,
    });

    /**
     * @private
     * @type {boolean | Array<number>}
     */
    this.renderEdges_ =
      options.renderEdges !== undefined ? options.renderEdges : false;

    /**
     * @private
     * @type {number}
     */
    this.pixelRatio_ = options.pixelRatio;

    /**
     * @private
     * @type {number}
     */
    this.gutter_ = options.gutter;

    /**
     * @type {import("../DataTile.js").Data}
     * @private
     */
    this.reprojData_ = null;

    /**
     * @type {Error}
     * @private
     */
    this.reprojError_ = null;

    /**
     * @type {import('../size.js').Size}
     * @private
     */
    this.reprojSize_ = undefined;

    /**
     * @private
     * @type {import("../tilegrid/TileGrid.js").default}
     */
    this.sourceTileGrid_ = options.sourceTileGrid;

    /**
     * @private
     * @type {import("../tilegrid/TileGrid.js").default}
     */
    this.targetTileGrid_ = options.targetTileGrid;

    /**
     * @private
     * @type {import("../tilecoord.js").TileCoord}
     */
    this.wrappedTileCoord_ = options.wrappedTileCoord || options.tileCoord;

    /**
     * @private
     * @type {!Array<TileOffset>}
     */
    this.sourceTiles_ = [];

    /**
     * @private
     * @type {?Array<import("../events.js").EventsKey>}
     */
    this.sourcesListenerKeys_ = null;

    /**
     * @private
     * @type {number}
     */
    this.sourceZ_ = 0;

    const sourceProj = options.sourceProj;
    const sourceProjExtent = sourceProj.getExtent();
    const sourceTileGridExtent = options.sourceTileGrid.getExtent();

    /**
     * @private
     * @type {import("../extent.js").Extent}
     */
    this.clipExtent_ = sourceProj.canWrapX()
      ? sourceTileGridExtent
        ? getIntersection(sourceProjExtent, sourceTileGridExtent)
        : sourceProjExtent
      : sourceTileGridExtent;

    const targetExtent = this.targetTileGrid_.getTileCoordExtent(
      this.wrappedTileCoord_,
    );
    const maxTargetExtent = this.targetTileGrid_.getExtent();
    let maxSourceExtent = this.sourceTileGrid_.getExtent();

    const limitedTargetExtent = maxTargetExtent
      ? getIntersection(targetExtent, maxTargetExtent)
      : targetExtent;

    if (getArea(limitedTargetExtent) === 0) {
      // Tile is completely outside range -> EMPTY
      // TODO: is it actually correct that the source even creates the tile ?
      this.state = TileState.EMPTY;
      return;
    }

    if (sourceProjExtent) {
      if (!maxSourceExtent) {
        maxSourceExtent = sourceProjExtent;
      } else {
        maxSourceExtent = getIntersection(maxSourceExtent, sourceProjExtent);
      }
    }

    const targetResolution = this.targetTileGrid_.getResolution(
      this.wrappedTileCoord_[0],
    );

    const targetProj = options.targetProj;
    const sourceResolution = calculateSourceExtentResolution(
      sourceProj,
      targetProj,
      limitedTargetExtent,
      targetResolution,
    );

    if (!isFinite(sourceResolution) || sourceResolution <= 0) {
      // invalid sourceResolution -> EMPTY
      // probably edges of the projections when no extent is defined
      this.state = TileState.EMPTY;
      return;
    }

    const errorThresholdInPixels =
      options.errorThreshold !== undefined
        ? options.errorThreshold
        : ERROR_THRESHOLD;

    /**
     * @private
     * @type {!import("./Triangulation.js").default}
     */
    this.triangulation_ = new Triangulation(
      sourceProj,
      targetProj,
      limitedTargetExtent,
      maxSourceExtent,
      sourceResolution * errorThresholdInPixels,
      targetResolution,
      options.transformMatrix,
    );

    if (this.triangulation_.getTriangles().length === 0) {
      // no valid triangles -> EMPTY
      this.state = TileState.EMPTY;
      return;
    }

    this.sourceZ_ = this.sourceTileGrid_.getZForResolution(sourceResolution);
    let sourceExtent = this.triangulation_.calculateSourceExtent();

    if (maxSourceExtent) {
      if (sourceProj.canWrapX()) {
        sourceExtent[1] = clamp(
          sourceExtent[1],
          maxSourceExtent[1],
          maxSourceExtent[3],
        );
        sourceExtent[3] = clamp(
          sourceExtent[3],
          maxSourceExtent[1],
          maxSourceExtent[3],
        );
      } else {
        sourceExtent = getIntersection(sourceExtent, maxSourceExtent);
      }
    }

    if (!getArea(sourceExtent)) {
      this.state = TileState.EMPTY;
    } else {
      let worldWidth = 0;
      let worldsAway = 0;
      if (sourceProj.canWrapX()) {
        worldWidth = getWidth(sourceProjExtent);
        worldsAway = Math.floor(
          (sourceExtent[0] - sourceProjExtent[0]) / worldWidth,
        );
      }

      const sourceExtents = wrapAndSliceX(
        sourceExtent.slice(),
        sourceProj,
        true,
      );
      sourceExtents.forEach((extent) => {
        const sourceRange = this.sourceTileGrid_.getTileRangeForExtentAndZ(
          extent,
          this.sourceZ_,
        );
        const getTile = options.getTileFunction;
        for (let srcX = sourceRange.minX; srcX <= sourceRange.maxX; srcX++) {
          for (let srcY = sourceRange.minY; srcY <= sourceRange.maxY; srcY++) {
            const tile = getTile(this.sourceZ_, srcX, srcY, this.pixelRatio_);
            if (tile) {
              const offset = worldsAway * worldWidth;
              this.sourceTiles_.push({tile, offset});
            }
          }
        }
        ++worldsAway;
      });

      if (this.sourceTiles_.length === 0) {
        this.state = TileState.EMPTY;
      }
    }
  }

  /**
   * Get the tile size.
   * @return {import('../size.js').Size} Tile size.
   * @override
   */
  getSize() {
    return this.reprojSize_;
  }

  /**
   * Get the data for the tile.
   * @return {import("../DataTile.js").Data} Tile data.
   * @override
   */
  getData() {
    return this.reprojData_;
  }

  /**
   * Get any loading error.
   * @return {Error} Loading error.
   * @override
   */
  getError() {
    return this.reprojError_;
  }

  /**
   * @private
   */
  reproject_() {
    const dataSources = [];
    let imageLike = false;
    this.sourceTiles_.forEach((source) => {
      const tile = source.tile;
      if (!tile || tile.getState() !== TileState.LOADED) {
        return;
      }
      const size = tile.getSize();
      const gutter = this.gutter_;
      /**
       * @type {import("../DataTile.js").ArrayLike}
       */
      let tileData;
      const arrayData = asArrayLike(tile.getData());
      if (arrayData) {
        tileData = arrayData;
      } else {
        imageLike = true;
        tileData = toArray(asImageLike(tile.getData()));
      }
      const pixelSize = [size[0] + 2 * gutter, size[1] + 2 * gutter];
      const isFloat = tileData instanceof Float32Array;
      const pixelCount = pixelSize[0] * pixelSize[1];
      const DataType = isFloat ? Float32Array : Uint8ClampedArray;
      const tileDataR = new DataType(
        /** @type {ArrayBuffer} */ (tileData.buffer),
      );
      const bytesPerElement = DataType.BYTES_PER_ELEMENT;
      const bytesPerPixel = (bytesPerElement * tileDataR.length) / pixelCount;
      const bytesPerRow = tileDataR.byteLength / pixelSize[1];
      const bandCount = Math.floor(
        bytesPerRow / bytesPerElement / pixelSize[0],
      );
      const extent = this.sourceTileGrid_.getTileCoordExtent(tile.tileCoord);
      extent[0] += source.offset;
      extent[2] += source.offset;
      const clipExtent = this.clipExtent_?.slice();
      if (clipExtent) {
        clipExtent[0] += source.offset;
        clipExtent[2] += source.offset;
      }
      dataSources.push({
        extent: extent,
        clipExtent: clipExtent,
        data: tileDataR,
        dataType: DataType,
        bytesPerPixel: bytesPerPixel,
        pixelSize: pixelSize,
        bandCount: bandCount,
      });
    });
    this.sourceTiles_.length = 0;

    if (dataSources.length === 0) {
      this.state = TileState.ERROR;
      this.changed();
      return;
    }

    const z = this.wrappedTileCoord_[0];
    const size = this.targetTileGrid_.getTileSize(z);
    const targetWidth = typeof size === 'number' ? size : size[0];
    const targetHeight = typeof size === 'number' ? size : size[1];
    const outWidth = Math.round(targetWidth * this.pixelRatio_);
    const outHeight = Math.round(targetHeight * this.pixelRatio_);
    const targetResolution = this.targetTileGrid_.getResolution(z);
    const sourceResolution = this.sourceTileGrid_.getResolution(this.sourceZ_);

    const targetExtent = this.targetTileGrid_.getTileCoordExtent(
      this.wrappedTileCoord_,
    );

    const bandCount = dataSources[0].bandCount;
    const dataR = new dataSources[0].dataType(bandCount * outWidth * outHeight);

    const gl = createCanvasContextWebGL(outWidth, outHeight, canvasGLPool, {
      premultipliedAlpha: false,
      antialias: false,
    });

    let willInterpolate;
    const format = gl.RGBA;
    let textureType;
    if (dataSources[0].dataType == Float32Array) {
      textureType = gl.FLOAT;
      gl.getExtension('WEBGL_color_buffer_float');
      gl.getExtension('OES_texture_float');
      gl.getExtension('EXT_float_blend');
      const extension = gl.getExtension('OES_texture_float_linear');
      const canInterpolate = extension !== null;
      willInterpolate = canInterpolate && this.interpolate;
    } else {
      textureType = gl.UNSIGNED_BYTE;
      willInterpolate = this.interpolate;
    }

    const BANDS_PR_REPROJ = 4;
    const reprojs = Math.ceil(bandCount / BANDS_PR_REPROJ);
    for (let reproj = reprojs - 1; reproj >= 0; --reproj) {
      const sources = [];
      for (let i = 0, len = dataSources.length; i < len; ++i) {
        const dataSource = dataSources[i];

        const pixelSize = dataSource.pixelSize;
        const width = pixelSize[0];
        const height = pixelSize[1];

        const data = new dataSource.dataType(BANDS_PR_REPROJ * width * height);
        const dataS = dataSource.data;
        let offset = reproj * BANDS_PR_REPROJ;
        for (let j = 0, len = data.length; j < len; j += BANDS_PR_REPROJ) {
          data[j] = dataS[offset];
          data[j + 1] = dataS[offset + 1];
          data[j + 2] = dataS[offset + 2];
          data[j + 3] = dataS[offset + 3];
          offset += bandCount;
        }

        const texture = gl.createTexture();
        gl.bindTexture(gl.TEXTURE_2D, texture);

        if (willInterpolate) {
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        } else {
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
        }
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texImage2D(
          gl.TEXTURE_2D,
          0,
          format,
          width,
          height,
          0,
          format,
          textureType,
          data,
        );

        sources.push({
          extent: dataSource.extent,
          clipExtent: dataSource.clipExtent,
          texture: texture,
          width: width,
          height: height,
        });
      }

      const {framebuffer, width, height} = renderReprojected(
        gl,
        targetWidth,
        targetHeight,
        this.pixelRatio_,
        sourceResolution,
        targetResolution,
        targetExtent,
        this.triangulation_,
        sources,
        this.gutter_,
        textureType,
        this.renderEdges_,
        willInterpolate,
      );

      // The texture is always RGBA.
      const rows = width;
      const cols = height * BANDS_PR_REPROJ;
      const data = new dataSources[0].dataType(rows * cols);
      gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
      gl.readPixels(0, 0, width, height, gl.RGBA, textureType, data);

      let offset = reproj * BANDS_PR_REPROJ;
      for (let i = 0, len = data.length; i < len; i += BANDS_PR_REPROJ) {
        // The data read by `readPixels` is flipped in the y-axis so flip it again.
        const flipY = (rows - 1 - ((i / cols) | 0)) * cols + (i % cols);
        dataR[offset] = data[flipY];
        dataR[offset + 1] = data[flipY + 1];
        dataR[offset + 2] = data[flipY + 2];
        dataR[offset + 3] = data[flipY + 3];
        offset += bandCount;
      }
    }

    releaseGLCanvas(gl);
    canvasGLPool.push(gl.canvas);

    if (imageLike) {
      const context = createCanvasContext2D(targetWidth, targetHeight);
      const imageData = new ImageData(dataR, targetWidth);
      context.putImageData(imageData, 0, 0);
      this.reprojData_ = context.canvas;
    } else {
      this.reprojData_ = dataR;
    }
    this.reprojSize_ = [outWidth, outHeight];
    this.state = TileState.LOADED;
    this.changed();
  }

  /**
   * Load not yet loaded URI.
   * @override
   */
  load() {
    if (this.state !== TileState.IDLE && this.state !== TileState.ERROR) {
      return;
    }
    this.state = TileState.LOADING;
    this.changed();

    let leftToLoad = 0;

    this.sourcesListenerKeys_ = [];
    this.sourceTiles_.forEach(({tile}) => {
      const state = tile.getState();
      if (state !== TileState.IDLE && state !== TileState.LOADING) {
        return;
      }
      leftToLoad++;

      const sourceListenKey = listen(tile, EventType.CHANGE, () => {
        const state = tile.getState();
        if (
          state == TileState.LOADED ||
          state == TileState.ERROR ||
          state == TileState.EMPTY
        ) {
          unlistenByKey(sourceListenKey);
          leftToLoad--;
          if (leftToLoad === 0) {
            this.unlistenSources_();
            this.reproject_();
          }
        }
      });
      this.sourcesListenerKeys_.push(sourceListenKey);
    });

    if (leftToLoad === 0) {
      setTimeout(this.reproject_.bind(this), 0);
    } else {
      this.sourceTiles_.forEach(function ({tile}) {
        const state = tile.getState();
        if (state == TileState.IDLE) {
          tile.load();
        }
      });
    }
  }

  /**
   * @private
   */
  unlistenSources_() {
    this.sourcesListenerKeys_.forEach(unlistenByKey);
    this.sourcesListenerKeys_ = null;
  }
}

export default ReprojDataTile;
