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
  canStart?: () => boolean
  onDismiss?: () => 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
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"]'

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,
    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'))

  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
  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
    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 }
  }

  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

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

      if (shouldDismiss) {
        // 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', '')
        onDismiss?.()
      }
      else {
        // On cancel, reset the drag transform so the drawer animates back to rest.
        clearCssVars(el)
        onCancel?.()
      }
    }

    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))
      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(DEFAULT_IGNORE_SELECTOR))
      return

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

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

    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 }

    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()
  })

  return {
    isSwiping,
    swipeDirection,
    dragOffset,
  }
}
