<template>
  <teleport :to="teleportTarget">
    <div
      v-if="isMounted"
      aria-hidden="true"
      class="cpBottomSheet__backdrop"
      :style="backdropStyle"
      @click="onBackdropClick"
    />
    <div
      v-if="isMounted"
      ref="sheetRef"
      v-bind="$attrs"
      aria-modal="true"
      class="cpBottomSheet"
      role="dialog"
      :style="sheetStyle"
      tabindex="-1"
      @keydown.esc.stop="onSheetEscapeKeydown"
      @keydown.tab="onSheetTabKeydown"
    >
      <header ref="headerRef" v-drag="onHandleDrag" class="cpBottomSheet__header">
        <slot name="header" />
      </header>
      <div ref="scrollRef" v-drag="onContentDrag" class="cpBottomSheet__scroll" @touchmove="onScrollTouchMove">
        <main ref="contentRef" class="cpBottomSheet__content" :class="contentClass" tabindex="0">
          <slot />
        </main>
      </div>
      <footer ref="footerRef" v-drag="onHandleDrag" class="cpBottomSheet__footer">
        <slot name="footer" />
      </footer>
    </div>
  </teleport>
</template>

<script setup lang="ts">
import { defaultDocument, defaultWindow, useEventListener } from '@vueuse/core'
import { dragDirective } from '@vueuse/gesture'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'

import { useBottomSheetDrag } from '@/composables/useBottomSheetDrag'

import { handleTrapFocus } from '@/helpers/dom'

type SheetState = 'closed' | 'closing' | 'open' | 'opening'

interface Emits {
  (e: 'close' | 'open'): void
}

interface Props {
  animationDuration?: number
  canBackdropClose?: boolean
  canSwipeClose?: boolean
  contentClass?: string
  expandOnContentDrag?: boolean
  halfAndExpand?: boolean
  preventClose?: boolean
  snapPoints?: Array<number | `${number}%`>
  swipeCloseThreshold?: string
  teleportTo?: string
}

const props = withDefaults(defineProps<Props>(), {
  animationDuration: 600,
  canBackdropClose: true,
  canSwipeClose: true,
  expandOnContentDrag: true,
  halfAndExpand: false,
  snapPoints: () => [],
  swipeCloseThreshold: '40%',
  teleportTo: '#teleports',
})

const emit = defineEmits<Emits>()

defineOptions({ inheritAttrs: false })

const vDrag = dragDirective()

const EXIT_DURATION_RATIO = 1 / 3
const BACKDROP_FADE_DURATION = 300

const isVisibleModel = defineModel<boolean>()

const sheetRef = useTemplateRef<HTMLElement>('sheetRef')
const headerRef = useTemplateRef<HTMLElement>('headerRef')
const contentRef = useTemplateRef<HTMLElement>('contentRef')
const footerRef = useTemplateRef<HTMLElement>('footerRef')
const scrollRef = useTemplateRef<HTMLElement>('scrollRef')

const state = ref<SheetState>('closed')
const keyboardInset = ref(0)

const isMounted = computed(() => state.value !== 'closed')

const {
  height,
  isDragging,
  measureNaturalHeight,
  onContentDrag,
  onHandleDrag,
  onScrollTouchMove,
  reset: resetDrag,
  snapToSmallest,
  translateY,
  windowHeight,
} = useBottomSheetDrag(props, {
  contentRef,
  footerRef,
  headerRef,
  onDismiss: () => dismissSheet(),
  scrollRef,
})

const teleportTarget = computed(() => {
  if (!defaultDocument) return props.teleportTo
  return defaultDocument.querySelector(props.teleportTo) ? props.teleportTo : 'body'
})

const exitDuration = computed(() => Math.round(props.animationDuration * EXIT_DURATION_RATIO))

const backdropStyle = computed(() => {
  const fadeIn = `opacity ${BACKDROP_FADE_DURATION}ms ease-out`
  const fadeOut = `opacity ${exitDuration.value}ms var(--cp-bottomsheet-exit)`

  return {
    opacity: state.value === 'open' ? 1 : 0,
    transition: state.value === 'closing' ? fadeOut : fadeIn,
  }
})

const sheetStyle = computed(() => {
  const spring = `${props.animationDuration}ms var(--cp-easing-elastic-strong)`
  const exit = `${exitDuration.value}ms var(--cp-bottomsheet-exit)`
  const timing = state.value === 'closing' ? exit : spring

  return {
    height: height.value === null ? undefined : `${Math.min(height.value + keyboardInset.value, windowHeight.value)}px`,
    paddingBottom: `${keyboardInset.value}px`,
    transform: state.value === 'open' ? `translate3d(0, ${translateY.value}px, 0)` : 'translate3d(0, 100%, 0)',
    transition: isDragging.value ? 'none' : `transform ${timing}, height ${timing}`,
  }
})

let animationTimer: ReturnType<typeof setTimeout> | undefined

const scheduleAfterAnimation = (duration: number, onDone: () => void) => {
  clearTimeout(animationTimer)
  animationTimer = setTimeout(onDone, duration)
}

const updateKeyboardInset = () => {
  const viewport = defaultWindow?.visualViewport

  if (!viewport || !isMounted.value) {
    keyboardInset.value = 0
    return
  }

  const overlap = (defaultWindow?.innerHeight ?? 0) - viewport.height - viewport.offsetTop

  keyboardInset.value = Math.round(Math.max(0, overlap))
}

let previouslyFocusedElement: HTMLElement | null = null

const focusSheet = () => {
  previouslyFocusedElement = (defaultDocument?.activeElement as HTMLElement | null) ?? null
  sheetRef.value?.focus({ preventScroll: true })
}

const restoreFocus = () => {
  const element = previouslyFocusedElement
  previouslyFocusedElement = null

  if (element?.isConnected) element.focus({ preventScroll: true })
}

const openSheet = async () => {
  if (state.value === 'opening' || state.value === 'open') return

  clearTimeout(animationTimer)
  state.value = 'opening'

  await nextTick()
  if (state.value !== 'opening') return

  measureNaturalHeight()
  updateKeyboardInset()
  snapToSmallest()
  focusSheet()

  state.value = 'open'
  scheduleAfterAnimation(props.animationDuration, () => emit('open'))
}

const closeSheet = () => {
  if (state.value === 'closed' || state.value === 'closing') return

  state.value = 'closing'
  isDragging.value = false
  restoreFocus()

  scheduleAfterAnimation(exitDuration.value, () => {
    if (state.value !== 'closing') return

    state.value = 'closed'
    keyboardInset.value = 0
    resetDrag()
    emit('close')
  })
}

const dismissSheet = () => {
  if (props.preventClose) return

  isVisibleModel.value = false
  closeSheet()
}

const onBackdropClick = () => {
  if (props.canBackdropClose) dismissSheet()
}

const onSheetTabKeydown = (event: KeyboardEvent) => handleTrapFocus(event, sheetRef.value)

const onSheetEscapeKeydown = () => {
  if (state.value === 'open') dismissSheet()
}

useEventListener('resize', updateKeyboardInset)
useEventListener(() => defaultWindow?.visualViewport, ['resize', 'scroll'], updateKeyboardInset)

watch(isVisibleModel, (isVisible) => (isVisible ? openSheet() : closeSheet()))

onMounted(() => {
  if (isVisibleModel.value) openSheet()
})

onBeforeUnmount(() => clearTimeout(animationTimer))

defineExpose({
  open: () => {
    isVisibleModel.value = true
    openSheet()
  },
  close: () => {
    isVisibleModel.value = false
    closeSheet()
  },
})
</script>

<style lang="scss">
$sheet-border-radius: var(--cp-radius-xl);
$sheet-divider-color: rgba(46, 59, 66, 12.5%);

.cpBottomSheet {
  &,
  &__backdrop {
    --cp-bottomsheet-exit: cubic-bezier(0.4, 0, 1, 1);
  }

  position: fixed;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  overflow: hidden;
  width: 100%;
  max-width: fn.px-to-rem(640);
  max-height: var(--cp-bottomsheet-max-height, calc(100% - 32px - env(safe-area-inset-top, 0px)));
  box-sizing: border-box;
  flex-direction: column;
  background-color: var(--cp-background-primary);
  border-top-left-radius: $sheet-border-radius;
  border-top-right-radius: $sheet-border-radius;
  box-shadow: 0 fn.px-to-rem(40) 0 var(--cp-background-primary);
  margin-inline: auto;
  pointer-events: all;
  will-change: height, transform;

  &__backdrop {
    position: fixed;
    background-color: var(--cp-background-overlay);
    inset: 0;
    pointer-events: auto;
    user-select: none;
    will-change: opacity;
  }

  &__header,
  &__footer {
    flex-shrink: 0;
    touch-action: none;
    user-select: none;
  }

  &__header {
    padding: var(--cp-spacing-2xl) var(--cp-spacing-xl) var(--cp-spacing-xl) var(--cp-spacing-xl);
    border-top-left-radius: $sheet-border-radius;
    border-top-right-radius: $sheet-border-radius;
    box-shadow: 0 1px 0 $sheet-divider-color;

    &:empty {
      box-shadow: none;
    }

    &::before {
      position: absolute;
      top: fn.px-to-rem(8);
      left: 50%;
      display: block;
      width: fn.px-to-rem(36);
      height: fn.px-to-rem(4);
      border-radius: var(--cp-radius-xs);
      background-color: rgba(0, 0, 0, 28%);
      content: '';
      transform: translate3d(-50%, 0, 0);
    }
  }

  &__scroll {
    flex-grow: 1;
    -webkit-overflow-scrolling: touch;
    overflow-y: auto;
    touch-action: pan-y;
  }

  &__content {
    @extend %u-layout-box-padding;

    user-select: none;
  }

  &__footer {
    flex-grow: 0;
    padding: var(--cp-spacing-xl);
    padding-bottom: var(--cp-bottomsheet-footer-padding-bottom, calc(16px + env(safe-area-inset-bottom, 0px)));
    box-shadow: 0 -1px 0 $sheet-divider-color;

    &:empty,
    &:has(div:empty) {
      display: none;
    }
  }
}
</style>
