<template>
  <div
    class="ptr"
    @touchstart.passive="onTouchStart"
    @touchmove="onTouchMove"
    @touchend="onTouchEnd"
    @touchcancel="onTouchEnd"
  >
    <div
      class="ptr__indicator"
      :class="{ 'ptr__indicator--ready': progress >= 1 }"
      :style="{ opacity: indicatorOpacity, transform: `translateY(${ptrY * 0.6}px)` }"
      aria-hidden="true"
    >
      <svg v-if="!refreshing" class="ptr__arrow" viewBox="0 0 24 24"
           :style="{ transform: `rotate(${progress * 180}deg)` }">
        <line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
        <polyline points="6 13 12 19 18 13" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
      </svg>
      <div v-else class="ptr__spinner" role="status" aria-label="Refreshing" />
    </div>
    <div class="ptr__content" :style="contentStyle">
      <slot />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const props = withDefaults(defineProps<{
  /** Returns true when the consumer's scroll surface is at the top. */
  isAtTop?: () => boolean
  /** Async refresh action. Resolves when refresh is done. */
  onRefresh: () => Promise<unknown> | unknown
  /** Drag distance (px) needed to commit a refresh on release. */
  threshold?: number
  /** Visual cap on the drag (px) — pulls beyond this damp out. */
  maxPull?: number
}>(), {
  threshold: 80,
  maxPull: 140,
})

const ptrY       = ref(0)
const refreshing = ref(false)

let startY  = 0
let startX  = 0
let pulling = false
let lockedAxis: 'x' | 'y' | null = null

const progress = computed(() => Math.min(ptrY.value / props.threshold, 1))

const indicatorOpacity = computed(() =>
  refreshing.value ? 1 : Math.min(ptrY.value / props.threshold, 1)
)

const contentStyle = computed(() => ({
  transform: `translateY(${ptrY.value}px)`,
  transition: pulling ? 'none' : 'transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)',
}))

function onTouchStart(e: TouchEvent) {
  if (refreshing.value) return
  if (props.isAtTop && !props.isAtTop()) return
  if (e.touches.length !== 1) return
  startY      = e.touches[0].clientY
  startX      = e.touches[0].clientX
  pulling     = true
  lockedAxis  = null
}

function onTouchMove(e: TouchEvent) {
  if (!pulling) return

  const dx = e.touches[0].clientX - startX
  const dy = e.touches[0].clientY - startY

  // Decide axis on the first meaningful movement; if the user is swiping
  // horizontally (e.g., the landscape carousel), bail out of PTR entirely.
  if (lockedAxis === null) {
    if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return
    lockedAxis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
  }
  if (lockedAxis === 'x' || dy <= 0) {
    pulling = false
    ptrY.value = 0
    return
  }

  // Re-check at-top on the way down too — if the surface isn't at the top
  // any more (e.g., user scrolled within a child during the gesture), stop.
  if (props.isAtTop && !props.isAtTop()) {
    pulling = false
    ptrY.value = 0
    return
  }

  // Damped pull: feels resistive past the threshold so it never feels free.
  const damped = dy < props.maxPull
    ? dy * 0.5
    : props.maxPull * 0.5 + (dy - props.maxPull) * 0.1
  ptrY.value = Math.min(damped, props.maxPull * 0.7)

  if (e.cancelable) e.preventDefault()
}

async function onTouchEnd() {
  if (!pulling) return
  pulling = false

  if (ptrY.value >= props.threshold) {
    refreshing.value = true
    ptrY.value = props.threshold * 0.7
    try {
      await props.onRefresh()
    } catch (e) {
      // Swallow — the consumer is responsible for surfacing the error
      // (e.g., via a toast). PTR's only job is to clear the spinner.
      console.error('Pull-to-refresh failed:', e)
    } finally {
      refreshing.value = false
      ptrY.value = 0
    }
  } else {
    ptrY.value = 0
  }
}
</script>

<style scoped lang="scss">
.ptr {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overscroll-behavior: contain;
}

.ptr__indicator {
  position: absolute;
  top: -56px;
  left: 0;
  right: 0;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  color: var(--color-text-muted);
  z-index: 1;

  &--ready {
    color: var(--color-primary);
  }
}

.ptr__arrow {
  width: 24px;
  height: 24px;
  transition: transform 120ms linear, color 150ms ease;
}

.ptr__spinner {
  width: 22px;
  height: 22px;
  border: 2.5px solid var(--color-border);
  border-top-color: var(--color-primary);
  border-radius: 50%;
  animation: ptr-spin 0.8s linear infinite;
}

@keyframes ptr-spin {
  to { transform: rotate(360deg); }
}

.ptr__content {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  will-change: transform;
}
</style>
