import { BufferAttribute, BufferGeometry, FileLoader, Loader } from 'three'

const _taskCache = new WeakMap()

class DRACOLoader extends Loader {
  constructor(manager) {
    super(manager)

    this.decoderPath = ''
    this.decoderConfig = {}
    this.decoderBinary = null
    this.decoderPending = null

    this.workerLimit = 4
    this.workerPool = []
    this.workerNextTaskID = 1
    this.workerSourceURL = ''

    this.defaultAttributeIDs = {
      position: 'POSITION',
      normal: 'NORMAL',
      color: 'COLOR',
      uv: 'TEX_COORD',
    }
    this.defaultAttributeTypes = {
      position: 'Float32Array',
      normal: 'Float32Array',
      color: 'Float32Array',
      uv: 'Float32Array',
    }
  }

  setDecoderPath(path) {
    this.decoderPath = path

    return this
  }

  setDecoderConfig(config) {
    this.decoderConfig = config

    return this
  }

  setWorkerLimit(workerLimit) {
    this.workerLimit = workerLimit

    return this
  }

  load(url, onLoad, onProgress, onError) {
    const loader = new FileLoader(this.manager)

    loader.setPath(this.path)
    loader.setResponseType('arraybuffer')
    loader.setRequestHeader(this.requestHeader)
    loader.setWithCredentials(this.withCredentials)

    loader.load(
      url,
      (buffer) => {
        const taskConfig = {
          attributeIDs: this.defaultAttributeIDs,
          attributeTypes: this.defaultAttributeTypes,
          useUniqueIDs: false,
        }

        this.decodeGeometry(buffer, taskConfig).then(onLoad).catch(onError)
      },
      onProgress,
      onError,
    )
  }

  /** @deprecated Kept for backward-compatibility with previous DRACOLoader versions. */
  decodeDracoFile(buffer, callback, attributeIDs, attributeTypes) {
    const taskConfig = {
      attributeIDs: attributeIDs || this.defaultAttributeIDs,
      attributeTypes: attributeTypes || this.defaultAttributeTypes,
      useUniqueIDs: !!attributeIDs,
    }

    this.decodeGeometry(buffer, taskConfig).then(callback)
  }

  decodeGeometry(buffer, taskConfig) {
    // TODO: For backward-compatibility, support 'attributeTypes' objects containing
    // references (rather than names) to typed array constructors. These must be
    // serialized before sending them to the worker.
    for (const attribute in taskConfig.attributeTypes) {
      const type = taskConfig.attributeTypes[attribute]

      if (type.BYTES_PER_ELEMENT !== undefined) {
        taskConfig.attributeTypes[attribute] = type.name
      }
    }

    //

    const taskKey = JSON.stringify(taskConfig)

    // Check for an existing task using this buffer. A transferred buffer cannot be transferred
    // again from this thread.
    if (_taskCache.has(buffer)) {
      const cachedTask = _taskCache.get(buffer)

      if (cachedTask.key === taskKey) {
        return cachedTask.promise
      } else if (buffer.byteLength === 0) {
        // Technically, it would be possible to wait for the previous task to complete,
        // transfer the buffer back, and decode again with the second configuration. That
        // is complex, and I don't know of any reason to decode a Draco buffer twice in
        // different ways, so this is left unimplemented.
        throw new Error(
          'THREE.DRACOLoader: Unable to re-decode a buffer with different ' +
            'settings. Buffer has already been transferred.',
        )
      }
    }

    //

    let worker
    const taskID = this.workerNextTaskID++
    const taskCost = buffer.byteLength

    // Obtain a worker and assign a task, and construct a geometry instance
    // when the task completes.
    const geometryPending = this._getWorker(taskID, taskCost)
      .then((_worker) => {
        worker = _worker

        return new Promise((resolve, reject) => {
          worker._callbacks[taskID] = { resolve, reject }

          worker.postMessage({ type: 'decode', id: taskID, taskConfig, buffer }, [buffer])

          // this.debug();
        })
      })
      .then((message) => this._createGeometry(message.geometry))

    // Remove task from the task list.
    // Note: replaced '.finally()' with '.catch().then()' block - iOS 11 support (#19416)
    geometryPending
      .catch(() => true)
      .then(() => {
        if (worker && taskID) {
          this._releaseTask(worker, taskID)

          // this.debug();
        }
      })

    // Cache the task result.
    _taskCache.set(buffer, {
      key: taskKey,
      promise: geometryPending,
    })

    return geometryPending
  }

  _createGeometry(geometryData) {
    const geometry = new BufferGeometry()

    if (geometryData.index) {
      geometry.setIndex(new BufferAttribute(geometryData.index.array, 1))
    }

    for (let i = 0; i < geometryData.attributes.length; i++) {
      const attribute = geometryData.attributes[i]
      const name = attribute.name
      const array = attribute.array
      const itemSize = attribute.itemSize

      geometry.setAttribute(name, new BufferAttribute(array, itemSize))
    }

    return geometry
  }

  _loadLibrary(url, responseType) {
    const loader = new FileLoader(this.manager)
    loader.setPath(this.decoderPath)
    loader.setResponseType(responseType)
    loader.setWithCredentials(this.withCredentials)

    return new Promise((resolve, reject) => {
      loader.load(url, resolve, undefined, reject)
    })
  }

  preload() {
    this._initDecoder()

    return this
  }

  _initDecoder() {
    if (this.decoderPending) return this.decoderPending

    const useJS = typeof WebAssembly !== 'object' || this.decoderConfig.type === 'js'
    const librariesPending = []

    if (useJS) {
      librariesPending.push(this._loadLibrary('draco_decoder.js', 'text'))
    } else {
      librariesPending.push(this._loadLibrary('draco_wasm_wrapper.js', 'text'))
      librariesPending.push(this._loadLibrary('draco_decoder.wasm', 'arraybuffer'))
    }

    this.decoderPending = Promise.all(librariesPending).then((libraries) => {
      const jsContent = libraries[0]

      if (!useJS) {
        this.decoderConfig.wasmBinary = libraries[1]
      }

      const fn = DRACOWorker.toString()

      const body = [
        '/* draco decoder */',
        jsContent,
        '',
        '/* worker */',
        fn.substring(fn.indexOf('{') + 1, fn.lastIndexOf('}')),
      ].join('\n')

      this.workerSourceURL = URL.createObjectURL(new Blob([body]))
    })

    return this.decoderPending
  }

  _getWorker(taskID, taskCost) {
    return this._initDecoder().then(() => {
      if (this.workerPool.length < this.workerLimit) {
        const worker = new Worker(this.workerSourceURL)

        worker._callbacks = {}
        worker._taskCosts = {}
        worker._taskLoad = 0

        worker.postMessage({ type: 'init', decoderConfig: this.decoderConfig })

        worker.onmessage = function (e) {
          const message = e.data

          switch (message.type) {
            case 'decode':
              worker._callbacks[message.id].resolve(message)
              break

            case 'error':
              worker._callbacks[message.id].reject(message)
              break

            default:
              console.error('THREE.DRACOLoader: Unexpected message, "' + message.type + '"')
          }
        }

        this.workerPool.push(worker)
      } else {
        this.workerPool.sort(function (a, b) {
          return a._taskLoad > b._taskLoad ? -1 : 1
        })
      }

      const worker = this.workerPool[this.workerPool.length - 1]
      worker._taskCosts[taskID] = taskCost
      worker._taskLoad += taskCost
      return worker
    })
  }

  _releaseTask(worker, taskID) {
    worker._taskLoad -= worker._taskCosts[taskID]
    delete worker._callbacks[taskID]
    delete worker._taskCosts[taskID]
  }

  debug() {
    console.log(
      'Task load: ',
      this.workerPool.map((worker) => worker._taskLoad),
    )
  }

  dispose() {
    for (let i = 0; i < this.workerPool.length; ++i) {
      this.workerPool[i].terminate()
    }

    this.workerPool.length = 0

    return this
  }
}

/* WEB WORKER */

function DRACOWorker() {
  let decoderConfig
  let decoderPending

  onmessage = function (e) {
    const message = e.data

    switch (message.type) {
      case 'init':
        decoderConfig = message.decoderConfig
        decoderPending = new Promise(function (resolve /*, reject*/) {
          decoderConfig.onModuleLoaded = function (draco) {
            // Module is Promise-like. Wrap before resolving to avoid loop.
            resolve({ draco: draco })
          }

          DracoDecoderModule(decoderConfig) // eslint-disable-line no-undef
        })
        break

      case 'decode':
        const buffer = message.buffer
        const taskConfig = message.taskConfig
        decoderPending.then((module) => {
          const draco = module.draco
          const decoder = new draco.Decoder()
          const decoderBuffer = new draco.DecoderBuffer()
          decoderBuffer.Init(new Int8Array(buffer), buffer.byteLength)

          try {
            const geometry = decodeGeometry(draco, decoder, decoderBuffer, taskConfig)

            const buffers = geometry.attributes.map((attr) => attr.array.buffer)

            if (geometry.index) buffers.push(geometry.index.array.buffer)

            self.postMessage({ type: 'decode', id: message.id, geometry }, buffers)
          } catch (error) {
            console.error(error)

            self.postMessage({ type: 'error', id: message.id, error: error.message })
          } finally {
            draco.destroy(decoderBuffer)
            draco.destroy(decoder)
          }
        })
        break
    }
  }

  function decodeGeometry(draco, decoder, decoderBuffer, taskConfig) {
    const attributeIDs = taskConfig.attributeIDs
    const attributeTypes = taskConfig.attributeTypes

    let dracoGeometry
    let decodingStatus

    const geometryType = decoder.GetEncodedGeometryType(decoderBuffer)

    if (geometryType === draco.TRIANGULAR_MESH) {
      dracoGeometry = new draco.Mesh()
      decodingStatus = decoder.DecodeBufferToMesh(decoderBuffer, dracoGeometry)
    } else if (geometryType === draco.POINT_CLOUD) {
      dracoGeometry = new draco.PointCloud()
      decodingStatus = decoder.DecodeBufferToPointCloud(decoderBuffer, dracoGeometry)
    } else {
      throw new Error('THREE.DRACOLoader: Unexpected geometry type.')
    }

    if (!decodingStatus.ok() || dracoGeometry.ptr === 0) {
      throw new Error('THREE.DRACOLoader: Decoding failed: ' + decodingStatus.error_msg())
    }

    const geometry = { index: null, attributes: [] }

    // Gather all vertex attributes.
    for (const attributeName in attributeIDs) {
      const attributeType = self[attributeTypes[attributeName]]

      let attribute
      let attributeID

      // A Draco file may be created with default vertex attributes, whose attribute IDs
      // are mapped 1:1 from their semantic name (POSITION, NORMAL, ...). Alternatively,
      // a Draco file may contain a custom set of attributes, identified by known unique
      // IDs. glTF files always do the latter, and `.drc` files typically do the former.
      if (taskConfig.useUniqueIDs) {
        attributeID = attributeIDs[attributeName]
        attribute = decoder.GetAttributeByUniqueId(dracoGeometry, attributeID)
      } else {
        attributeID = decoder.GetAttributeId(dracoGeometry, draco[attributeIDs[attributeName]])

        if (attributeID === -1) continue

        attribute = decoder.GetAttribute(dracoGeometry, attributeID)
      }

      geometry.attributes.push(decodeAttribute(draco, decoder, dracoGeometry, attributeName, attributeType, attribute))
    }

    // Add index.
    if (geometryType === draco.TRIANGULAR_MESH) {
      geometry.index = decodeIndex(draco, decoder, dracoGeometry)
    }

    draco.destroy(dracoGeometry)

    return geometry
  }

  function decodeIndex(draco, decoder, dracoGeometry) {
    const numFaces = dracoGeometry.num_faces()
    const numIndices = numFaces * 3
    const byteLength = numIndices * 4

    const ptr = draco._malloc(byteLength)
    decoder.GetTrianglesUInt32Array(dracoGeometry, byteLength, ptr)
    const index = new Uint32Array(draco.HEAPF32.buffer, ptr, numIndices).slice()
    draco._free(ptr)

    return { array: index, itemSize: 1 }
  }

  function decodeAttribute(draco, decoder, dracoGeometry, attributeName, attributeType, attribute) {
    const numComponents = attribute.num_components()
    const numPoints = dracoGeometry.num_points()
    const numValues = numPoints * numComponents
    const byteLength = numValues * attributeType.BYTES_PER_ELEMENT
    const dataType = getDracoDataType(draco, attributeType)

    const ptr = draco._malloc(byteLength)
    decoder.GetAttributeDataArrayForAllPoints(dracoGeometry, attribute, dataType, byteLength, ptr)
    const array = new attributeType(draco.HEAPF32.buffer, ptr, numValues).slice()
    draco._free(ptr)

    return {
      name: attributeName,
      array: array,
      itemSize: numComponents,
    }
  }

  function getDracoDataType(draco, attributeType) {
    switch (attributeType) {
      case Float32Array:
        return draco.DT_FLOAT32
      case Int8Array:
        return draco.DT_INT8
      case Int16Array:
        return draco.DT_INT16
      case Int32Array:
        return draco.DT_INT32
      case Uint8Array:
        return draco.DT_UINT8
      case Uint16Array:
        return draco.DT_UINT16
      case Uint32Array:
        return draco.DT_UINT32
    }
  }
}

export { DRACOLoader }
