const _ = require('lodash')
const OError = require('@overleaf/o-error')
const async = require('async')
const logger = require('@overleaf/logger')
const Settings = require('@overleaf/settings')
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 { iterablePaths } = require('./IterablePath')

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, lockManager = LockManager) {
  // 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, folder) => {
          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.debug(
                      { projectId, docId },
                      'updating doc that is in process of getting soft-deleted'
                    )
                  }
                  callback(null, {
                    projectName: project.name,
                    isDeletedDoc: true,
                    path: null,
                    folder: 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,
              folder,
            })
          }
        }
      )
    }
  )
}

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, folder } = ctx
      logger.debug(
        { 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.debug(
            { projectId, docId, modified },
            'finished updating doc lines'
          )
          // path will only be present if the doc is not deleted
          if (!modified || isDeletedDoc) {
            return callback(null, { rev })
          }
          // Don't need to block for marking as updated
          ProjectUpdateHandler.markAsUpdated(
            projectId,
            lastUpdatedAt,
            lastUpdatedBy
          )
          TpdsUpdateSender.addDoc(
            {
              projectId,
              path,
              docId,
              projectName,
              rev,
              folderId: folder?._id,
            },
            err => {
              if (err) {
                return callback(err)
              }
              callback(null, { rev, modified })
            }
          )
        }
      )
    })
  },

  setRootDoc(projectId, newRootDocID, callback) {
    logger.debug({ 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.debug({ 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(
          {
            projectId,
            docId: doc != null ? doc._id : undefined,
            path: result?.path?.fileSystem,
            projectName: project.name,
            rev: 0,
            folderId,
          },
          err => {
            if (err != null) {
              return callback(err)
            }
            callback(null, result, project)
          }
        )
      }
    )
  },

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

  addDocWithRanges: wrapWithLock({
    beforeLock(next) {
      return function (
        projectId,
        folderId,
        docName,
        docLines,
        ranges,
        userId,
        source,
        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)
            }
            doc.rev = rev
            next(
              projectId,
              folderId,
              doc,
              docName,
              docLines,
              ranges,
              userId,
              source,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      folderId,
      doc,
      docName,
      docLines,
      ranges,
      userId,
      source,
      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 },
            source,
            error => {
              if (error != null) {
                return callback(error)
              }
              callback(null, doc, folderId || project.rootFolder[0]._id)
            }
          )
        }
      )
    },
  }),

  _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(
          {
            projectId,
            fileId: fileRef._id,
            path: result?.path?.fileSystem,
            projectName: project.name,
            rev: fileRef.rev,
            folderId,
          },
          err => {
            if (err != null) {
              return callback(err)
            }
            callback(null, result, project)
          }
        )
      }
    )
  },

  addFile: wrapWithLock({
    beforeLock(next) {
      return function (
        projectId,
        folderId,
        fileName,
        fsPath,
        linkedFileData,
        userId,
        source,
        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,
              source,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      folderId,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      fileRef,
      fileStoreUrl,
      source,
      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 },
            source,
            error => {
              if (error != null) {
                return callback(error)
              }
              ProjectUpdateHandler.markAsUpdated(projectId, new Date(), userId)
              callback(null, fileRef, folderId)
            }
          )
        }
      )
    },
  }),

  _replaceFile(
    projectId,
    fileId,
    fsPath,
    linkedFileData,
    userId,
    newFileRef,
    fileStoreUrl,
    folderId,
    source,
    callback
  ) {
    ProjectEntityMongoUpdateHandler.replaceFileWithNew(
      projectId,
      fileId,
      newFileRef,
      (err, oldFileRef, project, path, newProject, newFileRef) => {
        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
        TpdsUpdateSender.addFile(
          {
            projectId: project._id,
            fileId: newFileRef._id,
            path: path.fileSystem,
            rev: newFileRef.rev,
            projectName: project.name,
            folderId,
          },
          err => {
            if (err != null) {
              return callback(err)
            }
            ProjectUpdateHandler.markAsUpdated(projectId, new Date(), userId)

            DocumentUpdaterHandler.updateProjectStructure(
              projectId,
              projectHistoryId,
              userId,
              { oldFiles, newFiles, newProject },
              source,
              err => {
                if (err) {
                  return callback(err)
                }
                callback(null, newFileRef)
              }
            )
          }
        )
      }
    )
  },

  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)
              }
              doc.rev = rev
              ProjectEntityMongoUpdateHandler.replaceFileWithDoc(
                projectId,
                existingFile._id,
                doc,
                (err, project) => {
                  if (err) {
                    return callback(err)
                  }
                  TpdsUpdateSender.addDoc(
                    {
                      projectId,
                      docId: doc._id,
                      path: filePath,
                      projectName: project.name,
                      rev: existingFile.rev + 1,
                      folderId,
                    },
                    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 },
                        source,
                        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, result) => {
              if (err != null) {
                return callback(err)
              }
              logger.debug(
                { projectId, docId: existingDoc._id },
                'notifying users that the document has been updated'
              )
              // there is no need to flush the doc to mongo at this point as docupdater
              // flushes it as part of setDoc.
              //
              // combine rev from response with existing doc metadata
              callback(null, { ...existingDoc, ...result }, existingDoc == null)
            }
          )
        } else {
          ProjectEntityUpdateHandler.addDocWithRanges.withoutLock(
            projectId,
            folderId,
            docName,
            docLines,
            {},
            userId,
            source,
            (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,
        source,
        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,
              source,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      folderId,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      newFileRef,
      fileStoreUrl,
      source,
      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(
                      {
                        projectId: project._id,
                        fileId: newFileRef._id,
                        path: path.fileSystem,
                        rev: newFileRef.rev,
                        projectName: project.name,
                        folderId,
                      },
                      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,
                          },
                          source,
                          err => {
                            if (err) {
                              return callback(err)
                            }
                            EditorRealTimeController.emitToRoom(
                              projectId,
                              'removeEntity',
                              existingDoc._id,
                              'convertDocToFile'
                            )
                            callback(null, newFileRef, true, existingFile)
                          }
                        )
                      }
                    )
                  }
                )
              }
            )
          } else if (existingFile) {
            ProjectEntityUpdateHandler._replaceFile(
              projectId,
              existingFile._id,
              fsPath,
              linkedFileData,
              userId,
              newFileRef,
              fileStoreUrl,
              folderId,
              source,
              (err, newFileRef) => {
                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,
              source,
              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,
        source,
        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,
              source,
              callback
            )
          }
        )
      }
    },
    withLock(
      projectId,
      folderPath,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      fileRef,
      fileStoreUrl,
      source,
      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,
            source,
            (err, newFile, isNewFile, existingFile) => {
              if (err != null) {
                return callback(err)
              }
              callback(
                null,
                newFile,
                isNewFile,
                existingFile,
                newFolders,
                folder
              )
            }
          )
        }
      )
    },
  }),

  deleteEntity: wrapWithLock(function (
    projectId,
    entityId,
    entityType,
    userId,
    source,
    callback
  ) {
    logger.debug({ 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,
          source,
          (error, subtreeListing) => {
            if (error != null) {
              return callback(error)
            }
            const subtreeEntityIds = subtreeListing.map(entry =>
              entry.entity._id.toString()
            )
            TpdsUpdateSender.deleteEntity(
              {
                projectId,
                path: path.fileSystem,
                projectName: projectBeforeDeletion.name,
                entityId,
                entityType,
                subtreeEntityIds,
              },
              error => {
                if (error != null) {
                  return callback(error)
                }
                callback(null, entityId)
              }
            )
          }
        )
      }
    )
  }),

  deleteEntityWithPath: wrapWithLock(
    (projectId, path, userId, source, callback) =>
      ProjectLocator.findElementByPath(
        { project_id: projectId, path, exactCaseMatch: true },
        (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,
            source,
            callback
          )
        }
      )
  ),

  mkdirp: wrapWithLock(function (projectId, path, callback) {
    for (const 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 (const 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,
    source,
    callback
  ) {
    logger.debug(
      { 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()
    DocumentUpdaterHandler.flushProjectToMongo(projectId, err => {
      if (err) {
        return callback(err)
      }
      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
          TpdsUpdateSender.moveEntity(
            {
              projectId,
              projectName: project.name,
              startPath,
              endPath,
              rev,
              entityId,
              entityType,
              folderId: destFolderId,
            },
            err => {
              if (err) {
                logger.error({ err }, 'error sending tpds update')
              }
              DocumentUpdaterHandler.updateProjectStructure(
                projectId,
                projectHistoryId,
                userId,
                changes,
                source,
                callback
              )
            }
          )
        }
      )
    })
  }),

  renameEntity: wrapWithLock(function (
    projectId,
    entityId,
    entityType,
    newName,
    userId,
    source,
    callback
  ) {
    if (!newName || typeof newName !== 'string') {
      const err = new OError('invalid newName value', {
        value: newName,
        type: typeof newName,
        projectId,
        entityId,
        entityType,
        userId,
        source,
      })
      logger.error({ err }, 'Invalid newName passed to renameEntity')
      return callback(err)
    }
    if (!SafePath.isCleanFilename(newName)) {
      return callback(new Errors.InvalidNameError('invalid element name'))
    }
    logger.debug({ 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()

    DocumentUpdaterHandler.flushProjectToMongo(projectId, err => {
      if (err) {
        return callback(err)
      }
      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
          TpdsUpdateSender.moveEntity(
            {
              projectId,
              projectName: project.name,
              startPath,
              endPath,
              rev,
              entityId,
              entityType,
              folderId: null, // this means the folder has not changed
            },
            err => {
              if (err) {
                logger.error({ err }, 'error sending tpds update')
              }
              DocumentUpdaterHandler.updateProjectStructure(
                projectId,
                projectHistoryId,
                userId,
                changes,
                source,
                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)
          }

          let { docs, files, folders } =
            ProjectEntityHandler.getAllEntitiesFromProject(project)
          // _checkFileTree() must be passed the folders before docs and
          // files
          ProjectEntityUpdateHandler._checkFiletree(
            projectId,
            projectHistoryId,
            [...folders, ...docs, ...files],
            error => {
              if (error) {
                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),
                _hash: file.file.hash,
              }))

              DocumentUpdaterHandler.resyncProjectHistory(
                projectId,
                projectHistoryId,
                docs,
                files,
                callback
              )
            }
          )
        }
      ),
    LockManager.withTimeout(6 * 60) // use an extended lock for the resync operations
  ),

  _checkFiletree(projectId, projectHistoryId, entities, callback) {
    const adjustPathsAfterFolderRename = (oldPath, newPath) => {
      oldPath = oldPath + '/'
      newPath = newPath + '/'
      for (const entity of entities) {
        if (entity.path.startsWith(oldPath)) {
          entity.path = newPath + entity.path.slice(oldPath.length)
        }
      }
    }

    // Data structures for recording pending renames
    const renames = []
    const paths = new Set()
    for (const entity of entities) {
      const originalName = entity.folder
        ? entity.folder.name
        : entity.doc
        ? entity.doc.name
        : entity.file.name

      let newPath = entity.path
      let newName = originalName

      // Clean the filename if necessary
      if (newName === '') {
        newName = 'untitled'
      } else {
        newName = SafePath.clean(newName)
      }
      if (newName !== originalName) {
        newPath = Path.join(
          newPath.slice(0, newPath.length - originalName.length),
          newName
        )
      }

      // Check if we've seen that path already
      if (paths.has(newPath)) {
        newPath = ProjectEntityUpdateHandler.findNextAvailablePath(
          paths,
          newPath
        )
        newName = newPath.split('/').pop()
      }

      // If we've changed the filename, schedule a rename
      if (newName !== originalName) {
        renames.push({ entity, newName, newPath })
        if (entity.folder) {
          // Here, we rely on entities being processed in the right order.
          // Parent folders need to be processed before their children. This is
          // the case only because getAllEntitiesFromProject() returns folders
          // in that order and resyncProjectHistory() calls us with the folders
          // first.
          adjustPathsAfterFolderRename(entity.path, newPath)
        }
      }

      // Remember that we've seen this path
      paths.add(newPath)
    }

    if (renames.length === 0) {
      return callback()
    }
    logger.warn(
      {
        projectId,
        renames: renames.map(rename => ({
          oldPath: rename.entity.path,
          newPath: rename.newPath,
        })),
      },
      'found conflicts or bad filenames in filetree'
    )

    // rename the duplicate files
    const doRename = (rename, cb) => {
      const entity = rename.entity
      const entityId = entity.folder
        ? entity.folder._id
        : entity.doc
        ? entity.doc._id
        : entity.file._id
      const entityType = entity.folder ? 'folder' : entity.doc ? 'doc' : 'file'
      ProjectEntityMongoUpdateHandler.renameEntity(
        projectId,
        entityId,
        entityType,
        rename.newName,
        (err, project, startPath, endPath, rev, changes) => {
          if (err) {
            return cb(err)
          }
          // update the renamed entity for the resync
          entity.path = rename.newPath
          if (entityType === 'folder') {
            entity.folder.name = rename.newName
          } else if (entityType === 'doc') {
            entity.doc.name = rename.newName
          } else {
            entity.file.name = rename.newName
          }
          DocumentUpdaterHandler.updateProjectStructure(
            projectId,
            projectHistoryId,
            null,
            changes,
            'automatic-fix',
            cb
          )
        }
      )
    }

    // Avoid conflicts by processing renames in the reverse order. If we have
    // the following starting situation:
    //
    // somefile.tex
    // somefile.tex
    // somefile.tex (1)
    //
    // somefile.tex would be processed first, and then somefile.tex (1),
    // yielding the following renames:
    //
    // somefile.tex -> somefile.tex (1)
    // somefile.tex (1) -> somefile.tex (2)
    //
    // When the first rename was decided, we didn't know that somefile.tex (1)
    // existed, so that created a conflict. By processing renames in the
    // reverse order, we start with the files that had the most extensive
    // information about existing files.
    renames.reverse()

    async.eachSeries(renames, doRename, callback)
  },

  findNextAvailablePath(allPaths, candidatePath) {
    const incrementReplacer = (match, p1) => {
      return ' (' + (parseInt(p1, 10) + 1) + ')'
    }
    // if the filename was invalid we should normalise it here too.  Currently
    // this only handles renames in the same folder, so we will be out of luck
    // if it is the folder name which in invalid.  We could handle folder
    // renames by returning the folders list from getAllEntitiesFromProject
    do {
      // does the filename look like "foo (1)" if so, increment the number in parentheses
      if (/ \(\d+\)$/.test(candidatePath)) {
        candidatePath = candidatePath.replace(/ \((\d+)\)$/, incrementReplacer)
      } else {
        // otherwise, add a ' (1)' suffix to the name
        candidatePath = candidatePath + ' (1)'
      }
    } while (allPaths.has(candidatePath)) // keep going until the name is unique
    // add the new name to the set
    allPaths.add(candidatePath)
    return candidatePath
  },

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

  _cleanUpEntity(
    project,
    newProject,
    entity,
    entityType,
    path,
    userId,
    source,
    callback
  ) {
    const subtreeListing = _listSubtree(entity, entityType, path)
    ProjectEntityUpdateHandler._updateProjectStructureWithDeletedEntity(
      project,
      newProject,
      subtreeListing,
      userId,
      source,
      error => {
        if (error != null) {
          return callback(error)
        }
        const jobs = []

        for (const entry of subtreeListing) {
          if (entry.type === 'doc') {
            jobs.push(cb => {
              ProjectEntityUpdateHandler._cleanUpDoc(
                project,
                entry.entity,
                entry.path,
                userId,
                cb
              )
            })
          } else if (entry.type === 'file') {
            jobs.push(cb => {
              ProjectEntityUpdateHandler._cleanUpFile(
                project,
                entry.entity,
                entry.path,
                userId,
                cb
              )
            })
          }
        }
        async.series(jobs, err => {
          if (err) {
            return callback(err)
          }
          callback(null, subtreeListing)
        })
      }
    )
  },

  _updateProjectStructureWithDeletedEntity(
    project,
    newProject,
    subtreeListing,
    userId,
    source,
    callback
  ) {
    const changes = { oldDocs: [], oldFiles: [] }
    for (const entry of subtreeListing) {
      if (entry.type === 'doc') {
        changes.oldDocs.push({ doc: entry.entity, path: entry.path })
      } else if (entry.type === 'file') {
        changes.oldFiles.push({ file: entry.entity, path: entry.path })
      }
    }

    // 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,
      source,
      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
    )
  },

  convertDocToFile: wrapWithLock({
    beforeLock(next) {
      return function (projectId, docId, userId, source, 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,
                                source,
                                callback
                              )
                            })
                          }
                        )
                      }
                    )
                  })
                }
              )
            }
          )
        })
      }
    },
    withLock(
      projectId,
      doc,
      path,
      fileRef,
      fileStoreUrl,
      userId,
      source,
      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,
            },
            source,
            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)
                }
              )
            }
          )
        }
      )
    },
  }),
}

/**
 * List all descendants of an entity along with their type and path. Include
 * the top-level entity as well.
 */
function _listSubtree(entity, entityType, entityPath) {
  if (entityType.indexOf('file') !== -1) {
    return [{ type: 'file', entity, path: entityPath }]
  } else if (entityType.indexOf('doc') !== -1) {
    return [{ type: 'doc', entity, path: entityPath }]
  } else if (entityType.indexOf('folder') !== -1) {
    const listing = []
    const _recurseFolder = (folder, folderPath) => {
      listing.push({ type: 'folder', entity: folder, path: folderPath })
      for (const doc of iterablePaths(folder, 'docs')) {
        listing.push({
          type: 'doc',
          entity: doc,
          path: Path.join(folderPath, doc.name),
        })
      }
      for (const file of iterablePaths(folder, 'fileRefs')) {
        listing.push({
          type: 'file',
          entity: file,
          path: Path.join(folderPath, file.name),
        })
      }
      for (const childFolder of iterablePaths(folder, 'folders')) {
        _recurseFolder(childFolder, Path.join(folderPath, childFolder.name))
      }
    }
    _recurseFolder(entity, entityPath)
    return listing
  } else {
    // This shouldn't happen, but if it does, fail silently.
    return []
  }
}

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'],
  },
})
