<template>
  <div
    class="picker"
    :style="{ perspective: `${perspective}px` }"
    @mousedown="(e) => touchstartForMouse(e)"
    @mouseup="touchend"
    @touchstart="(e) => touchstartForTouch(e)"
    @touchend="touchend"
    @wheel="debounced"
    @keyup.up="doManualSelect(selectedIndex, selectedIndex - 1)"
    @keyup.down="doManualSelect(selectedIndex, selectedIndex + 1)"
    tabindex="0"
    :slotKey="_slotKey"
  >
    <div
      v-if="arrows"
      class="picker_arrow top"
      @click="doManualSelect(selectedIndex, selectedIndex - 1)"
    >
      <slot name="arrow-top" />
    </div>

    <div class="picker_wrapper">
      <ul class="picker_options" :style="listStyle">
        <li
          v-for="(item, i) in visibleOptions"
          :key="item.value"
          class="picker_option"
          :style="{
            'top': `${itemHeight * -0.5}px`,
            'height': `${itemHeight}px`,
            'line-height': `${itemHeight}px`,
            'transform': `rotateX(${0 - rotationAngle * i}deg) translate3d(0, 0, ${radius}px)`,
            'visibility': item.visibility ? 'visible' : 'hidden',
          }"
          :data-index="i"
        >
          <slot
            name="before-option"
            v-if="props.addClassConditions.includes(String(item.value))"
          ></slot>
          <slot name="option" v-bind="item">
            {{ item.label }}
          </slot>
          <slot
            name="after-option"
            v-if="props.addClassConditions.includes(String(item.value))"
          ></slot>
        </li>
      </ul>

      <div
        class="picker_chosen"
        :style="{
          'height': `${itemHeight}px`,
          'line-height': `${itemHeight}px`,
        }"
      >
        <ul class="picker_chosen_list" :style="highlightListStyle">
          <li
            v-for="(item, i) in visibleOptions"
            :key="i"
            class="picker_chosen_item"
            :style="`height: ${itemHeight}px`"
          >
            <slot
              name="before-option"
              v-if="props.addClassConditions.includes(String(item.value))"
            ></slot>
            <slot name="option" v-bind="item">
              {{ item.label }}
            </slot>
            <slot
              name="after-option"
              v-if="props.addClassConditions.includes(String(item.value))"
            ></slot>
          </li>
        </ul>
      </div>
    </div>

    <div
      v-if="arrows"
      class="picker_arrow bottom"
      @click="doManualSelect(selectedIndex, selectedIndex + 1)"
    >
      <slot name="arrow-bottom" />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { PropType, ref, computed, onMounted, watch, toRef } from 'vue'
import {
  calculateVelocity,
  easing,
  getVisibleOptions,
  isTouchEvent,
  MouseOrTouch,
  PickerValue,
  TouchDataType,
  wheelDebounce,
  MouseEventFunc,
  TouchEventFunc,
} from './utils'

const props = defineProps({
  /** Pickerに表示するオプションの配列。形式は { value: string, label: string } です */
  options: {
    type: Array as PropType<PickerValue[]>,
    default: () => [],
  },
  /**
   * text-alignの指定
   */
  align: {
    type: String as PropType<'left' | 'center' | 'right'>,
    default: 'center',
  },

  selectedBorderRadius: {
    type: Array as PropType<string[]>,
    default: ['100px', '100px', '100px', '100px'],
  },

  /** Pickerのデフォルト値。初期値としても、v-modelとしても使用します */
  modelValue: {
    type: Object as PropType<PickerValue | null>,
    default: null,
  },

  /** リスト内の各アイテム間の距離 */
  radius: {
    type: Number,
    default: 100,
  },

  /** Pickerが視聴者から「どれくらい遠くにあるか」を定義します。視覚的にPickerの丸みを変更します。 */
  perspective: {
    type: Number,
    default: 200,
  },

  /** リスト内の各アイテムの高さ。それらの位置を適切に計算するために必要です */
  itemHeight: {
    type: Number,
    default: 40,
  },

  /**
   * Pickerがタッチにどれぐらい敏感か。大きな値を設定すると、タッチを動かした後のPickerの加速が増します。
   * 範囲は0から10です。
   */
  sensitivity: {
    type: Number,
    default: 8,
    validator: function (value: number) {
      return value > 0 && value <= 10
    },
  },
  /**
   * スロットのキーを定義します。これは、複数のPickerを同じページに配置するときに、それらを区別するために使用されます。
   */
  slotKey: {
    type: String,
    default: () => {
      let l = 8

      // 生成する文字列に含める文字セット
      let c = 'abcdefghijklmnopqrstuvwxyz0123456789'

      let cl = c.length
      let r = ''
      for (let i = 0; i < l; i++) {
        r += c[Math.floor(Math.random() * cl)]
      }
      return r
    },
  },
  /**
   * singleがfalseの場合、複数のPickerを同じページに配置するときに、それらを区別するために使用されます。
   */
  single: {
    type: Boolean,
    default: true,
  },

  /**
   * Pickerのリング上に表示されるオプションの数。
   * 最大で、指定された数プラスリングの中央に選択されたオプションを表示します。
   * 最良の体験のためには、2の倍数である必要があります。
   */
  visibleOptionsAmount: {
    type: Number,
    default: 8,
    validator: function (value: number) {
      return value % 2 === 0
    },
  },

  /** 'infinite'が渡されると、Pickerを永遠にスクロールできます。すべての値が繰り返されます */
  type: {
    type: String as PropType<'normal' | 'infinite'>,
    default: 'normal',
    validator: function (value: 'normal' | 'infinite') {
      return ['normal', 'infinite'].includes(value)
    },
  },
  /**
   * Pickerのスタイルを定義します
   */
  style: {
    type: Object as PropType<CSSStyleDeclaration>,
    default: () => ({}),
  },
  /** トップまたはボトムにスクロールするための矢印を表示する必要があるかどうか */
  arrows: {
    type: Boolean,
    default: false,
  },
  /** 条件に合う選択肢のCSSを触れるようにする */
  addClassConditions: {
    type: Array as PropType<string[]>,
    default: () => [],
  },
})
const selected = ref<number | string>(
  props.modelValue
    ? props.options.findIndex((option) => option.value === props?.modelValue?.value)
    : 0
)
/**
 * 選択されたオプションのインデックス
 */
const selectedIndex = ref(
  typeof selected.value == 'number' ? (selected.value > -1 ? selected.value : 0) : 0
)
/**
 * 選択されたオプションを表示している部分のborder-radius
 */
const selectedBorderRadius = props.selectedBorderRadius.join(' ')

/**
 * ランダムな文字列を生成
 */
function createRandomString() {
  let l = 8

  // 生成する文字列に含める文字セット
  let c = 'abcdefghijklmnopqrstuvwxyz0123456789'

  let cl = c.length
  let r = ''
  for (let i = 0; i < l; i++) {
    r += c[Math.floor(Math.random() * cl)]
  }
  return r
}

/**
 * スロットの識別keyを生成
 */
const _slotKey = createRandomString()

/**
 * 選択されたオプションの値を変更したときに発火するイベント
 */
const emit = defineEmits(['update:modelValue', 'earlyUpdate'])
watch(selectedIndex, () => {
  if (!props.options[selectedIndex.value]) return
  emit('update:modelValue', props.options[selectedIndex.value])
})

const options = getVisibleOptions(props.options, selectedIndex.value)

// const circular = initCircularBuffer(this.options, selectedIndex);

const listStyle = ref({
  transform: `transform: translate3d(0, 0, ${-1 * props.radius}px) rotateX(0deg)`,
})
const highlightListStyle = ref({
  transform: `translate3d(0, 0, 0)`,
})

const source = ref(options)
const visibleOptions = ref(options.options)
const visibleOptionsIndex = ref(options.start)

const prevSelectedIndex = ref(selectedIndex.value)
const maxAcceleration = ref(10)
const requestAnimationId = ref(0)
const wheeling = ref(false)
const touchData = ref({
  startY: 0,
  yArr: [],
  touchScroll: 0,
  delta: 0,
} as TouchDataType)

const debounced = computed<MouseEventFunc>(() => {
  return wheelDebounce(wheel, endWheel, 100)
})
const rotationAngle = computed<number>(() => {
  return 185 / props.visibleOptionsAmount
})

onMounted(() => {
  document.addEventListener('mousedown', touchstart as EventListener)
  document.addEventListener('mouseup', touchend as EventListener)

  // Move to the initial value
  moveTo(selectedIndex.value)
})

const modelValue = toRef(props, 'modelValue')

watch(
  modelValue,
  (val) => {
    if (val?.value === selected.value) return
    if (!val?.value) return
    const newIndex = val?.value
      ? source.value.options.findIndex((option) => option.value === val?.value)
      : 0
    selected.value = val?.value
    if (selectedIndex.value !== newIndex) selectedIndex.value = newIndex
    animateToScroll(selectedIndex.value, newIndex)
  },
  { deep: true }
)
function changeSelectedIndex(newIndex: number) {
  if (Math.abs(newIndex - prevSelectedIndex.value) > props.visibleOptionsAmount / 2) {
    const options = getVisibleOptions(source.value.options, newIndex)
    visibleOptions.value = options.options
    visibleOptionsIndex.value = options.start
    prevSelectedIndex.value = selectedIndex.value
  }
  selectedIndex.value = newIndex
}

function startRoll(yPos: number) {
  touchData.value.startY = yPos
  touchData.value.yArr = [[yPos, new Date().getTime()]]
  touchData.value.touchScroll = selectedIndex.value
  stop()
}

function doRoll(yPos: number) {
  touchData.value.yArr.push([yPos, new Date().getTime()])

  // Calculate new selected index by the item height and the scrolled amount
  const scrollAdd = (touchData.value.startY - yPos) / props.itemHeight
  let moveToScroll = scrollAdd + selectedIndex.value

  if (props.type === 'normal') {
    if (moveToScroll < 0) {
      moveToScroll = wheeling.value ? Math.max(-1.5, moveToScroll * 0.05) : moveToScroll * 0.3
    } else if (moveToScroll > source.value.options.length) {
      const toPos = moveToScroll - source.value.options.length
      moveToScroll =
        source.value.options.length + (wheeling ? Math.min(1.5, toPos * 0.05) : toPos * 0.3)
    }
  } else {
    moveToScroll = normalizeScroll(moveToScroll)
  }

  touchData.value.touchScroll = moveTo(moveToScroll)
}

/**
 * ロール後の処理
 */
function endRoll() {
  const velocity = calculateVelocity(touchData.value.yArr, props.itemHeight)
  changeSelectedIndex(touchData.value.touchScroll)

  animateMoveByVelocity(velocity)
}

function touchstartForMouse(e: MouseEvent) {
  if (e.target) e.target.addEventListener('touchmove', touchmove as EventListener)
  document.addEventListener('mousemove', touchmove as EventListener)
  const eventY = e.clientY
  touchData.value.delta = 0
  startRoll(eventY)
}
function touchstart(e: MouseOrTouch) {
  if (e.target) e.target.addEventListener('touchmove', touchmove as EventListener)
  document.addEventListener('mousemove', touchmove as EventListener)

  const eventY = isTouchEvent(e) ? e.touches[0].clientY : e.clientY
  touchData.value.delta = 0
  startRoll(eventY)
}
function touchstartForTouch(e: TouchEvent) {
  if (e.target) e.target.addEventListener('touchmove', touchmove as EventListener)
  document.addEventListener('mousemove', touchmove as EventListener)

  const eventY = e.touches[0].clientY
  touchData.value.delta = 0
  startRoll(eventY)
}

function getElPickerNodeInfo(ev: any) {
  if (!ev?.parentElement) throw new Error('これ以上parentElementが見当たりません')
  if (ev?.parentElement?.classList?.contains('picker')) return ev?.parentElement
  return getElPickerNodeInfo(ev?.parentElement)
}

function getSlotKeyFromEl(ev: any) {
  const targetEl = getElPickerNodeInfo(ev)
  return targetEl?.attributes.slotKey.value
}

function touchmove(e: MouseOrTouch) {
  const ev = e.target as any
  if (!ev.className.includes('picker')) return

  if (ev) {
    const slotKey = getSlotKeyFromEl(ev)
    if (slotKey !== _slotKey) return
  }
  e.preventDefault()
  const eventY = isTouchEvent(e) ? e.touches[0].clientY : e.clientY
  doRoll(eventY)
}
function wheel(e: WheelEvent) {
  e.preventDefault()
  if (!wheeling.value) {
    wheeling.value = true
    const startPos = e.clientY
    touchData.value.delta = e.deltaY
    startRoll(startPos)
  } else {
    touchData.value.delta -= e.deltaY
    doRoll(touchData.value.startY + touchData.value.delta)
  }
}

function endWheel() {
  endRoll()
  wheeling.value = false
}

function touchend(e: MouseEvent | TouchEvent) {
  if (e.target) e.target.removeEventListener('touchmove', touchmove as EventListener)
  document.removeEventListener('mousemove', touchmove as EventListener)
  endRoll()
}

function normalizeScroll(scroll: number): number {
  let normalizedScroll = scroll

  while (normalizedScroll < 0) {
    normalizedScroll += source.value.options.length
  }
  normalizedScroll = normalizedScroll % source.value.options.length

  return normalizedScroll
}

const preIndex = ref<number | null>(null)

/** オプションの配列内の特定のインデックスに直ちに移動する*/
function moveTo(newIndex: number): number {
  if (props.type === 'infinite') {
    changeSelectedIndex(normalizeScroll(newIndex))
  }

  if (!source.value.options.length) {
    return 0
  }

  listStyle.value.transform = `translate3d(0, 0, ${-1 * props.radius}px) rotateX(${
    rotationAngle.value * (newIndex - visibleOptionsIndex.value)
  }deg)`
  highlightListStyle.value.transform = `translate3d(0, ${
    -(newIndex - visibleOptionsIndex.value) * props.itemHeight
  }px, 0)`

  visibleOptions.value = visibleOptions.value.map((item, index) => {
    item.visibility =
      Math.abs(visibleOptionsIndex.value + index - newIndex) <= props.visibleOptionsAmount / 2
    return item
  })

  if (
    newIndex - Math.round(newIndex) < 0.9 &&
    preIndex.value !== null &&
    Math.abs(preIndex.value - Math.round(newIndex)) < 1
  ) {
    emit('earlyUpdate', props.options[Math.round(newIndex)])
  }
  preIndex.value = newIndex
  return newIndex
}

const textAlign = props.align

/**
 * At initial speed scroll (?) initV
 * @param {init} initV， initV Will be reset
 * スクロールの加速度に基づいて、整数の位置まで確実にスクロールすることができます（スクロールターゲットが選択された値を正確に受け取ることが保証されています）
 */
function animateMoveByVelocity(initV: number) {
  let initScroll
  let finalScroll
  let totalScrollLen

  let a // 加速度
  let t // 時間

  if (props.type === 'normal') {
    if (selectedIndex.value < 0 || selectedIndex.value > source.value.options.length - 1) {
      a = maxAcceleration.value
      initScroll = selectedIndex.value
      finalScroll = selectedIndex.value < 0 ? 0 : source.value.options.length - 1
      totalScrollLen = initScroll - finalScroll
      t = Math.sqrt(Math.abs(totalScrollLen / a))

      animateToScroll(initScroll, finalScroll, t)?.then(() => {
        selectByScroll(selectedIndex.value)
      })
    } else {
      initScroll = selectedIndex.value

      a = initV > 0 ? -props.sensitivity : props.sensitivity // Is acceleration or deceleration
      t = Math.abs(initV / a) // Speed reduced to 0 takes time
      totalScrollLen = initV * t + (a * t * t) / 2 // Total rolling length
      finalScroll = Math.round(selectedIndex.value + totalScrollLen) // Round to ensure accuracy and finally scroll to an integer
      finalScroll =
        finalScroll < 0
          ? 0
          : finalScroll > source.value.options.length - 1
          ? source.value.options.length - 1
          : finalScroll

      totalScrollLen = finalScroll - initScroll
      t = Math.sqrt(Math.abs(totalScrollLen / a))
      animateToScroll(selectedIndex.value, finalScroll, t)?.then(() =>
        selectByScroll(selectedIndex.value)
      )
    }
  } else {
    a = initV > 0 ? -props.sensitivity : props.sensitivity // Deceleration/Acceleration
    t = Math.abs(initV / a) // Speed reduced to 0 takes time
    totalScrollLen = initV * t + (a * t * t) / 2 // Total rolling length
    finalScroll = Math.round(selectedIndex.value + totalScrollLen) // Round to ensure accuracy and finally scroll as an integer
    animateToScroll(selectedIndex.value, finalScroll, t)?.then(() =>
      selectByScroll(selectedIndex.value)
    )
  }
}

function animateToScroll(initScroll: number, finalScroll: number, time: number | null = null) {
  if (finalScroll > source.value.options.length - 1 || finalScroll < 0) {
    return
  }

  if (time === null) {
    time = 0.125 * Math.abs(finalScroll - initScroll)
  }

  if (initScroll === finalScroll || time === 0) {
    moveTo(initScroll)

    return
  }

  const start = new Date().getTime() / 1000
  let pass = 0
  const totalScrollLen = finalScroll - initScroll

  return new Promise<void>((resolve) => {
    const tick = () => {
      pass = new Date().getTime() / 1000 - start

      if (pass < (time as number)) {
        changeSelectedIndex(moveTo(initScroll + easing(pass / (time as number)) * totalScrollLen))
        requestAnimationId.value = requestAnimationFrame(tick)
      } else {
        resolve()
        stop()
        changeSelectedIndex(moveTo(initScroll + totalScrollLen))
      }
    }
    tick()
  })
}

function stop() {
  cancelAnimationFrame(requestAnimationId.value)
}

/**
 * スクロールで値を選択するときの処理
 * @param scroll　インデックスの数
 */
function selectByScroll(scroll: number) {
  scroll = normalizeScroll(scroll) | 0
  if (scroll > source.value.options.length - 1) {
    scroll = source.value.options.length - 1
    moveTo(scroll)
  }

  moveTo(scroll)
  selectedIndex.value = scroll
  selected.value = source[scroll]
}
function doManualSelect(initScroll: number, finalScroll: number, time: number | null = null) {
  animateToScroll(initScroll, finalScroll, time)?.then(() => selectByScroll(selectedIndex.value))
}

const optionPadding = 30
const singleWidth = `calc(100% - ${optionPadding}px)`
const singlePadding = `0 ${optionPadding / 2}px`
</script>

<style scoped>
.picker {
  position: relative;
  overflow-y: auto;
  overscroll-behavior: none;
  scrollbar-width: none;
  -ms-overflow-style: none;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
  height: 300px;
  margin: 16px auto 0;
  background-color: #fff;
  border-radius: 4px;
  text-align: v-bind(textAlign);
  font-size: 24px;
  color: #bbbcc9;
}

.picker * {
  box-sizing: initial;
}

.picker::-webkit-scrollbar {
  display: none;
}

.picker:focus {
  outline: none;
}

.picker_wrapper {
  position: relative;
  height: 100%;
  width: 100%;
  flex-grow: 1;
  overflow: hidden;
}

.picker_options {
  position: absolute;
  top: 50%;
  left: 0;
  width: v-bind(singleWidth);
  height: 0;
  transform-style: preserve-3d;
  margin: 0 auto;
  padding: v-bind(singlePadding);
  display: block;
  transform: translateZ(-150px) rotateX(0deg);
  -webkit-font-smoothing: subpixel-antialiased;
  list-style: none;
}

.picker_option {
  position: absolute;
  top: 0;
  left: 0;
  width: v-bind(singleWidth);
  padding: v-bind(singlePadding);

  height: 50px;
  user-select: none;
  -webkit-font-smoothing: subpixel-antialiased;
}

.picker_chosen {
  position: absolute;
  top: 50%;
  width: v-bind(singleWidth);

  transform: translate(0, -50%);
  overflow: hidden;
  background-color: #f8f8fa;
  border-radius: v-bind(selectedBorderRadius);
  color: #121212;
  font-size: 24px;
  padding: v-bind(singlePadding);
}

.picker_chosen_list {
  position: absolute;
  width: v-bind(singleWidth);

  margin: 0;
  padding: 0;
  list-style: none;
}

.picker_chosen_item,
.picker_arrow {
  user-select: none;
  position: relative;
}

.picker_arrow {
  cursor: pointer;
}
</style>
