"use client";

import { useCallback, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";

// ─── Project-shift drag state ───────────────────────────────────────────────

export interface DragState {
  isDragging: boolean;
  projectId: string | null;
  projectName: string | null;
  allocationId: string | null;
  originalStartDate: Date | null;
  originalEndDate: Date | null;
  currentStartDate: Date | null;
  currentEndDate: Date | null;
  startMouseX: number;
  originalLeft: number;
  blockWidth: number;
  daysDelta: number;
}

export interface BlockClickInfo {
  allocationId: string;
  projectId: string;
  projectName: string;
  startDate: Date;
  endDate: Date;
}

export interface ShiftPreviewData {
  valid: boolean;
  deltaCents: number;
  wouldExceedBudget: boolean;
  budgetUtilizationAfter: number;
  conflictCount: number;
  errors: string[];
  warnings: string[];
}

const INITIAL_DRAG_STATE: DragState = {
  isDragging: false,
  projectId: null,
  projectName: null,
  allocationId: null,
  originalStartDate: null,
  originalEndDate: null,
  currentStartDate: null,
  currentEndDate: null,
  startMouseX: 0,
  originalLeft: 0,
  blockWidth: 0,
  daysDelta: 0,
};

// ─── Per-allocation drag state ──────────────────────────────────────────────

export type AllocDragMode = "move" | "resize-start" | "resize-end";

export interface AllocDragState {
  isActive: boolean;
  mode: AllocDragMode;
  allocationId: string | null;
  mutationAllocationId: string | null;
  projectId: string | null;
  projectName: string | null;
  resourceId: string | null;
  originalStartDate: Date | null;
  originalEndDate: Date | null;
  currentStartDate: Date | null;
  currentEndDate: Date | null;
  startMouseX: number;
  daysDelta: number;
}

const INITIAL_ALLOC_DRAG: AllocDragState = {
  isActive: false,
  mode: "move",
  allocationId: null,
  mutationAllocationId: null,
  projectId: null,
  projectName: null,
  resourceId: null,
  originalStartDate: null,
  originalEndDate: null,
  currentStartDate: null,
  currentEndDate: null,
  startMouseX: 0,
  daysDelta: 0,
};

// ─── Range-select state ─────────────────────────────────────────────────────

export interface RangeState {
  isSelecting: boolean;
  resourceId: string | null;
  startDate: Date | null;
  currentDate: Date | null;
  suggestedProjectId: string | null;
  startClientX: number;
}

export interface RangeSelectedInfo {
  resourceId: string;
  startDate: Date;
  endDate: Date;
  suggestedProjectId: string | null;
  anchorX: number;
  anchorY: number;
}

const INITIAL_RANGE_STATE: RangeState = {
  isSelecting: false,
  resourceId: null,
  startDate: null,
  currentDate: null,
  suggestedProjectId: null,
  startClientX: 0,
};

// ─── Multi-select state ────────────────────────────────────────────────────

export interface MultiSelectState {
  isSelecting: boolean;
  startX: number;
  startY: number;
  currentX: number;
  currentY: number;
  selectedAllocationIds: string[];
  selectedResourceIds: string[];
  dateRange: { start: Date; end: Date } | null;
  /** When multi-dragging, the number of days all selected allocations are shifted */
  multiDragDaysDelta: number;
  /** Whether a multi-drag is currently in progress */
  isMultiDragging: boolean;
  /** The drag mode during multi-drag (move, resize-start, resize-end) */
  multiDragMode: AllocDragMode;
}

const INITIAL_MULTI_SELECT: MultiSelectState = {
  isSelecting: false,
  startX: 0,
  startY: 0,
  currentX: 0,
  currentY: 0,
  selectedAllocationIds: [],
  selectedResourceIds: [],
  dateRange: null,
  multiDragDaysDelta: 0,
  isMultiDragging: false,
  multiDragMode: "move",
};

// ─── Hook ───────────────────────────────────────────────────────────────────

export interface AllocationMovedSnapshot {
  allocationId: string;
  mutationAllocationId: string;
  projectName: string;
  before: { startDate: Date; endDate: Date };
  after: { startDate: Date; endDate: Date };
}

export function useTimelineDrag({
  cellWidth,
  onShiftApplied,
  onBlockClick,
  onRangeSelected,
  onAllocationMoved,
  onShiftClickAlloc,
  onMultiDragComplete,
}: {
  cellWidth: number;
  onShiftApplied?: (projectId: string) => void;
  onBlockClick?: (info: BlockClickInfo) => void;
  onRangeSelected?: (info: RangeSelectedInfo) => void;
  onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
  onShiftClickAlloc?: (allocationId: string) => void;
  onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode, selectedIds?: string[]) => void;
}) {
  const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
  const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
  const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
  const [multiSelectState, setMultiSelectState] = useState<MultiSelectState>(INITIAL_MULTI_SELECT);

  const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
  const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
  const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
  const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
  // Keep ref in sync with state so document-level handlers read the latest selection
  multiSelectRef.current = multiSelectState;

  // Keep always-current refs for values used inside document event handlers
  const cellWidthRef = useRef(cellWidth);
  cellWidthRef.current = cellWidth;

  // Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
  const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({
    x: 0,
    y: 0,
    decided: false,
  });

  const onBlockClickRef = useRef(onBlockClick);
  onBlockClickRef.current = onBlockClick;

  const onAllocationMovedRef = useRef(onAllocationMoved);
  onAllocationMovedRef.current = onAllocationMoved;

  const onShiftClickAllocRef = useRef(onShiftClickAlloc);
  onShiftClickAllocRef.current = onShiftClickAlloc;

  const onMultiDragCompleteRef = useRef(onMultiDragComplete);
  onMultiDragCompleteRef.current = onMultiDragComplete;

  const utils = trpc.useUtils();
  const invalidateTimeline = useInvalidateTimeline();

  // Project-shift preview
  const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
    {
      projectId: dragState.projectId ?? "",
      newStartDate: dragState.currentStartDate ?? new Date(),
      newEndDate: dragState.currentEndDate ?? new Date(),
    },
    {
      enabled:
        dragState.isDragging &&
        dragState.projectId !== null &&
        dragState.daysDelta !== 0 &&
        dragState.currentStartDate !== null,
      staleTime: 0,
    },
  );

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
    onSuccess: (data: { project: { id: string } }) => {
      invalidateTimeline();
      void utils.project.list.invalidate();
      onShiftApplied?.(data.project.id);
    },
  }) as {
    isPending: boolean;
    mutate: (...args: unknown[]) => void;
    mutateAsync: (...args: unknown[]) => Promise<unknown>;
  };

  const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);

  const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
    onSuccess: () => {
      invalidateTimeline();
      const snap = pendingSnapshotRef.current;
      if (snap) {
        onAllocationMovedRef.current?.(snap);
        pendingSnapshotRef.current = null;
      }
    },
  });

  // ── Project-bar drag (shifts all allocations) ──────────────────────────────

  const onProjectBarMouseDown = useCallback(
    (
      e: React.MouseEvent,
      opts: {
        projectId: string;
        projectName: string;
        startDate: Date;
        endDate: Date;
      },
    ) => {
      if (e.button !== 0) return;
      e.preventDefault();
      e.stopPropagation();
      const state: DragState = {
        isDragging: true,
        projectId: opts.projectId,
        projectName: opts.projectName,
        allocationId: null,
        originalStartDate: opts.startDate,
        originalEndDate: opts.endDate,
        currentStartDate: opts.startDate,
        currentEndDate: opts.endDate,
        startMouseX: e.clientX,
        originalLeft: 0,
        blockWidth: 0,
        daysDelta: 0,
      };
      dragStateRef.current = state;
      setDragState(state);
    },
    [],
  );

  // Legacy — kept for backward compat (triggers project shift from allocation block)
  const onBlockMouseDown = useCallback(
    (
      e: React.MouseEvent,
      opts: {
        projectId: string;
        projectName: string;
        allocationId?: string;
        startDate: Date;
        endDate: Date;
        blockLeft: number;
        blockWidth: number;
      },
    ) => {
      if (e.button !== 0) return;
      e.preventDefault();
      e.stopPropagation();
      const state: DragState = {
        isDragging: true,
        projectId: opts.projectId,
        projectName: opts.projectName,
        allocationId: opts.allocationId ?? null,
        originalStartDate: opts.startDate,
        originalEndDate: opts.endDate,
        currentStartDate: opts.startDate,
        currentEndDate: opts.endDate,
        startMouseX: e.clientX,
        originalLeft: opts.blockLeft,
        blockWidth: opts.blockWidth,
        daysDelta: 0,
      };
      dragStateRef.current = state;
      setDragState(state);
    },
    [],
  );

  // ── Per-allocation drag — document-level listeners ─────────────────────────
  //
  // Uses document.addEventListener instead of React canvas events so the drag
  // works reliably even when the cursor leaves the canvas boundary (e.g. while
  // moving quickly or scrolling into the sticky header area).

  const onAllocMouseDown = useCallback(
    (
      e: React.MouseEvent,
      opts: {
        mode: AllocDragMode;
        allocationId: string;
        mutationAllocationId?: string;
        projectId: string;
        projectName: string;
        resourceId: string | null;
        startDate: Date;
        endDate: Date;
      },
    ) => {
      if (e.button !== 0) return;
      e.preventDefault();
      e.stopPropagation();

      const wasShift = e.shiftKey;

      // Check if this allocation is part of a multi-selection → multi-drag mode
      const ms = multiSelectRef.current;
      const isMultiSelected =
        ms.selectedAllocationIds.length > 1 &&
        ms.selectedAllocationIds.includes(opts.allocationId);

      if (isMultiSelected) {
        // ── Multi-drag: move/resize all selected allocations together ──
        const startMouseX = e.clientX;
        let currentDaysDelta = 0;
        const dragMode = opts.mode;

        setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
        multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };

        function handleMultiMove(ev: MouseEvent) {
          const deltaX = ev.clientX - startMouseX;
          const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
          if (daysDelta === currentDaysDelta) return;
          currentDaysDelta = daysDelta;

          setMultiSelectState((prev) => ({ ...prev, multiDragDaysDelta: daysDelta }));
          multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
        }

        function handleMultiUp() {
          document.removeEventListener("mousemove", handleMultiMove);
          document.removeEventListener("mouseup", handleMultiUp);

          const finalDelta = currentDaysDelta;

          setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
          multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };

          if (finalDelta !== 0) {
            // Pass IDs from ref to avoid stale closure in the callback
            const ids = multiSelectRef.current.selectedAllocationIds;
            onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids);
          }
        }

        document.addEventListener("mousemove", handleMultiMove);
        document.addEventListener("mouseup", handleMultiUp);
        return;
      }

      // ── Single allocation drag ────────────────────────────────────────────

      const initial: AllocDragState = {
        isActive: true,
        mode: opts.mode,
        allocationId: opts.allocationId,
        mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId,
        projectId: opts.projectId,
        projectName: opts.projectName,
        resourceId: opts.resourceId,
        originalStartDate: opts.startDate,
        originalEndDate: opts.endDate,
        currentStartDate: opts.startDate,
        currentEndDate: opts.endDate,
        startMouseX: e.clientX,
        daysDelta: 0,
      };
      allocDragRef.current = initial;
      setAllocDragState(initial);

      // ── document handlers ────────────────────────────────────────────────

      function handleMove(ev: MouseEvent) {
        const alloc = allocDragRef.current;
        if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;

        const deltaX = ev.clientX - alloc.startMouseX;
        const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
        if (daysDelta === alloc.daysDelta) return;

        const { start: newStart, end: newEnd } = computeDragDates(
          alloc.mode,
          alloc.originalStartDate,
          alloc.originalEndDate,
          daysDelta,
        );

        const updated: AllocDragState = {
          ...alloc,
          currentStartDate: newStart,
          currentEndDate: newEnd,
          daysDelta,
        };
        allocDragRef.current = updated;
        setAllocDragState(updated);
      }

      function handleUp() {
        document.removeEventListener("mousemove", handleMove);
        document.removeEventListener("mouseup", handleUp);

        const alloc = allocDragRef.current;
        if (!alloc.isActive) return;

        if (alloc.daysDelta === 0 && alloc.allocationId) {
          // No movement → treat as click
          if (wasShift) {
            // Shift+Click → toggle multi-selection for this allocation
            onShiftClickAllocRef.current?.(alloc.allocationId);
          } else {
            // Normal click → open alloc popover
            onBlockClickRef.current?.({
              allocationId: alloc.allocationId,
              projectId: alloc.projectId ?? "",
              projectName: alloc.projectName ?? "",
              startDate: alloc.originalStartDate!,
              endDate: alloc.originalEndDate!,
            });
          }
        } else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
          pendingSnapshotRef.current = {
            allocationId: alloc.allocationId,
            mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
            projectName: alloc.projectName ?? "",
            before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! },
            after: { startDate: alloc.currentStartDate, endDate: alloc.currentEndDate },
          };
          updateAllocMutation.mutate({
            allocationId: alloc.mutationAllocationId ?? alloc.allocationId,
            startDate: alloc.currentStartDate,
            endDate: alloc.currentEndDate,
          });
        }

        allocDragRef.current = INITIAL_ALLOC_DRAG;
        setAllocDragState(INITIAL_ALLOC_DRAG);
      }

      document.addEventListener("mousemove", handleMove);
      document.addEventListener("mouseup", handleUp);
    },
    [updateAllocMutation.mutate], // mutate is stable across renders (React Query guarantee)
  );

  // ── Range-select ────────────────────────────────────────────────────────────

  const onRowMouseDown = useCallback(
    (
      e: React.MouseEvent,
      opts: {
        resourceId: string;
        startDate: Date;
        suggestedProjectId?: string;
      },
    ) => {
      if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return;
      if (e.button !== 0) return;
      e.preventDefault();
      const state: RangeState = {
        isSelecting: true,
        resourceId: opts.resourceId,
        startDate: opts.startDate,
        currentDate: opts.startDate,
        suggestedProjectId: opts.suggestedProjectId ?? null,
        startClientX: e.clientX,
      };
      rangeStateRef.current = state;
      setRangeState(state);
    },
    [],
  );

  // ── Canvas-level handlers (project shift + range select only) ──────────────

  const onCanvasMouseMove = useCallback(
    (e: React.MouseEvent) => {
      // Project shift
      const drag = dragStateRef.current;
      if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
        const deltaX = e.clientX - drag.startMouseX;
        const daysDelta = pixelsToDays(deltaX, cellWidth);
        if (daysDelta !== drag.daysDelta) {
          const { start: newStart, end: newEnd } = computeDragDates(
            "move",
            drag.originalStartDate,
            drag.originalEndDate,
            daysDelta,
          );
          const updated: DragState = {
            ...drag,
            currentStartDate: newStart,
            currentEndDate: newEnd,
            daysDelta,
          };
          dragStateRef.current = updated;
          setDragState(updated);
        }
        return;
      }

      // Range select
      const range = rangeStateRef.current;
      if (range.isSelecting && range.startDate) {
        const deltaX = e.clientX - range.startClientX;
        const daysDelta = pixelsToDays(deltaX, cellWidth);
        const currentDate = new Date(range.startDate);
        currentDate.setDate(currentDate.getDate() + daysDelta);

        const prevDelta = range.currentDate
          ? Math.round((range.currentDate.getTime() - range.startDate.getTime()) / 86400000)
          : 0;
        if (daysDelta === prevDelta) return;

        const updated: RangeState = { ...range, currentDate };
        rangeStateRef.current = updated;
        setRangeState(updated);
      }
    },
    [cellWidth],
  );

  const onCanvasMouseUp = useCallback(
    async (e: React.MouseEvent) => {
      // Project shift
      const drag = dragStateRef.current;
      if (drag.isDragging) {
        if (drag.daysDelta === 0) {
          if (drag.projectId && drag.originalStartDate && drag.originalEndDate) {
            onBlockClick?.({
              allocationId: drag.allocationId ?? "",
              projectId: drag.projectId,
              projectName: drag.projectName ?? "",
              startDate: drag.originalStartDate,
              endDate: drag.originalEndDate,
            });
          }
        } else if (drag.projectId && drag.currentStartDate && drag.currentEndDate) {
          try {
            await applyShiftMutation.mutateAsync({
              projectId: drag.projectId,
              newStartDate: drag.currentStartDate,
              newEndDate: drag.currentEndDate,
            });
          } catch {
            // Validation error — revert visually
          }
        }
        dragStateRef.current = INITIAL_DRAG_STATE;
        setDragState(INITIAL_DRAG_STATE);
        return;
      }

      // Range select
      const range = rangeStateRef.current;
      if (range.isSelecting && range.resourceId && range.startDate) {
        const endDate = range.currentDate ?? range.startDate;
        const [startDate, finalEnd] =
          range.startDate <= endDate ? [range.startDate, endDate] : [endDate, range.startDate];

        onRangeSelected?.({
          resourceId: range.resourceId,
          startDate,
          endDate: finalEnd,
          suggestedProjectId: range.suggestedProjectId,
          anchorX: e.clientX,
          anchorY: e.clientY,
        });

        rangeStateRef.current = INITIAL_RANGE_STATE;
        setRangeState(INITIAL_RANGE_STATE);
      }
    },
    [applyShiftMutation, onBlockClick, onRangeSelected],
  );

  const onCanvasMouseLeave = useCallback(() => {
    // Only cancel project-shift and range-select on canvas leave.
    // Alloc drag is managed by document-level listeners and must NOT be cancelled here.
    if (dragStateRef.current.isDragging) {
      dragStateRef.current = INITIAL_DRAG_STATE;
      setDragState(INITIAL_DRAG_STATE);
    }
    if (rangeStateRef.current.isSelecting) {
      rangeStateRef.current = INITIAL_RANGE_STATE;
      setRangeState(INITIAL_RANGE_STATE);
    }
  }, []);

  // ── Multi-select (right-click drag) ─────────────────────────────────────────

  const onCanvasRightMouseDown = useCallback((e: React.MouseEvent) => {
    if (e.button !== 2) return;
    e.preventDefault();

    const initial: MultiSelectState = {
      isSelecting: true,
      startX: e.clientX,
      startY: e.clientY,
      currentX: e.clientX,
      currentY: e.clientY,
      selectedAllocationIds: [],
      selectedResourceIds: [],
      dateRange: null,
      multiDragDaysDelta: 0,
      isMultiDragging: false,
      multiDragMode: "move",
    };
    multiSelectRef.current = initial;
    setMultiSelectState(initial);

    function handleMove(ev: MouseEvent) {
      const ms = multiSelectRef.current;
      if (!ms.isSelecting) return;

      const updated: MultiSelectState = {
        ...ms,
        currentX: ev.clientX,
        currentY: ev.clientY,
      };
      multiSelectRef.current = updated;
      setMultiSelectState(updated);
    }

    function handleUp(ev: MouseEvent) {
      document.removeEventListener("mousemove", handleMove);
      document.removeEventListener("mouseup", handleUp);

      const ms = multiSelectRef.current;
      if (!ms.isSelecting) return;

      const distance = Math.hypot(ev.clientX - ms.startX, ev.clientY - ms.startY);

      if (distance < 5) {
        // Minimal movement → not a drag selection, reset.
        // Let existing onContextMenu handlers on allocation blocks handle right-click.
        multiSelectRef.current = INITIAL_MULTI_SELECT;
        setMultiSelectState(INITIAL_MULTI_SELECT);
        return;
      }

      // Keep the rectangle coordinates for the parent to compute intersection.
      // isSelecting is set to false to indicate the drag is done, but the
      // rectangle data (startX/Y, currentX/Y) is preserved so TimelineView
      // can resolve which allocations/resources fall within the selection.
      const finished: MultiSelectState = {
        ...ms,
        isSelecting: false,
        currentX: ev.clientX,
        currentY: ev.clientY,
      };
      multiSelectRef.current = finished;
      setMultiSelectState(finished);
    }

    document.addEventListener("mousemove", handleMove);
    document.addEventListener("mouseup", handleUp);
  }, []);

  const clearMultiSelect = useCallback(() => {
    multiSelectRef.current = INITIAL_MULTI_SELECT;
    setMultiSelectState(INITIAL_MULTI_SELECT);
  }, []);

  // ── Touch support ───────────────────────────────────────────────────────────

  // Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback)
  function toClientX(e: React.TouchEvent): number {
    return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0;
  }

  const onProjectBarTouchStart = useCallback(
    (
      e: React.TouchEvent,
      opts: {
        projectId: string;
        projectName: string;
        startDate: Date;
        endDate: Date;
      },
    ) => {
      e.preventDefault();
      touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
      onProjectBarMouseDown(
        {
          clientX: toClientX(e),
          preventDefault: () => {},
          stopPropagation: () => {},
        } as unknown as React.MouseEvent,
        opts,
      );
    },
    [onProjectBarMouseDown],
  );

  const onAllocTouchStart = useCallback(
    (
      e: React.TouchEvent,
      opts: {
        mode: AllocDragMode;
        allocationId: string;
        mutationAllocationId?: string;
        projectId: string;
        projectName: string;
        resourceId: string | null;
        startDate: Date;
        endDate: Date;
      },
    ) => {
      e.preventDefault();
      touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
      onAllocMouseDown(
        {
          clientX: toClientX(e),
          preventDefault: () => {},
          stopPropagation: () => {},
        } as unknown as React.MouseEvent,
        opts,
      );
    },
    [onAllocMouseDown],
  );

  const onRowTouchStart = useCallback(
    (
      e: React.TouchEvent,
      opts: {
        resourceId: string;
        startDate: Date;
        suggestedProjectId?: string;
      },
    ) => {
      e.preventDefault();
      touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: false };
      onRowMouseDown(
        {
          clientX: toClientX(e),
          preventDefault: () => {},
          stopPropagation: () => {},
        } as unknown as React.MouseEvent,
        opts,
      );
    },
    [onRowMouseDown],
  );

  const onCanvasTouchMove = useCallback(
    (e: React.TouchEvent) => {
      const touch = e.touches[0];
      if (!touch) return;

      // Scroll vs drag disambiguation: once decided, stick with the decision
      if (!touchStartRef.current.decided) {
        const dx = Math.abs(touch.clientX - touchStartRef.current.x);
        const dy = Math.abs(touch.clientY - touchStartRef.current.y);
        if (dx > 8 || dy > 8) {
          touchStartRef.current.decided = true;
          if (dy > dx) return; // vertical scroll wins — don't intercept
        } else {
          return; // haven't moved enough to decide yet
        }
      }

      onCanvasMouseMove({ clientX: touch.clientX } as React.MouseEvent);
    },
    [onCanvasMouseMove],
  );

  const onCanvasTouchEnd = useCallback(
    async (e: React.TouchEvent) => {
      const clientX = e.changedTouches[0]?.clientX ?? 0;
      const clientY = e.changedTouches[0]?.clientY ?? 0;
      await onCanvasMouseUp({ clientX, clientY } as React.MouseEvent);
    },
    [onCanvasMouseUp],
  );

  // ── Derived ─────────────────────────────────────────────────────────────────

  const shiftPreview: ShiftPreviewData | null =
    dragState.isDragging && dragState.daysDelta !== 0 && previewData
      ? {
          valid: previewData.valid,
          deltaCents: previewData.costImpact.deltaCents,
          wouldExceedBudget: previewData.costImpact.wouldExceedBudget,
          budgetUtilizationAfter: previewData.costImpact.budgetUtilizationAfter,
          conflictCount: previewData.conflictDetails.length,
          errors: previewData.errors.map((e) => e.message),
          warnings: previewData.warnings.map((w) => w.message),
        }
      : null;

  return {
    dragState,
    allocDragState,
    rangeState,
    multiSelectState,
    setMultiSelectState,
    shiftPreview,
    isPreviewLoading,
    isApplying: applyShiftMutation.isPending,
    isAllocSaving: updateAllocMutation.isPending,
    onProjectBarMouseDown,
    onBlockMouseDown,
    onAllocMouseDown,
    onRowMouseDown,
    onCanvasMouseMove,
    onCanvasMouseUp,
    onCanvasMouseLeave,
    onCanvasRightMouseDown,
    clearMultiSelect,
    // Touch equivalents
    onProjectBarTouchStart,
    onAllocTouchStart,
    onRowTouchStart,
    onCanvasTouchMove,
    onCanvasTouchEnd,
  };
}
