import { isDomElement, isElement } from '@baserow/modules/core/utils/dom'

export default {
  props: {
    value: {
      type: [String, Number, Boolean, Object],
      required: false,
      default: null,
    },
    searchText: {
      type: String,
      required: false,
      default: 'Search',
    },
    showSearch: {
      type: Boolean,
      required: false,
      default: true,
    },
    showInput: {
      type: Boolean,
      required: false,
      default: true,
    },
    disabled: {
      type: Boolean,
      required: false,
      default: false,
    },
  },
  data() {
    return {
      loaded: false,
      open: false,
      name: null,
      icon: null,
      query: '',
      hasItems: true,
      hover: null,
    }
  },
  computed: {
    selectedName() {
      return this.getSelectedProperty(this.value, 'name')
    },
    selectedIcon() {
      return this.getSelectedProperty(this.value, 'icon')
    },
  },
  watch: {
    value() {
      this.$nextTick(() => {
        // When the value changes we want to forcefully reload the selectName and
        // selectedIcon a little bit later because the children might have changed.
        this.forceRefreshSelectedValue()
      })
    },
  },
  mounted() {
    // When the component is mounted we want to forcefully reload the selectedName and
    // selectedIcon.
    this.forceRefreshSelectedValue()
  },
  methods: {
    /**
     * Toggles the open state of the dropdown menu.
     */
    toggle(target, value) {
      if (value === undefined) {
        value = !this.open
      }

      if (value) {
        this.show(target)
      } else {
        this.hide()
      }
    },
    /**
     * Shows the lists of choices, so a user can change the value.
     */
    show(target) {
      if (this.disabled) {
        return
      }

      const isElementOrigin = isDomElement(target)

      this.open = true
      this.hover = this.value
      this.opener = isElementOrigin ? target : null
      this.$emit('show')

      this.$nextTick(() => {
        // We have to wait for the input to be visible before we can focus.
        this.showSearch && this.$refs.search.focus()

        // Scroll to the selected child.
        this.$children.forEach((child) => {
          if (child.value === this.value) {
            this.$refs.items.scrollTop =
              child.$el.offsetTop -
              child.$el.clientHeight -
              Math.round(this.$refs.items.clientHeight / 2)
          }
        })
      })

      // If the user clicks outside the dropdown while the list of choices of open we
      // have to hide them.
      this.$el.clickOutsideEvent = (event) => {
        if (
          // Check if the context menu is still open
          this.open &&
          // If the click was outside the context element because we want to ignore
          // clicks inside it.
          !isElement(this.$el, event.target) &&
          // If the click was not on the opener because he can trigger the toggle
          // method.
          !isElement(this.opener, event.target)
        ) {
          this.hide()
        }
      }
      document.body.addEventListener('click', this.$el.clickOutsideEvent)

      this.$el.keydownEvent = (event) => {
        if (
          // Check if the context menu is still open
          this.open &&
          // Check if the user has hit either of the keys we care about. If not,
          // ignore.
          (event.code === 'ArrowUp' || event.code === 'ArrowDown')
        ) {
          // Prevent scrolling up and down while pressing the up and down key.
          event.stopPropagation()
          event.preventDefault()
          this.handleUpAndDownArrowPress(event)
        }
        // Allow the Enter key to select the value that is currently being hovered
        // over.
        if (this.open && event.code === 'Enter') {
          // Prevent submitting the whole form when pressing the enter key while the
          // dropdown is open.
          event.preventDefault()
          this.select(this.hover)
        }
      }
      document.body.addEventListener('keydown', this.$el.keydownEvent)
    },
    /**
     * Hides the list of choices. If something change in this method, you might need
     * to update the hide method of the `PaginatedDropdown` component because it
     * contains a partial copy of this code.
     */
    hide() {
      this.open = false
      this.$emit('hide')

      // Make sure that all the items are visible the next time we open the dropdown.
      this.query = ''
      this.search(this.query)

      document.body.removeEventListener('click', this.$el.clickOutsideEvent)
      document.body.removeEventListener('keydown', this.$el.keydownEvent)
    },
    /**
     * Selects a new value which will also be
     */
    select(value) {
      this.$emit('input', value)
      this.$emit('change', value)
      this.hide()
    },
    /**
     * If not empty it will only show children that contain the given query.
     */
    search(query) {
      this.hasItems = query === ''
      this.$children.forEach((item) => {
        if (item.search(query)) {
          this.hasItems = true
        }
      })
    },
    /**
     * Loops over all children to see if any of the values match with given value. If
     * so the requested property of the child is returned
     */
    getSelectedProperty(value, property) {
      for (const i in this.$children) {
        const item = this.$children[i]
        if (item.value === value) {
          return item[property]
        }
      }
      return ''
    },
    /**
     * Returns true if there is a value.
     * @return {boolean}
     */
    hasValue() {
      for (const i in this.$children) {
        const item = this.$children[i]
        if (item.value === this.value) {
          return true
        }
      }
      return false
    },
    /**
     * A nasty hack, but in some cases the $children have not yet been loaded when the
     * `selectName` and `selectIcon` are computed. This would result in an empty
     * initial value of the Dropdown because the correct value can't be extracted from
     * the DropdownItem. With this hack we force the computed properties to recompute
     * when the component is mounted. At this moment the $children have been added.
     */
    forceRefreshSelectedValue() {
      this._computedWatchers.selectedName.run()
      this._computedWatchers.selectedIcon.run()
      this.$forceUpdate()
    },
    /**
     * Method that is called when the arrow up or arrow down key is pressed. Based on
     * the index of the current child, the next child enabled child is set as hover.
     */
    handleUpAndDownArrowPress(event) {
      const children = this.$children.filter(
        (child) => !child.disabled && child.isVisible(this.query)
      )

      const isArrowUp = event.code === 'ArrowUp'
      let index = children.findIndex((item) => item.value === this.hover)
      index = isArrowUp ? index - 1 : index + 1

      // Check if the new index is within the allowed range.
      if (index < 0 || index > children.length - 1) {
        return
      }

      const next = children[index]
      this.hover = next.value
      this.$refs.items.scrollTop = this.getScrollTopAmountForNextChild(
        next,
        isArrowUp
      )
    },
    /**
     * This method calculates the expected container scroll top offset if the next
     * child is selected. This is for example used when navigating with the arrow keys.
     * If the element to scroll to is below the current dropdown's bottom scroll
     * position, then scroll so that the item to scroll to is the last visible item
     * in the dropdown window. Conversely if the element to scroll to is above the
     * current dropdown's top scroll position then scroll so that the item to scroll
     * to is the first viewable item in the dropdown window.
     */
    getScrollTopAmountForNextChild(itemToScrollTo, isArrowUp) {
      // Styles of the itemToScroll to. Needed in order to get margins and height
      const itemToScrollToStyles =
        itemToScrollTo.$el.currentStyle ||
        window.getComputedStyle(itemToScrollTo.$el)

      // Styles of the ref items (the dropdown window). Needed in order to get
      // ::before height and ::after height
      const dropdownWindowBeforeStyles =
        this.$refs.items.currentStyle ||
        window.getComputedStyle(this.$refs.items, ':before')

      const dropdownWindowAfterStyles =
        this.$refs.items.currentStyle ||
        window.getComputedStyle(this.$refs.items, ':after')

      const dropdownWindowBeforeHeight = parseInt(
        dropdownWindowBeforeStyles.height
      )
      const dropdownWindowAfterHeight = parseInt(
        dropdownWindowAfterStyles.height
      )
      const dropdownWindowHeight = this.$refs.items.clientHeight

      const itemHeight = parseInt(itemToScrollToStyles.height)
      const itemMarginTop = parseInt(itemToScrollToStyles.marginTop)
      const itemMarginBottom = parseInt(itemToScrollToStyles.marginBottom)
      const itemHeightWithMargins =
        itemHeight + itemMarginTop + itemMarginBottom

      // Based on the values set in the SCSS files. The height of a dropdowns select
      // item is set to 32px and the height of the select_items window is set to 4 *
      // 36 (select item height plus margins) plus 20 (heights of before and after
      // pseudo elements) so that there is room for four elements
      const itemsInView =
        (dropdownWindowHeight -
          dropdownWindowBeforeHeight -
          dropdownWindowAfterHeight) /
        itemHeightWithMargins

      // Get the direction of the scrolling.
      const movingDownwards = !isArrowUp
      const movingUpwards = isArrowUp

      // nextItemOutOfView can be used if one wants to check if the item to scroll
      // to is out of view of the current dropdowns bottom scroll position.
      // This happens when the difference between the element to scroll to's
      // offsetTop and the current scrollTop of the dropdown is smaller than height
      // of the dropdown minus the full height of the element
      const nextItemOutOfView =
        itemToScrollTo.$el.offsetTop - this.$refs.items.scrollTop >
        dropdownWindowHeight - itemHeightWithMargins

      // prevItemOutOfView can be used if one wants to check if the item to scroll
      // to is out of view of the current dropdowns top scroll position.
      // This happens when the element to scroll to's offsetTop is smaller than the
      // current scrollTop of the dropdown
      const prevItemOutOfView =
        itemToScrollTo.$el.offsetTop < this.$refs.items.scrollTop

      // When the user is scrolling downwards (i.e. pressing key down)
      // and the itemToScrollTo is out of view we want to add the height of the
      // elements preceding the itemToScrollTo plus the dropdownWindowBeforeHeight.
      // This can be achieved by removing said heights from the itemToScrollTo's
      // offsetTop
      if (nextItemOutOfView && movingDownwards) {
        const elementsHeightBeforeItemToScrollTo =
          itemHeightWithMargins * (itemsInView - 1)

        return (
          itemToScrollTo.$el.offsetTop -
          elementsHeightBeforeItemToScrollTo -
          dropdownWindowBeforeHeight
        )
      }

      // When the user is scrolling upwards (i.e. pressing key up) and the
      // itemToScrollTo is out of view we want to set the scrollPosition to be the
      // offsetTop of the element minus it's top margin and the height of the
      // ::after pseudo element of the ref items element
      if (prevItemOutOfView && movingUpwards) {
        return (
          itemToScrollTo.$el.offsetTop -
          itemMarginTop -
          dropdownWindowAfterHeight
        )
      }

      // In the case that the next item to scroll to is completely visible we simply
      // return the current scroll position so that no scrolling happens
      return this.$refs.items.scrollTop
    },
  },
}