import BoundingRectangle from "../Core/BoundingRectangle.js";
import Cartesian2 from "../Core/Cartesian2.js";
import Check from "../Core/Check.js";
import createGuid from "../Core/createGuid.js";
import Frozen from "../Core/Frozen.js";
import defined from "../Core/defined.js";
import destroyObject from "../Core/destroyObject.js";
import CesiumMath from "../Core/Math.js";
import PixelFormat from "../Core/PixelFormat.js";
import Resource from "../Core/Resource.js";
import RuntimeError from "../Core/RuntimeError.js";
import TexturePacker from "../Core/TexturePacker.js";
import Framebuffer from "./Framebuffer.js";
import Texture from "./Texture.js";

const defaultInitialDimensions = 16;

/**
 * A TextureAtlas stores multiple images in one∂ texture and keeps
 * track of the texture coordinates for each image. A TextureAtlas is dynamic,
 * meaning new images can be added at any point in time.
 * Texture coordinates are subject to change if the texture atlas resizes, so it's
 * important to check {@link TextureAtlas#guid} before using old values.
 *
 * @alias TextureAtlas
 * @constructor
 *
 * @param {object} options Object with the following properties:
 * @param {PixelFormat} [options.pixelFormat=PixelFormat.RGBA] The pixel format of the texture.
 * @param {Sampler} [options.sampler=new Sampler()] Information about how to sample the texture.
 * @param {number} [options.borderWidthInPixels=1] The amount of spacing between adjacent images in pixels.
 * @param {Cartesian2} [options.initialSize=new Cartesian2(16.0, 16.0)] The initial side lengths of the texture.
 *
 * @exception {DeveloperError} borderWidthInPixels must be greater than or equal to zero.
 * @exception {DeveloperError} initialSize must be greater than zero.
 *
 * @private
 */
function TextureAtlas(options) {
  options = options ?? Frozen.EMPTY_OBJECT;
  const borderWidthInPixels = options.borderWidthInPixels ?? 1.0;
  const initialSize =
    options.initialSize ??
    new Cartesian2(defaultInitialDimensions, defaultInitialDimensions);

  //>>includeStart('debug', pragmas.debug);
  Check.typeOf.number.greaterThanOrEquals(
    "options.borderWidthInPixels",
    borderWidthInPixels,
    0,
  );
  Check.typeOf.number.greaterThan("options.initialSize.x", initialSize.x, 0);
  Check.typeOf.number.greaterThan("options.initialSize.y", initialSize.y, 0);
  //>>includeEnd('debug');

  this._pixelFormat = options.pixelFormat ?? PixelFormat.RGBA;
  this._sampler = options.sampler;
  this._borderWidthInPixels = borderWidthInPixels;
  this._initialSize = initialSize;

  this._texturePacker = undefined;
  /** @type {BoundingRectangle[]} */
  this._rectangles = [];
  /** @type {Map<number, number>} */
  this._subRegions = new Map();
  this._guid = createGuid();

  this._imagesToAddQueue = [];
  /** @type {Map<string, number>} */
  this._indexById = new Map();
  /** @type {Map<string, Promise<number>>} */
  this._indexPromiseById = new Map();
  this._nextIndex = 0;
}

Object.defineProperties(TextureAtlas.prototype, {
  /**
   * The amount of spacing between adjacent images in pixels.
   * @memberof TextureAtlas.prototype
   * @type {number}
   * @readonly
   * @private
   */
  borderWidthInPixels: {
    get: function () {
      return this._borderWidthInPixels;
    },
  },

  /**
   * An array of {@link BoundingRectangle} pixel offset and dimensions for all the images in the texture atlas.
   * The x and y values of the rectangle correspond to the bottom-left corner of the texture coordinate.
   * If the index is a subregion of an existing image, thea and y values are specified as offsets relative to the parent.
   * The coordinates are in the order that the corresponding images were added to the atlas.
   * @memberof TextureAtlas.prototype
   * @type {BoundingRectangle[]}
   * @readonly
   * @private
   */
  rectangles: {
    get: function () {
      return this._rectangles;
    },
  },

  /**
   * The texture that all of the images are being written to. The value will be <code>undefined</code> until the first update.
   * @memberof TextureAtlas.prototype
   * @type {Texture|undefined}
   * @readonly
   * @private
   */
  texture: {
    get: function () {
      return this._texture;
    },
  },

  /**
   * The pixel format of the texture.
   * @memberof TextureAtlas.prototype
   * @type {PixelFormat}
   * @readonly
   * @private
   */
  pixelFormat: {
    get: function () {
      return this._pixelFormat;
    },
  },

  /**
   * The sampler to use when sampling this texture. If <code>undefined</code>, the default sampler is used.
   * @memberof TextureAtlas.prototype
   * @type {Sampler|undefined}
   * @readonly
   * @private
   */
  sampler: {
    get: function () {
      return this._sampler;
    },
  },

  /**
   * The number of images in the texture atlas. This value increases
   * every time addImage or addImageSubRegion is called.
   * Texture coordinates are subject to change if the texture atlas resizes, so it is
   * important to check {@link TextureAtlas#guid} before using old values.
   * @memberof TextureAtlas.prototype
   * @type {number}
   * @readonly
   * @private
   */
  numberOfImages: {
    get: function () {
      return this._nextIndex;
    },
  },

  /**
   * The atlas' globally unique identifier (GUID).
   * The GUID changes whenever the texture atlas is modified.
   * Classes that use a texture atlas should check if the GUID
   * has changed before processing the atlas data.
   * @memberof TextureAtlas.prototype
   * @type {string}
   * @readonly
   * @private
   */
  guid: {
    get: function () {
      return this._guid;
    },
  },

  /**
   * Returns the size in bytes of the texture.
   * @memberof TextureAtlas.prototype
   * @type {number}
   * @readonly
   * @private
   */
  sizeInBytes: {
    get: function () {
      if (!defined(this._texture)) {
        return 0;
      }

      return this._texture.sizeInBytes;
    },
  },
});

/**
 * Get the texture coordinates for reading the associated image in shaders.
 * @param {number} index The index of the image region.
 * @param {BoundingRectangle} [result] The object into which to store the result.
 * @return {BoundingRectangle} The modified result parameter or a new BoundingRectangle instance if one was not provided.
 * @private
 * @example
 * const index = await atlas.addImage("myImage", image);
 * const rectangle = atlas.computeTextureCoordinates(index);
 * BoundingRectangle.pack(rectangle, bufferView);
 */
TextureAtlas.prototype.computeTextureCoordinates = function (index, result) {
  //>>includeStart('debug', pragmas.debug);
  Check.typeOf.number.greaterThanOrEquals("index", index, 0);
  //>>includeEnd('debug');

  const texture = this._texture;
  const rectangle = this._rectangles[index];

  if (!defined(result)) {
    result = new BoundingRectangle();
  }

  if (!defined(rectangle)) {
    result.x = 0;
    result.y = 0;
    result.width = 0;
    result.height = 0;

    return result;
  }

  const atlasWidth = texture.width;
  const atlasHeight = texture.height;

  const width = rectangle.width;
  const height = rectangle.height;
  let x = rectangle.x;
  let y = rectangle.y;

  const parentIndex = this._subRegions.get(index);
  if (defined(parentIndex)) {
    const parentRectangle = this._rectangles[parentIndex];

    x += parentRectangle.x;
    y += parentRectangle.y;
  }

  result.x = x / atlasWidth;
  result.y = y / atlasHeight;
  result.width = width / atlasWidth;
  result.height = height / atlasHeight;

  return result;
};

/**
 * Perform a WebGL texture copy for each existing image from its previous packed position to its new packed position in the new texture.
 * @param {Context} context The rendering context
 * @param {number} width The pixel width of the texture
 * @param {number} height The pixel height of the texture
 * @param {BoundingRectangle[]} rectangles The packed bounding rectangles for the reszied texture
 * @param {number} queueOffset Index of the last queued item that was successfully packed
 * @private
 */
TextureAtlas.prototype._copyFromTexture = function (
  context,
  width,
  height,
  rectangles,
) {
  const pixelFormat = this._pixelFormat;
  const sampler = this._sampler;
  const newTexture = new Texture({
    context,
    height,
    width,
    pixelFormat,
    sampler,
  });

  const gl = context._gl;
  const target = newTexture._textureTarget;

  const oldTexture = this._texture;
  const framebuffer = new Framebuffer({
    context,
    colorTextures: [oldTexture],
    destroyAttachments: false,
  });

  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(target, newTexture._texture);

  framebuffer._bind();

  // Copy any textures from the old atlas to its new position in the new atlas
  const oldRectangles = this.rectangles;
  const subRegions = this._subRegions;
  for (let index = 0; index < oldRectangles.length; ++index) {
    const rectangle = rectangles[index];
    const frameBufferOffset = oldRectangles[index];

    if (
      !defined(rectangle) ||
      !defined(frameBufferOffset) ||
      defined(subRegions.get(index)) // The rectangle corresponds to a subregion of a parent image
    ) {
      continue;
    }

    const { x, y, width, height } = rectangle;
    gl.copyTexSubImage2D(
      target,
      0,
      x,
      y,
      frameBufferOffset.x,
      frameBufferOffset.y,
      width,
      height,
    );
  }

  gl.bindTexture(target, null);
  newTexture._initialized = true;

  framebuffer._unBind();
  framebuffer.destroy();

  return newTexture;
};

/**
 * Recreates the texture atlas texture with new dimensions and repacks images as needed.
 * @param {Context} context The rendering context
 * @param {number} [queueOffset = 0] Index of the last queued item that was successfully packed
 * @private
 */
TextureAtlas.prototype._resize = function (context, queueOffset = 0) {
  const borderPadding = this._borderWidthInPixels;
  const oldRectangles = this._rectangles;
  const queue = this._imagesToAddQueue;

  const oldTexture = this._texture;
  let width = oldTexture.width;
  let height = oldTexture.height;

  // Get the rectangles (width and height) of the current set of images,
  // ignoring the subregions, which don't get packed
  const subRegions = this._subRegions;
  const toPack = oldRectangles
    .map((image, index) => {
      return new AddImageRequest({ index, image });
    })
    .filter(
      (request, index) =>
        defined(request.image) && !defined(subRegions.get(index)),
    );

  // Add the new set of images
  let maxWidth = 0;
  let maxHeight = 0;
  let areaQueued = 0;
  for (let i = queueOffset; i < queue.length; ++i) {
    const { width, height } = queue[i].image;
    maxWidth = Math.max(maxWidth, width);
    maxHeight = Math.max(maxHeight, height);
    areaQueued += width * height;
    toPack.push(queue[i]);
  }

  // At minimum, atlas must fit its largest input images. Texture coordinates are
  // compressed to 0–1 with 12-bit precision, so use power-of-two size to align pixels.
  width = CesiumMath.nextPowerOfTwo(Math.max(maxWidth, width));
  height = CesiumMath.nextPowerOfTwo(Math.max(maxHeight, height));

  // Iteratively double the smallest dimension until atlas area is (approximately) sufficient.
  while (areaQueued >= width * height) {
    if (width > height) {
      height *= 2;
    } else {
      width *= 2;
    }
  }

  toPack.sort(
    ({ image: imageA }, { image: imageB }) =>
      imageB.height * imageB.width - imageA.height * imageA.width,
  );

  const newRectangles = new Array(this._nextIndex);
  for (const index of this._subRegions.keys()) {
    // Subregions are specified relative to their parents,
    // so we can copy them directly
    if (defined(subRegions.get(index))) {
      newRectangles[index] = oldRectangles[index];
    }
  }

  let texturePacker,
    packed = false;
  while (!packed) {
    texturePacker = new TexturePacker({ height, width, borderPadding });

    let i;
    for (i = 0; i < toPack.length; ++i) {
      const { index, image } = toPack[i];
      if (!defined(image)) {
        continue;
      }

      const repackedNode = texturePacker.pack(index, image);
      if (!defined(repackedNode)) {
        // Could not fit everything into the new texture.
        // Scale texture size and try again
        if (width > height) {
          // Resize height
          height *= 2.0;
        } else {
          // Resize width
          width *= 2.0;
        }

        break;
      }

      newRectangles[index] = repackedNode.rectangle;
    }

    packed = i === toPack.length;
  }

  this._texturePacker = texturePacker;
  this._texture = this._copyFromTexture(context, width, height, newRectangles);

  oldTexture.destroy();

  this._rectangles = newRectangles;
  this._guid = createGuid();
};

/**
 * Return the index of the image region for the specified ID. If the image is already in the atlas, the existing index is returned. Otherwise, the result is undefined.
 * @param {string} id An identifier to detect whether the image already exists in the atlas.
 * @returns {number|undefined} The image index, or undefined if the image does not exist in the atlas.
 * @private
 */
TextureAtlas.prototype.getImageIndex = function (id) {
  //>>includeStart('debug', pragmas.debug);
  Check.typeOf.string("id", id);
  //>>includeEnd('debug');

  return this._indexById.get(id);
};

/**
 * Copy image data into the underlying texture atlas.
 * @param {AddImageRequest} imageRequest The data needed to resolve the call to addImage in the queue
 * @private
 */
TextureAtlas.prototype._copyImageToTexture = function ({
  index,
  image,
  resolve,
  reject,
}) {
  const texture = this._texture;
  const rectangle = this._rectangles[index];

  try {
    texture.copyFrom({
      source: image,
      xOffset: rectangle.x,
      yOffset: rectangle.y,
    });

    if (defined(resolve)) {
      resolve(index);
    }
  } catch (e) {
    if (defined(reject)) {
      reject(e);
      return;
    }
  }
};

/**
 * Info needed to add a queued image to the texture atlas when update operatons are executed, typically at the end of a frame.
 * @constructor
 * @private
 * @param {object} options Object with the following properties:
 * @param {number} options.index An identifier
 * @param {TexturePacker.PackableObject} options.image An object, such as an <code>Image</code> with <code>width</code> and <code>height</code> properties in pixels
 * @param {function} [options.resolve] The promise resolver
 * @param {function} [options.reject] The promise rejecter
 */
function AddImageRequest({ index, image, resolve, reject }) {
  this.index = index;
  this.image = image;
  this.resolve = resolve;
  this.reject = reject;
  this.rectangle = undefined;
}

/**
 * Adds an image to the queue for this frame.
 * The image will be copied to the texture at the end of the frame, resizing the texture if needed.
 *
 * @private
 * @param {number} index An identifier
 * @param {TexturePacker.PackableObject} image An object, such as an <code>Image</code> with <code>width</code> and <code>height</code> properties in pixels
 * @returns {Promise<number>} Promise which resolves to the image index once the image has been added, or rejects if there was an error. The promise resolves to <code>-1</code> if the texture atlas is destoyed in the interim.
 */
TextureAtlas.prototype._addImage = function (index, image) {
  //>>includeStart('debug', pragmas.debug);
  Check.typeOf.number.greaterThanOrEquals("index", index, 0);
  Check.defined("image", image);
  //>>includeEnd('debug');

  return new Promise((resolve, reject) => {
    this._imagesToAddQueue.push(
      new AddImageRequest({
        index,
        image,
        resolve,
        reject,
      }),
    );

    this._imagesToAddQueue.sort(
      ({ image: imageA }, { image: imageB }) =>
        imageB.height * imageB.width - imageA.height * imageA.width,
    );
  });
};

/**
 * Process the image queue for this frame, copying to the texture atlas and resizing the texture as needed.
 * @private
 * @param {Context} context The rendering context
 * @return {boolean} true if the texture was updated this frame
 */
TextureAtlas.prototype._processImageQueue = function (context) {
  const queue = this._imagesToAddQueue;
  if (queue.length === 0) {
    return false;
  }

  this._rectangles.length = this._nextIndex;

  let i, error;
  for (i = 0; i < queue.length; ++i) {
    const imageRequest = queue[i];
    const { image, index } = imageRequest;
    const node = this._texturePacker.pack(index, image);
    if (!defined(node)) {
      // Atlas cannot fit all images in the queue
      // Bail early and resize
      try {
        this._resize(context, i);
      } catch (e) {
        error = e;

        if (defined(imageRequest.reject)) {
          imageRequest.reject(error);
        }
      }
      break;
    }
    this._rectangles[index] = node.rectangle;
  }

  if (defined(error)) {
    for (i = i + 1; i < queue.length; ++i) {
      const { resolve } = queue[i];
      if (defined(resolve)) {
        resolve(-1);
      }
    }

    queue.length = 0;
    return false;
  }

  for (let i = 0; i < queue.length; ++i) {
    this._copyImageToTexture(queue[i]);
  }

  queue.length = 0;
  return true;
};

/**
 * Processes any updates queued this frame, and updates rendering resources accordingly. Call before or after a frame has been rendered to avoid any race conditions for any dependant render commands.
 * @private
 * @param {Context} context The rendering context
 * @return {boolean} true if rendering resources were updated.
 */
TextureAtlas.prototype.update = function (context) {
  if (!defined(this._texture)) {
    const width = this._initialSize.x;
    const height = this._initialSize.y;
    const pixelFormat = this._pixelFormat;
    const sampler = this._sampler;
    const borderPadding = this._borderWidthInPixels;

    this._texture = new Texture({
      context,
      width,
      height,
      pixelFormat,
      sampler,
    });

    this._texturePacker = new TexturePacker({
      height,
      width,
      borderPadding,
    });
  }

  return this._processImageQueue(context);
};

async function resolveImage(image, id) {
  if (typeof image === "function") {
    image = image(id);
  }

  if (typeof image === "string" || image instanceof Resource) {
    // Fetch the resource
    const resource = Resource.createIfNeeded(image);
    image = resource.fetchImage();
  }

  return image;
}

/**
 * Adds an image to the atlas.  If the image is already in the atlas, the atlas is unchanged and
 * the existing index is used.
 * @private
 * @param {string} id An identifier to detect whether the image already exists in the atlas.
 * @param {HTMLImageElement|HTMLCanvasElement|string|Resource|Promise|TextureAtlas.CreateImageCallback} image An image or canvas to add to the texture atlas,
 *        or a URL to an Image, or a Promise for an image, or a function that creates an image.
 * @param {number} width A number specifying the width of the texture. If undefined, the image width will be used.
 * @param {number} height A number specifying the height of the texture. If undefined, the image height will be used.
 * @returns {Promise<number> | number} The image region index or a promise that resolves to it. -1 is returned if resources are in the process of being destroyed.
 */
TextureAtlas.prototype.addImage = function (id, image, width, height) {
  //>>includeStart('debug', pragmas.debug);
  Check.typeOf.string("id", id);
  Check.defined("image", image);
  //>>includeEnd('debug');

  let promise = this._indexPromiseById.get(id);
  let index = this._indexById.get(id);
  if (defined(promise)) {
    // This image is already being added
    return promise;
  }
  if (defined(index)) {
    // This image has already been added and resolved
    return index;
  }

  index = this._nextIndex++;
  this._indexById.set(id, index);

  const resolveAndAddImage = async () => {
    const resolvedImage = await resolveImage(image, id);
    //>>includeStart('debug', pragmas.debug);
    Check.defined("image", resolvedImage);
    //>>includeEnd('debug');

    if (this.isDestroyed() || !defined(resolvedImage)) {
      this._indexPromiseById.delete(id);
      return -1;
    }

    if (defined(width)) {
      resolvedImage.width = width;
    }
    if (defined(height)) {
      resolvedImage.height = height;
    }

    const imageIndex = await this._addImage(index, resolvedImage);
    this._indexPromiseById.delete(id);
    return imageIndex;
  };

  promise = resolveAndAddImage();
  this._indexPromiseById.set(id, promise);
  return promise;
};

/**
 * Get an existing sub-region of an existing atlas image as additional image indices.
 * @private
 * @param {string} id The identifier of the existing image.
 * @param {BoundingRectangle} subRegion An {@link BoundingRectangle} defining a region of an existing image, measured in pixels from the bottom-left of the image.
 * @param {number} imageIndex The index of the image.
 * @returns {Promise<number> | number | undefined} The existing subRegion index, or undefined if not yet added.
 */
TextureAtlas.prototype.getCachedImageSubRegion = function (
  id,
  subRegion,
  imageIndex,
) {
  const imagePromise = this._indexPromiseById.get(id);
  for (const [index, parentIndex] of this._subRegions.entries()) {
    if (imageIndex === parentIndex) {
      const boundingRegion = this._rectangles[index];
      if (boundingRegion.equals(subRegion)) {
        // The subregion is already being tracked
        if (imagePromise) {
          return imagePromise.then((resolvedImageIndex) =>
            resolvedImageIndex === -1 ? -1 : index,
          );
        }
        return index;
      }
    }
  }
};

/**
 * Add a sub-region of an existing atlas image as additional image indices.
 * @private
 * @param {string} id The identifier of the existing image.
 * @param {BoundingRectangle} subRegion An {@link BoundingRectangle} defining a region of an existing image, measured in pixels from the bottom-left of the image.
 * @returns {number | Promise<number>} The resolved image region index, or a Promise that resolves to it. -1 is returned if resources are in the process of being destroyed.
 */
TextureAtlas.prototype.addImageSubRegion = function (id, subRegion) {
  //>>includeStart('debug', pragmas.debug);
  Check.typeOf.string("id", id);
  Check.defined("subRegion", subRegion);
  //>>includeEnd('debug');
  const imageIndex = this._indexById.get(id);
  if (!defined(imageIndex)) {
    throw new RuntimeError(`image with id "${id}" not found in the atlas.`);
  }

  let index = this.getCachedImageSubRegion(id, subRegion, imageIndex);
  if (defined(index)) {
    return index;
  }

  index = this._nextIndex++;
  this._subRegions.set(index, imageIndex);
  this._rectangles[index] = subRegion.clone();

  const indexPromise =
    this._indexPromiseById.get(id) ?? Promise.resolve(imageIndex);

  return indexPromise.then((imageIndex) => {
    if (imageIndex === -1) {
      // The atlas has been destroyed
      return -1;
    }

    const rectangle = this._rectangles[imageIndex];

    //>>includeStart('debug', pragmas.debug);
    Check.typeOf.number.lessThanOrEquals(
      "subRegion.x",
      subRegion.x,
      rectangle.width,
    );
    Check.typeOf.number.lessThanOrEquals(
      "subRegion.x + subRegion.width",
      subRegion.x + subRegion.width,
      rectangle.width,
    );
    Check.typeOf.number.lessThanOrEquals(
      "subRegion.y",
      subRegion.y,
      rectangle.height,
    );
    Check.typeOf.number.lessThanOrEquals(
      "subRegion.y + subRegion.height",
      subRegion.y + subRegion.height,
      rectangle.height,
    );
    //>>includeEnd('debug');

    return index;
  });
};

/**
 * Returns true if this object was destroyed; otherwise, false.
 * <br /><br />
 * If this object was destroyed, it should not be used; calling any function other than
 * <code>isDestroyed</code> will result in a {@link DeveloperError} exception.
 * @private
 * @returns {boolean} True if this object was destroyed; otherwise, false.
 * @see TextureAtlas#destroy
 */
TextureAtlas.prototype.isDestroyed = function () {
  return false;
};

/**
 * Destroys the WebGL resources held by this object.  Destroying an object allows for deterministic
 * release of WebGL resources, instead of relying on the garbage collector to destroy this object.
 * <br /><br />
 * Once an object is destroyed, it should not be used; calling any function other than
 * <code>isDestroyed</code> will result in a {@link DeveloperError} exception.  Therefore,
 * assign the return value (<code>undefined</code>) to the object as done in the example.
 * @private
 * @exception {DeveloperError} This object was destroyed, i.e., destroy() was called.
 * @example
 * atlas = atlas && atlas.destroy();
 * @see TextureAtlas#isDestroyed
 */
TextureAtlas.prototype.destroy = function () {
  this._texture = this._texture && this._texture.destroy();
  this._imagesToAddQueue.forEach(({ resolve }) => {
    if (defined(resolve)) {
      resolve(-1);
    }
  });

  return destroyObject(this);
};

/**
 * A function that creates an image.
 * @private
 * @callback TextureAtlas.CreateImageCallback
 * @param {string} id The identifier of the image to load.
 * @returns {HTMLImageElement|Promise<HTMLImageElement>} The image, or a promise that will resolve to an image.
 */

export default TextureAtlas;
