<template>
  <div class="virtual-scroller" :style="containerStyle">
    <div class="virtual-scroller-wrapper" :style="wrapperStyle">
      <div
        ref="scrollContainerRef"
        class="virtual-scroller-container"
        :style="containerInnerStyle"
        @scroll.passive="handleScroll"
        @wheel="handleWheel"
      >
        <div class="virtual-scroller-spacer" :style="spacerStyle"></div>
        <div class="virtual-scroller-placeholder" :style="placeholderStyle">
          <slot name="bottom-placeholder" />
        </div>
        <div
          v-for="item in visibleItems"
          :key="item.key"
          :ref="el => setItemRef(el, item.key)"
          class="virtual-scroller-item"
          :style="getItemStyle(item)"
          :data-index="item.index"
          :data-key="item.key"
        >
          <slot name="default" :item="item.data" :index="item.index" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

const props = defineProps({
  data: {
    type: Array,
    required: true,
    default: () => []
  },
  itemKey: {
    type: [String, Function],
    default: 'id'
  },
  estimatedHeight: {
    type: Number,
    default: 100
  },
  buffer: {
    type: Number,
    default: 5
  },
  placeholderHeight: {
    type: Number,
    default: 350
  }
})

const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])

const scrollContainerRef = ref(null)
const itemRefs = new Map()
const itemHeights = ref(new Map())
const resizeObserver = ref(null)
const isScrolling = ref(false)
const scrollTimeout = ref(null)

const getKey = (item, index) => {
  if (typeof props.itemKey === 'function') {
    return props.itemKey(item, index)
  }
  if (item && typeof item === 'object') {
    return item[props.itemKey] ?? index
  }
  return index
}

const getItemHeight = (key) => {
  return itemHeights.value.get(key) ?? props.estimatedHeight
}

const containerStyle = computed(() => ({
  height: '100%',
  width: '100%',
  position: 'relative'
}))

const wrapperStyle = computed(() => ({
  height: '100%',
  width: '100%',
  position: 'relative',
  overflow: 'hidden',
  transform: 'rotate(180deg)',
  direction: 'rtl'
}))

const containerInnerStyle = computed(() => ({
  position: 'absolute',
  top: 0,
  left: 0,
  right: 0,
  bottom: 0,
  overflowX: 'hidden',
  overflowY: 'auto',
  direction: 'ltr'
}))

const totalDataHeight = computed(() => {
  let height = 0
  for (let i = 0; i < props.data.length; i++) {
    const key = getKey(props.data[i], i)
    height += getItemHeight(key)
  }
  return height
})

const totalHeight = computed(() => {
  return props.placeholderHeight + totalDataHeight.value
})

const spacerStyle = computed(() => ({
  height: `${totalHeight.value}px`,
  width: '100%',
  flexShrink: 0
}))

const placeholderStyle = computed(() => ({
  position: 'absolute',
  top: 0,
  left: 0,
  right: 0,
  height: `${props.placeholderHeight}px`,
  zIndex: 1
}))

const getItemOffsets = () => {
  const offsets = []
  let offset = 0
  for (let i = 0; i < props.data.length; i++) {
    offsets.push(offset)
    const key = getKey(props.data[i], i)
    offset += getItemHeight(key)
  }
  return offsets
}

const visibleRange = computed(() => {
  const count = props.data.length
  if (count === 0) {
    return { start: 0, end: 0, offset: 0 }
  }

  const el = scrollContainerRef.value
  if (!el) {
    return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
  }

  const scrollTop = el.scrollTop
  const viewportHeight = el.clientHeight || 600
  const bufferCount = props.buffer

  // In inverted scroll (180deg rotation):
  // - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
  // - scrollTop = max: visual TOP (shows older data, higher index)
  // - Items are positioned from top: placeholderHeight, then data items
  // - visibleStart/visibleEnd are offsets in the data area (after placeholder)
  
  // When scrollTop = 0, we're at visual bottom, showing items near the START of data
  // When scrollTop = max, we're at visual top, showing items near the END of data
  
  // The visible area in data coordinates:
  // - scrollTop 0 means we see items at offset 0 (start of data)
  // - scrollTop increases means we see items at higher offsets (end of data)
  
  const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
  const visibleEnd = visibleStart + viewportHeight

  let startIndex = 0
  let endIndex = count - 1
  let startOffset = 0
  let currentOffset = 0

  // Find startIndex: first item that ends after visibleStart
  for (let i = 0; i < count; i++) {
    const key = getKey(props.data[i], i)
    const height = getItemHeight(key)
    const itemEnd = currentOffset + height

    if (itemEnd > visibleStart) {
      startIndex = Math.max(0, i - bufferCount)
      break
    }

    currentOffset += height
  }

  // Calculate startOffset for startIndex
  startOffset = 0
  for (let i = 0; i < startIndex; i++) {
    const key = getKey(props.data[i], i)
    startOffset += getItemHeight(key)
  }

  // Find endIndex: last item that starts before visibleEnd
  currentOffset = startOffset
  for (let i = startIndex; i < count; i++) {
    const key = getKey(props.data[i], i)
    const height = getItemHeight(key)

    // Check if this item is visible (item starts before visibleEnd)
    if (currentOffset >= visibleEnd) {
      // This item starts after visibleEnd, so previous item is the last visible
      endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
      break
    }

    endIndex = i
    currentOffset += height
  }

  return { start: startIndex, end: endIndex, offset: startOffset }
})

const visibleItems = computed(() => {
  const { start, end, offset } = visibleRange.value
  const items = []
  const count = props.data.length

  if (count === 0) return items

  const safeStart = Math.max(0, start)
  const safeEnd = Math.min(count - 1, end)

  if (safeStart > safeEnd) return items

  let currentOffset = offset + props.placeholderHeight
  const seenKeys = new Set()

  for (let i = safeStart; i <= safeEnd; i++) {
    const data = props.data[i]
    if (!data) continue

    const key = getKey(data, i)
    
    // Deduplicate by key
    if (seenKeys.has(key)) continue
    seenKeys.add(key)

    const height = getItemHeight(key)

    items.push({
      data,
      index: i,
      key,
      offset: currentOffset,
      height
    })

    currentOffset += height
  }

  return items
})

const getItemStyle = (item) => ({
  position: 'absolute',
  top: 0,
  left: 0,
  right: 0,
  transform: `translateY(${item.offset}px)`,
  willChange: 'transform'
})

const setItemRef = (el, key) => {
  if (el) {
    itemRefs.set(key, el)
  } else {
    itemRefs.delete(key)
  }
}

const measureItem = (key, element) => {
  if (!element) return

  const target = element.firstElementChild || element
  const height = target.getBoundingClientRect().height

  if (height > 0 && height !== itemHeights.value.get(key)) {
    const newHeights = new Map(itemHeights.value)
    newHeights.set(key, height)
    itemHeights.value = newHeights
  }
}

const setupResizeObserver = () => {
  if (resizeObserver.value) {
    resizeObserver.value.disconnect()
  }

  resizeObserver.value = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const key = entry.target.dataset.key
      if (key !== undefined) {
        measureItem(key, entry.target)
      }
    }
  })
}

const observeItems = () => {
  if (!resizeObserver.value) return

  resizeObserver.value.disconnect()

  for (const [key, element] of itemRefs) {
    if (element) {
      resizeObserver.value.observe(element)
    }
  }
}

const handleWheel = (event) => {
  if (!scrollContainerRef.value) return

  scrollContainerRef.value.scrollBy({
    top: -event.deltaY,
    behavior: 'instant'
  })

  event.preventDefault()
}

const handleScroll = (event) => {
  const target = event.target
  const scrollHeight = target.scrollHeight
  const scrollTop = target.scrollTop
  const clientHeight = target.clientHeight

  isScrolling.value = true

  if (scrollTimeout.value) {
    clearTimeout(scrollTimeout.value)
  }

  scrollTimeout.value = setTimeout(() => {
    isScrolling.value = false
  }, 150)

  // In inverted scroll:
  // - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
  // - distanceToBottom (visual bottom) = scrollTop
  // - isAtTop (visual top, older data) = distanceToTop <= threshold
  // - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
  const distanceToTop = scrollHeight - scrollTop - clientHeight
  const distanceToBottom = scrollTop
  const threshold = 5

  const isAtTop = distanceToTop <= threshold
  const isAtBottom = distanceToBottom <= threshold

  emit('scroll', {
    scrollTop,
    scrollHeight,
    clientHeight,
    distanceToTop,
    distanceToBottom,
    isAtTop,
    isAtBottom
  })

  // scroll-start: reached visual top (older data, need to load more)
  if (isAtTop) {
    emit('scroll-start')
  }

  // scroll-end: reached visual bottom (newer data)
  if (isAtBottom) {
    emit('scroll-end')
  }
}

const scrollToIndex = (index, behavior = 'auto') => {
  if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return

  let offset = 0
  for (let i = 0; i < index; i++) {
    const key = getKey(props.data[i], i)
    offset += getItemHeight(key)
  }

  const targetScrollTop = offset + props.placeholderHeight

  scrollContainerRef.value.scrollTo({
    top: targetScrollTop,
    behavior
  })
}

const scrollToBottom = (behavior = 'smooth') => {
  if (!scrollContainerRef.value) return

  requestAnimationFrame(() => {
    if (!scrollContainerRef.value) return
    // In inverted scroll, bottom is scrollTop = 0
    scrollContainerRef.value.scrollTo({ top: 0, behavior })
  })
}

const scrollToTop = (behavior = 'smooth') => {
  if (!scrollContainerRef.value) return

  requestAnimationFrame(() => {
    if (!scrollContainerRef.value) return
    // In inverted scroll, top is scrollTop = max
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior
    })
  })
}

const getScrollElement = () => scrollContainerRef.value

const isAtTop = () => {
  if (!scrollContainerRef.value) return false
  const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
  return scrollHeight - scrollTop - clientHeight <= 5
}

const isAtBottom = () => {
  if (!scrollContainerRef.value) return false
  return scrollContainerRef.value.scrollTop <= 5
}

const reset = () => {
  itemHeights.value = new Map()
  itemRefs.clear()
}

watch(() => props.data, (newData, oldData) => {
  const newLength = newData?.length || 0
  const oldLength = oldData?.length || 0

  if (newLength < oldLength) {
    reset()
  }

  nextTick(observeItems)
}, { deep: true })

watch(visibleItems, () => {
  nextTick(observeItems)
}, { deep: true })

onMounted(() => {
  setupResizeObserver()
  nextTick(observeItems)
})

onBeforeUnmount(() => {
  if (resizeObserver.value) {
    resizeObserver.value.disconnect()
  }

  if (scrollTimeout.value) {
    clearTimeout(scrollTimeout.value)
  }

  itemRefs.clear()
})

defineExpose({
  scrollToIndex,
  scrollToBottom,
  scrollToTop,
  getScrollElement,
  isAtTop,
  isAtBottom,
  reset,
  scrollContainerRef
})
</script>

<style lang="less" scoped>
.virtual-scroller {
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  -ms-overflow-style: none;

  &-wrapper {
    contain: content;
  }

  &-container {
    contain: layout style;

    &::-webkit-scrollbar {
      display: none;
      width: 0;
      height: 0;
    }
  }

  &-spacer {
    flex-shrink: 0;
    width: 100%;
  }

  &-item {
    contain: layout style;
    backface-visibility: hidden;
    perspective: 1000px;
  }

  &-placeholder {
    contain: layout style;
  }
}
</style>
