import _ from 'lodash'
import OError from '@overleaf/o-error'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import Path from 'node:path'
import fs from 'node:fs'
import { Doc } from '../../models/Doc.mjs'
import DocstoreManager from '../../Features/Docstore/DocstoreManager.mjs'
import DocumentUpdaterHandler from '../../Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
import Errors from '../Errors/Errors.js'
import FileStoreHandler from '../FileStore/FileStoreHandler.mjs'
import LockManager from '../../infrastructure/LockManager.mjs'
import { Project } from '../../models/Project.mjs'
import ProjectEntityHandler from './ProjectEntityHandler.mjs'
import ProjectGetter from './ProjectGetter.mjs'
import ProjectLocator from './ProjectLocator.mjs'
import ProjectOptionsHandler from './ProjectOptionsHandler.mjs'
import ProjectUpdateHandler from './ProjectUpdateHandler.mjs'
import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.mjs'
import SafePath from './SafePath.mjs'
import TpdsUpdateSender from '../ThirdPartyDataStore/TpdsUpdateSender.mjs'
import FileWriter from '../../infrastructure/FileWriter.mjs'
import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
import { callbackifyMultiResult, callbackify } from '@overleaf/promise-utils'
import { iterablePaths } from './IterablePath.mjs'

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 = async (projectId, ...rest) => {
      return lockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
        methodWithoutLock(projectId, ...rest)
      )
    }
    methodWithLock.withoutLock = methodWithoutLock
    return methodWithLock
  } else {
    // handle case with separate setup and locked stages
    const mainTask = methodWithoutLock.withLock
    const methodWithLock = async (projectId, ...rest) => {
      const arg = await methodWithoutLock.beforeLock(projectId, ...rest)
      return lockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
        mainTask(arg)
      )
    }
    methodWithLock.withoutLock = async (...args) => {
      return await mainTask(await methodWithoutLock.beforeLock(...args))
    }
    methodWithLock.beforeLock = methodWithoutLock.beforeLock
    methodWithLock.mainTask = methodWithoutLock.withLock
    return methodWithLock
  }
}

async function getDocContext(projectId, docId) {
  let project
  try {
    project = await ProjectGetter.promises.getProject(projectId, {
      name: true,
      rootFolder: true,
    })
  } catch (err) {
    throw OError.tag(err, 'error fetching project', {
      projectId,
    })
  }

  if (!project) {
    throw new Errors.NotFoundError('project not found')
  }
  try {
    const { path, folder } = await ProjectLocator.promises.findElement({
      project,
      element_id: docId,
      type: 'docs',
    })
    return {
      projectName: project.name,
      isDeletedDoc: false,
      path: path.fileSystem,
      folder,
    }
  } catch (err) {
    if (err instanceof Errors.NotFoundError) {
      // (Soft-)Deleted docs are removed from the file-tree (rootFolder).
      // docstore can tell whether it exists and is (soft)-deleted.
      let isDeletedDoc
      try {
        isDeletedDoc = await DocstoreManager.promises.isDocDeleted(
          projectId,
          docId
        )
        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'
          )
        }
        return {
          projectName: project.name,
          isDeletedDoc: true,
          path: null,
          folder: null,
        }
      } catch (error) {
        if (error instanceof Errors.NotFoundError) {
          logger.warn(
            { projectId, docId },
            'doc not found while updating doc lines'
          )
          throw error
        }
        throw OError.tag(
          error,
          'error checking deletion status with docstore',
          {
            projectId,
            docId,
          }
        )
      }
    } else {
      throw OError.tag(err, 'error finding doc in rootFolder', {
        docId,
        projectId,
      })
    }
  }
}

async function updateDocLines(
  projectId,
  docId,
  lines,
  version,
  ranges,
  lastUpdatedAt,
  lastUpdatedBy
) {
  let ctx
  try {
    ctx = await getDocContext(projectId, docId)
  } catch (error) {
    if (error 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'
      )
    }

    throw error
  }
  const { projectName, isDeletedDoc, path, folder } = ctx
  logger.debug({ projectId, docId }, 'telling docstore manager to update doc')
  let modified, rev
  try {
    ;({ modified, rev } = await DocstoreManager.promises.updateDoc(
      projectId,
      docId,
      lines,
      version,
      ranges
    ))
  } catch (err) {
    throw OError.tag(err, 'error sending doc to docstore', { docId, projectId })
  }
  // path will only be present if the doc is not deleted
  if (!modified || isDeletedDoc) {
    return { rev }
  }
  // Don't need to block for marking as updated
  ProjectUpdateHandler.promises
    .markAsUpdated(projectId, lastUpdatedAt, lastUpdatedBy)
    .catch(error => {
      logger.error({ error }, 'failed to mark project as updated')
    })
  await TpdsUpdateSender.promises.addDoc({
    projectId,
    path,
    docId,
    projectName,
    rev,
    folderId: folder?._id,
  })
  return { rev, modified }
}

async function setRootDoc(projectId, rootDocId) {
  logger.debug({ projectId, rootDocId }, 'setting root doc')
  if (projectId == null || rootDocId == null) {
    throw new Errors.InvalidError('missing arguments (project or doc)')
  }
  const rootResourcePath =
    await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
      projectId,
      rootDocId
    )
  if (ProjectEntityUpdateHandler.isPathValidForRootDoc(rootResourcePath)) {
    await Project.updateOne(
      { _id: projectId },
      { rootDoc_id: rootDocId }
    ).exec()
  } else {
    throw new Errors.UnsupportedFileTypeError(
      'invalid file extension for root doc'
    )
  }
  return { rootDocId, rootResourcePath }
}

async function unsetRootDoc(projectId) {
  logger.debug({ projectId }, 'removing root doc')
  await Project.updateOne(
    { _id: projectId },
    { $unset: { rootDoc_id: true } }
  ).exec()
}

async function addDoc(projectId, folderId, docName, docLines, userId, source) {
  return await ProjectEntityUpdateHandler.promises.addDocWithRanges(
    projectId,
    folderId,
    docName,
    docLines,
    {},
    userId,
    source
  )
}

const addDocWithRanges = wrapWithLock({
  async beforeLock(
    projectId,
    folderId,
    docName,
    docLines,
    ranges,
    userId,
    source
  ) {
    if (!SafePath.isCleanFilename(docName)) {
      throw 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 })
    const { rev } = await DocstoreManager.promises.updateDoc(
      projectId.toString(),
      doc._id.toString(),
      docLines,
      0,
      ranges
    )

    doc.rev = rev
    return {
      projectId,
      folderId,
      doc,
      docName,
      docLines,
      ranges,
      userId,
      source,
    }
  },
  async withLock({
    projectId,
    folderId,
    doc,
    docName,
    docLines,
    ranges,
    userId,
    source,
  }) {
    const { result, project } =
      await ProjectEntityUpdateHandler._addDocAndSendToTpds(
        projectId,
        folderId,
        doc,
        userId
      )
    const docPath = result?.path?.fileSystem
    const projectHistoryId = project?.overleaf?.history?.id
    const newDocs = [
      {
        doc,
        path: docPath,
        docLines: docLines.join('\n'),
        ranges,
      },
    ]
    await DocumentUpdaterHandler.promises.updateProjectStructure(
      projectId,
      projectHistoryId,
      userId,
      { newDocs, newProject: project },
      source
    )
    return { doc, folderId: folderId || project.rootFolder[0]._id }
  },
})

const addFile = wrapWithLock({
  async beforeLock(
    projectId,
    folderId,
    fileName,
    fsPath,
    linkedFileData,
    userId,
    source
  ) {
    if (!SafePath.isCleanFilename(fileName)) {
      throw new Errors.InvalidNameError('invalid element name')
    }
    const { fileRef, createdBlob } =
      await ProjectEntityUpdateHandler._uploadFile(
        projectId,
        folderId,
        fileName,
        fsPath,
        linkedFileData
      )

    return {
      projectId,
      folderId,
      userId,
      fileRef,
      createdBlob,
      source,
    }
  },
  async withLock({
    projectId,
    folderId,
    userId,
    fileRef,
    createdBlob,
    source,
  }) {
    const { result, project } =
      await ProjectEntityUpdateHandler._addFileAndSendToTpds(
        projectId,
        folderId,
        fileRef,
        userId
      )
    const projectHistoryId = project.overleaf?.history?.id
    const newFiles = [
      {
        createdBlob,
        file: fileRef,
        path: result && result.path && result.path.fileSystem,
      },
    ]
    await DocumentUpdaterHandler.promises.updateProjectStructure(
      projectId,
      projectHistoryId,
      userId,
      { newFiles, newProject: project },
      source
    )
    return { fileRef, folderId, createdBlob }
  },
})

const upsertDoc = wrapWithLock(
  async function (projectId, folderId, docName, docLines, source, userId) {
    if (!SafePath.isCleanFilename(docName)) {
      throw new Errors.InvalidNameError('invalid element name')
    }
    let element, folderPath
    try {
      ;({ element, path: folderPath } =
        await ProjectLocator.promises.findElement({
          project_id: projectId,
          element_id: folderId,
          type: 'folder',
        }))
    } catch (error) {
      if (error instanceof Errors.NotFoundError) {
        throw new Error('folder_not_found')
      }
      throw error
    }

    if (element == null) {
      throw new Error("Couldn't find folder")
    }

    const existingDoc = element.docs.find(({ name }) => name === docName)
    const existingFile = element.fileRefs.find(({ name }) => name === docName)
    if (existingFile) {
      const doc = new Doc({ name: docName })
      const filePath = `${folderPath.fileSystem}/${existingFile.name}`
      const { rev } = await DocstoreManager.promises.updateDoc(
        projectId.toString(),
        doc._id.toString(),
        docLines,
        0,
        {}
      )

      doc.rev = rev
      const project =
        await ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc(
          projectId,
          existingFile._id,
          doc,
          userId
        )

      await TpdsUpdateSender.promises.addDoc({
        projectId,
        docId: doc._id,
        path: filePath,
        projectName: project.name,
        rev: existingFile.rev + 1,
        folderId,
      })

      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,
        },
      ]
      await DocumentUpdaterHandler.promises.updateProjectStructure(
        projectId,
        projectHistoryId,
        userId,
        { oldFiles, newDocs, newProject: project },
        source
      )

      EditorRealTimeController.emitToRoom(
        projectId,
        'removeEntity',
        existingFile._id,
        'convertFileToDoc'
      )
      return { doc, isNew: true }
    } else if (existingDoc) {
      const result = await DocumentUpdaterHandler.promises.setDocument(
        projectId,
        existingDoc._id,
        userId,
        docLines,
        source
      )
      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
      return {
        doc: { ...existingDoc, ...result },
        isNew: existingDoc == null,
      }
    } else {
      const { doc } =
        await ProjectEntityUpdateHandler.promises.addDocWithRanges.withoutLock(
          projectId,
          folderId,
          docName,
          docLines,
          {},
          userId,
          source
        )

      return { doc, isNew: existingDoc == null }
    }
  }
)

const appendToDoc = wrapWithLock(
  async (projectId, docId, lines, source, userId) => {
    const { element } = await ProjectLocator.promises.findElement({
      project_id: projectId,
      element_id: docId,
      type: 'doc',
    })

    return await DocumentUpdaterHandler.promises.appendToDocument(
      projectId,
      element._id,
      userId,
      lines,
      source
    )
  }
)

const upsertFile = wrapWithLock({
  async beforeLock(
    projectId,
    folderId,
    fileName,
    fsPath,
    linkedFileData,
    userId,
    source
  ) {
    if (!SafePath.isCleanFilename(fileName)) {
      throw new Errors.InvalidNameError('invalid element name')
    }
    // create a new file
    const fileArgs = {
      name: fileName,
      linkedFileData,
    }
    const { fileRef, createdBlob } =
      await FileStoreHandler.promises.uploadFileFromDisk(
        projectId,
        fileArgs,
        fsPath
      )

    return {
      projectId,
      folderId,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      fileRef,
      createdBlob,
      source,
    }
  },
  async withLock({
    projectId,
    folderId,
    fileName,
    userId,
    fileRef,
    createdBlob,
    source,
  }) {
    let element
    try {
      ;({ element } = await ProjectLocator.promises.findElement({
        project_id: projectId,
        element_id: folderId,
        type: 'folder',
      }))
    } catch (error) {
      if (error instanceof Errors.NotFoundError) {
        throw new Error('folder_not_found')
      }
      throw error
    }

    if (element == null) {
      throw new Error("Couldn't find folder")
    }
    const existingFile = element.fileRefs.find(({ name }) => name === fileName)
    const existingDoc = element.docs.find(({ name }) => name === fileName)

    if (existingDoc) {
      let path
      try {
        ;({ path } = await ProjectLocator.promises.findElement({
          project_id: projectId,
          element_id: existingDoc._id,
          type: 'doc',
        }))
      } catch (err) {
        throw new Error("couldn't find existing file")
      }
      const project =
        await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
          projectId,
          existingDoc._id,
          fileRef,
          userId
        )
      const projectHistoryId = project.overleaf?.history?.id
      await TpdsUpdateSender.promises.addFile({
        projectId: project._id,
        historyId: projectHistoryId,
        fileId: fileRef._id,
        hash: fileRef.hash,
        path: path.fileSystem,
        rev: fileRef.rev,
        projectName: project.name,
        folderId,
      })
      await DocumentUpdaterHandler.promises.updateProjectStructure(
        projectId,
        projectHistoryId,
        userId,
        {
          oldDocs: [{ doc: existingDoc, path: path.fileSystem }],

          newFiles: [
            {
              createdBlob,
              file: fileRef,
              path: path.fileSystem,
            },
          ],
          newProject: project,
        },
        source
      )
      EditorRealTimeController.emitToRoom(
        projectId,
        'removeEntity',
        existingDoc._id,
        'convertDocToFile'
      )
      return { fileRef, isNew: true, oldFileRef: existingFile }
    } else if (existingFile) {
      await ProjectEntityUpdateHandler._replaceFile(
        projectId,
        existingFile._id,
        userId,
        fileRef,
        folderId,
        source,
        createdBlob
      )

      return { fileRef, isNew: false, oldFileRef: existingFile }
    } else {
      // this calls directly into the addFile main task (without the beforeLock part)
      await ProjectEntityUpdateHandler.promises.addFile.mainTask({
        projectId,
        folderId,
        userId,
        fileRef,
        createdBlob,
        source,
      })

      return {
        fileRef,
        isNew: existingFile == null,
        oldFileRef: existingFile,
      }
    }
  },
})

const upsertDocWithPath = wrapWithLock(
  async function (projectId, elementPath, docLines, source, userId) {
    if (!SafePath.isCleanPath(elementPath)) {
      throw new Errors.InvalidNameError('invalid element name')
    }
    const docName = Path.basename(elementPath)
    const folderPath = Path.dirname(elementPath)
    const { newFolders, folder } =
      await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
        projectId,
        folderPath,
        userId
      )
    const { isNew, doc } =
      await ProjectEntityUpdateHandler.promises.upsertDoc.withoutLock(
        projectId,
        folder._id,
        docName,
        docLines,
        source,
        userId
      )

    return { doc, isNew, newFolders, folder }
  }
)

const upsertFileWithPath = wrapWithLock({
  async beforeLock(
    projectId,
    elementPath,
    fsPath,
    linkedFileData,
    userId,
    source
  ) {
    if (!SafePath.isCleanPath(elementPath)) {
      throw 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,
    }
    const { fileRef, createdBlob } =
      await FileStoreHandler.promises.uploadFileFromDisk(
        projectId,
        fileArgs,
        fsPath
      )

    return {
      projectId,
      folderPath,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      fileRef,
      createdBlob,
      source,
    }
  },
  async withLock({
    projectId,
    folderPath,
    fileName,
    fsPath,
    linkedFileData,
    userId,
    fileRef,
    createdBlob,
    source,
  }) {
    const { newFolders, folder } =
      await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
        projectId,
        folderPath,
        userId
      )
    // this calls directly into the upsertFile main task (without the beforeLock part)
    const {
      fileRef: newFileRef,
      isNew,
      oldFileRef,
    } = await ProjectEntityUpdateHandler.promises.upsertFile.mainTask({
      projectId,
      folderId: folder._id,
      fileName,
      fsPath,
      linkedFileData,
      userId,
      fileRef,
      createdBlob,
      source,
    })

    return {
      fileRef: newFileRef,
      isNew,
      oldFileRef,
      newFolders,
      folder,
    }
  },
})

const deleteEntity = wrapWithLock(
  async 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 })
      throw new Error('No entityType set')
    }
    entityType = entityType.toLowerCase()

    // Flush the entire project to avoid leaving partially deleted docs in redis.
    await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)

    const { entity, path, projectBeforeDeletion, newProject } =
      await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
        projectId,
        entityId,
        entityType,
        userId
      )
    const subtreeListing = await ProjectEntityUpdateHandler._cleanUpEntity(
      projectBeforeDeletion,
      newProject,
      entity,
      entityType,
      path.fileSystem,
      userId,
      source
    )

    const subtreeEntityIds = subtreeListing.map(entry =>
      entry.entity._id.toString()
    )
    await TpdsUpdateSender.promises.deleteEntity({
      projectId,
      path: path.fileSystem,
      projectName: projectBeforeDeletion.name,
      entityId,
      entityType,
      subtreeEntityIds,
    })

    return entityId
  }
)

const deleteEntityWithPath = wrapWithLock(
  async (projectId, path, userId, source) => {
    const { element, type } = await ProjectLocator.promises.findElementByPath({
      project_id: projectId,
      path,
      exactCaseMatch: true,
    })
    if (element == null) {
      throw new Errors.NotFoundError('project not found')
    }
    return await ProjectEntityUpdateHandler.promises.deleteEntity.withoutLock(
      projectId,
      element._id,
      type,
      userId,
      source
    )
  }
)

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

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

const addFolder = wrapWithLock(
  async function (projectId, parentFolderId, folderName, userId) {
    if (!SafePath.isCleanFilename(folderName)) {
      throw new Errors.InvalidNameError('invalid element name')
    }
    return await ProjectEntityMongoUpdateHandler.promises.addFolder(
      projectId,
      parentFolderId,
      folderName,
      userId
    )
  }
)

const moveEntity = wrapWithLock(
  async function (
    projectId,
    entityId,
    destFolderId,
    entityType,
    userId,
    source
  ) {
    logger.debug(
      { entityType, entityId, projectId, destFolderId },
      'moving entity'
    )
    if (entityType == null) {
      logger.warn({ err: 'No entityType set', projectId, entityId })
      throw new Error('No entityType set')
    }
    entityType = entityType.toLowerCase()
    await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
    const { project, startPath, endPath, rev, changes } =
      await ProjectEntityMongoUpdateHandler.promises.moveEntity(
        projectId,
        entityId,
        destFolderId,
        entityType,
        userId
      )

    const projectHistoryId = project.overleaf?.history?.id
    try {
      await TpdsUpdateSender.promises.moveEntity({
        projectId,
        projectName: project.name,
        startPath,
        endPath,
        rev,
        entityId,
        entityType,
        folderId: destFolderId,
      })
    } catch (err) {
      logger.error({ err }, 'error sending tpds update')
    }

    return await DocumentUpdaterHandler.promises.updateProjectStructure(
      projectId,
      projectHistoryId,
      userId,
      changes,
      source
    )
  }
)

const renameEntity = wrapWithLock(
  async function (projectId, entityId, entityType, newName, userId, source) {
    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')
      throw err
    }
    if (!SafePath.isCleanFilename(newName)) {
      throw new Errors.InvalidNameError('invalid element name')
    }
    logger.debug({ entityId, projectId }, `renaming ${entityType}`)
    if (entityType == null) {
      logger.warn({ err: 'No entityType set', projectId, entityId })
      throw new Error('No entityType set')
    }
    entityType = entityType.toLowerCase()
    await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
    const { project, startPath, endPath, rev, changes } =
      await ProjectEntityMongoUpdateHandler.promises.renameEntity(
        projectId,
        entityId,
        entityType,
        newName,
        userId
      )

    const projectHistoryId = project.overleaf?.history?.id
    try {
      await TpdsUpdateSender.promises.moveEntity({
        projectId,
        projectName: project.name,
        startPath,
        endPath,
        rev,
        entityId,
        entityType,
        folderId: null, // this means the folder has not changed
      })
    } catch (err) {
      logger.error({ err }, 'error sending tpds update')
    }
    return await DocumentUpdaterHandler.promises.updateProjectStructure(
      projectId,
      projectHistoryId,
      userId,
      changes,
      source
    )
  }
)

// This doesn't directly update project structure, but we need to take the lock
// to prevent anything else being queued before the resync update
const resyncProjectHistory = wrapWithLock(
  async (projectId, opts) => {
    const project = await ProjectGetter.promises.getProject(projectId, {
      rootFolder: true,
      overleaf: true,
    })
    const projectHistoryId = project.overleaf?.history?.id
    if (projectHistoryId == null) {
      throw new Errors.ProjectHistoryDisabledError(
        `project history not enabled for ${projectId}`
      )
    }

    const { docs, files, folders } =
      ProjectEntityHandler.getAllEntitiesFromProject(project)
    // _checkFileTree() must be passed the folders before docs and
    // files
    await ProjectEntityUpdateHandler._checkFiletree(
      projectId,
      projectHistoryId,
      [...folders, ...docs, ...files]
    )

    await DocumentUpdaterHandler.promises.resyncProjectHistory(
      projectId,
      projectHistoryId,
      docs,
      files,
      opts
    )
    if (opts.historyRangesMigration) {
      return await ProjectOptionsHandler.promises.setHistoryRangesSupport(
        projectId,
        opts.historyRangesMigration === 'forwards'
      )
    }
  },
  LockManager.withTimeout(6 * 60) // use an extended lock for the resync operations
)

const convertDocToFile = wrapWithLock(
  async (projectId, docId, userId, source) => {
    const { lines, ranges } = await DocumentUpdaterHandler.promises.getDocument(
      projectId,
      docId,
      -1
    )
    if (!_.isEmpty(ranges)) {
      throw new Errors.DocHasRangesError({})
    }

    const {
      folder,
      element: doc,
      path: { fileSystem: path },
    } = await ProjectLocator.promises.findElement({
      project_id: projectId,
      element_id: docId,
      type: 'doc',
    })

    const fsPath = await FileWriter.promises.writeLinesToDisk(projectId, lines)
    const { fileRef, createdBlob } =
      await FileStoreHandler.promises.uploadFileFromDisk(
        projectId,
        { name: doc.name },
        fsPath
      )
    try {
      await fs.promises.unlink(fsPath)
    } catch (err) {
      logger.warn({ err, path: fsPath }, 'failed to clean up temporary file')
    }

    const project =
      await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
        projectId,
        doc._id,
        fileRef,
        userId
      )
    const projectHistoryId = project.overleaf?.history?.id
    await DocumentUpdaterHandler.promises.updateProjectStructure(
      projectId,
      projectHistoryId,
      userId,
      {
        oldDocs: [{ doc, path }],
        newFiles: [{ file: fileRef, path, createdBlob }],
        newProject: project,
      },
      source
    )

    EditorRealTimeController.emitToRoom(
      projectId,
      'removeEntity',
      doc._id,
      'convertDocToFile'
    )
    EditorRealTimeController.emitToRoom(
      projectId,
      'reciveNewFile',
      folder._id,
      fileRef,
      'convertDocToFile',
      null,
      userId
    )

    try {
      // Mark doc as deleted in docstore
      await DocstoreManager.promises.deleteDoc(
        projectId,
        docId,
        Path.basename(path),
        new Date()
      )
    } catch (err) {
      logger.warn(
        { err, projectId, docId, path },
        'failed to mark converted doc as deleted in docstore, continuing with document-updater cleanup'
      )
    } finally {
      // Hard delete document in redis now that it has been successfully converted to a file
      await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId, true)
    }

    return fileRef
  }
)

async function setMainBibliographyDoc(projectId, newBibliographyDocId) {
  logger.debug(
    { projectId, mainBibliographyDocId: newBibliographyDocId },
    'setting main bibliography doc'
  )
  if (projectId == null || newBibliographyDocId == null) {
    throw new Errors.InvalidError('missing arguments (project or doc)')
  }
  const docPath =
    await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
      projectId,
      newBibliographyDocId
    )
  if (ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc(docPath)) {
    await Project.updateOne(
      { _id: projectId },
      { mainBibliographyDoc_id: newBibliographyDocId }
    ).exec()
  } else {
    throw new Errors.UnsupportedFileTypeError(
      'invalid file extension for main bibliography doc'
    )
  }
}

const ProjectEntityUpdateHandler = {
  LOCK_NAMESPACE,

  addDoc: callbackifyMultiResult(addDoc, ['doc', 'folderId']),

  addDocWithRanges: callbackifyMultiResult(addDocWithRanges, [
    'doc',
    'folderId',
  ]),

  addFile: callbackifyMultiResult(addFile, [
    'fileRef',
    'folderId',
    'createdBlob',
  ]),

  addFolder: callbackifyMultiResult(addFolder, ['folder', 'parentFolderId']),

  convertDocToFile: callbackify(convertDocToFile),

  deleteEntity: callbackify(deleteEntity),

  deleteEntityWithPath: callbackify(deleteEntityWithPath),

  mkdirp: callbackifyMultiResult(mkdirp, [
    'newFolders',
    'folder',
    'parentFolder',
  ]),

  mkdirpWithExactCase: callbackifyMultiResult(mkdirpWithExactCase, [
    'newFolders',
    'folder',
    'parentFolder',
  ]),

  moveEntity: callbackify(moveEntity),

  renameEntity: callbackify(renameEntity),

  resyncProjectHistory: callbackify(resyncProjectHistory),

  setRootDoc: callbackify(setRootDoc),

  unsetRootDoc: callbackify(unsetRootDoc),

  setMainBibliographyDoc: callbackify(setMainBibliographyDoc),

  updateDocLines: callbackify(updateDocLines),

  upsertDoc: callbackifyMultiResult(upsertDoc, ['doc', 'isNew']),

  appendToDoc: callbackify(appendToDoc),

  upsertDocWithPath: callbackifyMultiResult(upsertDocWithPath, [
    'doc',
    'isNew',
    'newFolders',
    'folder',
  ]),

  upsertFile: callbackifyMultiResult(upsertFile, [
    'fileRef',
    'isNew',
    'oldFileRef',
  ]),

  upsertFileWithPath: callbackifyMultiResult(upsertFileWithPath, [
    'fileRef',
    'isNew',
    'oldFileRef',
    'newFolders',
    'folder',
  ]),

  promises: {
    addDoc,
    addDocWithRanges,
    addFile,
    addFolder,
    convertDocToFile,
    deleteEntity,
    deleteEntityWithPath,
    mkdirp,
    mkdirpWithExactCase,
    moveEntity,
    renameEntity,
    resyncProjectHistory,
    setRootDoc,
    unsetRootDoc,
    updateDocLines,
    upsertDoc,
    upsertDocWithPath,
    upsertFile,
    upsertFileWithPath,
    appendToDocWithPath: appendToDoc,
    setMainBibliographyDoc,
  },

  async _addDocAndSendToTpds(projectId, folderId, doc, userId) {
    let result, project
    try {
      ;({ result, project } =
        await ProjectEntityMongoUpdateHandler.promises.addDoc(
          projectId,
          folderId,
          doc,
          userId
        ))
    } catch (err) {
      throw OError.tag(err, 'error adding file with project', {
        projectId,
        folderId,
        doc_name: doc != null ? doc.name : undefined,
        doc_id: doc != null ? doc._id : undefined,
      })
    }

    await TpdsUpdateSender.promises.addDoc({
      projectId,
      docId: doc != null ? doc._id : undefined,
      path: result?.path?.fileSystem,
      projectName: project.name,
      rev: 0,
      folderId,
    })
    return { result, project }
  },

  async _uploadFile(projectId, folderId, fileName, fsPath, linkedFileData) {
    if (!SafePath.isCleanFilename(fileName)) {
      throw new Errors.InvalidNameError('invalid element name')
    }
    const fileArgs = {
      name: fileName,
      linkedFileData,
    }
    try {
      return await FileStoreHandler.promises.uploadFileFromDisk(
        projectId,
        fileArgs,
        fsPath
      )
    } catch (err) {
      throw OError.tag(err, 'error uploading image to s3', {
        projectId,
        folderId,
        file_name: fileName,
      })
    }
  },

  async _addFileAndSendToTpds(projectId, folderId, fileRef, userId) {
    let result, project
    try {
      ;({ result, project } =
        await ProjectEntityMongoUpdateHandler.promises.addFile(
          projectId,
          folderId,
          fileRef,
          userId
        ))
    } catch (err) {
      throw OError.tag(err, 'error adding file with project', {
        projectId,
        folderId,
        file_name: fileRef.name,
        fileRef,
      })
    }

    const historyId = project?.overleaf?.history?.id
    if (!historyId) {
      throw new OError('project does not have a history id', { projectId })
    }
    await TpdsUpdateSender.promises.addFile({
      projectId,
      historyId,
      fileId: fileRef._id,
      hash: fileRef.hash,
      path: result?.path?.fileSystem,
      projectName: project.name,
      rev: fileRef.rev,
      folderId,
    })
    return { result, project }
  },

  async _replaceFile(
    projectId,
    fileId,
    userId,
    newFileRef,
    folderId,
    source,
    createdBlob
  ) {
    const {
      oldFileRef,
      project,
      path,
      newProject,
      newFileRef: updatedFileRef,
    } = await ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew(
      projectId,
      fileId,
      newFileRef,
      userId
    )

    const oldFiles = [
      {
        file: oldFileRef,
        path: path.fileSystem,
      },
    ]
    const newFiles = [
      {
        file: updatedFileRef,
        createdBlob,
        path: path.fileSystem,
      },
    ]
    const projectHistoryId = project.overleaf?.history?.id
    await TpdsUpdateSender.promises.addFile({
      projectId: project._id,
      historyId: projectHistoryId,
      fileId: updatedFileRef._id,
      hash: updatedFileRef.hash,
      path: path.fileSystem,
      rev: updatedFileRef.rev,
      projectName: project.name,
      folderId,
    })

    await DocumentUpdaterHandler.promises.updateProjectStructure(
      projectId,
      projectHistoryId,
      userId,
      { oldFiles, newFiles, newProject },
      source
    )

    return updatedFileRef
  },

  async _checkFiletree(projectId, projectHistoryId, entities) {
    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
    }
    logger.warn(
      {
        projectId,
        renames: renames.map(rename => ({
          oldPath: rename.entity.path,
          newPath: rename.newPath,
        })),
      },
      'found conflicts or bad filenames in filetree'
    )

    // 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()

    for (const rename of renames) {
      // rename the duplicate files
      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'
      const { changes } =
        await ProjectEntityMongoUpdateHandler.promises.renameEntity(
          projectId,
          entityId,
          entityType,
          rename.newName,
          null // unset lastUpdatedBy
        )

      // 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
      }
      await DocumentUpdaterHandler.promises.updateProjectStructure(
        projectId,
        projectHistoryId,
        null,
        changes,
        'automatic-fix'
      )
    }
  },

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

  isPathValidForMainBibliographyDoc(docPath) {
    const docExtension = Path.extname(docPath).toLowerCase()
    return docExtension === '.bib'
  },

  async _cleanUpEntity(
    project,
    newProject,
    entity,
    entityType,
    path,
    userId,
    source
  ) {
    const subtreeListing = _listSubtree(entity, entityType, path)
    await ProjectEntityUpdateHandler._updateProjectStructureWithDeletedEntity(
      project,
      newProject,
      subtreeListing,
      userId,
      source
    )

    for (const entry of subtreeListing) {
      if (entry.type === 'doc') {
        await ProjectEntityUpdateHandler._cleanUpDoc(
          project,
          entry.entity,
          entry.path,
          userId
        )
      }
    }
    return subtreeListing
  },

  async _updateProjectStructureWithDeletedEntity(
    project,
    newProject,
    subtreeListing,
    userId,
    source
  ) {
    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
    return await DocumentUpdaterHandler.promises.updateProjectStructure(
      projectId,
      projectHistoryId,
      userId,
      changes,
      source
    )
  },

  async _cleanUpDoc(project, doc) {
    const projectId = project._id.toString()
    const docId = doc._id.toString()
    if (project.rootDoc_id != null && project.rootDoc_id.toString() === docId) {
      await ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
    }

    const { name } = doc
    const deletedAt = new Date()
    await DocstoreManager.promises.deleteDoc(projectId, docId, name, deletedAt)

    return await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId)
  },
}

/**
 * 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 []
  }
}

export default ProjectEntityUpdateHandler
