import { useCallback, useRef, useState } from 'react';
import { useMutation } from 'react-query';

import { type AxiosError } from 'axios';

import useHuSnackbar from '@material-hu/components/design-system/Snackbar';

import { logEvent } from 'src/config/logging';
import { useAuth } from 'src/contexts/JWTContext';
import {
  moveShifts,
  type MoveShiftsParams,
  ShiftErrorCodes,
} from 'src/services/shifts';
import { EventName } from 'src/types/amplitude';
import { type ResponseError } from 'src/types/services';
import {
  type Shift,
  type ShiftAssignCellVC,
  type ShiftCalendarUser,
  type ShiftTimeSlot,
} from 'src/types/shifts';
import { useLokaliseTranslation } from 'src/utils/i18n';
import { isAdmin } from 'src/utils/permissions';

import { useShiftsSelectionActions } from '../contexts/ShiftsSelectionContext';
import { invalidateShiftsCalendar } from '../queries';

export type DraggingCell = {
  userId: number;
  date: string;
  shifts: Shift[];
};

export type OptimisticMove = {
  removals: Map<string, Set<number>>;
  additions: Map<string, Shift[]>;
};

type UseShiftDragDropProps = {
  registers: ShiftCalendarUser[];
};

/** Parses a YYYY-MM-DD string as UTC midnight ms to avoid local-timezone
 *  offsets when doing date arithmetic or converting back via .toISOString(). */
const toUtcMs = (d: string): number =>
  Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10));

const timeSlotsOverlap = (a: ShiftTimeSlot, b: ShiftTimeSlot): boolean =>
  a.startTime < b.endTime && a.endTime > b.startTime;

const mergeOverlay = (
  base: OptimisticMove | null,
  next: OptimisticMove,
): OptimisticMove => {
  if (!base) return next;

  // Deep-copy maps/values so we don't mutate `base`.
  const removals = new Map<string, Set<number>>();
  for (const [k, v] of base.removals) removals.set(k, new Set(v));
  const additions = new Map<string, Shift[]>();
  for (const [k, v] of base.additions) additions.set(k, [...v]);

  // A removal at cell K for shift X cancels a prior addition of X at K
  // (the shift was optimistically placed there and is now being moved out
  // again — e.g. A→B then B→A leaves the shift back where the cache has it).
  for (const [key, ids] of next.removals) {
    for (const id of ids) {
      const adds = additions.get(key);
      const idx = adds?.findIndex(s => s.shiftAssignedShiftId === id) ?? -1;
      if (adds && idx >= 0) {
        adds.splice(idx, 1);
        if (adds.length === 0) additions.delete(key);
      } else {
        const ex = removals.get(key);
        if (ex) ex.add(id);
        else removals.set(key, new Set([id]));
      }
    }
  }

  // Symmetric: an addition at K cancels a prior removal of the same shift at K
  // (the shift was optimistically removed and is now being moved back home).
  for (const [key, shifts] of next.additions) {
    for (const shift of shifts) {
      const id = shift.shiftAssignedShiftId;
      const rems = removals.get(key);
      if (rems?.has(id)) {
        rems.delete(id);
        if (rems.size === 0) removals.delete(key);
      } else {
        const ex = additions.get(key);
        if (ex) ex.push(shift);
        else additions.set(key, [shift]);
      }
    }
  }

  return { removals, additions };
};

/** Returns the shifts present in a cell after applying the optimistic overlay
 *  and excluding shifts that are currently being dragged away. */
const getEffectiveCellShifts = (
  userId: number,
  date: string,
  registers: ShiftCalendarUser[],
  removedIds: Set<number>,
  overlay?: OptimisticMove | null,
): Shift[] => {
  const key = `${userId}-${date}`;
  const user = registers.find(r => r.userId === userId);
  let shifts: Shift[] = user?.days[date]?.shifts ?? [];

  if (overlay) {
    const overlayRemovals = overlay.removals.get(key);
    const overlayAdditions = overlay.additions.get(key);
    if (overlayRemovals) {
      shifts = shifts.filter(s => !overlayRemovals.has(s.shiftAssignedShiftId));
    }
    if (overlayAdditions) {
      const existingIds = new Set(shifts.map(s => s.shiftAssignedShiftId));
      shifts = [
        ...shifts,
        ...overlayAdditions.filter(
          s => !existingIds.has(s.shiftAssignedShiftId),
        ),
      ];
    }
  }

  return shifts.filter(s => !removedIds.has(s.shiftAssignedShiftId));
};

/** Maps an array of DraggingCells to the flat moves array expected by the API. */
const cellsToMoves = (cells: DraggingCell[]): MoveShiftsParams['moves'] =>
  cells.flatMap(cell =>
    cell.shifts.map(s => ({
      shiftAssignedShiftId: s.shiftAssignedShiftId,
      targetUserId: cell.userId,
      targetDate: cell.date,
    })),
  );

/** Builds the optimistic overlay that describes a pending move operation. */
const buildMoveOverlay = (
  draggingCells: DraggingCell[],
  targetCells: DraggingCell[],
  registers: ShiftCalendarUser[],
): OptimisticMove => {
  const removals = new Map<string, Set<number>>();
  for (const cell of draggingCells) {
    removals.set(
      `${cell.userId}-${cell.date}`,
      new Set(cell.shifts.map(s => s.shiftAssignedShiftId)),
    );
  }

  // The /shifts/calendar response doesn't populate `isRest` on Shift, so the
  // target cell can't tell a moved rest shift apart from an integration shift
  // (both have empty timeSlots). Carry the rest flag via the source cell's
  // scheduledData → target cell during the optimistic window.
  const restShiftIds = new Set<number>();
  for (const cell of draggingCells) {
    const day = registers.find(r => r.userId === cell.userId)?.days[cell.date];
    const sourceIsDayOff =
      !!day &&
      !day.scheduledData.isWorkday &&
      day.scheduledData.scheduleSource === 'SHIFT';
    if (sourceIsDayOff) {
      for (const s of cell.shifts) restShiftIds.add(s.shiftAssignedShiftId);
    }
  }

  const additions = new Map<string, Shift[]>();
  for (const cell of targetCells) {
    additions.set(
      `${cell.userId}-${cell.date}`,
      cell.shifts.map(s =>
        restShiftIds.has(s.shiftAssignedShiftId) ? { ...s, isRest: true } : s,
      ),
    );
  }
  return { removals, additions };
};

/** Returns true if any time slot of `a` overlaps with any time slot of `b`. */
const shiftsConflict = (a: Shift, b: Shift): boolean =>
  a.timeSlots.some(slotA =>
    b.timeSlots.some(slotB => timeSlotsOverlap(slotA, slotB)),
  );

/** Computes where each dragging cell will land when dropped on `dropCell`.
 *  - Horizontal: each cell's date shifts by (dropDate − anchorDate).
 *  - Vertical: each cell's row index shifts by (dropRow − anchorRow).
 *  Cells that would land outside the visible register range are omitted. */
const computeTargetCells = (
  draggingCells: DraggingCell[],
  anchorCell: ShiftAssignCellVC,
  dropCell: ShiftAssignCellVC,
  allRegisters: ShiftCalendarUser[],
): DraggingCell[] => {
  const DAY_MS = 1000 * 60 * 60 * 24;
  const dateDeltaMs = toUtcMs(dropCell.date) - toUtcMs(anchorCell.date);

  const anchorRowIndex = allRegisters.findIndex(
    r => r.userId === anchorCell.userId,
  );
  const dropRowIndex = allRegisters.findIndex(
    r => r.userId === dropCell.userId,
  );
  const rowOffset =
    anchorRowIndex !== -1 && dropRowIndex !== -1
      ? dropRowIndex - anchorRowIndex
      : 0;

  const result: DraggingCell[] = [];
  for (const cell of draggingCells) {
    const sourceRowIndex = allRegisters.findIndex(
      r => r.userId === cell.userId,
    );
    const targetRowIndex =
      sourceRowIndex !== -1 ? sourceRowIndex + rowOffset : -1;
    const targetUser = allRegisters[targetRowIndex];

    if (!targetUser) continue; // would land outside the visible register

    const targetDateMs = toUtcMs(cell.date) + dateDeltaMs;
    // Ensure we land exactly on midnight UTC (avoids DST edge-cases)
    const targetDate = new Date(Math.round(targetDateMs / DAY_MS) * DAY_MS)
      .toISOString()
      .split('T')[0];

    result.push({
      userId: targetUser.userId,
      date: targetDate,
      shifts: cell.shifts,
    });
  }
  return result;
};

const hasTimeConflict = (
  draggingCells: DraggingCell[],
  targetCells: DraggingCell[],
  registers: ShiftCalendarUser[],
  overlay?: OptimisticMove | null,
): boolean => {
  const removedIds = new Set(
    draggingCells.flatMap(c => c.shifts.map(s => s.shiftAssignedShiftId)),
  );

  return targetCells.some(targetCell => {
    const existingShifts = getEffectiveCellShifts(
      targetCell.userId,
      targetCell.date,
      registers,
      removedIds,
      overlay,
    );
    return targetCell.shifts.some(dragged =>
      existingShifts.some(existing => shiftsConflict(dragged, existing)),
    );
  });
};

export const useShiftDragDrop = ({ registers }: UseShiftDragDropProps) => {
  const { t } = useLokaliseTranslation('shifts');
  const { enqueueSnackbar } = useHuSnackbar();
  const { permissions } = useAuth();

  const { selectedShifts, selectedCells, clearAllSelections } =
    useShiftsSelectionActions();

  const draggingInfoRef = useRef<{
    anchorCell: ShiftAssignCellVC;
    draggingCells: DraggingCell[];
  } | null>(null);

  const inFlightMutationsRef = useRef(0);

  const currentOverlayRef = useRef<OptimisticMove | null>(null);

  const dragStartOffsetRef = useRef<{
    offsetY: number;
    elementHeight: number;
  } | null>(null);
  const lastDragOverCellRef = useRef<ShiftAssignCellVC | null>(null);
  const dragTrackingRef = useRef<{
    originDate: string;
    originUserId: number;
  } | null>(null);

  const [dragOverCell, setDragOverCell] = useState<ShiftAssignCellVC | null>(
    null,
  );
  const [draggingShiftIds, setDraggingShiftIds] = useState<Set<number>>(
    new Set(),
  );
  const [optimisticMove, setOptimisticMove] = useState<OptimisticMove | null>(
    null,
  );
  const [dragPreviewMap, setDragPreviewMap] = useState<Map<string, number>>(
    new Map(),
  );

  const updateOverlay = useCallback((overlay: OptimisticMove | null) => {
    currentOverlayRef.current = overlay;
    setOptimisticMove(overlay);
  }, []);

  // ─── Mutation ────────────────────────────────────────────────────────────────

  const { mutate: executeMoveShifts } = useMutation({
    mutationFn: (params: MoveShiftsParams) => moveShifts(params),
  });

  const handleMutationError = useCallback(
    (error: AxiosError<ResponseError>, shiftCount: number) => {
      inFlightMutationsRef.current--;
      if (inFlightMutationsRef.current === 0) {
        invalidateShiftsCalendar();
        updateOverlay(null);
      }
      if (
        error.response?.data.code ===
          ShiftErrorCodes.SHIFT_MANAGEMENT_REST_SHIFT_OVERLAP ||
        error.response?.data.code ===
          ShiftErrorCodes.SHIFT_MANAGEMENT_INVALID_TIME_SLOTS
      ) {
        enqueueSnackbar({
          title: t('shifts:actions.paste.error_conflict'),
          description: t('shifts:actions.paste.error_conflict_description'),
          variant: 'error',
          hasClose: true,
        });
        return;
      }
      const errorKey =
        shiftCount === 1
          ? 'shifts:actions.drag_and_drop.error_single'
          : 'shifts:actions.drag_and_drop.error_multiple';
      enqueueSnackbar({
        title: t(errorKey),
        description: t('shifts:actions.drag_and_drop.error_description'),
        variant: 'error',
        hasClose: true,
      });
    },
    [enqueueSnackbar, t, updateOverlay],
  );

  const runMove = useCallback(
    (payload: MoveShiftsParams, onSuccess?: () => void) => {
      const shiftCount = payload.moves.length;
      inFlightMutationsRef.current++;
      executeMoveShifts(payload, {
        onSuccess: async () => {
          inFlightMutationsRef.current--;
          onSuccess?.();
          if (inFlightMutationsRef.current === 0) {
            // Wait briefly before refreshing so the server has time to finish
            // propagating the write — otherwise the GET can race the write and
            // return stale data, making the moved shift disappear momentarily.
            await new Promise<void>(resolve => setTimeout(resolve, 500));
            if (inFlightMutationsRef.current === 0) {
              await invalidateShiftsCalendar();
              updateOverlay(null);
            }
          }
        },
        onError: error =>
          handleMutationError(error as AxiosError<ResponseError>, shiftCount),
      });
    },
    [executeMoveShifts, handleMutationError, updateOverlay],
  );

  // ─── Build helpers ───────────────────────────────────────────────────────────

  const buildDraggingCells = useCallback(
    (shiftId: number, anchorCell: ShiftAssignCellVC): DraggingCell[] => {
      const isInSelection = selectedShifts.has(shiftId);

      if (isInSelection && selectedShifts.size > 0) {
        // Key: `${userId}-${date}` — supports multi-user selections
        const byUserDate = new Map<
          string,
          { userId: number; date: string; shiftIds: number[] }
        >();

        for (const [sid, sel] of selectedShifts.entries()) {
          const key = `${sel.userId}-${sel.date}`;
          const existing = byUserDate.get(key);
          if (existing) {
            existing.shiftIds.push(sid);
          } else {
            byUserDate.set(key, {
              userId: sel.userId,
              date: sel.date,
              shiftIds: [sid],
            });
          }
        }

        for (const cell of selectedCells) {
          const key = `${cell.userId}-${cell.date}`;
          if (!byUserDate.has(key)) {
            const cellShifts = getEffectiveCellShifts(
              cell.userId,
              cell.date,
              registers,
              new Set(),
              currentOverlayRef.current,
            );
            byUserDate.set(key, {
              userId: cell.userId,
              date: cell.date,
              shiftIds: cellShifts.map(s => s.shiftAssignedShiftId),
            });
          }
        }

        const result: DraggingCell[] = [];
        for (const [, entry] of byUserDate.entries()) {
          const shifts = getEffectiveCellShifts(
            entry.userId,
            entry.date,
            registers,
            new Set(),
            currentOverlayRef.current,
          ).filter(s => entry.shiftIds.includes(s.shiftAssignedShiftId));
          if (shifts.length > 0) {
            result.push({ userId: entry.userId, date: entry.date, shifts });
          }
        }
        return result;
      }

      // Single shift (not in selection): just the one shift at the anchor cell
      const shifts = getEffectiveCellShifts(
        anchorCell.userId,
        anchorCell.date,
        registers,
        new Set(),
        currentOverlayRef.current,
      ).filter(s => s.shiftAssignedShiftId === shiftId);
      return [{ userId: anchorCell.userId, date: anchorCell.date, shifts }];
    },
    [selectedShifts, selectedCells, registers],
  );

  // ─── Drag handlers ───────────────────────────────────────────────────────────

  const handleDragStart = useCallback(
    (
      e: React.DragEvent<HTMLElement>,
      shiftId: number,
      cellData: ShiftAssignCellVC,
    ) => {
      e.dataTransfer.setData('text/plain', '');
      e.dataTransfer.effectAllowed = 'move';

      dragTrackingRef.current = {
        originDate: cellData.date,
        originUserId: cellData.userId,
      };

      const draggingCells = buildDraggingCells(shiftId, cellData);
      draggingInfoRef.current = { anchorCell: cellData, draggingCells };

      // Flat sorted list used to derive unique users/dates for the ghost grid.
      const sortedCells = [...draggingCells].sort((a, b) =>
        a.date.localeCompare(b.date),
      );

      const ids = new Set(
        draggingCells.flatMap(c => c.shifts.map(s => s.shiftAssignedShiftId)),
      );
      setDraggingShiftIds(ids);

      const rect = e.currentTarget.getBoundingClientRect();
      dragStartOffsetRef.current = {
        offsetY: e.clientY - rect.top,
        elementHeight: rect.height,
      };

      const cardWidth = rect.width;
      const cardHeight = rect.height;
      const DAY_MS = 1000 * 60 * 60 * 24;
      const SLOT_WIDTH = cardWidth + 8;
      const CARD_GAP = 4; // horizontal gap between non-consecutive date cards
      // DateCell uses py: 1.5 (12px top + 12px bottom) → 24px between adjacent row cards
      const ROW_GAP = 24; // vertical gap between user rows

      // Live DOM lookup so each clone reflects its own shift's content (color,
      // name, timeSlots), not just the grabbed shift. data-shift-id is set on
      // each shift Stack in DateCell.
      const findShiftEl = (id: number): HTMLElement | null =>
        document.querySelector(`[data-shift-id="${id}"]`);

      // Clones a single shift card into `container`, mirroring its live look
      // (border color, sizing) while stripping the menu button and pointer
      // events. Falls back to the grabbed element when the source is missing.
      const cloneShiftCard = (
        container: HTMLElement,
        shift: Shift | undefined,
      ) => {
        const sourceEl = shift ? findShiftEl(shift.shiftAssignedShiftId) : null;
        const cloneSrc = sourceEl ?? e.currentTarget;

        // Capture border color from the live source (clones aren't in the DOM
        // yet, so getComputedStyle on them would return an empty string).
        const sourcePaper = cloneSrc.querySelector(
          '.MuiPaper-root',
        ) as HTMLElement | null;
        const borderColor = sourcePaper
          ? window.getComputedStyle(sourcePaper).borderColor
          : 'currentColor';

        const clone = cloneSrc.cloneNode(true) as HTMLElement;
        clone.querySelector('.MuiIconButton-root')?.remove();
        clone.style.pointerEvents = 'none';
        clone.style.width = `${cardWidth}px`;
        clone.style.flexShrink = '0';

        const clonePaper = clone.querySelector(
          '.MuiPaper-root',
        ) as HTMLElement | null;
        if (clonePaper) {
          clonePaper.style.borderColor = 'transparent';
          clonePaper.style.outline = `2px dashed ${borderColor}`;
          clonePaper.style.outlineOffset = '-1px';
        }

        container.appendChild(clone);
      };

      // Global grid origin: earliest date across all dragging cells. Without
      // this, each user row would start at column 0 independently, so cells on
      // different dates (e.g. A/Mon + B/Tue) would stack at the same X instead
      // of preserving their relative positions.
      const earliestMs = toUtcMs(sortedCells.map(c => c.date).sort()[0]);

      // Ghost is a column of rows — one row per user, preserving register order.
      // Vertical spacers between rows mirror empty rows; horizontal spacers
      // (both leading-per-row and inter-cell) mirror empty date columns.
      const ghost = document.createElement('div');
      ghost.style.cssText = [
        'position:fixed',
        'top:-9999px',
        'left:-9999px',
        'pointer-events:none',
        'display:flex',
        'flex-direction:column',
      ].join(';');

      // Build user order from registers so rows match the calendar layout.
      const userIdsInOrder = [...new Set(sortedCells.map(c => c.userId))].sort(
        (a, b) =>
          registers.findIndex(r => r.userId === a) -
          registers.findIndex(r => r.userId === b),
      );

      // Track the Y-top of each user's row to compute cursorY accurately.
      let currentRowTopY = 0;
      let anchorRowTopY = 0;

      for (let i = 0; i < userIdsInOrder.length; i++) {
        const userId = userIdsInOrder[i];

        if (i > 0) {
          // Vertical spacer between rows: mirrors the horizontal date-gap logic.
          // rowsBetween = 1 → adjacent rows → just ROW_GAP.
          // rowsBetween = 2 → one empty row between them → ROW_GAP + cardHeight + ROW_GAP, etc.
          const prevUserId = userIdsInOrder[i - 1];
          const prevRegIdx = registers.findIndex(r => r.userId === prevUserId);
          const currRegIdx = registers.findIndex(r => r.userId === userId);
          const rowsBetween = currRegIdx - prevRegIdx; // ≥ 1
          const spacerHeight =
            rowsBetween * ROW_GAP + (rowsBetween - 1) * cardHeight;

          const vSpacer = document.createElement('div');
          vSpacer.style.cssText = `height:${spacerHeight}px;flex-shrink:0;`;
          ghost.appendChild(vSpacer);

          currentRowTopY += cardHeight + spacerHeight;
        }

        if (userId === cellData.userId) anchorRowTopY = currentRowTopY;

        const userCells = sortedCells
          .filter(c => c.userId === userId)
          .sort((a, b) => a.date.localeCompare(b.date));

        const row = document.createElement('div');
        row.style.cssText = [
          'display:flex',
          'flex-direction:row',
          'align-items:flex-start',
        ].join(';');

        // Leading spacer per row to align this user's first cell with the
        // global earliest date — keeps columns coherent across user rows.
        const leadingDays = Math.round(
          (toUtcMs(userCells[0].date) - earliestMs) / DAY_MS,
        );
        if (leadingDays > 0) {
          const leadingSpacer = document.createElement('div');
          leadingSpacer.style.cssText = `width:${leadingDays * SLOT_WIDTH}px;flex-shrink:0;`;
          row.appendChild(leadingSpacer);
        }

        userCells.forEach((cell, idx) => {
          if (idx > 0) {
            const prevMs = toUtcMs(userCells[idx - 1].date);
            const cellMs = toUtcMs(cell.date);
            const daysBetween = Math.round((cellMs - prevMs) / DAY_MS);
            const spacerWidth = Math.max(
              0,
              CARD_GAP + (daysBetween - 1) * SLOT_WIDTH,
            );
            const spacer = document.createElement('div');
            spacer.style.cssText = `width:${spacerWidth}px;flex-shrink:0;`;
            row.appendChild(spacer);
          }

          // A cell can hold several shifts stacked vertically; DateCell wraps
          // them in a Stack with gap: 1 (8px). Mirror that column so the ghost
          // shows every dragged shift in the cell, not just the first.
          const cellColumn = document.createElement('div');
          cellColumn.style.cssText = [
            'display:flex',
            'flex-direction:column',
            'gap:8px',
            'flex-shrink:0',
            `width:${cardWidth}px`,
          ].join(';');

          if (cell.shifts.length) {
            cell.shifts.forEach(shift => cloneShiftCard(cellColumn, shift));
          } else {
            cloneShiftCard(cellColumn, undefined);
          }

          row.appendChild(cellColumn);
        });

        ghost.appendChild(row);
      }

      // cursorX: anchor cell's column in the global grid (so the cursor lands
      // inside the grabbed card regardless of which user row it's in).
      const grabbedDayOffset = Math.round(
        (toUtcMs(cellData.date) - earliestMs) / DAY_MS,
      );
      const cursorX = grabbedDayOffset * SLOT_WIDTH + 20;

      // cursorY: top of the anchor user's row + offset within that row.
      const cursorY = anchorRowTopY + dragStartOffsetRef.current.offsetY;

      document.body.appendChild(ghost);
      e.dataTransfer.setDragImage(ghost, cursorX, cursorY);
      setTimeout(() => {
        if (document.body.contains(ghost)) document.body.removeChild(ghost);
      }, 0);
    },
    [buildDraggingCells, registers],
  );

  const handleDragOver = useCallback(
    (e: React.DragEvent<HTMLElement>, cellData: ShiftAssignCellVC) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';

      const rect = e.currentTarget.getBoundingClientRect();
      const offset = dragStartOffsetRef.current;

      let showIndicator: boolean;
      if (offset) {
        const draggedCenterY =
          e.clientY - offset.offsetY + offset.elementHeight / 2;
        showIndicator =
          draggedCenterY >= rect.top && draggedCenterY <= rect.bottom;
      } else {
        showIndicator = e.clientY >= rect.top + rect.height / 2;
      }

      // Don't show any preview when hovering over the origin cell
      const isOriginCell =
        draggingInfoRef.current?.anchorCell.userId === cellData.userId &&
        draggingInfoRef.current?.anchorCell.date === cellData.date;

      if (showIndicator && !isOriginCell) {
        const cellChanged =
          lastDragOverCellRef.current?.userId !== cellData.userId ||
          lastDragOverCellRef.current?.date !== cellData.date;

        if (cellChanged) {
          lastDragOverCellRef.current = cellData;
          setDragOverCell(cellData);

          if (draggingInfoRef.current) {
            const { anchorCell, draggingCells } = draggingInfoRef.current;
            const targetCells = computeTargetCells(
              draggingCells,
              anchorCell,
              cellData,
              registers,
            );
            const newMap = new Map<string, number>();
            for (const tc of targetCells) {
              newMap.set(`${tc.userId}-${tc.date}`, tc.shifts.length);
            }
            setDragPreviewMap(newMap);
          }
        }
      } else {
        if (lastDragOverCellRef.current !== null) {
          lastDragOverCellRef.current = null;
          setDragOverCell(null);
          setDragPreviewMap(new Map());
        }
      }
    },
    [registers],
  );

  const handleDragLeave = useCallback((e: React.DragEvent<HTMLElement>) => {
    if (!e.currentTarget.contains(e.relatedTarget as Node)) {
      setDragOverCell(null);
      setDragPreviewMap(new Map());
    }
  }, []);

  const handleDrop = useCallback(
    (e: React.DragEvent<HTMLElement>, dropCell: ShiftAssignCellVC) => {
      e.preventDefault();
      setDragOverCell(null);

      if (!draggingInfoRef.current) return;

      const { anchorCell, draggingCells } = draggingInfoRef.current;

      // Drop on the origin cell → no-op
      if (
        dropCell.userId === anchorCell.userId &&
        dropCell.date === anchorCell.date
      ) {
        draggingInfoRef.current = null;
        setDraggingShiftIds(new Set());
        setDragPreviewMap(new Map());
        return;
      }

      const targetCells = computeTargetCells(
        draggingCells,
        anchorCell,
        dropCell,
        registers,
      );

      if (
        hasTimeConflict(
          draggingCells,
          targetCells,
          registers,
          currentOverlayRef.current,
        )
      ) {
        enqueueSnackbar({
          title: t('shifts:actions.paste.error_conflict'),
          description: t('shifts:actions.paste.error_conflict_description'),
          variant: 'error',
          hasClose: true,
        });
        draggingInfoRef.current = null;
        setDraggingShiftIds(new Set());
        return;
      }

      // Fire the tracking event here instead of handleDragEnd because the
      // optimistic UI update unmounts the source element before dragend fires.
      if (dragTrackingRef.current) {
        const { originDate, originUserId } = dragTrackingRef.current;
        logEvent(EventName.SHIFT_MANAGEMENT_SHIFT_DRAG, {
          user_admin: isAdmin(permissions),
          shift_dropped: true,
          changed_date: dropCell.date !== originDate,
          changed_employee: dropCell.userId !== originUserId,
        });
        dragTrackingRef.current = null;
      }

      updateOverlay(
        mergeOverlay(
          currentOverlayRef.current,
          buildMoveOverlay(draggingCells, targetCells, registers),
        ),
      );

      const movePayload: MoveShiftsParams = {
        moves: cellsToMoves(targetCells),
      };

      // Undo payload: move each shift back to its original cell
      const undoPayload: MoveShiftsParams = {
        moves: cellsToMoves(draggingCells),
      };

      setDraggingShiftIds(new Set());
      setDragPreviewMap(new Map());
      lastDragOverCellRef.current = null;
      clearAllSelections();
      draggingInfoRef.current = null;

      runMove(movePayload, () => {
        enqueueSnackbar({
          title: t('shifts:actions.drag_and_drop.success'),
          description: t('shifts:actions.drag_and_drop.success_description'),
          variant: 'success',
          autoHideDuration: 5000,
          cancelAction: {
            text: t('general:undo'),
            onClick: () => {
              runMove(undoPayload);
            },
          },
        });
      });
    },
    [
      enqueueSnackbar,
      clearAllSelections,
      t,
      registers,
      runMove,
      updateOverlay,
      permissions,
    ],
  );

  const handleDragEnd = useCallback(() => {
    // dragTrackingRef is nulled in handleDrop when the shift is successfully
    // moved to a different cell (event already fired there). It is non-null
    // only when the drag was cancelled or dropped back on the origin cell.
    const tracking = dragTrackingRef.current;
    if (tracking) {
      logEvent(EventName.SHIFT_MANAGEMENT_SHIFT_DRAG, {
        user_admin: isAdmin(permissions),
        shift_dropped: false,
        changed_date: false,
        changed_employee: false,
      });
      dragTrackingRef.current = null;
    }

    draggingInfoRef.current = null;
    dragStartOffsetRef.current = null;
    lastDragOverCellRef.current = null;
    setDragOverCell(null);
    setDraggingShiftIds(new Set());
    setDragPreviewMap(new Map());
  }, [permissions]);

  return {
    handleDragStart,
    handleDragOver,
    handleDragLeave,
    handleDrop,
    handleDragEnd,
    dragOverCell,
    draggingShiftIds,
    optimisticMove,
    dragPreviewMap,
  };
};
