import {
  useId,
  useMemo,
  useRef,
  useState,
  type ChangeEvent,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from 'react';
import type { CropSettings } from '../project';
import './visual-editor.css';

type Orientation = 0 | 90 | 180 | 270;
type ResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
type DragMode = 'move' | ResizeHandle;
type CropField = keyof CropSettings;
type RatioChoice = 'current' | 'source' | '16:9' | '4:3' | '1:1' | '9:16';

interface Rectangle {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface Dimensions {
  width: number;
  height: number;
}

interface ActiveDrag {
  mode: DragMode;
  pointerId: number;
  startClientX: number;
  startClientY: number;
  startRectangle: Rectangle;
}

interface CropDraftState {
  sourceKey: string;
  values: Record<CropField, string>;
}

export interface CropOverlayProps {
  /** Media element rendered below the crop controls. */
  children: ReactNode;
  /** Encoded source width, before applying display orientation. */
  sourceWidth: number;
  /** Encoded source height, before applying display orientation. */
  sourceHeight: number;
  /** Clockwise display orientation applied by this component. */
  orientation?: Orientation;
  /** Crop rectangle in encoded source pixels. */
  value?: CropSettings;
  onChange: (crop: CropSettings) => void;
  disabled?: boolean;
  requireEvenDimensions?: boolean;
  minimumSize?: number;
  ariaLabel?: string;
}

const handles: readonly ResizeHandle[] = [
  'n',
  'ne',
  'e',
  'se',
  's',
  'sw',
  'w',
  'nw',
];

const handleNames: Readonly<Record<ResizeHandle, string>> = {
  n: 'top',
  ne: 'top right',
  e: 'right',
  se: 'bottom right',
  s: 'bottom',
  sw: 'bottom left',
  w: 'left',
  nw: 'top left',
};

const ratioValues: Readonly<Record<Exclude<RatioChoice, 'current'>, number>> = {
  source: 1,
  '16:9': 16 / 9,
  '4:3': 4 / 3,
  '1:1': 1,
  '9:16': 9 / 16,
};

/**
 * A visual and numeric editor for one crop rectangle. Values emitted to the
 * project remain in encoded-source coordinates even when the preview is
 * displayed at a rotated orientation.
 */
export function CropOverlay({
  children,
  sourceWidth,
  sourceHeight,
  orientation = 0,
  value,
  onChange,
  disabled = false,
  requireEvenDimensions = false,
  minimumSize = requireEvenDimensions ? 2 : 1,
  ariaLabel = 'Visual crop editor',
}: CropOverlayProps) {
  const instanceId = useId();
  const errorId = `${instanceId}-crop-errors`;
  const stageRef = useRef<HTMLDivElement>(null);
  const dragRef = useRef<ActiveDrag | undefined>(undefined);
  const sourceDimensions = useMemo(
    () => validateSourceDimensions(sourceWidth, sourceHeight),
    [sourceHeight, sourceWidth]
  );
  const effectiveMinimum = normalizeMinimumSize(
    minimumSize,
    requireEvenDimensions
  );
  validateCropBounds(sourceDimensions, effectiveMinimum, requireEvenDimensions);
  const initialCrop = useMemo(
    () =>
      resolveCropValue(
        value,
        sourceDimensions,
        effectiveMinimum,
        requireEvenDimensions
      ),
    [effectiveMinimum, requireEvenDimensions, sourceDimensions, value]
  );
  const cropSourceKey = createCropSourceKey(
    initialCrop,
    sourceDimensions,
    requireEvenDimensions
  );
  const [draftState, setDraftState] = useState<CropDraftState>(() => ({
    sourceKey: cropSourceKey,
    values: cropToDraft(initialCrop),
  }));
  const [aspectLocked, setAspectLocked] = useState(false);
  const [ratioChoice, setRatioChoice] = useState<RatioChoice>('current');
  const [lockedDisplayRatio, setLockedDisplayRatio] = useState(() => {
    const displayed = cropToDisplayedRectangle(
      initialCrop,
      sourceDimensions,
      orientation
    );
    return displayed.width / displayed.height;
  });

  const crop = initialCrop;
  const draft =
    draftState.sourceKey === cropSourceKey
      ? draftState.values
      : cropToDraft(initialCrop);
  const displayedDimensions = getDisplayedDimensions(
    sourceDimensions,
    orientation
  );
  const displayedCrop = cropToDisplayedRectangle(
    crop,
    sourceDimensions,
    orientation
  );
  const draftResult = validateCropDraft(
    draft,
    sourceDimensions,
    effectiveMinimum,
    requireEvenDimensions
  );
  const externalValueErrors =
    value === undefined
      ? []
      : validateCrop(
          value,
          sourceDimensions,
          effectiveMinimum,
          requireEvenDimensions
        );
  const visibleErrors =
    draftResult.errors.length > 0 ? draftResult.errors : externalValueErrors;
  const displayStyle = rectangleToPercentStyle(
    displayedCrop,
    displayedDimensions
  );
  const mediaStyle = getOrientedMediaStyle(
    sourceDimensions,
    displayedDimensions,
    orientation
  );

  const emitDisplayedRectangle = (rectangle: Rectangle) => {
    const encoded = displayedRectangleToCrop(
      rectangle,
      sourceDimensions,
      orientation
    );
    const normalized = normalizeCrop(
      encoded,
      sourceDimensions,
      effectiveMinimum,
      requireEvenDimensions
    );
    setDraftState({
      sourceKey: cropSourceKey,
      values: cropToDraft(normalized),
    });
    onChange(normalized);
  };

  const beginDrag = (
    event: PointerEvent<HTMLButtonElement>,
    mode: DragMode
  ) => {
    if (disabled) return;
    event.preventDefault();
    event.stopPropagation();
    event.currentTarget.setPointerCapture?.(event.pointerId);
    dragRef.current = {
      mode,
      pointerId: event.pointerId,
      startClientX: event.clientX,
      startClientY: event.clientY,
      startRectangle: displayedCrop,
    };
  };

  const continueDrag = (event: PointerEvent<HTMLButtonElement>) => {
    const active = dragRef.current;
    const bounds = stageRef.current?.getBoundingClientRect();
    if (
      disabled ||
      active === undefined ||
      active.pointerId !== event.pointerId ||
      bounds === undefined ||
      bounds.width <= 0 ||
      bounds.height <= 0
    ) {
      return;
    }
    event.preventDefault();
    const deltaX =
      ((event.clientX - active.startClientX) / bounds.width) *
      displayedDimensions.width;
    const deltaY =
      ((event.clientY - active.startClientY) / bounds.height) *
      displayedDimensions.height;
    const next =
      active.mode === 'move'
        ? moveRectangle(
            active.startRectangle,
            deltaX,
            deltaY,
            displayedDimensions
          )
        : resizeRectangle(
            active.startRectangle,
            active.mode,
            deltaX,
            deltaY,
            displayedDimensions,
            effectiveMinimum,
            aspectLocked ? lockedDisplayRatio : undefined
          );
    emitDisplayedRectangle(next);
  };

  const endDrag = (event: PointerEvent<HTMLButtonElement>) => {
    if (dragRef.current?.pointerId === event.pointerId) {
      event.currentTarget.releasePointerCapture?.(event.pointerId);
      dragRef.current = undefined;
    }
  };

  const handleKeyboard = (
    event: KeyboardEvent<HTMLButtonElement>,
    mode: DragMode
  ) => {
    if (disabled) return;
    const step = event.shiftKey ? 10 : 1;
    const delta = arrowDelta(event.key, step);
    if (delta === undefined) return;
    event.preventDefault();
    const next =
      mode === 'move'
        ? moveRectangle(displayedCrop, delta.x, delta.y, displayedDimensions)
        : resizeRectangle(
            displayedCrop,
            mode,
            delta.x,
            delta.y,
            displayedDimensions,
            effectiveMinimum,
            aspectLocked ? lockedDisplayRatio : undefined
          );
    emitDisplayedRectangle(next);
  };

  const updateDraft = (
    field: CropField,
    event: ChangeEvent<HTMLInputElement>
  ) => {
    const nextDraft = { ...draft, [field]: event.currentTarget.value };
    setDraftState({ sourceKey: cropSourceKey, values: nextDraft });
    const result = validateCropDraft(
      nextDraft,
      sourceDimensions,
      effectiveMinimum,
      requireEvenDimensions
    );
    if (result.crop !== undefined) {
      onChange(result.crop);
    }
  };

  const reset = () => {
    const fullCrop = normalizeCrop(
      {
        x: 0,
        y: 0,
        width: sourceDimensions.width,
        height: sourceDimensions.height,
      },
      sourceDimensions,
      effectiveMinimum,
      requireEvenDimensions
    );
    setRatioChoice('source');
    setLockedDisplayRatio(
      getDisplayedDimensions(sourceDimensions, orientation).width /
        getDisplayedDimensions(sourceDimensions, orientation).height
    );
    setDraftState({
      sourceKey: cropSourceKey,
      values: cropToDraft(fullCrop),
    });
    onChange(fullCrop);
  };

  const selectRatio = (event: ChangeEvent<HTMLSelectElement>) => {
    const choice = event.currentTarget.value as RatioChoice;
    const ratio =
      choice === 'current'
        ? displayedCrop.width / displayedCrop.height
        : choice === 'source'
          ? displayedDimensions.width / displayedDimensions.height
          : ratioValues[choice];
    setRatioChoice(choice);
    setAspectLocked(true);
    setLockedDisplayRatio(ratio);
    emitDisplayedRectangle(
      fitRectangleToRatio(displayedCrop, ratio, displayedDimensions)
    );
  };

  const toggleAspectLock = (event: ChangeEvent<HTMLInputElement>) => {
    const locked = event.currentTarget.checked;
    setAspectLocked(locked);
    if (locked) {
      setRatioChoice('current');
      setLockedDisplayRatio(displayedCrop.width / displayedCrop.height);
    }
  };

  return (
    <section
      className="crop-editor"
      aria-label={ariaLabel}
      data-orientation={orientation}
    >
      <div
        ref={stageRef}
        className="crop-editor__stage"
        style={{
          aspectRatio: `${displayedDimensions.width} / ${displayedDimensions.height}`,
        }}
      >
        <div className="crop-editor__media" style={mediaStyle}>
          {children}
        </div>
        <div className="crop-editor__shade" aria-hidden="true" />
        <div className="crop-editor__rectangle" style={displayStyle}>
          <button
            type="button"
            className="crop-editor__move"
            aria-label="Move crop rectangle"
            aria-keyshortcuts="ArrowUp ArrowRight ArrowDown ArrowLeft"
            disabled={disabled}
            onPointerDown={(event) => beginDrag(event, 'move')}
            onPointerMove={continueDrag}
            onPointerUp={endDrag}
            onPointerCancel={endDrag}
            onKeyDown={(event) => handleKeyboard(event, 'move')}
          >
            <span aria-hidden="true">Move</span>
          </button>
          {handles.map((handle) => (
            <button
              key={handle}
              type="button"
              className={`crop-editor__handle crop-editor__handle--${handle}`}
              aria-label={`Resize crop from ${handleNames[handle]}`}
              aria-keyshortcuts="ArrowUp ArrowRight ArrowDown ArrowLeft"
              disabled={disabled}
              onPointerDown={(event) => beginDrag(event, handle)}
              onPointerMove={continueDrag}
              onPointerUp={endDrag}
              onPointerCancel={endDrag}
              onKeyDown={(event) => handleKeyboard(event, handle)}
            />
          ))}
        </div>
      </div>

      <div className="crop-editor__toolbar">
        <label className="crop-editor__lock">
          <input
            type="checkbox"
            checked={aspectLocked}
            disabled={disabled}
            onChange={toggleAspectLock}
          />
          Lock aspect ratio
        </label>
        <label>
          <span>Common ratio</span>
          <select
            value={ratioChoice}
            disabled={disabled}
            onChange={selectRatio}
          >
            <option value="current">Current crop</option>
            <option value="source">Source display ratio</option>
            <option value="16:9">16:9</option>
            <option value="4:3">4:3</option>
            <option value="1:1">1:1</option>
            <option value="9:16">9:16</option>
          </select>
        </label>
        <button type="button" disabled={disabled} onClick={reset}>
          Reset crop
        </button>
      </div>

      <fieldset className="crop-editor__numeric" disabled={disabled}>
        <legend>Encoded source pixels</legend>
        {(['x', 'y', 'width', 'height'] as const).map((field) => (
          <label key={field}>
            <span>
              {field === 'x' || field === 'y'
                ? field.toUpperCase()
                : capitalize(field)}
            </span>
            <input
              type="number"
              inputMode="numeric"
              step={requireEvenDimensions ? 2 : 1}
              min={
                field === 'width' || field === 'height' ? effectiveMinimum : 0
              }
              max={
                field === 'x' || field === 'width'
                  ? sourceDimensions.width
                  : sourceDimensions.height
              }
              value={draft[field]}
              aria-invalid={visibleErrors.length > 0}
              aria-describedby={visibleErrors.length > 0 ? errorId : undefined}
              onChange={(event) => updateDraft(field, event)}
            />
          </label>
        ))}
      </fieldset>
      <p className="crop-editor__hint">
        Display orientation: {orientation}°. Crop values are saved against the
        encoded {sourceDimensions.width}×{sourceDimensions.height} source.
      </p>
      {visibleErrors.length > 0 ? (
        <ul id={errorId} className="crop-editor__errors" role="alert">
          {visibleErrors.map((error) => (
            <li key={error}>{error}</li>
          ))}
        </ul>
      ) : null}
    </section>
  );
}

function validateSourceDimensions(width: number, height: number): Dimensions {
  if (
    !Number.isSafeInteger(width) ||
    !Number.isSafeInteger(height) ||
    width <= 0 ||
    height <= 0
  ) {
    throw new RangeError(
      'Crop source dimensions must be positive safe integers.'
    );
  }
  return Object.freeze({ width, height });
}

function normalizeMinimumSize(value: number, requireEven: boolean): number {
  if (!Number.isSafeInteger(value) || value < 1) {
    throw new RangeError('Crop minimum size must be a positive safe integer.');
  }
  return requireEven && value % 2 !== 0 ? value + 1 : value;
}

function validateCropBounds(
  bounds: Dimensions,
  minimum: number,
  requireEven: boolean
): void {
  if (bounds.width < minimum || bounds.height < minimum) {
    throw new RangeError(
      'Crop source dimensions are smaller than the minimum crop size.'
    );
  }
  if (
    requireEven &&
    (bounds.width < 2 ||
      bounds.height < 2 ||
      bounds.width - (bounds.width % 2) < minimum ||
      bounds.height - (bounds.height % 2) < minimum)
  ) {
    throw new RangeError(
      'Crop source dimensions cannot contain the required even crop.'
    );
  }
}

function resolveCropValue(
  value: CropSettings | undefined,
  bounds: Dimensions,
  minimum: number,
  requireEven: boolean
): CropSettings {
  const fallback = normalizeCrop(
    { x: 0, y: 0, width: bounds.width, height: bounds.height },
    bounds,
    minimum,
    requireEven
  );
  if (value === undefined) return fallback;
  return validateCrop(value, bounds, minimum, requireEven).length === 0
    ? Object.freeze({ ...value })
    : fallback;
}

function normalizeCrop(
  value: Rectangle,
  bounds: Dimensions,
  minimum: number,
  requireEven: boolean
): CropSettings {
  let x = Math.round(value.x);
  let y = Math.round(value.y);
  let width = Math.round(value.width);
  let height = Math.round(value.height);
  width = Math.max(minimum, Math.min(width, bounds.width));
  height = Math.max(minimum, Math.min(height, bounds.height));
  x = clamp(Math.round(x), 0, bounds.width - width);
  y = clamp(Math.round(y), 0, bounds.height - height);

  if (requireEven) {
    width -= width % 2;
    height -= height % 2;
    x -= x % 2;
    y -= y % 2;
    width = Math.max(minimum, width);
    height = Math.max(minimum, height);
    x = Math.min(x, bounds.width - width);
    y = Math.min(y, bounds.height - height);
  }
  return Object.freeze({ x, y, width, height });
}

function validateCrop(
  crop: CropSettings,
  bounds: Dimensions,
  minimum: number,
  requireEven: boolean
): string[] {
  const errors: string[] = [];
  for (const [field, value] of Object.entries(crop)) {
    if (!Number.isSafeInteger(value)) {
      errors.push(`${capitalize(field)} must be a whole number.`);
    }
  }
  if (crop.x < 0 || crop.y < 0) {
    errors.push('X and Y must not be negative.');
  }
  if (crop.width < minimum || crop.height < minimum) {
    errors.push(`Width and height must be at least ${String(minimum)} pixels.`);
  }
  if (crop.x + crop.width > bounds.width) {
    errors.push('The crop extends beyond the source width.');
  }
  if (crop.y + crop.height > bounds.height) {
    errors.push('The crop extends beyond the source height.');
  }
  if (
    requireEven &&
    (crop.x % 2 !== 0 ||
      crop.y % 2 !== 0 ||
      crop.width % 2 !== 0 ||
      crop.height % 2 !== 0)
  ) {
    errors.push('X, Y, width and height must be even for this output.');
  }
  return errors;
}

function cropToDraft(crop: CropSettings): Record<CropField, string> {
  return {
    x: String(crop.x),
    y: String(crop.y),
    width: String(crop.width),
    height: String(crop.height),
  };
}

function createCropSourceKey(
  crop: CropSettings,
  source: Dimensions,
  requireEven: boolean
): string {
  return [
    crop.x,
    crop.y,
    crop.width,
    crop.height,
    source.width,
    source.height,
    requireEven ? 1 : 0,
  ].join(':');
}

function validateCropDraft(
  draft: Record<CropField, string>,
  bounds: Dimensions,
  minimum: number,
  requireEven: boolean
): { crop?: CropSettings; errors: string[] } {
  const values = Object.fromEntries(
    Object.entries(draft).map(([field, value]) => [field, Number(value)])
  ) as unknown as CropSettings;
  const errors = Object.values(draft).some((value) => value.trim() === '')
    ? ['All crop fields are required.']
    : validateCrop(values, bounds, minimum, requireEven);
  return errors.length === 0
    ? { crop: Object.freeze({ ...values }), errors }
    : { errors };
}

function getDisplayedDimensions(
  source: Dimensions,
  orientation: Orientation
): Dimensions {
  return orientation === 90 || orientation === 270
    ? { width: source.height, height: source.width }
    : source;
}

function cropToDisplayedRectangle(
  crop: CropSettings,
  source: Dimensions,
  orientation: Orientation
): Rectangle {
  switch (orientation) {
    case 90:
      return {
        x: source.height - crop.y - crop.height,
        y: crop.x,
        width: crop.height,
        height: crop.width,
      };
    case 180:
      return {
        x: source.width - crop.x - crop.width,
        y: source.height - crop.y - crop.height,
        width: crop.width,
        height: crop.height,
      };
    case 270:
      return {
        x: crop.y,
        y: source.width - crop.x - crop.width,
        width: crop.height,
        height: crop.width,
      };
    default:
      return { ...crop };
  }
}

function displayedRectangleToCrop(
  rectangle: Rectangle,
  source: Dimensions,
  orientation: Orientation
): Rectangle {
  switch (orientation) {
    case 90:
      return {
        x: rectangle.y,
        y: source.height - rectangle.x - rectangle.width,
        width: rectangle.height,
        height: rectangle.width,
      };
    case 180:
      return {
        x: source.width - rectangle.x - rectangle.width,
        y: source.height - rectangle.y - rectangle.height,
        width: rectangle.width,
        height: rectangle.height,
      };
    case 270:
      return {
        x: source.width - rectangle.y - rectangle.height,
        y: rectangle.x,
        width: rectangle.height,
        height: rectangle.width,
      };
    default:
      return { ...rectangle };
  }
}

function rectangleToPercentStyle(
  rectangle: Rectangle,
  bounds: Dimensions
): React.CSSProperties {
  return {
    left: `${(rectangle.x / bounds.width) * 100}%`,
    top: `${(rectangle.y / bounds.height) * 100}%`,
    width: `${(rectangle.width / bounds.width) * 100}%`,
    height: `${(rectangle.height / bounds.height) * 100}%`,
  };
}

function getOrientedMediaStyle(
  source: Dimensions,
  displayed: Dimensions,
  orientation: Orientation
): React.CSSProperties {
  const rotated = orientation === 90 || orientation === 270;
  return {
    width: rotated ? `${(source.width / displayed.width) * 100}%` : '100%',
    height: rotated ? `${(source.height / displayed.height) * 100}%` : '100%',
    transform: `translate(-50%, -50%) rotate(${String(orientation)}deg)`,
  };
}

function moveRectangle(
  rectangle: Rectangle,
  deltaX: number,
  deltaY: number,
  bounds: Dimensions
): Rectangle {
  return {
    ...rectangle,
    x: clamp(rectangle.x + deltaX, 0, bounds.width - rectangle.width),
    y: clamp(rectangle.y + deltaY, 0, bounds.height - rectangle.height),
  };
}

function resizeRectangle(
  rectangle: Rectangle,
  handle: ResizeHandle,
  deltaX: number,
  deltaY: number,
  bounds: Dimensions,
  minimum: number,
  ratio?: number
): Rectangle {
  if (ratio === undefined) {
    const left = handle.includes('w')
      ? clamp(rectangle.x + deltaX, 0, rectangle.x + rectangle.width - minimum)
      : rectangle.x;
    const right = handle.includes('e')
      ? clamp(
          rectangle.x + rectangle.width + deltaX,
          rectangle.x + minimum,
          bounds.width
        )
      : rectangle.x + rectangle.width;
    const top = handle.includes('n')
      ? clamp(rectangle.y + deltaY, 0, rectangle.y + rectangle.height - minimum)
      : rectangle.y;
    const bottom = handle.includes('s')
      ? clamp(
          rectangle.y + rectangle.height + deltaY,
          rectangle.y + minimum,
          bounds.height
        )
      : rectangle.y + rectangle.height;
    return {
      x: left,
      y: top,
      width: right - left,
      height: bottom - top,
    };
  }

  if (!Number.isFinite(ratio) || ratio <= 0) return rectangle;
  const horizontal = handle.includes('w') ? -1 : handle.includes('e') ? 1 : 0;
  const vertical = handle.includes('n') ? -1 : handle.includes('s') ? 1 : 0;
  const minimumWidth = Math.max(minimum, minimum * ratio);

  if (horizontal !== 0 && vertical !== 0) {
    const anchorX =
      horizontal < 0 ? rectangle.x + rectangle.width : rectangle.x;
    const anchorY = vertical < 0 ? rectangle.y + rectangle.height : rectangle.y;
    const movingX =
      (horizontal < 0 ? rectangle.x : rectangle.x + rectangle.width) + deltaX;
    const movingY =
      (vertical < 0 ? rectangle.y : rectangle.y + rectangle.height) + deltaY;
    const rawWidth = Math.abs(movingX - anchorX);
    const rawHeight = Math.abs(movingY - anchorY);
    const requestedWidth =
      Math.abs(rawWidth - rectangle.width) >=
      Math.abs(rawHeight * ratio - rectangle.width)
        ? rawWidth
        : rawHeight * ratio;
    const maxWidthX = horizontal < 0 ? anchorX : bounds.width - anchorX;
    const maxHeightY = vertical < 0 ? anchorY : bounds.height - anchorY;
    const width = clamp(
      requestedWidth,
      Math.min(minimumWidth, maxWidthX, maxHeightY * ratio),
      Math.min(maxWidthX, maxHeightY * ratio)
    );
    const height = width / ratio;
    return {
      x: horizontal < 0 ? anchorX - width : anchorX,
      y: vertical < 0 ? anchorY - height : anchorY,
      width,
      height,
    };
  }

  if (horizontal !== 0) {
    const anchorX =
      horizontal < 0 ? rectangle.x + rectangle.width : rectangle.x;
    const movingX =
      (horizontal < 0 ? rectangle.x : rectangle.x + rectangle.width) + deltaX;
    const centerY = rectangle.y + rectangle.height / 2;
    const maxHeight = 2 * Math.min(centerY, bounds.height - centerY);
    const maxWidth = Math.min(
      horizontal < 0 ? anchorX : bounds.width - anchorX,
      maxHeight * ratio
    );
    const width = clamp(
      Math.abs(movingX - anchorX),
      Math.min(minimumWidth, maxWidth),
      maxWidth
    );
    const height = width / ratio;
    return {
      x: horizontal < 0 ? anchorX - width : anchorX,
      y: centerY - height / 2,
      width,
      height,
    };
  }

  const anchorY = vertical < 0 ? rectangle.y + rectangle.height : rectangle.y;
  const movingY =
    (vertical < 0 ? rectangle.y : rectangle.y + rectangle.height) + deltaY;
  const centerX = rectangle.x + rectangle.width / 2;
  const maxWidth = 2 * Math.min(centerX, bounds.width - centerX);
  const maxHeight = Math.min(
    vertical < 0 ? anchorY : bounds.height - anchorY,
    maxWidth / ratio
  );
  const height = clamp(
    Math.abs(movingY - anchorY),
    Math.min(minimum, maxHeight),
    maxHeight
  );
  const width = height * ratio;
  return {
    x: centerX - width / 2,
    y: vertical < 0 ? anchorY - height : anchorY,
    width,
    height,
  };
}

function fitRectangleToRatio(
  rectangle: Rectangle,
  ratio: number,
  bounds: Dimensions
): Rectangle {
  let width = rectangle.width;
  let height = width / ratio;
  if (height > rectangle.height) {
    height = rectangle.height;
    width = height * ratio;
  }
  if (width > bounds.width) {
    width = bounds.width;
    height = width / ratio;
  }
  if (height > bounds.height) {
    height = bounds.height;
    width = height * ratio;
  }
  return {
    x: clamp(
      rectangle.x + (rectangle.width - width) / 2,
      0,
      bounds.width - width
    ),
    y: clamp(
      rectangle.y + (rectangle.height - height) / 2,
      0,
      bounds.height - height
    ),
    width,
    height,
  };
}

function arrowDelta(
  key: string,
  step: number
): { x: number; y: number } | undefined {
  switch (key) {
    case 'ArrowLeft':
      return { x: -step, y: 0 };
    case 'ArrowRight':
      return { x: step, y: 0 };
    case 'ArrowUp':
      return { x: 0, y: -step };
    case 'ArrowDown':
      return { x: 0, y: step };
    default:
      return undefined;
  }
}

function capitalize(value: string): string {
  return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}

function clamp(value: number, minimum: number, maximum: number): number {
  if (maximum < minimum) return maximum;
  return Math.min(Math.max(value, minimum), maximum);
}
