<script setup lang="ts">
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  watch,
  type PropType,
} from "vue";
import { useNativeRouter } from "./lifecycle";
import {
  adjacentSheetBreakpoint,
  nearestSheetBreakpoint,
  normalizeSheetBreakpoints,
} from "./sheet";

defineOptions({ name: "NativeSheet", inheritAttrs: false });

const props = defineProps({
  /** Fractions of the available, safe-area-contained route height. */
  breakpoints: {
    type: Array as PropType<number[]>,
    default: () => [],
  },
  /** Initial fraction. The nearest declared breakpoint is used. */
  initialBreakpoint: Number,
  /** Current fraction for v-model. */
  modelValue: Number,
  dismissible: { type: Boolean, default: true },
  backdropDismiss: { type: Boolean, default: true },
  showHandle: { type: Boolean, default: true },
  ariaLabel: { type: String, default: "Sheet" },
});

const emit = defineEmits<{
  "update:modelValue": [value: number];
  "breakpoint-change": [value: number];
  dismiss: [];
}>();

const attrs = useAttrs();
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
const surface = ref<HTMLElement | null>(null);
const handle = ref<HTMLElement | null>(null);
const body = ref<HTMLElement | null>(null);
const content = ref<HTMLElement | null>(null);
const height = ref<number>();
const activeBreakpoint = ref<number>();
const dragging = ref(false);
const ready = ref(false);
const dismissing = ref(false);
const normalizedBreakpoints = computed(() =>
  normalizeSheetBreakpoints(props.breakpoints),
);
const usesBreakpoints = computed(() => normalizedBreakpoints.value.length > 0);
const heightStyle = computed(() =>
  height.value === undefined ? undefined : `${height.value}px`,
);
const breakpointLabel = computed(() =>
  activeBreakpoint.value === undefined
    ? "content"
    : String(activeBreakpoint.value),
);

let resizeObserver: ResizeObserver | undefined;
let owningLayer: HTMLElement | null = null;
let pointerId = -1;
let touchId = -1;
let candidateX = 0;
let candidateY = 0;
let candidateTime = 0;
let startY = 0;
let startHeight = 0;
let lastY = 0;
let lastTime = 0;
let velocity = 0;
let moved = false;
let wheelDistance = 0;
let wheelDirection: "up" | "down" | undefined;
let wheelOwner: "content" | "sheet" | undefined;
let wheelLocked = false;
let wheelResetTimer: ReturnType<typeof setTimeout> | undefined;
let contentGestureOwner: "pending" | "content" | "sheet" | undefined;

function availableHeight() {
  return Math.max(1, root.value?.clientHeight ?? window.innerHeight);
}

function bodyPadding() {
  if (!body.value) return 0;
  const style = getComputedStyle(body.value);
  return (
    (Number.parseFloat(style.paddingTop) || 0) +
    (Number.parseFloat(style.paddingBottom) || 0)
  );
}

function naturalHeight() {
  return Math.min(
    availableHeight(),
    Math.max(
      1,
      (handle.value?.offsetHeight ?? 0) +
        (content.value?.scrollHeight ?? 0) +
        bodyPadding(),
    ),
  );
}

function requestedBreakpoint() {
  const points = normalizedBreakpoints.value;
  if (!points.length) return undefined;
  return nearestSheetBreakpoint(
    points,
    props.modelValue ??
      activeBreakpoint.value ??
      props.initialBreakpoint ??
      points[0]!,
  );
}

function measure() {
  const breakpoint = requestedBreakpoint();
  activeBreakpoint.value = breakpoint;
  height.value = breakpoint ? availableHeight() * breakpoint : naturalHeight();
}

function setBreakpoint(breakpoint: number, notify = true) {
  const nearest = nearestSheetBreakpoint(
    normalizedBreakpoints.value,
    breakpoint,
  );
  if (nearest === undefined) return measure();
  activeBreakpoint.value = nearest;
  height.value = availableHeight() * nearest;
  if (notify) {
    emit("update:modelValue", nearest);
    emit("breakpoint-change", nearest);
  }
}

async function dismissSheet() {
  if (!props.dismissible || dismissing.value) return false;
  dismissing.value = true;
  emit("dismiss");
  const dismissed = await runtime.dismiss();
  dismissing.value = false;
  if (!dismissed) measure();
  return dismissed;
}

function beginDrag(clientY: number, timestamp: number) {
  startY = lastY = clientY;
  lastTime = timestamp;
  startHeight = height.value ?? surface.value?.offsetHeight ?? naturalHeight();
  velocity = 0;
  moved = false;
  dragging.value = true;
}

function updateDrag(clientY: number, timestamp: number) {
  const delta = clientY - startY;
  moved ||= Math.abs(delta) > 3;
  const minimum = usesBreakpoints.value
    ? availableHeight() * (normalizedBreakpoints.value[0] ?? 0.1) * 0.55
    : naturalHeight() * 0.55;
  height.value = Math.max(
    Math.min(72, availableHeight()),
    Math.min(availableHeight(), Math.max(minimum, startHeight - delta)),
  );
  const elapsed = Math.max(8, timestamp - lastTime);
  velocity = ((clientY - lastY) * 1000) / elapsed / availableHeight();
  lastY = clientY;
  lastTime = timestamp;
}

async function finishDrag() {
  dragging.value = false;
  if (!moved) return measure();

  const currentHeight = height.value ?? startHeight;
  const currentFraction = currentHeight / availableHeight();
  const points = normalizedBreakpoints.value;
  const smallest = points[0];

  if (!points.length) {
    if (
      props.dismissible &&
      (currentHeight < naturalHeight() * 0.72 || velocity > 1.1)
    )
      return void (await dismissSheet());
    return measure();
  }

  if (
    props.dismissible &&
    smallest !== undefined &&
    currentFraction < smallest * 0.72
  )
    return void (await dismissSheet());

  const active = activeBreakpoint.value ?? smallest!;
  if (Math.abs(velocity) > 0.65) {
    const direction = velocity < 0 ? "up" : "down";
    const adjacent = adjacentSheetBreakpoint(points, active, direction);
    if (adjacent !== undefined) return setBreakpoint(adjacent);
    if (direction === "down" && props.dismissible)
      return void (await dismissSheet());
  }

  setBreakpoint(nearestSheetBreakpoint(points, currentFraction) ?? active);
}

function cancelDrag() {
  dragging.value = false;
  measure();
}

function pointerDown(event: PointerEvent) {
  if (!event.isPrimary || event.button !== 0 || dismissing.value) return;
  event.preventDefault();
  event.stopPropagation();
  contentGestureOwner = "sheet";
  pointerId = event.pointerId;
  beginDrag(event.clientY, event.timeStamp);
  handle.value?.setPointerCapture?.(pointerId);
}

function pointerMove(event: PointerEvent) {
  if (event.pointerId !== pointerId) return;
  event.preventDefault();
  event.stopPropagation();
  updateDrag(event.clientY, event.timeStamp);
}

async function pointerUp(event: PointerEvent) {
  if (event.pointerId !== pointerId) return;
  handle.value?.releasePointerCapture?.(pointerId);
  pointerId = -1;
  await finishDrag();
  contentGestureOwner = undefined;
}

function pointerCancel(event: PointerEvent) {
  if (event.pointerId !== pointerId) return;
  pointerId = -1;
  cancelDrag();
  contentGestureOwner = undefined;
}

function atTop() {
  return (body.value?.scrollTop ?? 0) <= 1;
}

function atBottom() {
  const element = body.value;
  if (!element) return true;
  return element.scrollTop + element.clientHeight >= element.scrollHeight - 1;
}

function canResizeFromContent(direction: "up" | "down") {
  const points = normalizedBreakpoints.value;
  const active = activeBreakpoint.value;
  if (direction === "up")
    return Boolean(
      atBottom() &&
      active !== undefined &&
      adjacentSheetBreakpoint(points, active, "up") !== undefined,
    );
  return Boolean(
    atTop() &&
    (props.dismissible ||
      (active !== undefined &&
        adjacentSheetBreakpoint(points, active, "down") !== undefined)),
  );
}

function contentGestureDirection(deltaY: number) {
  return deltaY < 0 ? ("up" as const) : ("down" as const);
}

function shouldClaimContentGesture(deltaX: number, deltaY: number) {
  if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
    return false;
  return canResizeFromContent(contentGestureDirection(deltaY));
}

function contentPointerDown(event: PointerEvent) {
  if (
    event.pointerType === "touch" ||
    !event.isPrimary ||
    event.button !== 0 ||
    dismissing.value
  )
    return;
  pointerId = event.pointerId;
  contentGestureOwner = "pending";
  candidateX = event.clientX;
  candidateY = event.clientY;
  candidateTime = event.timeStamp;
}

function contentPointerMove(event: PointerEvent) {
  if (event.pointerId !== pointerId) return;
  if (contentGestureOwner === "pending") {
    const deltaX = event.clientX - candidateX;
    const deltaY = event.clientY - candidateY;
    if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
      return;
    contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
      ? "sheet"
      : "content";
    if (contentGestureOwner === "content") return;
    beginDrag(candidateY, candidateTime);
    body.value?.setPointerCapture?.(pointerId);
  }
  if (contentGestureOwner !== "sheet") return;
  event.preventDefault();
  event.stopPropagation();
  updateDrag(event.clientY, event.timeStamp);
}

async function contentPointerUp(event: PointerEvent) {
  if (event.pointerId !== pointerId) return;
  body.value?.releasePointerCapture?.(pointerId);
  pointerId = -1;
  if (dragging.value) await finishDrag();
  contentGestureOwner = undefined;
}

function contentPointerCancel(event: PointerEvent) {
  if (event.pointerId !== pointerId) return;
  pointerId = -1;
  if (dragging.value) cancelDrag();
  contentGestureOwner = undefined;
}

function trackedTouch(list: TouchList) {
  return [...list].find((touch) => touch.identifier === touchId);
}

function contentTouchStart(event: TouchEvent) {
  if (touchId !== -1 || dismissing.value) return;
  const touch = event.changedTouches[0];
  if (!touch) return;
  touchId = touch.identifier;
  contentGestureOwner = "pending";
  candidateX = touch.clientX;
  candidateY = touch.clientY;
  candidateTime = event.timeStamp;
}

function contentTouchMove(event: TouchEvent) {
  const touch = trackedTouch(event.touches);
  if (!touch) return;
  if (contentGestureOwner === "pending") {
    const deltaX = touch.clientX - candidateX;
    const deltaY = touch.clientY - candidateY;
    if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
      return;
    contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
      ? "sheet"
      : "content";
    if (contentGestureOwner === "content") return;
    beginDrag(candidateY, candidateTime);
  }
  if (contentGestureOwner !== "sheet") return;
  if (event.cancelable) event.preventDefault();
  event.stopPropagation();
  updateDrag(touch.clientY, event.timeStamp);
}

async function contentTouchEnd(event: TouchEvent) {
  if (!trackedTouch(event.changedTouches)) return;
  touchId = -1;
  if (dragging.value) await finishDrag();
  contentGestureOwner = undefined;
}

function contentTouchCancel(event: TouchEvent) {
  if (!trackedTouch(event.changedTouches)) return;
  touchId = -1;
  if (dragging.value) cancelDrag();
  contentGestureOwner = undefined;
}

function resetWheelHandoff() {
  if (wheelResetTimer) clearTimeout(wheelResetTimer);
  wheelDistance = 0;
  wheelDirection = undefined;
  wheelOwner = undefined;
  wheelLocked = false;
  wheelResetTimer = undefined;
}

function contentWheel(event: WheelEvent) {
  const direction = event.deltaY > 0 ? ("up" as const) : ("down" as const);
  if (!event.deltaY) return;
  if (wheelResetTimer) clearTimeout(wheelResetTimer);
  wheelResetTimer = setTimeout(resetWheelHandoff, 240);

  if (!wheelOwner)
    wheelOwner = canResizeFromContent(direction) ? "sheet" : "content";
  if (wheelOwner === "content") return;
  event.preventDefault();
  event.stopPropagation();
  if (wheelDirection !== direction) wheelDistance = 0;
  wheelDirection = direction;
  wheelDistance += Math.abs(event.deltaY);
  if (!wheelLocked && wheelDistance >= 48) {
    const active = activeBreakpoint.value;
    const adjacent =
      active === undefined
        ? undefined
        : adjacentSheetBreakpoint(
            normalizedBreakpoints.value,
            active,
            direction,
          );
    if (adjacent !== undefined) setBreakpoint(adjacent);
    else if (direction === "down") void dismissSheet();
    wheelLocked = true;
  }
}

function keyDown(event: KeyboardEvent) {
  const points = normalizedBreakpoints.value;
  const current = activeBreakpoint.value;
  if (event.key === "Escape") {
    event.preventDefault();
    void dismissSheet();
    return;
  }
  if (!points.length || current === undefined) return;
  const direction =
    event.key === "ArrowUp"
      ? "up"
      : event.key === "ArrowDown"
        ? "down"
        : undefined;
  const target =
    event.key === "Home"
      ? points[0]
      : event.key === "End"
        ? points.at(-1)
        : direction
          ? adjacentSheetBreakpoint(points, current, direction)
          : undefined;
  if (target !== undefined) {
    event.preventDefault();
    setBreakpoint(target);
  } else if (direction === "down" && props.dismissible) {
    event.preventDefault();
    void dismissSheet();
  }
}

function surfaceKeyDown(event: KeyboardEvent) {
  if (event.key !== "Escape" || event.target === handle.value) return;
  event.preventDefault();
  void dismissSheet();
}

watch(
  () => [props.modelValue, props.initialBreakpoint, props.breakpoints] as const,
  () => {
    if (!dragging.value) void nextTick(measure);
  },
  { deep: true },
);

onMounted(() => {
  owningLayer = root.value?.closest<HTMLElement>(".nvr-view") ?? null;
  if (owningLayer) owningLayer.dataset.nativeSheetSurface = "";
  if (typeof ResizeObserver !== "undefined") {
    resizeObserver = new ResizeObserver(() => {
      if (!dragging.value) measure();
    });
    if (root.value) resizeObserver.observe(root.value);
    if (content.value) resizeObserver.observe(content.value);
  }
  void nextTick(() => {
    measure();
    requestAnimationFrame(() => (ready.value = true));
  });
});

onBeforeUnmount(() => {
  resizeObserver?.disconnect();
  if (wheelResetTimer) clearTimeout(wheelResetTimer);
  if (owningLayer) delete owningLayer.dataset.nativeSheetSurface;
});
</script>

<template>
  <div ref="root" v-bind="attrs" class="nvr-sheet">
    <button
      v-if="backdropDismiss && dismissible"
      class="nvr-sheet__backdrop"
      type="button"
      aria-label="Close sheet"
      @click="dismissSheet"
    />
    <div v-else class="nvr-sheet__backdrop" aria-hidden="true" />
    <section
      ref="surface"
      class="nvr-sheet__surface"
      :style="{ height: heightStyle }"
      role="dialog"
      aria-modal="true"
      :aria-label="ariaLabel"
      data-native-sheet
      :data-native-sheet-mode="usesBreakpoints ? 'breakpoints' : 'content'"
      :data-native-sheet-breakpoint="breakpointLabel"
      :data-native-sheet-dragging="String(dragging)"
      :data-native-sheet-ready="String(ready)"
      @keydown="surfaceKeyDown"
    >
      <div
        v-if="showHandle"
        ref="handle"
        class="nvr-sheet__handle"
        :role="usesBreakpoints ? 'slider' : undefined"
        :tabindex="0"
        aria-label="Resize or dismiss sheet"
        aria-orientation="vertical"
        :aria-valuemin="usesBreakpoints ? normalizedBreakpoints[0] : undefined"
        :aria-valuemax="
          usesBreakpoints ? normalizedBreakpoints.at(-1) : undefined
        "
        :aria-valuenow="usesBreakpoints ? activeBreakpoint : undefined"
        @pointerdown="pointerDown"
        @pointermove="pointerMove"
        @pointerup="pointerUp"
        @pointercancel="pointerCancel"
        @keydown="keyDown"
      >
        <slot name="handle">
          <span aria-hidden="true" />
        </slot>
      </div>
      <div
        ref="body"
        class="nvr-sheet__body"
        @pointerdown="contentPointerDown"
        @pointermove="contentPointerMove"
        @pointerup="contentPointerUp"
        @pointercancel="contentPointerCancel"
        @touchstart="contentTouchStart"
        @touchmove="contentTouchMove"
        @touchend="contentTouchEnd"
        @touchcancel="contentTouchCancel"
        @wheel="contentWheel"
      >
        <div ref="content" class="nvr-sheet__content"><slot /></div>
      </div>
    </section>
  </div>
</template>
