import { Ref, Reactive, ComputedRef, ShallowReactive, ComponentInternalInstance } from 'vue';

type BreakpointMode = (typeof BREAKPOINT_MODE_OPTIONS)[number]

type Breakpoints = {
  [key: number]: Partial<
    Omit<CarouselConfig, 'breakpoints' | 'modelValue' | 'breakpointMode'>
  >
}

type Dir = (typeof DIR_OPTIONS)[number]

type I18nKeys = keyof typeof I18N_DEFAULT_CONFIG

type NonNormalizedDir = keyof typeof DIR_MAP

type NormalizedDir = (typeof NORMALIZED_DIR_OPTIONS)[number]

type SlideEffect = (typeof SLIDE_EFFECTS)[number]

type SnapAlign = (typeof SNAP_ALIGN_OPTIONS)[number]

type CarouselConfig = {
  autoplay?: number
  breakpointMode?: BreakpointMode
  breakpoints?: Breakpoints
  dir?: Dir
  enabled: boolean
  gap: number
  height: string | number
  i18n: { [key in I18nKeys]?: string }
  ignoreAnimations: boolean | string[] | string
  itemsToScroll: number
  itemsToShow: number | 'auto'
  modelValue?: number
  mouseDrag?: boolean
  pauseAutoplayOnHover?: boolean
  preventExcessiveDragging: boolean
  slideEffect: SlideEffect
  snapAlign: SnapAlign
  touchDrag?: boolean
  transition?: number
  wrapAround?: boolean
}

type VueClass = string | Record<string, boolean> | VueClass[]

declare const BREAKPOINT_MODE_OPTIONS = ['viewport', 'carousel'] as const

declare const DIR_MAP = {
  'bottom-to-top': 'btt',
  'left-to-right': 'ltr',
  'right-to-left': 'rtl',
  'top-to-bottom': 'ttb',
} as const

declare const DIR_OPTIONS = [
  'ltr',
  'left-to-right',
  'rtl',
  'right-to-left',
  'ttb',
  'top-to-bottom',
  'btt',
  'bottom-to-top',
] as const

declare const I18N_DEFAULT_CONFIG = {
  ariaGallery: 'Gallery',
  ariaNavigateToPage: 'Navigate to page {slideNumber}',
  ariaNavigateToSlide: 'Navigate to slide {slideNumber}',
  ariaNextSlide: 'Navigate to next slide',
  ariaPreviousSlide: 'Navigate to previous slide',
  iconArrowDown: 'Arrow pointing downwards',
  iconArrowLeft: 'Arrow pointing to the left',
  iconArrowRight: 'Arrow pointing to the right',
  iconArrowUp: 'Arrow pointing upwards',
  itemXofY: 'Item {currentSlide} of {slidesCount}',
} as const

declare const NORMALIZED_DIR_OPTIONS = Object.values(DIR_MAP)

declare const SLIDE_EFFECTS = ['slide', 'fade'] as const

declare const SNAP_ALIGN_OPTIONS = [
  'center',
  'start',
  'end',
  'center-even',
  'center-odd',
] as const

declare const DEFAULT_CONFIG: CarouselConfig = {
  autoplay: 0,
  breakpointMode: BREAKPOINT_MODE_OPTIONS[0],
  breakpoints: undefined,
  dir: DIR_OPTIONS[0],
  enabled: true,
  gap: 0,
  height: 'auto',
  i18n: I18N_DEFAULT_CONFIG,
  ignoreAnimations: false,
  itemsToScroll: 1,
  itemsToShow: 1,
  modelValue: 0,
  mouseDrag: true,
  pauseAutoplayOnHover: false,
  preventExcessiveDragging: false,
  slideEffect: SLIDE_EFFECTS[0],
  snapAlign: SNAP_ALIGN_OPTIONS[0],
  touchDrag: true,
  transition: 300,
  wrapAround: false,
}

// Use a symbol for inject provide to avoid any kind of collision with another lib
// https://vuejs.org/guide/components/provide-inject#working-with-symbol-keys
declare const injectCarousel = Symbol('carousel') as InjectionKey<
  InjectedCarousel | undefined
>

declare const createSlideRegistry = (emit: EmitFn) => {
  const slides = shallowReactive<Array<ComponentInternalInstance>>([])

  const updateSlideIndexes = (startIndex?: number) => {
    if (startIndex !== undefined) {
      slides.slice(startIndex).forEach((slide, offset) => {
        slide.exposed?.setIndex(startIndex + offset)
      })
    } else {
      slides.forEach((slide, index) => {
        slide.exposed?.setIndex(index)
      })
    }
  }

  return {
    cleanup: () => {
      slides.splice(0, slides.length)
    },

    getSlides: () => slides,

    registerSlide: (slide: ComponentInternalInstance, index?: number) => {
      if (!slide) return

      if (slide.props.isClone) {
        return
      }

      const slideIndex = index ?? slides.length
      slides.splice(slideIndex, 0, slide)
      updateSlideIndexes(slideIndex)
      emit('slide-registered', { slide, index: slideIndex })
    },

    unregisterSlide: (slide: ComponentInternalInstance) => {
      const slideIndex = slides.indexOf(slide)
      if (slideIndex === -1) return

      emit('slide-unregistered', { slide, index: slideIndex })

      slides.splice(slideIndex, 1)
      updateSlideIndexes(slideIndex)
    },
  }
}

type SlideRegistry = ReturnType<typeof createSlideRegistry>

type ElRect = {
  height: number
  width: number
}

type Range = {
  min: number
  max: number
}

type CarouselData = {
  config: CarouselConfig
  currentSlide: Ref<number>
  maxSlide: Ref<number>
  middleSlide: Ref<number>
  minSlide: Ref<number>
  slideSize: Ref<number>
  slidesCount: Ref<number>
}

type CarouselExposed = CarouselMethods & {
  data: Reactive<CarouselData>
  nav: CarouselNav
}

type CarouselMethods = CarouselNav & {
  restartCarousel: () => void
  updateBreakpointsConfig: () => void
  updateSlideSize: () => void
  updateSlidesData: () => void
}

type CarouselNav = {
  next: (skipTransition?: boolean) => void
  prev: (skipTransition?: boolean) => void
  slideTo: (index: number) => void
}

type InjectedCarousel = Reactive<{
  activeSlide: Ref<number>
  config: CarouselConfig
  currentSlide: Ref<number>
  isSliding: Ref<boolean>
  isVertical: ComputedRef<boolean>
  maxSlide: ComputedRef<number>
  minSlide: ComputedRef<number>
  nav: CarouselNav
  normalizedDir: ComputedRef<NormalizedDir>
  slideRegistry: SlideRegistry
  slideSize: Ref<number>
  slides: ShallowReactive<Array<ComponentInternalInstance>>
  slidesCount: ComputedRef<number>
  viewport: Ref<Element | null>
  visibleRange: ComputedRef<Range>
}>

declare const Carousel = defineComponent({
  name: 'VueCarousel',
  props: carouselProps,
  emits: [
    'before-init',
    'drag',
    'init',
    'loop',
    'slide-end',
    'slide-registered',
    'slide-start',
    'slide-unregistered',
    'update:modelValue',
  ],
  setup(props: CarouselConfig, { slots, emit, expose }: SetupContext) {
    const slideRegistry = createSlideRegistry(emit)
    const slides = slideRegistry.getSlides()
    const slidesCount = computed(() => slides.length)

    const root: Ref<Element | null> = ref(null)
    const viewport: Ref<Element | null> = ref(null)
    const slideSize: Ref<number> = ref(0)

    const fallbackConfig = computed(() => ({
      ...DEFAULT_CONFIG,
      // Avoid reactivity tracking in breakpoints and vModel which would trigger unnecessary updates
      ...except(props, ['breakpoints', 'modelValue']),
      i18n: { ...DEFAULT_CONFIG.i18n, ...props.i18n },
    }))

    // current active config
    const config = shallowReactive<CarouselConfig>({ ...fallbackConfig.value })

    // slides
    const currentSlideIndex = ref(props.modelValue ?? 0)
    const activeSlideIndex = ref(currentSlideIndex.value)

    watch(currentSlideIndex, (val) => (activeSlideIndex.value = val))
    const prevSlideIndex = ref(0)
    const middleSlideIndex = computed(() => Math.ceil((slidesCount.value - 1) / 2))
    const maxSlideIndex = computed(() => slidesCount.value - 1)
    const minSlideIndex = computed(() => 0)

    let autoplayTimer: ReturnType<typeof setInterval> | null = null
    let transitionTimer: ReturnType<typeof setTimeout> | null = null
    let resizeObserver: ResizeObserver | null = null

    const effectiveSlideSize = computed(() => slideSize.value + config.gap)

    const normalizedDir = computed<NormalizedDir>(() => {
      const dir = config.dir || 'ltr'
      return dir in DIR_MAP ? DIR_MAP[dir as NonNormalizedDir] : (dir as NormalizedDir)
    })

    const isReversed = computed(() => ['rtl', 'btt'].includes(normalizedDir.value))
    const isVertical = computed(() => ['ttb', 'btt'].includes(normalizedDir.value))
    const isAuto = computed(() => config.itemsToShow === 'auto')

    const dimension = computed(() => (isVertical.value ? 'height' : 'width'))

    function updateBreakpointsConfig(): void {
      if (!mounted.value) {
        return
      }
      // Determine the width source based on the 'breakpointMode' config
      const widthSource =
        (fallbackConfig.value.breakpointMode === 'carousel'
          ? root.value?.getBoundingClientRect().width
          : typeof window !== 'undefined'
            ? window.innerWidth
            : 0) || 0

      const breakpointsArray = Object.keys(props.breakpoints || {})
        .map((key) => Number(key))
        .sort((a, b) => +b - +a)

      const newConfig: Partial<CarouselConfig> = {}
      breakpointsArray.some((breakpoint) => {
        if (widthSource >= breakpoint) {
          Object.assign(newConfig, props.breakpoints![breakpoint])
          if (newConfig.i18n) {
            Object.assign(
              newConfig.i18n,
              fallbackConfig.value.i18n,
              props.breakpoints![breakpoint].i18n
            )
          }
          return true
        }
        return false
      })

      Object.assign(config, fallbackConfig.value, newConfig)
    }

    const handleResize = throttle(() => {
      updateBreakpointsConfig()
      updateSlidesData()
      updateSlideSize()
    })

    const transformElements = shallowReactive<Set<HTMLElement>>(new Set())

    /**
     * Setup functions
     */
    const slidesRect = ref<Array<ElRect>>([])
    function updateSlidesRectSize({
      widthMultiplier,
      heightMultiplier,
    }: ScaleMultipliers): void {
      slidesRect.value = slides.map((slide) => {
        const rect = slide.exposed?.getBoundingRect()
        return {
          width: rect.width * widthMultiplier,
          height: rect.height * heightMultiplier,
        }
      })
    }
    const viewportRect: Ref<ElRect> = ref({
      width: 0,
      height: 0,
    })
    function updateViewportRectSize({
      widthMultiplier,
      heightMultiplier,
    }: ScaleMultipliers): void {
      const rect = viewport.value?.getBoundingClientRect() || { width: 0, height: 0 }
      viewportRect.value = {
        width: rect.width * widthMultiplier,
        height: rect.height * heightMultiplier,
      }
    }

    function updateSlideSize(): void {
      if (!viewport.value) return

      const scaleMultipliers = getScaleMultipliers(transformElements)

      updateViewportRectSize(scaleMultipliers)
      updateSlidesRectSize(scaleMultipliers)

      if (isAuto.value) {
        slideSize.value = calculateAverage(
          slidesRect.value.map((slide) => slide[dimension.value])
        )
      } else {
        const itemsToShow = Number(config.itemsToShow)
        const totalGap = (itemsToShow - 1) * config.gap
        slideSize.value = (viewportRect.value[dimension.value] - totalGap) / itemsToShow
      }
    }

    function updateSlidesData(): void {
      if (!config.wrapAround && slidesCount.value > 0) {
        currentSlideIndex.value = getNumberInRange({
          val: currentSlideIndex.value,
          max: maxSlideIndex.value,
          min: minSlideIndex.value,
        })
      }

      // Validate itemsToShow
      if (!isAuto.value) {
        config.itemsToShow = getNumberInRange({
          val: Number(config.itemsToShow),
          max: slidesCount.value,
          min: 1,
        })
      }
    }

    const ignoreAnimations = computed<false | string[]>(() => {
      if (typeof props.ignoreAnimations === 'string') {
        return props.ignoreAnimations.split(',')
      } else if (Array.isArray(props.ignoreAnimations)) {
        return props.ignoreAnimations
      } else if (!props.ignoreAnimations) {
        return []
      }
      return false
    })

    watchEffect(() => updateSlidesData())

    watchEffect(() => {
      // Call updateSlideSize when viewport is ready and track deps
      updateSlideSize()
    })

    let animationInterval: number

    const setAnimationInterval = (event: AnimationEvent) => {
      const target = event.target as HTMLElement
      if (
        !target?.contains(root.value) ||
        (Array.isArray(ignoreAnimations.value) &&
          ignoreAnimations.value.includes(event.animationName))
      ) {
        return
      }

      transformElements.add(target)
      if (!animationInterval) {
        const stepAnimation = () => {
          animationInterval = requestAnimationFrame(() => {
            updateSlideSize()
            stepAnimation()
          })
        }
        stepAnimation()
      }
    }
    const finishAnimation = (event: AnimationEvent | TransitionEvent) => {
      const target = event.target as HTMLElement
      if (target) {
        transformElements.delete(target)
      }
      if (animationInterval && transformElements.size === 0) {
        cancelAnimationFrame(animationInterval)
        updateSlideSize()
      }
    }

    const mounted = ref(false)

    if (typeof document !== 'undefined') {
      watchEffect(() => {
        if (mounted.value && ignoreAnimations.value !== false) {
          document.addEventListener('animationstart', setAnimationInterval)
          document.addEventListener('animationend', finishAnimation)
        } else {
          document.removeEventListener('animationstart', setAnimationInterval)
          document.removeEventListener('animationend', finishAnimation)
        }
      })
    }

    onMounted((): void => {
      mounted.value = true
      updateBreakpointsConfig()
      initAutoplay()

      if (root.value) {
        resizeObserver = new ResizeObserver(handleResize)
        resizeObserver.observe(root.value)
      }

      emit('init')
    })

    onBeforeUnmount(() => {
      mounted.value = false

      slideRegistry.cleanup()

      if (transitionTimer) {
        clearTimeout(transitionTimer)
      }
      if (animationInterval) {
        cancelAnimationFrame(animationInterval)
      }
      if (autoplayTimer) {
        clearInterval(autoplayTimer)
      }
      if (resizeObserver) {
        resizeObserver.disconnect()
        resizeObserver = null
      }

      if (typeof document !== 'undefined') {
        handleBlur()
      }
      if (root.value) {
        root.value.removeEventListener('transitionend', updateSlideSize)
        root.value.removeEventListener('animationiteration', updateSlideSize)
      }
    })

    /**
     * Carousel Event listeners
     */
    let isTouch = false
    const startPosition = { x: 0, y: 0 }
    const dragged = reactive({ x: 0, y: 0 })
    const isHover = ref(false)
    const isDragging = ref(false)

    const handleMouseEnter = (): void => {
      isHover.value = true
    }
    const handleMouseLeave = (): void => {
      isHover.value = false
    }

    const handleArrowKeys = throttle((event: KeyboardEvent): void => {
      if (event.ctrlKey) return
      switch (event.key) {
        case 'ArrowLeft':
        case 'ArrowUp':
          if (isVertical.value === event.key.endsWith('Up')) {
            if (isReversed.value) {
              next(true)
            } else {
              prev(true)
            }
          }
          break
        case 'ArrowRight':
        case 'ArrowDown':
          if (isVertical.value === event.key.endsWith('Down')) {
            if (isReversed.value) {
              prev(true)
            } else {
              next(true)
            }
          }
          break
      }
    }, 200)
    const handleFocus = (): void => {
      document.addEventListener('keydown', handleArrowKeys)
    }
    const handleBlur = (): void => {
      document.removeEventListener('keydown', handleArrowKeys)
    }

    function handleDragStart(event: MouseEvent | TouchEvent): void {
      // Prevent drag initiation on input elements or if already sliding
      const targetTagName = (event.target as HTMLElement).tagName
      if (['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTagName) || isSliding.value) {
        return
      }

      // Detect if the event is a touchstart or mousedown event
      isTouch = event.type === 'touchstart'

      // For mouse events, prevent default to avoid text selection
      if (!isTouch) {
        event.preventDefault()
        if ((event as MouseEvent).button !== 0) {
          // Ignore non-left-click mouse events
          return
        }
      }

      // Initialize start positions for the drag
      startPosition.x = 'touches' in event ? event.touches[0].clientX : event.clientX
      startPosition.y = 'touches' in event ? event.touches[0].clientY : event.clientY

      // Attach event listeners for dragging and drag end

      const moveEvent = isTouch ? 'touchmove' : 'mousemove'
      const endEvent = isTouch ? 'touchend' : 'mouseup'
      document.addEventListener(moveEvent, handleDragging, { passive: false })
      document.addEventListener(endEvent, handleDragEnd, { passive: true })
    }

    const handleDragging = throttle((event: TouchEvent | MouseEvent): void => {
      isDragging.value = true

      // Get the current position based on the interaction type (touch or mouse)
      const currentX = 'touches' in event ? event.touches[0].clientX : event.clientX
      const currentY = 'touches' in event ? event.touches[0].clientY : event.clientY

      // Calculate deltas for X and Y axes
      dragged.x = currentX - startPosition.x
      dragged.y = currentY - startPosition.y

      const draggedSlides = getDraggedSlidesCount({
        isVertical: isVertical.value,
        isReversed: isReversed.value,
        dragged,
        effectiveSlideSize: effectiveSlideSize.value,
      })

      activeSlideIndex.value = config.wrapAround
        ? currentSlideIndex.value + draggedSlides
        : getNumberInRange({
            val: currentSlideIndex.value + draggedSlides,
            max: maxSlideIndex.value,
            min: minSlideIndex.value,
          })

      // Emit a drag event for further customization if needed
      emit('drag', { deltaX: dragged.x, deltaY: dragged.y })
    })

    function handleDragEnd(): void {
      handleDragging.cancel()

      // Prevent accidental clicks when there is a slide drag
      if (activeSlideIndex.value !== currentSlideIndex.value && !isTouch) {
        const preventClick = (e: MouseEvent) => {
          e.preventDefault()
          window.removeEventListener('click', preventClick)
        }
        window.addEventListener('click', preventClick)
      }

      slideTo(activeSlideIndex.value)

      // Reset drag state
      dragged.x = 0
      dragged.y = 0
      isDragging.value = false

      const moveEvent = isTouch ? 'touchmove' : 'mousemove'
      const endEvent = isTouch ? 'touchend' : 'mouseup'
      document.removeEventListener(moveEvent, handleDragging)
      document.removeEventListener(endEvent, handleDragEnd)
    }

    /**
     * Autoplay
     */
    function initAutoplay(): void {
      if (!config.autoplay || config.autoplay <= 0) {
        return
      }

      autoplayTimer = setInterval(() => {
        if (config.pauseAutoplayOnHover && isHover.value) {
          return
        }

        next()
      }, config.autoplay)
    }

    function stopAutoplay(): void {
      if (autoplayTimer) {
        clearInterval(autoplayTimer)
        autoplayTimer = null
      }
    }

    function resetAutoplay(): void {
      stopAutoplay()
      initAutoplay()
    }

    /**
     * Navigation function
     */
    const isSliding = ref(false)

    function slideTo(slideIndex: number, skipTransition = false): void {
      if (!skipTransition && isSliding.value) {
        return
      }

      let targetIndex = slideIndex
      let mappedIndex = slideIndex

      prevSlideIndex.value = currentSlideIndex.value

      if (!config.wrapAround) {
        targetIndex = getNumberInRange({
          val: targetIndex,
          max: maxSlideIndex.value,
          min: minSlideIndex.value,
        })
      } else {
        mappedIndex = mapNumberToRange({
          val: targetIndex,
          max: maxSlideIndex.value,
          min: minSlideIndex.value,
        })
      }

      emit('slide-start', {
        slidingToIndex: slideIndex,
        currentSlideIndex: currentSlideIndex.value,
        prevSlideIndex: prevSlideIndex.value,
        slidesCount: slidesCount.value,
      })

      stopAutoplay()
      isSliding.value = true

      currentSlideIndex.value = targetIndex
      if (mappedIndex !== targetIndex) {
        modelWatcher.pause()
      }
      emit('update:modelValue', mappedIndex)

      const transitionCallback = (): void => {
        if (config.wrapAround && mappedIndex !== targetIndex) {
          modelWatcher.resume()

          currentSlideIndex.value = mappedIndex
          emit('loop', {
            currentSlideIndex: currentSlideIndex.value,
            slidingToIndex: slideIndex,
          })
        }

        emit('slide-end', {
          currentSlideIndex: currentSlideIndex.value,
          prevSlideIndex: prevSlideIndex.value,
          slidesCount: slidesCount.value,
        })

        isSliding.value = false
        resetAutoplay()
      }

      transitionTimer = setTimeout(transitionCallback, config.transition)
    }

    function next(skipTransition = false): void {
      slideTo(currentSlideIndex.value + config.itemsToScroll, skipTransition)
    }

    function prev(skipTransition = false): void {
      slideTo(currentSlideIndex.value - config.itemsToScroll, skipTransition)
    }

    function restartCarousel(): void {
      updateBreakpointsConfig()
      updateSlidesData()
      updateSlideSize()
      resetAutoplay()
    }

    // Update the carousel on props change
    watch(
      () => [fallbackConfig.value, props.breakpoints],
      () => updateBreakpointsConfig(),
      { deep: true }
    )

    watch(
      () => props.autoplay,
      () => resetAutoplay()
    )

    // Handle changing v-model value
    const modelWatcher = watch(
      () => props.modelValue,
      (val) => {
        if (val === currentSlideIndex.value) {
          return
        }
        slideTo(Number(val), true)
      }
    )

    // Init carousel
    emit('before-init')

    const clonedSlidesCount = computed(() => {
      if (!config.wrapAround) {
        return { before: 0, after: 0 }
      }
      if (isAuto.value) {
        return { before: slides.length, after: slides.length }
      }

      const itemsToShow = Number(config.itemsToShow)
      const slidesToClone = Math.ceil(itemsToShow + (config.itemsToScroll - 1))
      const before = slidesToClone - activeSlideIndex.value
      const after = slidesToClone - (slidesCount.value - (activeSlideIndex.value + 1))

      return {
        before: Math.max(0, before),
        after: Math.max(0, after),
      }
    })

    const clonedSlidesOffset = computed(() => {
      if (!clonedSlidesCount.value.before) {
        return 0
      }
      if (isAuto.value) {
        return (
          slidesRect.value
            .slice(-1 * clonedSlidesCount.value.before)
            .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) * -1
        )
      }

      return clonedSlidesCount.value.before * effectiveSlideSize.value * -1
    })

    const snapAlignOffset = computed(() => {
      if (isAuto.value) {
        const slideIndex =
          ((currentSlideIndex.value % slides.length) + slides.length) % slides.length
        return getSnapAlignOffset({
          slideSize: slidesRect.value[slideIndex]?.[dimension.value],
          viewportSize: viewportRect.value[dimension.value],
          align: config.snapAlign,
        })
      }

      return getSnapAlignOffset({
        align: config.snapAlign,
        itemsToShow: +config.itemsToShow,
      })
    })
    const scrolledOffset = computed(() => {
      let output = 0

      if (isAuto.value) {
        if (currentSlideIndex.value < 0) {
          output =
            slidesRect.value
              .slice(currentSlideIndex.value)
              .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) * -1
        } else {
          output = slidesRect.value
            .slice(0, currentSlideIndex.value)
            .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0)
        }
        output -= snapAlignOffset.value

        // remove whitespace
        if (!config.wrapAround) {
          const maxSlidingValue =
            slidesRect.value.reduce(
              (acc, slide) => acc + slide[dimension.value] + config.gap,
              0
            ) -
            viewportRect.value[dimension.value] -
            config.gap

          output = getNumberInRange({
            val: output,
            max: maxSlidingValue,
            min: 0,
          })
        }
      } else {
        let scrolledSlides = currentSlideIndex.value - snapAlignOffset.value

        // remove whitespace
        if (!config.wrapAround) {
          scrolledSlides = getNumberInRange({
            val: scrolledSlides,
            max: slidesCount.value - +config.itemsToShow,
            min: 0,
          })
        }
        output = scrolledSlides * effectiveSlideSize.value
      }

      return output * (isReversed.value ? 1 : -1)
    })

    const visibleRange = computed(() => {
      if (!isAuto.value) {
        const base = currentSlideIndex.value - snapAlignOffset.value
        if (config.wrapAround) {
          return {
            min: Math.floor(base),
            max: Math.ceil(base + Number(config.itemsToShow) - 1),
          }
        }
        return {
          min: Math.floor(
            getNumberInRange({
              val: base,
              max: slidesCount.value - Number(config.itemsToShow),
              min: 0,
            })
          ),
          max: Math.ceil(
            getNumberInRange({
              val: base + Number(config.itemsToShow) - 1,
              max: slidesCount.value - 1,
              min: 0,
            })
          ),
        }
      }

      // Auto width mode
      let minIndex = 0
      {
        let accumulatedSize = 0
        let index = 0 - clonedSlidesCount.value.before
        const offset = Math.abs(scrolledOffset.value + clonedSlidesOffset.value)

        while (accumulatedSize <= offset) {
          const normalizedIndex =
            ((index % slides.length) + slides.length) % slides.length
          accumulatedSize +=
            slidesRect.value[normalizedIndex]?.[dimension.value] + config.gap
          index++
        }
        minIndex = index - 1
      }

      let maxIndex = 0
      {
        let index = minIndex
        let accumulatedSize = 0
        if (index < 0) {
          accumulatedSize =
            slidesRect.value
              .slice(0, index)
              .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) -
            Math.abs(scrolledOffset.value + clonedSlidesOffset.value)
        } else {
          accumulatedSize =
            slidesRect.value
              .slice(0, index)
              .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) -
            Math.abs(scrolledOffset.value)
        }

        while (accumulatedSize < viewportRect.value[dimension.value]) {
          const normalizedIndex =
            ((index % slides.length) + slides.length) % slides.length
          accumulatedSize +=
            slidesRect.value[normalizedIndex]?.[dimension.value] + config.gap
          index++
        }
        maxIndex = index - 1
      }

      return {
        min: Math.floor(minIndex),
        max: Math.ceil(maxIndex),
      }
    })

    const trackTransform: ComputedRef<string | undefined> = computed(() => {
      if (config.slideEffect === 'fade') {
        return undefined
      }

      const translateAxis = isVertical.value ? 'Y' : 'X'

      // Include user drag interaction offset
      const dragOffset = isVertical.value ? dragged.y : dragged.x

      let totalOffset = scrolledOffset.value + dragOffset

      if (!config.wrapAround && config.preventExcessiveDragging) {
        let maxSlidingValue = 0
        if (isAuto.value) {
          maxSlidingValue = slidesRect.value.reduce(
            (acc, slide) => acc + slide[dimension.value],
            0
          )
        } else {
          maxSlidingValue =
            (slidesCount.value - Number(config.itemsToShow)) * effectiveSlideSize.value
        }
        const min = isReversed.value ? 0 : -1 * maxSlidingValue
        const max = isReversed.value ? maxSlidingValue : 0
        totalOffset = getNumberInRange({
          val: totalOffset,
          min,
          max,
        })
      }
      return `translate${translateAxis}(${totalOffset}px)`
    })

    const carouselStyle = computed(() => ({
      '--vc-transition-duration': isSliding.value
        ? toCssValue(config.transition, 'ms')
        : undefined,
      '--vc-slide-gap': toCssValue(config.gap),
      '--vc-carousel-height': toCssValue(config.height),
      '--vc-cloned-offset': toCssValue(clonedSlidesOffset.value),
    }))

    const nav: CarouselNav = { slideTo, next, prev }

    const provided: InjectedCarousel = reactive({
      activeSlide: activeSlideIndex,
      config,
      currentSlide: currentSlideIndex,
      isSliding,
      isVertical,
      maxSlide: maxSlideIndex,
      minSlide: minSlideIndex,
      nav,
      normalizedDir,
      slideRegistry,
      slideSize,
      slides,
      slidesCount,
      viewport,
      visibleRange,
    })

    provide(injectCarousel, provided)

    const data = reactive<CarouselData>({
      config,
      currentSlide: currentSlideIndex,
      maxSlide: maxSlideIndex,
      middleSlide: middleSlideIndex,
      minSlide: minSlideIndex,
      slideSize,
      slidesCount,
    })

    expose<CarouselExposed>({
      data,
      nav,
      next,
      prev,
      restartCarousel,
      slideTo,
      updateBreakpointsConfig,
      updateSlideSize,
      updateSlidesData,
    })

    return () => {
      const slotSlides = slots.default || slots.slides
      const outputSlides = slotSlides?.(data) || []

      const { before, after } = clonedSlidesCount.value
      const slidesBefore = createCloneSlides({
        slides,
        position: 'before',
        toShow: before,
      })

      const slidesAfter = createCloneSlides({
        slides,
        position: 'after',
        toShow: after,
      })

      const output = [...slidesBefore, ...outputSlides, ...slidesAfter]

      if (!config.enabled || !output.length) {
        return h(
          'section',
          {
            ref: root,
            class: ['carousel', 'is-disabled'],
          },
          output
        )
      }

      const addonsElements = slots.addons?.(data) || []

      const trackEl = h(
        'ol',
        {
          class: 'carousel__track',
          style: { transform: trackTransform.value },
          onMousedownCapture: config.mouseDrag ? handleDragStart : null,
          onTouchstartPassiveCapture: config.touchDrag ? handleDragStart : null,
        },
        output
      )
      const viewPortEl = h('div', { class: 'carousel__viewport', ref: viewport }, trackEl)

      return h(
        'section',
        {
          ref: root,
          class: [
            'carousel',
            `is-${normalizedDir.value}`,
            `is-effect-${config.slideEffect}`,
            {
              'is-vertical': isVertical.value,
              'is-sliding': isSliding.value,
              'is-dragging': isDragging.value,
              'is-hover': isHover.value,
            },
          ],
          dir: normalizedDir.value,
          style: carouselStyle.value,
          'aria-label': config.i18n['ariaGallery'],
          tabindex: '0',
          onFocus: handleFocus,
          onBlur: handleBlur,
          onMouseenter: handleMouseEnter,
          onMouseleave: handleMouseLeave,
        },
        [viewPortEl, addonsElements, h(ARIAComponent)]
      )
    }
  },
})

declare enum IconName {
  arrowDown = 'arrowDown',
  arrowLeft = 'arrowLeft',
  arrowRight = 'arrowRight',
  arrowUp = 'arrowUp',
}

type IconNameValue = `${IconName}`

type IconProps = {
  name: IconNameValue
  title?: string
}

declare const icons = {
  arrowDown: 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z',
  arrowLeft: 'M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z',
  arrowRight: 'M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z',
  arrowUp: 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z',
}

declare const Icon = defineComponent<IconProps>({
  props: {
    name: {
      type: String as PropType<IconNameValue>,
      required: true,
      validator: validateIconName,
    },
    title: {
      type: String,
      default: (props: { name: IconNameValue }) =>
        props.name ? DEFAULT_CONFIG.i18n[iconI18n(props.name)] : '',
    },
  },
  setup(props) {
    const carousel = inject(injectCarousel, null)

    return () => {
      const iconName = props.name
      if (!iconName || !validateIconName(iconName)) return

      const path = icons[iconName]
      const pathEl = h('path', { d: path })

      const iconTitle: string = carousel?.config.i18n[iconI18n(iconName)] || props.title!

      const titleEl = h('title', iconTitle)

      return h(
        'svg',
        {
          class: 'carousel__icon',
          viewBox: '0 0 24 24',
          role: 'img',
          'aria-label': iconTitle,
        },
        [titleEl, pathEl]
      )
    }
  },
})

type NavigationProps = Record<never, never> // No props for now

declare const Navigation = defineComponent<NavigationProps>({
  name: 'CarouselNavigation',
  inheritAttrs: false,
  setup(props, { slots, attrs }) {
    const carousel = inject(injectCarousel)
    if (!carousel) {
      return () => '' // Don't render, let vue warn about the missing provide
    }
    const { next: slotNext, prev: slotPrev } = slots

    const getPrevIcon = () => {
      const directionIcons: Record<NormalizedDir, IconNameValue> = {
        btt: 'arrowDown',
        ltr: 'arrowLeft',
        rtl: 'arrowRight',
        ttb: 'arrowUp',
      }

      return directionIcons[carousel.normalizedDir]
    }
    const getNextIcon = () => {
      const directionIcons: Record<NormalizedDir, IconNameValue> = {
        btt: 'arrowUp',
        ltr: 'arrowRight',
        rtl: 'arrowLeft',
        ttb: 'arrowDown',
      }

      return directionIcons[carousel.normalizedDir]
    }

    const prevDisabled = computed(
      () => !carousel.config.wrapAround && carousel.currentSlide <= carousel.minSlide
    )
    const nextDisabled = computed(
      () => !carousel.config.wrapAround && carousel.currentSlide >= carousel.maxSlide
    )

    return () => {
      const { i18n } = carousel.config
      const prevButton = h(
        'button',
        {
          type: 'button',
          disabled: prevDisabled.value,
          'aria-label': i18n['ariaPreviousSlide'],
          title: i18n['ariaPreviousSlide'],
          onClick: carousel.nav.prev,
          ...attrs,
          class: [
            'carousel__prev',
            { 'carousel__prev--disabled': prevDisabled.value },
            attrs.class,
          ],
        },
        slotPrev?.() || h(Icon, { name: getPrevIcon() })
      )
      const nextButton = h(
        'button',
        {
          type: 'button',
          disabled: nextDisabled.value,
          'aria-label': i18n['ariaNextSlide'],
          title: i18n['ariaNextSlide'],
          onClick: carousel.nav.next,
          ...attrs,
          class: [
            'carousel__next',
            { 'carousel__next--disabled': nextDisabled.value },
            attrs.class,
          ],
        },
        slotNext?.() || h(Icon, { name: getNextIcon() })
      )

      return [prevButton, nextButton]
    }
  },
})

type PaginationProps = {
  disableOnClick?: boolean
  paginateByItemsToShow?: boolean
}

declare const Pagination = defineComponent<PaginationProps>({
  name: 'CarouselPagination',
  props: {
    disableOnClick: {
      type: Boolean,
    },
    paginateByItemsToShow: {
      type: Boolean,
    },
  },
  setup(props) {
    const carousel = inject(injectCarousel)

    if (!carousel) {
      return () => '' // Don't render, let vue warn about the missing provide
    }

    const itemsToShow = computed(() => carousel.config.itemsToShow as number)
    const offset = computed(() =>
      getSnapAlignOffset({
        align: carousel.config.snapAlign,
        itemsToShow: itemsToShow.value,
      })
    )
    const isPaginated = computed(
      () => props.paginateByItemsToShow && itemsToShow.value > 1
    )
    const currentPage = computed(() =>
      Math.ceil((carousel.activeSlide - offset.value) / itemsToShow.value)
    )
    const pageCount = computed(() => Math.ceil(carousel.slidesCount / itemsToShow.value))

    const isActive = (slide: number): boolean =>
      mapNumberToRange(
        isPaginated.value
          ? {
              val: currentPage.value,
              max: pageCount.value - 1,
              min: 0,
            }
          : {
              val: carousel.activeSlide,
              max: carousel.maxSlide,
              min: carousel.minSlide,
            }
      ) === slide

    return () => {
      const children: Array<VNode> = []

      for (
        let slide = isPaginated.value ? 0 : carousel.minSlide;
        slide <= (isPaginated.value ? pageCount.value - 1 : carousel.maxSlide);
        slide++
      ) {
        const buttonLabel = i18nFormatter(
          carousel.config.i18n[
            isPaginated.value ? 'ariaNavigateToPage' : 'ariaNavigateToSlide'
          ],
          {
            slideNumber: slide + 1,
          }
        )
        const active = isActive(slide)
        const button = h('button', {
          type: 'button',
          class: {
            'carousel__pagination-button': true,
            'carousel__pagination-button--active': active,
          },
          'aria-label': buttonLabel,
          'aria-pressed': active,
          'aria-controls': carousel.slides[slide]?.exposed?.id,
          title: buttonLabel,
          disabled: props.disableOnClick,
          onClick: () =>
            carousel.nav.slideTo(
              isPaginated.value
                ? Math.floor(slide * +carousel.config.itemsToShow + offset.value)
                : slide
            ),
        })
        const item = h('li', { class: 'carousel__pagination-item', key: slide }, button)
        children.push(item)
      }

      return h('ol', { class: 'carousel__pagination' }, children)
    }
  },
})

type SlideProps = {
  id?: string
  index: number
  isClone?: boolean
  position?: 'before' | 'after'
}

declare const Slide = defineComponent({
  name: 'CarouselSlide',
  props: {
    id: {
      type: String,
      default: (props: { isClone?: boolean }) => (props.isClone ? undefined : useId()),
    },
    index: {
      type: Number,
      default: undefined,
    },
    isClone: {
      type: Boolean,
      default: false,
    },
    position: {
      type: String,
      default: undefined,
    },
  },
  setup(props: DeepReadonly<SlideProps>, { attrs, slots, expose }: SetupContext) {
    const carousel = inject(injectCarousel)
    provide(injectCarousel, undefined) // Don't provide for nested slides

    if (!carousel) {
      return () => '' // Don't render, let vue warn about the missing provide
    }

    const currentIndex = ref(props.index)

    const setIndex = (newIndex: number) => {
      currentIndex.value = newIndex
    }

    const instance = getCurrentInstance()!

    const getBoundingRect = () => {
      const el = instance.vnode.el as HTMLElement
      return el ? el.getBoundingClientRect() : { width: 0, height: 0 }
    }

    expose({
      id: props.id,
      setIndex,
      getBoundingRect,
    })

    const isActive: ComputedRef<boolean> = computed(
      () => currentIndex.value === carousel.activeSlide
    )
    const isPrev: ComputedRef<boolean> = computed(
      () => currentIndex.value === carousel.activeSlide - 1
    )
    const isNext: ComputedRef<boolean> = computed(
      () => currentIndex.value === carousel.activeSlide + 1
    )
    const isVisible: ComputedRef<boolean> = computed(
      () =>
        currentIndex.value >= carousel.visibleRange.min &&
        currentIndex.value <= carousel.visibleRange.max
    )

    const slideStyle = computed(() => {
      if (carousel.config.itemsToShow === 'auto') {
        return
      }
      const itemsToShow = carousel.config.itemsToShow
      const dimension =
        carousel.config.gap > 0 && itemsToShow > 1
          ? `calc(${100 / itemsToShow}% - ${
              (carousel.config.gap * (itemsToShow - 1)) / itemsToShow
            }px)`
          : `${100 / itemsToShow}%`

      return carousel.isVertical ? { height: dimension } : { width: dimension }
    })

    carousel.slideRegistry.registerSlide(instance, props.index)
    onUnmounted(() => {
      carousel.slideRegistry.unregisterSlide(instance)
    })

    if (props.isClone) {
      // Prevent cloned slides from being focusable
      onMounted(() => {
        disableChildrenTabbing(instance.vnode)
      })
      onUpdated(() => {
        disableChildrenTabbing(instance.vnode)
      })
    }

    return () => {
      if (!carousel.config.enabled) {
        return slots.default?.()
      }

      return h(
        'li',
        {
          style: [attrs.style, { ...slideStyle.value }],
          class: {
            carousel__slide: true,
            'carousel__slide--clone': props.isClone,
            'carousel__slide--visible': isVisible.value,
            'carousel__slide--active': isActive.value,
            'carousel__slide--prev': isPrev.value,
            'carousel__slide--next': isNext.value,
            'carousel__slide--sliding': carousel.isSliding,
          },
          onFocusin: () => {
            // Prevent the viewport being scrolled by the focus
            if (carousel.viewport) {
              carousel.viewport.scrollLeft = 0
            }
            carousel.nav.slideTo(currentIndex.value)
          },
          id: props.isClone ? undefined : props.id,
          'aria-hidden': props.isClone || undefined,
        },
        slots.default?.({
          currentIndex: currentIndex.value,
          isActive: isActive.value,
          isClone: props.isClone,
          isPrev: isPrev.value,
          isNext: isNext.value,
          isSliding: carousel.isSliding,
          isVisible: isVisible.value,
        })
      )
    }
  },
})

export { BREAKPOINT_MODE_OPTIONS, type BreakpointMode, type Breakpoints, Carousel, type CarouselConfig, type CarouselData, type CarouselExposed, type CarouselMethods, type CarouselNav, DEFAULT_CONFIG, DIR_MAP, DIR_OPTIONS, type Dir, type ElRect, I18N_DEFAULT_CONFIG, type I18nKeys, Icon, IconName, type IconNameValue, type IconProps, type InjectedCarousel, NORMALIZED_DIR_OPTIONS, Navigation, type NavigationProps, type NonNormalizedDir, type NormalizedDir, Pagination, type PaginationProps, type Range, SLIDE_EFFECTS, SNAP_ALIGN_OPTIONS, Slide, type SlideEffect, type SlideProps, type SlideRegistry, type SnapAlign, type VueClass, createSlideRegistry, icons, injectCarousel };
