import {load} from '@loaders.gl/core';
import {getSupportedGPUTextureFormats, selectSupportedBasisFormat} from '@loaders.gl/textures';
import {I3SNodePageLoader} from '../../i3s-node-page-loader';
import {normalizeTileNonUrlData} from '../parsers/parse-i3s';
import {getUrlWithToken, generateTilesetAttributeUrls} from '../utils/url-utils';
import type {LoaderOptions} from '@loaders.gl/loader-utils';
import {
  LodSelection,
  NodePage,
  NodeInPage,
  Obb,
  MeshMaterial,
  I3SMaterialDefinition,
  I3STextureFormat,
  MeshGeometry,
  I3STileHeader,
  SceneLayer3D
} from '../../types';

/**
 * class I3SNodePagesTiles - loads nodePages and form i3s tiles from them
 */
export default class I3SNodePagesTiles {
  tileset: SceneLayer3D;
  nodePages: NodePage[] = [];
  pendingNodePages: {promise: Promise<NodePage>; status: 'Pending' | 'Done'}[] = [];
  nodesPerPage: number;
  options: LoaderOptions;
  lodSelectionMetricType?: string;
  textureDefinitionsSelectedFormats: ({format: I3STextureFormat; name: string} | null)[] = [];
  nodesInNodePages: number;
  url: string;
  private textureLoaderOptions: {[key: string]: any} = {};

  /**
   * @constructs
   * Create a I3SNodePagesTiles instance.
   * @param tileset - i3s tileset header ('layers/0')
   * @param url - tileset url
   * @param options - i3s loader options
   */
  constructor(tileset: SceneLayer3D, url: string = '', options: LoaderOptions) {
    this.tileset = {...tileset}; // spread the tileset to avoid circular reference
    this.url = url;
    this.nodesPerPage = tileset.nodePages?.nodesPerPage || 64;
    this.lodSelectionMetricType = tileset.nodePages?.lodSelectionMetricType;
    this.options = options;
    this.nodesInNodePages = 0;

    this.initSelectedFormatsForTextureDefinitions(tileset);
  }

  /**
   * Loads some nodePage and return a particular node from it
   * @param id - id of node through all node pages
   */
  async getNodeById(id: number): Promise<NodeInPage> {
    const pageIndex = Math.floor(id / this.nodesPerPage);
    if (!this.nodePages[pageIndex] && !this.pendingNodePages[pageIndex]) {
      const nodePageUrl = getUrlWithToken(
        `${this.url}/nodepages/${pageIndex}`,
        // @ts-expect-error this.options is not properly typed
        this.options.i3s?.token
      );
      this.pendingNodePages[pageIndex] = {
        status: 'Pending',
        promise: load(nodePageUrl, I3SNodePageLoader, this.options)
      };
      this.nodePages[pageIndex] = await this.pendingNodePages[pageIndex].promise;
      this.nodesInNodePages += this.nodePages[pageIndex].nodes.length;
      this.pendingNodePages[pageIndex].status = 'Done';
    }
    if (this.pendingNodePages[pageIndex].status === 'Pending') {
      this.nodePages[pageIndex] = await this.pendingNodePages[pageIndex].promise;
    }
    const nodeIndex = id % this.nodesPerPage;
    return this.nodePages[pageIndex].nodes[nodeIndex];
  }

  /**
   * Forms tile header using node and tileset data
   * @param id - id of node through all node pages
   */
  // eslint-disable-next-line complexity, max-statements
  async formTileFromNodePages(id: number): Promise<I3STileHeader> {
    const node: NodeInPage = await this.getNodeById(id);
    const children: {id: string; obb: Obb}[] = [];
    const childNodesPromises: Promise<NodeInPage>[] = [];
    for (const child of node.children || []) {
      childNodesPromises.push(this.getNodeById(child));
    }

    const childNodes = await Promise.all(childNodesPromises);
    for (const childNode of childNodes) {
      children.push({
        id: childNode.index.toString(),
        obb: childNode.obb
      });
    }

    let contentUrl: string | undefined;
    let textureUrl: string | undefined;
    let materialDefinition: I3SMaterialDefinition | undefined;
    let textureFormat: I3STextureFormat = 'jpg';
    let attributeUrls: string[] = [];
    let isDracoGeometry: boolean = false;

    if (node && node.mesh) {
      // Get geometry resource URL and type (compressed / non-compressed)
      const {url, isDracoGeometry: isDracoGeometryResult} = (node.mesh.geometry &&
        this.getContentUrl(node.mesh.geometry)) || {isDracoGeometry: false};
      contentUrl = url;
      isDracoGeometry = isDracoGeometryResult;

      const {textureData, materialDefinition: nodeMaterialDefinition} =
        this.getInformationFromMaterial(node.mesh.material);
      materialDefinition = nodeMaterialDefinition;
      textureFormat = textureData.format || textureFormat;
      if (textureData.name) {
        textureUrl = `${this.url}/nodes/${node.mesh.material.resource}/textures/${textureData.name}`;
      }

      if (this.tileset.attributeStorageInfo) {
        attributeUrls = generateTilesetAttributeUrls(
          this.tileset,
          this.url,
          node.mesh.attribute.resource
        );
      }
    }

    const lodSelection = this.getLodSelection(node);

    return normalizeTileNonUrlData({
      id: id.toString(),
      lodSelection,
      obb: node.obb,
      contentUrl,
      textureUrl,
      attributeUrls,
      materialDefinition,
      textureFormat,
      textureLoaderOptions: this.textureLoaderOptions,
      children,
      isDracoGeometry
    });
  }

  /**
   * Forms url and type of geometry resource by nodepage's data and `geometryDefinitions` in the tileset
   * @param - data about the node's mesh from the nodepage
   * @returns -
   *   {string} url - url to the geometry resource
   *   {boolean} isDracoGeometry - whether the geometry resource contain DRACO compressed geometry
   */
  private getContentUrl(meshGeometryData: MeshGeometry) {
    let result: {url: string; isDracoGeometry: boolean} | null = null;
    // @ts-ignore
    const geometryDefinition = this.tileset.geometryDefinitions[meshGeometryData.definition];
    let geometryIndex = -1;
    // Try to find DRACO geometryDefinition of `useDracoGeometry` option is set
    // @ts-expect-error this.options is not properly typed
    if (this.options.i3s && this.options.i3s.useDracoGeometry) {
      geometryIndex = geometryDefinition.geometryBuffers.findIndex(
        (buffer) => buffer.compressedAttributes && buffer.compressedAttributes.encoding === 'draco'
      );
    }
    // If DRACO geometry is not applicable try to select non-compressed geometry
    if (geometryIndex === -1) {
      geometryIndex = geometryDefinition.geometryBuffers.findIndex(
        (buffer) => !buffer.compressedAttributes
      );
    }
    if (geometryIndex !== -1) {
      const isDracoGeometry = Boolean(
        geometryDefinition.geometryBuffers[geometryIndex].compressedAttributes
      );
      result = {
        url: `${this.url}/nodes/${meshGeometryData.resource}/geometries/${geometryIndex}`,
        isDracoGeometry
      };
    }
    return result;
  }

  /**
   * Forms 1.6 compatible LOD selection object from a nodepage's node data
   * @param node - a node from nodepage
   * @returns- Array of LodSelection
   */
  private getLodSelection(node: NodeInPage): LodSelection[] {
    const lodSelection: LodSelection[] = [];
    if (this.lodSelectionMetricType === 'maxScreenThresholdSQ') {
      lodSelection.push({
        metricType: 'maxScreenThreshold',
        // @ts-ignore
        maxError: Math.sqrt(node.lodThreshold / (Math.PI * 0.25))
      });
    }
    lodSelection.push({
      metricType: this.lodSelectionMetricType,
      // @ts-ignore
      maxError: node.lodThreshold
    });
    return lodSelection;
  }

  /**
   * Returns information about texture and material from `materialDefinitions`
   * @param material - material data from nodepage
   * @returns - Couple {textureData, materialDefinition}
   * {string} textureData.name - path name of the texture
   * {string} textureData.format - format of the texture
   * materialDefinition - PBR-like material definition from `materialDefinitions`
   */
  private getInformationFromMaterial(material: MeshMaterial) {
    const informationFromMaterial: {
      textureData: {name: string | null; format?: I3STextureFormat};
      materialDefinition?: I3SMaterialDefinition;
    } = {textureData: {name: null}};

    if (material) {
      const materialDefinition = this.tileset.materialDefinitions?.[material.definition];
      if (materialDefinition) {
        informationFromMaterial.materialDefinition = materialDefinition;
        const textureSetDefinitionIndex =
          materialDefinition?.pbrMetallicRoughness?.baseColorTexture?.textureSetDefinitionId;

        if (typeof textureSetDefinitionIndex === 'number') {
          informationFromMaterial.textureData =
            this.textureDefinitionsSelectedFormats[textureSetDefinitionIndex] ||
            informationFromMaterial.textureData;
        }
      }
    }
    return informationFromMaterial;
  }

  /**
   * Sets preferable and supported format for each textureDefinition of the tileset
   * @param tileset - I3S layer data
   * @returns
   */
  private initSelectedFormatsForTextureDefinitions(tileset: SceneLayer3D): void {
    this.textureDefinitionsSelectedFormats = [];
    const possibleI3sFormats = this.getSupportedTextureFormats();
    const textureSetDefinitions = tileset.textureSetDefinitions || [];
    for (const textureSetDefinition of textureSetDefinitions) {
      const formats = (textureSetDefinition && textureSetDefinition.formats) || [];
      let selectedFormat: {name: string; format: I3STextureFormat} | null = null;
      for (const i3sFormat of possibleI3sFormats) {
        const format = formats.find((value) => value.format === i3sFormat);
        if (format) {
          selectedFormat = format;
          break;
        }
      }
      // For I3S 1.8 need to define basis target format to decode
      if (selectedFormat && selectedFormat.format === 'ktx2') {
        this.textureLoaderOptions.basis = {
          format: selectSupportedBasisFormat(),
          containerFormat: 'ktx2',
          module: 'encoder'
        };
      }

      this.textureDefinitionsSelectedFormats.push(selectedFormat);
    }
  }

  /**
   * Returns the array of supported texture format
   * @returns list of format strings
   */
  private getSupportedTextureFormats(): I3STextureFormat[] {
    const formats: I3STextureFormat[] = [];
    // @ts-expect-error this.options is not properly typed
    if (!this.options.i3s || this.options.i3s.useCompressedTextures) {
      // I3S 1.7 selection
      const supportedCompressedFormats = getSupportedGPUTextureFormats();
      // List of possible in i3s formats:
      // https://github.com/Esri/i3s-spec/blob/master/docs/1.7/textureSetDefinitionFormat.cmn.md
      if (supportedCompressedFormats.has('etc2')) {
        formats.push('ktx-etc2');
      }
      if (supportedCompressedFormats.has('dxt')) {
        formats.push('dds');
      }

      // I3S 1.8 selection
      // ktx2 wraps basis texture which at the edge case can be decoded as uncompressed image
      formats.push('ktx2');
    }

    formats.push('jpg');
    formats.push('png');
    return formats;
  }
}
