/**
 * Drag-and-drop reorder for period recipe cards (#recipesList).
 * Drag starts from .recipe-drag-handle; uses the same API as arrow buttons.
 * Desktop: HTML5 DnD. Mobile: pointer reorder with card clone + insertion line.
 */
import { cloneTemplateInto } from "./recipe-skeleton.js";

const MOBILE_REORDER_MQ = "(max-width: 768px)";
const POINTER_ACTIVATION_PX = { mobile: 10, desktop: 10 };
const AUTO_SCROLL_EDGE_PX = 72;
const AUTO_SCROLL_MAX_STEP = 28;
const REORDER_TRACKING_BODY_CLASS = "recipe-list-reorder-tracking";

function isMobileReorderUi() {
  return globalThis.matchMedia?.(MOBILE_REORDER_MQ)?.matches ?? false;
}

function activationThresholdPx() {
  return isMobileReorderUi() ? POINTER_ACTIVATION_PX.mobile : POINTER_ACTIVATION_PX.desktop;
}

function transferBlocksReorder() {
  const listRoot = document.getElementById("recipesList");
  if (
    listRoot?.classList.contains("recipe-list-reorder-active") ||
    document.body.classList.contains("recipe-list-reorder-active")
  ) {
    return false;
  }
  return (
    document.body.classList.contains("recipe-mobile-transfer-armed") ||
    document.body.classList.contains("recipe-period-transfer-active")
  );
}

function removeRecipeDragGhost() {
  const existing = document.getElementById("recipe-card-drag-ghost-root");
  if (existing?.parentNode) {
    existing.parentNode.removeChild(existing);
  }
}

function removeInsertIndicator(listRoot) {
  listRoot?.querySelector(":scope > .recipe-card-insert-indicator")?.remove();
}

function mountRecipeCardSkeletonInner(container) {
  if (!cloneTemplateInto(container, "recipe-card-skeleton-inner")) {
    container.innerHTML =
      '<div class="dashboard-skeleton-card-inner"><div class="dashboard-skeleton-bar"></div></div>';
  }
}

function getRecipeListItems(listRoot) {
  return [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")];
}

function resolveRawInsertIndexAtY(listRoot, clientY) {
  const items = getRecipeListItems(listRoot);
  for (let i = 0; i < items.length; i++) {
    const rect = items[i].getBoundingClientRect();
    if (clientY < rect.top + rect.height / 2) {
      return i;
    }
  }
  return items.length;
}

function resolveInsertIndexAtY(listRoot, clientY, draggingItem) {
  const items = getRecipeListItems(listRoot);
  if (!draggingItem) {
    return Math.max(0, Math.min(resolveRawInsertIndexAtY(listRoot, clientY), items.length));
  }

  const fromIndex = items.indexOf(draggingItem);
  if (fromIndex < 0) return 0;

  let toIndex = items.length;
  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    if (item === draggingItem) continue;
    const rect = item.getBoundingClientRect();
    const inCard = clientY >= rect.top && clientY <= rect.bottom;

    if (inCard && fromIndex > i) {
      toIndex = i;
      break;
    }
    if (inCard && fromIndex < i) {
      toIndex = clientY < rect.top + rect.height / 2 ? i : i + 1;
      break;
    }
    if (clientY < rect.top + rect.height / 2) {
      toIndex = i;
      break;
    }
    toIndex = i + 1;
  }

  if (fromIndex < toIndex) {
    toIndex -= 1;
  }
  return Math.max(0, Math.min(toIndex, Math.max(0, items.length - 1)));
}

function resolveReorderDropIndex(listRoot, dragEvent, draggingItem) {
  const items = getRecipeListItems(listRoot);
  const fromIndex = items.indexOf(draggingItem);
  if (fromIndex < 0) return null;

  const toIndex = resolveInsertIndexAtY(listRoot, dragEvent.clientY, draggingItem);
  if (fromIndex === toIndex) return null;
  return { fromIndex, toIndex };
}

function resolveTrackedReorderDropIndex(listRoot, draggingItem, dropToIndex) {
  const items = getRecipeListItems(listRoot);
  const fromIndex = items.indexOf(draggingItem);
  if (fromIndex < 0 || dropToIndex == null) return null;
  const toIndex = Math.max(0, Math.min(dropToIndex, Math.max(0, items.length - 1)));
  if (fromIndex === toIndex) return null;
  return { fromIndex, toIndex };
}

function mountRecipeCardDragGhost(item, dragEvent) {
  removeRecipeDragGhost();
  const ghost = document.createElement("div");
  ghost.id = "recipe-card-drag-ghost-root";
  ghost.className = "recipe-card-drag-ghost";
  ghost.setAttribute("aria-hidden", "true");
  mountRecipeCardSkeletonInner(ghost);

  const w = item.offsetWidth;
  ghost.style.cssText = [
    "position:fixed",
    "left:-10000px",
    "top:0",
    "width:" + Math.round(Math.min(Math.max(w, 200), 640)) + "px",
    "pointer-events:none",
  ].join(";");

  document.body.appendChild(ghost);
  void ghost.offsetWidth;

  const rect = item.getBoundingClientRect();
  const ox = Math.max(0, Math.round(dragEvent.clientX - rect.left));
  const oy = Math.max(0, Math.round(dragEvent.clientY - rect.top));

  try {
    dragEvent.dataTransfer.setDragImage(ghost, ox, oy);
  } catch (_) {
    /* старые движки без кастомного изображения */
  }
}

function sanitizeDragGhostClone(node) {
  node.querySelectorAll("[data-action]").forEach((el) => el.removeAttribute("data-action"));
  node.querySelectorAll("button").forEach((btn) => {
    btn.disabled = true;
    btn.tabIndex = -1;
  });
  node.classList.remove("active", "recipe-card-drop-target");
}

function mountPointerFollowGhost(item, clientX, clientY, grabOffsetX, grabOffsetY) {
  removeRecipeDragGhost();

  if (isMobileReorderUi()) {
    const ghost = item.cloneNode(true);
    ghost.id = "recipe-card-drag-ghost-root";
    ghost.className = "list-item recipe-card-drag-ghost recipe-card-drag-ghost--card-clone";
    ghost.setAttribute("aria-hidden", "true");
    sanitizeDragGhostClone(ghost);
    const width = Math.round(Math.min(Math.max(item.offsetWidth, 200), window.innerWidth - 24));
    ghost.style.cssText = [
      "position:fixed",
      "left:0",
      "top:0",
      "width:" + width + "px",
      "margin:0",
      "transition:none",
      "transform:none",
      "z-index:1300",
      "pointer-events:none",
    ].join(";");
    document.body.appendChild(ghost);
    positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX, grabOffsetY);
    return ghost;
  }

  const ghost = document.createElement("div");
  ghost.id = "recipe-card-drag-ghost-root";
  ghost.className = "recipe-card-drag-ghost recipe-card-drag-ghost--follow";
  ghost.setAttribute("aria-hidden", "true");
  mountRecipeCardSkeletonInner(ghost);
  ghost.style.width = `${Math.round(Math.min(Math.max(item.offsetWidth, 200), 640))}px`;
  document.body.appendChild(ghost);
  positionPointerFollowGhost(ghost, clientX, clientY);
  return ghost;
}

function positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX = null, grabOffsetY = null) {
  if (!ghost) return;
  if (grabOffsetX != null && grabOffsetY != null) {
    ghost.style.left = `${clientX - grabOffsetX}px`;
    ghost.style.top = `${clientY - grabOffsetY}px`;
    ghost.style.transform = "none";
    return;
  }
  ghost.style.left = `${clientX}px`;
  ghost.style.top = `${clientY}px`;
  ghost.style.transform = "translate(-50%, -50%)";
}

function slotPickActive() {
  return Boolean(document.querySelector(".recipe-transfer-slot"));
}

function clearDropTargets(listRoot) {
  listRoot.querySelectorAll(".list-item.recipe-card-drop-target").forEach((el) => {
    el.classList.remove("recipe-card-drop-target");
  });
  removeInsertIndicator(listRoot);
}

function updateInsertIndicator(listRoot, draggingItem, clientY) {
  if (!isMobileReorderUi()) return;
  const items = getRecipeListItems(listRoot);
  if (!items.length) return;

  let line = listRoot.querySelector(":scope > .recipe-card-insert-indicator");
  if (!line) {
    line = document.createElement("div");
    line.className = "recipe-card-insert-indicator";
    line.setAttribute("aria-hidden", "true");
    listRoot.appendChild(line);
  }

  const insertIndex = resolveRawInsertIndexAtY(listRoot, clientY);
  let yInList;

  if (insertIndex >= items.length) {
    const last = items[items.length - 1];
    yInList = last.offsetTop + last.offsetHeight + 6;
  } else {
    yInList = items[insertIndex].offsetTop - 4;
  }

  line.style.top = `${yInList}px`;
  line.hidden = false;
}

function highlightDropTarget(listRoot, draggingItem, clientY) {
  clearDropTargets(listRoot);
  if (isMobileReorderUi()) {
    updateInsertIndicator(listRoot, draggingItem, clientY);
    return;
  }
  const items = getRecipeListItems(listRoot);
  for (const item of items) {
    if (item === draggingItem) continue;
    const rect = item.getBoundingClientRect();
    if (clientY >= rect.top && clientY <= rect.bottom) {
      item.classList.add("recipe-card-drop-target");
      break;
    }
  }
}

function configureMobileDragHandles(listRoot) {
  if (!isMobileReorderUi()) return;
  listRoot.querySelectorAll(".recipe-drag-handle").forEach((handle) => {
    handle.setAttribute("draggable", "false");
  });
}

function listCanScroll(listRoot) {
  return listRoot.scrollHeight > listRoot.clientHeight + 2;
}

function getPageScrollElement() {
  return document.scrollingElement || document.documentElement;
}

function pageCanScrollUp() {
  return getPageScrollElement().scrollTop > 0;
}

function pageCanScrollDown() {
  const scrollEl = getPageScrollElement();
  return scrollEl.scrollTop + window.innerHeight < scrollEl.scrollHeight - 2;
}

function ensureMobileEdgeScrollHints() {
  if (!isMobileReorderUi() || document.getElementById("recipe-reorder-edge-hints")) return;

  const wrap = document.createElement("div");
  wrap.id = "recipe-reorder-edge-hints";
  wrap.className = "recipe-reorder-edge-hints";
  wrap.setAttribute("aria-hidden", "true");
  wrap.innerHTML =
    '<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--top">' +
    '<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-up" aria-hidden="true"></i><span>К краю — вверх</span></span>' +
    "</div>" +
    '<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--bottom">' +
    '<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-down" aria-hidden="true"></i><span>К краю — вниз</span></span>' +
    "</div>";
  document.body.appendChild(wrap);
}

function updateMobileEdgeScrollHints(clientY) {
  if (!isMobileReorderUi()) return;
  const wrap = document.getElementById("recipe-reorder-edge-hints");
  if (!wrap) return;
  const vh = window.innerHeight;
  wrap.querySelector(".recipe-reorder-edge-hint--top")?.classList.toggle(
    "is-active",
    clientY < AUTO_SCROLL_EDGE_PX && pageCanScrollUp()
  );
  wrap.querySelector(".recipe-reorder-edge-hint--bottom")?.classList.toggle(
    "is-active",
    clientY > vh - AUTO_SCROLL_EDGE_PX && pageCanScrollDown()
  );
}

function removeReorderUiChrome() {
  document.body.classList.remove(REORDER_TRACKING_BODY_CLASS);
  document.getElementById("recipe-reorder-edge-hints")?.remove();
  document.getElementById("recipe-reorder-floating-hint")?.remove();
}

function showRecipeReorderHint() {
  if (!isMobileReorderUi() || document.getElementById("recipe-reorder-floating-hint")) return;

  const hint = document.createElement("div");
  hint.id = "recipe-reorder-floating-hint";
  hint.className = "recipe-reorder-hint recipe-reorder-hint--floating";
  hint.setAttribute("role", "status");
  hint.textContent = "Перетащите вверх или вниз. К краю экрана — прокрутка списка.";
  document.body.appendChild(hint);
  requestAnimationFrame(() => hint.classList.add("is-visible"));
  globalThis.setTimeout(() => {
    hint.classList.remove("is-visible");
    globalThis.setTimeout(() => hint.remove(), 320);
  }, 3200);
}

function autoScrollWindowAtEdge(clientY) {
  const vh = window.innerHeight;
  const scrollEl = getPageScrollElement();
  let delta = 0;

  if (clientY < AUTO_SCROLL_EDGE_PX) {
    const speed = Math.min(
      AUTO_SCROLL_MAX_STEP,
      Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(clientY, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
    );
    delta = -Math.max(8, speed);
  } else if (clientY > vh - AUTO_SCROLL_EDGE_PX) {
    const distBottom = vh - clientY;
    const speed = Math.min(
      AUTO_SCROLL_MAX_STEP,
      Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
    );
    delta = Math.max(8, speed);
  }

  if (delta === 0) return false;
  const prev = scrollEl.scrollTop;
  scrollEl.scrollTop += delta;
  return scrollEl.scrollTop !== prev;
}

function autoScrollRecipeList(listRoot, clientY) {
  if (!listCanScroll(listRoot)) return false;
  const rect = listRoot.getBoundingClientRect();
  const distTop = clientY - rect.top;
  const distBottom = rect.bottom - clientY;
  let delta = 0;

  if (distTop < AUTO_SCROLL_EDGE_PX || clientY < rect.top) {
    const speed = Math.min(
      AUTO_SCROLL_MAX_STEP,
      Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distTop, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
    );
    delta = -Math.max(8, speed);
  } else if (distBottom < AUTO_SCROLL_EDGE_PX || clientY > rect.bottom) {
    const speed = Math.min(
      AUTO_SCROLL_MAX_STEP,
      Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
    );
    delta = Math.max(8, speed);
  }

  if (delta === 0) return false;
  listRoot.scrollTop += delta;
  return true;
}

function autoScrollDuringReorder(listRoot, clientY) {
  if (isMobileReorderUi()) {
    return autoScrollWindowAtEdge(clientY);
  }
  return autoScrollRecipeList(listRoot, clientY);
}

function findTouchById(touchList, touchId) {
  for (let i = 0; i < touchList.length; i++) {
    if (touchList[i].identifier === touchId) {
      return touchList[i];
    }
  }
  return null;
}

function attachMobileTouchReorder(listRoot, { moveRecipe, notyf }) {
  let touchDrag = null;
  let dropPending = false;
  let autoScrollRaf = null;
  let touchMoveListener = null;
  let touchEndListener = null;

  const detachTouchListeners = () => {
    if (touchMoveListener) {
      document.removeEventListener("touchmove", touchMoveListener, true);
      touchMoveListener = null;
    }
    if (touchEndListener) {
      document.removeEventListener("touchend", touchEndListener, true);
      document.removeEventListener("touchcancel", touchEndListener, true);
      touchEndListener = null;
    }
  };

  const stopAutoScroll = () => {
    if (autoScrollRaf != null) {
      cancelAnimationFrame(autoScrollRaf);
      autoScrollRaf = null;
    }
  };

  const startAutoScroll = () => {
    stopAutoScroll();
    const tick = () => {
      if (!touchDrag?.active) {
        stopAutoScroll();
        return;
      }
      const y = touchDrag.lastY;
      autoScrollDuringReorder(listRoot, y);
      updateMobileEdgeScrollHints(y);
      highlightDropTarget(listRoot, touchDrag.item, y);
      touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, touchDrag.item);
      autoScrollRaf = requestAnimationFrame(tick);
    };
    autoScrollRaf = requestAnimationFrame(tick);
  };

  const finishTouchDrag = () => {
    detachTouchListeners();
    stopAutoScroll();
    if (!touchDrag) return;
    touchDrag.item.classList.remove("recipe-card-dragging");
    listRoot.classList.remove("recipe-list-reorder-active");
    removeReorderUiChrome();
    touchDrag = null;
    clearDropTargets(listRoot);
    removeRecipeDragGhost();
  };

  const activateTouchDrag = (touch) => {
    if (!touchDrag || touchDrag.active) return;
    touchDrag.active = true;
    touchDrag.item.classList.add("recipe-card-dragging");
    listRoot.classList.add("recipe-list-reorder-active");
    document.body.classList.add(REORDER_TRACKING_BODY_CLASS);
    listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true }));
    ensureMobileEdgeScrollHints();
    showRecipeReorderHint();
    touchDrag.ghost = mountPointerFollowGhost(
      touchDrag.item,
      touch.clientX,
      touch.clientY,
      touchDrag.grabOffsetX,
      touchDrag.grabOffsetY
    );
    startAutoScroll();
    globalThis.navigator?.vibrate?.(8);
  };

  const handleTouchMove = (e) => {
    if (!touchDrag) return;
    const touch = findTouchById(e.touches, touchDrag.touchId);
    if (!touch) return;

    e.preventDefault();
    if (transferBlocksReorder()) {
      finishTouchDrag();
      return;
    }

    touchDrag.lastY = touch.clientY;
    const dx = Math.abs(touch.clientX - touchDrag.startX);
    const dy = Math.abs(touch.clientY - touchDrag.startY);
    if (!touchDrag.active && dx + dy < activationThresholdPx()) return;

    if (!touchDrag.active) {
      activateTouchDrag(touch);
    }

    positionPointerFollowGhost(
      touchDrag.ghost,
      touch.clientX,
      touch.clientY,
      touchDrag.grabOffsetX,
      touchDrag.grabOffsetY
    );
    updateMobileEdgeScrollHints(touch.clientY);
    highlightDropTarget(listRoot, touchDrag.item, touch.clientY);
    touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, touch.clientY, touchDrag.item);
  };

  const handleTouchEnd = (e) => {
    if (!touchDrag) return;
    const touch = findTouchById(e.changedTouches, touchDrag.touchId);
    if (!touch) return;

    const { item, active, dropToIndex } = touchDrag;
    finishTouchDrag();

    if (!active || dropPending) return;
    if (transferBlocksReorder() && slotPickActive()) return;

    const indices =
      resolveReorderDropIndex(listRoot, touch, item) ||
      resolveTrackedReorderDropIndex(listRoot, item, dropToIndex);
    if (!indices) return;

    void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex);
  };

  listRoot.addEventListener("recipe-transfer-armed", () => {
    if (touchDrag?.active) {
      finishTouchDrag();
    } else if (touchDrag) {
      detachTouchListeners();
      removeReorderUiChrome();
      touchDrag = null;
    }
  });

  if (typeof MutationObserver !== "undefined") {
    const handleObserver = new MutationObserver(() => configureMobileDragHandles(listRoot));
    handleObserver.observe(listRoot, { childList: true });
    configureMobileDragHandles(listRoot);
  }

  listRoot.addEventListener(
    "touchstart",
    (e) => {
      if (e.touches.length !== 1) return;
      if (transferBlocksReorder()) return;
      const handle = e.target.closest(".recipe-drag-handle");
      if (!handle || !listRoot.contains(handle)) return;
      const item = handle.closest(".list-item[data-recipe-id]");
      if (!item || item.dataset.index === undefined) return;

      const touch = e.touches[0];
      const handleRect = handle.getBoundingClientRect();
      touchDrag = {
        item,
        touchId: touch.identifier,
        startX: touch.clientX,
        startY: touch.clientY,
        lastY: touch.clientY,
        grabOffsetX: touch.clientX - handleRect.left,
        grabOffsetY: touch.clientY - handleRect.top,
        active: false,
        ghost: null,
        dropToIndex: null,
      };
      e.stopPropagation();
      touchMoveListener = handleTouchMove;
      touchEndListener = handleTouchEnd;
      document.addEventListener("touchmove", touchMoveListener, { capture: true, passive: false });
      document.addEventListener("touchend", touchEndListener, { capture: true });
      document.addEventListener("touchcancel", touchEndListener, { capture: true });
    },
    { capture: true, passive: true }
  );
}

function attachPointerReorder(listRoot, { moveRecipe, notyf }) {
  if (isMobileReorderUi()) {
    attachMobileTouchReorder(listRoot, { moveRecipe, notyf });
    return;
  }

  let pointerDrag = null;
  let dropPending = false;
  let autoScrollRaf = null;
  let documentDragMove = null;
  let documentDragEnd = null;

  const detachDocumentDragListeners = () => {
    if (documentDragMove) {
      document.removeEventListener("pointermove", documentDragMove, true);
      documentDragMove = null;
    }
    if (documentDragEnd) {
      document.removeEventListener("pointerup", documentDragEnd, true);
      document.removeEventListener("pointercancel", documentDragEnd, true);
      documentDragEnd = null;
    }
  };

  const stopAutoScroll = () => {
    if (autoScrollRaf != null) {
      cancelAnimationFrame(autoScrollRaf);
      autoScrollRaf = null;
    }
  };

  const startAutoScroll = () => {
    stopAutoScroll();
    const tick = () => {
      if (!pointerDrag?.active) {
        stopAutoScroll();
        return;
      }
      const y = pointerDrag.lastY;
      autoScrollDuringReorder(listRoot, y);
      highlightDropTarget(listRoot, pointerDrag.item, y);
      pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, pointerDrag.item);
      autoScrollRaf = requestAnimationFrame(tick);
    };
    autoScrollRaf = requestAnimationFrame(tick);
  };

  const finishPointerDrag = () => {
    detachDocumentDragListeners();
    stopAutoScroll();
    if (!pointerDrag) return;
    pointerDrag.item.classList.remove("recipe-card-dragging");
    listRoot.classList.remove("recipe-list-reorder-active");
    removeReorderUiChrome();
    pointerDrag = null;
    clearDropTargets(listRoot);
    removeRecipeDragGhost();
  };

  const isOverTransferHoverTarget = (clientX, clientY) => {
    const el = document.elementFromPoint(clientX, clientY);
    return !!(
      el?.closest("#periodsList .list-item[data-period-id]") ||
      el?.closest("#dispensersList .list-item[data-dispenser-id]")
    );
  };

  const handlePointerMove = (e) => {
    if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
    if (transferBlocksReorder()) {
      finishPointerDrag();
      return;
    }
    if (!pointerDrag.active && isOverTransferHoverTarget(e.clientX, e.clientY)) {
      pointerDrag.lastY = e.clientY;
      return;
    }

    e.preventDefault();
    pointerDrag.lastY = e.clientY;
    const dx = Math.abs(e.clientX - pointerDrag.startX);
    const dy = Math.abs(e.clientY - pointerDrag.startY);
    if (!pointerDrag.active && dx + dy < activationThresholdPx()) return;

    if (!pointerDrag.active) {
      pointerDrag.active = true;
      pointerDrag.item.classList.add("recipe-card-dragging");
      listRoot.classList.add("recipe-list-reorder-active");
      listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true }));
      pointerDrag.ghost = mountPointerFollowGhost(
        pointerDrag.item,
        e.clientX,
        e.clientY,
        pointerDrag.grabOffsetX,
        pointerDrag.grabOffsetY
      );
      startAutoScroll();
    }

    positionPointerFollowGhost(
      pointerDrag.ghost,
      e.clientX,
      e.clientY,
      pointerDrag.grabOffsetX,
      pointerDrag.grabOffsetY
    );
    updateMobileEdgeScrollHints(e.clientY);
    highlightDropTarget(listRoot, pointerDrag.item, e.clientY);
    pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, e.clientY, pointerDrag.item);
  };

  const onPointerEnd = (e) => {
    if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;

    const { item, active, dropToIndex } = pointerDrag;
    finishPointerDrag();

    if (!active || dropPending) return;
    if (transferBlocksReorder() && slotPickActive()) return;

    const indices =
      resolveReorderDropIndex(listRoot, e, item) ||
      resolveTrackedReorderDropIndex(listRoot, item, dropToIndex);
    if (!indices) return;

    void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex);
  };

  const attachDocumentDragListeners = () => {
    if (documentDragMove) return;
    documentDragMove = handlePointerMove;
    documentDragEnd = onPointerEnd;
    document.addEventListener("pointermove", documentDragMove, { capture: true, passive: false });
    document.addEventListener("pointerup", documentDragEnd, { capture: true });
    document.addEventListener("pointercancel", documentDragEnd, { capture: true });
  };

  const cancelPointerDrag = () => {
    if (!pointerDrag) return;
    if (pointerDrag.active) return;
    finishPointerDrag();
  };

  listRoot.addEventListener("recipe-list-drag-cancel", cancelPointerDrag);

  listRoot.addEventListener("recipe-transfer-armed", () => {
    if (pointerDrag && !pointerDrag.active) {
      detachDocumentDragListeners();
      pointerDrag = null;
    } else if (pointerDrag?.active) {
      finishPointerDrag();
    }
  });

  listRoot.addEventListener(
    "pointerdown",
    (e) => {
      if (e.pointerType === "mouse" && e.button !== 0) return;
      if (transferBlocksReorder()) return;
      const handle = e.target.closest(".recipe-drag-handle");
      if (!handle || !listRoot.contains(handle)) return;
      const item = handle.closest(".list-item[data-recipe-id]");
      if (!item || item.dataset.index === undefined) return;
      e.stopPropagation();

      const handleRect = handle.getBoundingClientRect();
      pointerDrag = {
        item,
        pointerId: e.pointerId,
        startX: e.clientX,
        startY: e.clientY,
        lastY: e.clientY,
        grabOffsetX: e.clientX - handleRect.left,
        grabOffsetY: e.clientY - handleRect.top,
        active: false,
        ghost: null,
        dropToIndex: null,
      };
      attachDocumentDragListeners();
    },
    { capture: true, passive: false }
  );
}

export function attachRecipeListDragDrop(listRoot, { moveRecipe, notyf }) {
  if (!listRoot || typeof moveRecipe !== "function") return;

  let draggingItem = null;
  let dropPending = false;

  listRoot.addEventListener("dragstart", (e) => {
    if (isMobileReorderUi()) {
      e.preventDefault();
      return;
    }
    if (transferBlocksReorder()) {
      e.preventDefault();
      return;
    }
    const handle = e.target.closest(".recipe-drag-handle");
    if (!handle || !listRoot.contains(handle)) return;
    const item = handle.closest(".list-item[data-recipe-id]");
    if (!item || item.dataset.index === undefined) return;
    e.stopPropagation();
    draggingItem = item;
    item.classList.add("recipe-card-dragging");
    e.dataTransfer.effectAllowed = "move";
    e.dataTransfer.setData("text/plain", item.dataset.recipeId || "");
    mountRecipeCardDragGhost(item, e);
  });

  listRoot.addEventListener("dragend", () => {
    removeRecipeDragGhost();
    if (draggingItem) {
      draggingItem.classList.remove("recipe-card-dragging");
    }
    draggingItem = null;
    dropPending = false;
    clearDropTargets(listRoot);
  });

  listRoot.addEventListener("dragover", (e) => {
    if (!draggingItem) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = "move";

    const target = e.target.closest(".list-item[data-recipe-id]");
    clearDropTargets(listRoot);
    if (target && target !== draggingItem) {
      target.classList.add("recipe-card-drop-target");
    }
  });

  listRoot.addEventListener("dragleave", (e) => {
    if (!listRoot.contains(e.relatedTarget)) {
      clearDropTargets(listRoot);
    }
  });

  listRoot.addEventListener("drop", async (e) => {
    if (!draggingItem || dropPending) return;
    if (transferBlocksReorder() && slotPickActive()) return;
    e.preventDefault();
    e.stopPropagation();

    clearDropTargets(listRoot);

    const indices = resolveReorderDropIndex(listRoot, e, draggingItem);
    if (!indices) return;

    void moveRecipe(draggingItem.dataset.recipeId, indices.fromIndex, indices.toIndex);
  });

  const cancelHtml5Drag = () => {
    if (listRoot.classList.contains("recipe-list-reorder-active")) return;
    removeRecipeDragGhost();
    if (draggingItem) {
      draggingItem.classList.remove("recipe-card-dragging");
      draggingItem = null;
    }
    dropPending = false;
    clearDropTargets(listRoot);
  };

  listRoot.addEventListener("recipe-list-drag-cancel", cancelHtml5Drag);

  if (!listRoot.dataset.recipeDragCancelBound) {
    listRoot.dataset.recipeDragCancelBound = "1";
    document.addEventListener(
      "keydown",
      (e) => {
        if (e.key !== "Escape") return;
        cancelHtml5Drag();
        listRoot.dispatchEvent(
          new CustomEvent("recipe-list-drag-cancel", { bubbles: true })
        );
      },
      true
    );
  }

  attachPointerReorder(listRoot, { moveRecipe, notyf });
}
