import { clipboard, ipcRenderer, shell, webFrame } from 'electron'
import path from 'path'
import equal from 'deep-equal'
import { isSamePathSync } from 'common/filesystem/paths'
import bus from '../bus'
import { hasKeys, getUniqueId } from '../util'
import listToTree from '../util/listToTree'
import { createDocumentState, getOptionsFromState, getSingleFileState, getBlankFileState } from './help'
import notice from '../services/notification'
import {
  FileEncodingCommand,
  LineEndingCommand,
  QuickOpenCommand,
  TrailingNewlineCommand
} from '../commands'

const autoSaveTimers = new Map()

const state = {
  currentFile: {},
  tabs: [],
  listToc: [], // Just use for deep equal check. and replace with new toc if needed.
  toc: []
}

const mutations = {
  // set search key and matches also index
  SET_SEARCH (state, value) {
    state.currentFile.searchMatches = value
  },
  SET_TOC (state, toc) {
    state.listToc = toc
    state.toc = listToTree(toc)
  },
  SET_CURRENT_FILE (state, currentFile) {
    const oldCurrentFile = state.currentFile
    if (!oldCurrentFile.id || oldCurrentFile.id !== currentFile.id) {
      const { id, markdown, cursor, history, pathname } = currentFile
      window.DIRNAME = pathname ? path.dirname(pathname) : ''
      // set state first, then emit file changed event
      state.currentFile = currentFile
      bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
    }
  },
  ADD_FILE_TO_TABS (state, currentFile) {
    state.tabs.push(currentFile)
  },
  REMOVE_FILE_WITHIN_TABS (state, file) {
    const { tabs, currentFile } = state
    const index = tabs.indexOf(file)
    tabs.splice(index, 1)

    if (file.id && autoSaveTimers.has(file.id)) {
      const timer = autoSaveTimers.get(file.id)
      clearTimeout(timer)
      autoSaveTimers.delete(file.id)
    }

    if (file.id === currentFile.id) {
      const fileState = state.tabs[index] || state.tabs[index - 1] || state.tabs[0] || {}
      state.currentFile = fileState
      if (typeof fileState.markdown === 'string') {
        const { id, markdown, cursor, history, pathname } = fileState
        window.DIRNAME = pathname ? path.dirname(pathname) : ''
        bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
      }
    }

    if (state.tabs.length === 0) {
      // Handle close the last tab, need to reset the TOC state
      state.listToc = []
      state.toc = []
    }
  },
  // Exchange from with to and move from to the end if to is null or empty.
  EXCHANGE_TABS_BY_ID (state, tabIDs) {
    const { fromId } = tabIDs
    const toId = tabIDs.toId // may be null

    const { tabs } = state
    const moveItem = (arr, from, to) => {
      if (from === to) return true
      const len = arr.length
      const item = arr.splice(from, 1)
      if (item.length === 0) return false

      arr.splice(to, 0, item[0])
      return arr.length === len
    }

    const fromIndex = tabs.findIndex(t => t.id === fromId)
    if (!toId) {
      moveItem(tabs, fromIndex, tabs.length - 1)
    } else {
      const toIndex = tabs.findIndex(t => t.id === toId)
      const realToIndex = fromIndex < toIndex ? toIndex - 1 : toIndex
      moveItem(tabs, fromIndex, realToIndex)
    }
  },
  LOAD_CHANGE (state, change) {
    const { tabs, currentFile } = state
    const { data, pathname } = change
    const {
      isMixedLineEndings,
      lineEnding,
      adjustLineEndingOnSave,
      trimTrailingNewline,
      encoding,
      markdown,
      filename
    } = data
    const options = { encoding, lineEnding, adjustLineEndingOnSave, trimTrailingNewline }

    // Create a new document and update few entires later.
    const newFileState = getSingleFileState({ markdown, filename, pathname, options })

    const tab = tabs.find(t => isSamePathSync(t.pathname, pathname))
    if (!tab) {
      // The tab may be closed in the meanwhile.
      console.error('LOAD_CHANGE: Cannot find tab in tab list.')
      notice.notify({
        title: 'Error loading tab',
        message: 'There was an error while loading the file change because the tab cannot be found.',
        type: 'error',
        time: 20000,
        showConfirm: false
      })
      return
    }

    // Backup few entries that we need to restore later.
    const oldId = tab.id
    const oldNotifications = tab.notifications
    let oldHistory = null
    if (tab.history.index >= 0 && tab.history.stack.length >= 1) {
      // Allow to restore the old document.
      oldHistory = {
        stack: [tab.history.stack[tab.history.index]],
        index: 0
      }

      // Free reference from array
      tab.history.index--
      tab.history.stack.pop()
    }

    // Update file content and restore some entries.
    Object.assign(tab, newFileState)
    tab.id = oldId
    tab.notifications = oldNotifications
    if (oldHistory) {
      tab.history = oldHistory
    }

    if (isMixedLineEndings) {
      tab.notifications.push({
        msg: `"${filename}" has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`,
        showConfirm: false,
        style: 'info',
        exclusiveType: '',
        action: () => {}
      })
    }

    // Reload the editor if the tab is currently opened.
    if (pathname === currentFile.pathname) {
      state.currentFile = tab
      const { id, cursor, history } = tab
      bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
    }
  },
  // NOTE: Please call this function only from main process via "mt::set-pathname" and free resources before!
  SET_PATHNAME (state, { tab, fileInfo }) {
    const { currentFile } = state
    const { filename, pathname, id } = fileInfo

    // Change reference path for images.
    if (id === currentFile.id && pathname) {
      window.DIRNAME = path.dirname(pathname)
    }

    if (tab) {
      Object.assign(tab, { filename, pathname, isSaved: true })
    }
  },
  SET_SAVE_STATUS_BY_TAB (state, { tab, status }) {
    if (hasKeys(tab)) {
      tab.isSaved = status
    }
  },
  SET_SAVE_STATUS (state, status) {
    if (hasKeys(state.currentFile)) {
      state.currentFile.isSaved = status
    }
  },
  SET_SAVE_STATUS_WHEN_REMOVE (state, { pathname }) {
    state.tabs.forEach(f => {
      if (f.pathname === pathname) {
        f.isSaved = false
      }
    })
  },
  SET_MARKDOWN (state, markdown) {
    if (hasKeys(state.currentFile)) {
      state.currentFile.markdown = markdown
    }
  },
  SET_DOCUMENT_ENCODING (state, encoding) {
    if (hasKeys(state.currentFile)) {
      state.currentFile.encoding = encoding
    }
  },
  SET_LINE_ENDING (state, lineEnding) {
    if (hasKeys(state.currentFile)) {
      state.currentFile.lineEnding = lineEnding
    }
  },
  SET_FILE_ENCODING_BY_NAME (state, encodingName) {
    if (hasKeys(state.currentFile)) {
      const { encoding: encodingObj } = state.currentFile
      encodingObj.encoding = encodingName
      encodingObj.isBom = false
    }
  },
  SET_FINAL_NEWLINE (state, value) {
    if (hasKeys(state.currentFile) && value >= 0 && value <= 3) {
      state.currentFile.trimTrailingNewline = value
    }
  },
  SET_ADJUST_LINE_ENDING_ON_SAVE (state, adjustLineEndingOnSave) {
    if (hasKeys(state.currentFile)) {
      state.currentFile.adjustLineEndingOnSave = adjustLineEndingOnSave
    }
  },
  SET_WORD_COUNT (state, wordCount) {
    if (hasKeys(state.currentFile)) {
      state.currentFile.wordCount = wordCount
    }
  },
  SET_CURSOR (state, cursor) {
    if (hasKeys(state.currentFile)) {
      state.currentFile.cursor = cursor
    }
  },
  SET_HISTORY (state, history) {
    if (hasKeys(state.currentFile)) {
      state.currentFile.history = history
    }
  },
  CLOSE_TABS (state, tabIdList) {
    if (!tabIdList || tabIdList.length === 0) return

    let tabIndex = 0
    tabIdList.forEach(id => {
      const index = state.tabs.findIndex(f => f.id === id)
      const { pathname } = state.tabs[index]

      // Notify main process to remove the file from the window and free resources.
      if (pathname) {
        ipcRenderer.send('mt::window-tab-closed', pathname)
      }

      state.tabs.splice(index, 1)
      if (state.currentFile.id === id) {
        state.currentFile = {}
        window.DIRNAME = ''
        if (tabIdList.length === 1) {
          tabIndex = index
        }
      }
    })

    if (!state.currentFile.id && state.tabs.length) {
      state.currentFile = state.tabs[tabIndex] || state.tabs[tabIndex - 1] || state.tabs[0] || {}
      if (typeof state.currentFile.markdown === 'string') {
        const { id, markdown, cursor, history, pathname } = state.currentFile
        window.DIRNAME = pathname ? path.dirname(pathname) : ''
        bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
      }
    }

    if (state.tabs.length === 0) {
      // Handle close the last tab, need to reset the TOC state
      state.listToc = []
      state.toc = []
    }
  },
  RENAME_IF_NEEDED (state, { src, dest }) {
    const { tabs } = state
    tabs.forEach(f => {
      if (f.pathname === src) {
        f.pathname = dest
        f.filename = path.basename(dest)
      }
    })
  },

  // Push a tab specific notification on stack that never disappears.
  PUSH_TAB_NOTIFICATION (state, data) {
    const defaultAction = () => {}
    const { tabId, msg } = data
    const action = data.action || defaultAction
    const showConfirm = data.showConfirm || false
    const style = data.style || 'info'
    // Whether only one notification should exist.
    const exclusiveType = data.exclusiveType || ''

    const { tabs } = state
    const tab = tabs.find(t => t.id === tabId)
    if (!tab) {
      console.error('PUSH_TAB_NOTIFICATION: Cannot find tab in tab list.')
      return
    }

    const { notifications } = tab

    // Remove the old notification if only one should exist.
    if (exclusiveType) {
      const index = notifications.findIndex(n => n.exclusiveType === exclusiveType)
      if (index >= 0) {
        // Reorder current notification
        notifications.splice(index, 1)
      }
    }

    // Push new notification on stack.
    notifications.push({
      msg,
      showConfirm,
      style,
      exclusiveType,
      action: action
    })
  }
}

const actions = {
  FORMAT_LINK_CLICK ({ commit }, { data, dirname }) {
    ipcRenderer.send('mt::format-link-click', { data, dirname })
  },

  LISTEN_SCREEN_SHOT ({ commit }) {
    ipcRenderer.on('mt::screenshot-captured', e => {
      bus.$emit('screenshot-captured')
    })
  },

  // image path auto complement
  ASK_FOR_IMAGE_AUTO_PATH ({ commit, state }, src) {
    const { pathname } = state.currentFile
    if (pathname) {
      let rs
      const promise = new Promise((resolve, reject) => {
        rs = resolve
      })
      const id = getUniqueId()
      ipcRenderer.once(`mt::response-of-image-path-${id}`, (e, files) => {
        rs(files)
      })
      ipcRenderer.send('mt::ask-for-image-auto-path', { pathname, src, id })
      return promise
    } else {
      return []
    }
  },

  SEARCH ({ commit }, value) {
    commit('SET_SEARCH', value)
  },

  SHOW_IMAGE_DELETION_URL ({ commit }, deletionUrl) {
    notice.notify({
      title: 'Image deletion URL',
      message: `Click to copy the deletion URL of the uploaded image to the clipboard (${deletionUrl}).`,
      showConfirm: true,
      time: 20000
    })
      .then(() => {
        clipboard.writeText(deletionUrl)
      })
  },

  FORCE_CLOSE_TAB ({ commit, dispatch }, file) {
    commit('REMOVE_FILE_WITHIN_TABS', file)
    const { pathname } = file

    // Notify main process to remove the file from the window and free resources.
    if (pathname) {
      ipcRenderer.send('mt::window-tab-closed', pathname)
    }
  },

  EXCHANGE_TABS_BY_ID ({ commit }, tabIDs) {
    commit('EXCHANGE_TABS_BY_ID', tabIDs)
  },

  // We need to update line endings menu when changing tabs.
  UPDATE_LINE_ENDING_MENU ({ state }) {
    const { lineEnding } = state.currentFile
    if (lineEnding) {
      const { windowId } = global.marktext.env
      ipcRenderer.send('mt::update-line-ending-menu', windowId, lineEnding)
    }
  },

  CLOSE_UNSAVED_TAB ({ commit, state }, file) {
    const { id, pathname, filename, markdown } = file
    const options = getOptionsFromState(file)

    // Save the file content via main process and send a close tab response.
    ipcRenderer.send('mt::save-and-close-tabs', [{ id, pathname, filename, markdown, options }])
  },

  // need pass some data to main process when `save` menu item clicked
  LISTEN_FOR_SAVE ({ state, rootState }) {
    ipcRenderer.on('mt::editor-ask-file-save', () => {
      const { id, filename, pathname, markdown } = state.currentFile
      const options = getOptionsFromState(state.currentFile)
      const defaultPath = getRootFolderFromState(rootState)
      if (id) {
        ipcRenderer.send('mt::response-file-save', {
          id,
          filename,
          pathname,
          markdown,
          options,
          defaultPath
        })
      }
    })
  },

  // need pass some data to main process when `save as` menu item clicked
  LISTEN_FOR_SAVE_AS ({ state, rootState }) {
    ipcRenderer.on('mt::editor-ask-file-save-as', () => {
      const { id, filename, pathname, markdown } = state.currentFile
      const options = getOptionsFromState(state.currentFile)
      const defaultPath = getRootFolderFromState(rootState)
      if (id) {
        ipcRenderer.send('mt::response-file-save-as', {
          id,
          filename,
          pathname,
          markdown,
          options,
          defaultPath
        })
      }
    })
  },

  LISTEN_FOR_SET_PATHNAME ({ commit, dispatch, state }) {
    ipcRenderer.on('mt::set-pathname', (e, fileInfo) => {
      const { tabs } = state
      const { pathname, id } = fileInfo
      const tab = tabs.find(f => f.id === id)
      if (!tab) {
        console.err('[ERROR] Cannot change file path from unknown tab.')
        return
      }

      // If a tab with the same file path already exists we need to close the tab.
      // The existing tab is overwritten by this tab.
      const existingTab = tabs.find(t => t.id !== id && isSamePathSync(t.pathname, pathname))
      if (existingTab) {
        dispatch('CLOSE_TAB', existingTab)
      }
      commit('SET_PATHNAME', { tab, fileInfo })
    })

    ipcRenderer.on('mt::tab-saved', (e, tabId) => {
      const { tabs } = state
      const tab = tabs.find(f => f.id === tabId)
      if (tab) {
        Object.assign(tab, { isSaved: true })
      }
    })

    ipcRenderer.on('mt::tab-save-failure', (e, tabId, msg) => {
      const { tabs } = state
      const tab = tabs.find(t => t.id === tabId)
      if (!tab) {
        notice.notify({
          title: 'Save failure',
          message: msg,
          type: 'error',
          time: 20000,
          showConfirm: false
        })
        return
      }

      commit('SET_SAVE_STATUS_BY_TAB', { tab, status: false })
      commit('PUSH_TAB_NOTIFICATION', {
        tabId,
        msg: `There was an error while saving: ${msg}`,
        style: 'crit'
      })
    })
  },

  LISTEN_FOR_CLOSE ({ state }) {
    ipcRenderer.on('mt::ask-for-close', e => {
      const unsavedFiles = state.tabs
        .filter(file => !file.isSaved)
        .map(file => {
          const { id, filename, pathname, markdown } = file
          const options = getOptionsFromState(file)
          return { id, filename, pathname, markdown, options }
        })

      if (unsavedFiles.length) {
        ipcRenderer.send('mt::close-window-confirm', unsavedFiles)
      } else {
        ipcRenderer.send('mt::close-window')
      }
    })
  },

  LISTEN_FOR_SAVE_CLOSE ({ commit }) {
    ipcRenderer.on('mt::force-close-tabs-by-id', (e, tabIdList) => {
      if (Array.isArray(tabIdList) && tabIdList.length) {
        commit('CLOSE_TABS', tabIdList)
      }
    })
  },

  ASK_FOR_SAVE_ALL ({ commit, state }, closeTabs) {
    const { tabs } = state
    const unsavedFiles = tabs
      .filter(file => !(file.isSaved && /[^\n]/.test(file.markdown)))
      .map(file => {
        const { id, filename, pathname, markdown } = file
        const options = getOptionsFromState(file)
        return { id, filename, pathname, markdown, options }
      })

    if (closeTabs) {
      if (unsavedFiles.length) {
        commit('CLOSE_TABS', tabs.filter(f => f.isSaved).map(f => f.id))
        ipcRenderer.send('mt::save-and-close-tabs', unsavedFiles)
      } else {
        commit('CLOSE_TABS', tabs.map(f => f.id))
      }
    } else {
      ipcRenderer.send('mt::save-tabs', unsavedFiles)
    }
  },

  LISTEN_FOR_MOVE_TO ({ state, rootState }) {
    ipcRenderer.on('mt::editor-move-file', () => {
      const { id, filename, pathname, markdown } = state.currentFile
      const options = getOptionsFromState(state.currentFile)
      const defaultPath = getRootFolderFromState(rootState)
      if (!id) return
      if (!pathname) {
        // if current file is a newly created file, just save it!
        ipcRenderer.send('mt::response-file-save', {
          id,
          filename,
          pathname,
          markdown,
          options,
          defaultPath
        })
      } else {
        // if not, move to a new(maybe) folder
        ipcRenderer.send('mt::response-file-move-to', { id, pathname })
      }
    })
  },

  LISTEN_FOR_RENAME ({ commit, state, dispatch }) {
    ipcRenderer.on('mt::editor-rename-file', () => {
      dispatch('RESPONSE_FOR_RENAME')
    })
  },

  RESPONSE_FOR_RENAME ({ state, rootState }) {
    const { id, filename, pathname, markdown } = state.currentFile
    const options = getOptionsFromState(state.currentFile)
    const defaultPath = getRootFolderFromState(rootState)
    if (!id) return
    if (!pathname) {
      // if current file is a newly created file, just save it!
      ipcRenderer.send('mt::response-file-save', {
        id,
        filename,
        pathname,
        markdown,
        options,
        defaultPath
      })
    } else {
      bus.$emit('rename')
    }
  },

  // ask for main process to rename this file to a new name `newFilename`
  RENAME ({ commit, state }, newFilename) {
    const { id, pathname, filename } = state.currentFile
    if (typeof filename === 'string' && filename !== newFilename) {
      const newPathname = path.join(path.dirname(pathname), newFilename)
      ipcRenderer.send('mt::rename', { id, pathname, newPathname })
    }
  },

  UPDATE_CURRENT_FILE ({ commit, state, dispatch }, currentFile) {
    commit('SET_CURRENT_FILE', currentFile)
    const { tabs } = state
    if (!tabs.some(file => file.id === currentFile.id)) {
      commit('ADD_FILE_TO_TABS', currentFile)
    }
    dispatch('UPDATE_LINE_ENDING_MENU')
  },

  // This events are only used during window creation.
  LISTEN_FOR_BOOTSTRAP_WINDOW ({ commit, state, dispatch, rootState }) {
    // Delay load runtime commands and initialize commands.
    setTimeout(() => {
      bus.$emit('cmd::register-command', new FileEncodingCommand(rootState.editor))
      bus.$emit('cmd::register-command', new QuickOpenCommand(rootState))
      bus.$emit('cmd::register-command', new LineEndingCommand(rootState.editor))
      bus.$emit('cmd::register-command', new TrailingNewlineCommand(rootState.editor))

      setTimeout(() => {
        ipcRenderer.send('mt::request-keybindings')
        bus.$emit('cmd::sort-commands')
      }, 100)
    }, 400)

    ipcRenderer.on('mt::bootstrap-editor', (e, config) => {
      const {
        addBlankTab,
        markdownList,
        lineEnding,
        sideBarVisibility,
        tabBarVisibility,
        sourceCodeModeEnabled
      } = config

      dispatch('SEND_INITIALIZED')
      commit('SET_USER_PREFERENCE', { endOfLine: lineEnding })
      commit('SET_LAYOUT', {
        rightColumn: 'files',
        showSideBar: !!sideBarVisibility,
        showTabBar: !!tabBarVisibility
      })
      dispatch('DISPATCH_LAYOUT_MENU_ITEMS')

      commit('SET_MODE', {
        type: 'sourceCode',
        checked: !!sourceCodeModeEnabled
      })

      if (addBlankTab) {
        dispatch('NEW_UNTITLED_TAB', {})
      } else if (markdownList.length) {
        let isFirst = true
        for (const markdown of markdownList) {
          isFirst = false
          dispatch('NEW_UNTITLED_TAB', { markdown, selected: isFirst })
        }
      }
    })
  },

  // Open a new tab, optionally with content.
  LISTEN_FOR_NEW_TAB ({ dispatch }) {
    ipcRenderer.on('mt::open-new-tab', (e, markdownDocument, options = {}, selected = true) => {
      if (markdownDocument) {
        // Create tab with content.
        dispatch('NEW_TAB_WITH_CONTENT', { markdownDocument, options, selected })
      } else {
        // Fallback: create a blank tab and always select it
        dispatch('NEW_UNTITLED_TAB', {})
      }
    })

    ipcRenderer.on('mt::new-untitled-tab', (e, selected = true, markdown = '') => {
      // Create a blank tab
      dispatch('NEW_UNTITLED_TAB', { markdown, selected })
    })
  },

  LISTEN_FOR_CLOSE_TAB ({ commit, state, dispatch }) {
    ipcRenderer.on('mt::editor-close-tab', e => {
      const file = state.currentFile
      if (!hasKeys(file)) return
      dispatch('CLOSE_TAB', file)
    })
  },

  LISTEN_FOR_TAB_CYCLE ({ commit, state, dispatch }) {
    ipcRenderer.on('mt::tabs-cycle-left', e => {
      dispatch('CYCLE_TABS', false)
    })
    ipcRenderer.on('mt::tabs-cycle-right', e => {
      dispatch('CYCLE_TABS', true)
    })
  },

  LISTEN_FOR_SWITCH_TABS ({ commit, state, dispatch }) {
    ipcRenderer.on('mt::switch-tab-by-index', (event, index) => {
      dispatch('SWITCH_TAB_BY_INDEX', index)
    })
  },

  CLOSE_TAB ({ dispatch }, file) {
    const { isSaved } = file
    if (isSaved) {
      dispatch('FORCE_CLOSE_TAB', file)
    } else {
      dispatch('CLOSE_UNSAVED_TAB', file)
    }
  },

  CLOSE_OTHER_TABS ({ state, dispatch }, file) {
    const { tabs } = state
    tabs.filter(f => f.id !== file.id).forEach(tab => {
      dispatch('CLOSE_TAB', tab)
    })
  },

  CLOSE_SAVED_TABS ({ state, dispatch }) {
    const { tabs } = state
    tabs.filter(f => f.isSaved).forEach(tab => {
      dispatch('CLOSE_TAB', tab)
    })
  },

  CLOSE_ALL_TABS ({ state, dispatch }) {
    const { tabs } = state
    tabs.slice().forEach(tab => {
      dispatch('CLOSE_TAB', tab)
    })
  },

  RENAME_FILE ({ commit, dispatch }, file) {
    commit('SET_CURRENT_FILE', file)
    dispatch('UPDATE_LINE_ENDING_MENU')
    bus.$emit('rename')
  },

  // Direction is a boolean where false is left and true right.
  CYCLE_TABS ({ commit, dispatch, state }, direction) {
    const { tabs, currentFile } = state
    if (tabs.length <= 1) {
      return
    }

    const currentIndex = tabs.findIndex(t => t.id === currentFile.id)
    if (currentIndex === -1) {
      console.error('CYCLE_TABS: Cannot find current tab index.')
      return
    }

    let nextTabIndex = 0
    if (!direction) {
      // Switch tab to the left.
      nextTabIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1
    } else {
      // Switch tab to the right.
      nextTabIndex = (currentIndex + 1) % tabs.length
    }

    const nextTab = tabs[nextTabIndex]
    if (!nextTab || !nextTab.id) {
      console.error(`CYCLE_TABS: Cannot find next tab (index="${nextTabIndex}").`)
      return
    }

    commit('SET_CURRENT_FILE', nextTab)
    dispatch('UPDATE_LINE_ENDING_MENU')
  },

  SWITCH_TAB_BY_INDEX ({ commit, dispatch, state }, nextTabIndex) {
    const { tabs, currentFile } = state
    if (nextTabIndex < 0 || nextTabIndex >= tabs.length) {
      console.warn('Invalid tab index:', nextTabIndex)
      return
    }

    const currentIndex = tabs.findIndex(t => t.id === currentFile.id)
    if (currentIndex === -1) {
      console.error('Cannot find current tab index.')
      return
    }

    const nextTab = tabs[nextTabIndex]
    if (!nextTab || !nextTab.id) {
      console.error(`Cannot find tab by index="${nextTabIndex}".`)
      return
    }

    commit('SET_CURRENT_FILE', nextTab)
    dispatch('UPDATE_LINE_ENDING_MENU')
  },

  /**
   * Create a new untitled tab optional from a markdown string.
   *
   * @param {*} context The store context.
   * @param {{markdown?: string, selected?: boolean}} obj Optional markdown string
   * and whether the tab should become the selected tab (true if not set).
   */
  NEW_UNTITLED_TAB ({ commit, state, dispatch, rootState }, { markdown: markdownString, selected }) {
    // If not set select the tab.
    if (selected == null) {
      selected = true
    }

    dispatch('SHOW_TAB_VIEW', false)

    const { defaultEncoding, endOfLine } = rootState.preferences
    const { tabs } = state
    const fileState = getBlankFileState(tabs, defaultEncoding, endOfLine, markdownString)

    if (selected) {
      const { id, markdown } = fileState
      dispatch('UPDATE_CURRENT_FILE', fileState)
      bus.$emit('file-loaded', { id, markdown })
    } else {
      commit('ADD_FILE_TO_TABS', fileState)
    }
  },

  /**
   * Create a new tab from the given markdown document.
   *
   * @param {*} context The store context.
   * @param {{markdownDocument: IMarkdownDocumentRaw, selected?: boolean}} obj The markdown document
   * and optional whether the tab should become the selected tab (true if not set).
   */
  NEW_TAB_WITH_CONTENT ({ commit, state, dispatch }, { markdownDocument, options = {}, selected }) {
    if (!markdownDocument) {
      console.warn('Cannot create a file tab without a markdown document!')
      dispatch('NEW_UNTITLED_TAB', {})
      return
    }

    // Select the tab if not value is specified.
    if (typeof selected === 'undefined') {
      selected = true
    }
    // Check if tab already exist and always select existing tab if so.
    const { currentFile, tabs } = state
    const { pathname } = markdownDocument
    const existingTab = tabs.find(t => isSamePathSync(t.pathname, pathname))
    if (existingTab) {
      dispatch('UPDATE_CURRENT_FILE', existingTab)
      return
    }

    // Replace/close selected untitled empty tab
    let keepTabBarState = false
    if (currentFile) {
      const { isSaved, pathname } = currentFile
      if (isSaved && !pathname) {
        keepTabBarState = true
        dispatch('FORCE_CLOSE_TAB', currentFile)
      }
    }

    if (!keepTabBarState) {
      dispatch('SHOW_TAB_VIEW', false)
    }

    const { markdown, isMixedLineEndings } = markdownDocument
    const docState = createDocumentState(Object.assign(markdownDocument, options))
    const { id, cursor } = docState

    if (selected) {
      dispatch('UPDATE_CURRENT_FILE', docState)
      bus.$emit('file-loaded', { id, markdown, cursor })
    } else {
      commit('ADD_FILE_TO_TABS', docState)
    }

    if (isMixedLineEndings) {
      const { filename, lineEnding } = markdownDocument
      commit('PUSH_TAB_NOTIFICATION', {
        tabId: id,
        msg: `${filename}" has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`
      })
    }
  },

  SHOW_TAB_VIEW ({ commit, state, dispatch }, always) {
    const { tabs } = state
    if (always || tabs.length === 1) {
      commit('SET_LAYOUT', { showTabBar: true })
      dispatch('DISPATCH_LAYOUT_MENU_ITEMS')
    }
  },

  // Content change from realtime preview editor and source code editor
  // WORKAROUND: id is "muya" if changes come from muya and not source code editor! So we don't have to apply the workaround.
  LISTEN_FOR_CONTENT_CHANGE ({ commit, dispatch, state, rootState }, { id, markdown, wordCount, cursor, history, toc }) {
    const { autoSave } = rootState.preferences
    const {
      id: currentId,
      filename,
      pathname,
      markdown: oldMarkdown,
      trimTrailingNewline
    } = state.currentFile
    const { listToc } = state

    if (!id) {
      throw new Error('Listen for document change but id was not set!')
    } else if (!currentId || state.tabs.length === 0) {
      // Discard changes - this case should normally not occur.
      return
    } else if (id !== 'muya' && currentId !== id) {
      // WORKAROUND: We commit changes after switching the tab in source code mode.
      // Update old tab or discard changes
      for (const tab of state.tabs) {
        if (tab.id && tab.id === id) {
          tab.markdown = adjustTrailingNewlines(markdown, tab.trimTrailingNewline)
          // Set cursor
          if (cursor) {
            tab.cursor = cursor
          }
          // Set history
          if (history) {
            tab.history = history
          }
          break
        }
      }
      return
    }

    markdown = adjustTrailingNewlines(markdown, trimTrailingNewline)
    commit('SET_MARKDOWN', markdown)

    // Ignore new line which is added if the editor text is empty (#422)
    if (oldMarkdown.length === 0 && markdown.length === 1 && markdown[0] === '\n') {
      return
    }

    // Word count
    if (wordCount) {
      commit('SET_WORD_COUNT', wordCount)
    }
    // Set cursor
    if (cursor) {
      commit('SET_CURSOR', cursor)
    }
    // Set history
    if (history) {
      commit('SET_HISTORY', history)
    }
    // Set toc
    if (toc && !equal(toc, listToc)) {
      commit('SET_TOC', toc)
    }

    // Change save status/save to file only when the markdown changed!
    if (markdown !== oldMarkdown) {
      commit('SET_SAVE_STATUS', false)

      // Save file is auto save is enable and file exist on disk.
      if (pathname && autoSave) {
        const options = getOptionsFromState(state.currentFile)
        dispatch('HANDLE_AUTO_SAVE', {
          id: currentId,
          filename,
          pathname,
          markdown,
          options
        })
      }
    }
  },

  HANDLE_AUTO_SAVE ({ commit, state, rootState }, { id, filename, pathname, markdown, options }) {
    if (!id || !pathname) {
      throw new Error('HANDLE_AUTO_SAVE: Invalid tab.')
    }

    const { tabs } = state
    const { autoSaveDelay } = rootState.preferences

    if (autoSaveTimers.has(id)) {
      const timer = autoSaveTimers.get(id)
      clearTimeout(timer)
      autoSaveTimers.delete(id)
    }

    const timer = setTimeout(() => {
      autoSaveTimers.delete(id)

      // Validate that the tab still exists. A tab is unchanged until successfully saved
      // or force closed. The user decides whether to discard or save the tab when
      // gracefully closed. The automatically save event may fire meanwhile.
      const tab = tabs.find(t => t.id === id)
      if (tab && !tab.isSaved) {
        const defaultPath = getRootFolderFromState(rootState)

        // Tab changed status is set after the file is saved.
        ipcRenderer.send('mt::response-file-save', {
          id,
          filename,
          pathname,
          markdown,
          options,
          defaultPath
        })
      }
    }, autoSaveDelay)
    autoSaveTimers.set(id, timer)
  },

  SELECTION_CHANGE ({ commit }, changes) {
    const { start, end } = changes
    // Set search keyword to store.
    if (start.key === end.key && start.block.text) {
      const value = start.block.text.substring(start.offset, end.offset)
      commit('SET_SEARCH', {
        matches: [],
        index: -1,
        value
      })
    }

    const { windowId } = global.marktext.env
    ipcRenderer.send('mt::editor-selection-changed', windowId, createApplicationMenuState(changes))
  },

  SELECTION_FORMATS (_, formats) {
    const { windowId } = global.marktext.env
    ipcRenderer.send('mt::update-format-menu', windowId, createSelectionFormatState(formats))
  },

  EXPORT ({ state }, { type, content, pageOptions }) {
    if (!hasKeys(state.currentFile)) return

    // Extract title from TOC buffer.
    let title = ''
    const { listToc } = state
    if (listToc && listToc.length > 0) {
      let headerRef = listToc[0]

      // The main title should be at the beginning of the document.
      const len = Math.min(listToc.length, 6)
      for (let i = 1; i < len; ++i) {
        if (headerRef.lvl === 1) {
          break
        }

        const header = listToc[i]
        if (headerRef.lvl > header.lvl) {
          headerRef = header
        }
      }
      title = headerRef.content
    }

    const { filename, pathname } = state.currentFile
    ipcRenderer.send('mt::response-export', {
      type,
      title,
      content,
      filename,
      pathname,
      pageOptions
    })
  },

  LINTEN_FOR_EXPORT_SUCCESS ({ commit }) {
    ipcRenderer.on('mt::export-success', (e, { type, filePath }) => {
      notice.notify({
        title: 'Exported successfully',
        message: `Exported "${path.basename(filePath)}" successfully!`,
        showConfirm: true
      })
        .then(() => {
          shell.showItemInFolder(filePath)
        })
    })
  },

  PRINT_RESPONSE ({ commit }) {
    ipcRenderer.send('mt::response-print')
  },

  LINTEN_FOR_PRINT_SERVICE_CLEARUP ({ commit }) {
    ipcRenderer.on('mt::print-service-clearup', e => {
      bus.$emit('print-service-clearup')
    })
  },

  LINTEN_FOR_SET_LINE_ENDING ({ commit, dispatch, state }) {
    ipcRenderer.on('mt::set-line-ending', (e, lineEnding) => {
      const { lineEnding: oldLineEnding } = state.currentFile
      if (lineEnding !== oldLineEnding) {
        commit('SET_LINE_ENDING', lineEnding)
        commit('SET_ADJUST_LINE_ENDING_ON_SAVE', lineEnding !== 'lf')
        commit('SET_SAVE_STATUS', true)

        // Update menu when emitted from renderer process.
        if (!e) {
          dispatch('UPDATE_LINE_ENDING_MENU')
        }
      }
    })
  },

  LINTEN_FOR_SET_ENCODING ({ commit, state }) {
    ipcRenderer.on('mt::set-file-encoding', (e, encodingName) => {
      const { encoding } = state.currentFile.encoding
      if (encoding !== encodingName) {
        commit('SET_FILE_ENCODING_BY_NAME', encodingName)
        commit('SET_SAVE_STATUS', true)
      }
    })
  },

  LINTEN_FOR_SET_FINAL_NEWLINE ({ commit, state }) {
    ipcRenderer.on('mt::set-final-newline', (e, value) => {
      const { trimTrailingNewline } = state.currentFile
      if (trimTrailingNewline !== value) {
        commit('SET_FINAL_NEWLINE', value)
        commit('SET_SAVE_STATUS', true)
      }
    })
  },

  LISTEN_FOR_FILE_CHANGE ({ commit, state, rootState }) {
    ipcRenderer.on('mt::update-file', (e, { type, change }) => {
      // TODO: We should only load the changed content if the user want to reload the document.

      const { tabs } = state
      const { pathname } = change
      const tab = tabs.find(t => isSamePathSync(t.pathname, pathname))
      if (tab) {
        const { id, isSaved, filename } = tab
        switch (type) {
          case 'unlink': {
            commit('SET_SAVE_STATUS_BY_TAB', { tab, status: false })
            commit('PUSH_TAB_NOTIFICATION', {
              tabId: id,
              msg: `"${filename}" has been removed on disk.`,
              style: 'warn',
              showConfirm: false,
              exclusiveType: 'file_changed'
            })
            break
          }
          case 'add':
          case 'change': {
            const { autoSave } = rootState.preferences
            if (autoSave) {
              if (autoSaveTimers.has(id)) {
                const timer = autoSaveTimers.get(id)
                clearTimeout(timer)
                autoSaveTimers.delete(id)
              }

              // Only reload the content if the tab is saved.
              if (isSaved) {
                commit('LOAD_CHANGE', change)
                return
              }
            }

            commit('SET_SAVE_STATUS_BY_TAB', { tab, status: false })
            commit('PUSH_TAB_NOTIFICATION', {
              tabId: id,
              msg: `"${filename}" has been changed on disk. Do you want to reload it?`,
              showConfirm: true,
              exclusiveType: 'file_changed',
              action: status => {
                if (status) {
                  commit('LOAD_CHANGE', change)
                }
              }
            })
            break
          }
          default:
            console.error(`LISTEN_FOR_FILE_CHANGE: Invalid type "${type}"`)
        }
      } else {
        console.error(`LISTEN_FOR_FILE_CHANGE: Cannot find tab for path "${pathname}".`)
      }
    })
  },

  ASK_FOR_IMAGE_PATH ({ commit }) {
    return ipcRenderer.sendSync('mt::ask-for-image-path')
  },

  LISTEN_WINDOW_ZOOM ({ dispatch, rootState }) {
    ipcRenderer.on('mt::window-zoom', (e, zoomFactor) => {
      zoomFactor = Number.parseFloat(zoomFactor.toFixed(3)) // prevent float rounding errors
      const { zoom } = rootState.preferences
      if (zoom !== zoomFactor) {
        dispatch('SET_SINGLE_PREFERENCE', { type: 'zoom', value: zoomFactor })
      }
      webFrame.setZoomFactor(zoomFactor)
    })
  },

  LISTEN_FOR_RELOAD_IMAGES () {
    ipcRenderer.on('mt::invalidate-image-cache', () => {
      bus.$emit('invalidate-image-cache')
    })
  },

  LISTEN_FOR_CONTEXT_MENU () {
    // General context menu
    ipcRenderer.on('mt::cm-copy-as-markdown', () => {
      bus.$emit('copyAsMarkdown', 'copyAsMarkdown')
    })
    ipcRenderer.on('mt::cm-copy-as-html', () => {
      bus.$emit('copyAsHtml', 'copyAsHtml')
    })
    ipcRenderer.on('mt::cm-paste-as-plain-text', () => {
      bus.$emit('pasteAsPlainText', 'pasteAsPlainText')
    })
    ipcRenderer.on('mt::cm-insert-paragraph', (e, location) => {
      bus.$emit('insertParagraph', location)
    })

    // Spelling
    ipcRenderer.on('mt::spelling-replace-misspelling', (e, info) => {
      bus.$emit('replace-misspelling', info)
    })
    ipcRenderer.on('mt::spelling-show-switch-language', () => {
      bus.$emit('open-command-spellchecker-switch-language')
    })
  }
}

// ----------------------------------------------------------------------------

/**
 * Return the opened root folder or an empty string.
 *
 * @param {*} rootState The root state.
 */
const getRootFolderFromState = rootState => {
  const openedFolder = rootState.project.projectTree
  if (openedFolder) {
    return openedFolder.pathname
  }
  return ''
}

/**
 * Trim the final newlines according `trimTrailingNewlineOption`.
 *
 * @param {string} markdown The text to trim.
 * @param {*} trimTrailingNewlineOption The option how we should trim the final newlines.
 */
const adjustTrailingNewlines = (markdown, trimTrailingNewlineOption) => {
  if (!markdown) {
    return ''
  }

  switch (trimTrailingNewlineOption) {
    // Trim trailing newlines.
    case 0: {
      return trimTrailingNewlines(markdown)
    }
    // Ensure single trailing newline.
    case 1: {
      // Muya will always add a final new line to the markdown text. Check first whether
      // only one newline exist to prevent copying the string.
      const lastIndex = markdown.length - 1
      if (markdown[lastIndex] === '\n') {
        if (markdown.length === 1) {
          // Just return nothing because adding a final new line makes no sense.
          return ''
        } else if (markdown[lastIndex - 1] !== '\n') {
          return markdown
        }
      }

      // Otherwise trim trailing newlines and add one.
      markdown = trimTrailingNewlines(markdown)
      if (markdown.length === 0) {
        // Just return nothing because adding a final new line makes no sense.
        return ''
      }
      return markdown + '\n'
    }
    // Disabled, use text as it is.
    default:
      return markdown
  }
}

/**
 * Trim trailing newlines from `text`.
 *
 * @param {string} text The text to trim.
 */
const trimTrailingNewlines = text => {
  return text.replace(/[\r?\n]+$/, '')
}

/**
 * Creates a object that contains the application menu state.
 *
 * @param {*} selection The selection.
 * @returns A object that represents the application menu state.
 */
const createApplicationMenuState = ({ start, end, affiliation }) => {
  const state = {
    isDisabled: false,
    // Whether multiple lines are selected.
    isMultiline: start.key !== end.key,
    // List information - a list must be selected.
    isLooseListItem: false,
    isTaskList: false,
    // Whether the selection is code block like (math, html or code block).
    isCodeFences: false,
    // Whether a code block line is selected.
    isCodeContent: false,
    // Whether the selection contains a table.
    isTable: false,
    // Contains keys about the selection type(s) (string, boolean) like "ul: true".
    affiliation: {}
  }
  const { isMultiline } = state

  // Get code block information from selection.
  if (
    (start.block.functionType === 'cellContent' && end.block.functionType === 'cellContent') ||
    (start.type === 'span' && start.block.functionType === 'codeContent') ||
    (end.type === 'span' && end.block.functionType === 'codeContent')
  ) {
    // A code block like block is selected (code, math, ...).
    state.isCodeFences = true

    // A code block line is selected.
    if (start.block.functionType === 'codeContent' || end.block.functionType === 'codeContent') {
      state.isCodeContent = true
    }
  }

  // Query list information.
  if (affiliation.length >= 1 && /ul|ol/.test(affiliation[0].type)) {
    const listBlock = affiliation[0]
    state.affiliation[listBlock.type] = true
    state.isLooseListItem = listBlock.children[0].isLooseListItem
    state.isTaskList = listBlock.listType === 'task'
  } else if (affiliation.length >= 3 && affiliation[1].type === 'li') {
    const listItem = affiliation[1]
    const listType = listItem.listItemType === 'order' ? 'ol' : 'ul'
    state.affiliation[listType] = true
    state.isLooseListItem = listItem.isLooseListItem
    state.isTaskList = listItem.listItemType === 'task'
  }

  // Search with block depth 3 (e.g. "ul -> li -> p" where p is the actually paragraph inside the list (item)).
  for (const b of affiliation.slice(0, 3)) {
    if (b.type === 'pre' && b.functionType) {
      if (/frontmatter|html|multiplemath|code$/.test(b.functionType)) {
        state.isCodeFences = true
        state.affiliation[b.functionType] = true
      }
      break
    } else if (b.type === 'figure' && b.functionType) {
      if (b.functionType === 'table') {
        state.isTable = true
        state.isDisabled = true
      }
      break
    } else if (isMultiline && /^h{1,6}$/.test(b.type)) {
      // Multiple block elements are selected.
      state.affiliation = {}
      break
    } else {
      if (!state.affiliation[b.type]) {
        state.affiliation[b.type] = true
      }
    }
  }

  // Clean up
  if (Object.getOwnPropertyNames(state.affiliation).length >= 2 && state.affiliation.p) {
    delete state.affiliation.p
  }
  if ((state.affiliation.ul || state.affiliation.ol) && state.affiliation.li) {
    delete state.affiliation.li
  }
  return state
}

/**
 * Creates a object that contains the formats selection state.
 *
 * @param {*} selection The selection.
 * @returns A object that represents the formats menu state.
 */
const createSelectionFormatState = formats => {
  // NOTE: Normally only one format can be selected but the selection is
  // given as array by Muya.
  const state = {}
  for (const item of formats) {
    state[item.type] = true
  }
  return state
}

export default { state, mutations, actions }
