import type { MaybeRef, Ref } from 'vue'
import type { SwipeDirection } from '../utils'
import { useEventListener } from '@vueuse/core'
import { computed, onUnmounted, ref, toValue, watch } from 'vue'
import { getDisplacement, getElementTransform } from '../utils'

export interface SwipeProgressDetails {
  deltaX: number
  deltaY: number
  direction: SwipeDirection | undefined
}

export interface UseSwipeDismissOptions {
  enabled: MaybeRef<boolean>
  elementRef: Ref<HTMLElement | null | undefined>
  directions: MaybeRef<SwipeDirection[]>
  movementCssVars: { x: string, y: string }
  swipeThreshold?: number | ((opts: { element: HTMLElement, direction: SwipeDirection }) => number)
  ignoreScrollableAncestors?: boolean
  /**
   * When `false`, touch swipes may start on the interactive elements
   * (buttons, links, labels, fields) that mouse and pen swipes skip.
   * @default true
   */
  ignoreSelectorWhenTouch?: boolean
  canStart?: () => boolean
  /** Return `false` when the dismissal was rejected, so the element settles back. */
  onDismiss?: () => boolean | void
  onProgress?: (progress: number, details?: SwipeProgressDetails) => void
  onCancel?: () => void
  onSwipeStart?: () => void
  /**
   * Fired on release with the measured velocity vector. Return `true` to signal
   * that the callback fully owns the open/close + transform outcome (e.g. the
   * snap-point release math), so `finishSwipe` skips its own dismiss-vs-cancel
   * decision (no `data-swipe-dismissed`, no `onDismiss`/`onCancel`).
   */
  onRelease?: (velocity: { x: number, y: number }) => boolean | void
  onSwipingChange?: (swiping: boolean) => void
}

const DEFAULT_SWIPE_THRESHOLD = 40
const REVERSE_CANCEL_THRESHOLD = 10
const MIN_DRAG_THRESHOLD = 1
// Travel an axis must cover from the touch origin to claim the gesture, and the
// margin the cross axis must beat the drawer axis by to win it.
const AXIS_LOCK_SLOP = 6
const AXIS_LOCK_BIAS = 2
const MIN_RELEASE_VELOCITY_DURATION_MS = 16
const MAX_RELEASE_VELOCITY_AGE_MS = 80
const DEFAULT_IGNORE_SELECTOR = 'button,a,input,select,textarea,label,[role="button"]'
// Opts an element out of swipe dismissal for every input type. Ported from
// Base UI's `data-base-ui-swipe-ignore`.
const SWIPE_IGNORE_SELECTOR = '[data-reka-swipe-ignore]'

function isRangeInput(target: EventTarget): boolean {
  return (target as Element).tagName === 'INPUT' && (target as HTMLInputElement).type === 'range'
}

function findScrollableAncestor(
  el: Element | null,
  axis: 'vertical' | 'horizontal',
): HTMLElement | null {
  if (!el || el === document.body)
    return null
  const style = window.getComputedStyle(el as HTMLElement)
  const overflow = axis === 'vertical'
    ? style.overflowY
    : style.overflowX
  if (
    (overflow === 'auto' || overflow === 'scroll')
    && (axis === 'vertical'
      ? (el as HTMLElement).scrollHeight > (el as HTMLElement).clientHeight
      : (el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth)
  ) {
    return el as HTMLElement
  }
  return findScrollableAncestor(el.parentElement, axis)
}

/**
 * Returns whether the user can start a swipe dismiss from a scrollable element
 * based on the scroll position and drag direction.
 * Ported from BaseUI `useSwipeDismiss.ts:canSwipeFromScrollEdgeOnPendingMove`.
 *
 * Returns:
 *   true  → swipe is allowed (scroll is at the relevant edge)
 *   false → swipe is blocked (user is scrolling, not dismissing)
 *   null  → the drag is not along a recognized axis; caller should fall through
 */
function canSwipeFromScrollEdge(
  scrollTarget: HTMLElement,
  deltaX: number,
  deltaY: number,
  flags: { hasVertical: boolean, hasHorizontal: boolean, allowUp: boolean, allowDown: boolean, allowLeft: boolean, allowRight: boolean },
): boolean | null {
  const absDx = Math.abs(deltaX)
  const absDy = Math.abs(deltaY)
  const useVerticalAxis
    = flags.hasVertical && deltaY !== 0 && (!flags.hasHorizontal || absDy >= absDx)

  if (useVerticalAxis) {
    const maxScrollTop = Math.max(0, scrollTarget.scrollHeight - scrollTarget.clientHeight)
    const atTop = scrollTarget.scrollTop <= 0
    const atBottom = scrollTarget.scrollTop >= maxScrollTop
    const movingDown = deltaY > 0
    const movingUp = deltaY < 0
    // A 'down'-dismiss drawer starts dismiss on a downward pull from the top.
    // An 'up'-dismiss drawer starts dismiss on an upward pull from the bottom.
    const canSwipeDown = movingDown && atTop && flags.allowDown
    const canSwipeUp = movingUp && atBottom && flags.allowUp
    return canSwipeDown || canSwipeUp
  }

  const useHorizontalAxis
    = flags.hasHorizontal && deltaX !== 0 && (!flags.hasVertical || absDx > absDy)
  if (useHorizontalAxis) {
    const maxScrollLeft = Math.max(0, scrollTarget.scrollWidth - scrollTarget.clientWidth)
    const atLeft = scrollTarget.scrollLeft <= 0
    const atRight = scrollTarget.scrollLeft >= maxScrollLeft
    const movingRight = deltaX > 0
    const movingLeft = deltaX < 0
    const canSwipeRight = movingRight && atLeft && flags.allowRight
    const canSwipeLeft = movingLeft && atRight && flags.allowLeft
    return canSwipeRight || canSwipeLeft
  }
  return null
}

export function useSwipeDismiss(options: UseSwipeDismissOptions) {
  const {
    elementRef,
    directions,
    movementCssVars,
    swipeThreshold: swipeThresholdProp,
    canStart,
    ignoreSelectorWhenTouch = true,
    onDismiss,
    onProgress,
    onCancel,
    onSwipeStart,
    onRelease,
    onSwipingChange,
  } = options

  const hasVertical = computed(() => toValue(directions).includes('up') || toValue(directions).includes('down'))
  const hasHorizontal = computed(() => toValue(directions).includes('left') || toValue(directions).includes('right'))
  const allowUp = computed(() => toValue(directions).includes('up'))
  const allowDown = computed(() => toValue(directions).includes('down'))
  const allowLeft = computed(() => toValue(directions).includes('left'))
  const allowRight = computed(() => toValue(directions).includes('right'))

  // The axis the drawer dismisses along, or `null` when both axes are
  // dismissable and there is no cross axis to yield native scrolling to.
  const drawerAxis = computed<'vertical' | 'horizontal' | null>(() => {
    if (hasVertical.value === hasHorizontal.value)
      return null
    return hasVertical.value ? 'vertical' : 'horizontal'
  })

  const isSwiping = ref(false)
  const swipeDirection = ref<SwipeDirection | undefined>(undefined)
  const dragOffset = ref({ x: 0, y: 0 })

  // Internal state (not reactive -- use plain vars for perf)
  let dragStartPos = { x: 0, y: 0 }
  let intendedDirection: SwipeDirection | undefined
  let maxDisplacement = 0
  let cancelledSwipe = false
  let isFirstMove = false
  let pendingSwipe = false
  let pendingSwipeStartPos: { x: number, y: number } | null = null
  let swipeFromScrollable = false
  let scrollableAncestor: HTMLElement | null = null
  // Cross-axis arbitration state (touch only), reset per gesture.
  let crossAxisScrollable: HTMLElement | null = null
  let drawerAxisAttributed = false
  let preserveNativeCrossAxisScroll = false
  let elementSize = { width: 0, height: 0 }
  let swipeProgress = 0
  let lastDragSample: { x: number, y: number, time: number } | null = null
  let lastVelocity = { x: 0, y: 0 }
  let lockedAxis: 'horizontal' | 'vertical' | null = null
  let activePointerId: number | null = null
  let pointerStarted = false

  function getThreshold(el: HTMLElement, dir: SwipeDirection): number {
    if (typeof swipeThresholdProp === 'function')
      return Math.max(0, swipeThresholdProp({ element: el, direction: dir }))
    return typeof swipeThresholdProp === 'number' ? swipeThresholdProp : DEFAULT_SWIPE_THRESHOLD
  }

  function setSwiping(next: boolean) {
    if (isSwiping.value === next)
      return
    isSwiping.value = next
    onSwipingChange?.(next)
  }

  function recordSample(offset: { x: number, y: number }, time: number) {
    if (lastDragSample && time > lastDragSample.time) {
      const dt = Math.max(time - lastDragSample.time, MIN_RELEASE_VELOCITY_DURATION_MS)
      lastVelocity = {
        x: (offset.x - lastDragSample.x) / dt,
        y: (offset.y - lastDragSample.y) / dt,
      }
    }
    lastDragSample = { x: offset.x, y: offset.y, time }
  }

  function setCssVars(el: HTMLElement, x: number, y: number) {
    el.style.setProperty(movementCssVars.x, `${x}px`)
    el.style.setProperty(movementCssVars.y, `${y}px`)
  }

  function clearCssVars(el: HTMLElement) {
    el.style.setProperty(movementCssVars.x, '0px')
    el.style.setProperty(movementCssVars.y, '0px')
  }

  /**
   * Clears the per-gesture tracking state. Deliberately leaves the pointer
   * identity and the touch scroll-guard flags alone, so it is safe to call at
   * the start of a gesture as well as at the end of one.
   */
  function resetGestureState() {
    setSwiping(false)
    swipeDirection.value = undefined
    dragOffset.value = { x: 0, y: 0 }
    dragStartPos = { x: 0, y: 0 }
    intendedDirection = undefined
    maxDisplacement = 0
    cancelledSwipe = false
    isFirstMove = false
    pendingSwipe = false
    pendingSwipeStartPos = null
    elementSize = { width: 0, height: 0 }
    swipeProgress = 0
    lastDragSample = null
    lastVelocity = { x: 0, y: 0 }
    lockedAxis = null
  }

  function reset() {
    resetGestureState()
    swipeFromScrollable = false
    scrollableAncestor = null
    crossAxisScrollable = null
    drawerAxisAttributed = false
    preserveNativeCrossAxisScroll = false
    activePointerId = null
    pointerStarted = false
  }

  function startSwipe(el: HTMLElement, pos: { x: number, y: number }) {
    // Start from a clean slate (BaseUI parity: `startSwipeAtPosition` resets the
    // gesture refs on every press). A previous gesture whose end we never saw
    // would otherwise leak `isSwiping`, the intended direction and the velocity
    // samples into this one.
    resetGestureState()
    // Capture the element's current transform so we can account for it
    getElementTransform(el)
    dragStartPos = pos
    pendingSwipeStartPos = pos
    elementSize = { width: el.offsetWidth, height: el.offsetHeight }
    isFirstMove = true
    pendingSwipe = true
  }

  /**
   * Applies sqrt-damping to any axis moving in a disallowed direction.
   * Axes moving in an allowed direction pass through linearly.
   * Ported from BaseUI `useSwipeDismiss.ts:applyDirectionalDamping`.
   */
  function applyDirectionalDamping(deltaX: number, deltaY: number) {
    const exponent = (value: number) => (value >= 0 ? value ** 0.5 : -((Math.abs(value)) ** 0.5))
    const dampAxis = (delta: number, allowNegative: boolean, allowPositive: boolean) => {
      if (!allowNegative && delta < 0)
        return exponent(delta)
      if (!allowPositive && delta > 0)
        return exponent(delta)
      return delta
    }
    const newDx = hasHorizontal.value
      ? dampAxis(deltaX, allowLeft.value, allowRight.value)
      : exponent(deltaX)
    const newDy = hasVertical.value
      ? dampAxis(deltaY, allowUp.value, allowDown.value)
      : exponent(deltaY)
    return { x: newDx, y: newDy }
  }

  /**
   * Arbitrates a touchmove between the drawer swipe and a native scroll on the
   * cross axis. Returns `true` when the move must be left alone — the cross axis
   * already won the gesture, or neither axis has passed the slop yet.
   */
  function shouldYieldTouchMove(e: TouchEvent, pos: { x: number, y: number }): boolean {
    if (preserveNativeCrossAxisScroll)
      return true

    // Attribution happens once per gesture: the slop is measured from the touch
    // origin, which is never re-baselined, so re-arbitrating mid-drag would
    // freeze the drawer and drop `preventDefault()`.
    if (drawerAxisAttributed || !crossAxisScrollable || !drawerAxis.value)
      return false

    // The browser has already committed the gesture to a native scroll.
    if (!e.cancelable) {
      preserveNativeCrossAxisScroll = true
      return true
    }

    const isVerticalDrawerAxis = drawerAxis.value === 'vertical'
    const drawerAxisDelta = isVerticalDrawerAxis ? pos.y - dragStartPos.y : pos.x - dragStartPos.x
    const crossAxisDelta = isVerticalDrawerAxis ? pos.x - dragStartPos.x : pos.y - dragStartPos.y
    const absDrawerAxisDelta = Math.abs(drawerAxisDelta)
    const absCrossAxisDelta = Math.abs(crossAxisDelta)

    if (
      absCrossAxisDelta >= AXIS_LOCK_SLOP
      && absCrossAxisDelta > absDrawerAxisDelta + AXIS_LOCK_BIAS
    ) {
      preserveNativeCrossAxisScroll = true
      return true
    }

    if (absDrawerAxisDelta >= AXIS_LOCK_SLOP) {
      drawerAxisAttributed = true
      return false
    }

    // Unattributed. Leave the event alone: on iOS, `preventDefault()` on the
    // first cancelable touchmove cancels native scrolling for the whole gesture,
    // locking out a cross-axis scroll that only passes the slop on a later move.
    return true
  }

  function processMove(el: HTMLElement, pos: { x: number, y: number }, time: number) {
    const rawDx = pos.x - dragStartPos.x
    const rawDy = pos.y - dragStartPos.y

    // Determine direction lock on first move
    if (isFirstMove) {
      isFirstMove = false
      const absX = Math.abs(rawDx)
      const absY = Math.abs(rawDy)
      if (hasVertical.value && hasHorizontal.value) {
        lockedAxis = absX > absY ? 'horizontal' : 'vertical'
      }
      else if (hasVertical.value) {
        lockedAxis = 'vertical'
      }
      else {
        lockedAxis = 'horizontal'
      }
    }

    // Axis-locked deltas for direction detection
    const dx = lockedAxis === 'vertical' ? 0 : rawDx
    const dy = lockedAxis === 'horizontal' ? 0 : rawDy

    const dir: SwipeDirection | undefined = toValue(directions).find(d => getDisplacement(d, dx, dy) > 0)

    if (pendingSwipe && pendingSwipeStartPos) {
      // BaseUI parity (`useSwipeDismiss.ts:startSwipeAtPosition`): the drag is
      // tracked from the first movement whatever its direction — the gesture
      // does NOT need to point at a dismissable direction to begin. Movement
      // along a non-dismissable direction still reaches
      // `applyDirectionalDamping` below, which sqrt-damps it; that damped
      // offset is the elastic "pull" feedback you get when dragging a bottom
      // drawer upward. Bailing out on `!dir` instead would leave the drawer
      // completely frozen until the user drags the dismissable way.
      const pending = Math.max(Math.abs(dx), Math.abs(dy))
      if (pending < MIN_DRAG_THRESHOLD)
        return
      pendingSwipe = false
      setSwiping(true)
      onSwipeStart?.()
    }

    if (!isSwiping.value)
      return

    // The intended (dismissable) direction is adopted the first time the drag
    // moves in one, which may be many moves after the swipe started — e.g. pull
    // up, then push back down on a `down` drawer. Until then the gesture has no
    // dismiss candidate and only rubber-bands.
    if (!intendedDirection && dir) {
      intendedDirection = dir
      swipeDirection.value = dir
      maxDisplacement = getDisplacement(dir, dx, dy)
    }

    const currentDir = intendedDirection

    // Detect reversal (cancel swipe)
    if (currentDir && !cancelledSwipe) {
      const displacement = getDisplacement(currentDir, dx, dy)
      maxDisplacement = Math.max(maxDisplacement, displacement)
      if (
        maxDisplacement > DEFAULT_SWIPE_THRESHOLD / 2
        && maxDisplacement - displacement > REVERSE_CANCEL_THRESHOLD
      ) {
        cancelledSwipe = true
      }
    }

    // Apply directional damping to raw deltas (BaseUI parity).
    // Allowed-direction axes pass through linearly; disallowed directions
    // sqrt-damp. No manual single-axis sign reconstruction.
    const damped = applyDirectionalDamping(dx, dy)

    dragOffset.value = { x: damped.x, y: damped.y }
    setCssVars(el, damped.x, damped.y)
    recordSample({ x: damped.x, y: damped.y }, time)

    // Progress: 0 = closed/start, 1 = fully dismissed. Before a dismissable
    // direction is adopted, measure against the primary one — a pull away from
    // it yields a negative displacement, which clamps to 0 (BaseUI parity:
    // `progressDirection = primaryDirection ?? intendedSwipeDirection`).
    const currentEl = elementRef.value
    const progressDir = currentDir ?? toValue(directions)[0]
    if (currentEl && progressDir) {
      const dim = (progressDir === 'up' || progressDir === 'down')
        ? elementSize.height || currentEl.offsetHeight
        : elementSize.width || currentEl.offsetWidth
      const threshold = getThreshold(currentEl, progressDir)
      const p = Math.min(1, Math.max(0, getDisplacement(progressDir, dx, dy) / (dim + threshold)))
      if (p !== swipeProgress) {
        swipeProgress = p
        onProgress?.(p, { deltaX: damped.x, deltaY: damped.y, direction: currentDir })
      }
    }
  }

  function finishSwipe(el: HTMLElement) {
    if (!isSwiping.value) {
      reset()
      return
    }

    const displacement = getDisplacement(
      intendedDirection ?? toValue(directions)[0],
      dragOffset.value.x,
      dragOffset.value.y,
    )
    const threshold = getThreshold(el, intendedDirection ?? toValue(directions)[0])

    const now = performance.now()
    const velAge = lastDragSample ? now - lastDragSample.time : Infinity
    const velocity = velAge > MAX_RELEASE_VELOCITY_AGE_MS ? { x: 0, y: 0 } : lastVelocity

    // When onRelease returns true (snap-point release), it owns the open/close
    // and transform outcome — don't run the dismiss-vs-cancel branch, which
    // would otherwise set a stray `data-swipe-dismissed` on a drawer that
    // actually snapped to a point, or clear CSS vars onRelease already wrote.
    const releaseHandled = onRelease?.(velocity) === true
    let dismissed = false

    if (!releaseHandled) {
      const velInDirection = getDisplacement(
        intendedDirection ?? toValue(directions)[0],
        velocity.x,
        velocity.y,
      )
      const shouldDismiss = !cancelledSwipe
        && (displacement >= threshold || velInDirection > 0.3)

      if (shouldDismiss && onDismiss?.() !== false) {
        // BaseUI parity: on dismiss, keep the drag transform in place so the
        // close animation runs smoothly from the dragged position. Clearing the
        // CSS vars here would cause a one-frame snap-back to resting before the
        // closing transition begins (visible as a flicker).
        el.setAttribute('data-swipe-dismissed', '')
        dismissed = true
      }
      else {
        // On cancel, reset the drag transform so the drawer animates back to rest.
        clearCssVars(el)
        onCancel?.()
      }
    }

    // BaseUI parity (`settleInPlace`): a drawer that stays open settles back to
    // rest, so drop the published progress along with the drag transform.
    if (!dismissed)
      onProgress?.(0)

    reset()
  }

  // -- Pointer Events (mouse + pen) --

  function onPointerDown(e: PointerEvent) {
    if (!toValue(options.enabled))
      return
    if (e.pointerType === 'touch')
      return // handled by touch events
    if (e.button !== 0)
      return
    if (canStart && !canStart())
      return

    const target = e.target as HTMLElement
    if (target?.closest(`${DEFAULT_IGNORE_SELECTOR},${SWIPE_IGNORE_SELECTOR}`))
      return

    const el = elementRef.value
    if (!el)
      return

    startSwipe(el, { x: e.clientX, y: e.clientY })
    activePointerId = e.pointerId
    pointerStarted = true
    try {
      el.setPointerCapture(e.pointerId)
    }
    catch {}
  }

  function onPointerMove(e: PointerEvent) {
    if (!pointerStarted || e.pointerId !== activePointerId)
      return
    const el = elementRef.value
    if (!el)
      return
    if ((e.buttons & 1) === 0) {
      // The primary button is no longer held, but we never saw the release —
      // it happened outside the window, over another application, or after the
      // browser dropped pointer capture. Treat this move as the missing
      // `pointerup`: without it the gesture stays wedged with `data-swiping`
      // set (which pins the transition to 0ms), leaving the drawer frozen
      // mid-pull and ignoring every subsequent drag.
      finishSwipe(el)
      return
    }
    processMove(el, { x: e.clientX, y: e.clientY }, e.timeStamp)
  }

  function onPointerUp(e: PointerEvent) {
    if (!pointerStarted || e.pointerId !== activePointerId)
      return
    const el = elementRef.value
    if (!el) {
      reset()
      return
    }
    finishSwipe(el)
  }

  /**
   * Ends an in-flight pointer drag that can no longer be tracked — pointer
   * capture was lost, or the window lost focus mid-drag (which is what happens
   * when the release lands over another application). The drawer settles from
   * wherever it was last dragged to.
   */
  function onPointerInterrupted() {
    if (!pointerStarted)
      return
    const el = elementRef.value
    if (!el) {
      reset()
      return
    }
    finishSwipe(el)
  }

  // -- Touch Events (mobile) --

  function onTouchStart(e: TouchEvent) {
    if (!toValue(options.enabled))
      return
    if (canStart && !canStart())
      return

    const target = e.target as HTMLElement
    if (target?.closest(SWIPE_IGNORE_SELECTOR))
      return
    // A tap that stays under the drag threshold still clicks, so touch can
    // start a swipe on buttons and links; a range input's thumb drag cannot
    // be told apart from a swipe, so it keeps the gesture.
    if (ignoreSelectorWhenTouch
      ? target?.closest(DEFAULT_IGNORE_SELECTOR)
      : e.composedPath().some(isRangeInput)) {
      return
    }

    const el = elementRef.value
    if (!el)
      return

    // A gesture whose end we never saw would otherwise leak its verdict into
    // this one, permanently muting the swipe.
    crossAxisScrollable = null
    drawerAxisAttributed = false
    preserveNativeCrossAxisScroll = false

    if (!options.ignoreScrollableAncestors) {
      const axis = hasVertical.value ? 'vertical' : 'horizontal'
      const scrollable = findScrollableAncestor(target, axis)
      if (scrollable) {
        swipeFromScrollable = true
        scrollableAncestor = scrollable
      }
    }

    // The scroller on the axis the drawer does NOT dismiss along, so
    // `shouldYieldTouchMove` can hand the gesture to it. Deliberately not gated
    // on `ignoreScrollableAncestors`: that option is about starting a swipe from
    // inside a scroller on the dismiss axis.
    if (drawerAxis.value) {
      const crossAxis = drawerAxis.value === 'vertical' ? 'horizontal' : 'vertical'
      crossAxisScrollable = findScrollableAncestor(target, crossAxis)
    }

    const t = e.touches[0]
    if (!t)
      return
    startSwipe(el, { x: t.clientX, y: t.clientY })
  }

  function onTouchMove(e: TouchEvent) {
    const el = elementRef.value
    if (!el || (!pendingSwipe && !isSwiping.value))
      return

    const t = e.touches[0]
    if (!t)
      return

    const pos = { x: t.clientX, y: t.clientY }

    // Bailing here leaves the move untouched, so the browser can scroll natively.
    if (shouldYieldTouchMove(e, pos))
      return

    if (swipeFromScrollable && pendingSwipe && scrollableAncestor) {
      const dx = pos.x - dragStartPos.x
      const dy = pos.y - dragStartPos.y
      // BaseUI-style scroll-edge allowance: only cancel if the user is genuinely
      // scrolling (not at the edge in the dismiss direction). If they are at the
      // relevant edge, let the swipe proceed.
      const allowed = canSwipeFromScrollEdge(scrollableAncestor, dx, dy, {
        hasVertical: hasVertical.value,
        hasHorizontal: hasHorizontal.value,
        allowUp: allowUp.value,
        allowDown: allowDown.value,
        allowLeft: allowLeft.value,
        allowRight: allowRight.value,
      })
      if (allowed === false) {
        reset()
        return
      }
      if (allowed === true) {
        // Scroll edge reached in the swipe direction → clear the guard so
        // subsequent moves go through the normal swipe path.
        swipeFromScrollable = false
        scrollableAncestor = null
      }
      // allowed === null → axis unclear, fall through to processMove which
      // will lock the axis and retry the decision on the next frame.
    }

    // Capture swiping state BEFORE processMove — that function is what sets
    // isSwiping.value = true on the move that crosses the drag threshold, so
    // we need to preventDefault on that same event (not the next one) to avoid
    // a visible scroll hitch on the first frame of the gesture on mobile.
    const wasSwiping = isSwiping.value
    processMove(el, pos, e.timeStamp)
    if (wasSwiping || isSwiping.value)
      e.preventDefault()
  }

  function onTouchEnd() {
    const el = elementRef.value
    if (!el)
      return
    finishSwipe(el)
  }

  // -- Attach listeners --

  const cleanups: Array<() => void> = []

  watch(
    () => elementRef.value,
    (el) => {
      cleanups.forEach(fn => fn())
      cleanups.length = 0

      if (!el)
        return

      // BaseUI hangs the gesture off `Drawer.Viewport`, a `position: fixed;
      // inset: 0` element, so the pointer can never leave the element that is
      // listening. Here the listeners live on the popup itself, which the
      // pointer leaves as soon as the drag goes past the drawer's bounds.
      // `setPointerCapture` covers that for an ordinary in-window drag, but a
      // release the popup never sees — outside the window, over another app, or
      // after capture is dropped — would otherwise leave the gesture wedged.
      // Mirror the pointer end events on the document and window so the drag
      // can always be finished.
      const doc = el.ownerDocument
      const win = doc.defaultView

      cleanups.push(
        useEventListener(el, 'pointerdown', onPointerDown as EventListener),
        useEventListener(el, 'pointermove', onPointerMove as EventListener),
        useEventListener(el, 'pointerup', onPointerUp as EventListener),
        useEventListener(el, 'pointercancel', onPointerUp as EventListener),
        useEventListener(el, 'lostpointercapture', onPointerInterrupted as EventListener),
        useEventListener(doc, 'pointerup', onPointerUp as EventListener),
        useEventListener(doc, 'pointercancel', onPointerUp as EventListener),
        useEventListener(el, 'touchstart', onTouchStart as EventListener, { passive: true }),
        useEventListener(el, 'touchmove', onTouchMove as EventListener, { passive: false }),
        useEventListener(el, 'touchend', onTouchEnd as EventListener),
        useEventListener(el, 'touchcancel', onTouchEnd as EventListener),
      )

      if (win)
        cleanups.push(useEventListener(win, 'blur', onPointerInterrupted))
    },
    { immediate: true },
  )

  onUnmounted(() => {
    cleanups.forEach(fn => fn())
    cleanups.length = 0
    reset()
  })

  /**
   * Undoes a dismissal that did not take effect, e.g. a controlled `open` the
   * parent never updated. The element animates back to rest.
   */
  function restore() {
    const el = elementRef.value
    if (!el?.hasAttribute('data-swipe-dismissed'))
      return
    el.removeAttribute('data-swipe-dismissed')
    clearCssVars(el)
    onProgress?.(0)
    onCancel?.()
  }

  return {
    isSwiping,
    swipeDirection,
    dragOffset,
    restore,
  }
}
