// Composables
import { useDisplay } from '@/composables/display'
import { useResizeObserver } from '@/composables/resizeObserver'

// Utilities
import { computed, nextTick, onScopeDispose, ref, shallowRef, watch, watchEffect } from 'vue'
import { clamp, debounce, getPropertyFromItem, IN_BROWSER, propsFactory } from '@/util'

// Types
import type { PropType, Ref } from 'vue'
import type { SelectItemKey } from '@/util'

const UP = -1
const DOWN = 1

/** Determines how large each batch of items should be */
const BUFFER_PX = 100

type VirtualProps = {
  itemHeight: number | string | null | undefined
  itemKey: SelectItemKey
  height: number | string | undefined
}

export const makeVirtualProps = propsFactory({
  itemHeight: {
    type: [Number, String],
    default: null,
  },
  itemKey: {
    type: [String, Array, Function] as PropType<SelectItemKey>,
    default: null,
  },
  height: [Number, String],
}, 'virtual')

export function useVirtual <T> (props: VirtualProps, items: Ref<readonly T[]>) {
  const display = useDisplay()

  const itemHeight = shallowRef(0)
  watchEffect(() => {
    itemHeight.value = parseFloat(props.itemHeight || 0)
  })

  const first = shallowRef(0)
  const last = shallowRef(Math.ceil(
    // Assume 16px items filling the entire screen height if
    // not provided. This is probably incorrect but it minimises
    // the chance of ending up with empty space at the bottom.
    // The default value is set here to avoid poisoning getSize()
    (parseInt(props.height!) || display.height.value) / (itemHeight.value || 16)
  ) || 1)
  const paddingTop = shallowRef(0)
  const paddingBottom = shallowRef(0)

  /** The scrollable element */
  const containerRef = ref<HTMLElement>()
  /** An element marking the top of the scrollable area,
   * used to add an offset if there's padding or other elements above the virtual list */
  const markerRef = ref<HTMLElement>()
  /** markerRef's offsetTop, lazily evaluated */
  let markerOffset = 0

  const { resizeRef, contentRect } = useResizeObserver()
  watchEffect(() => {
    resizeRef.value = containerRef.value
  })
  const viewportHeight = computed(() => {
    return containerRef.value === document.documentElement
      ? display.height.value
      : contentRect.value?.height || parseInt(props.height!) || 0
  })
  /** All static elements have been rendered and we have an assumed item height */
  const hasInitialRender = computed(() => {
    return !!(containerRef.value && markerRef.value && viewportHeight.value && itemHeight.value)
  })

  let sizes = Array.from<number | null>({ length: items.value.length })
  let offsets = Array.from<number>({ length: items.value.length })
  const updateTime = shallowRef(0)
  let targetScrollIndex = -1

  function getSize (index: number) {
    return sizes[index] || itemHeight.value
  }

  const updateOffsets = debounce(() => {
    const start = performance.now()
    offsets[0] = 0
    const length = items.value.length
    for (let i = 1; i <= length; i++) {
      offsets[i] = (offsets[i - 1] || 0) + getSize(i - 1)
    }
    updateTime.value = Math.max(updateTime.value, performance.now() - start)
    calculateVisibleItems()
  }, updateTime)

  const unwatch = watch(hasInitialRender, v => {
    if (!v) return
    // First render is complete, update offsets and visible
    // items in case our assumed item height was incorrect

    unwatch()
    markerOffset = markerRef.value!.offsetTop
    updateOffsets.immediate()
    calculateVisibleItems()

    if (!~targetScrollIndex) return

    nextTick(() => {
      IN_BROWSER && window.requestAnimationFrame(() => {
        if (~targetScrollIndex) scrollToIndex(targetScrollIndex)
      })
    })
  })

  onScopeDispose(() => {
    updateOffsets.clear()
  })

  function handleItemResize (index: number, height: number) {
    const prevHeight = sizes[index]
    const prevMinHeight = itemHeight.value

    if (height > 0) {
      itemHeight.value = prevMinHeight ? Math.min(prevMinHeight, height) : height
    }

    if (prevHeight !== height || prevMinHeight !== itemHeight.value) {
      sizes[index] = height
      updateOffsets()
    }
  }

  function calculateOffset (index: number) {
    index = clamp(index, 0, items.value.length)
    const whole = Math.floor(index)
    const fraction = index % 1
    const next = whole + 1
    const wholeOffset = offsets[whole] || 0
    const nextOffset = offsets[next] || wholeOffset
    return wholeOffset + (nextOffset - wholeOffset) * fraction
  }

  function calculateIndex (scrollTop: number) {
    return binaryClosest(offsets, scrollTop)
  }

  let lastScrollTop = 0
  let scrollVelocity = 0
  let lastScrollTime = 0

  watch(viewportHeight, (val, oldVal) => {
    calculateVisibleItems()
    if (val < oldVal) {
      requestAnimationFrame(() => {
        scrollVelocity = 0
        calculateVisibleItems()
      })
    }
  })

  let scrollTimeout = -1
  function handleScroll () {
    if (!containerRef.value || !markerRef.value) return

    const scrollTop = containerRef.value.scrollTop
    const scrollTime = performance.now()
    const scrollDeltaT = scrollTime - lastScrollTime

    if (scrollDeltaT > 500) {
      scrollVelocity = Math.sign(scrollTop - lastScrollTop)

      // Not super important, only update at the
      // start of a scroll sequence to avoid reflows
      markerOffset = markerRef.value.offsetTop
    } else {
      scrollVelocity = scrollTop - lastScrollTop
    }

    lastScrollTop = scrollTop
    lastScrollTime = scrollTime

    window.clearTimeout(scrollTimeout)
    scrollTimeout = window.setTimeout(handleScrollend, 500)

    calculateVisibleItems()
  }
  function handleScrollend () {
    if (!containerRef.value || !markerRef.value) return

    scrollVelocity = 0
    lastScrollTime = 0

    window.clearTimeout(scrollTimeout)
    calculateVisibleItems()
  }

  let raf = -1
  function calculateVisibleItems () {
    cancelAnimationFrame(raf)
    raf = requestAnimationFrame(_calculateVisibleItems)
  }
  function _calculateVisibleItems () {
    if (!containerRef.value || !viewportHeight.value || !itemHeight.value) return
    const scrollTop = lastScrollTop - markerOffset
    const direction = Math.sign(scrollVelocity)

    const startPx = Math.max(0, scrollTop - BUFFER_PX)
    const start = clamp(calculateIndex(startPx), 0, items.value.length)

    const endPx = scrollTop + viewportHeight.value + BUFFER_PX
    const end = clamp(calculateIndex(endPx) + 1, start + 1, items.value.length)

    if (
      // Only update the side we're scrolling towards,
      // the other side will be updated incidentally
      (direction !== UP || start < first.value) &&
      (direction !== DOWN || end > last.value)
    ) {
      const topOverflow = calculateOffset(first.value) - calculateOffset(start)
      const bottomOverflow = calculateOffset(end) - calculateOffset(last.value)
      const bufferOverflow = Math.max(topOverflow, bottomOverflow)

      if (bufferOverflow > BUFFER_PX) {
        first.value = start
        last.value = end
      } else {
        // Only update the side that's reached its limit if there's still buffer left
        if (start <= 0) first.value = start
        if (end >= items.value.length) {
          last.value = end
          first.value = start
        }
      }
    }

    paddingTop.value = calculateOffset(first.value)
    paddingBottom.value = calculateOffset(items.value.length) - calculateOffset(last.value)
  }

  function scrollToIndex (index: number) {
    const offset = calculateOffset(index)
    if (!containerRef.value || (index && !offset)) {
      targetScrollIndex = index
      return
    }

    // Move the window first so paddingTop/Bottom make scrollHeight large enough
    // before we assign scrollTop (otherwise the browser clamps the scroll).
    const itemSize = itemHeight.value || 16
    const buffer = Math.ceil(BUFFER_PX / itemSize)
    const viewport = Math.max(1, Math.ceil((viewportHeight.value || 0) / itemSize))
    first.value = clamp(index - buffer, 0, Math.max(0, items.value.length - 1))
    last.value = clamp(index + viewport + buffer, first.value + 1, items.value.length)
    paddingTop.value = calculateOffset(first.value)
    paddingBottom.value = calculateOffset(items.value.length) - calculateOffset(last.value)

    scrollVelocity = 0
    lastScrollTime = 0
    targetScrollIndex = index

    nextTick(() => {
      const el = containerRef.value
      // Superseded by a later scrollToIndex
      if (!el || !~targetScrollIndex || targetScrollIndex !== index) return

      const top = calculateOffset(index)
      el.scrollTop = top
      // Resize-driven calculateVisibleItems reads lastScrollTop, not the DOM
      lastScrollTop = el.scrollTop

      const fullHeight = calculateOffset(items.value.length) + markerOffset
      if (index && el.scrollTop < top - 1 && el.scrollHeight < fullHeight - 1) {
        IN_BROWSER && requestAnimationFrame(() => {
          if (targetScrollIndex === index) scrollToIndex(index)
        })
      } else {
        targetScrollIndex = -1
        calculateVisibleItems()
      }
    })
  }

  const computedItems = computed(() => {
    return items.value.slice(first.value, last.value).map((item, index) => {
      const _index = index + first.value
      return {
        raw: item,
        index: _index,
        key: getPropertyFromItem(item, props.itemKey, _index),
      }
    })
  })

  watch(items, () => {
    sizes = Array.from({ length: items.value.length })
    offsets = Array.from({ length: items.value.length })
    updateOffsets.immediate()
    calculateVisibleItems()
  }, { deep: 1 })

  return {
    calculateVisibleItems,
    containerRef,
    markerRef,
    computedItems,
    paddingTop,
    paddingBottom,
    scrollToIndex,
    handleScroll,
    handleScrollend,
    handleItemResize,
  }
}

// https://gist.github.com/robertleeplummerjr/1cc657191d34ecd0a324
function binaryClosest (arr: ArrayLike<number>, val: number) {
  let high = arr.length - 1
  let low = 0
  let mid = 0
  let item = null
  let target = -1

  if (arr[high]! < val) {
    return high
  }

  while (low <= high) {
    mid = (low + high) >> 1
    item = arr[mid]!

    if (item > val) {
      high = mid - 1
    } else if (item < val) {
      target = mid
      low = mid + 1
    } else if (item === val) {
      return mid
    } else {
      return low
    }
  }

  return target
}
