const _ = require('lodash')
const OError = require('@overleaf/o-error')
const async = require('async')
const logger = require('logger-sharelatex')
const Settings = require('settings-sharelatex')
const Path = require('path')
const fs = require('fs')
const { Doc } = require('../../models/Doc')
const DocstoreManager = require('../Docstore/DocstoreManager')
const DocumentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler')
const Errors = require('../Errors/Errors')
const FileStoreHandler = require('../FileStore/FileStoreHandler')
const LockManager = require('../../infrastructure/LockManager')
const { Project } = require('../../models/Project')
const ProjectEntityHandler = require('./ProjectEntityHandler')
const ProjectGetter = require('./ProjectGetter')
const ProjectLocator = require('./ProjectLocator')
const ProjectUpdateHandler = require('./ProjectUpdateHandler')
const ProjectEntityMongoUpdateHandler = require('./ProjectEntityMongoUpdateHandler')
const SafePath = require('./SafePath')
const TpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender')
const FileWriter = require('../../infrastructure/FileWriter')
const EditorRealTimeController = require('../Editor/EditorRealTimeController')
const { promisifyAll } = require('../../util/promises')

const LOCK_NAMESPACE = 'sequentialProjectStructureUpdateLock'
const VALID_ROOT_DOC_EXTENSIONS = Settings.validRootDocExtensions
const VALID_ROOT_DOC_REGEXP = new RegExp(
  `^\\.(${VALID_ROOT_DOC_EXTENSIONS.join('|')})$`,
  'i'
)

function wrapWithLock(methodWithoutLock) {
  // This lock is used to make sure that the project structure updates are made
  // sequentially. In particular the updates must be made in mongo and sent to
  // the doc-updater in the same order.
  if (typeof methodWithoutLock === 'function') {
    const methodWithLock = (projectId, ...rest) => {
      const adjustedLength = Math.max(rest.length, 1)
      const args = rest.slice(0, adjustedLength - 1)
      const callback = rest[adjustedLength - 1]
      LockManager.runWithLock(
        LOCK_NAMESPACE,
        projectId,
        cb => methodWithoutLock(projectId, ...args, cb),
        callback
      )
    }
    methodWithLock.withoutLock = methodWithoutLock
    return methodWithLock
  } else {
    // handle case with separate setup and locked stages
    const wrapWithSetup = methodWithoutLock.beforeLock // a function to set things up before the lock
    const mainTask = methodWithoutLock.withLock // function to execute inside the lock
    const methodWithLock = wrapWithSetup((projectId, ...rest) => {
      const adjustedLength = Math.max(rest.length, 1)
      const args = rest.slice(0, adjustedLength - 1)
      const callback = rest[adjustedLength - 1]
      LockManager.runWithLock(
        LOCK_NAMESPACE,
        projectId,
        cb => mainTask(projectId, ...args, cb),
        callback
      )
    })
    methodWithLock.withoutLock = wrapWithSetup(mainTask)
    methodWithLock.beforeLock = methodWithoutLock.beforeLock
    methodWithLock.mainTask = methodWithoutLock.withLock
    return methodWithLock
  }
}

function getDocContext(projectId, docId, callback) {
  ProjectGetter.getProject(
    projectId,
    { name: true, rootFolder: true },
    (err, project) => {
      if (err) {
        return callback(
          OError.tag(err, 'error fetching project', {
            projectId
          })
        )
      }
      if (!project) {
        return callback(new Errors.NotFoundError('project not found'))
      }
      ProjectLocator.findElement(
        { project, element_id: docId, type: 'docs' },
        (err, doc, path) => {
          if (err && err instanceof Errors.NotFoundError) {
            // (Soft-)Deleted docs are removed from the file-tree (rootFolder).
            // docstore can tell whether it exists and is (soft)-deleted.
            DocstoreManager.isDocDeleted(
              projectId,
              docId,
              (err, isDeletedDoc) => {
                if (err && err instanceof Errors.NotFoundError) {
                  logger.warn(
                    { projectId, docId },
                    'doc not found while updating doc lines'
                  )
                  callback(err)
                } else if (err) {
                  callback(
                    OError.tag(
                      err,
                      'error checking deletion status with docstore',
                      { projectId, docId }
                    )
                  )
                } else {
                  if (!isDeletedDoc) {
                    // NOTE: This can happen while we delete a doc:
                    //  1. web will update the projects entry
                    //  2. web triggers flushes to tpds/doc-updater
                    //  3. web triggers (soft)-delete in docstore
                    // Specifically when an update comes in after 1
                    //  and before 3 completes.
                    logger.info(
                      { projectId, docId },
                      'updating doc that is in process of getting soft-deleted'
                    )
                  }
                  callback(null, {
                    projectName: project.name,
                    isDeletedDoc: true,
                    path: null
                  })
                }
              }
            )
          } else if (err) {
            callback(
              OError.tag(err, 'error finding doc in rootFolder', {
                docId,
                projectId
              })
            )
          } else {
            callback(null, {
              projectName: project.name,
              isDeletedDoc: false,
              path: path.fileSystem
            })
          }
        }
      )
    }
  )
}

const ProjectEntityUpdateHandler = {
  updateDocLines(
    projectId,
    docId,
    lines,
    version,
    ranges,
    lastUpdatedAt,
    lastUpdatedBy,
    callback
  ) {
    getDocContext(projectId, docId, (err, ctx) => {
      if (err && err instanceof Errors.NotFoundError) {
        // Do not allow an update to a doc which has never exist on this project
        logger.warn(
          { docId, projectId },
          'project or doc not found while updating doc lines'
        )
        return callback(err)
      }
      if (err) {
        return callback(err)
      }
      const { projectName, isDeletedDoc, path } = ctx
      logger.log({ projectId, docId }, 'telling docstore manager to update doc')
      DocstoreManager.updateDoc(
        projectId,
        docId,
        lines,
        version,
        ranges,
        (err, modified, rev) => {
          if (err != null) {
            OError.tag(err, 'error sending doc to docstore', {
              docId,
              projectId
            })
            return callback(err)
          }
          logger.log(
            { projectId, docId, modified },
            'finished updating doc lines'
          )
          // path will only be present if the doc is not deleted
          if (!modified || isDeletedDoc) {
            return callback()
          }
          // Don't need to block for marking as updated
          ProjectUpdateHandler.markAsUpdated(
            projectId,
            lastUpdatedAt,
            lastUpdatedBy
          )
          TpdsUpdateSender.addDoc(
            {
              project_id: projectId,
              path,
              doc_id: docId,
              project_name: projectName,
              rev
            },
            callback
          )
        }
      )
    })
  },

  setRootDoc(projectId, newRootDocID, callback) {
    logger.log({ projectId, rootDocId: newRootDocID }, 'setting root doc')
    if (projectId == null || newRootDocID == null) {
      return callback(
        new Errors.InvalidError('missing arguments (project or doc)')
      )
    }
    ProjectEntityHandler.getDocPathByProjectIdAndDocId(
      projectId,
      newRootDocID,
      (err, docPath) => {
        if (err != null) {
          return callback(err)
        }
        if (ProjectEntityUpdateHandler.isPathValidForRootDoc(docPath)) {
          Project.updateOne(
            { _id: projectId },
            { rootDoc_id: newRootDocID },
            {},
            callback
          )
        } else {
          callback(
            new Errors.UnsupportedFileTypeError(
              'invalid file extension for root doc'
            )
          )
        }
      }
    )
  },

  unsetRootDoc(projectId, callback) {
    logger.log({ projectId }, 'removing root doc')
    Project.updateOne(
      { _id: projectId },
      { $unset: { rootDoc_id: true } },
      {},
      callback
    )
  },

  _addDocAndSendToTpds(projectId, folderId, doc, callback) {
    ProjectEntityMongoUpdateHandler.addDoc(
      projectId,
      folderId,
      doc,
      (err, result, project) => {
        if (err != null) {
          OError.tag(err, 'error adding file with project', {
            projectId,
            folderId,
            doc_name: doc != null ? doc.name : undefined,
            doc_id: doc != null ? doc._id : undefined
          })
          return callback(err)
        }
        TpdsUpdateSender.addDoc(
          {
            project_id: projectId,
            doc_id: doc != null ? doc._id : undefined,
            path: result && result.path && result.path.fileSystem,
            project_name: project.name,
            rev: 0
          },
          err => {
            if (err != null) {
              return callback(err)
            }
            callback(null, result, project)
          }
        )
      }
    )
  },

  addDoc(projectId, folderId, docName, docLines, userId, callback) {
    ProjectEntityUpdateHandler.addDocWithRanges(
      projectId,
      folderId,
      docName,
      docLines,
      {},
      userId,
      callback
    )
  },

  addDocWithRanges: wrapWithLock({
    beforeLock(next) {
      return function (
        projectId,
        folderId,
        docName,
        docLines,
        ranges,
        userId,
        callback
      ) {
        if (!SafePath.isCleanFilename(docName)) {
          return callback(new Errors.InvalidNameError('invalid element name'))
        }
        // Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
        // which hasn't been created in docstore.
        const doc = new Doc({ name: docName })
        DocstoreManager.updateDoc(
          projectId.toString(),
          doc._id.toString(),
          docLines,
          0,
          ranges,
          (err, modified, rev) => {
            if (err != null) {
              return callback(err)
            }
            next(
              projectId,
              folderId,
              doc,
              docName,
              docLines,
              ranges,
              userId,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      folderId,
      doc,
      docName,
      docLines,
      ranges,
      userId,
      callback
    ) {
      ProjectEntityUpdateHandler._addDocAndSendToTpds(
        projectId,
        folderId,
        doc,
        (err, result, project) => {
          if (err != null) {
            return callback(err)
          }
          const docPath = result && result.path && result.path.fileSystem
          const projectHistoryId =
            project.overleaf &&
            project.overleaf.history &&
            project.overleaf.history.id
          const newDocs = [
            {
              doc,
              path: docPath,
              docLines: docLines.join('\n')
            }
          ]
          DocumentUpdaterHandler.updateProjectStructure(
            projectId,
            projectHistoryId,
            userId,
            { newDocs, newProject: project },
            error => {
              if (error != null) {
                return callback(error)
              }
              callback(null, doc, folderId)
            }
          )
        }
      )
    }
  }),

  _uploadFile(projectId, folderId, fileName, fsPath, linkedFileData, callback) {
    if (!SafePath.isCleanFilename(fileName)) {
      return callback(new Errors.InvalidNameError('invalid element name'))
    }
    const fileArgs = {
      name: fileName,
      linkedFileData
    }
    FileStoreHandler.uploadFileFromDisk(
      projectId,
      fileArgs,
      fsPath,
      (err, fileStoreUrl, fileRef) => {
        if (err != null) {
          OError.tag(err, 'error uploading image to s3', {
            projectId,
            folderId,
            file_name: fileName,
            fileRef
          })
          return callback(err)
        }
        callback(null, fileStoreUrl, fileRef)
      }
    )
  },

  _addFileAndSendToTpds(projectId, folderId, fileRef, callback) {
    ProjectEntityMongoUpdateHandler.addFile(
      projectId,
      folderId,
      fileRef,
      (err, result, project) => {
        if (err != null) {
          OError.tag(err, 'error adding file with project', {
            projectId,
            folderId,
            file_name: fileRef.name,
            fileRef
          })
          return callback(err)
        }
        TpdsUpdateSender.addFile(
          {
            project_id: projectId,
            file_id: fileRef._id,
            path: result && result.path && result.path.fileSystem,
            project_name: project.name,
            rev: fileRef.rev
          },
          err => {
            if (err != null) {
              return callback(err)
            }
            callback(null, result, project)
          }
        )
      }
    )
  },

  addFile: wrapWithLock({
    beforeLock(next) {
      return function (
        projectId,
        folderId,
        fileName,
        fsPath,
        linkedFileData,
        userId,
        callback
      ) {
        if (!SafePath.isCleanFilename(fileName)) {
          return callback(new Errors.InvalidNameError('invalid element name'))
        }
        ProjectEntityUpdateHandler._uploadFile(
          projectId,
          folderId,
          fileName,
          fsPath,
          linkedFileData,
          (error, fileStoreUrl, fileRef) => {
            if (error != null) {
              return callback(error)
            }
            next(
              projectId,
              folderId,
              fileName,
              fsPath,
              linkedFileData,
              userId,
              fileRef,
              fileStoreUrl,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      folderId,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      fileRef,
      fileStoreUrl,
      callback
    ) {
      ProjectEntityUpdateHandler._addFileAndSendToTpds(
        projectId,
        folderId,
        fileRef,
        (err, result, project) => {
          if (err != null) {
            return callback(err)
          }
          const projectHistoryId =
            project.overleaf &&
            project.overleaf.history &&
            project.overleaf.history.id
          const newFiles = [
            {
              file: fileRef,
              path: result && result.path && result.path.fileSystem,
              url: fileStoreUrl
            }
          ]
          DocumentUpdaterHandler.updateProjectStructure(
            projectId,
            projectHistoryId,
            userId,
            { newFiles, newProject: project },
            error => {
              if (error != null) {
                return callback(error)
              }
              ProjectUpdateHandler.markAsUpdated(projectId, new Date(), userId)
              callback(null, fileRef, folderId)
            }
          )
        }
      )
    }
  }),

  replaceFile: wrapWithLock({
    beforeLock(next) {
      return function (
        projectId,
        fileId,
        fsPath,
        linkedFileData,
        userId,
        callback
      ) {
        // create a new file
        const fileArgs = {
          name: 'dummy-upload-filename',
          linkedFileData
        }
        FileStoreHandler.uploadFileFromDisk(
          projectId,
          fileArgs,
          fsPath,
          (err, fileStoreUrl, fileRef) => {
            if (err != null) {
              return callback(err)
            }
            next(
              projectId,
              fileId,
              fsPath,
              linkedFileData,
              userId,
              fileRef,
              fileStoreUrl,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      fileId,
      fsPath,
      linkedFileData,
      userId,
      newFileRef,
      fileStoreUrl,
      callback
    ) {
      ProjectEntityMongoUpdateHandler.replaceFileWithNew(
        projectId,
        fileId,
        newFileRef,
        (err, oldFileRef, project, path, newProject) => {
          if (err != null) {
            return callback(err)
          }
          const oldFiles = [
            {
              file: oldFileRef,
              path: path.fileSystem
            }
          ]
          const newFiles = [
            {
              file: newFileRef,
              path: path.fileSystem,
              url: fileStoreUrl
            }
          ]
          const projectHistoryId =
            project.overleaf &&
            project.overleaf.history &&
            project.overleaf.history.id
          // Increment the rev for an in-place update (with the same path) so the third-party-datastore
          // knows this is a new file.
          // Ideally we would get this from ProjectEntityMongoUpdateHandler.replaceFileWithNew
          // but it returns the original oldFileRef (after incrementing the rev value in mongo),
          // so we add 1 to the rev from that. This isn't atomic and relies on the lock
          // but it is acceptable for now.
          TpdsUpdateSender.addFile(
            {
              project_id: project._id,
              file_id: newFileRef._id,
              path: path.fileSystem,
              rev: oldFileRef.rev + 1,
              project_name: project.name
            },
            err => {
              if (err != null) {
                return callback(err)
              }
              ProjectUpdateHandler.markAsUpdated(projectId, new Date(), userId)

              DocumentUpdaterHandler.updateProjectStructure(
                projectId,
                projectHistoryId,
                userId,
                { oldFiles, newFiles, newProject },
                callback
              )
            }
          )
        }
      )
    }
  }),

  upsertDoc: wrapWithLock(function (
    projectId,
    folderId,
    docName,
    docLines,
    source,
    userId,
    callback
  ) {
    if (!SafePath.isCleanFilename(docName)) {
      return callback(new Errors.InvalidNameError('invalid element name'))
    }
    ProjectLocator.findElement(
      { project_id: projectId, element_id: folderId, type: 'folder' },
      (error, folder, folderPath) => {
        if (error != null) {
          return callback(error)
        }
        if (folder == null) {
          return callback(new Error("Couldn't find folder"))
        }
        const existingDoc = folder.docs.find(({ name }) => name === docName)
        const existingFile = folder.fileRefs.find(
          ({ name }) => name === docName
        )
        if (existingFile) {
          const doc = new Doc({ name: docName })
          const filePath = `${folderPath.fileSystem}/${existingFile.name}`
          DocstoreManager.updateDoc(
            projectId.toString(),
            doc._id.toString(),
            docLines,
            0,
            {},
            (err, modified, rev) => {
              if (err != null) {
                return callback(err)
              }
              ProjectEntityMongoUpdateHandler.replaceFileWithDoc(
                projectId,
                existingFile._id,
                doc,
                (err, project) => {
                  if (err) {
                    return callback(err)
                  }
                  TpdsUpdateSender.addDoc(
                    {
                      project_id: projectId,
                      doc_id: doc._id,
                      path: filePath,
                      project_name: project.name,
                      rev: existingFile.rev + 1
                    },
                    err => {
                      if (err) {
                        return callback(err)
                      }
                      const projectHistoryId =
                        project.overleaf &&
                        project.overleaf.history &&
                        project.overleaf.history.id
                      const newDocs = [
                        {
                          doc,
                          path: filePath,
                          docLines: docLines.join('\n')
                        }
                      ]
                      const oldFiles = [
                        {
                          file: existingFile,
                          path: filePath
                        }
                      ]
                      DocumentUpdaterHandler.updateProjectStructure(
                        projectId,
                        projectHistoryId,
                        userId,
                        { oldFiles, newDocs, newProject: project },
                        error => {
                          if (error != null) {
                            return callback(error)
                          }
                          EditorRealTimeController.emitToRoom(
                            projectId,
                            'removeEntity',
                            existingFile._id,
                            'convertFileToDoc'
                          )
                          callback(null, doc, true)
                        }
                      )
                    }
                  )
                }
              )
            }
          )
        } else if (existingDoc) {
          DocumentUpdaterHandler.setDocument(
            projectId,
            existingDoc._id,
            userId,
            docLines,
            source,
            err => {
              if (err != null) {
                return callback(err)
              }
              logger.log(
                { projectId, docId: existingDoc._id },
                'notifying users that the document has been updated'
              )
              DocumentUpdaterHandler.flushDocToMongo(
                projectId,
                existingDoc._id,
                err => {
                  if (err != null) {
                    return callback(err)
                  }
                  callback(null, existingDoc, existingDoc == null)
                }
              )
            }
          )
        } else {
          ProjectEntityUpdateHandler.addDocWithRanges.withoutLock(
            projectId,
            folderId,
            docName,
            docLines,
            {},
            userId,
            (err, doc) => {
              if (err != null) {
                return callback(err)
              }
              callback(null, doc, existingDoc == null)
            }
          )
        }
      }
    )
  }),

  upsertFile: wrapWithLock({
    beforeLock(next) {
      return function (
        projectId,
        folderId,
        fileName,
        fsPath,
        linkedFileData,
        userId,
        callback
      ) {
        if (!SafePath.isCleanFilename(fileName)) {
          return callback(new Errors.InvalidNameError('invalid element name'))
        }
        // create a new file
        const fileArgs = {
          name: fileName,
          linkedFileData
        }
        FileStoreHandler.uploadFileFromDisk(
          projectId,
          fileArgs,
          fsPath,
          (err, fileStoreUrl, fileRef) => {
            if (err != null) {
              return callback(err)
            }
            next(
              projectId,
              folderId,
              fileName,
              fsPath,
              linkedFileData,
              userId,
              fileRef,
              fileStoreUrl,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      folderId,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      newFileRef,
      fileStoreUrl,
      callback
    ) {
      ProjectLocator.findElement(
        { project_id: projectId, element_id: folderId, type: 'folder' },
        (error, folder) => {
          if (error != null) {
            return callback(error)
          }
          if (folder == null) {
            return callback(new Error("Couldn't find folder"))
          }
          const existingFile = folder.fileRefs.find(
            ({ name }) => name === fileName
          )
          const existingDoc = folder.docs.find(({ name }) => name === fileName)

          if (existingDoc) {
            ProjectLocator.findElement(
              {
                project_id: projectId,
                element_id: existingDoc._id,
                type: 'doc'
              },
              (err, doc, path) => {
                if (err) {
                  return callback(new Error('coudnt find existing file'))
                }
                ProjectEntityMongoUpdateHandler.replaceDocWithFile(
                  projectId,
                  existingDoc._id,
                  newFileRef,
                  (err, project) => {
                    if (err) {
                      return callback(err)
                    }
                    const projectHistoryId =
                      project.overleaf &&
                      project.overleaf.history &&
                      project.overleaf.history.id
                    TpdsUpdateSender.addFile(
                      {
                        project_id: project._id,
                        file_id: newFileRef._id,
                        path: path.fileSystem,
                        rev: newFileRef.rev,
                        project_name: project.name
                      },
                      err => {
                        if (err) {
                          return callback(err)
                        }
                        DocumentUpdaterHandler.updateProjectStructure(
                          projectId,
                          projectHistoryId,
                          userId,
                          {
                            oldDocs: [
                              { doc: existingDoc, path: path.fileSystem }
                            ],

                            newFiles: [
                              {
                                file: newFileRef,
                                path: path.fileSystem,
                                url: fileStoreUrl
                              }
                            ],
                            newProject: project
                          },
                          err => {
                            if (err) {
                              return callback(err)
                            }
                            EditorRealTimeController.emitToRoom(
                              projectId,
                              'removeEntity',
                              existingDoc._id,
                              'convertDocToFile'
                            )
                            callback(null, newFileRef, true, existingFile)
                          }
                        )
                      }
                    )
                  }
                )
              }
            )
          } else if (existingFile) {
            // this calls directly into the replaceFile main task (without the beforeLock part)
            return ProjectEntityUpdateHandler.replaceFile.mainTask(
              projectId,
              existingFile._id,
              fsPath,
              linkedFileData,
              userId,
              newFileRef,
              fileStoreUrl,
              err => {
                if (err != null) {
                  return callback(err)
                }
                callback(null, newFileRef, existingFile == null, existingFile)
              }
            )
          } else {
            // this calls directly into the addFile main task (without the beforeLock part)
            ProjectEntityUpdateHandler.addFile.mainTask(
              projectId,
              folderId,
              fileName,
              fsPath,
              linkedFileData,
              userId,
              newFileRef,
              fileStoreUrl,
              err => {
                if (err != null) {
                  return callback(err)
                }
                callback(null, newFileRef, existingFile == null, existingFile)
              }
            )
          }
        }
      )
    }
  }),

  upsertDocWithPath: wrapWithLock(function (
    projectId,
    elementPath,
    docLines,
    source,
    userId,
    callback
  ) {
    if (!SafePath.isCleanPath(elementPath)) {
      return callback(new Errors.InvalidNameError('invalid element name'))
    }
    const docName = Path.basename(elementPath)
    const folderPath = Path.dirname(elementPath)
    ProjectEntityUpdateHandler.mkdirp.withoutLock(
      projectId,
      folderPath,
      (err, newFolders, folder) => {
        if (err != null) {
          return callback(err)
        }
        ProjectEntityUpdateHandler.upsertDoc.withoutLock(
          projectId,
          folder._id,
          docName,
          docLines,
          source,
          userId,
          (err, doc, isNewDoc) => {
            if (err != null) {
              return callback(err)
            }
            callback(null, doc, isNewDoc, newFolders, folder)
          }
        )
      }
    )
  }),

  upsertFileWithPath: wrapWithLock({
    beforeLock(next) {
      return function (
        projectId,
        elementPath,
        fsPath,
        linkedFileData,
        userId,
        callback
      ) {
        if (!SafePath.isCleanPath(elementPath)) {
          return callback(new Errors.InvalidNameError('invalid element name'))
        }
        const fileName = Path.basename(elementPath)
        const folderPath = Path.dirname(elementPath)
        // create a new file
        const fileArgs = {
          name: fileName,
          linkedFileData
        }
        FileStoreHandler.uploadFileFromDisk(
          projectId,
          fileArgs,
          fsPath,
          (err, fileStoreUrl, fileRef) => {
            if (err != null) {
              return callback(err)
            }
            next(
              projectId,
              folderPath,
              fileName,
              fsPath,
              linkedFileData,
              userId,
              fileRef,
              fileStoreUrl,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      folderPath,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      fileRef,
      fileStoreUrl,
      callback
    ) {
      ProjectEntityUpdateHandler.mkdirp.withoutLock(
        projectId,
        folderPath,
        (err, newFolders, folder) => {
          if (err != null) {
            return callback(err)
          }
          // this calls directly into the upsertFile main task (without the beforeLock part)
          ProjectEntityUpdateHandler.upsertFile.mainTask(
            projectId,
            folder._id,
            fileName,
            fsPath,
            linkedFileData,
            userId,
            fileRef,
            fileStoreUrl,
            (err, newFile, isNewFile, existingFile) => {
              if (err != null) {
                return callback(err)
              }
              callback(
                null,
                newFile,
                isNewFile,
                existingFile,
                newFolders,
                folder
              )
            }
          )
        }
      )
    }
  }),

  deleteEntity: wrapWithLock(function (
    projectId,
    entityId,
    entityType,
    userId,
    callback
  ) {
    logger.log({ entityId, entityType, projectId }, 'deleting project entity')
    if (entityType == null) {
      logger.warn({ err: 'No entityType set', projectId, entityId })
      return callback(new Error('No entityType set'))
    }
    entityType = entityType.toLowerCase()
    ProjectEntityMongoUpdateHandler.deleteEntity(
      projectId,
      entityId,
      entityType,
      (error, entity, path, projectBeforeDeletion, newProject) => {
        if (error != null) {
          return callback(error)
        }
        ProjectEntityUpdateHandler._cleanUpEntity(
          projectBeforeDeletion,
          newProject,
          entity,
          entityType,
          path.fileSystem,
          userId,
          error => {
            if (error != null) {
              return callback(error)
            }
            TpdsUpdateSender.deleteEntity(
              {
                project_id: projectId,
                path: path.fileSystem,
                project_name: projectBeforeDeletion.name
              },
              error => {
                if (error != null) {
                  return callback(error)
                }
                callback(null, entityId)
              }
            )
          }
        )
      }
    )
  }),

  deleteEntityWithPath: wrapWithLock((projectId, path, userId, callback) =>
    ProjectLocator.findElementByPath(
      { project_id: projectId, path },
      (err, element, type) => {
        if (err != null) {
          return callback(err)
        }
        if (element == null) {
          return callback(new Errors.NotFoundError('project not found'))
        }
        ProjectEntityUpdateHandler.deleteEntity.withoutLock(
          projectId,
          element._id,
          type,
          userId,
          callback
        )
      }
    )
  ),

  mkdirp: wrapWithLock(function (projectId, path, callback) {
    for (let folder of path.split('/')) {
      if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
        return callback(new Errors.InvalidNameError('invalid element name'))
      }
    }
    ProjectEntityMongoUpdateHandler.mkdirp(
      projectId,
      path,
      { exactCaseMatch: false },
      callback
    )
  }),

  mkdirpWithExactCase: wrapWithLock(function (projectId, path, callback) {
    for (let folder of path.split('/')) {
      if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
        return callback(new Errors.InvalidNameError('invalid element name'))
      }
    }
    ProjectEntityMongoUpdateHandler.mkdirp(
      projectId,
      path,
      { exactCaseMatch: true },
      callback
    )
  }),

  addFolder: wrapWithLock(function (
    projectId,
    parentFolderId,
    folderName,
    callback
  ) {
    if (!SafePath.isCleanFilename(folderName)) {
      return callback(new Errors.InvalidNameError('invalid element name'))
    }
    ProjectEntityMongoUpdateHandler.addFolder(
      projectId,
      parentFolderId,
      folderName,
      callback
    )
  }),

  moveEntity: wrapWithLock(function (
    projectId,
    entityId,
    destFolderId,
    entityType,
    userId,
    callback
  ) {
    logger.log(
      { entityType, entityId, projectId, destFolderId },
      'moving entity'
    )
    if (entityType == null) {
      logger.warn({ err: 'No entityType set', projectId, entityId })
      return callback(new Error('No entityType set'))
    }
    entityType = entityType.toLowerCase()
    ProjectEntityMongoUpdateHandler.moveEntity(
      projectId,
      entityId,
      destFolderId,
      entityType,
      (err, project, startPath, endPath, rev, changes) => {
        if (err != null) {
          return callback(err)
        }
        const projectHistoryId =
          project.overleaf &&
          project.overleaf.history &&
          project.overleaf.history.id
        // do not wait
        TpdsUpdateSender.promises
          .moveEntity({
            project_id: projectId,
            project_name: project.name,
            startPath,
            endPath,
            rev
          })
          .catch(err => {
            logger.error({ err }, 'error sending tpds update')
          })
        DocumentUpdaterHandler.updateProjectStructure(
          projectId,
          projectHistoryId,
          userId,
          changes,
          callback
        )
      }
    )
  }),

  renameEntity: wrapWithLock(function (
    projectId,
    entityId,
    entityType,
    newName,
    userId,
    callback
  ) {
    if (!SafePath.isCleanFilename(newName)) {
      return callback(new Errors.InvalidNameError('invalid element name'))
    }
    logger.log({ entityId, projectId }, `renaming ${entityType}`)
    if (entityType == null) {
      logger.warn({ err: 'No entityType set', projectId, entityId })
      return callback(new Error('No entityType set'))
    }
    entityType = entityType.toLowerCase()

    ProjectEntityMongoUpdateHandler.renameEntity(
      projectId,
      entityId,
      entityType,
      newName,
      (err, project, startPath, endPath, rev, changes) => {
        if (err != null) {
          return callback(err)
        }
        const projectHistoryId =
          project.overleaf &&
          project.overleaf.history &&
          project.overleaf.history.id
        // do not wait
        TpdsUpdateSender.promises
          .moveEntity({
            project_id: projectId,
            project_name: project.name,
            startPath,
            endPath,
            rev
          })
          .catch(err => {
            logger.error({ err }, 'error sending tpds update')
          })
        DocumentUpdaterHandler.updateProjectStructure(
          projectId,
          projectHistoryId,
          userId,
          changes,
          callback
        )
      }
    )
  }),

  // This doesn't directly update project structure but we need to take the lock
  // to prevent anything else being queued before the resync update
  resyncProjectHistory: wrapWithLock((projectId, callback) =>
    ProjectGetter.getProject(
      projectId,
      { rootFolder: true, overleaf: true },
      (error, project) => {
        if (error != null) {
          return callback(error)
        }

        const projectHistoryId =
          project &&
          project.overleaf &&
          project.overleaf.history &&
          project.overleaf.history.id
        if (projectHistoryId == null) {
          error = new Errors.ProjectHistoryDisabledError(
            `project history not enabled for ${projectId}`
          )
          return callback(error)
        }

        ProjectEntityHandler.getAllEntitiesFromProject(
          project,
          (error, docs, files) => {
            if (error != null) {
              return callback(error)
            }

            docs = _.map(docs, doc => ({
              doc: doc.doc._id,
              path: doc.path
            }))

            files = _.map(files, file => ({
              file: file.file._id,
              path: file.path,
              url: FileStoreHandler._buildUrl(projectId, file.file._id)
            }))

            DocumentUpdaterHandler.resyncProjectHistory(
              projectId,
              projectHistoryId,
              docs,
              files,
              callback
            )
          }
        )
      }
    )
  ),

  isPathValidForRootDoc(docPath) {
    let docExtension = Path.extname(docPath)
    return VALID_ROOT_DOC_REGEXP.test(docExtension)
  },

  _cleanUpEntity(
    project,
    newProject,
    entity,
    entityType,
    path,
    userId,
    callback
  ) {
    ProjectEntityUpdateHandler._updateProjectStructureWithDeletedEntity(
      project,
      newProject,
      entity,
      entityType,
      path,
      userId,
      error => {
        if (error != null) {
          return callback(error)
        }
        if (entityType.indexOf('file') !== -1) {
          ProjectEntityUpdateHandler._cleanUpFile(
            project,
            entity,
            path,
            userId,
            callback
          )
        } else if (entityType.indexOf('doc') !== -1) {
          ProjectEntityUpdateHandler._cleanUpDoc(
            project,
            entity,
            path,
            userId,
            callback
          )
        } else if (entityType.indexOf('folder') !== -1) {
          ProjectEntityUpdateHandler._cleanUpFolder(
            project,
            entity,
            path,
            userId,
            callback
          )
        } else {
          callback()
        }
      }
    )
  },

  // Note: the _cleanUpEntity code and _updateProjectStructureWithDeletedEntity
  // methods both need to recursively iterate over the entities in folder.
  // These are currently using separate implementations of the recursion. In
  // future, these could be simplified using a common project entity iterator.
  _updateProjectStructureWithDeletedEntity(
    project,
    newProject,
    entity,
    entityType,
    entityPath,
    userId,
    callback
  ) {
    // compute the changes to the project structure
    let changes
    if (entityType.indexOf('file') !== -1) {
      changes = { oldFiles: [{ file: entity, path: entityPath }] }
    } else if (entityType.indexOf('doc') !== -1) {
      changes = { oldDocs: [{ doc: entity, path: entityPath }] }
    } else if (entityType.indexOf('folder') !== -1) {
      changes = { oldDocs: [], oldFiles: [] }
      const _recurseFolder = (folder, folderPath) => {
        for (let doc of folder.docs) {
          changes.oldDocs.push({ doc, path: Path.join(folderPath, doc.name) })
        }
        for (let file of folder.fileRefs) {
          changes.oldFiles.push({
            file,
            path: Path.join(folderPath, file.name)
          })
        }
        for (const childFolder of folder.folders) {
          _recurseFolder(childFolder, Path.join(folderPath, childFolder.name))
        }
      }
      _recurseFolder(entity, entityPath)
    }
    // now send the project structure changes to the docupdater
    changes.newProject = newProject
    const projectId = project._id.toString()
    const projectHistoryId =
      project.overleaf &&
      project.overleaf.history &&
      project.overleaf.history.id
    DocumentUpdaterHandler.updateProjectStructure(
      projectId,
      projectHistoryId,
      userId,
      changes,
      callback
    )
  },

  _cleanUpDoc(project, doc, path, userId, callback) {
    const projectId = project._id.toString()
    const docId = doc._id.toString()
    const unsetRootDocIfRequired = callback => {
      if (
        project.rootDoc_id != null &&
        project.rootDoc_id.toString() === docId
      ) {
        ProjectEntityUpdateHandler.unsetRootDoc(projectId, callback)
      } else {
        callback()
      }
    }

    unsetRootDocIfRequired(error => {
      if (error != null) {
        return callback(error)
      }
      const { name } = doc
      const deletedAt = new Date()
      DocstoreManager.deleteDoc(projectId, docId, name, deletedAt, error => {
        if (error) {
          return callback(error)
        }
        DocumentUpdaterHandler.deleteDoc(projectId, docId, callback)
      })
    })
  },

  _cleanUpFile(project, file, path, userId, callback) {
    ProjectEntityMongoUpdateHandler._insertDeletedFileReference(
      project._id,
      file,
      callback
    )
  },

  _cleanUpFolder(project, folder, folderPath, userId, callback) {
    const jobs = []
    folder.docs.forEach(doc => {
      const docPath = Path.join(folderPath, doc.name)
      jobs.push(callback =>
        ProjectEntityUpdateHandler._cleanUpDoc(
          project,
          doc,
          docPath,
          userId,
          callback
        )
      )
    })

    folder.fileRefs.forEach(file => {
      const filePath = Path.join(folderPath, file.name)
      jobs.push(callback =>
        ProjectEntityUpdateHandler._cleanUpFile(
          project,
          file,
          filePath,
          userId,
          callback
        )
      )
    })

    folder.folders.forEach(childFolder => {
      folderPath = Path.join(folderPath, childFolder.name)
      jobs.push(callback =>
        ProjectEntityUpdateHandler._cleanUpFolder(
          project,
          childFolder,
          folderPath,
          userId,
          callback
        )
      )
    })

    async.series(jobs, callback)
  },

  convertDocToFile: wrapWithLock({
    beforeLock(next) {
      return function (projectId, docId, userId, callback) {
        DocumentUpdaterHandler.flushDocToMongo(projectId, docId, err => {
          if (err) {
            return callback(err)
          }
          ProjectLocator.findElement(
            { project_id: projectId, element_id: docId, type: 'doc' },
            (err, doc, path) => {
              const docPath = path.fileSystem
              if (err) {
                return callback(err)
              }
              DocstoreManager.getDoc(
                projectId,
                docId,
                (err, docLines, rev, version, ranges) => {
                  if (err) {
                    return callback(err)
                  }
                  if (!_.isEmpty(ranges)) {
                    return callback(new Errors.DocHasRangesError({}))
                  }
                  DocumentUpdaterHandler.deleteDoc(projectId, docId, err => {
                    if (err) {
                      return callback(err)
                    }
                    FileWriter.writeLinesToDisk(
                      projectId,
                      docLines,
                      (err, fsPath) => {
                        if (err) {
                          return callback(err)
                        }
                        FileStoreHandler.uploadFileFromDisk(
                          projectId,
                          { name: doc.name, rev: rev + 1 },
                          fsPath,
                          (err, fileStoreUrl, fileRef) => {
                            if (err) {
                              return callback(err)
                            }
                            fs.unlink(fsPath, err => {
                              if (err) {
                                logger.warn(
                                  { err, path: fsPath },
                                  'failed to clean up temporary file'
                                )
                              }
                              next(
                                projectId,
                                doc,
                                docPath,
                                fileRef,
                                fileStoreUrl,
                                userId,
                                callback
                              )
                            })
                          }
                        )
                      }
                    )
                  })
                }
              )
            }
          )
        })
      }
    },
    withLock(projectId, doc, path, fileRef, fileStoreUrl, userId, callback) {
      ProjectEntityMongoUpdateHandler.replaceDocWithFile(
        projectId,
        doc._id,
        fileRef,
        (err, project) => {
          if (err) {
            return callback(err)
          }
          const projectHistoryId =
            project.overleaf &&
            project.overleaf.history &&
            project.overleaf.history.id
          DocumentUpdaterHandler.updateProjectStructure(
            projectId,
            projectHistoryId,
            userId,
            {
              oldDocs: [{ doc, path }],
              newFiles: [{ file: fileRef, path, url: fileStoreUrl }],
              newProject: project
            },
            err => {
              if (err) {
                return callback(err)
              }
              ProjectLocator.findElement(
                {
                  project_id: projectId,
                  element_id: fileRef._id,
                  type: 'file'
                },
                (err, element, path, folder) => {
                  if (err) {
                    return callback(err)
                  }
                  EditorRealTimeController.emitToRoom(
                    projectId,
                    'removeEntity',
                    doc._id,
                    'convertDocToFile'
                  )
                  EditorRealTimeController.emitToRoom(
                    projectId,
                    'reciveNewFile',
                    folder._id,
                    fileRef,
                    'convertDocToFile',
                    null,
                    userId
                  )
                  callback(null, fileRef)
                }
              )
            }
          )
        }
      )
    }
  })
}

module.exports = ProjectEntityUpdateHandler
module.exports.promises = promisifyAll(ProjectEntityUpdateHandler, {
  without: ['isPathValidForRootDoc'],
  multiResult: {
    _addDocAndSendToTpds: ['result', 'project'],
    addDoc: ['doc', 'folderId'],
    addDocWithRanges: ['doc', 'folderId'],
    _uploadFile: ['fileStoreUrl', 'fileRef'],
    _addFileAndSendToTpds: ['result', 'project'],
    addFile: ['fileRef', 'folderId'],
    upsertDoc: ['doc', 'isNew'],
    upsertFile: ['fileRef', 'isNew', 'oldFileRef'],
    upsertDocWithPath: ['doc', 'isNew', 'newFolders', 'folder'],
    upsertFileWithPath: ['fileRef', 'isNew', 'oldFile', 'newFolders', 'folder'],
    mkdirp: ['newFolders', 'folder'],
    mkdirpWithExactCase: ['newFolders', 'folder'],
    addFolder: ['folder', 'parentFolderId']
  }
})
