/**
 * @import {KTX2Header, KTX2ParserInitOptions, KTX2ParserParseOptions, KTX2TranscodeTarget} from "./_types.mjs";
 */

/**
 * A KTX2 PIXI loader parser using the official Khronos KTX module.
 */
export default class KTX2Parser {

  /**
   * The default path to the Khronos libktx WebAssembly module.
   * @type {string}
   */
  static WASM_PATH = "scripts/ktx2/libktx.wasm";

  /**
   * The binary KTX2 identifier.
   * @type {number[]}
   */
  static #IDENTIFIER = [0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A];

  /**
   * Map KTX2 GPU format identifiers to PIXI internal formats.
   * @type {Object<number, number>}
   */
  static #KTX2_FORMAT_TO_INTERNAL_FORMAT = {
    131: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB_S3TC_DXT1_EXT,
    132: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB_S3TC_DXT1_EXT,
    133: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT,
    134: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT,
    135: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT,
    136: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT,
    137: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT,
    138: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT,
    145: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_BPTC_UNORM_EXT,
    146: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_BPTC_UNORM_EXT,
    147: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB8_ETC2,
    148: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB8_ETC2,
    149: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2,
    150: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2,
    151: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA8_ETC2_EAC,
    152: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA8_ETC2_EAC,
    153: PIXI.INTERNAL_FORMATS.COMPRESSED_R11_EAC,
    154: PIXI.INTERNAL_FORMATS.COMPRESSED_SIGNED_R11_EAC,
    155: PIXI.INTERNAL_FORMATS.COMPRESSED_RG11_EAC,
    156: PIXI.INTERNAL_FORMATS.COMPRESSED_SIGNED_RG11_EAC,
    157: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_ASTC_4x4_KHR,
    158: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_ASTC_4x4_KHR
  };

  /**
   * Extension names used by the current WebGL compressed texture APIs.
   * @type {Object<string, string[]>}
   */
  static #WEBGL_EXTENSIONS = {
    astc: ["WEBGL_compressed_texture_astc"],
    bptc: ["EXT_texture_compression_bptc"],
    etc: ["WEBGL_compressed_texture_etc"],
    s3tc: ["WEBGL_compressed_texture_s3tc"]
  };

  /**
   * A PIXI asset detection parser for KTX2 textures.
   * @type {object}
   */
  static detectKTX2 = {
    extension: {
      type: PIXI.ExtensionType.DetectionParser,
      priority: 3
    },
    test: async () => !!globalThis.WebAssembly,
    add: async formats => [...formats, "ktx2"],
    remove: async formats => formats.filter(format => format !== "ktx2")
  };

  /**
   * A PIXI asset resolver for KTX2 texture URLs.
   * @type {object}
   */
  static resolveKTX2TextureUrl = {
    extension: PIXI.ExtensionType.ResolveParser,
    test: value => PIXI.utils.path.extname(value).toLowerCase() === ".ktx2",
    parse: value => ({
      resolution: PIXI.utils.getResolutionOfUrl(value),
      format: "ktx2",
      src: value
    })
  };

  /**
   * A PIXI asset loader parser for KTX2 textures.
   * @type {object}
   */
  static loadKTX2 = {
    extension: {
      type: PIXI.ExtensionType.LoadParser,
      priority: PIXI.LoaderParserPriority.High
    },
    name: "loadKTX2",
    test: url => PIXI.checkExtension(url, ".ktx2"),
    load: async (url, asset, loader) => {
      const resource = await KTX2Parser.loadResource(url);
      const baseTexture = new PIXI.BaseTexture(resource, {
        alphaMode: resource.isPremultiplied ? PIXI.ALPHA_MODES.PREMULTIPLIED_ALPHA
          : PIXI.ALPHA_MODES.NO_PREMULTIPLIED_ALPHA,
        mipmap: resource.levels > 1 ? PIXI.MIPMAP_MODES.ON_MANUAL : PIXI.MIPMAP_MODES.OFF,
        resolution: PIXI.utils.getResolutionOfUrl(url),
        ...asset.data
      });
      return PIXI.createTexture(baseTexture, loader, url);
    },
    unload: texture => texture.destroy(true)
  };

  /**
   * The initialized Khronos KTX module.
   * @type {object|null}
   */
  static #module = null;

  /**
   * The pending Khronos KTX module initialization.
   * @type {Promise<object>|null}
   */
  static #modulePromise = null;

  /* -------------------------------------------- */

  /**
   * The initialized Khronos KTX module.
   * @type {object|null}
   */
  static get module() {
    return this.#module;
  }

  /* -------------------------------------------- */

  /**
   * Has the Khronos KTX module been initialized?
   * @type {boolean}
   */
  static get initialized() {
    return !!this.#module;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the Khronos KTX module.
   * @param {KTX2ParserInitOptions} [options={}]  Initialization options.
   * @returns {Promise<object>}                   The initialized Khronos KTX module.
   */
  static initialize(options={}) {
    if ( this.#module ) return Promise.resolve(this.#module);
    if ( this.#modulePromise ) return this.#modulePromise;
    const wasmPath = options.wasmPath ?? this.WASM_PATH;
    this.#modulePromise = this.#initialize(wasmPath).catch(err => {
      this.#modulePromise = null;
      throw err;
    });
    return this.#modulePromise;
  }

  /* -------------------------------------------- */

  /**
   * Load a KTX2 URL as a PIXI compressed texture resource.
   * @param {string} url                           The texture URL.
   * @param {KTX2ParserParseOptions} [options]     Parser options.
   * @returns {Promise<PIXI.CompressedTextureResource>} The compressed texture resource.
   */
  static async loadResource(url, options={}) {
    const response = await PIXI.settings.ADAPTER.fetch(url);
    const arrayBuffer = await response.arrayBuffer();
    return this.parse(arrayBuffer, options);
  }

  /* -------------------------------------------- */

  /**
   * Parse KTX2 data as a PIXI compressed texture resource.
   * @param {ArrayBuffer|Uint8Array} data          The KTX2 file data.
   * @param {KTX2ParserParseOptions} [options]     Parser options.
   * @returns {Promise<PIXI.CompressedTextureResource>} The compressed texture resource.
   */
  static async parse(data, options={}) {
    const ktx = await this.initialize();
    const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
    const header = this.#readHeader(bytes);
    this.#validateHeader(header);

    const texture = new ktx.texture(bytes);
    try {

      // Read isPremultiplied before transcoding because it becomes false when transcoded for some reason
      const isPremultiplied = texture.isPremultiplied;
      if ( texture.needsTranscoding ) this.#transcodeTexture(ktx, texture, options);
      const format = this.#getInternalFormat(texture.vkFormat || header.gpuFormat);
      const levelBuffers = this.#getLevelBuffers(texture, header);
      const resource = new PIXI.CompressedTextureResource(null, {
        format,
        width: texture.baseWidth || header.pixelWidth,
        height: texture.baseHeight || header.pixelHeight,
        levels: levelBuffers.length,
        levelBuffers
      });
      resource.isPremultiplied = isPremultiplied;
      return resource;
    }
    finally {
      texture.delete();
    }
  }

  /* -------------------------------------------- */

  /**
   * Initialize the Khronos KTX module from its preloaded JavaScript factory.
   * @param {string} wasmPath   The URL of the libktx WebAssembly module.
   * @returns {Promise<object>} The initialized Khronos KTX module.
   */
  static async #initialize(wasmPath) {
    const {createKtxModule} = globalThis;
    if ( typeof createKtxModule !== "function" ) {
      throw new Error("KTX2Parser requires the Khronos libktx script to be loaded.");
    }
    const ktx = await createKtxModule({locateFile: path => path === "libktx.wasm" ? wasmPath : path});
    if ( !ktx?.ktxTexture || !ktx.TranscodeTarget ) {
      throw new Error("KTX2Parser initialized an invalid Khronos libktx module.");
    }
    return this.#module = ktx;
  }

  /* -------------------------------------------- */

  /**
   * Read the KTX2 file header.
   * @param {Uint8Array} bytes  The file bytes.
   * @returns {KTX2Header}      The parsed KTX2 header.
   */
  static #readHeader(bytes) {
    if ( bytes.byteLength < 68 ) throw new Error("KTX2Parser received an invalid KTX2 file.");
    for ( let i = 0; i < this.#IDENTIFIER.length; i++ ) {
      if ( bytes[i] !== this.#IDENTIFIER[i] ) throw new Error("KTX2Parser received an invalid KTX2 file.");
    }
    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    return {
      gpuFormat: view.getUint32(12, true),
      pixelWidth: view.getUint32(20, true),
      pixelHeight: view.getUint32(24, true),
      pixelDepth: view.getUint32(28, true),
      layerCount: view.getUint32(32, true) || 1,
      faceCount: view.getUint32(36, true) || 1,
      levelCount: view.getUint32(40, true) || 1,
      supercompressionScheme: view.getUint32(44, true)
    };
  }

  /* -------------------------------------------- */

  /**
   * Validate that a KTX2 texture is compatible with Foundry's 2D texture pipeline.
   * @param {KTX2Header} header  The parsed KTX2 header.
   */
  static #validateHeader(header) {
    if ( !header.pixelWidth || !header.pixelHeight ) {
      throw new Error("KTX2Parser requires a 2D KTX2 texture with non-zero dimensions.");
    }
    if ( header.pixelDepth > 0 ) throw new Error("KTX2Parser does not support 3D KTX2 textures.");
    if ( header.layerCount > 1 ) throw new Error("KTX2Parser does not support array KTX2 textures.");
    if ( header.faceCount > 1 ) throw new Error("KTX2Parser does not support cubemap KTX2 textures.");
  }

  /* -------------------------------------------- */

  /**
   * Transcode a Basis Universal KTX2 texture to the best supported compressed target.
   * @param {object} ktx                         The Khronos KTX module.
   * @param {object} texture                     The KTX texture object.
   * @param {KTX2ParserParseOptions} [options]   Parser options.
   */
  static #transcodeTexture(ktx, texture, options={}) {
    const target = options.transcodeTarget
      ? this.#getNamedTranscodeTarget(ktx, options.transcodeTarget)
      : this.#getTranscodeTarget(ktx);
    const result = texture.transcodeBasis(target.target, 0);
    const resultCode = result?.value ?? result;

    if ( resultCode !== ktx.ErrorCode.SUCCESS.value ) {
      throw new Error(`KTX2Parser failed to transcode the texture to ${target.name}.`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get a named Khronos transcode target.
   * @param {object} ktx       The Khronos KTX module.
   * @param {string} name      The transcode target name.
   * @returns {KTX2TranscodeTarget} The transcode target.
   */
  static #getNamedTranscodeTarget(ktx, name) {
    const target = ktx.TranscodeTarget[name];
    if ( target === undefined ) throw new Error(`KTX2Parser does not recognize transcode target ${name}.`);
    return {name, target};
  }

  /* -------------------------------------------- */

  /**
   * Select the best supported Khronos transcode target.
   * @param {object} ktx       The Khronos KTX module.
   * @returns {KTX2TranscodeTarget} The selected transcode target.
   */
  static #getTranscodeTarget(ktx) {
    const supported = this.#getSupportedExtensions();
    const targets = [
      {name: "ASTC_4x4_RGBA", extension: "astc"},
      {name: "BC7_RGBA", extension: "bptc"},
      {name: "BC1_OR_3", extension: "s3tc"},
      {name: "ETC2_RGBA", extension: "etc"}
    ];

    for ( const target of targets ) {
      if ( !supported[target.extension] ) continue;
      const value = ktx.TranscodeTarget[target.name];
      if ( value === undefined ) continue;
      return {name: target.name, target: value};
    }

    throw new Error("KTX2Parser could not find a supported compressed texture target.");
  }

  /* -------------------------------------------- */

  /**
   * Get the supported WebGL compressed texture extensions.
   * @returns {Object<string, boolean>} The supported extension map.
   */
  static #getSupportedExtensions() {
    const rendererExtensions = globalThis.canvas?.app?.renderer?.context?.extensions;
    if ( rendererExtensions ) return {
      astc: !!rendererExtensions.astc,
      bptc: !!rendererExtensions.bptc,
      etc: !!rendererExtensions.etc,
      s3tc: !!rendererExtensions.s3tc
    };

    const gl = this.#getWebGLContext();
    if ( !gl ) return {};

    const supported = {};
    for ( const [key, names] of Object.entries(this.#WEBGL_EXTENSIONS) ) {
      supported[key] = names.some(name => !!gl.getExtension(name));
    }
    return supported;
  }

  /* -------------------------------------------- */

  /**
   * Get the active WebGL context for compressed texture capability checks.
   * @returns {WebGLRenderingContext|WebGL2RenderingContext|null} The WebGL context.
   */
  static #getWebGLContext() {
    const renderer = globalThis.canvas?.app?.renderer;
    if ( renderer?.gl ) return renderer.gl;
    return renderer?.context?.gl ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Get the PIXI internal format for a KTX2 GPU format identifier.
   * @param {number} gpuFormat The KTX2 GPU format identifier.
   * @returns {number}         The PIXI internal format.
   */
  static #getInternalFormat(gpuFormat) {
    const format = this.#KTX2_FORMAT_TO_INTERNAL_FORMAT[gpuFormat];
    if ( !format ) throw new Error(`KTX2Parser does not support KTX2 GPU format ${gpuFormat}.`);
    return format;
  }

  /* -------------------------------------------- */

  /**
   * Extract the embedded KTX2 mip levels.
   * @param {object} texture     The KTX texture object.
   * @param {KTX2Header} header  The parsed KTX2 header.
   * @returns {object[]}         The PIXI compressed texture level buffers.
   */
  static #getLevelBuffers(texture, header) {
    const levels = header.levelCount;
    const levelBuffers = [];
    for ( let levelID = 0; levelID < levels; levelID++ ) {
      const levelBuffer = texture.getImage(levelID, 0, 0);
      if ( !levelBuffer ) throw new Error(`KTX2Parser could not read mip level ${levelID}.`);
      levelBuffers.push({
        levelID,
        levelWidth: Math.max(1, header.pixelWidth >> levelID),
        levelHeight: Math.max(1, header.pixelHeight >> levelID),
        levelBuffer: new Uint8Array(levelBuffer)
      });
    }
    return levelBuffers;
  }
}

PIXI.extensions.add(KTX2Parser.detectKTX2, KTX2Parser.resolveKTX2TextureUrl, KTX2Parser.loadKTX2);
