import { v1 as uuidv1 } from 'uuid'
import { StoreItemLookupError } from '@baserow/modules/core/errors'
import { uuid } from '@baserow/modules/core/utils/string'
import {
  createFiltersTree,
  readDefaultViewIdFromCookie,
  saveDefaultViewIdInCookie,
} from '@baserow/modules/database/utils/view'
import ViewService from '@baserow/modules/database/services/view'
import FilterService from '@baserow/modules/database/services/filter'
import DecorationService from '@baserow/modules/database/services/decoration'
import SortService from '@baserow/modules/database/services/sort'
import GroupByService from '@baserow/modules/database/services/groupBy'
import { clone } from '@baserow/modules/core/utils/object'
import { DATABASE_ACTION_SCOPES } from '@baserow/modules/database/utils/undoRedoConstants'
import { createNewUndoRedoActionGroupId } from '@baserow/modules/database/utils/action'

export function populateFilter(filter) {
  filter._ = {
    hover: false,
    loading: false,
  }
  return filter
}

export function populateFilterGroup(filterGroup) {
  filterGroup._ = {
    hover: false,
    loading: false,
  }
  return filterGroup
}

export function populateSort(sort) {
  sort._ = {
    hover: false,
    loading: false,
  }
  return sort
}

export function populateGroupBy(groupBy) {
  groupBy._ = {
    hover: false,
    loading: false,
    width: null,
  }
  return groupBy
}

export function populateDecoration(decoration) {
  decoration._ = { loading: false }
  return decoration
}

export function populateView(view, registry) {
  const type = registry.get('view', view.type)

  view._ = view._ || {
    type: type.serialize(),
    selected: false,
    loading: false,
    focusFilter: null,
  }

  view.isShared = type.isShared(view)

  if (Object.prototype.hasOwnProperty.call(view, 'filters')) {
    view.filters.forEach((filter) => {
      populateFilter(filter)
    })
  } else {
    view.filters = []
  }

  if (Object.prototype.hasOwnProperty.call(view, 'filter_groups')) {
    view.filter_groups.forEach((filterGroup) => {
      populateFilterGroup(filterGroup)
    })
  } else {
    view.filter_groups = []
  }

  if (Object.prototype.hasOwnProperty.call(view, 'sortings')) {
    view.sortings.forEach((sort) => {
      populateSort(sort)
    })
  } else {
    view.sortings = []
  }
  if (Object.prototype.hasOwnProperty.call(view, 'group_bys')) {
    view.group_bys.forEach((groupBy) => {
      populateGroupBy(groupBy)
    })
  } else {
    view.group_bys = []
  }

  if (Object.prototype.hasOwnProperty.call(view, 'group_bys')) {
    view.group_bys.forEach((groupBy) => {
      populateGroupBy(groupBy)
    })
  } else {
    view.group_bys = []
  }

  if (Object.prototype.hasOwnProperty.call(view, 'decorations')) {
    view.decorations.forEach((decoration) => {
      populateDecoration(decoration)
    })
  } else {
    view.decorations = []
  }

  return type.populate(view)
}

export const state = () => ({
  types: {},
  loading: false,
  items: [],
  selected: {},
  defaultViewId: null,
})

export const mutations = {
  SET_ITEMS(state, applications) {
    state.items = applications
  },
  SET_LOADING(state, value) {
    state.loading = value
  },
  SET_ITEM_LOADING(state, { view, value }) {
    if (!Object.prototype.hasOwnProperty.call(view, '_')) {
      return
    }
    view._.loading = value
  },
  ADD_ITEM(state, item) {
    if (!state.items.some((existingItem) => existingItem.id === item.id))
      state.items = [...state.items, item].sort((a, b) => a.order - b.order)
  },
  UPDATE_ITEM(state, { id, view, values, repopulate, readOnly }) {
    if (!readOnly) {
      const index = state.items.findIndex((item) => item.id === id)
      Object.assign(state.items[index], state.items[index], values)
      if (repopulate === true) {
        populateView(state.items[index], this.$registry)
      }
    } else {
      Object.assign(view, view, values)
    }
  },
  ORDER_ITEMS(state, { ownershipType, order }) {
    if (ownershipType === undefined) {
      const firstView = state.items.find((item) => item.id === order[0])
      ownershipType = firstView.ownership_type
    }
    const items = state.items.filter(
      (view) => view.ownership_type === ownershipType
    )
    items.forEach((view) => {
      const index = order.findIndex((value) => value === view.id)
      view.order = index === -1 ? 0 : index + 1
    })
  },
  DELETE_ITEM(state, id) {
    const index = state.items.findIndex((item) => item.id === id)
    state.items.splice(index, 1)
  },
  SET_SELECTED(state, view) {
    Object.values(state.items).forEach((item) => {
      item._.selected = false
    })
    view._.selected = true
    state.selected = view
  },
  UNSELECT(state) {
    Object.values(state.items).forEach((item) => {
      item._.selected = false
    })
    state.selected = {}
  },
  ADD_FILTER(state, { view, filter }) {
    filter.view = view.id
    view.filters.push(filter)
  },
  FINALIZE_FILTER(state, { view, oldId, id }) {
    const index = view.filters.findIndex((item) => item.id === oldId)
    if (index !== -1) {
      view.filters[index].id = id
      view.filters[index]._.loading = false
    }
  },
  SET_FILTER_FOCUS(state, { view, filterId }) {
    view._.focusFilter = filterId
  },
  DELETE_FILTER(state, { view, id }) {
    const index = view.filters.findIndex((item) => item.id === id)
    if (index !== -1) {
      view.filters.splice(index, 1)
    }
  },
  ADD_FILTER_GROUP(state, { view, filterGroup }) {
    filterGroup.view = view.id
    view.filter_groups.push(filterGroup)
  },
  FINALIZE_FILTER_GROUP(state, { view, oldId, id }) {
    const index = view.filter_groups.findIndex((item) => item.id === oldId)
    if (index !== -1) {
      view.filter_groups[index].id = id
      view.filter_groups[index]._.loading = false
    }
  },
  UPDATE_FILTER_GROUP(state, { filterGroup, values }) {
    Object.assign(filterGroup, filterGroup, values)
  },
  DELETE_FILTER_GROUP(state, { view, id }) {
    const index = view.filter_groups.findIndex((item) => item.id === id)
    if (index !== -1) {
      view.filter_groups.splice(index, 1)
    }
  },
  DELETE_FIELD_FILTERS(state, { view, fieldId }) {
    for (let i = view.filters.length - 1; i >= 0; i--) {
      if (view.filters[i].field === fieldId) {
        view.filters.splice(i, 1)
      }
    }
  },
  UPDATE_FILTER(state, { filter, values }) {
    Object.assign(filter, filter, values)
  },
  SET_FILTER_LOADING(state, { filter, value }) {
    filter._.loading = value
  },
  ADD_DECORATION(state, { view, decoration }) {
    view.decorations.push({
      type: null,
      value_provider_type: null,
      value_provider_conf: null,
      ...decoration,
    })
  },
  FINALIZE_DECORATION(state, { view, oldId, id }) {
    const index = view.decorations.findIndex((item) => item.id === oldId)
    if (index !== -1) {
      view.decorations[index].id = id
      view.decorations[index]._.loading = false
    }
  },
  DELETE_DECORATION(state, { view, id }) {
    const index = view.decorations.findIndex((item) => item.id === id)
    if (index !== -1) {
      view.decorations.splice(index, 1)
    }
  },
  UPDATE_DECORATION(state, { decoration, values }) {
    Object.assign(decoration, decoration, values)
  },
  SET_DECORATION_LOADING(state, { decoration, value }) {
    decoration._.loading = value
  },
  ADD_SORT(state, { view, sort }) {
    view.sortings.push(sort)
  },
  FINALIZE_SORT(state, { view, oldId, id }) {
    const index = view.sortings.findIndex((item) => item.id === oldId)
    if (index !== -1) {
      view.sortings[index].id = id
      view.sortings[index]._.loading = false
    }
  },
  DELETE_SORT(state, { view, id }) {
    const index = view.sortings.findIndex((item) => item.id === id)
    if (index !== -1) {
      view.sortings.splice(index, 1)
    }
  },
  DELETE_FIELD_SORTINGS(state, { view, fieldId }) {
    for (let i = view.sortings.length - 1; i >= 0; i--) {
      if (view.sortings[i].field === fieldId) {
        view.sortings.splice(i, 1)
      }
    }
  },
  UPDATE_SORT(state, { sort, values }) {
    Object.assign(sort, sort, values)
  },
  SET_SORT_LOADING(state, { sort, value }) {
    sort._.loading = value
  },
  ADD_GROUP_BY(state, { view, groupBy }) {
    view.group_bys.push(groupBy)
  },
  FINALIZE_GROUP_BY(state, { view, oldId, id }) {
    const index = view.group_bys.findIndex((item) => item.id === oldId)
    if (index !== -1) {
      view.group_bys[index].id = id
      view.group_bys[index]._.loading = false
    }
  },
  DELETE_GROUP_BY(state, { view, id }) {
    const index = view.group_bys.findIndex((item) => item.id === id)
    if (index !== -1) {
      view.group_bys.splice(index, 1)
    }
  },
  DELETE_FIELD_GROUP_BYS(state, { view, fieldId }) {
    for (let i = view.group_bys.length - 1; i >= 0; i--) {
      if (view.group_bys[i].field === fieldId) {
        view.group_bys.splice(i, 1)
      }
    }
  },
  UPDATE_GROUP_BY(state, { groupBy, values }) {
    Object.assign(groupBy, groupBy, values)
  },
  SET_GROUP_BY_LOADING(state, { groupBy, value }) {
    groupBy._.loading = value
  },
  /**
   * Data for defaultViewId for Vuex store:
   * {
   *   defaultViewId: view1Id,
   * }
   */
  SET_DEFAULT_VIEW_ID(state, viewId) {
    state.defaultViewId = viewId
  },
}

export const actions = {
  /**
   * Changes the loading state of a specific view.
   */
  setItemLoading({ commit }, { view, value }) {
    commit('SET_ITEM_LOADING', { view, value })
  },
  /**
   * Fetches all the views of a given table. The is mostly called when the user
   * selects a different table.
   */
  async fetchAll({ commit, getters, dispatch, state }, table) {
    commit('SET_LOADING', true)
    commit('UNSELECT', {})

    try {
      const { data } = await ViewService(this.$client).fetchAll(
        table.id,
        true,
        true,
        true,
        true
      )
      data.forEach((part, index, d) => {
        populateView(data[index], this.$registry)
      })
      commit('SET_ITEMS', data)
      commit('SET_LOADING', false)

      // Get the default view for the table.
      const defaultViewId = readDefaultViewIdFromCookie(this.$cookies, table.id)
      if (defaultViewId !== null) {
        commit('SET_DEFAULT_VIEW_ID', defaultViewId)
      }
    } catch (error) {
      commit('SET_ITEMS', [])
      commit('SET_LOADING', false)

      throw error
    }
  },
  /**
   * Creates a new view with the provided type for the given table.
   */
  async create(
    { commit, getters, rootGetters, dispatch },
    { type, table, values }
  ) {
    if (Object.prototype.hasOwnProperty.call(values, 'type')) {
      throw new Error(
        'The key "type" is a reserved, but is already set on the ' +
          'values when creating a new view.'
      )
    }

    if (!this.$registry.exists('view', type)) {
      throw new Error(`A view with type "${type}" doesn't exist.`)
    }

    const postData = clone(values)
    postData.type = type

    const { data } = await ViewService(this.$client).create(table.id, postData)
    return await dispatch('forceCreate', { data })
  },
  /**
   * Forcefully create a new view without making a request to the server.
   */
  forceCreate({ commit }, { data }) {
    populateView(data, this.$registry)
    commit('ADD_ITEM', data)
    return { view: data }
  },
  /**
   * Updates the values of the view with the provided id.
   */
  async update(
    { commit, dispatch },
    {
      view,
      values,
      readOnly = false,
      refreshFromFetch = false,
      optimisticUpdate = true,
    }
  ) {
    commit('SET_ITEM_LOADING', { view, value: true })
    const oldValues = {}
    const newValues = {}
    Object.keys(values).forEach((name) => {
      if (Object.prototype.hasOwnProperty.call(view, name)) {
        oldValues[name] = view[name]
        newValues[name] = values[name]
      }
    })

    function updatePublicViewHasPassword() {
      // public_view_has_password needs to be updated after the api request
      // is finished and the modal closes.
      const viewHasPassword = Object.keys(values).includes(
        'public_view_password'
      )
        ? values.public_view_password !== ''
        : view.public_view_has_password
      // update the password protection toggle state accordingly
      dispatch('forceUpdate', {
        view,
        values: {
          public_view_has_password: viewHasPassword,
        },
      })
    }

    if (optimisticUpdate) {
      dispatch('forceUpdate', {
        view,
        values: newValues,
        repopulate: true,
        readOnly,
      })
    }
    try {
      if (!readOnly) {
        dispatch(
          'undoRedo/updateCurrentScopeSet',
          DATABASE_ACTION_SCOPES.view(view.id),
          {
            root: true,
          }
        )
        // in some cases view may return extra data that were not present in values
        const newValues = (
          await ViewService(this.$client).update(view.id, values)
        ).data
        if (refreshFromFetch || !optimisticUpdate) {
          dispatch('forceUpdate', { view, values: newValues, repopulate: true })
        }

        updatePublicViewHasPassword()
      }
      commit('SET_ITEM_LOADING', { view, value: false })
    } catch (error) {
      commit('SET_ITEM_LOADING', { view, value: false })
      dispatch('forceUpdate', { view, values: oldValues })
      throw error
    }
  },
  /**
   * Updates the order of all the views in a table.
   */
  async order({ commit, getters }, { table, ownershipType, order, oldOrder }) {
    commit('ORDER_ITEMS', { ownershipType, order })

    try {
      await ViewService(this.$client).order(table.id, ownershipType, order)
    } catch (error) {
      commit('ORDER_ITEMS', { ownershipType, order: oldOrder })
      throw error
    }
  },
  /**
   * Forcefully update an existing view without making a request to the backend.
   */
  forceUpdate(
    { commit },
    { view, values, repopulate = false, readOnly = false }
  ) {
    commit('UPDATE_ITEM', {
      id: view.id,
      view,
      values,
      repopulate,
      readOnly,
    })
  },
  /**
   * Duplicates an existing view.
   */
  async duplicate({ commit, dispatch }, view) {
    const { data } = await ViewService(this.$client).duplicate(view.id)
    await dispatch('forceCreate', { data })
    return data
  },
  /**
   * Deletes an existing view with the provided id. A request to the server is first
   * made and after that it will be deleted from the store.
   */
  async delete({ commit, dispatch }, view) {
    try {
      await ViewService(this.$client).delete(view.id)
      dispatch('forceDelete', view)
    } catch (error) {
      // If the view to delete wasn't found we can just delete it from the
      // state.
      if (error.response && error.response.status === 404) {
        dispatch('forceDelete', view)
      } else {
        throw error
      }
    }
  },
  /**
   * Removes the view from the this store without making a delete request to the server.
   */
  forceDelete({ commit, dispatch, getters, rootGetters }, view) {
    // If the currently selected view is selected.
    if (view._.selected && view.id === getters.getSelectedId) {
      commit('UNSELECT')

      const route = this.$router.history.current
      const tableId = view.table.id

      // If the current route is the same table as the deleting view.
      if (
        route.name === 'database-table' &&
        parseInt(route.params.tableId) === tableId
      ) {
        // Check if there are any other views and figure out what the next selected
        // view should be. This is always the first one in the list.
        const otherViews = getters.getAll
          .filter((v) => view.id !== v.id)
          .sort((a, b) => a.order - b.order)
        const nextView = otherViews.length > 0 ? otherViews[0] : null

        if (nextView !== null) {
          // If there is a next view, we can redirect to that page.
          this.$router.replace({ params: { viewId: nextView.id } })
        } else if (route.params.viewId) {
          // If there isn't a next view and the user was already viewing a view, we
          // need to redirect to the empty table page.
          this.$router.replace({ params: { viewId: null } })
        } else {
          // If there isn't a next view and the user wasn't looking at a view, we need
          // to refresh to show an empty table page. Changing the view id to 0,
          // which never exists forces the table page to show empty. We have
          // to do it this way because we can't navigate to the page without view.
          this.$router.replace({ params: { viewId: '0' } })
        }
      }
    }

    commit('DELETE_ITEM', view.id)
  },
  /**
   * Select a view and fetch all the applications related to that view. Note that
   * only the views of the selected table are stored in this store. It might be
   * possible you need to select the table first.
   */
  select({ commit, dispatch }, view) {
    commit('SET_SELECTED', view)
    commit('SET_DEFAULT_VIEW_ID', view.id)

    // Set the default view for the table.
    saveDefaultViewIdInCookie(this.$cookies, view, this.$config)

    dispatch(
      'undoRedo/updateCurrentScopeSet',
      DATABASE_ACTION_SCOPES.view(view.id),
      {
        root: true,
      }
    )
    return { view }
  },
  /**
   * Unselect the currently selected view.
   */
  unselect({ commit, dispatch }) {
    commit('UNSELECT', {})
    dispatch(
      'undoRedo/updateCurrentScopeSet',
      DATABASE_ACTION_SCOPES.view(null),
      {
        root: true,
      }
    )
  },
  /**
   * Selects a view by a given view id. Note that only the views of the selected
   * table are stored in this store. It might be possible you need to select the
   * table first.
   */
  selectById({ dispatch, getters }, id) {
    const view = getters.get(id)
    if (view === undefined) {
      throw new StoreItemLookupError(`View with id ${id} is not found.`)
    }
    return dispatch('select', view)
  },
  /**
   * Changes the loading state of a specific filter.
   */
  setFilterLoading({ commit }, { filter, value }) {
    commit('SET_FILTER_LOADING', { filter, value })
  },
  /**
   * Focus a specific filter.
   */
  setFocusFilter({ commit }, { view, filterId }) {
    commit('SET_FILTER_FOCUS', { view, filterId })
  },
  /**
   * Creates a new filter and adds it to the store right away. If the API call succeeds
   * the filter ID will be added, but if it fails it will be removed from the store.
   * It also create the filter group if it doesn't exist yet in the same optimistic
   * way, removing it if the API call fails.
   */
  async createFilter(
    { commit },
    {
      view,
      field,
      values,
      emitEvent = true,
      readOnly = false,
      filterGroupId = null,
      parentGroupId = null,
    }
  ) {
    // If the type is not provided we are going to choose the first available type.
    if (!Object.prototype.hasOwnProperty.call(values, 'type')) {
      const viewFilterTypes = this.$registry.getAll('viewFilter')
      const compatibleType = Object.values(viewFilterTypes).find(
        (viewFilterType) => {
          return viewFilterType.fieldIsCompatible(field)
        }
      )
      if (compatibleType === undefined) {
        throw new Error(
          `No compatible filter type could be found for field' ${field.type}`
        )
      }
      values.type = compatibleType.type
    }

    // If the value is not provided, then we use the default value related to the type.
    if (!Object.prototype.hasOwnProperty.call(values, 'value')) {
      const viewFilterType = this.$registry.get('viewFilter', values.type)
      values.value = viewFilterType.getDefaultValue(field)
    }

    // Some filter input components expect the preload values to exist, that's why we
    // need to add an empty object if it doesn't yet exist. They can all handle
    // empty preload_values.
    if (!Object.prototype.hasOwnProperty.call(values, 'preload_values')) {
      values.preload_values = {}
    }

    // If the filter group doesn't exist yet optimistically create it.
    // If we first create the filter group and only once that succeeds create the
    // filter itself, we can run into a situation where a user with a slow connection
    // will see an empty group first and the filter only after a while. This code
    // will optimistically create both the group and the filter to provide a smoother
    // experience.
    const createNewFilterGroup =
      filterGroupId &&
      view.filter_groups.findIndex((group) => group.id === filterGroupId) === -1

    const filterGroup = {}
    if (createNewFilterGroup) {
      populateFilterGroup(filterGroup)
      filterGroup.id = filterGroupId
      filterGroup._.loading = !readOnly
      filterGroup.filter_type = 'AND'
      filterGroup.parent_group = parentGroupId
      commit('ADD_FILTER_GROUP', { view, filterGroup })
    }

    const filter = Object.assign({}, values)
    populateFilter(filter)
    filter.id = uuidv1()
    filter._.loading = !readOnly
    filter.group = filterGroupId
    values.group = filterGroupId
    commit('ADD_FILTER', { view, filter })

    if (emitEvent) {
      this.$bus.$emit('view-filter-created', { view, filter })
    }
    commit('SET_FILTER_FOCUS', { view, filterId: filter.id })

    const undoRedoActionGroupId = createNewUndoRedoActionGroupId()
    if (!readOnly) {
      if (createNewFilterGroup) {
        // The group needs to be created first before we can create the filter
        // in the case we're trying to create a new filter in a new group.
        try {
          const { data } = await FilterService(this.$client).createGroup(
            view.id,
            parentGroupId,
            undoRedoActionGroupId
          )
          commit('FINALIZE_FILTER_GROUP', {
            view,
            oldId: filterGroup.id,
            id: data.id,
          })
          // update the group id with the created group id
          values.group = data.id
          commit('UPDATE_FILTER', { filter, values: { group: data.id } })
        } catch (error) {
          commit('DELETE_FILTER_GROUP', { view, id: filterGroup.id })
          commit('DELETE_FILTER', { view, id: filter.id })
          throw error
        }
      }

      try {
        const { data } = await FilterService(this.$client).create(
          view.id,
          values,
          undoRedoActionGroupId
        )
        commit('FINALIZE_FILTER', { view, oldId: filter.id, id: data.id })
      } catch (error) {
        commit('DELETE_FILTER', { view, id: filter.id })
        throw error
      }
    }

    return { filter }
  },
  /**
   * Creates a new filter group and adds it to the store right away. If the API
   * call succeeds the filter group ID will be updated, but if it fails it will be
   * removed from the store.
   */
  async createFilterGroup({ commit }, { view, readOnly = false }) {
    const filterGroup = {}
    populateFilterGroup(filterGroup)
    filterGroup.id = uuidv1()
    filterGroup._.loading = !readOnly
    filterGroup.filter_type = 'AND'

    commit('ADD_FILTER_GROUP', { view, filterGroup })

    try {
      const { data } = await FilterService(this.$client).createGroup(view.id)
      commit('FINALIZE_FILTER_GROUP', {
        view,
        oldId: filterGroup.id,
        id: data.id,
      })
    } catch (error) {
      commit('DELETE_FILTER_GROUP', { view, id: filterGroup.id })
      throw error
    }

    return { filterGroup }
  },
  /**
   * Forcefully create a new view filter group without making a request to the backend.
   */
  forceCreateFilterGroup({ commit }, { view, values }) {
    const filterGroup = Object.assign({}, values)
    populateFilterGroup(filterGroup)
    commit('ADD_FILTER_GROUP', { view, filterGroup })
  },
  /**
   * Forcefully create a new view filter without making a request to the backend.
   */
  forceCreateFilter({ commit }, { view, values }) {
    const filter = Object.assign({}, values) // clone the object
    populateFilter(filter)
    commit('ADD_FILTER', { view, filter })
  },
  /**
   * Updates the filter values in the store right away. If the API call fails the
   * changes will be undone.
   */
  async updateFilter(
    { dispatch, commit },
    { filter, values, readOnly = false }
  ) {
    commit('SET_FILTER_LOADING', { filter, value: true })

    const oldValues = {}
    const newValues = {}
    Object.keys(values).forEach((name) => {
      if (Object.prototype.hasOwnProperty.call(filter, name)) {
        oldValues[name] = filter[name]
        newValues[name] = values[name]
      }
    })

    // When updating a filter, the preload values must be cleared because they
    // might not match the filter anymore.
    newValues.preload_values = {}

    dispatch('forceUpdateFilter', { filter, values: newValues })

    try {
      if (!readOnly) {
        await FilterService(this.$client).update(filter.id, values)
      }
      commit('SET_FILTER_LOADING', { filter, value: false })
    } catch (error) {
      dispatch('forceUpdateFilter', { filter, values: oldValues })
      commit('SET_FILTER_LOADING', { filter, value: false })
      throw error
    }
  },
  /**
   *
   */
  async updateFilterGroup(
    { dispatch },
    { filterGroup, values, readOnly = false }
  ) {
    const oldValues = {}
    const newValues = {}
    Object.keys(values).forEach((name) => {
      if (Object.prototype.hasOwnProperty.call(filterGroup, name)) {
        oldValues[name] = filterGroup[name]
        newValues[name] = values[name]
      }
    })

    dispatch('forceUpdateFilterGroup', {
      filterGroup,
      values: newValues,
    })

    try {
      if (!readOnly) {
        await FilterService(this.$client).updateGroup(filterGroup.id, values)
      }
    } catch (error) {
      dispatch('forceUpdateFilterGroup', {
        filterGroup,
        values: oldValues,
      })
      throw error
    }
  },
  /**
   * Forcefully update an existing view filter group without making a request to the backend.
   */
  forceUpdateFilterGroup({ commit }, { filterGroup, values }) {
    commit('UPDATE_FILTER_GROUP', { filterGroup, values })
  },
  /**
   * Forcefully update an existing view filter without making a request to the backend.
   */
  forceUpdateFilter({ commit }, { filter, values }) {
    commit('UPDATE_FILTER', { filter, values })
  },
  /**
   * Deletes an existing filter. A request to the server will be made first and
   * after that it will be deleted.
   */
  async deleteFilter({ dispatch, commit }, { view, filter, readOnly = false }) {
    commit('SET_FILTER_LOADING', { filter, value: true })

    try {
      if (!readOnly) {
        await FilterService(this.$client).delete(filter.id)
      }
      dispatch('forceDeleteFilter', { view, filter })
    } catch (error) {
      commit('SET_FILTER_LOADING', { filter, value: false })
      throw error
    }
  },
  /**
   * Forcefully delete an existing filter without making a request to the backend.
   */
  forceDeleteFilter({ commit }, { view, filter }) {
    commit('DELETE_FILTER', { view, id: filter.id })
  },
  /**
   * Deletes an existing filter. A request to the server will be made first and
   * after that it will be deleted.
   */
  async deleteFilterGroup(
    { dispatch, commit },
    { view, filterGroup, readOnly = false }
  ) {
    const filters = view.filters.filter((f) => f.group === filterGroup.id)
    for (const filter of filters) {
      commit('SET_FILTER_LOADING', { filter, value: true })
    }

    try {
      if (!readOnly) {
        await FilterService(this.$client).deleteGroup(filterGroup.id)
      }
      dispatch('forceDeleteFilterGroup', {
        view,
        filterGroup,
      })
    } catch (error) {
      for (const filter of filters) {
        commit('SET_FILTER_LOADING', { filter, value: false })
      }
      throw error
    }
  },
  /**
   * Forcefully delete an existing filter group without making a request to the backend.
   * This function will also delete all the filters that are part of the group and all
   * the child groups and filters.
   */
  forceDeleteFilterGroup({ commit }, { view, filterGroup }) {
    const filtersTree = createFiltersTree(
      view.filter_type,
      view.filters,
      view.filter_groups
    )
    const groupNode = filtersTree.findNodeByGroupId(filterGroup.id)
    if (groupNode === null) {
      return
    }
    const deleteFromNode = (node) => {
      for (const child in node.children) {
        deleteFromNode(node.children[child])
      }
      for (const filter of node.filters) {
        commit('DELETE_FILTER', { view, id: filter.id })
      }
      commit('DELETE_FILTER_GROUP', { view, id: node.groupId })
    }

    deleteFromNode(groupNode)
  },
  /**
   * When a field is deleted the related filters are also automatically deleted in the
   * backend so they need to be removed here.
   */
  deleteFieldFilters({ commit, getters }, { field }) {
    getters.getAll.forEach((view) => {
      commit('DELETE_FIELD_FILTERS', { view, fieldId: field.id })
    })
  },

  /**
   * Creates a new decoration and adds it to the store right away. If the API call succeeds
   * the decorator ID will be updatede, but if it fails it will be removed from the store.
   */
  async createDecoration({ commit }, { view, values, readOnly = false }) {
    const decoration = { ...values }
    populateDecoration(decoration)
    decoration.id = uuid()
    decoration._.loading = !readOnly

    commit('ADD_DECORATION', { view, decoration })

    try {
      if (!readOnly) {
        const { data } = await DecorationService(this.$client).create(
          view.id,
          values
        )
        commit('FINALIZE_DECORATION', {
          view,
          oldId: decoration.id,
          id: data.id,
        })
      }
    } catch (error) {
      commit('DELETE_DECORATION', { view, id: decoration.id })
      throw error
    }

    return { decoration }
  },
  /**
   * Forcefully create a new view decoration without making a request to the backend.
   */
  forceCreateDecoration({ commit }, { view, values }) {
    const decoration = { ...values }
    populateDecoration(decoration)
    commit('ADD_DECORATION', { view, decoration })
  },
  /**
   * Updates the decoration values in the store right away. If the API call fails the
   * changes will be undone.
   */
  async updateDecoration(
    { dispatch, commit },
    { decoration, values, readOnly = false }
  ) {
    commit('SET_DECORATION_LOADING', { decoration, value: true })

    const oldValues = {}
    const newValues = {}
    Object.keys(values).forEach((name) => {
      if (Object.prototype.hasOwnProperty.call(decoration, name)) {
        oldValues[name] = decoration[name]
        newValues[name] = values[name]
      }
    })

    dispatch('forceUpdateDecoration', { decoration, values: newValues })

    try {
      if (!readOnly) {
        await DecorationService(this.$client).update(decoration.id, values)
      }
      commit('SET_DECORATION_LOADING', { decoration, value: false })
    } catch (error) {
      dispatch('forceUpdateDecoration', { decoration, values: oldValues })
      commit('SET_DECORATION_LOADING', { decoration, value: false })
      throw error
    }
  },
  /**
   * Forcefully update an existing view decoration without making a request to the
   * backend.
   */
  forceUpdateDecoration({ commit }, { decoration, values }) {
    commit('UPDATE_DECORATION', { decoration, values })
  },
  /**
   * Deletes an existing decoration. A request to the server will be made first and
   * after that it will be deleted.
   */
  async deleteDecoration(
    { dispatch, commit },
    { view, decoration, readOnly = false }
  ) {
    commit('SET_DECORATION_LOADING', { decoration, value: true })
    dispatch('forceDeleteDecoration', { view, decoration })

    try {
      if (!readOnly) {
        await DecorationService(this.$client).delete(decoration.id)
      }
    } catch (error) {
      // Restore decoration in case of error
      dispatch('forceCreateDecoration', {
        view,
        values: decoration,
      })
      commit('SET_DECORATION_LOADING', { decoration, value: false })
      throw error
    }
  },
  /**
   * Forcefully delete an existing decoration without making a request to the backend.
   */
  forceDeleteDecoration({ commit }, { view, decoration }) {
    commit('DELETE_DECORATION', { view, id: decoration.id })
  },
  /**
   * Changes the loading state of a specific sort.
   */
  setSortLoading({ commit }, { sort, value }) {
    commit('SET_SORT_LOADING', { sort, value })
  },
  /**
   * Creates a new sort and adds it to the store right away. If the API call succeeds
   * the row ID will be added, but if it fails it will be removed from the store.
   */
  async createSort({ getters, commit }, { view, values, readOnly = false }) {
    // If the order is not provided we are going to choose the ascending order.
    if (!Object.prototype.hasOwnProperty.call(values, 'order')) {
      values.order = 'ASC'
    }

    const sort = Object.assign({}, values)
    populateSort(sort)
    sort.id = uuid()
    sort._.loading = !readOnly

    commit('ADD_SORT', { view, sort })

    if (!readOnly) {
      try {
        const { data } = await SortService(this.$client).create(view.id, values)
        commit('FINALIZE_SORT', { view, oldId: sort.id, id: data.id })
      } catch (error) {
        commit('DELETE_SORT', { view, id: sort.id })
        throw error
      }
    }

    return { sort }
  },
  /**
   * Forcefully create a new  view sorting without making a request to the backend.
   */
  forceCreateSort({ commit }, { view, values }) {
    const sort = Object.assign({}, values)
    populateSort(sort)
    commit('ADD_SORT', { view, sort })
  },
  /**
   * Updates the sort values in the store right away. If the API call fails the
   * changes will be undone.
   */
  async updateSort({ dispatch, commit }, { sort, values, readOnly = false }) {
    commit('SET_SORT_LOADING', { sort, value: true })

    const oldValues = {}
    const newValues = {}
    Object.keys(values).forEach((name) => {
      if (Object.prototype.hasOwnProperty.call(sort, name)) {
        oldValues[name] = sort[name]
        newValues[name] = values[name]
      }
    })

    dispatch('forceUpdateSort', { sort, values: newValues })

    try {
      if (!readOnly) {
        await SortService(this.$client).update(sort.id, values)
      }
      commit('SET_SORT_LOADING', { sort, value: false })
    } catch (error) {
      dispatch('forceUpdateSort', { sort, values: oldValues })
      commit('SET_SORT_LOADING', { sort, value: false })
      throw error
    }
  },
  /**
   * Forcefully update an existing view sort without making a request to the backend.
   */
  forceUpdateSort({ commit }, { sort, values }) {
    commit('UPDATE_SORT', { sort, values })
  },
  /**
   * Deletes an existing sort. A request to the server will be made first and
   * after that it will be deleted.
   */
  async deleteSort({ dispatch, commit }, { view, sort, readOnly = false }) {
    commit('SET_SORT_LOADING', { sort, value: true })

    try {
      if (!readOnly) {
        await SortService(this.$client).delete(sort.id)
      }
      dispatch('forceDeleteSort', { view, sort })
    } catch (error) {
      commit('SET_SORT_LOADING', { sort, value: false })
      throw error
    }
  },
  /**
   * Forcefully delete an existing view sort without making a request to the backend.
   */
  forceDeleteSort({ commit }, { view, sort }) {
    commit('DELETE_SORT', { view, id: sort.id })
  },
  /**
   * When a field is deleted the related sortings are also automatically deleted in the
   * backend so they need to be removed here.
   */
  deleteFieldSortings({ commit, getters }, { field }) {
    getters.getAll.forEach((view) => {
      commit('DELETE_FIELD_SORTINGS', { view, fieldId: field.id })
    })
  },
  /**
   * Changes the loading state of a specific groupBy.
   */
  setGroupByLoading({ commit }, { groupBy, value }) {
    commit('SET_GROUP_BY_LOADING', { groupBy, value })
  },
  /**
   * Creates a new groupBy and adds it to the store right away. If the API call succeeds
   * the row ID will be added, but if it fails it will be removed from the store.
   */
  async createGroupBy({ getters, commit }, { view, values, readOnly = false }) {
    // If the order is not provided we are going to choose the ascending order.
    if (!Object.prototype.hasOwnProperty.call(values, 'order')) {
      values.order = 'ASC'
    }

    if (!Object.prototype.hasOwnProperty.call(values, 'width')) {
      values.width = 200
    }

    const groupBy = Object.assign({}, values)
    populateGroupBy(groupBy)
    groupBy.id = uuid()
    groupBy._.loading = !readOnly

    commit('ADD_GROUP_BY', { view, groupBy })

    if (!readOnly) {
      try {
        const { data } = await GroupByService(this.$client).create(
          view.id,
          values
        )
        commit('FINALIZE_GROUP_BY', { view, oldId: groupBy.id, id: data.id })
      } catch (error) {
        commit('DELETE_GROUP_BY', { view, id: groupBy.id })
        throw error
      }
    }

    return { groupBy }
  },
  /**
   * Forcefully create a new  view group by without making a request to the backend.
   */
  forceCreateGroupBy({ commit }, { view, values }) {
    const groupBy = Object.assign({}, values)
    populateGroupBy(groupBy)
    commit('ADD_GROUP_BY', { view, groupBy })
  },
  /**
   * Updates the groupBy values in the store right away. If the API call fails the
   * changes will be undone.
   */
  async updateGroupBy(
    { dispatch, commit },
    { groupBy, values, readOnly = false }
  ) {
    commit('SET_GROUP_BY_LOADING', { groupBy, value: true })

    const oldValues = {}
    const newValues = {}
    Object.keys(values).forEach((name) => {
      if (Object.prototype.hasOwnProperty.call(groupBy, name)) {
        oldValues[name] = groupBy[name]
        newValues[name] = values[name]
      }
    })

    dispatch('forceUpdateGroupBy', { groupBy, values: newValues })

    try {
      if (!readOnly) {
        await GroupByService(this.$client).update(groupBy.id, values)
      }
      commit('SET_GROUP_BY_LOADING', { groupBy, value: false })
    } catch (error) {
      dispatch('forceUpdateGroupBy', { groupBy, values: oldValues })
      commit('SET_GROUP_BY_LOADING', { groupBy, value: false })
      throw error
    }
  },
  /**
   * Forcefully update an existing view groupBy without making a request to the backend.
   */
  forceUpdateGroupBy({ commit }, { groupBy, values }) {
    commit('UPDATE_GROUP_BY', { groupBy, values })
  },
  /**
   * Deletes an existing groupBy. A request to the server will be made first and
   * after that it will be deleted.
   */
  async deleteGroupBy(
    { dispatch, commit },
    { view, groupBy, readOnly = false }
  ) {
    commit('SET_GROUP_BY_LOADING', { groupBy, value: true })

    try {
      if (!readOnly) {
        await GroupByService(this.$client).delete(groupBy.id)
      }
      dispatch('forceDeleteGroupBy', { view, groupBy })
    } catch (error) {
      commit('SET_GROUP_BY_LOADING', { groupBy, value: false })
      throw error
    }
  },
  /**
   * Forcefully delete an existing view groupBy without making a request to the backend.
   */
  forceDeleteGroupBy({ commit }, { view, groupBy }) {
    commit('DELETE_GROUP_BY', { view, id: groupBy.id })
  },
  /**
   * When a field is deleted the related group bys are also automatically deleted in the
   * backend so they need to be removed here.
   */
  deleteFieldGroupBys({ commit, getters }, { field }) {
    getters.getAll.forEach((view) => {
      commit('DELETE_FIELD_GROUP_BYS', { view, fieldId: field.id })
    })
  },

  /**
   * Is called when a field is restored. Will force create all filters and sortings
   * provided along with the field.
   */
  fieldRestored({ dispatch, commit, getters }, { field, fieldType, view }) {
    dispatch('resetFieldsFiltersSortsAndGroupBysInView', { field, view })
  },
  /**
   * Called when a field is restored. Will force create all filters and sortings
   * provided along with the field.
   */
  resetFieldsFiltersSortsAndGroupBysInView(
    { dispatch, commit, getters },
    { field, view }
  ) {
    if (field.filters != null) {
      commit('DELETE_FIELD_FILTERS', { view, fieldId: field.id })
      field.filters
        .filter((filter) => filter.view === view.id)
        .forEach((filter) => {
          dispatch('forceCreateFilter', { view, values: filter })
        })
    }
    if (field.sortings != null) {
      commit('DELETE_FIELD_SORTINGS', { view, fieldId: field.id })
      field.sortings
        .filter((sorting) => sorting.view === view.id)
        .forEach((sorting) => {
          dispatch('forceCreateSort', { view, values: sorting })
        })
    }
    if (field.group_bys != null) {
      commit('DELETE_FIELD_GROUP_BYS', { view, fieldId: field.id })
      field.group_bys
        .filter((groupBy) => groupBy.view === view.id)
        .forEach((groupBy) => {
          dispatch('forceCreateSort', { view, values: groupBy })
        })
    }
  },
  /**
   * Is called when a field is updated. It will check if there are filters related
   * to the delete field.
   */
  fieldUpdated({ dispatch, commit, getters }, { field, fieldType }) {
    // Remove all filters are not compatible anymore.
    getters.getAll.forEach((view) => {
      view.filters
        .filter((filter) => filter.field === field.id)
        .forEach((filter) => {
          const filterType = this.$registry.get('viewFilter', filter.type)
          const compatible = filterType.fieldIsCompatible(field)
          if (!compatible) {
            commit('DELETE_FILTER', { view, id: filter.id })
          }
        })
    })

    // Remove all the field sortings because the new field does not support sortings
    // at all.
    if (!fieldType.getCanSortInView(field)) {
      dispatch('deleteFieldSortings', { field })
    }

    // Remove all the field group bys because the new field does not support group bys
    // at all.
    if (!fieldType.getCanGroupByInView(field)) {
      dispatch('deleteFieldGroupBys', { field })
    }
  },
  /**
   * Is called when a field is deleted. It will remove all filters and sortings
   * related to the field.
   */
  fieldDeleted({ dispatch }, { field }) {
    dispatch('deleteFieldFilters', { field })
    dispatch('deleteFieldSortings', { field })
    dispatch('deleteFieldGroupBys', { field })
  },
}

export const getters = {
  hasSelected(state) {
    return Object.prototype.hasOwnProperty.call(state.selected, '_')
  },
  getSelected(state) {
    return state.selected
  },
  getSelectedId(state) {
    return state.selected.id || 0
  },
  get: (state) => (id) => {
    return state.items.find((item) => item.id === id)
  },
  first(state, getters) {
    const items = getters.getAllOrdered
    return items.length > 0 ? items[0] : null
  },
  // currently only used during unit tests:
  defaultId: (state) => {
    return state.defaultViewId
  },
  default: (state, getters) => {
    return getters.get(state.defaultViewId)
  },
  getAll(state) {
    return state.items
  },
  getAllOrdered(state) {
    return state.items.map((item) => item).sort((a, b) => a.order - b.order)
  },
}

export default {
  namespaced: true,
  state,
  getters,
  actions,
  mutations,
}