// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type JSX,
} from 'react';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import { fabric } from 'fabric';
import lodash from 'lodash';
import type {
  DraftBodyRanges,
  HydratedBodyRangesType,
} from '../types/BodyRange.std.ts';
import type { ImageStateType } from '../mediaEditor/ImageStateType.std.ts';
import type {
  InputApi,
  Props as CompositionInputProps,
} from './CompositionInput.dom.tsx';
import type { LocalizerType } from '../types/Util.std.ts';
import type { MIMEType } from '../types/MIME.std.ts';
import type { imageToBlurHash } from '../util/imageToBlurHash.dom.ts';
import { MediaEditorFabricAnalogTimeSticker } from '../mediaEditor/MediaEditorFabricAnalogTimeSticker.dom.ts';
import { MediaEditorFabricCropRect } from '../mediaEditor/MediaEditorFabricCropRect.dom.ts';
import { MediaEditorFabricDigitalTimeSticker } from '../mediaEditor/MediaEditorFabricDigitalTimeSticker.dom.ts';
import { MediaEditorFabricIText } from '../mediaEditor/MediaEditorFabricIText.dom.ts';
import { MediaEditorFabricPencilBrush } from '../mediaEditor/MediaEditorFabricPencilBrush.dom.ts';
import { MediaEditorFabricSticker } from '../mediaEditor/MediaEditorFabricSticker.dom.ts';
import { fabricEffectListener } from '../mediaEditor/fabricEffectListener.std.ts';
import { getRGBA, getHSL } from '../mediaEditor/util/color.std.ts';
import {
  getTextStyleAttributes,
  TextStyle,
} from '../mediaEditor/util/getTextStyleAttributes.std.ts';
import { createLogger } from '../logging/log.std.ts';
import { Button, ButtonVariant } from './Button.dom.tsx';
import { CompositionInput } from './CompositionInput.dom.tsx';
import { ContextMenu } from './ContextMenu.dom.tsx';
import { IMAGE_PNG } from '../types/MIME.std.ts';
import { SizeObserver } from '../hooks/useSizeObserver.dom.tsx';
import { Slider } from './Slider.dom.tsx';
import { Theme } from '../util/theme.std.ts';
import { ThemeType } from '../types/Util.std.ts';
import { arrow } from '../util/keyboard.dom.ts';
import { canvasToBytes } from '../util/canvasToBytes.std.ts';
import { loadImage } from '../util/loadImage.std.ts';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.tsx';
import { useFabricHistory } from '../mediaEditor/useFabricHistory.dom.ts';
import { usePortal } from '../hooks/usePortal.dom.ts';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import {
  FunEmojiPickerButton,
  FunStickerPickerButton,
} from './fun/FunButton.dom.tsx';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
import { FunStickerPicker } from './fun/FunStickerPicker.dom.tsx';
import type { FunStickerSelection } from './fun/panels/FunPanelStickers.dom.tsx';
import { drop } from '../util/drop.std.ts';
import { MediaQualitySelector } from './MediaQualitySelector.dom.tsx';
import { AxoButton } from '../axo/AxoButton.dom.tsx';
import { tw } from '../axo/tw.dom.tsx';
import type { FunTimeStickerStyle } from './fun/constants.dom.tsx';
import * as Errors from '../types/errors.std.ts';
import { AxoTheme } from '../axo/AxoTheme.dom.tsx';

const { get, has, noop } = lodash;

const log = createLogger('MediaEditor');

export type MediaEditorResultType = Readonly<{
  data: Uint8Array<ArrayBuffer>;
  contentType: MIMEType;
  blurHash: string;
  caption?: string;
  captionBodyRanges?: DraftBodyRanges;
  isViewOnce?: boolean;
  isHighQuality?: boolean;
}>;

export type PropsType = {
  isCreatingStory: boolean;
  doneButtonLabel?: string;
  i18n: LocalizerType;
  imageSrc: string;
  isSending: boolean;
  imageToBlurHash: typeof imageToBlurHash;
  onClose: () => unknown;
  onDone: (result: MediaEditorResultType) => unknown;
  convertDraftBodyRangesIntoHydrated: (
    bodyRanges: DraftBodyRanges | undefined
  ) => HydratedBodyRangesType | undefined;
  isHighQuality?: boolean;
  isViewOnce?: boolean;
  showViewOnceToggle?: boolean;
} & Pick<
  CompositionInputProps,
  | 'draftText'
  | 'draftBodyRanges'
  | 'getPreferredBadge'
  | 'isFormattingEnabled'
  | 'emojiSkinToneDefault'
  | 'onSelectEmoji'
  | 'onTextTooLong'
  | 'ourConversationId'
  | 'platform'
  | 'sortedGroupMembers'
>;

const INITIAL_IMAGE_STATE: ImageStateType = {
  angle: 0,
  cropX: 0,
  cropY: 0,
  flipX: false,
  flipY: false,
  height: 0,
  width: 0,
};

enum EditMode {
  Crop = 'Crop',
  Draw = 'Draw',
  Text = 'Text',
}

enum DrawWidth {
  Thin = 2,
  Regular = 4,
  Medium = 12,
  Heavy = 24,
}

enum DrawTool {
  Pen = 'Pen',
  Highlighter = 'Highlighter',
}

enum CropPreset {
  Freeform = 'Freeform',
  Square = 'Square',
  Vertical = 'Vertical',
}

type PendingCropType = {
  left: number;
  top: number;
  width: number;
  height: number;
};

function isCmdOrCtrl(ev: KeyboardEvent): boolean {
  const { ctrlKey, metaKey } = ev;
  const commandKey = get(window, 'platform') === 'darwin' && metaKey;
  const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey;
  return commandKey || controlKey;
}

export function MediaEditor({
  doneButtonLabel,
  i18n,
  imageSrc,
  isCreatingStory,
  isSending,
  onClose,
  onDone,
  isHighQuality,
  isViewOnce,
  showViewOnceToggle = false,

  // CompositionInput
  draftText,
  draftBodyRanges,
  getPreferredBadge,
  isFormattingEnabled,
  emojiSkinToneDefault,
  onSelectEmoji,
  onTextTooLong,
  ourConversationId,
  platform,
  sortedGroupMembers,
  convertDraftBodyRangesIntoHydrated,
  imageToBlurHash,
}: PropsType): JSX.Element | null {
  const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>();
  const [image, setImage] = useState<HTMLImageElement>(new Image());
  const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
  const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);

  const [caption, setCaption] = useState(draftText ?? '');
  const [captionBodyRanges, setCaptionBodyRanges] =
    useState<DraftBodyRanges | null>(draftBodyRanges);
  const [localIsViewOnce, setLocalIsViewOnce] = useState(isViewOnce ?? false);
  const hasViewOnceChange = localIsViewOnce !== (isViewOnce ?? false);
  const [localIsHighQuality, setLocalIsHighQuality] = useState(
    isHighQuality ?? false
  );
  const hasHighQualityChange =
    typeof isHighQuality === 'boolean' && localIsHighQuality !== isHighQuality;
  const showMediaQualitySelector = typeof isHighQuality === 'boolean';
  const pickerTheme = ThemeType.dark;

  const hydratedBodyRanges = useMemo(
    () => convertDraftBodyRangesIntoHydrated(captionBodyRanges ?? undefined),
    [captionBodyRanges, convertDraftBodyRangesIntoHydrated]
  );

  const inputApiRef = useRef<InputApi | null>(null);

  const canvasId = useId();

  const [imageState, setImageState] =
    useState<ImageStateType>(INITIAL_IMAGE_STATE);

  const handleEmojiPickerOpenChange = useCallback((open: boolean) => {
    setEmojiPickerOpen(open);
  }, []);

  const handleStickerPickerOpenChange = useCallback((open: boolean) => {
    setStickerPickerOpen(open);
  }, []);

  const handleSelectEmoji = useCallback((emojiSelection: FunEmojiSelection) => {
    if (inputApiRef.current) {
      inputApiRef.current.insertEmoji(emojiSelection);
    }
  }, []);

  const handleSelectQuality = useCallback((_id: string, isHQ: boolean) => {
    setLocalIsHighQuality(isHQ);
  }, []);

  const handlePickSticker = useCallback(
    (_packId: string, _stickerId: number, src: string) => {
      async function run() {
        if (!fabricCanvas) {
          return;
        }

        const img = await loadImage(src);

        const STICKER_SIZE_RELATIVE_TO_CANVAS = 4;
        const size =
          Math.min(imageState.width, imageState.height) /
          STICKER_SIZE_RELATIVE_TO_CANVAS;

        const sticker = new MediaEditorFabricSticker(img);
        sticker.scaleToHeight(size);
        sticker.setPositionByOrigin(
          new fabric.Point(imageState.width / 2, imageState.height / 2),
          'center',
          'center'
        );
        sticker.setCoords();

        fabricCanvas.add(sticker);
        fabricCanvas.setActiveObject(sticker);
        setEditMode(undefined);
      }

      drop(run());
    },
    [fabricCanvas, imageState.height, imageState.width]
  );

  const handlePickTimeSticker = useCallback(
    (style: FunTimeStickerStyle) => {
      if (!fabricCanvas) {
        return;
      }

      if (style === 'digital') {
        const sticker = new MediaEditorFabricDigitalTimeSticker(Date.now());
        sticker.setPositionByOrigin(
          new fabric.Point(imageState.width / 2, imageState.height / 2),
          'center',
          'center'
        );
        sticker.setCoords();

        fabricCanvas.add(sticker);
        fabricCanvas.setActiveObject(sticker);
      }

      if (style === 'analog') {
        const sticker = new MediaEditorFabricAnalogTimeSticker();
        const STICKER_SIZE_RELATIVE_TO_CANVAS = 4;
        const size =
          Math.min(imageState.width, imageState.height) /
          STICKER_SIZE_RELATIVE_TO_CANVAS;

        sticker.scaleToHeight(size);
        sticker.setPositionByOrigin(
          new fabric.Point(imageState.width / 2, imageState.height / 2),
          'center',
          'center'
        );
        sticker.setCoords();

        fabricCanvas.add(sticker);
        fabricCanvas.setActiveObject(sticker);
      }

      setEditMode(undefined);
    },
    [fabricCanvas, imageState.height, imageState.width]
  );

  const handleSelectSticker = useCallback(
    (stickerSelection: FunStickerSelection) => {
      handlePickSticker(
        stickerSelection.stickerPackId,
        stickerSelection.stickerId,
        stickerSelection.stickerUrl
      );
    },
    [handlePickSticker]
  );

  const [cropPreset, setCropPreset] = useState<CropPreset>(CropPreset.Freeform);

  // History state
  const { canRedo, canUndo, redoIfPossible, takeSnapshot, undoIfPossible } =
    useFabricHistory({
      fabricCanvas,
      imageState,
      setImageState,
    });

  // Initial image load and Fabric canvas setup
  useEffect(() => {
    // This is important. We can't re-run this function if we've already setup
    //    a canvas since Fabric doesn't like that.
    if (fabricCanvas) {
      return;
    }

    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      setImage(img);

      const canvas = new fabric.Canvas(canvasId);
      canvas.selection = false;
      setFabricCanvas(canvas);

      const newImageState = {
        ...INITIAL_IMAGE_STATE,
        height: img.height,
        width: img.width,
      };
      setImageState(newImageState);
    };
    img.onerror = (
      event: Event | string,
      source?: string,
      line?: number,
      column?: number,
      error?: Error
    ) => {
      // This is a bad experience, but it should be impossible.
      log.error(
        '<MediaEditor>: image failed to load. Closing',
        event,
        Errors.toLocation(source, line, column),
        Errors.toLogFormat(error)
      );
      onClose();
    };
    img.src = imageSrc;
    return () => {
      img.onload = noop;
      img.onerror = noop;
    };
  }, [canvasId, fabricCanvas, imageSrc, onClose, takeSnapshot]);

  const [editMode, setEditMode] = useState<EditMode | undefined>();

  const tryClose = useRef<(() => void) | null>(null);
  const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
    i18n,
    name: 'MediaEditor',
    // @ts-expect-error ConfirmationDialog migration: Needs title
    title: null,
    // @ts-expect-error ConfirmationDialog migration: Needs description
    description: null,
    tryClose,
  });

  const onTryClose = useCallback(() => {
    confirmDiscardIf(
      canUndo || isCreatingStory || hasViewOnceChange || hasHighQualityChange,
      onClose
    );
  }, [
    confirmDiscardIf,
    canUndo,
    isCreatingStory,
    hasViewOnceChange,
    hasHighQualityChange,
    onClose,
  ]);
  tryClose.current = onTryClose;

  // Keyboard support
  useEffect(() => {
    if (!fabricCanvas) {
      return noop;
    }

    const globalShortcuts: Array<
      [(ev: KeyboardEvent) => boolean, () => unknown]
    > = [
      [
        ev => isCmdOrCtrl(ev) && ev.key === 'c',
        () => setEditMode(EditMode.Crop),
      ],
      [
        ev => isCmdOrCtrl(ev) && ev.key === 'd',
        () => setEditMode(EditMode.Draw),
      ],
      [
        ev => isCmdOrCtrl(ev) && ev.key === 't',
        () => setEditMode(EditMode.Text),
      ],
      [ev => isCmdOrCtrl(ev) && ev.key === 'z', undoIfPossible],
      [ev => isCmdOrCtrl(ev) && ev.shiftKey && ev.key === 'z', redoIfPossible],
      [
        ev => ev.key === 'Escape',
        () => {
          // if the emoji popper is open,
          // it will use the escape key to close itself
          if (emojiPickerOpen) {
            return;
          }

          // close window if the user is not in the middle of something
          if (editMode === undefined) {
            // if the stickers popper is open,
            // it will use the escape key to close itself
            //
            // there's no easy way to prevent an ESC meant for the
            // sticker-picker from hitting this handler first
            if (!stickerPickerOpen) {
              onTryClose();
            }
          } else {
            setEditMode(undefined);
          }

          if (fabricCanvas.getActiveObject()) {
            fabricCanvas.discardActiveObject();
            fabricCanvas.requestRenderAll();
          }
        },
      ],
    ];

    const objectShortcuts: Array<
      [
        (ev: KeyboardEvent) => boolean,
        (obj: fabric.Object, ev: KeyboardEvent) => unknown,
      ]
    > = [
      [
        ev => ev.key === 'Delete',
        obj => {
          fabricCanvas.remove(obj);
          setEditMode(undefined);
        },
      ],
      [
        ev => ev.key === 'ArrowUp',
        (obj, ev) => {
          const px = ev.shiftKey ? 20 : 1;
          if (ev.altKey) {
            obj.set('angle', (obj.angle || 0) - px);
          } else {
            const { x, y } = obj.getCenterPoint();
            obj.setPositionByOrigin(
              new fabric.Point(x, y - px),
              'center',
              'center'
            );
          }
          obj.setCoords();
          fabricCanvas.requestRenderAll();
        },
      ],
      [
        ev => ev.key === arrow('start'),
        (obj, ev) => {
          const px = ev.shiftKey ? 20 : 1;
          if (ev.altKey) {
            obj.set('angle', (obj.angle || 0) - px);
          } else {
            const { x, y } = obj.getCenterPoint();
            obj.setPositionByOrigin(
              new fabric.Point(x - px, y),
              'center',
              'center'
            );
          }
          obj.setCoords();
          fabricCanvas.requestRenderAll();
        },
      ],
      [
        ev => ev.key === 'ArrowDown',
        (obj, ev) => {
          const px = ev.shiftKey ? 20 : 1;
          if (ev.altKey) {
            obj.set('angle', (obj.angle || 0) + px);
          } else {
            const { x, y } = obj.getCenterPoint();
            obj.setPositionByOrigin(
              new fabric.Point(x, y + px),
              'center',
              'center'
            );
          }
          obj.setCoords();
          fabricCanvas.requestRenderAll();
        },
      ],
      [
        ev => ev.key === arrow('end'),
        (obj, ev) => {
          const px = ev.shiftKey ? 20 : 1;
          if (ev.altKey) {
            obj.set('angle', (obj.angle || 0) + px);
          } else {
            const { x, y } = obj.getCenterPoint();
            obj.setPositionByOrigin(
              new fabric.Point(x + px, y),
              'center',
              'center'
            );
          }
          obj.setCoords();
          fabricCanvas.requestRenderAll();
        },
      ],
    ];

    function handleKeydown(ev: KeyboardEvent) {
      if (!fabricCanvas) {
        return;
      }

      globalShortcuts.forEach(([conditional, runShortcut]) => {
        if (conditional(ev)) {
          runShortcut();
          ev.preventDefault();
          ev.stopPropagation();
        }
      });

      const obj = fabricCanvas.getActiveObject();

      if (
        !obj ||
        obj.excludeFromExport ||
        (obj instanceof MediaEditorFabricIText && obj.isEditing)
      ) {
        return;
      }

      objectShortcuts.forEach(([conditional, runShortcut]) => {
        if (conditional(ev)) {
          runShortcut(obj, ev);
          ev.preventDefault();
          ev.stopPropagation();
        }
      });
    }

    document.addEventListener('keydown', handleKeydown);

    return () => {
      document.removeEventListener('keydown', handleKeydown);
    };
  }, [
    fabricCanvas,
    editMode,
    emojiPickerOpen,
    stickerPickerOpen,
    onTryClose,
    redoIfPossible,
    undoIfPossible,
  ]);

  const [containerWidth, setContainerWidth] = useState(0);
  const [containerHeight, setContainerHeight] = useState(0);

  const zoom =
    Math.min(
      containerWidth / imageState.width,
      containerHeight / imageState.height
    ) || 1;

  // Update the canvas dimensions (and therefore zoom)
  useEffect(() => {
    if (!fabricCanvas || !imageState.width || !imageState.height) {
      return;
    }
    fabricCanvas.setDimensions({
      width: imageState.width * zoom,
      height: imageState.height * zoom,
    });
    fabricCanvas.setZoom(zoom);
  }, [
    containerHeight,
    containerWidth,
    fabricCanvas,
    imageState.height,
    imageState.width,
    zoom,
  ]);

  // Refresh the background image according to imageState changes
  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }
    drawFabricBackgroundImage({ fabricCanvas, image, imageState });
  }, [fabricCanvas, image, imageState]);

  const initialSnapshotTaken = useRef(false);
  useEffect(() => {
    if (
      !fabricCanvas ||
      !fabricCanvas.backgroundImage ||
      initialSnapshotTaken.current
    ) {
      return;
    }

    takeSnapshot('initial state', imageState, fabricCanvas);
    initialSnapshotTaken.current = true;
  }, [fabricCanvas, imageState, takeSnapshot]);

  const [canCrop, setCanCrop] = useState(false);
  const [cropAspectRatioLock, setCropAspectRatioLock] = useState(false);
  const [drawTool, setDrawTool] = useState<DrawTool>(DrawTool.Pen);
  const [drawWidth, setDrawWidth] = useState<DrawWidth>(DrawWidth.Regular);
  const [sliderValue, setSliderValue] = useState<number>(0);
  const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular);

  // If you select a text path auto enter edit mode
  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }
    return fabricEffectListener(
      fabricCanvas,
      ['selection:created', 'selection:updated', 'selection:cleared'],
      () => {
        if (fabricCanvas?.getActiveObject() instanceof MediaEditorFabricIText) {
          setEditMode(EditMode.Text);
        } else if (editMode === EditMode.Text) {
          setEditMode(undefined);
        }
      }
    );
  }, [editMode, fabricCanvas]);

  // Ensure scaling is in locked|unlocked state only when cropping
  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }

    if (editMode === EditMode.Crop) {
      fabricCanvas.uniformScaling = cropAspectRatioLock;
    } else {
      fabricCanvas.uniformScaling = true;
    }
  }, [cropAspectRatioLock, editMode, fabricCanvas]);

  // Remove any blank text when edit mode changes off of text
  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }

    if (editMode !== EditMode.Text) {
      const obj = fabricCanvas.getActiveObject();
      if (obj && has(obj, 'text') && get(obj, 'text') === '') {
        fabricCanvas.remove(obj);
      }
    }
  }, [editMode, fabricCanvas]);

  // Toggle draw mode
  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }

    if (editMode !== EditMode.Draw) {
      fabricCanvas.isDrawingMode = false;
      return;
    }

    fabricCanvas.discardActiveObject();
    fabricCanvas.isDrawingMode = true;

    const freeDrawingBrush = new MediaEditorFabricPencilBrush(fabricCanvas);
    if (drawTool === DrawTool.Highlighter) {
      freeDrawingBrush.color = getRGBA(sliderValue, 0.5);
      freeDrawingBrush.strokeLineCap = 'square';
      freeDrawingBrush.strokeLineJoin = 'miter';
      freeDrawingBrush.width = (drawWidth / zoom) * 2;
    } else {
      freeDrawingBrush.color = getHSL(sliderValue);
      freeDrawingBrush.strokeLineCap = 'round';
      freeDrawingBrush.strokeLineJoin = 'bevel';
      freeDrawingBrush.width = drawWidth / zoom;
    }
    fabricCanvas.freeDrawingBrush = freeDrawingBrush;

    fabricCanvas.requestRenderAll();
  }, [drawTool, drawWidth, editMode, fabricCanvas, sliderValue, zoom]);

  // Change text style
  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }

    const obj = fabricCanvas.getActiveObject();

    if (!obj || !(obj instanceof MediaEditorFabricIText)) {
      return;
    }

    const { isEditing } = obj;
    obj.exitEditing();
    obj.set(getTextStyleAttributes(textStyle, sliderValue));
    fabricCanvas.requestRenderAll();
    if (isEditing) {
      obj.enterEditing();
    }
  }, [fabricCanvas, sliderValue, textStyle]);

  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }

    const rect = fabricCanvas.getObjects().find(obj => {
      return obj instanceof MediaEditorFabricCropRect;
    });

    if (!rect) {
      return;
    }

    const PADDING = MediaEditorFabricCropRect.PADDING / zoom;
    let height =
      imageState.height - PADDING * Math.max(440 / imageState.height, 2);
    let width =
      imageState.width - PADDING * Math.max(440 / imageState.width, 2);

    if (cropPreset === CropPreset.Square) {
      const size = Math.min(height, width);
      height = size;
      width = size;
    } else if (cropPreset === CropPreset.Vertical) {
      width = height * 0.5625;
    }

    rect.set({ height, width, scaleX: 1, scaleY: 1 });
    fabricCanvas.viewportCenterObject(rect);
    rect.setCoords();

    setCanCrop(true);
  }, [cropPreset, fabricCanvas, imageState.height, imageState.width, zoom]);

  // Create the CroppingRect
  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }

    if (editMode === EditMode.Crop) {
      const PADDING = MediaEditorFabricCropRect.PADDING / zoom;
      // For reasons we don't understand, height and width on small images doesn't work
      //   right (it bleeds out) so we decrease them for small images.
      const height =
        imageState.height - PADDING * Math.max(440 / imageState.height, 2);
      const width =
        imageState.width - PADDING * Math.max(440 / imageState.width, 2);

      let rect: MediaEditorFabricCropRect;
      const obj = fabricCanvas.getActiveObject();

      if (obj instanceof MediaEditorFabricCropRect) {
        rect = obj;
        rect.set({ height, width, scaleX: 1, scaleY: 1 });
      } else {
        rect = new MediaEditorFabricCropRect({
          height,
          width,
        });

        rect.on('modified', () => {
          const { height: currHeight, width: currWidth } =
            rect.getBoundingRect(true);

          setCanCrop(currHeight < height || currWidth < width);
        });

        rect.on('deselected', () => {
          setEditMode(undefined);
        });

        fabricCanvas.add(rect);
        fabricCanvas.setActiveObject(rect);
      }

      fabricCanvas.viewportCenterObject(rect);
      rect.setCoords();
    } else {
      fabricCanvas.getObjects().forEach(obj => {
        if (obj instanceof MediaEditorFabricCropRect) {
          fabricCanvas.remove(obj);
        }
      });
    }

    setCanCrop(false);
  }, [editMode, fabricCanvas, imageState.height, imageState.width, zoom]);

  // Create an IText node when edit mode changes to Text
  useEffect(() => {
    if (!fabricCanvas) {
      return;
    }

    if (editMode !== EditMode.Text) {
      return;
    }

    const obj = fabricCanvas.getActiveObject();
    if (obj instanceof MediaEditorFabricIText) {
      return;
    }

    const FONT_SIZE_RELATIVE_TO_CANVAS = 10;
    const fontSize =
      Math.min(imageState.width, imageState.height) /
      FONT_SIZE_RELATIVE_TO_CANVAS;
    const text = new MediaEditorFabricIText('', {
      ...getTextStyleAttributes(textStyle, sliderValue),
      fontSize,
    });
    text.setPositionByOrigin(
      new fabric.Point(imageState.width / 2, imageState.height / 2),
      'center',
      'center'
    );
    text.setCoords();
    fabricCanvas.add(text);
    fabricCanvas.setActiveObject(text);

    text.enterEditing();
  }, [
    editMode,
    fabricCanvas,
    imageState.height,
    imageState.width,
    sliderValue,
    textStyle,
  ]);

  const [isSaving, setIsSaving] = useState(false);

  const handleSave = useCallback(async () => {
    if (!fabricCanvas) {
      return;
    }

    setEditMode(undefined);
    setIsSaving(true);

    let data: Uint8Array<ArrayBuffer>;
    let blurHash: string;
    try {
      const renderFabricCanvas = await cloneFabricCanvas(fabricCanvas);

      renderFabricCanvas.remove(
        ...renderFabricCanvas.getObjects().filter(obj => obj.excludeFromExport)
      );

      let finalImageState: ImageStateType;
      const pendingCrop = getPendingCrop(fabricCanvas);
      if (pendingCrop) {
        finalImageState = getNewImageStateFromCrop(imageState, pendingCrop);
        moveFabricObjectsForCrop(renderFabricCanvas, pendingCrop);
        drawFabricBackgroundImage({
          fabricCanvas: renderFabricCanvas,
          image,
          imageState: finalImageState,
        });
      } else {
        finalImageState = imageState;
      }

      renderFabricCanvas.setDimensions({
        width: finalImageState.width,
        height: finalImageState.height,
      });
      renderFabricCanvas.setZoom(1);
      const renderedCanvas = renderFabricCanvas.toCanvasElement();

      data = await canvasToBytes(renderedCanvas);

      const blob = new Blob([data], {
        type: IMAGE_PNG,
      });

      blurHash = await imageToBlurHash(blob);
    } catch (err) {
      onTryClose();
      throw err;
    } finally {
      setIsSaving(false);
    }

    onDone({
      contentType: IMAGE_PNG,
      data,
      caption: caption !== '' ? caption : undefined,
      captionBodyRanges: captionBodyRanges ?? undefined,
      blurHash,
      isViewOnce: localIsViewOnce,
      isHighQuality: localIsHighQuality,
    });
  }, [
    captionBodyRanges,
    imageState,
    localIsHighQuality,
    localIsViewOnce,
    fabricCanvas,
    image,
    imageToBlurHash,
    caption,
    onDone,
    onTryClose,
    setEditMode,
    setIsSaving,
  ]);

  // In an ideal world we'd use <ModalHost /> to get the nice animation benefits
  // but because of the way IText is implemented -- with a hidden textarea -- to
  // capture keyboard events, we can't use ModalHost since that traps focus, and
  // focus trapping doesn't play nice with fabric's IText.
  const portal = usePortal();

  if (!portal) {
    return null;
  }

  let toolElement: JSX.Element | undefined;
  if (editMode === EditMode.Text) {
    toolElement = (
      <>
        <div className="MediaEditor__tools-row-1" />
        <div className="MediaEditor__tools-row-2">
          <div className="MediaEditor__toolbar">
            <Slider
              handleStyle={{ backgroundColor: getHSL(sliderValue) }}
              label={i18n('icu:CustomColorEditor__hue')}
              moduleClassName="HueSlider MediaEditor__toolbar__tool"
              onChange={setSliderValue}
              value={sliderValue}
            />
            <ContextMenu
              i18n={i18n}
              menuOptions={[
                {
                  icon: 'MediaEditor__icon--text-regular',
                  label: i18n('icu:MediaEditor__text--regular'),
                  onClick: () => setTextStyle(TextStyle.Regular),
                  value: TextStyle.Regular,
                },
                {
                  icon: 'MediaEditor__icon--text-highlight',
                  label: i18n('icu:MediaEditor__text--highlight'),
                  onClick: () => setTextStyle(TextStyle.Highlight),
                  value: TextStyle.Highlight,
                },
                {
                  icon: 'MediaEditor__icon--text-outline',
                  label: i18n('icu:MediaEditor__text--outline'),
                  onClick: () => setTextStyle(TextStyle.Outline),
                  value: TextStyle.Outline,
                },
              ]}
              moduleClassName={classNames('MediaEditor__toolbar__tool', {
                'MediaEditor__toolbar__button--text-regular':
                  textStyle === TextStyle.Regular,
                'MediaEditor__toolbar__button--text-highlight':
                  textStyle === TextStyle.Highlight,
                'MediaEditor__toolbar__button--text-outline':
                  textStyle === TextStyle.Outline,
              })}
              theme={Theme.Dark}
              value={textStyle}
            />
          </div>
          <Button
            onClick={() => {
              setEditMode(undefined);

              const activeObject = fabricCanvas?.getActiveObject();
              if (activeObject instanceof MediaEditorFabricIText) {
                activeObject.exitEditing();
              }
            }}
            theme={Theme.Dark}
            variant={ButtonVariant.Secondary}
          >
            {i18n('icu:done')}
          </Button>
        </div>
      </>
    );
  } else if (editMode === EditMode.Draw) {
    toolElement = (
      <>
        <div className="MediaEditor__tools-row-1" />
        <div className="MediaEditor__tools-row-2">
          <div className="MediaEditor__toolbar">
            <Slider
              handleStyle={{ backgroundColor: getHSL(sliderValue) }}
              label={i18n('icu:CustomColorEditor__hue')}
              moduleClassName="HueSlider MediaEditor__toolbar__tool"
              onChange={setSliderValue}
              value={sliderValue}
            />
            <ContextMenu
              i18n={i18n}
              menuOptions={[
                {
                  icon: 'MediaEditor__icon--draw-pen',
                  label: i18n('icu:MediaEditor__draw--pen'),
                  onClick: () => setDrawTool(DrawTool.Pen),
                  value: DrawTool.Pen,
                },
                {
                  icon: 'MediaEditor__icon--draw-highlighter',
                  label: i18n('icu:MediaEditor__draw--highlighter'),
                  onClick: () => setDrawTool(DrawTool.Highlighter),
                  value: DrawTool.Highlighter,
                },
              ]}
              moduleClassName={classNames('MediaEditor__toolbar__tool', {
                'MediaEditor__toolbar__button--draw-pen':
                  drawTool === DrawTool.Pen,
                'MediaEditor__toolbar__button--draw-highlighter':
                  drawTool === DrawTool.Highlighter,
              })}
              theme={Theme.Dark}
              value={drawTool}
            />
            <ContextMenu
              i18n={i18n}
              menuOptions={[
                {
                  icon: 'MediaEditor__icon--width-thin',
                  label: i18n('icu:MediaEditor__draw--thin'),
                  onClick: () => setDrawWidth(DrawWidth.Thin),
                  value: DrawWidth.Thin,
                },
                {
                  icon: 'MediaEditor__icon--width-regular',
                  label: i18n('icu:MediaEditor__draw--regular'),
                  onClick: () => setDrawWidth(DrawWidth.Regular),
                  value: DrawWidth.Regular,
                },
                {
                  icon: 'MediaEditor__icon--width-medium',
                  label: i18n('icu:MediaEditor__draw--medium'),
                  onClick: () => setDrawWidth(DrawWidth.Medium),
                  value: DrawWidth.Medium,
                },
                {
                  icon: 'MediaEditor__icon--width-heavy',
                  label: i18n('icu:MediaEditor__draw--heavy'),
                  onClick: () => setDrawWidth(DrawWidth.Heavy),
                  value: DrawWidth.Heavy,
                },
              ]}
              moduleClassName={classNames('MediaEditor__toolbar__tool', {
                'MediaEditor__toolbar__button--width-thin':
                  drawWidth === DrawWidth.Thin,
                'MediaEditor__toolbar__button--width-regular':
                  drawWidth === DrawWidth.Regular,
                'MediaEditor__toolbar__button--width-medium':
                  drawWidth === DrawWidth.Medium,
                'MediaEditor__toolbar__button--width-heavy':
                  drawWidth === DrawWidth.Heavy,
              })}
              theme={Theme.Dark}
              value={drawWidth}
            />
          </div>
          <Button
            onClick={() => setEditMode(undefined)}
            theme={Theme.Dark}
            variant={ButtonVariant.Secondary}
          >
            {i18n('icu:done')}
          </Button>
        </div>
      </>
    );
  } else if (editMode === EditMode.Crop) {
    const canReset =
      imageState.cropX !== 0 ||
      imageState.cropY !== 0 ||
      imageState.flipX ||
      imageState.flipY ||
      imageState.angle !== 0;

    toolElement = (
      <>
        <div className="MediaEditor__tools-row-1">
          <button
            className={classNames(
              'MediaEditor__crop-preset MediaEditor__crop-preset--free',
              {
                'MediaEditor__crop-preset--selected':
                  cropPreset === CropPreset.Freeform,
              }
            )}
            onClick={() => setCropPreset(CropPreset.Freeform)}
            type="button"
          >
            {i18n('icu:MediaEditor__crop-preset--freeform')}
          </button>
          <button
            className={classNames(
              'MediaEditor__crop-preset MediaEditor__crop-preset--square',
              {
                'MediaEditor__crop-preset--selected':
                  cropPreset === CropPreset.Square,
              }
            )}
            onClick={() => setCropPreset(CropPreset.Square)}
            type="button"
          >
            {i18n('icu:MediaEditor__crop-preset--square')}
          </button>
          <button
            className={classNames(
              'MediaEditor__crop-preset MediaEditor__crop-preset--vertical',
              {
                'MediaEditor__crop-preset--selected':
                  cropPreset === CropPreset.Vertical,
              }
            )}
            onClick={() => setCropPreset(CropPreset.Vertical)}
            type="button"
          >
            {i18n('icu:MediaEditor__crop-preset--9-16')}
          </button>
        </div>
        <div className="MediaEditor__tools-row-2">
          <Button
            disabled={!canReset}
            onClick={async () => {
              if (!fabricCanvas) {
                return;
              }

              const newImageState = {
                ...INITIAL_IMAGE_STATE,
                height: image.height,
                width: image.width,
              };
              setImageState(newImageState);
              setCropPreset(CropPreset.Freeform);
              moveFabricObjectsForReset(fabricCanvas, imageState);
              takeSnapshot('reset', newImageState);
            }}
            theme={Theme.Dark}
            variant={ButtonVariant.Secondary}
          >
            {i18n('icu:MediaEditor__crop--reset')}
          </Button>
          <div className="MediaEditor__toolbar__crop">
            <button
              aria-label={i18n('icu:MediaEditor__crop--rotate')}
              className="MediaEditor__toolbar__crop__button MediaEditor__toolbar__button--rotate"
              onClick={() => {
                if (!fabricCanvas) {
                  return;
                }

                fabricCanvas.getObjects().forEach(obj => {
                  if (obj instanceof MediaEditorFabricCropRect) {
                    return;
                  }

                  const center = obj.getCenterPoint();

                  obj.set('angle', ((obj.angle || 0) + 270) % 360);

                  obj.setPositionByOrigin(
                    new fabric.Point(center.y, imageState.width - center.x),
                    'center',
                    'center'
                  );
                  obj.setCoords();
                });

                const newImageState = {
                  ...imageState,
                  angle: (imageState.angle + 270) % 360,
                  height: imageState.width,
                  width: imageState.height,
                };
                setImageState(newImageState);
                takeSnapshot('rotate', newImageState);
              }}
              type="button"
            />
            <button
              aria-label={i18n('icu:MediaEditor__crop--flip')}
              className="MediaEditor__toolbar__crop__button MediaEditor__toolbar__button--flip"
              onClick={() => {
                if (!fabricCanvas) {
                  return;
                }

                const newImageState = {
                  ...imageState,
                  ...(imageState.angle % 180
                    ? { flipY: !imageState.flipY }
                    : { flipX: !imageState.flipX }),
                };
                setImageState(newImageState);
                takeSnapshot('flip', newImageState);
              }}
              type="button"
            />
            <button
              aria-label={i18n('icu:MediaEditor__crop--lock')}
              className={classNames(
                'MediaEditor__toolbar__crop__button',
                `MediaEditor__toolbar__button--crop-${
                  cropAspectRatioLock ? '' : 'un'
                }locked`
              )}
              onClick={() => {
                if (fabricCanvas) {
                  fabricCanvas.uniformScaling = !cropAspectRatioLock;
                }
                setCropAspectRatioLock(!cropAspectRatioLock);
              }}
              type="button"
            />
          </div>
          <Button
            onClick={() => {
              if (!canCrop) {
                setEditMode(undefined);
                return;
              }

              if (!fabricCanvas) {
                return;
              }

              const pendingCrop = getPendingCrop(fabricCanvas);
              if (!pendingCrop) {
                return;
              }

              const newImageState = getNewImageStateFromCrop(
                imageState,
                pendingCrop
              );
              setImageState(newImageState);
              moveFabricObjectsForCrop(fabricCanvas, pendingCrop);
              takeSnapshot('crop', newImageState);
              setEditMode(undefined);
              setCropPreset(CropPreset.Freeform);
            }}
            theme={Theme.Dark}
            variant={ButtonVariant.Secondary}
          >
            {i18n('icu:done')}
          </Button>
        </div>
      </>
    );
  }

  return createPortal(
    <AxoTheme.Override theme="force-dark">
      <div className="MediaEditor dark-theme">
        <div className="MediaEditor__history-buttons">
          <button
            aria-label={i18n('icu:MediaEditor__control--undo')}
            className="MediaEditor__control MediaEditor__control--undo"
            disabled={!canUndo}
            onClick={() => {
              if (editMode === EditMode.Crop) {
                setEditMode(undefined);
              }
              undoIfPossible();
            }}
            type="button"
          />
          <button
            aria-label={i18n('icu:MediaEditor__control--redo')}
            className="MediaEditor__control MediaEditor__control--redo"
            disabled={!canRedo}
            onClick={() => {
              if (editMode === EditMode.Crop) {
                setEditMode(undefined);
              }
              redoIfPossible();
            }}
            type="button"
          />
        </div>
        <button
          aria-label={i18n('icu:close')}
          className="MediaEditor__close"
          onClick={onTryClose}
          type="button"
        />
        <div className="MediaEditor__container">
          <SizeObserver
            onSizeChange={size => {
              if (size.hidden) {
                return;
              }
              setContainerWidth(size.width);
              setContainerHeight(size.height);
            }}
          >
            {ref => (
              <div className="MediaEditor__media" ref={ref}>
                {image && (
                  <div>
                    {/* FIXME */}
                    {/* oxlint-disable-next-line jsx-a11y/control-has-associated-label */}
                    <canvas
                      className={classNames('MediaEditor__media--canvas', {
                        'MediaEditor__media--canvas--cropping':
                          editMode === EditMode.Crop,
                      })}
                      id={canvasId}
                    />
                  </div>
                )}
              </div>
            )}
          </SizeObserver>
        </div>
        <div className="MediaEditor__tools">
          {toolElement !== undefined ? (
            toolElement
          ) : (
            <>
              <div className="MediaEditor__tools-row-1">
                <button
                  aria-label={i18n('icu:MediaEditor__control--draw')}
                  className={classNames({
                    MediaEditor__control: true,
                    'MediaEditor__control--pen': true,
                    'MediaEditor__control--selected':
                      editMode === EditMode.Draw,
                  })}
                  onClick={() => {
                    setEditMode(
                      editMode === EditMode.Draw ? undefined : EditMode.Draw
                    );
                  }}
                  type="button"
                />
                <button
                  aria-label={i18n('icu:MediaEditor__control--text')}
                  className={classNames({
                    MediaEditor__control: true,
                    'MediaEditor__control--text': true,
                    'MediaEditor__control--selected':
                      editMode === EditMode.Text,
                  })}
                  onClick={() => {
                    if (editMode === EditMode.Text) {
                      setEditMode(undefined);
                      const obj = fabricCanvas?.getActiveObject();
                      if (obj instanceof MediaEditorFabricIText) {
                        obj.exitEditing();
                      }
                    } else {
                      setEditMode(EditMode.Text);
                    }
                  }}
                  type="button"
                />
                <button
                  aria-label={i18n('icu:MediaEditor__control--crop')}
                  className={classNames({
                    MediaEditor__control: true,
                    'MediaEditor__control--crop': true,
                    'MediaEditor__control--selected':
                      editMode === EditMode.Crop,
                  })}
                  onClick={() => {
                    if (!fabricCanvas) {
                      return;
                    }
                    if (editMode === EditMode.Crop) {
                      const obj = fabricCanvas.getActiveObject();
                      if (obj instanceof MediaEditorFabricCropRect) {
                        fabricCanvas.remove(obj);
                      }
                      setEditMode(undefined);
                    } else {
                      setEditMode(EditMode.Crop);
                    }
                  }}
                  type="button"
                />
                <FunStickerPicker
                  open={stickerPickerOpen}
                  onOpenChange={handleStickerPickerOpenChange}
                  onSelectSticker={handleSelectSticker}
                  showTimeStickers
                  onSelectTimeSticker={handlePickTimeSticker}
                  placement="top"
                  theme={pickerTheme}
                >
                  <FunStickerPickerButton i18n={i18n} />
                </FunStickerPicker>
              </div>
              <div className="MediaEditor__tools-row-2">
                <div
                  className={classNames(
                    tw('mx-1 flex items-center justify-center'),
                    localIsViewOnce ? tw('invisible') : null
                  )}
                >
                  <FunEmojiPicker
                    open={emojiPickerOpen}
                    onOpenChange={handleEmojiPickerOpenChange}
                    onSelectEmoji={handleSelectEmoji}
                    placement="top"
                    theme={pickerTheme}
                    closeOnSelect={false}
                  >
                    <FunEmojiPickerButton i18n={i18n} />
                  </FunEmojiPicker>
                </div>
                {showMediaQualitySelector && (
                  <div className={tw('mx-1 flex items-center justify-center')}>
                    <MediaQualitySelector
                      conversationId=""
                      i18n={i18n}
                      isHighQuality={localIsHighQuality}
                      onSelectQuality={handleSelectQuality}
                    />
                  </div>
                )}
                <div className="MediaEditor__tools--input">
                  <CompositionInput
                    draftText={caption}
                    draftBodyRanges={hydratedBodyRanges ?? null}
                    getPreferredBadge={getPreferredBadge}
                    i18n={i18n}
                    inputApi={inputApiRef}
                    isActive
                    isFormattingEnabled={isFormattingEnabled}
                    moduleClassName="StoryViewsNRepliesModal__input"
                    onCloseLinkPreview={noop}
                    onEditorStateChange={({ bodyRanges, messageText }) => {
                      setCaptionBodyRanges(bodyRanges);
                      setCaption(messageText);
                    }}
                    emojiSkinToneDefault={emojiSkinToneDefault ?? null}
                    onSelectEmoji={onSelectEmoji}
                    onSubmit={handleSave}
                    onTextTooLong={onTextTooLong}
                    ourConversationId={ourConversationId}
                    placeholder={i18n('icu:MediaEditor__input-placeholder')}
                    showRecoveryKeyPasteWarning={false}
                    platform={platform}
                    quotedMessageId={null}
                    sendCounter={0}
                    sortedGroupMembers={sortedGroupMembers}
                    theme={pickerTheme}
                    // Only needed for state updates and we need to override those
                    conversationId={null}
                    // Cannot enter media editor while editing
                    draftEditMessage={null}
                    // We don't use the large editor mode
                    large={null}
                    // panels do not appear over the media editor
                    shouldHidePopovers={null}
                    // link previews not displayed with media
                    linkPreviewResult={null}
                    showViewOnceButton={showViewOnceToggle}
                    isViewOnceActive={localIsViewOnce}
                    onToggleViewOnce={() => {
                      const newValue = !localIsViewOnce;
                      setLocalIsViewOnce(newValue);
                      if (newValue) {
                        setEmojiPickerOpen(false);
                      }
                    }}
                  />
                </div>
                <AxoButton.Root
                  variant="strong-primary"
                  size="md"
                  disabled={!image}
                  pending={isSaving || isSending}
                  onClick={handleSave}
                >
                  {doneButtonLabel || i18n('icu:save')}
                </AxoButton.Root>
              </div>
            </>
          )}
        </div>
        {confirmDiscardModal}
      </div>
    </AxoTheme.Override>,
    portal
  );
}

function getPendingCrop(
  fabricCanvas: fabric.Canvas
): undefined | PendingCropType {
  const activeObject = fabricCanvas.getActiveObject();
  return activeObject instanceof MediaEditorFabricCropRect
    ? activeObject.getBoundingRect(true)
    : undefined;
}

function getNewImageStateFromCrop(
  state: Readonly<ImageStateType>,
  { left, height, top, width }: Readonly<PendingCropType>
): ImageStateType {
  let cropX: number;
  let cropY: number;
  switch (state.angle) {
    case 0:
      cropX = state.cropX + left;
      cropY = state.cropY + top;
      break;
    case 90:
      cropX = state.cropX + top;
      cropY = state.cropY + (state.width - (left + width));
      break;
    case 180:
      cropX = state.cropX + (state.width - (left + width));
      cropY = state.cropY + (state.height - (top + height));
      break;
    case 270:
      cropX = state.cropX + (state.height - (top + height));
      cropY = state.cropY + left;
      break;
    default:
      throw new Error('Unexpected angle');
  }

  return {
    ...state,
    cropX,
    cropY,
    height,
    width,
  };
}

function cloneFabricCanvas(original: fabric.Canvas): Promise<fabric.Canvas> {
  return new Promise(resolve => {
    original.clone(resolve, ['data']);
  });
}

function moveFabricObjectsForCrop(
  fabricCanvas: fabric.Canvas,
  { left, top }: Readonly<PendingCropType>
): void {
  fabricCanvas.getObjects().forEach(obj => {
    const { x, y } = obj.getCenterPoint();

    const translatedCenter = new fabric.Point(x - left, y - top);
    obj.setPositionByOrigin(translatedCenter, 'center', 'center');
    obj.setCoords();
  });
}

function moveFabricObjectsForReset(
  fabricCanvas: fabric.Canvas,
  oldImageState: Readonly<ImageStateType>
): void {
  fabricCanvas.getObjects().forEach(obj => {
    if (obj.excludeFromExport) {
      return;
    }

    let newCenterX: number;
    let newCenterY: number;

    // First, reset position changes caused by image rotation:
    const oldCenter = obj.getCenterPoint();
    const distanceFromRightEdge = oldImageState.width - oldCenter.x;
    const distanceFromBottomEdge = oldImageState.height - oldCenter.y;
    switch (oldImageState.angle % 360) {
      case 0:
        newCenterX = oldCenter.x;
        newCenterY = oldCenter.y;
        break;
      case 90:
        newCenterX = oldCenter.y;
        newCenterY = distanceFromRightEdge;
        break;
      case 180:
        newCenterX = distanceFromRightEdge;
        newCenterY = distanceFromBottomEdge;
        break;
      case 270:
        newCenterX = distanceFromBottomEdge;
        newCenterY = oldCenter.x;
        break;
      default:
        throw new Error('Unexpected angle');
    }

    // Next, reset position changes caused by crop:
    newCenterX += oldImageState.cropX;
    newCenterY += oldImageState.cropY;

    // It's important to set the angle *before* setting the position, because
    //   Fabric's positioning is affected by object angle.
    obj.set('angle', (obj.angle || 0) - oldImageState.angle);
    obj.setPositionByOrigin(
      new fabric.Point(newCenterX, newCenterY),
      'center',
      'center'
    );

    obj.setCoords();
  });
}

function drawFabricBackgroundImage({
  fabricCanvas,
  image,
  imageState,
}: Readonly<{
  fabricCanvas: fabric.Canvas;
  image: HTMLImageElement;
  imageState: Readonly<ImageStateType>;
}>): void {
  const backgroundImage = new fabric.Image(image, {
    canvas: fabricCanvas,
    height: imageState.height || image.height,
    width: imageState.width || image.width,
  });

  let left: number;
  let top: number;
  switch (imageState.angle) {
    case 0:
      left = 0;
      top = 0;
      break;
    case 90:
      left = imageState.width;
      top = 0;
      break;
    case 180:
      left = imageState.width;
      top = imageState.height;
      break;
    case 270:
      left = 0;
      top = imageState.height;
      break;
    default:
      throw new Error('Unexpected angle');
  }

  let { height, width } = imageState;
  if (imageState.angle % 180) {
    [width, height] = [height, width];
  }

  fabricCanvas.setBackgroundImage(
    backgroundImage,
    fabricCanvas.requestRenderAll.bind(fabricCanvas),
    {
      angle: imageState.angle,
      cropX: imageState.cropX,
      cropY: imageState.cropY,
      flipX: imageState.flipX,
      flipY: imageState.flipY,
      left,
      top,
      originX: 'left',
      originY: 'top',
      width,
      height,
    }
  );
}
