@@ -120,8 +120,10 @@ module.exports = class Transloadit extends Plugin {
       signature: options.signature
     }).then((assembly) => {
       // Store the list of assemblies related to this upload.
-      const uploadsAssemblies = Object.assign({}, this.state.uploadsAssemblies)
-      uploadsAssemblies[uploadID] = (uploadsAssemblies[uploadID] || []).concat([ assembly.assembly_id ])
+      const assemblyList = this.state.uploadsAssemblies[uploadID]
+      const uploadsAssemblies = Object.assign({}, this.state.uploadsAssemblies, {
+        [uploadID]: assemblyList.concat([ assembly.assembly_id ])
+      })
 
       this.updateState({
         assemblies: Object.assign(this.state.assemblies, {
const Plugin = require('../Plugin')
const Client = require('./Client')
const StatusSocket = require('./Socket')

/**
 * Upload files to Transloadit using Tus.
 */
module.exports = class Transloadit extends Plugin {
  constructor (core, opts) {
    super(core, opts)
    this.type = 'uploader'
    this.id = 'Transloadit'
    this.title = 'Transloadit'

    const defaultLocale = {
      strings: {
        creatingAssembly: 'Preparing upload...',
        creatingAssemblyFailed: 'Transloadit: Could not create assembly',
        encoding: 'Encoding...'
      }
    }

    const defaultOptions = {
      waitForEncoding: false,
      waitForMetadata: false,
      alwaysRunAssembly: false, // TODO name
      signature: null,
      params: null,
      fields: {},
      getAssemblyOptions (file, options) {
        return {
          params: options.params,
          signature: options.signature,
          fields: options.fields
        }
      },
      locale: defaultLocale
    }

    this.opts = Object.assign({}, defaultOptions, opts)

    this.locale = Object.assign({}, defaultLocale, this.opts.locale)
    this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)

    this.prepareUpload = this.prepareUpload.bind(this)
    this.afterUpload = this.afterUpload.bind(this)

    if (this.opts.params) {
      this.validateParams(this.opts.params)
    }

    this.client = new Client()
    this.sockets = {}
  }

  validateParams (params) {
    if (!params) {
      throw new Error('Transloadit: The `params` option is required.')
    }

    if (typeof params === 'string') {
      try {
        params = JSON.parse(params)
      } catch (err) {
        // Tell the user that this is not an Uppy bug!
        err.message = 'Transloadit: The `params` option is a malformed JSON string: ' +
          err.message
        throw err
      }
    }

    if (!params.auth || !params.auth.key) {
      throw new Error('Transloadit: The `params.auth.key` option is required. ' +
        'You can find your Transloadit API key at https://transloadit.com/accounts/credentials.')
    }
  }

  getAssemblyOptions (fileIDs) {
    const options = this.opts
    return Promise.all(
      fileIDs.map((fileID) => {
        const file = this.core.getFile(fileID)
        const promise = Promise.resolve(options.getAssemblyOptions(file, options))
        return promise.then((assemblyOptions) => {
          this.validateParams(assemblyOptions.params)

          return {
            fileIDs: [fileID],
            options: assemblyOptions
          }
        })
      })
    )
  }

  dedupeAssemblyOptions (list) {
    const dedupeMap = Object.create(null)
    list.forEach(({ fileIDs, options }) => {
      const id = JSON.stringify(options)
      if (dedupeMap[id]) {
        dedupeMap[id].fileIDs.push(...fileIDs)
      } else {
        dedupeMap[id] = {
          options,
          fileIDs: [...fileIDs]
        }
      }
    })

    return Object.keys(dedupeMap).map((id) => dedupeMap[id])
  }

  createAssembly (fileIDs, uploadID, options) {
    this.core.log('Transloadit: create assembly')

    return this.client.createAssembly({
      params: options.params,
      fields: options.fields,
      expectedFiles: fileIDs.length,
      signature: options.signature
    }).then((assembly) => {
      // Store the list of assemblies related to this upload.
      const uploadsAssemblies = Object.assign({}, this.state.uploadsAssemblies)
      uploadsAssemblies[uploadID] = (uploadsAssemblies[uploadID] || []).concat([ assembly.assembly_id ])

      this.updateState({
        assemblies: Object.assign(this.state.assemblies, {
          [assembly.assembly_id]: assembly
        }),
        uploadsAssemblies
      })

      function attachAssemblyMetadata (file, assembly) {
        // Attach meta parameters for the Tus plugin. See:
        // https://github.com/tus/tusd/wiki/Uploading-to-Transloadit-using-tus#uploading-using-tus
        // TODO Should this `meta` be moved to a `tus.meta` property instead?
        // If the MetaData plugin can add eg. resize parameters, it doesn't
        // make much sense to set those as upload-metadata for tus.
        const meta = Object.assign({}, file.meta, {
          assembly_url: assembly.assembly_url,
          filename: file.name,
          fieldname: 'file'
        })
        // Add assembly-specific Tus endpoint.
        const tus = Object.assign({}, file.tus, {
          endpoint: assembly.tus_url
        })
        const transloadit = {
          assembly: assembly.assembly_id
        }
        return Object.assign(
          {},
          file,
          { meta, tus, transloadit }
        )
      }

      const files = Object.assign({}, this.core.state.files)
      fileIDs.forEach((id) => {
        files[id] = attachAssemblyMetadata(files[id], assembly)
      })

      this.core.setState({ files })

      this.core.emit('transloadit:assembly-created', assembly, fileIDs)

      return this.connectSocket(assembly)
    }).then(() => {
      this.core.log('Transloadit: Created assembly')
    }).catch((err) => {
      this.core.emit('informer', this.opts.locale.strings.creatingAssemblyFailed, 'error', 0)

      // Reject the promise.
      throw err
    })
  }

  shouldWait () {
    return this.opts.waitForEncoding || this.opts.waitForMetadata
  }

  findFile (uploadedFile) {
    const files = this.core.state.files
    for (const id in files) {
      if (!files.hasOwnProperty(id)) {
        continue
      }
      if (files[id].uploadURL === uploadedFile.tus_upload_url) {
        return files[id]
      }
    }
  }

  onFileUploadComplete (assemblyId, uploadedFile) {
    const file = this.findFile(uploadedFile)
    this.updateState({
      files: Object.assign({}, this.state.files, {
        [uploadedFile.id]: {
          id: file.id,
          uploadedFile
        }
      })
    })
    this.core.emit('transloadit:upload', uploadedFile, this.getAssembly(assemblyId))
  }

  onResult (assemblyId, stepName, result) {
    const file = this.state.files[result.original_id]
    // The `file` may not exist if an import robot was used instead of a file upload.
    result.localId = file ? file.id : null

    this.updateState({
      results: this.state.results.concat(result)
    })
    this.core.emit('transloadit:result', stepName, result, this.getAssembly(assemblyId))
  }

  onAssemblyFinished (url) {
    this.client.getAssemblyStatus(url).then((assembly) => {
      this.updateState({
        assemblies: Object.assign({}, this.state.assemblies, {
          [assembly.assembly_id]: assembly
        })
      })
      this.core.emit('transloadit:complete', assembly)
    })
  }

  connectSocket (assembly) {
    const socket = new StatusSocket(
      assembly.websocket_url,
      assembly
    )
    this.sockets[assembly.assembly_id] = socket

    socket.on('upload', this.onFileUploadComplete.bind(this, assembly.assembly_id))
    socket.on('error', (error) => {
      this.core.emit('transloadit:assembly-error', assembly, error)
    })

    if (this.opts.waitForEncoding) {
      socket.on('result', this.onResult.bind(this, assembly.assembly_id))
    }

    if (this.opts.waitForEncoding) {
      socket.on('finished', () => {
        this.onAssemblyFinished(assembly.assembly_ssl_url)
      })
    } else if (this.opts.waitForMetadata) {
      socket.on('metadata', () => {
        this.onAssemblyFinished(assembly.assembly_ssl_url)
        this.core.emit('transloadit:complete', assembly)
      })
    }

    return new Promise((resolve, reject) => {
      socket.on('connect', resolve)
      socket.on('error', reject)
    }).then(() => {
      this.core.log('Transloadit: Socket is ready')
    })
  }

  prepareUpload (fileIDs, uploadID) {
    fileIDs.forEach((fileID) => {
      this.core.emit('core:preprocess-progress', fileID, {
        mode: 'indeterminate',
        message: this.opts.locale.strings.creatingAssembly
      })
    })

    const createAssembly = ({ fileIDs, options }) => {
      return this.createAssembly(fileIDs, uploadID, options).then(() => {
        fileIDs.forEach((fileID) => {
          this.core.emit('core:preprocess-complete', fileID)
        })
      })
    }

    let optionsPromise
    if (fileIDs.length > 0) {
      optionsPromise = this.getAssemblyOptions(fileIDs)
        .then((allOptions) => this.dedupeAssemblyOptions(allOptions))
    } else if (this.opts.alwaysRunAssembly) {
      optionsPromise = Promise.resolve().then(() => {
        const options = this.opts.getAssemblyOptions(null, this.opts)
        this.validateParams(options.params)
        return [
          { fileIDs, options }
        ]
      })
    } else {
      // If there are no files and we do not `alwaysRunAssembly`,
      // don't do anything.
      return Promise.resolve()
    }

    return optionsPromise.then((assemblies) => Promise.all(
      assemblies.map(createAssembly)
    ))
  }

  afterUpload (fileIDs, uploadID) {
    const assemblyIDs = this.state.uploadsAssemblies[uploadID]

    // If we don't have to wait for encoding metadata or results, we can close
    // the socket immediately and finish the upload.
    if (!this.shouldWait()) {
      assemblyIDs.forEach((assemblyID) => {
        const socket = this.sockets[assemblyID]
        socket.close()
      })
      return Promise.resolve()
    }

    let finishedAssemblies = 0

    return new Promise((resolve, reject) => {
      fileIDs.forEach((fileID) => {
        this.core.emit('core:postprocess-progress', fileID, {
          mode: 'indeterminate',
          message: this.opts.locale.strings.encoding
        })
      })

      const onAssemblyFinished = (assembly) => {
        // An assembly for a different upload just finished. We can ignore it.
        if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
          return
        }

        // TODO set the `file.uploadURL` to a result?
        // We will probably need an option here so the plugin user can tell us
        // which result to pick…?

        const files = this.getAssemblyFiles(assembly.assembly_id)
        files.forEach((file) => {
          this.core.emit('core:postprocess-complete', file.id)
        })

        finishedAssemblies += 1
        if (finishedAssemblies === assemblyIDs.length) {
          // We're done, these listeners can be removed
          removeListeners()
          resolve()
        }
      }

      const onAssemblyError = (assembly, error) => {
        // An assembly for a different upload just finished. We can ignore it.
        if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
          return
        }

        // Clear postprocessing state for all our files.
        const files = this.getAssemblyFiles(assembly.assembly_id)
        files.forEach((file) => {
          this.core.emit('core:postprocess-complete', file.id)
        })

        // Should we remove the listeners here or should we keep handling finished
        // assemblies?
        // Doing this for now so that it's not possible to receive more postprocessing
        // events once the upload has failed.
        removeListeners()

        // Reject the `afterUpload()` promise.
        reject(error)
      }

      const removeListeners = () => {
        this.core.off('transloadit:complete', onAssemblyFinished)
        this.core.off('transloadit:assembly-error', onAssemblyError)
      }

      this.core.on('transloadit:complete', onAssemblyFinished)
      this.core.on('transloadit:assembly-error', onAssemblyError)
    }).then(() => {
      // Clean up uploadID → assemblyIDs, they're no longer going to be used anywhere.
      const uploadsAssemblies = Object.assign({}, this.state.uploadsAssemblies)
      delete uploadsAssemblies[uploadID]
      this.updateState({ uploadsAssemblies })
    })
  }

  install () {
    this.core.addPreProcessor(this.prepareUpload)
    this.core.addPostProcessor(this.afterUpload)

    this.updateState({
      // Contains assembly status objects, indexed by their ID.
      assemblies: {},
      // Contains arrays of assembly IDs, indexed by the upload ID that they belong to.
      uploadsAssemblies: {},
      // Contains file data from Transloadit, indexed by their Transloadit-assigned ID.
      files: {},
      // Contains result data from Transloadit.
      results: []
    })
  }

  uninstall () {
    this.core.removePreProcessor(this.prepareUpload)
    this.core.removePostProcessor(this.afterUpload)
  }

  getAssembly (id) {
    return this.state.assemblies[id]
  }

  getAssemblyFiles (assemblyID) {
    const fileIDs = Object.keys(this.core.state.files)
    return fileIDs.map((fileID) => {
      return this.core.getFile(fileID)
    }).filter((file) => {
      return file && file.transloadit && file.transloadit.assembly === assemblyID
    })
  }

  get state () {
    return this.core.state.transloadit || {}
  }

  updateState (newState) {
    const transloadit = Object.assign({}, this.state, newState)

    this.core.setState({ transloadit })
  }
}

Should this be `this.state.transloadit.uploadsAssemblies`? If `assemblies` are transloadit-related-only.