"use client";

import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { pixelsToDays } from "~/components/timeline/dragMath.js";
import {
  clearLivePreview,
  preserveLivePreview,
  scheduleLivePreview,
  type LivePreviewSession,
} from "./timelineLivePreview.js";
import {
  finalizeAllocationMultiDrag,
  isAllocationMultiSelected,
  startAllocationMultiDrag,
  updateAllocationMultiDrag,
} from "./timelineAllocationMultiDrag.js";
import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js";
import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
import { createProjectDragState } from "./timelineProjectDrag.js";
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
import {
  forwardCanvasTouchEnd,
  forwardCanvasTouchMove,
  forwardTouchStartAsMouseDown,
} from "./timelineTouchEvents.js";
import {
  completeMultiSelectDraft,
  createMultiSelectState,
  updateMultiSelectDraft,
} from "./timelineMultiSelect.js";
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js";
import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js";
import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";

const DRAG_CLICK_THRESHOLD_PX = 5;

// ─── 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;
  pointerDeltaX: 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,
  pointerDeltaX: 0,
  originalLeft: 0,
  blockWidth: 0,
  daysDelta: 0,
};

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

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

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

const INITIAL_ALLOC_DRAG: AllocDragState = {
  isActive: false,
  mode: "move",
  scope: "allocation",
  allocationId: null,
  mutationAllocationId: null,
  projectId: null,
  projectName: null,
  resourceId: null,
  allocationStartDate: null,
  allocationEndDate: null,
  originalStartDate: null,
  originalEndDate: null,
  currentStartDate: null,
  currentEndDate: null,
  startMouseX: 0,
  pointerDeltaX: 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 interface OptimisticTimelineEntry {
  id: string;
  startDate: Date | string;
  endDate: Date | string;
}

export interface OptimisticTimelineOverride {
  startDate: Date;
  endDate: Date;
}

export function useTimelineDrag({
  cellWidthRef,
  onShiftApplied,
  onBlockClick,
  onRangeSelected,
  onAllocationMoved,
  onShiftClickAlloc,
  onMultiDragComplete,
}: {
  cellWidthRef: MutableRefObject<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);
  const projectPreviewRef = useRef<LivePreviewSession | null>(null);
  const allocPreviewRef = useRef<LivePreviewSession | null>(null);
  const projectDragCleanupRef = useRef<(() => void) | null>(null);
  const allocDragCleanupRef = useRef<(() => void) | null>(null);
  const multiSelectCleanupRef = useRef<(() => void) | null>(null);
  // Keep ref in sync with state so document-level handlers read the latest selection
  multiSelectRef.current = multiSelectState;

  // 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();

  const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
    clearLivePreview(projectPreviewRef.current);
    projectPreviewRef.current = createProjectPreviewSession({
      projectId,
      currentTarget,
      cellWidth: cellWidthRef.current,
    });
  }, []);

  const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
    clearLivePreview(allocPreviewRef.current);
    allocPreviewRef.current = createAllocationPreviewSession({
      currentTarget,
      mode,
      cellWidth: cellWidthRef.current,
    });
  }, []);

  const updateLivePreview = useCallback(
    (previewRef: MutableRefObject<LivePreviewSession | null>, pointerDeltaX: number, daysDelta: number) => {
      const preview = previewRef.current;
      if (!preview) return;
      preview.cellWidth = cellWidthRef.current;
      preview.pointerDeltaX = pointerDeltaX;
      preview.daysDelta = daysDelta;
      scheduleLivePreview(preview);
    },
    [],
  );

  const updateProjectDragPosition = useCallback(
    (clientX: number) => {
      const result = resolveProjectDragPosition(dragStateRef.current, clientX, cellWidthRef.current);
      if (!result.handled) return false;

      updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta);
      dragStateRef.current = result.nextState;
      if (result.shouldSyncState) {
        setDragState(result.nextState);
      }
      return true;
    },
    [updateLivePreview],
  );

  const updateAllocationDragPosition = useCallback(
    (clientX: number) => {
      const result = resolveAllocationDragPosition(allocDragRef.current, clientX, cellWidthRef.current);
      if (!result.handled) return false;

      updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta);
      allocDragRef.current = result.nextState;
      if (result.shouldSyncState) {
        setAllocDragState(result.nextState);
      }
      return true;
    },
    [updateLivePreview],
  );

  const clearProjectDragSession = useCallback(() => {
    projectDragCleanupRef.current?.();
    projectDragCleanupRef.current = null;
    clearLivePreview(projectPreviewRef.current);
    projectPreviewRef.current = null;
    dragStateRef.current = INITIAL_DRAG_STATE;
    setDragState(INITIAL_DRAG_STATE);
  }, []);

  const cancelActiveInteractions = useCallback(() => {
    const hasActiveInteraction =
      dragStateRef.current.isDragging ||
      allocDragRef.current.isActive ||
      rangeStateRef.current.isSelecting ||
      multiSelectRef.current.isSelecting ||
      multiSelectRef.current.isMultiDragging;
    if (!hasActiveInteraction) return;

    const nextMultiSelectState = cancelTransientMultiSelectState(
      multiSelectRef.current,
      INITIAL_MULTI_SELECT,
    );

    cleanupTimelineDragState({
      projectDragCleanupRef,
      allocDragCleanupRef,
      multiSelectCleanupRef,
      projectPreviewRef,
      allocPreviewRef,
      dragStateRef,
      allocDragRef,
      rangeStateRef,
      multiSelectRef,
      initialDragState: INITIAL_DRAG_STATE,
      initialAllocDragState: INITIAL_ALLOC_DRAG,
      initialRangeState: INITIAL_RANGE_STATE,
      initialMultiSelectState: nextMultiSelectState,
      clearPreview: clearLivePreview,
    });

    setDragState(INITIAL_DRAG_STATE);
    setAllocDragState(INITIAL_ALLOC_DRAG);
    setRangeState(INITIAL_RANGE_STATE);
    setMultiSelectState(nextMultiSelectState);
  }, []);

  // 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 finalizeActiveProjectDrag = useCallback(
    (clientX: number, mode: "mutate" | "mutateAsync" = "mutate") =>
      finalizeProjectDrag({
        clientX,
        mode,
        dragRef: dragStateRef,
        previewRef: projectPreviewRef,
        updatePosition: updateProjectDragPosition,
        clearSession: clearProjectDragSession,
        mutate: applyShiftMutation.mutate,
        mutateAsync: applyShiftMutation.mutateAsync,
      }),
    [applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
  );

  const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
  const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
  const [optimisticAllocations, setOptimisticAllocations] = useState<Map<string, OptimisticTimelineOverride>>(
    () => new Map(),
  );

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

  const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({
    onSuccess: () => {
      invalidateTimeline();
    },
  });

  const clearPendingOptimisticAllocation = useCallback((allocationId?: string | null) => {
    pendingSnapshotRef.current = null;
    const optimisticAllocationId = allocationId ?? pendingOptimisticAllocationIdRef.current;
    if (!optimisticAllocationId) {
      pendingOptimisticAllocationIdRef.current = null;
      return;
    }

    setOptimisticAllocations((prev) => {
      if (!prev.has(optimisticAllocationId)) return prev;
      const next = new Map(prev);
      next.delete(optimisticAllocationId);
      return next;
    });
    pendingOptimisticAllocationIdRef.current = null;
  }, []);

  const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => {
    setOptimisticAllocations((prev) => {
      const result = reconcileOptimisticEntries(prev, entries, pendingOptimisticAllocationIdRef.current);
      pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId;
      return result.changed ? result.optimisticAllocations : prev;
    });
  }, []);

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

  const onProjectBarMouseDown = useCallback(
    (
      e: TouchMouseDownEvent,
      opts: {
        projectId: string;
        projectName: string;
        startDate: Date;
        endDate: Date;
      },
    ) => {
      if (e.button !== 0) return;
      multiSelectRef.current = INITIAL_MULTI_SELECT;
      setMultiSelectState(INITIAL_MULTI_SELECT);
      e.preventDefault();
      e.stopPropagation();
      const state = createProjectDragState<DragState>({
        projectId: opts.projectId,
        projectName: opts.projectName,
        startDate: opts.startDate,
        endDate: opts.endDate,
        startMouseX: e.clientX,
      });
      setProjectPreviewTargets(opts.projectId, e.currentTarget);
      beginProjectDragSession({
        state,
        cleanupRef: projectDragCleanupRef,
        stateRef: dragStateRef,
        setState: setDragState,
        documentTarget: document,
        attachDrag: attachDocumentMouseDrag,
        updatePosition: updateProjectDragPosition,
        finalize: (clientX) => {
          void finalizeActiveProjectDrag(clientX);
        },
      });
    },
    [finalizeActiveProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
  );

  // Legacy — kept for backward compat (triggers project shift from allocation block)
  const onBlockMouseDown = useCallback(
    (
      e: TouchMouseDownEvent,
      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 = createProjectDragState<DragState>({
        projectId: opts.projectId,
        projectName: opts.projectName,
        allocationId: opts.allocationId ?? null,
        startDate: opts.startDate,
        endDate: opts.endDate,
        startMouseX: e.clientX,
        originalLeft: opts.blockLeft,
        blockWidth: opts.blockWidth,
      });
      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: TouchMouseDownEvent,
      opts: {
        mode: AllocDragMode;
        allocationId: string;
        mutationAllocationId?: string;
        projectId: string;
        projectName: string;
        resourceId: string | null;
        startDate: Date;
        endDate: Date;
        allocationStartDate?: Date;
        allocationEndDate?: Date;
        scope?: AllocDragScope;
      },
    ) => {
      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 = isAllocationMultiSelected(ms, opts.allocationId);

      if (isMultiSelected) {
        beginAllocationMultiDragSession({
          startMouseX: e.clientX,
          dragMode: opts.mode,
          documentTarget: document,
          cleanupRef: multiSelectCleanupRef,
          stateRef: multiSelectRef,
          setState: setMultiSelectState,
          startState: startAllocationMultiDrag,
          updateState: updateAllocationMultiDrag,
          finalizeState: finalizeAllocationMultiDrag,
          toDaysDelta: (deltaX) => pixelsToDays(deltaX, cellWidthRef.current),
          onComplete: (daysDelta, finalized) => {
            onMultiDragCompleteRef.current?.(daysDelta, opts.mode, finalized.selectedAllocationIds);
          },
          attachDrag: attachDocumentMouseDrag,
        });
        return;
      }

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

      multiSelectRef.current = INITIAL_MULTI_SELECT;
      setMultiSelectState(INITIAL_MULTI_SELECT);
      const initial = createAllocationDragState<AllocDragState, AllocDragMode, AllocDragScope>({
        mode: opts.mode,
        scope: opts.scope,
        allocationId: opts.allocationId,
        mutationAllocationId: opts.mutationAllocationId,
        projectId: opts.projectId,
        projectName: opts.projectName,
        resourceId: opts.resourceId,
        allocationStartDate: opts.allocationStartDate,
        allocationEndDate: opts.allocationEndDate,
        startDate: opts.startDate,
        endDate: opts.endDate,
        startMouseX: e.clientX,
      });
      setAllocationPreviewTarget(e.currentTarget, opts.mode);
      beginAllocationDragSession({
        state: initial,
        cleanupRef: allocDragCleanupRef,
        stateRef: allocDragRef,
        setState: setAllocDragState,
        documentTarget: document,
        attachDrag: attachDocumentMouseDrag,
        updatePosition: updateAllocationDragPosition,
        finalize: (clientX) => {
          void finalizeAllocationReleaseEffects({
            clientX,
            allocRef: allocDragRef,
            previewRef: allocPreviewRef,
            updatePosition: updateAllocationDragPosition,
            clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
            wasShift,
            onShiftClick: onShiftClickAllocRef.current,
            onBlockClick: onBlockClickRef.current,
            pendingSnapshotRef,
            pendingOptimisticAllocationIdRef,
            setOptimisticAllocations,
            extractAllocationFragment: extractAllocFragmentMutation.mutateAsync,
            updateAllocation: updateAllocMutation.mutate,
            clearPendingOptimisticAllocation,
          });

          allocDragRef.current = INITIAL_ALLOC_DRAG;
          setAllocDragState(INITIAL_ALLOC_DRAG);
        },
      });
    },
    [
      clearPendingOptimisticAllocation,
      extractAllocFragmentMutation,
      setAllocationPreviewTarget,
      updateAllocationDragPosition,
      updateAllocMutation,
    ],
  );

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

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

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

  const onCanvasMouseMove = useCallback(
    (e: TouchCanvasPointerEvent) => {
      if (updateProjectDragPosition(e.clientX)) {
        return;
      }

      // Range select
      const range = rangeStateRef.current;
      const updated = updateRangeSelectionDraft(range, e.clientX, cellWidthRef.current);
      if (updated) {
        rangeStateRef.current = updated;
        setRangeState(updated);
      }
    },
    [updateProjectDragPosition],
  );

  const onCanvasMouseUp = useCallback(
    async (e: TouchCanvasPointerEvent) => {
      // Project shift
      const drag = dragStateRef.current;
      if (drag.isDragging) {
        try {
          await finalizeActiveProjectDrag(e.clientX, "mutateAsync");
        } catch {
          // Validation error — revert visually
        }
        return;
      }

      // Range select
      const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE);
      if (release.kind !== "complete") return;

      onRangeSelected?.(release.selection);
      rangeStateRef.current = release.nextState;
      setRangeState(release.nextState);
    },
    [finalizeActiveProjectDrag, 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.
    const cancellation = resolveRangeSelectionCancel(rangeStateRef.current, INITIAL_RANGE_STATE);
    if (!cancellation.didReset) return;

    rangeStateRef.current = cancellation.nextState;
    setRangeState(cancellation.nextState);
  }, []);

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

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

    beginCanvasMultiSelectSession({
      clientX: e.clientX,
      clientY: e.clientY,
      documentTarget: document,
      cleanupRef: multiSelectCleanupRef,
      stateRef: multiSelectRef,
      setState: setMultiSelectState,
      createInitialState: (clientX, clientY) =>
        createMultiSelectState<MultiSelectState>(clientX, clientY, {
          selectedAllocationIds: [],
          selectedResourceIds: [],
          dateRange: null,
          multiDragDaysDelta: 0,
          isMultiDragging: false,
          multiDragMode: "move",
        }),
      updateState: updateMultiSelectDraft,
      completeState: completeMultiSelectDraft,
      initialState: INITIAL_MULTI_SELECT,
      attachDrag: attachDocumentMouseDrag,
    });
  }, []);

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

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

  const onProjectBarTouchStart = useCallback(
    (
      e: React.TouchEvent,
      opts: {
        projectId: string;
        projectName: string;
        startDate: Date;
        endDate: Date;
      },
    ) => {
      forwardTouchStartAsMouseDown({
        event: e,
        touchStartRef,
        decided: true,
        onMouseDown: onProjectBarMouseDown,
        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;
        allocationStartDate?: Date;
        allocationEndDate?: Date;
        scope?: AllocDragScope;
      },
    ) => {
      forwardTouchStartAsMouseDown({
        event: e,
        touchStartRef,
        decided: true,
        onMouseDown: onAllocMouseDown,
        opts,
      });
    },
    [onAllocMouseDown],
  );

  const onRowTouchStart = useCallback(
    (
      e: React.TouchEvent,
      opts: {
        resourceId: string;
        startDate: Date;
        suggestedProjectId?: string;
      },
    ) => {
      forwardTouchStartAsMouseDown({
        event: e,
        touchStartRef,
        decided: false,
        onMouseDown: onRowMouseDown,
        opts,
      });
    },
    [onRowMouseDown],
  );

  const onCanvasTouchMove = useCallback(
    (e: React.TouchEvent) => {
      forwardCanvasTouchMove({
        event: e,
        touchStartRef,
        onCanvasMouseMove,
      });
    },
    [onCanvasMouseMove],
  );

  const onCanvasTouchEnd = useCallback(
    async (e: React.TouchEvent) => {
      await forwardCanvasTouchEnd({ event: e, onCanvasMouseUp });
    },
    [onCanvasMouseUp],
  );

  useEffect(() => {
    function handleWindowBlur() {
      cancelActiveInteractions();
    }

    function handleVisibilityChange() {
      if (document.visibilityState === "hidden") {
        cancelActiveInteractions();
      }
    }

    window.addEventListener("blur", handleWindowBlur);
    document.addEventListener("visibilitychange", handleVisibilityChange);

    return () => {
      window.removeEventListener("blur", handleWindowBlur);
      document.removeEventListener("visibilitychange", handleVisibilityChange);
      cleanupTimelineDragState({
        projectDragCleanupRef,
        allocDragCleanupRef,
        multiSelectCleanupRef,
        projectPreviewRef,
        allocPreviewRef,
        dragStateRef,
        allocDragRef,
        rangeStateRef,
        multiSelectRef,
        initialDragState: INITIAL_DRAG_STATE,
        initialAllocDragState: INITIAL_ALLOC_DRAG,
        initialRangeState: INITIAL_RANGE_STATE,
        initialMultiSelectState: INITIAL_MULTI_SELECT,
        clearPreview: clearLivePreview,
      });
    };
  }, [cancelActiveInteractions]);

  // ── 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,
    optimisticAllocations,
    reconcileOptimisticAllocations,
    shiftPreview,
    isPreviewLoading,
    isApplying: applyShiftMutation.isPending,
    isAllocSaving: updateAllocMutation.isPending,
    onProjectBarMouseDown,
    onBlockMouseDown,
    onAllocMouseDown,
    onRowMouseDown,
    onCanvasMouseMove,
    onCanvasMouseUp,
    onCanvasMouseLeave,
    onCanvasRightMouseDown,
    clearMultiSelect,
    // Touch equivalents
    onProjectBarTouchStart,
    onAllocTouchStart,
    onRowTouchStart,
    onCanvasTouchMove,
    onCanvasTouchEnd,
  };
}
