<template>
  <div class="gallery-view">
    <a
      v-if="!readOnly"
      class="gallery-view__add"
      @click="$refs.rowCreateModal.show()"
    >
      <i class="fas fa-plus"></i>
    </a>
    <div ref="scroll" class="gallery-view__scroll">
      <div
        class="gallery-view__cards"
        :style="{
          height: height + 'px',
        }"
      >
        <RowCard
          v-for="slot in buffer"
          v-show="slot.left != -1"
          :key="'card-' + slot.id"
          :fields="cardFields"
          :row="slot.row === null ? {} : slot.row"
          :loading="slot.row === null"
          class="gallery-view__card"
          :style="{
            width: cardWidth + 'px',
            height: slot.row === null ? cardHeight + 'px' : undefined,
            transform: `translateX(${slot.left}px) translateY(${slot.top}px)`,
          }"
        ></RowCard>
      </div>
    </div>
    <RowCreateModal
      v-if="!readOnly"
      ref="rowCreateModal"
      :table="table"
      :fields="fields"
      :primary="primary"
      @created="createRow"
      @field-updated="$emit('refresh', $event)"
      @field-deleted="$emit('refresh')"
    ></RowCreateModal>
  </div>
</template>

<script>
import debounce from 'lodash/debounce'
import { mapGetters } from 'vuex'
import ResizeObserver from 'resize-observer-polyfill'

import { getCardHeight } from '@baserow/modules/database/utils/card'
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
import RowCard from '@baserow/modules/database/components/card/RowCard'
import RowCreateModal from '@baserow/modules/database/components/row/RowCreateModal'

export default {
  name: 'GalleryView',
  components: { RowCard, RowCreateModal },
  props: {
    primary: {
      type: Object,
      required: true,
    },
    fields: {
      type: Array,
      required: true,
    },
    view: {
      type: Object,
      required: true,
    },
    table: {
      type: Object,
      required: true,
    },
    database: {
      type: Object,
      required: true,
    },
    readOnly: {
      type: Boolean,
      required: true,
    },
    storePrefix: {
      type: String,
      required: true,
    },
  },
  data() {
    return {
      gutterSize: 30,
      minimumCardWidth: 280,
      height: 0,
      cardWidth: 0,
      buffer: [],
    }
  },
  computed: {
    firstRows() {
      return this.allRows.slice(0, 200)
    },
    /**
     * In order for the virtual scrolling to work, we need to know what the height of
     * the card is to correctly position it.
     */
    cardHeight() {
      return getCardHeight(this.cardFields, this.$registry)
    },
    /**
     * Returns the visible field objects in the right order.
     */
    cardFields() {
      return [this.primary]
        .concat(this.fields)
        .filter((field) => {
          const exists = Object.prototype.hasOwnProperty.call(
            this.fieldOptions,
            field.id
          )
          return !exists || (exists && !this.fieldOptions[field.id].hidden)
        })
        .sort((a, b) => {
          const orderA = this.fieldOptions[a.id]
            ? this.fieldOptions[a.id].order
            : maxPossibleOrderValue
          const orderB = this.fieldOptions[b.id]
            ? this.fieldOptions[b.id].order
            : maxPossibleOrderValue

          // First by order.
          if (orderA > orderB) {
            return 1
          } else if (orderA < orderB) {
            return -1
          }

          // Then by id.
          if (a.id < b.id) {
            return -1
          } else if (a.id > b.id) {
            return 1
          } else {
            return 0
          }
        })
    },
  },
  watch: {
    cardHeight() {
      this.$nextTick(() => {
        this.updateBuffer()
      })
    },
    allRows() {
      this.$nextTick(() => {
        this.updateBuffer()
      })
    },
  },
  mounted() {
    this.updateBuffer()
    this.$el.resizeObserver = new ResizeObserver(() => {
      this.updateBuffer()
    })
    this.$el.resizeObserver.observe(this.$el)

    const fireUpdateBuffer = {
      last: Date.now(),
      distance: 0,
    }

    // Debounce function that's called when the user scrolls really fast. This is to
    // make sure that the `updateBuffer` method is called with the
    // `dispatchVisibleRows` parameter to true when the user immediately stops
    // scrolling fast.
    const updateBufferDebounced = debounce(() => {
      this.updateBuffer(true)
    }, 100)

    this.$el.scrollEvent = (event) => {
      const now = Date.now()
      const { scrollTop } = event.target

      const distance = Math.abs(scrollTop - fireUpdateBuffer.distance)
      const timeDelta = now - fireUpdateBuffer.last

      if (timeDelta > 100) {
        const velocity = distance / timeDelta

        fireUpdateBuffer.last = now
        fireUpdateBuffer.distance = scrollTop

        if (velocity < 2.5) {
          // When scrolling "slow", the dispatchVisibleRows parameter is true so that
          // the visible rows are fetched if needed.
          updateBufferDebounced.cancel()
          this.updateBuffer(true)
        } else {
          // Check if the user is scrolling super fast because in that case we don't
          // fetch the rows when they're not needed.
          updateBufferDebounced()
          this.updateBuffer(false)
        }
      } else {
        // If scroll stopped within the 100ms we still want to have a last
        // updateBuffer(true) call.
        updateBufferDebounced()
        this.updateBuffer(false)
      }
    }
    this.$refs.scroll.addEventListener('scroll', this.$el.scrollEvent)
  },
  beforeDestroy() {
    this.$el.resizeObserver.unobserve(this.$el)
    this.$refs.scroll.removeEventListener('scroll', this.$el.scrollEvent)
  },
  beforeCreate() {
    this.$options.computed = {
      ...(this.$options.computed || {}),
      ...mapGetters({
        allRows: this.$options.propsData.storePrefix + 'view/gallery/getRows',
        fieldOptions:
          this.$options.propsData.storePrefix +
          'view/gallery/getAllFieldOptions',
      }),
    }
  },
  methods: {
    /**
     * This method makes sure that the correct cards/rows are shown based on the
     * scroll offset, viewport width, viewport height and card height. Based on these
     * values we can calculate which how many rows should be visible, which ones are
     * visible and what their position is without rendering all the rows in the store
     * at once.
     *
     * @TODO make this really virtual scrolling by letting the row persist in the same
     *   slot, even when it changes position. Currently, it updates all the cards when
     *   a new row of cards must be displayed.
     *
     * @param dispatchVisibleRows Indicates whether we want to dispatch the visibleRows
     *  action in the store. In some cases, when scrolling really fast through data we
     *  might want to wait a small moment before calling the action, which will make a
     *  request to the backend if needed.
     */
    updateBuffer(dispatchVisibleRows = true) {
      const el = this.$refs.scroll

      const gutterSize = this.gutterSize
      const containerWidth = el.clientWidth
      const containerHeight = el.clientHeight

      const cardsPerRow = Math.min(
        Math.max(Math.floor(containerWidth / this.minimumCardWidth), 1),
        20
      )
      const cardHeight = this.cardHeight
      const cardWidth = (containerWidth - gutterSize) / cardsPerRow - gutterSize
      const totalRows = Math.ceil(this.allRows.length / cardsPerRow)
      const height = totalRows * (cardHeight + gutterSize) + gutterSize

      this.cardWidth = cardWidth
      this.height = height

      const scrollTop = el.scrollTop
      const minimumCardsToRender =
        (Math.ceil(containerHeight / (cardHeight + gutterSize)) + 1) *
        cardsPerRow
      const startIndex =
        Math.floor(scrollTop / (cardHeight + gutterSize)) * cardsPerRow
      const endIndex = startIndex + minimumCardsToRender
      const visibleRows = this.allRows.slice(startIndex, endIndex)

      // Calculate an array containing only the rows that must be displayed and their
      // position in the gallery as if all the rows are there.
      this.buffer = visibleRows.map((row, positionInVisible) => {
        const positionInAll = startIndex + positionInVisible
        const left =
          gutterSize + (positionInAll % cardsPerRow) * (gutterSize + cardWidth)
        const top =
          gutterSize +
          Math.floor(positionInAll / cardsPerRow) * (gutterSize + cardHeight)

        return {
          id: positionInVisible,
          row,
          left,
          top,
        }
      })

      if (dispatchVisibleRows) {
        // Tell the store which rows/cards are visible so that it can fetch the missing
        // ones if needed.
        this.$store.dispatch(
          this.storePrefix + 'view/gallery/fetchMissingRowsInNewRange',
          {
            startIndex,
            endIndex,
          }
        )
      }
    },
    async createRow({ row, callback }) {
      try {
        await this.$store.dispatch(
          this.storePrefix + 'view/gallery/createNewRow',
          {
            view: this.view,
            table: this.table,
            fields: this.fields,
            primary: this.primary,
            values: row,
          }
        )
        callback()
      } catch (error) {
        callback(error)
      }
    },
  },
}
</script>