import {
  createContext,
  useCallback,
  useMemo,
  useReducer,
  useContext,
  useEffect,
  useState,
} from 'react'
import PropTypes from 'prop-types'

import { mapSeries } from '../../../infrastructure/promise'

import {
  syncRename,
  syncDelete,
  syncMove,
  syncCreateEntity,
} from '../util/sync-mutation'
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
import { isBlockedFilename, isCleanFilename } from '../util/safe-path'

import { useProjectContext } from '../../../shared/context/project-context'
import { useEditorContext } from '../../../shared/context/editor-context'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import { useFileTreeSelectable } from './file-tree-selectable'

import {
  InvalidFilenameError,
  BlockedFilenameError,
  DuplicateFilenameError,
  DuplicateFilenameMoveError,
} from '../errors'

const FileTreeActionableContext = createContext()

const ACTION_TYPES = {
  START_RENAME: 'START_RENAME',
  START_DELETE: 'START_DELETE',
  DELETING: 'DELETING',
  START_CREATE_FILE: 'START_CREATE_FILE',
  START_CREATE_FOLDER: 'START_CREATE_FOLDER',
  CREATING_FILE: 'CREATING_FILE',
  CREATING_FOLDER: 'CREATING_FOLDER',
  MOVING: 'MOVING',
  CANCEL: 'CANCEL',
  CLEAR: 'CLEAR',
  ERROR: 'ERROR',
}

const defaultState = {
  isDeleting: false,
  isRenaming: false,
  isCreatingFile: false,
  isCreatingFolder: false,
  isMoving: false,
  inFlight: false,
  actionedEntities: null,
  newFileCreateMode: null,
  error: null,
}

function fileTreeActionableReadOnlyReducer(state) {
  return state
}

function fileTreeActionableReducer(state, action) {
  switch (action.type) {
    case ACTION_TYPES.START_RENAME:
      return { ...defaultState, isRenaming: true }
    case ACTION_TYPES.START_DELETE:
      return {
        ...defaultState,
        isDeleting: true,
        actionedEntities: action.actionedEntities,
      }
    case ACTION_TYPES.START_CREATE_FILE:
      return {
        ...defaultState,
        isCreatingFile: true,
        newFileCreateMode: action.newFileCreateMode,
      }
    case ACTION_TYPES.START_CREATE_FOLDER:
      return { ...defaultState, isCreatingFolder: true }
    case ACTION_TYPES.CREATING_FILE:
      return {
        ...defaultState,
        isCreatingFile: true,
        newFileCreateMode: state.newFileCreateMode,
        inFlight: true,
      }
    case ACTION_TYPES.CREATING_FOLDER:
      return { ...defaultState, isCreatingFolder: true, inFlight: true }
    case ACTION_TYPES.DELETING:
      // keep `actionedEntities` so the entities list remains displayed in the
      // delete modal
      return {
        ...defaultState,
        isDeleting: true,
        inFlight: true,
        actionedEntities: state.actionedEntities,
      }
    case ACTION_TYPES.MOVING:
      return {
        ...defaultState,
        isMoving: true,
        inFlight: true,
      }
    case ACTION_TYPES.CLEAR:
      return { ...defaultState }
    case ACTION_TYPES.CANCEL:
      if (state.inFlight) return state
      return { ...defaultState }
    case ACTION_TYPES.ERROR:
      return { ...state, inFlight: false, error: action.error }
    default:
      throw new Error(`Unknown user action type: ${action.type}`)
  }
}

export function FileTreeActionableProvider({ reindexReferences, children }) {
  const { _id: projectId } = useProjectContext(projectContextPropTypes)
  const { permissionsLevel } = useEditorContext(editorContextPropTypes)

  const [state, dispatch] = useReducer(
    permissionsLevel === 'readOnly'
      ? fileTreeActionableReadOnlyReducer
      : fileTreeActionableReducer,
    defaultState
  )

  const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeData()
  const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()

  const [droppedFiles, setDroppedFiles] = useState(null)

  const startRenaming = useCallback(() => {
    dispatch({ type: ACTION_TYPES.START_RENAME })
  }, [])

  // update the entity with the new name immediately in the tree, but revert to
  // the old name if the sync fails
  const finishRenaming = useCallback(
    newName => {
      const selectedEntityId = Array.from(selectedEntityIds)[0]
      const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
      const oldName = found.entity.name
      if (newName === oldName) {
        return dispatch({ type: ACTION_TYPES.CLEAR })
      }

      const error = validateRename(fileTreeData, found, newName)
      if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })

      dispatch({ type: ACTION_TYPES.CLEAR })
      dispatchRename(selectedEntityId, newName)
      return syncRename(projectId, found.type, found.entity._id, newName).catch(
        error => {
          dispatchRename(selectedEntityId, oldName)
          // The state from this error action isn't used anywhere right now
          // but we need to handle the error for linting
          dispatch({ type: ACTION_TYPES.ERROR, error })
        }
      )
    },
    [dispatchRename, fileTreeData, projectId, selectedEntityIds]
  )

  const isDuplicate = useCallback(
    (parentFolderId, name) => {
      return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
    },
    [fileTreeData]
  )

  // init deletion flow (this will open the delete modal).
  // A copy of the selected entities is set as `actionedEntities` so it is kept
  // unchanged as the entities are deleted and the selection is updated
  const startDeleting = useCallback(() => {
    const actionedEntities = Array.from(selectedEntityIds).map(
      entityId => findInTreeOrThrow(fileTreeData, entityId).entity
    )
    dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
  }, [fileTreeData, selectedEntityIds])

  // deletes entities in series. Tree will be updated via the socket event
  const finishDeleting = useCallback(() => {
    dispatch({ type: ACTION_TYPES.DELETING })
    let shouldReindexReferences = false

    return mapSeries(Array.from(selectedEntityIds), id => {
      const found = findInTreeOrThrow(fileTreeData, id)
      shouldReindexReferences =
        shouldReindexReferences || /\.bib$/.test(found.entity.name)
      return syncDelete(projectId, found.type, found.entity._id).catch(
        error => {
          // throw unless 404
          if (error.info.statusCode !== 404) {
            throw error
          }
        }
      )
    })
      .then(() => {
        if (shouldReindexReferences) {
          reindexReferences()
        }
        dispatch({ type: ACTION_TYPES.CLEAR })
      })
      .catch(error => {
        // set an error and allow user to retry
        dispatch({ type: ACTION_TYPES.ERROR, error })
      })
  }, [fileTreeData, projectId, selectedEntityIds, reindexReferences])

  // moves entities. Tree is updated immediately and data are sync'd after.
  const finishMoving = useCallback(
    (toFolderId, draggedEntityIds) => {
      dispatch({ type: ACTION_TYPES.MOVING })

      // find entities and filter out no-ops and nested files
      const founds = Array.from(draggedEntityIds)
        .map(draggedEntityId =>
          findInTreeOrThrow(fileTreeData, draggedEntityId)
        )
        .filter(
          found =>
            found.parentFolderId !== toFolderId &&
            !draggedEntityIds.has(found.parentFolderId)
        )

      // make sure all entities can be moved, return early otherwise
      const isMoveToRoot = toFolderId === fileTreeData._id
      const validationError = founds
        .map(found =>
          validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
        )
        .find(error => error)
      if (validationError) {
        return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
      }

      // keep track of old parent folder ids so we can revert entities if sync fails
      const oldParentFolderIds = {}
      let isMoveFailed = false

      // dispatch moves immediately
      founds.forEach(found => {
        oldParentFolderIds[found.entity._id] = found.parentFolderId
        dispatchMove(found.entity._id, toFolderId)
      })

      // sync dispatched moves after
      return mapSeries(founds, async found => {
        try {
          await syncMove(projectId, found.type, found.entity._id, toFolderId)
        } catch (error) {
          isMoveFailed = true
          dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
          dispatch({ type: ACTION_TYPES.ERROR, error })
        }
      }).then(() => {
        if (!isMoveFailed) {
          dispatch({ type: ACTION_TYPES.CLEAR })
        }
      })
    },
    [dispatchMove, fileTreeData, projectId]
  )

  const startCreatingFolder = useCallback(() => {
    dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
  }, [])

  const parentFolderId = useMemo(() => {
    return getSelectedParentFolderId(
      fileTreeData,
      selectedEntityIds,
      isRootFolderSelected
    )
  }, [fileTreeData, selectedEntityIds, isRootFolderSelected])

  const finishCreatingEntity = useCallback(
    entity => {
      const error = validateCreate(fileTreeData, parentFolderId, entity)
      if (error) {
        return Promise.reject(error)
      }

      return syncCreateEntity(projectId, parentFolderId, entity)
    },
    [fileTreeData, parentFolderId, projectId]
  )

  const finishCreatingFolder = useCallback(
    name => {
      dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
      return finishCreatingEntity({ endpoint: 'folder', name })
        .then(() => {
          dispatch({ type: ACTION_TYPES.CLEAR })
        })
        .catch(error => {
          dispatch({ type: ACTION_TYPES.ERROR, error })
        })
    },
    [finishCreatingEntity]
  )

  const startCreatingFile = useCallback(newFileCreateMode => {
    dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
  }, [])

  const startCreatingDocOrFile = useCallback(() => {
    startCreatingFile('doc')
  }, [startCreatingFile])

  const startUploadingDocOrFile = useCallback(() => {
    startCreatingFile('upload')
  }, [startCreatingFile])

  const finishCreatingDocOrFile = useCallback(
    entity => {
      dispatch({ type: ACTION_TYPES.CREATING_FILE })

      return finishCreatingEntity(entity)
        .then(() => {
          dispatch({ type: ACTION_TYPES.CLEAR })
        })
        .catch(error => {
          dispatch({ type: ACTION_TYPES.ERROR, error })
        })
    },
    [finishCreatingEntity]
  )

  const finishCreatingDoc = useCallback(
    entity => {
      entity.endpoint = 'doc'
      return finishCreatingDocOrFile(entity)
    },
    [finishCreatingDocOrFile]
  )

  const finishCreatingLinkedFile = useCallback(
    entity => {
      entity.endpoint = 'linked_file'
      return finishCreatingDocOrFile(entity)
    },
    [finishCreatingDocOrFile]
  )

  const cancel = useCallback(() => {
    dispatch({ type: ACTION_TYPES.CANCEL })
  }, [])

  // listen for `file-tree.start-creating` events
  useEffect(() => {
    function handleEvent(event) {
      dispatch({
        type: ACTION_TYPES.START_CREATE_FILE,
        newFileCreateMode: event.detail.mode,
      })
    }

    window.addEventListener('file-tree.start-creating', handleEvent)

    return () => {
      window.removeEventListener('file-tree.start-creating', handleEvent)
    }
  }, [])

  // build the path for downloading a single file
  const downloadPath = useMemo(() => {
    if (selectedEntityIds.size === 1) {
      const [selectedEntityId] = selectedEntityIds
      const selectedEntity = findInTree(fileTreeData, selectedEntityId)
      if (selectedEntity?.type === 'fileRef') {
        return `/project/${projectId}/file/${selectedEntityId}`
      }
    }
  }, [fileTreeData, projectId, selectedEntityIds])

  const value = {
    canDelete: selectedEntityIds.size > 0 && !isRootFolderSelected,
    canRename: selectedEntityIds.size === 1 && !isRootFolderSelected,
    canCreate: selectedEntityIds.size < 2,
    ...state,
    parentFolderId,
    isDuplicate,
    startRenaming,
    finishRenaming,
    startDeleting,
    finishDeleting,
    finishMoving,
    startCreatingFile,
    startCreatingFolder,
    finishCreatingFolder,
    startCreatingDocOrFile,
    startUploadingDocOrFile,
    finishCreatingDoc,
    finishCreatingLinkedFile,
    cancel,
    droppedFiles,
    setDroppedFiles,
    downloadPath,
  }

  return (
    <FileTreeActionableContext.Provider value={value}>
      {children}
    </FileTreeActionableContext.Provider>
  )
}

FileTreeActionableProvider.propTypes = {
  reindexReferences: PropTypes.func.isRequired,
  children: PropTypes.oneOfType([
    PropTypes.arrayOf(PropTypes.node),
    PropTypes.node,
  ]).isRequired,
}

const projectContextPropTypes = {
  _id: PropTypes.string.isRequired,
}

const editorContextPropTypes = {
  permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}

export function useFileTreeActionable() {
  const context = useContext(FileTreeActionableContext)

  if (!context) {
    throw new Error(
      'useFileTreeActionable is only available inside FileTreeActionableProvider'
    )
  }

  return context
}

function getSelectedParentFolderId(
  fileTreeData,
  selectedEntityIds,
  isRootFolderSelected
) {
  if (isRootFolderSelected) {
    return fileTreeData._id
  }

  // we expect only one entity to be selected in that case, so we pick the first
  const selectedEntityId = Array.from(selectedEntityIds)[0]
  if (!selectedEntityId) {
    // in some cases no entities are selected. Return the root folder id then.
    return fileTreeData._id
  }

  const found = findInTree(fileTreeData, selectedEntityId)

  if (!found) {
    // if the entity isn't in the tree, return the root folder id.
    return fileTreeData._id
  }

  return found.type === 'folder' ? found.entity._id : found.parentFolderId
}

function validateCreate(fileTreeData, parentFolderId, entity) {
  if (!isCleanFilename(entity.name)) {
    return new InvalidFilenameError()
  }

  if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
    return new DuplicateFilenameError()
  }

  // check that the name of a file is allowed, if creating in the root folder
  const isMoveToRoot = parentFolderId === fileTreeData._id
  const isFolder = entity.endpoint === 'folder'
  if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
    return new BlockedFilenameError()
  }
}

function validateRename(fileTreeData, found, newName) {
  if (!isCleanFilename(newName)) {
    return new InvalidFilenameError()
  }

  if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
    return new DuplicateFilenameError()
  }

  const isTopLevel = found.path.length === 1
  const isFolder = found.type === 'folder'
  if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
    return new BlockedFilenameError()
  }
}

function validateMove(fileTreeData, toFolderId, found, isMoveToRoot) {
  if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
    const error = new DuplicateFilenameMoveError()
    error.entityName = found.entity.name
    return error
  }

  const isFolder = found.type === 'folder'
  if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
    return new BlockedFilenameError()
  }
}
