import type { CSSProperties, Component, PropType } from 'vue'
import {
  computed,
  defineComponent,
  nextTick,
  onBeforeUnmount,
  onMounted,
  onUpdated,
  reactive,
  shallowRef,
  toRaw,
  watch,
  watchEffect,
} from 'vue'

import classNames from '../_util/classNames'
import supportsPassive from '../_util/supportsPassive'
import type { Key } from '../_util/type'
import PropTypes from '../_util/vue-types'
import Filler from './Filler'
import Item from './Item'
import ScrollBar from './ScrollBar'
import useFrameWheel from './hooks/useFrameWheel'
import useHeights from './hooks/useHeights'
import useMobileTouchMove from './hooks/useMobileTouchMove'
import useOriginScroll from './hooks/useOriginScroll'
import useScrollTo from './hooks/useScrollTo'
import type { RenderFunc, SharedConfig } from './interface'

const EMPTY_DATA = []

const ScrollStyle: CSSProperties = {
  overflowY: 'auto',
  overflowAnchor: 'none',
}

export type ScrollAlign = 'top' | 'bottom' | 'auto'
export type ScrollConfig =
  | {
      index: number
      align?: ScrollAlign
      offset?: number
    }
  | {
      key: Key
      align?: ScrollAlign
      offset?: number
    }
export type ScrollTo = (arg: number | ScrollConfig) => void

function renderChildren<T>(
  list: T[],
  startIndex: number,
  endIndex: number,
  setNodeRef: (item: T, element: HTMLElement) => void,
  renderFunc: RenderFunc<T>,
  { getKey }: SharedConfig<T>,
) {
  return list.slice(startIndex, endIndex + 1).map((item, index) => {
    const eleIndex = startIndex + index
    const node = renderFunc(item, eleIndex, {
      // style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {},
    })
    const key = getKey(item)
    return (
      <Item key={key} setRef={(ele) => setNodeRef(item, ele as HTMLElement)}>
        {node}
      </Item>
    )
  })
}

export interface ListState {
  scrollTop: number
  scrollMoving: boolean
}

const List = defineComponent({
  compatConfig: { MODE: 3 },
  name: 'List',
  inheritAttrs: false,
  props: {
    prefixCls: String,
    data: PropTypes.array,
    height: Number,
    itemHeight: Number,
    /** If not match virtual scroll condition, Set List still use height of container. */
    fullHeight: { type: Boolean, default: undefined },
    itemKey: {
      type: [String, Number, Function] as PropType<Key | ((item: Record<string, any>) => Key)>,
      required: true,
    },
    component: {
      type: [String, Object] as PropType<string | Component>,
    },
    /** Set `false` will always use real scroll instead of virtual one */
    virtual: { type: Boolean, default: undefined },
    children: Function,
    onScroll: Function,
    onMousedown: Function,
    onMouseenter: Function,
    onVisibleChange: Function as PropType<(visibleList: any[], fullList: any[]) => void>,
  },
  setup(props, { expose }) {
    // ================================= MISC =================================
    const useVirtual = computed(() => {
      const { height, itemHeight, virtual } = props
      return !!(virtual !== false && height && itemHeight)
    })
    const inVirtual = computed(() => {
      const { height, itemHeight, data } = props
      return useVirtual.value && data && itemHeight * data.length > height
    })

    const state = reactive<ListState>({
      scrollTop: 0,
      scrollMoving: false,
    })
    const data = computed(() => {
      return props.data || EMPTY_DATA
    })
    const mergedData = shallowRef([])
    watch(
      data,
      () => {
        mergedData.value = toRaw(data.value).slice()
      },
      { immediate: true },
    )
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const itemKey = shallowRef((_item: Record<string, any>) => undefined)
    watch(
      () => props.itemKey,
      (val) => {
        if (typeof val === 'function') {
          itemKey.value = val
        } else {
          itemKey.value = (item) => item?.[val]
        }
      },
      { immediate: true },
    )
    const componentRef = shallowRef<HTMLDivElement>()
    const fillerInnerRef = shallowRef<HTMLDivElement>()
    const scrollBarRef = shallowRef<any>() // Hack on scrollbar to enable flash call
    // =============================== Item Key ===============================
    const getKey = (item: Record<string, any>) => {
      return itemKey.value(item)
    }

    const sharedConfig = {
      getKey,
    }

    // ================================ Scroll ================================
    function syncScrollTop(newTop: number | ((prev: number) => number)) {
      let value: number
      if (typeof newTop === 'function') {
        value = newTop(state.scrollTop)
      } else {
        value = newTop
      }

      const alignedTop = keepInRange(value)

      if (componentRef.value) {
        componentRef.value.scrollTop = alignedTop
      }
      state.scrollTop = alignedTop
    }

    // ================================ Height ================================
    const [setInstance, collectHeight, heights, updatedMark] = useHeights(
      mergedData,
      getKey,
      null,
      null,
    )

    const calRes = reactive<{
      scrollHeight?: number
      start?: number
      end?: number
      offset?: number
    }>({
      scrollHeight: undefined,
      start: 0,
      end: 0,
      offset: undefined,
    })

    const offsetHeight = shallowRef(0)
    onMounted(() => {
      nextTick(() => {
        offsetHeight.value = fillerInnerRef.value?.offsetHeight || 0
      })
    })
    onUpdated(() => {
      nextTick(() => {
        offsetHeight.value = fillerInnerRef.value?.offsetHeight || 0
      })
    })
    watch(
      [useVirtual, mergedData],
      () => {
        if (!useVirtual.value) {
          Object.assign(calRes, {
            scrollHeight: undefined,
            start: 0,
            end: mergedData.value.length - 1,
            offset: undefined,
          })
        }
      },
      { immediate: true },
    )
    watch(
      [useVirtual, mergedData, offsetHeight, inVirtual],
      () => {
        // Always use virtual scroll bar in avoid shaking
        if (useVirtual.value && !inVirtual.value) {
          Object.assign(calRes, {
            scrollHeight: offsetHeight.value,
            start: 0,
            end: mergedData.value.length - 1,
            offset: undefined,
          })
        }
        if (componentRef.value) {
          state.scrollTop = componentRef.value.scrollTop
        }
      },
      {
        immediate: true,
      },
    )
    watch(
      [
        inVirtual,
        useVirtual,
        () => state.scrollTop,
        mergedData,
        updatedMark,
        () => props.height,
        offsetHeight,
      ],
      () => {
        if (!useVirtual.value || !inVirtual.value) {
          return
        }

        let itemTop = 0
        let startIndex: number | undefined
        let startOffset: number | undefined
        let endIndex: number | undefined
        const dataLen = mergedData.value.length
        const data = mergedData.value
        const scrollTop = state.scrollTop
        const { itemHeight, height } = props
        const scrollTopHeight = scrollTop + height

        for (let i = 0; i < dataLen; i += 1) {
          const item = data[i]
          const key = getKey(item)

          let cacheHeight = heights.get(key)
          if (cacheHeight === undefined) {
            cacheHeight = itemHeight
          }
          const currentItemBottom = itemTop + cacheHeight

          if (startIndex === undefined && currentItemBottom >= scrollTop) {
            startIndex = i
            startOffset = itemTop
          }

          // Check item bottom in the range. We will render additional one item for motion usage
          if (endIndex === undefined && currentItemBottom > scrollTopHeight) {
            endIndex = i
          }

          itemTop = currentItemBottom
        }

        // When scrollTop at the end but data cut to small count will reach this
        if (startIndex === undefined) {
          startIndex = 0
          startOffset = 0
          endIndex = Math.ceil(height / itemHeight)
        }
        if (endIndex === undefined) {
          endIndex = dataLen - 1
        }

        // Give cache to improve scroll experience
        endIndex = Math.min(endIndex + 1, dataLen)
        Object.assign(calRes, {
          scrollHeight: itemTop,
          start: startIndex,
          end: endIndex,
          offset: startOffset,
        })
      },
      { immediate: true },
    )

    // =============================== In Range ===============================
    const maxScrollHeight = computed(() => calRes.scrollHeight! - props.height!)

    function keepInRange(newScrollTop: number) {
      let newTop = newScrollTop
      if (!Number.isNaN(maxScrollHeight.value)) {
        newTop = Math.min(newTop, maxScrollHeight.value)
      }
      newTop = Math.max(newTop, 0)
      return newTop
    }

    const isScrollAtTop = computed(() => state.scrollTop <= 0)
    const isScrollAtBottom = computed(() => state.scrollTop >= maxScrollHeight.value)

    const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom)

    // ================================ Scroll ================================
    function onScrollBar(newScrollTop: number) {
      const newTop = newScrollTop
      syncScrollTop(newTop)
    }

    // When data size reduce. It may trigger native scroll event back to fit scroll position
    function onFallbackScroll(e: UIEvent) {
      const { scrollTop: newScrollTop } = e.currentTarget as Element
      if (newScrollTop !== state.scrollTop) {
        syncScrollTop(newScrollTop)
      }

      // Trigger origin onScroll
      props.onScroll?.(e)
    }

    // Since this added in global,should use ref to keep update
    const [onRawWheel, onFireFoxScroll] = useFrameWheel(
      useVirtual,
      isScrollAtTop,
      isScrollAtBottom,
      (offsetY) => {
        syncScrollTop((top) => {
          const newTop = top + offsetY
          return newTop
        })
      },
    )

    // Mobile touch move
    useMobileTouchMove(useVirtual, componentRef, (deltaY, smoothOffset) => {
      if (originScroll(deltaY, smoothOffset)) {
        return false
      }

      onRawWheel({ preventDefault() {}, deltaY } as WheelEvent)
      return true
    })
    // Firefox only
    function onMozMousePixelScroll(e: MouseEvent) {
      if (useVirtual.value) {
        e.preventDefault()
      }
    }
    const removeEventListener = () => {
      if (componentRef.value) {
        componentRef.value.removeEventListener(
          'wheel',
          onRawWheel,
          supportsPassive ? ({ passive: false } as EventListenerOptions) : false,
        )
        componentRef.value.removeEventListener('DOMMouseScroll', onFireFoxScroll as any)
        componentRef.value.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll as any)
      }
    }
    watchEffect(() => {
      nextTick(() => {
        if (componentRef.value) {
          removeEventListener()
          componentRef.value.addEventListener(
            'wheel',
            onRawWheel,
            supportsPassive ? ({ passive: false } as EventListenerOptions) : false,
          )
          componentRef.value.addEventListener('DOMMouseScroll', onFireFoxScroll as any)
          componentRef.value.addEventListener('MozMousePixelScroll', onMozMousePixelScroll as any)
        }
      })
    })

    onBeforeUnmount(() => {
      removeEventListener()
    })

    // ================================= Ref ==================================
    const scrollTo = useScrollTo(
      componentRef,
      mergedData,
      heights,
      props,
      getKey,
      collectHeight,
      syncScrollTop,
      () => {
        scrollBarRef.value?.delayHidden()
      },
    )

    expose({
      scrollTo,
    })

    const componentStyle = computed(() => {
      let cs: CSSProperties | null = null
      if (props.height) {
        cs = { [props.fullHeight ? 'height' : 'maxHeight']: `${props.height}px`, ...ScrollStyle }

        if (useVirtual.value) {
          cs!.overflowY = 'hidden'

          if (state.scrollMoving) {
            cs!.pointerEvents = 'none'
          }
        }
      }
      return cs
    })

    // ================================ Effect ================================
    /** We need told outside that some list not rendered */
    watch(
      [() => calRes.start, () => calRes.end, mergedData],
      () => {
        if (props.onVisibleChange) {
          const renderList = mergedData.value.slice(calRes.start, calRes.end + 1)

          props.onVisibleChange(renderList, mergedData.value)
        }
      },
      { flush: 'post' },
    )
    const delayHideScrollBar = () => {
      scrollBarRef.value?.delayHidden()
    }
    return {
      state,
      mergedData,
      componentStyle,
      onFallbackScroll,
      onScrollBar,
      componentRef,
      useVirtual,
      calRes,
      collectHeight,
      setInstance,
      sharedConfig,
      scrollBarRef,
      fillerInnerRef,
      delayHideScrollBar,
    }
  },

  render() {
    const {
      prefixCls = 'rc-virtual-list',
      height,
      itemHeight,

      fullHeight,
      data,
      itemKey,
      virtual,
      component: Component = 'div',
      onScroll,
      children = this.$slots.default,
      style,
      class: className,
      ...restProps
    } = { ...this.$props, ...this.$attrs } as any
    const mergedClassName = classNames(prefixCls, className)
    const { scrollTop } = this.state
    const { scrollHeight, offset, start, end } = this.calRes
    const {
      componentStyle,
      onFallbackScroll,
      onScrollBar,
      useVirtual,
      collectHeight,
      sharedConfig,
      setInstance,
      mergedData,
      delayHideScrollBar,
    } = this
    return (
      <div
        style={{
          ...style,
          position: 'relative',
        }}
        class={mergedClassName}
        {...restProps}
      >
        <Component
          class={`${prefixCls}-holder`}
          style={componentStyle}
          ref="componentRef"
          onScroll={onFallbackScroll}
          onMouseenter={delayHideScrollBar}
        >
          <Filler
            prefixCls={prefixCls}
            height={scrollHeight}
            offset={offset}
            onInnerResize={collectHeight}
            ref="fillerInnerRef"
            v-slots={{
              default: () =>
                renderChildren(mergedData, start, end, setInstance, children, sharedConfig),
            }}
          ></Filler>
        </Component>

        {useVirtual && (
          <ScrollBar
            ref="scrollBarRef"
            prefixCls={prefixCls}
            scrollTop={scrollTop}
            height={height}
            scrollHeight={scrollHeight}
            count={mergedData.length}
            onScroll={onScrollBar}
            onStartMove={() => {
              this.state.scrollMoving = true
            }}
            onStopMove={() => {
              this.state.scrollMoving = false
            }}
          />
        )}
      </div>
    )
  },
})

export default List
