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

import React, {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import { fabric } from 'fabric';
import { useSelector } from 'react-redux';
import { get, has, noop } from 'lodash';

import type {
  EmojiPickDataType,
  Props as EmojiPickerProps,
} from './emoji/EmojiPicker';
import type { DraftBodyRanges } from '../types/BodyRange';
import type { ImageStateType } from '../mediaEditor/ImageStateType';
import type {
  InputApi,
  Props as CompositionInputProps,
} from './CompositionInput';
import type { LocalizerType } from '../types/Util';
import type { MIMEType } from '../types/MIME';
import type { Props as StickerButtonProps } from './stickers/StickerButton';
import type { imageToBlurHash } from '../util/imageToBlurHash';

import { MediaEditorFabricAnalogTimeSticker } from '../mediaEditor/MediaEditorFabricAnalogTimeSticker';
import { MediaEditorFabricCropRect } from '../mediaEditor/MediaEditorFabricCropRect';
import { MediaEditorFabricDigitalTimeSticker } from '../mediaEditor/MediaEditorFabricDigitalTimeSticker';
import { MediaEditorFabricIText } from '../mediaEditor/MediaEditorFabricIText';
import { MediaEditorFabricPencilBrush } from '../mediaEditor/MediaEditorFabricPencilBrush';
import { MediaEditorFabricSticker } from '../mediaEditor/MediaEditorFabricSticker';
import { fabricEffectListener } from '../mediaEditor/fabricEffectListener';
import { getRGBA, getHSL } from '../mediaEditor/util/color';
import {
  getTextStyleAttributes,
  TextStyle,
} from '../mediaEditor/util/getTextStyleAttributes';

import * as log from '../logging/log';
import { Button, ButtonVariant } from './Button';
import { CompositionInput } from './CompositionInput';
import { ContextMenu } from './ContextMenu';
import { EmojiButton } from './emoji/EmojiButton';
import { IMAGE_PNG } from '../types/MIME';
import { SizeObserver } from '../hooks/useSizeObserver';
import { Slider } from './Slider';
import { Spinner } from './Spinner';
import { StickerButton } from './stickers/StickerButton';
import { Theme } from '../util/theme';
import { ThemeType } from '../types/Util';
import { arrow } from '../util/keyboard';
import { canvasToBytes } from '../util/canvasToBytes';
import { getConversationSelector } from '../state/selectors/conversations';
import { hydrateRanges } from '../types/BodyRange';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { useFabricHistory } from '../mediaEditor/useFabricHistory';
import { usePortal } from '../hooks/usePortal';
import { useUniqueId } from '../hooks/useUniqueId';

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

export type PropsType = {
  doneButtonLabel?: string;
  i18n: LocalizerType;
  imageSrc: string;
  isSending: boolean;
  imageToBlurHash: typeof imageToBlurHash;
  onClose: () => unknown;
  onDone: (result: MediaEditorResultType) => unknown;
} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'> &
  Pick<
    CompositionInputProps,
    | 'draftText'
    | 'draftBodyRanges'
    | 'getPreferredBadge'
    | 'isFormattingEnabled'
    | 'onPickEmoji'
    | 'onTextTooLong'
    | 'platform'
    | 'sortedGroupMembers'
  > &
  Omit<EmojiPickerProps, 'wasInvokedFromKeyboard'>;

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,
  isSending,
  onClose,
  onDone,

  // CompositionInput
  draftText,
  draftBodyRanges,
  getPreferredBadge,
  isFormattingEnabled,
  onPickEmoji,
  onTextTooLong,
  platform,
  sortedGroupMembers,

  // EmojiPickerProps
  onSetSkinTone,
  recentEmojis,
  skinTone,

  // StickerButtonProps
  installedPacks,
  recentStickers,
  ...props
}: PropsType): JSX.Element | null {
  const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>();
  const [image, setImage] = useState<HTMLImageElement>(new Image());
  const [isStickerPopperOpen, setIsStickerPopperOpen] =
    useState<boolean>(false);
  const [isEmojiPopperOpen, setEmojiPopperOpen] = useState<boolean>(false);

  const [caption, setCaption] = useState(draftText ?? '');
  const [captionBodyRanges, setCaptionBodyRanges] =
    useState<DraftBodyRanges | null>(draftBodyRanges);

  const conversationSelector = useSelector(getConversationSelector);
  const hydratedBodyRanges = useMemo(
    () => hydrateRanges(captionBodyRanges ?? undefined, conversationSelector),
    [captionBodyRanges, conversationSelector]
  );

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

  const closeEmojiPickerAndFocusComposer = useCallback(() => {
    if (inputApiRef.current) {
      inputApiRef.current.focus();
    }
    setEmojiPopperOpen(false);
  }, [inputApiRef]);

  const insertEmoji = useCallback(
    (e: EmojiPickDataType) => {
      if (inputApiRef.current) {
        inputApiRef.current.insertEmoji(e);
        onPickEmoji(e);
      }
    },
    [inputApiRef, onPickEmoji]
  );

  const canvasId = useUniqueId();

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

  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.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);
      takeSnapshot('initial state', newImageState, canvas);
    };
    img.onerror = () => {
      // This is a bad experience, but it should be impossible.
      log.error('<MediaEditor>: image failed to load. Closing');
      onClose();
    };
    img.src = imageSrc;
    return () => {
      img.onload = noop;
      img.onerror = noop;
    };
  }, [canvasId, fabricCanvas, imageSrc, onClose, takeSnapshot]);

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

  const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n);

  const onTryClose = useCallback(() => {
    confirmDiscardIf(canUndo, onClose);
  }, [confirmDiscardIf, canUndo, onClose]);

  // 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 (isEmojiPopperOpen) {
            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 (!isStickerPopperOpen) {
              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,
    isEmojiPopperOpen,
    isStickerPopperOpen,
    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 [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);

  // 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(
    <div className="MediaEditor">
      <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 => {
            setContainerWidth(size.width);
            setContainerHeight(size.height);
          }}
        >
          {ref => (
            <div className="MediaEditor__media" ref={ref}>
              {image && (
                <div>
                  <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"
              />
              <StickerButton
                blessedPacks={[]}
                className={classNames({
                  MediaEditor__control: true,
                  'MediaEditor__control--sticker': true,
                })}
                onOpenStateChanged={value => {
                  setIsStickerPopperOpen(value);
                }}
                clearInstalledStickerPack={noop}
                clearShowIntroduction={() => {
                  // We're using this as a callback for when the sticker button
                  // is pressed.
                  fabricCanvas?.discardActiveObject();
                  setEditMode(undefined);
                }}
                clearShowPickerHint={noop}
                i18n={i18n}
                installedPacks={installedPacks}
                knownPacks={[]}
                onPickSticker={(_packId, _stickerId, src: string) => {
                  if (!fabricCanvas) {
                    return;
                  }

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

                  const sticker = new MediaEditorFabricSticker(src);
                  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);
                }}
                onPickTimeSticker={(style: 'analog' | 'digital') => {
                  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);
                }}
                receivedPacks={[]}
                recentStickers={recentStickers}
                showPickerHint={false}
                theme={Theme.Dark}
              />
            </div>
            <div className="MediaEditor__tools-row-2">
              <div className="MediaEditor__tools--input dark-theme">
                <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);
                  }}
                  skinTone={skinTone ?? null}
                  onPickEmoji={onPickEmoji}
                  onSubmit={noop}
                  onTextTooLong={onTextTooLong}
                  placeholder={i18n('icu:MediaEditor__input-placeholder')}
                  platform={platform}
                  sendCounter={0}
                  sortedGroupMembers={sortedGroupMembers}
                  theme={ThemeType.dark}
                  // 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}
                >
                  <EmojiButton
                    className="StoryViewsNRepliesModal__emoji-button"
                    i18n={i18n}
                    onPickEmoji={insertEmoji}
                    onOpen={() => setEmojiPopperOpen(true)}
                    onClose={closeEmojiPickerAndFocusComposer}
                    recentEmojis={recentEmojis}
                    skinTone={skinTone}
                    onSetSkinTone={onSetSkinTone}
                  />
                </CompositionInput>
              </div>
              <Button
                disabled={!image || isSaving || isSending}
                onClick={async () => {
                  if (!fabricCanvas) {
                    return;
                  }

                  setEditMode(undefined);
                  setIsSaving(true);

                  let data: Uint8Array;
                  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 props.imageToBlurHash(blob);
                  } catch (err) {
                    onTryClose();
                    throw err;
                  } finally {
                    setIsSaving(false);
                  }

                  onDone({
                    contentType: IMAGE_PNG,
                    data,
                    caption: caption !== '' ? caption : undefined,
                    captionBodyRanges: captionBodyRanges ?? undefined,
                    blurHash,
                  });
                }}
                theme={Theme.Dark}
                variant={ButtonVariant.Primary}
              >
                {isSending ? (
                  <Spinner svgSize="small" />
                ) : (
                  doneButtonLabel || i18n('icu:save')
                )}
              </Button>
            </div>
          </>
        )}
      </div>
      {confirmDiscardModal}
    </div>,
    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,
    }
  );
}
