<template>
  <div
    ref="rootEl"
    class="mini-stepper drag-mode"
    :class="[extraClass, { dragging }]"
    :aria-label="ariaLabel"
    role="spinbutton"
    :aria-valuemin="minValue"
    :aria-valuemax="maxValue"
    :aria-valuenow="isPrefix(model) ? undefined : model"
    :aria-valuetext="display"
    tabindex="0"
    @pointerdown="onPointerDown"
    @keydown="onKey"
    @wheel.prevent="onWheel"
  >
    <span class="value" :title="String(model)">{{ display }}</span>
  </div>
</template>

<script setup>
import { computed, ref } from 'vue'
const model = defineModel({ default: 0 })
const props = defineProps({
  min: { type: Number, default: 0 },
  max: { type: Number, default: 999 },
  step: { type: Number, default: 1 },
  prefixValues: {
    type: Array,
    default: () => [],
    validator: (arr) =>
      arr.every((item) => typeof item === 'object' && 'value' in item && 'display' in item),
  },
  numberPrefix: { type: String, default: '' },
  numberPostfix: { type: String, default: '' },
  clamp: { type: Boolean, default: true },
  pixelsPerStep: { type: Number, default: 16 },
  axis: { type: String, default: 'x' },
  ariaLabel: { type: String, default: '' },
  extraClass: { type: String, default: '' },
})
const minValue = computed(() => props.min)
const maxValue = computed(() => props.max)
const isPrefix = (value) => props.prefixValues.some((p) => p.value === value)
const getPrefixDisplay = (value) =>
  props.prefixValues.find((p) => p.value === value)?.display ?? null
const allValidValues = computed(() => {
  const prefixVals = props.prefixValues.map((p) => p.value)
  const numericVals = []
  for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i)
  return [...prefixVals, ...numericVals]
})
const display = computed(() => {
  const prefixDisplay = getPrefixDisplay(model.value)
  if (prefixDisplay !== null) return prefixDisplay
  return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}`
})
const dragging = ref(false)
const rootEl = ref(null)
let startX = 0
let startY = 0
let accumX = 0
let lastClientX = 0
const pointerLocked = ref(false)
function updatePointerLocked() {
  pointerLocked.value =
    typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
  if (pointerLocked.value) {
    accumX = 0
    startX = 0
  }
}
function addPointerLockListeners() {
  if (typeof document === 'undefined') return
  document.addEventListener('pointerlockchange', updatePointerLocked)
  document.addEventListener('pointerlockerror', updatePointerLocked)
}
function removePointerLockListeners() {
  if (typeof document === 'undefined') return
  document.removeEventListener('pointerlockchange', updatePointerLocked)
  document.removeEventListener('pointerlockerror', updatePointerLocked)
}
function onPointerDown(e) {
  e.preventDefault()
  startX = e.clientX
  startY = e.clientY
  lastClientX = e.clientX
  accumX = 0
  dragging.value = true
  try {
    e.currentTarget.setPointerCapture?.(e.pointerId)
  } catch {}
  if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) {
    addPointerLockListeners()
    try {
      rootEl.value.requestPointerLock()
    } catch {}
  }
  document.addEventListener('pointermove', onPointerMove)
  document.addEventListener('pointerup', onPointerUp, { once: true })
  document.addEventListener('pointercancel', onPointerCancel, { once: true })
}
function onPointerMove(e) {
  if (!dragging.value) return
  if (e.pointerType === 'touch') e.preventDefault()
  let dx = pointerLocked.value ? e.movementX || 0 : e.clientX - lastClientX
  if (!pointerLocked.value) lastClientX = e.clientX
  if (!dx) return
  accumX += dx
  const stepSize = props.pixelsPerStep || 1
  let steps = Math.trunc(accumX / stepSize)
  if (steps === 0) return
  const applySteps = (count) => {
    if (!count) return
    let direction = count > 0 ? 1 : -1
    let remaining = Math.abs(count)
    let curVal = model.value
    const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
    let idx = allValidValues.value.indexOf(curVal)
    if (idx === -1) {
      if (!isNumeric) {
        curVal = props.prefixValues.length ? props.prefixValues[0].value : props.min
      } else {
        if (direction > 0) curVal = props.min
        else
          curVal = props.prefixValues.length
            ? props.prefixValues[props.prefixValues.length - 1].value
            : props.min
      }
      remaining--
    }
    while (remaining > 0) {
      idx = allValidValues.value.indexOf(curVal)
      if (idx === -1) break
      let targetIdx = idx + direction
      if (props.clamp) targetIdx = Math.max(0, Math.min(targetIdx, allValidValues.value.length - 1))
      if (targetIdx < 0 || targetIdx >= allValidValues.value.length || targetIdx === idx) break
      curVal = allValidValues.value[targetIdx]
      remaining--
    }
    model.value = curVal
  }
  applySteps(steps)
  accumX -= steps * stepSize
}
function endDragListeners() {
  document.removeEventListener('pointermove', onPointerMove)
  if (pointerLocked.value && document.exitPointerLock) {
    try {
      document.exitPointerLock()
    } catch {}
  }
  removePointerLockListeners()
}
function onPointerUp() {
  dragging.value = false
  endDragListeners()
}
function onPointerCancel() {
  dragging.value = false
  endDragListeners()
}
function onKey(e) {
  const key = e.key
  let handled = false
  let newValue = null
  const currentIndex = allValidValues.value.indexOf(model.value)
  switch (key) {
    case 'ArrowRight':
    case 'ArrowUp':
      if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1)
        newValue = allValidValues.value[currentIndex + 1]
      else if (currentIndex === -1) {
        const curVal = model.value
        const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
        if (!isNumeric && props.prefixValues.length) newValue = props.prefixValues[0].value
        else newValue = props.min
      }
      handled = true
      break
    case 'ArrowLeft':
    case 'ArrowDown':
      if (currentIndex !== -1 && currentIndex > 0) newValue = allValidValues.value[currentIndex - 1]
      else if (currentIndex === -1)
        newValue = props.prefixValues.length
          ? props.prefixValues[props.prefixValues.length - 1].value
          : props.min
      handled = true
      break
    case 'PageUp':
      if (currentIndex !== -1)
        newValue =
          allValidValues.value[Math.min(currentIndex + 10, allValidValues.value.length - 1)]
      else newValue = model.value + props.step * 10
      handled = true
      break
    case 'PageDown':
      if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)]
      else newValue = model.value - props.step * 10
      handled = true
      break
    case 'Home':
      newValue = allValidValues.value[0] || props.min
      handled = true
      break
    case 'End':
      newValue = allValidValues.value[allValidValues.value.length - 1] || props.max
      handled = true
      break
  }
  if (newValue !== null) model.value = newValue
  if (handled) {
    e.preventDefault()
    e.stopPropagation()
  }
}
function onWheel(e) {
  const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0
  if (direction === 0) return
  const idx = allValidValues.value.indexOf(model.value)
  if (idx !== -1) {
    const nextIdx = idx + direction
    if (nextIdx >= 0 && nextIdx < allValidValues.value.length)
      model.value = allValidValues.value[nextIdx]
  } else {
    const curVal = model.value
    const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
    if (!isNumeric)
      model.value = props.prefixValues.length ? props.prefixValues[0].value : props.min
    else if (direction > 0) model.value = props.min
    else
      model.value = props.prefixValues.length
        ? props.prefixValues[props.prefixValues.length - 1].value
        : props.min
  }
}
</script>

<style scoped>
.mini-stepper.drag-mode {
  cursor: ew-resize;
  user-select: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.25rem;
  background: none;
  font-variant-numeric: tabular-nums;
  touch-action: none;
}
.mini-stepper.drag-mode:focus-visible {
  box-shadow: 0 0 0 2px var(--input-focus, #2563eb);
  outline: none;
}
.mini-stepper.drag-mode .value {
  font-weight: 600;
  min-width: 1.6rem;
  text-align: center;
  pointer-events: none;
}
.mini-stepper.drag-mode.dragging {
  cursor: grabbing;
}
</style>
