// https://github.com/ueberdosis/tiptap/blob/91c51be53c4655ef07e29ec489471524debfa0ca/packages/core/src/lib/ResizableNodeView.ts - MIT
import type { Node as PMNode } from '@tiptap/pm/model';
import type { Decoration, DecorationSource, NodeView } from '@tiptap/pm/view';
import type { Editor } from '@tiptap/core';

const isTouchEvent = (e: MouseEvent | TouchEvent): e is TouchEvent => {
  return 'touches' in e;
};

/**
 * Directions where resize handles can be placed
 *
 * @example
 * - `'top'` - Top edge handle
 * - `'bottom-right'` - Bottom-right corner handle
 */
export type ResizableNodeViewDirection =
  | 'top'
  | 'right'
  | 'bottom'
  | 'left'
  | 'top-right'
  | 'top-left'
  | 'bottom-right'
  | 'bottom-left';

/**
 * Dimensions for the resizable node in pixels
 */
export type ResizableNodeDimensions = {
  /** Width in pixels */
  width: number;
  /** Height in pixels */
  height: number;
};

/**
 * Configuration options for creating a ResizableNodeView
 *
 * @example
 * ```ts
 * new ResizableNodeView({
 *   element: imgElement,
 *   node,
 *   getPos,
 *   onResize: (width, height) => {
 *     imgElement.style.width = `${width}px`
 *     imgElement.style.height = `${height}px`
 *   },
 *   onCommit: (width, height) => {
 *     editor.commands.updateAttributes('image', { width, height })
 *   },
 *   onUpdate: (node) => true,
 *   options: {
 *     directions: ['bottom-right', 'bottom-left'],
 *     min: { width: 100, height: 100 },
 *     preserveAspectRatio: true
 *   }
 * })
 * ```
 */
export type ResizableNodeViewOptions = {
  /**
   * The DOM element to make resizable (e.g., an img, video, or iframe element)
   */
  element: HTMLElement;

  /**
   * The DOM element that will hold the editable content element
   */
  contentElement?: HTMLElement;

  /**
   * The ProseMirror node instance
   */
  node: PMNode;

  /**
   * The Tiptap editor instance
   */
  editor: Editor;

  /**
   * Function that returns the current position of the node in the document
   */
  getPos: () => number | undefined;

  /**
   * Callback fired continuously during resize with current dimensions.
   * Use this to update the element's visual size in real-time.
   *
   * @param width - Current width in pixels
   * @param height - Current height in pixels
   *
   * @example
   * ```ts
   * onResize: (width, height) => {
   *   element.style.width = `${width}px`
   *   element.style.height = `${height}px`
   * }
   * ```
   */
  onResize?: (width: number, height: number) => void;

  /**
   * Callback fired once when resize completes with final dimensions.
   * Use this to persist the new size to the node's attributes.
   *
   * @param width - Final width in pixels
   * @param height - Final height in pixels
   *
   * @example
   * ```ts
   * onCommit: (width, height) => {
   *   const pos = getPos()
   *   if (pos !== undefined) {
   *     editor.commands.updateAttributes('image', { width, height })
   *   }
   * }
   * ```
   */
  onCommit: (width: number, height: number) => void;

  /**
   * Callback for handling node updates.
   * Return `true` to accept the update, `false` to reject it.
   *
   * @example
   * ```ts
   * onUpdate: (node, decorations, innerDecorations) => {
   *   if (node.type !== this.node.type) return false
   *   return true
   * }
   * ```
   */
  onUpdate: NodeView['update'];

  /**
   * Optional configuration for resize behavior and styling
   */
  options?: {
    /**
     * Which resize handles to display.
     * @default ['bottom-left', 'bottom-right', 'top-left', 'top-right']
     *
     * @example
     * ```ts
     * // Only show corner handles
     * directions: ['top-left', 'top-right', 'bottom-left', 'bottom-right']
     *
     * // Only show right edge handle
     * directions: ['right']
     * ```
     */
    directions?: ResizableNodeViewDirection[];

    /**
     * Minimum dimensions in pixels
     * @default { width: 8, height: 8 }
     *
     * @example
     * ```ts
     * min: { width: 100, height: 50 }
     * ```
     */
    min?: Partial<ResizableNodeDimensions>;

    /**
     * Maximum dimensions in pixels
     * @default undefined (no maximum)
     *
     * @example
     * ```ts
     * max: { width: 1000, height: 800 }
     * ```
     */
    max?: Partial<ResizableNodeDimensions>;

    /**
     * Always preserve aspect ratio when resizing.
     * When `false`, aspect ratio is preserved only when Shift key is pressed.
     * @default false
     *
     * @example
     * ```ts
     * preserveAspectRatio: true // Always lock aspect ratio
     * ```
     */
    preserveAspectRatio?: boolean;

    /**
     * Custom CSS class names for styling
     *
     * @example
     * ```ts
     * className: {
     *   container: 'resize-container',
     *   wrapper: 'resize-wrapper',
     *   handle: 'resize-handle',
     *   resizing: 'is-resizing'
     * }
     * ```
     */
    className?: {
      /** Class for the outer container element */
      container?: string;
      /** Class for the wrapper element that contains the resizable element */
      wrapper?: string;
      /** Class applied to all resize handles */
      handle?: string;
      /** Class added to container while actively resizing */
      resizing?: string;
    };

    /**
     * Optional callback for creating custom resize handle elements.
     *
     * This function allows developers to define their own handle element
     * (e.g., custom icons, classes, or styles) for a given resize direction.
     * It is called internally for each handle direction.
     *
     * @param direction - The direction of the handle being created (e.g., 'top', 'bottom-right').
     * @returns The custom handle HTMLElement.
     *
     * @example
     * ```ts
     * createCustomHandle: (direction) => {
     *   const handle = document.createElement('div')
     *   handle.dataset.resizeHandle = direction
     *   handle.style.position = 'absolute'
     *   handle.className = 'tiptap-custom-handle'
     *
     *   const isTop = direction.includes('top')
     *   const isBottom = direction.includes('bottom')
     *   const isLeft = direction.includes('left')
     *   const isRight = direction.includes('right')
     *
     *   if (isTop) handle.style.top = '0'
     *   if (isBottom) handle.style.bottom = '0'
     *   if (isLeft) handle.style.left = '0'
     *   if (isRight) handle.style.right = '0'
     *
     *   // Edge handles span the full width or height
     *   if (direction === 'top' || direction === 'bottom') {
     *     handle.style.left = '0'
     *     handle.style.right = '0'
     *   }
     *
     *   if (direction === 'left' || direction === 'right') {
     *     handle.style.top = '0'
     *     handle.style.bottom = '0'
     *   }
     *
     *   return handle
     * }
     * ```
     */
    createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
  };
};

/**
 * A NodeView implementation that adds resize handles to any DOM element.
 *
 * This class creates a resizable node view for Tiptap/ProseMirror editors.
 * It wraps your element with resize handles and manages the resize interaction,
 * including aspect ratio preservation, min/max constraints, and keyboard modifiers.
 *
 * @example
 * ```ts
 * // Basic usage in a Tiptap extension
 * addNodeView() {
 *   return ({ node, getPos }) => {
 *     const img = document.createElement('img')
 *     img.src = node.attrs.src
 *
 *     return new ResizableNodeView({
 *       element: img,
 *       node,
 *       getPos,
 *       onResize: (width, height) => {
 *         img.style.width = `${width}px`
 *         img.style.height = `${height}px`
 *       },
 *       onCommit: (width, height) => {
 *         this.editor.commands.updateAttributes('image', { width, height })
 *       },
 *       onUpdate: () => true,
 *       options: {
 *         min: { width: 100, height: 100 },
 *         preserveAspectRatio: true
 *       }
 *     })
 *   }
 * }
 * ```
 */
export class ResizableNodeView {
  /** The ProseMirror node instance */
  node: PMNode;

  /** The Tiptap editor instance */
  editor: Editor;

  /** The DOM element being made resizable */
  element: HTMLElement;

  /** The editable DOM element inside the DOM */
  contentElement?: HTMLElement;

  /** The outer container element (returned as NodeView.dom) */
  container: HTMLElement;

  /** The wrapper element that contains the element and handles */
  wrapper: HTMLElement;

  /** Function to get the current node position */
  getPos: () => number | undefined;

  /** Callback fired during resize */
  onResize?: (width: number, height: number) => void;

  /** Callback fired when resize completes */
  onCommit: (width: number, height: number) => void;

  /** Callback for node updates */
  onUpdate?: NodeView['update'];

  /** Active resize handle directions */
  directions: ResizableNodeViewDirection[] = [
    'bottom-left',
    'bottom-right',
    'top-left',
    'top-right',
  ];

  /** Minimum allowed dimensions */
  minSize: ResizableNodeDimensions = {
    height: 8,
    width: 8,
  };

  /** Maximum allowed dimensions (optional) */
  maxSize?: Partial<ResizableNodeDimensions>;

  /** Whether to always preserve aspect ratio */
  preserveAspectRatio: boolean = false;

  /** CSS class names for elements */
  classNames = {
    container: '',
    wrapper: '',
    handle: '',
    resizing: '',
  };

  /** Optional callback for creating custom resize handles */
  createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;

  /** Initial width of the element (for aspect ratio calculation) */
  private initialWidth: number = 0;

  /** Initial height of the element (for aspect ratio calculation) */
  private initialHeight: number = 0;

  /** Calculated aspect ratio (width / height) */
  private aspectRatio: number = 1;

  /** Whether a resize operation is currently active */
  private isResizing: boolean = false;

  /** The handle currently being dragged */
  private activeHandle: ResizableNodeViewDirection | null = null;

  /** Starting mouse X position when resize began */
  private startX: number = 0;

  /** Starting mouse Y position when resize began */
  private startY: number = 0;

  /** Element width when resize began */
  private startWidth: number = 0;

  /** Element height when resize began */
  private startHeight: number = 0;

  /** Whether Shift key is currently pressed (for temporary aspect ratio lock) */
  private isShiftKeyPressed: boolean = false;

  /** Last known editable state of the editor */
  private lastEditableState: boolean | undefined = undefined;

  /** Map of handle elements by direction */
  private handleMap = new Map<ResizableNodeViewDirection, HTMLElement>();

  /**
   * Creates a new ResizableNodeView instance.
   *
   * The constructor sets up the resize handles, applies initial sizing from
   * node attributes, and configures all resize behavior options.
   *
   * @param options - Configuration options for the resizable node view
   */
  constructor(options: ResizableNodeViewOptions) {
    this.node = options.node;
    this.editor = options.editor;
    this.element = options.element;
    this.contentElement = options.contentElement;

    this.getPos = options.getPos;

    this.onResize = options.onResize;
    this.onCommit = options.onCommit;
    this.onUpdate = options.onUpdate;

    if (options.options?.min) {
      this.minSize = {
        ...this.minSize,
        ...options.options.min,
      };
    }

    if (options.options?.max) {
      this.maxSize = options.options.max;
    }

    if (options?.options?.directions) {
      this.directions = options.options.directions;
    }

    if (options.options?.preserveAspectRatio) {
      this.preserveAspectRatio = options.options.preserveAspectRatio;
    }

    if (options.options?.className) {
      this.classNames = {
        container: options.options.className.container || '',
        wrapper: options.options.className.wrapper || '',
        handle: options.options.className.handle || '',
        resizing: options.options.className.resizing || '',
      };
    }

    if (options.options?.createCustomHandle) {
      this.createCustomHandle = options.options.createCustomHandle;
    }

    this.wrapper = this.createWrapper();
    this.container = this.createContainer();

    this.applyInitialSize();

    if (this.editor.isEditable) {
      this.attachHandles();
    }

    this.editor.on('update', this.handleEditorUpdate.bind(this));
  }

  /**
   * Returns the top-level DOM node that should be placed in the editor.
   *
   * This is required by the ProseMirror NodeView interface. The container
   * includes the wrapper, handles, and the actual content element.
   *
   * @returns The container element to be inserted into the editor
   */
  get dom() {
    return this.container;
  }

  get contentDOM(): HTMLElement | null {
    return this.contentElement ?? null;
  }

  private handleEditorUpdate() {
    const isEditable = this.editor.isEditable;

    // Only if state actually changed
    if (isEditable === this.lastEditableState) {
      return;
    }

    this.lastEditableState = isEditable;

    if (!isEditable) {
      this.removeHandles();
    } else if (isEditable && this.handleMap.size === 0) {
      this.attachHandles();
    }
  }

  /**
   * Called when the node's content or attributes change.
   *
   * Updates the internal node reference. If a custom `onUpdate` callback
   * was provided, it will be called to handle additional update logic.
   *
   * @param node - The new/updated node
   * @param decorations - Node decorations
   * @param innerDecorations - Inner decorations
   * @returns `false` if the node type has changed (requires full rebuild), otherwise the result of `onUpdate` or `true`
   */
  update(
    node: PMNode,
    decorations: readonly Decoration[],
    innerDecorations: DecorationSource,
  ): boolean {
    if (node.type !== this.node.type) {
      return false;
    }

    this.node = node;

    if (this.onUpdate) {
      return this.onUpdate(node, decorations, innerDecorations);
    }

    return true;
  }

  /**
   * Cleanup method called when the node view is being removed.
   *
   * Removes all event listeners to prevent memory leaks. This is required
   * by the ProseMirror NodeView interface. If a resize is active when
   * destroy is called, it will be properly cancelled.
   */
  destroy() {
    if (this.isResizing) {
      this.container.dataset.resizeState = 'false';

      if (this.classNames.resizing) {
        this.container.classList.remove(this.classNames.resizing);
      }

      document.removeEventListener('mousemove', this.handleMouseMove);
      document.removeEventListener('touchmove', this.handleTouchMove);
      document.removeEventListener('mouseup', this.handleMouseUp);
      document.removeEventListener('touchend', this.handleMouseUp);
      window.removeEventListener('blur', this.handleMouseUp);
      document.removeEventListener('keydown', this.handleKeyDown);
      document.removeEventListener('keyup', this.handleKeyUp);
      this.isResizing = false;
      this.activeHandle = null;
    }

    this.editor.off('update', this.handleEditorUpdate.bind(this));

    this.container.remove();
  }

  /**
   * Creates the outer container element.
   *
   * The container is the top-level element returned by the NodeView and
   * wraps the entire resizable node. It's set up with flexbox to handle
   * alignment and includes data attributes for styling and identification.
   *
   * @returns The container element
   */
  createContainer() {
    const element = document.createElement('div');
    element.dataset.resizeContainer = '';
    element.dataset.node = this.node.type.name;
    element.style.display = 'flex';

    if (this.classNames.container) {
      element.className = this.classNames.container;
    }

    element.appendChild(this.wrapper);

    return element;
  }

  /**
   * Creates the wrapper element that contains the content and handles.
   *
   * The wrapper uses relative positioning so that resize handles can be
   * positioned absolutely within it. This is the direct parent of the
   * content element being made resizable.
   *
   * @returns The wrapper element
   */
  createWrapper() {
    const element = document.createElement('div');
    element.style.position = 'relative';
    element.style.display = 'block';
    element.dataset.resizeWrapper = '';

    if (this.classNames.wrapper) {
      element.className = this.classNames.wrapper;
    }

    element.appendChild(this.element);

    return element;
  }

  /**
   * Creates a resize handle element for a specific direction.
   *
   * Each handle is absolutely positioned and includes a data attribute
   * identifying its direction for styling purposes.
   *
   * @param direction - The resize direction for this handle
   * @returns The handle element
   */
  private createHandle(direction: ResizableNodeViewDirection): HTMLElement {
    const handle = document.createElement('div');
    handle.dataset.resizeHandle = direction;
    handle.style.position = 'absolute';

    if (this.classNames.handle) {
      handle.className = this.classNames.handle;
    }

    return handle;
  }

  /**
   * Positions a handle element according to its direction.
   *
   * Corner handles (e.g., 'top-left') are positioned at the intersection
   * of two edges. Edge handles (e.g., 'top') span the full width or height.
   *
   * @param handle - The handle element to position
   * @param direction - The direction determining the position
   */
  private positionHandle(
    handle: HTMLElement,
    direction: ResizableNodeViewDirection,
  ): void {
    const isTop = direction.includes('top');
    const isBottom = direction.includes('bottom');
    const isLeft = direction.includes('left');
    const isRight = direction.includes('right');

    if (isTop) {
      handle.style.top = '0';
    }

    if (isBottom) {
      handle.style.bottom = '0';
    }

    if (isLeft) {
      handle.style.left = '0';
    }

    if (isRight) {
      handle.style.right = '0';
    }

    // Edge handles span the full width or height
    if (direction === 'top' || direction === 'bottom') {
      handle.style.left = '0';
      handle.style.right = '0';
    }

    if (direction === 'left' || direction === 'right') {
      handle.style.top = '0';
      handle.style.bottom = '0';
    }
  }

  /**
   * Creates and attaches all resize handles to the wrapper.
   *
   * Iterates through the configured directions, creates a handle for each,
   * positions it, attaches the mousedown listener, and appends it to the DOM.
   */
  private attachHandles(): void {
    this.directions.forEach((direction) => {
      let handle: HTMLElement;

      if (this.createCustomHandle) {
        handle = this.createCustomHandle(direction);
      } else {
        handle = this.createHandle(direction);
      }

      if (!(handle instanceof HTMLElement)) {
        console.warn(
          `[ResizableNodeView] createCustomHandle("${direction}") did not return an HTMLElement. Falling back to default handle.`,
        );
        handle = this.createHandle(direction);
      }

      if (!this.createCustomHandle) {
        this.positionHandle(handle, direction);
      }

      handle.addEventListener('mousedown', (event) =>
        this.handleResizeStart(event, direction),
      );
      handle.addEventListener('touchstart', (event) =>
        this.handleResizeStart(event as unknown as MouseEvent, direction),
      );

      this.handleMap.set(direction, handle);

      this.wrapper.appendChild(handle);
    });
  }

  /**
   * Removes all resize handles from the wrapper.
   *
   * Cleans up the handle map and removes each handle element from the DOM.
   */
  private removeHandles(): void {
    this.handleMap.forEach((el) => el.remove());
    this.handleMap.clear();
  }

  /**
   * Applies initial sizing from node attributes to the element.
   *
   * If width/height attributes exist on the node, they're applied to the element.
   * Otherwise, the element's natural/current dimensions are measured. The aspect
   * ratio is calculated for later use in aspect-ratio-preserving resizes.
   */
  private applyInitialSize(): void {
    const width = this.node.attrs.width as number | undefined;
    const height = this.node.attrs.height as number | undefined;

    if (width) {
      this.element.style.width = `${width}px`;
      this.initialWidth = width;
    } else {
      this.initialWidth = this.element.offsetWidth;
    }

    if (height) {
      this.element.style.height = `${height}px`;
      this.initialHeight = height;
    } else {
      this.initialHeight = this.element.offsetHeight;
    }

    // Calculate aspect ratio for use during resizing
    if (this.initialWidth > 0 && this.initialHeight > 0) {
      this.aspectRatio = this.initialWidth / this.initialHeight;
    }
  }

  /**
   * Initiates a resize operation when a handle is clicked.
   *
   * Captures the starting mouse position and element dimensions, sets up
   * the resize state, adds the resizing class and state attribute, and
   * attaches document-level listeners for mouse movement and keyboard input.
   *
   * @param event - The mouse down event
   * @param direction - The direction of the handle being dragged
   */
  private handleResizeStart(
    event: MouseEvent | TouchEvent,
    direction: ResizableNodeViewDirection,
  ): void {
    event.preventDefault();
    event.stopPropagation();

    // Capture initial state
    this.isResizing = true;
    this.activeHandle = direction;

    if (isTouchEvent(event)) {
      this.startX = event.touches[0].clientX;
      this.startY = event.touches[0].clientY;
    } else {
      this.startX = event.clientX;
      this.startY = event.clientY;
    }

    this.startWidth = this.element.offsetWidth;
    this.startHeight = this.element.offsetHeight;

    // Recalculate aspect ratio at resize start for accuracy
    if (this.startWidth > 0 && this.startHeight > 0) {
      this.aspectRatio = this.startWidth / this.startHeight;
    }

    const pos = this.getPos();
    if (pos !== undefined) {
      // TODO: Select the node in the editor
    }

    // Update UI state
    this.container.dataset.resizeState = 'true';

    if (this.classNames.resizing) {
      this.container.classList.add(this.classNames.resizing);
    }

    // Attach document-level listeners for resize
    document.addEventListener('mousemove', this.handleMouseMove);
    document.addEventListener('touchmove', this.handleTouchMove);
    document.addEventListener('mouseup', this.handleMouseUp);
    document.addEventListener('touchend', this.handleMouseUp);
    window.addEventListener('blur', this.handleMouseUp);
    document.addEventListener('keydown', this.handleKeyDown);
    document.addEventListener('keyup', this.handleKeyUp);
  }

  /**
   * Handles mouse movement during an active resize.
   *
   * Calculates the delta from the starting position, computes new dimensions
   * based on the active handle direction, applies constraints and aspect ratio,
   * then updates the element's style and calls the onResize callback.
   *
   * @param event - The mouse move event
   */
  private handleMouseMove = (event: MouseEvent): void => {
    if (!this.isResizing || !this.activeHandle) {
      return;
    }

    const deltaX = event.clientX - this.startX;
    const deltaY = event.clientY - this.startY;

    this.handleResize(deltaX, deltaY);
  };

  private handleTouchMove = (event: TouchEvent): void => {
    if (!this.isResizing || !this.activeHandle) {
      return;
    }

    const touch = event.touches[0];
    if (!touch) {
      return;
    }

    const deltaX = touch.clientX - this.startX;
    const deltaY = touch.clientY - this.startY;

    this.handleResize(deltaX, deltaY);
  };

  private handleResize(deltaX: number, deltaY: number) {
    if (!this.activeHandle) {
      return;
    }

    const shouldPreserveAspectRatio =
      this.preserveAspectRatio || this.isShiftKeyPressed;
    const { width, height } = this.calculateNewDimensions(
      this.activeHandle,
      deltaX,
      deltaY,
    );
    const constrained = this.applyConstraints(
      width,
      height,
      shouldPreserveAspectRatio,
    );

    this.element.style.width = `${constrained.width}px`;
    this.element.style.height = `${constrained.height}px`;

    if (this.onResize) {
      this.onResize(constrained.width, constrained.height);
    }
  }

  /**
   * Completes the resize operation when the mouse button is released.
   *
   * Captures final dimensions, calls the onCommit callback to persist changes,
   * removes the resizing state and class, and cleans up document-level listeners.
   */
  private handleMouseUp = (): void => {
    if (!this.isResizing) {
      return;
    }

    const finalWidth = this.element.offsetWidth;
    const finalHeight = this.element.offsetHeight;

    this.onCommit(finalWidth, finalHeight);

    this.isResizing = false;
    this.activeHandle = null;

    // Remove UI state
    this.container.dataset.resizeState = 'false';

    if (this.classNames.resizing) {
      this.container.classList.remove(this.classNames.resizing);
    }

    // Clean up document-level listeners
    document.removeEventListener('mousemove', this.handleMouseMove);
    document.removeEventListener('touchmove', this.handleTouchMove);
    document.removeEventListener('mouseup', this.handleMouseUp);
    document.removeEventListener('touchend', this.handleMouseUp);
    window.removeEventListener('blur', this.handleMouseUp);
    document.removeEventListener('keydown', this.handleKeyDown);
    document.removeEventListener('keyup', this.handleKeyUp);
  };

  /**
   * Tracks Shift key state to enable temporary aspect ratio locking.
   *
   * When Shift is pressed during resize, aspect ratio is preserved even if
   * preserveAspectRatio is false.
   *
   * @param event - The keyboard event
   */
  private handleKeyDown = (event: KeyboardEvent): void => {
    if (event.key === 'Shift') {
      this.isShiftKeyPressed = true;
    }
  };

  /**
   * Tracks Shift key release to disable temporary aspect ratio locking.
   *
   * @param event - The keyboard event
   */
  private handleKeyUp = (event: KeyboardEvent): void => {
    if (event.key === 'Shift') {
      this.isShiftKeyPressed = false;
    }
  };

  /**
   * Calculates new dimensions based on mouse delta and resize direction.
   *
   * Takes the starting dimensions and applies the mouse movement delta
   * according to the handle direction. For corner handles, both dimensions
   * are affected. For edge handles, only one dimension changes. If aspect
   * ratio should be preserved, delegates to applyAspectRatio.
   *
   * @param direction - The active resize handle direction
   * @param deltaX - Horizontal mouse movement since resize start
   * @param deltaY - Vertical mouse movement since resize start
   * @returns The calculated width and height
   */
  private calculateNewDimensions(
    direction: ResizableNodeViewDirection,
    deltaX: number,
    deltaY: number,
  ): ResizableNodeDimensions {
    let newWidth = this.startWidth;
    let newHeight = this.startHeight;

    const isRight = direction.includes('right');
    const isLeft = direction.includes('left');
    const isBottom = direction.includes('bottom');
    const isTop = direction.includes('top');

    // Apply horizontal delta
    if (isRight) {
      newWidth = this.startWidth + deltaX;
    } else if (isLeft) {
      newWidth = this.startWidth - deltaX;
    }

    // Apply vertical delta
    if (isBottom) {
      newHeight = this.startHeight + deltaY;
    } else if (isTop) {
      newHeight = this.startHeight - deltaY;
    }

    // For pure horizontal/vertical handles, only one dimension changes
    if (direction === 'right' || direction === 'left') {
      newWidth = this.startWidth + (isRight ? deltaX : -deltaX);
    }

    if (direction === 'top' || direction === 'bottom') {
      newHeight = this.startHeight + (isBottom ? deltaY : -deltaY);
    }

    const shouldPreserveAspectRatio =
      this.preserveAspectRatio || this.isShiftKeyPressed;

    if (shouldPreserveAspectRatio) {
      return this.applyAspectRatio(newWidth, newHeight, direction);
    }

    return { width: newWidth, height: newHeight };
  }

  /**
   * Applies min/max constraints to dimensions.
   *
   * When aspect ratio is NOT preserved, constraints are applied independently
   * to width and height. When aspect ratio IS preserved, constraints are
   * applied while maintaining the aspect ratio—if one dimension hits a limit,
   * the other is recalculated proportionally.
   *
   * This ensures that aspect ratio is never broken when constrained.
   *
   * @param width - The unconstrained width
   * @param height - The unconstrained height
   * @param preserveAspectRatio - Whether to maintain aspect ratio while constraining
   * @returns The constrained dimensions
   */
  private applyConstraints(
    width: number,
    height: number,
    preserveAspectRatio: boolean,
  ): ResizableNodeDimensions {
    if (!preserveAspectRatio) {
      // Independent constraints for each dimension
      let constrainedWidth = Math.max(this.minSize.width, width);
      let constrainedHeight = Math.max(this.minSize.height, height);

      if (this.maxSize?.width) {
        constrainedWidth = Math.min(this.maxSize.width, constrainedWidth);
      }

      if (this.maxSize?.height) {
        constrainedHeight = Math.min(this.maxSize.height, constrainedHeight);
      }

      return { width: constrainedWidth, height: constrainedHeight };
    }

    // Aspect-ratio-aware constraints: adjust both dimensions proportionally
    let constrainedWidth = width;
    let constrainedHeight = height;

    // Check minimum constraints
    if (constrainedWidth < this.minSize.width) {
      constrainedWidth = this.minSize.width;
      constrainedHeight = constrainedWidth / this.aspectRatio;
    }

    if (constrainedHeight < this.minSize.height) {
      constrainedHeight = this.minSize.height;
      constrainedWidth = constrainedHeight * this.aspectRatio;
    }

    // Check maximum constraints
    if (this.maxSize?.width && constrainedWidth > this.maxSize.width) {
      constrainedWidth = this.maxSize.width;
      constrainedHeight = constrainedWidth / this.aspectRatio;
    }

    if (this.maxSize?.height && constrainedHeight > this.maxSize.height) {
      constrainedHeight = this.maxSize.height;
      constrainedWidth = constrainedHeight * this.aspectRatio;
    }

    return { width: constrainedWidth, height: constrainedHeight };
  }

  /**
   * Adjusts dimensions to maintain the original aspect ratio.
   *
   * For horizontal handles (left/right), uses width as the primary dimension
   * and calculates height from it. For vertical handles (top/bottom), uses
   * height as primary and calculates width. For corner handles, uses width
   * as the primary dimension.
   *
   * @param width - The new width
   * @param height - The new height
   * @param direction - The active resize direction
   * @returns Dimensions adjusted to preserve aspect ratio
   */
  private applyAspectRatio(
    width: number,
    height: number,
    direction: ResizableNodeViewDirection,
  ): ResizableNodeDimensions {
    const isHorizontal = direction === 'left' || direction === 'right';
    const isVertical = direction === 'top' || direction === 'bottom';

    if (isHorizontal) {
      // For horizontal resize, width is primary
      return {
        width,
        height: width / this.aspectRatio,
      };
    }

    if (isVertical) {
      // For vertical resize, height is primary
      return {
        width: height * this.aspectRatio,
        height,
      };
    }

    // For corner resize, width is primary
    return {
      width,
      height: width / this.aspectRatio,
    };
  }
}
