// loaders.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import type {GLTFWithBuffers} from '../types/gltf-types';
import type {
  GLTF,
  GLTFScene,
  GLTFNode,
  GLTFMesh,
  GLTFSkin,
  GLTFMaterial,
  GLTFAccessor,
  GLTFSampler,
  GLTFTexture,
  GLTFImage,
  GLTFBuffer,
  GLTFBufferView
} from '../types/gltf-json-schema';

import {getBinaryImageMetadata} from '@loaders.gl/images';
import {padToNBytes, copyToArray} from '@loaders.gl/loader-utils';
import {assert} from '../utils/assert';
import {getAccessorTypeFromSize, getComponentTypeFromArray} from '../gltf-utils/gltf-utils';

import {getTypedArrayForAccessor as _getTypedArrayForAccessor} from '../gltf-utils/get-typed-array';

type Extension = {[key: string]: any};

function makeDefaultGLTFJson(): GLTF {
  return {
    asset: {
      version: '2.0',
      generator: 'loaders.gl'
    },
    buffers: [],
    extensions: {},
    extensionsRequired: [],
    extensionsUsed: []
  };
}

/**
 * Class for structured access to GLTF data
 */
export class GLTFScenegraph {
  // internal
  gltf: GLTFWithBuffers;
  sourceBuffers: any[];
  byteLength: number;

  // TODO - why is this not GLTFWithBuffers - what happens to images?
  constructor(gltf?: {json: GLTF; buffers?: any[]; images?: any[]}) {
    // Declare locally so

    this.gltf = {
      json: gltf?.json || makeDefaultGLTFJson(),
      buffers: gltf?.buffers || [],
      images: gltf?.images || []
    };
    this.sourceBuffers = [];
    this.byteLength = 0;

    // Initialize buffers
    if (this.gltf.buffers && this.gltf.buffers[0]) {
      this.byteLength = this.gltf.buffers[0].byteLength;
      this.sourceBuffers = [this.gltf.buffers[0]];
    }
  }

  // Accessors

  get json(): GLTF {
    return this.gltf.json;
  }

  getApplicationData(key: string): unknown {
    // TODO - Data is already unpacked by GLBParser
    const data = this.json[key];
    return data;
  }

  getExtraData(key: string): unknown {
    // TODO - Data is already unpacked by GLBParser
    const extras = (this.json.extras || {}) as Record<string, unknown>;
    return extras[key];
  }

  hasExtension(extensionName: string): boolean {
    const isUsedExtension = this.getUsedExtensions().find((name) => name === extensionName);
    const isRequiredExtension = this.getRequiredExtensions().find((name) => name === extensionName);
    return typeof isUsedExtension === 'string' || typeof isRequiredExtension === 'string';
  }

  getExtension<T = Extension>(extensionName: string): T | null {
    const isExtension = this.getUsedExtensions().find((name) => name === extensionName);
    const extensions = this.json.extensions || {};
    return isExtension ? (extensions[extensionName] as T) : null;
  }

  getRequiredExtension<T = Extension>(extensionName: string): T | null {
    const isRequired = this.getRequiredExtensions().find((name) => name === extensionName);
    return isRequired ? this.getExtension(extensionName) : null;
  }

  getRequiredExtensions(): string[] {
    return this.json.extensionsRequired || [];
  }

  getUsedExtensions(): string[] {
    return this.json.extensionsUsed || [];
  }

  getRemovedExtensions(): string[] {
    return (this.json.extensionsRemoved || []) as string[];
  }

  getObjectExtension<T = Extension>(object: {[key: string]: any}, extensionName: string): T | null {
    const extensions = object.extensions || {};
    return extensions[extensionName];
  }

  getScene(index: number): GLTFScene {
    return this.getObject('scenes', index) as GLTFScene;
  }

  getNode(index: number): GLTFNode {
    return this.getObject('nodes', index) as GLTFNode;
  }

  getSkin(index: number): GLTFSkin {
    return this.getObject('skins', index) as GLTFSkin;
  }

  getMesh(index: number): GLTFMesh {
    return this.getObject('meshes', index) as GLTFMesh;
  }

  getMaterial(index: number): GLTFMaterial {
    return this.getObject('materials', index) as GLTFMaterial;
  }

  getAccessor(index: number): GLTFAccessor {
    return this.getObject('accessors', index) as GLTFAccessor;
  }

  // getCamera(index: number): object | null {
  //   return null; // TODO: fix thi: object  as null;
  // }

  getTexture(index: number): GLTFTexture {
    return this.getObject('textures', index) as GLTFTexture;
  }

  getSampler(index: number): GLTFSampler {
    return this.getObject('samplers', index) as GLTFSampler;
  }

  getImage(index: number): GLTFImage {
    return this.getObject('images', index) as GLTFImage;
  }

  getBufferView(index: number | object): GLTFBufferView {
    return this.getObject('bufferViews', index) as GLTFBufferView;
  }

  getBuffer(index: number): GLTFBuffer {
    return this.getObject('buffers', index) as GLTFBuffer;
  }

  getObject(array: string, index: number | object): Record<string, unknown> {
    // check if already resolved
    if (typeof index === 'object') {
      return index as Record<string, unknown>;
    }
    const object = this.json[array] && (this.json[array] as {}[])[index];
    if (!object) {
      throw new Error(`glTF file error: Could not find ${array}[${index}]`); // eslint-disable-line
    }
    return object as Record<string, unknown>;
  }

  /**
   * Accepts buffer view index or buffer view object
   * @returns a `Uint8Array`
   */
  getTypedArrayForBufferView(bufferView: number | object): Uint8Array {
    bufferView = this.getBufferView(bufferView);
    // @ts-ignore
    const bufferIndex = bufferView.buffer;

    // Get hold of the arrayBuffer
    // const buffer = this.getBuffer(bufferIndex);
    const binChunk = this.gltf.buffers[bufferIndex];
    assert(binChunk);

    // @ts-ignore
    const byteOffset = (bufferView.byteOffset || 0) + binChunk.byteOffset;
    // @ts-ignore
    return new Uint8Array(binChunk.arrayBuffer, byteOffset, bufferView.byteLength);
  }

  /** Accepts accessor index or accessor object
   * @returns a typed array with type that matches the types
   */
  getTypedArrayForAccessor(accessor: number | object): any {
    // @ts-ignore
    const gltfAccessor = this.getAccessor(accessor);
    return _getTypedArrayForAccessor(this.gltf.json, this.gltf.buffers, gltfAccessor);
  }

  /** accepts accessor index or accessor object
   * returns a `Uint8Array`
   */
  getTypedArrayForImageData(image: number | object): Uint8Array {
    // @ts-ignore
    image = this.getAccessor(image);
    // @ts-ignore
    const bufferView = this.getBufferView(image.bufferView);
    const buffer = this.getBuffer(bufferView.buffer);
    // @ts-ignore
    const arrayBuffer = buffer.data;

    const byteOffset = bufferView.byteOffset || 0;
    return new Uint8Array(arrayBuffer, byteOffset, bufferView.byteLength);
  }

  // MODIFERS

  /**
   * Add an extra application-defined key to the top-level data structure
   */
  addApplicationData(key: string, data: object): GLTFScenegraph {
    this.json[key] = data;
    return this;
  }

  /**
   * `extras` - Standard GLTF field for storing application specific data
   */
  addExtraData(key: string, data: object): GLTFScenegraph {
    this.json.extras = this.json.extras || {};
    (this.json.extras as Record<string, unknown>)[key] = data;
    return this;
  }

  addObjectExtension(object: object, extensionName: string, data: object): GLTFScenegraph {
    // @ts-ignore
    object.extensions = object.extensions || {};
    // TODO - clobber or merge?
    // @ts-ignore
    object.extensions[extensionName] = data;
    this.registerUsedExtension(extensionName);
    return this;
  }

  setObjectExtension(object: any, extensionName: string, data: object): void {
    const extensions = object.extensions || {};
    extensions[extensionName] = data;
    // TODO - add to usedExtensions...
  }

  removeObjectExtension(object: any, extensionName: string): void {
    const extensions = object?.extensions || {};

    if (extensions[extensionName]) {
      this.json.extensionsRemoved = this.json.extensionsRemoved || [];
      const extensionsRemoved = this.json.extensionsRemoved as string[];
      if (!extensionsRemoved.includes(extensionName)) {
        extensionsRemoved.push(extensionName);
      }
    }

    delete extensions[extensionName];
  }

  /**
   * Add to standard GLTF top level extension object, mark as used
   */
  addExtension(extensionName: string, extensionData: object = {}): object {
    assert(extensionData);
    this.json.extensions = this.json.extensions || {};
    this.json.extensions[extensionName] = extensionData;
    this.registerUsedExtension(extensionName);
    return extensionData;
  }

  /**
   * Standard GLTF top level extension object, mark as used and required
   */
  addRequiredExtension(extensionName, extensionData: object = {}): object {
    assert(extensionData);
    this.addExtension(extensionName, extensionData);
    this.registerRequiredExtension(extensionName);
    return extensionData;
  }

  /**
   * Add extensionName to list of used extensions
   */
  registerUsedExtension(extensionName: string): void {
    this.json.extensionsUsed = this.json.extensionsUsed || [];
    if (!this.json.extensionsUsed.find((ext) => ext === extensionName)) {
      this.json.extensionsUsed.push(extensionName);
    }
  }

  /**
   * Add extensionName to list of required extensions
   */
  registerRequiredExtension(extensionName: string): void {
    this.registerUsedExtension(extensionName);
    this.json.extensionsRequired = this.json.extensionsRequired || [];
    if (!this.json.extensionsRequired.find((ext) => ext === extensionName)) {
      this.json.extensionsRequired.push(extensionName);
    }
  }

  /**
   * Removes an extension from the top-level list
   */
  removeExtension(extensionName: string): void {
    if (this.json.extensions?.[extensionName]) {
      this.json.extensionsRemoved = this.json.extensionsRemoved || [];
      const extensionsRemoved = this.json.extensionsRemoved as string[];
      if (!extensionsRemoved.includes(extensionName)) {
        extensionsRemoved.push(extensionName);
      }
    }
    if (this.json.extensions) {
      delete this.json.extensions[extensionName];
    }
    if (this.json.extensionsRequired) {
      this._removeStringFromArray(this.json.extensionsRequired, extensionName);
    }
    if (this.json.extensionsUsed) {
      this._removeStringFromArray(this.json.extensionsUsed, extensionName);
    }
  }

  /**
   *  Set default scene which is to be displayed at load time
   */
  setDefaultScene(sceneIndex: number): void {
    this.json.scene = sceneIndex;
  }

  /**
   * @todo: add more properties for scene initialization:
   *   name`, `extensions`, `extras`
   *   https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-scene
   */
  addScene(scene: {nodeIndices: number[]}): number {
    const {nodeIndices} = scene;
    this.json.scenes = this.json.scenes || [];
    this.json.scenes.push({nodes: nodeIndices});
    return this.json.scenes.length - 1;
  }

  /**
   * @todo: add more properties for node initialization:
   *   `name`, `extensions`, `extras`, `camera`, `children`, `skin`, `rotation`, `scale`, `translation`, `weights`
   *   https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#node
   */
  addNode(node: {meshIndex: number; matrix?: number[]}): number {
    const {meshIndex, matrix} = node;
    this.json.nodes = this.json.nodes || [];
    const nodeData = {mesh: meshIndex};
    if (matrix) {
      // @ts-ignore
      nodeData.matrix = matrix;
    }
    this.json.nodes.push(nodeData);
    return this.json.nodes.length - 1;
  }

  /** Adds a mesh to the json part */
  addMesh(mesh: {attributes: object; indices?: object; material?: number; mode?: number}): number {
    const {attributes, indices, material, mode = 4} = mesh;
    const accessors = this._addAttributes(attributes);

    const glTFMesh = {
      primitives: [
        {
          attributes: accessors,
          mode
        }
      ]
    };

    if (indices) {
      const indicesAccessor = this._addIndices(indices);
      // @ts-ignore
      glTFMesh.primitives[0].indices = indicesAccessor;
    }

    if (Number.isFinite(material)) {
      // @ts-ignore
      glTFMesh.primitives[0].material = material;
    }

    this.json.meshes = this.json.meshes || [];
    this.json.meshes.push(glTFMesh);
    return this.json.meshes.length - 1;
  }

  addPointCloud(attributes: object): number {
    // @ts-ignore
    const accessorIndices = this._addAttributes(attributes);

    const glTFMesh = {
      primitives: [
        {
          attributes: accessorIndices,
          mode: 0 // GL.POINTS
        }
      ]
    };

    this.json.meshes = this.json.meshes || [];
    this.json.meshes.push(glTFMesh);
    return this.json.meshes.length - 1;
  }

  /**
   * Adds a binary image. Builds glTF "JSON metadata" and saves buffer reference
   * Buffer will be copied into BIN chunk during "pack"
   * Currently encodes as glTF image
   * @param imageData
   * @param mimeType
   */
  addImage(imageData: any, mimeTypeOpt?: string): number {
    // If image is referencing a bufferView instead of URI, mimeType must be defined:
    //   https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#images
    //   "a reference to a bufferView; in that case mimeType must be defined."
    const metadata = getBinaryImageMetadata(imageData);
    const mimeType = mimeTypeOpt || metadata?.mimeType;

    const bufferViewIndex = this.addBufferView(imageData);

    const glTFImage = {
      bufferView: bufferViewIndex,
      mimeType
    };

    this.json.images = this.json.images || [];
    this.json.images.push(glTFImage);
    return this.json.images.length - 1;
  }

  /**
   * Add one untyped source buffer, create a matching glTF `bufferView`, and return its index
   * @param buffer
   */
  addBufferView(buffer: any, bufferIndex = 0, byteOffset = this.byteLength): number {
    const byteLength = buffer.byteLength;
    assert(Number.isFinite(byteLength));

    // Add this buffer to the list of buffers to be written to the body.
    this.sourceBuffers = this.sourceBuffers || [];
    this.sourceBuffers.push(buffer);

    const glTFBufferView = {
      buffer: bufferIndex,
      // Write offset from the start of the binary body
      byteOffset,
      byteLength
    };

    // We've now added the contents to the body, so update the total length
    // Every sub-chunk needs to be 4-byte align ed
    this.byteLength += padToNBytes(byteLength, 4);

    // Add a bufferView indicating start and length of this binary sub-chunk
    this.json.bufferViews = this.json.bufferViews || [];
    this.json.bufferViews.push(glTFBufferView);
    return this.json.bufferViews.length - 1;
  }

  /**
   * Adds an accessor to a bufferView
   * @param bufferViewIndex
   * @param accessor
   */
  addAccessor(bufferViewIndex: number, accessor: object): number {
    const glTFAccessor = {
      bufferView: bufferViewIndex,
      // @ts-ignore
      type: getAccessorTypeFromSize(accessor.size),
      // @ts-ignore
      componentType: accessor.componentType,
      // @ts-ignore
      count: accessor.count,
      // @ts-ignore
      max: accessor.max,
      // @ts-ignore
      min: accessor.min
    };

    this.json.accessors = this.json.accessors || [];
    this.json.accessors.push(glTFAccessor);
    return this.json.accessors.length - 1;
  }

  /**
   * Add a binary buffer. Builds glTF "JSON metadata" and saves buffer reference
   * Buffer will be copied into BIN chunk during "pack"
   * Currently encodes buffers as glTF accessors, but this could be optimized
   * @param sourceBuffer
   * @param accessor
   */
  addBinaryBuffer(sourceBuffer: any, accessor: object = {size: 3}): number {
    const bufferViewIndex = this.addBufferView(sourceBuffer);
    // @ts-ignore
    let minMax = {min: accessor.min, max: accessor.max};
    if (!minMax.min || !minMax.max) {
      // @ts-ignore
      minMax = this._getAccessorMinMax(sourceBuffer, accessor.size);
    }

    const accessorDefaults = {
      // @ts-ignore
      size: accessor.size,
      componentType: getComponentTypeFromArray(sourceBuffer),
      // @ts-ignore
      count: Math.round(sourceBuffer.length / accessor.size),
      min: minMax.min,
      max: minMax.max
    };

    return this.addAccessor(bufferViewIndex, Object.assign(accessorDefaults, accessor));
  }

  /**
   * Adds a texture to the json part
   * @todo: add more properties for texture initialization
   * `sampler`, `name`, `extensions`, `extras`
   * https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#texture
   */
  addTexture(texture: {imageIndex: number}): number {
    const {imageIndex} = texture;
    const glTFTexture = {
      source: imageIndex
    };

    this.json.textures = this.json.textures || [];
    this.json.textures.push(glTFTexture);
    return this.json.textures.length - 1;
  }

  /** Adds a material to the json part */
  addMaterial(pbrMaterialInfo: Object): number {
    this.json.materials = this.json.materials || [];
    this.json.materials.push(pbrMaterialInfo);
    return this.json.materials.length - 1;
  }

  /** Pack the binary chunk */
  createBinaryChunk(): void {
    // Allocate total array
    const totalByteLength = this.byteLength;
    const arrayBuffer = new ArrayBuffer(totalByteLength);
    const targetArray = new Uint8Array(arrayBuffer);

    // Copy each array into
    let dstByteOffset = 0;
    for (const sourceBuffer of this.sourceBuffers || []) {
      dstByteOffset = copyToArray(sourceBuffer, targetArray, dstByteOffset);
    }

    // Update the glTF BIN CHUNK byte length
    if (this.json?.buffers?.[0]) {
      this.json.buffers[0].byteLength = totalByteLength;
    } else {
      this.json.buffers = [{byteLength: totalByteLength}];
    }

    // Save generated arrayBuffer
    this.gltf.binary = arrayBuffer;

    // Put arrayBuffer to sourceBuffers for possible additional writing data in the chunk
    this.sourceBuffers = [arrayBuffer];
    this.gltf.buffers = [{arrayBuffer, byteOffset: 0, byteLength: arrayBuffer.byteLength}];
  }

  // PRIVATE

  _removeStringFromArray(array, string) {
    let found = true;
    while (found) {
      const index = array.indexOf(string);
      if (index > -1) {
        array.splice(index, 1);
      } else {
        found = false;
      }
    }
  }

  /**
   * Add attributes to buffers and create `attributes` object which is part of `mesh`
   */
  _addAttributes(attributes = {}) {
    const result = {};
    for (const attributeKey in attributes) {
      const attributeData = attributes[attributeKey];
      const attrName = this._getGltfAttributeName(attributeKey);
      const accessor = this.addBinaryBuffer(attributeData.value, attributeData);
      result[attrName] = accessor;
    }
    return result;
  }

  /**
   * Add indices to buffers
   */
  _addIndices(indices) {
    return this.addBinaryBuffer(indices, {size: 1});
  }

  /**
   * Deduce gltf specific attribue name from input attribute name
   */
  _getGltfAttributeName(attributeName) {
    switch (attributeName.toLowerCase()) {
      case 'position':
      case 'positions':
      case 'vertices':
        return 'POSITION';
      case 'normal':
      case 'normals':
        return 'NORMAL';
      case 'color':
      case 'colors':
        return 'COLOR_0';
      case 'texcoord':
      case 'texcoords':
        return 'TEXCOORD_0';
      default:
        return attributeName;
    }
  }

  /**
   * Calculate `min` and `max` arrays of accessor according to spec:
   * https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-accessor
   */
  _getAccessorMinMax(buffer, size) {
    const result = {min: null, max: null};
    if (buffer.length < size) {
      return result;
    }
    // @ts-ignore
    result.min = [];
    // @ts-ignore
    result.max = [];
    const initValues = buffer.subarray(0, size);
    for (const value of initValues) {
      // @ts-ignore
      result.min.push(value);
      // @ts-ignore
      result.max.push(value);
    }

    for (let index = size; index < buffer.length; index += size) {
      for (let componentIndex = 0; componentIndex < size; componentIndex++) {
        // @ts-ignore
        result.min[0 + componentIndex] = Math.min(
          // @ts-ignore
          result.min[0 + componentIndex],
          buffer[index + componentIndex]
        );
        // @ts-ignore
        result.max[0 + componentIndex] = Math.max(
          // @ts-ignore
          result.max[0 + componentIndex],
          buffer[index + componentIndex]
        );
      }
    }
    return result;
  }
}
