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

import type { ReactNode } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import moment from 'moment';
import { createPortal } from 'react-dom';
import { noop } from 'lodash';
import { useSpring, animated, to } from '@react-spring/web';

import * as GoogleChrome from '../util/GoogleChrome';
import type { AttachmentType } from '../types/Attachment';
import { isGIF } from '../types/Attachment';
import { Avatar, AvatarSize } from './Avatar';
import type { ConversationType } from '../state/ducks/conversations';
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME';
import type { LocalizerType } from '../types/Util';
import type { MediaItemType, MessageAttributesType } from '../types/MediaItem';
import { formatDuration } from '../util/formatDuration';
import { useRestoreFocus } from '../hooks/useRestoreFocus';
import * as log from '../logging/log';

export type PropsType = {
  children?: ReactNode;
  close: () => void;
  getConversation?: (id: string) => ConversationType;
  i18n: LocalizerType;
  isViewOnce?: boolean;
  media: Array<MediaItemType>;
  onForward?: (messageId: string) => void;
  onSave?: (options: {
    attachment: AttachmentType;
    message: MessageAttributesType;
    index: number;
  }) => void;
  selectedIndex?: number;
};

const ZOOM_SCALE = 3;

const INITIAL_IMAGE_TRANSFORM = {
  scale: 1,
  translateX: 0,
  translateY: 0,
  config: {
    clamp: true,
    friction: 20,
    mass: 0.5,
    tension: 350,
  },
};

export function Lightbox({
  children,
  close,
  getConversation,
  media,
  i18n,
  isViewOnce = false,
  onForward,
  onSave,
  selectedIndex: initialSelectedIndex = 0,
}: PropsType): JSX.Element | null {
  const [root, setRoot] = React.useState<HTMLElement | undefined>();
  const [selectedIndex, setSelectedIndex] =
    useState<number>(initialSelectedIndex);

  const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
    null
  );
  const [videoTime, setVideoTime] = useState<number | undefined>();
  const [isZoomed, setIsZoomed] = useState(false);
  const containerRef = useRef<HTMLDivElement | null>(null);
  const [focusRef] = useRestoreFocus();
  const animateRef = useRef<HTMLDivElement | null>(null);
  const dragCacheRef = useRef<
    | {
        startX: number;
        startY: number;
        translateX: number;
        translateY: number;
      }
    | undefined
  >();
  const imageRef = useRef<HTMLImageElement | null>(null);
  const zoomCacheRef = useRef<
    | {
        maxX: number;
        maxY: number;
        screenWidth: number;
        screenHeight: number;
      }
    | undefined
  >();

  const onPrevious = useCallback(
    (
      event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
    ) => {
      event.preventDefault();
      event.stopPropagation();

      if (isZoomed) {
        return;
      }

      setSelectedIndex(prevSelectedIndex => Math.max(prevSelectedIndex - 1, 0));
    },
    [isZoomed]
  );

  const onNext = useCallback(
    (
      event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
    ) => {
      event.preventDefault();
      event.stopPropagation();

      if (isZoomed) {
        return;
      }

      setSelectedIndex(prevSelectedIndex =>
        Math.min(prevSelectedIndex + 1, media.length - 1)
      );
    },
    [isZoomed, media]
  );

  const onTimeUpdate = useCallback(() => {
    if (!videoElement) {
      return;
    }
    setVideoTime(videoElement.currentTime);
  }, [setVideoTime, videoElement]);

  const handleSave = (
    event: React.MouseEvent<HTMLButtonElement, MouseEvent>
  ) => {
    event.stopPropagation();
    event.preventDefault();

    const mediaItem = media[selectedIndex];
    const { attachment, message, index } = mediaItem;

    onSave?.({ attachment, message, index });
  };

  const handleForward = (
    event: React.MouseEvent<HTMLButtonElement, MouseEvent>
  ) => {
    event.preventDefault();
    event.stopPropagation();

    close();
    const mediaItem = media[selectedIndex];
    onForward?.(mediaItem.message.id);
  };

  const onKeyDown = useCallback(
    (event: KeyboardEvent) => {
      switch (event.key) {
        case 'Escape': {
          close();

          event.preventDefault();
          event.stopPropagation();

          break;
        }

        case 'ArrowLeft':
          onPrevious(event);
          break;

        case 'ArrowRight':
          onNext(event);
          break;

        default:
      }
    },
    [close, onNext, onPrevious]
  );

  const onClose = (event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    event.preventDefault();

    close();
  };

  const playVideo = useCallback(() => {
    if (!videoElement) {
      return;
    }

    if (videoElement.paused) {
      videoElement.play();
    } else {
      videoElement.pause();
    }
  }, [videoElement]);

  useEffect(() => {
    const div = document.createElement('div');
    document.body.appendChild(div);
    setRoot(div);

    return () => {
      document.body.removeChild(div);
      setRoot(undefined);
    };
  }, []);

  useEffect(() => {
    const useCapture = true;
    document.addEventListener('keydown', onKeyDown, useCapture);

    return () => {
      document.removeEventListener('keydown', onKeyDown, useCapture);
    };
  }, [onKeyDown]);

  const {
    attachment,
    contentType,
    loop = false,
    objectURL,
    message,
  } = media[selectedIndex] || {};

  const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined);

  useEffect(() => {
    playVideo();

    if (!videoElement || !isViewOnce) {
      return noop;
    }

    if (isAttachmentGIF) {
      return noop;
    }

    videoElement.addEventListener('timeupdate', onTimeUpdate);

    return () => {
      videoElement.removeEventListener('timeupdate', onTimeUpdate);
    };
  }, [isViewOnce, isAttachmentGIF, onTimeUpdate, playVideo, videoElement]);

  const [{ scale, translateX, translateY }, springApi] = useSpring(
    () => INITIAL_IMAGE_TRANSFORM
  );

  const maxBoundsLimiter = useCallback(
    (x: number, y: number): [number, number] => {
      const zoomCache = zoomCacheRef.current;

      if (!zoomCache) {
        return [0, 0];
      }

      const { maxX, maxY } = zoomCache;

      const posX = Math.min(maxX, Math.max(-maxX, x));
      const posY = Math.min(maxY, Math.max(-maxY, y));

      return [posX, posY];
    },
    []
  );

  const positionImage = useCallback(
    (ev: MouseEvent) => {
      const zoomCache = zoomCacheRef.current;

      if (!zoomCache) {
        return;
      }

      const { maxX, maxY, screenWidth, screenHeight } = zoomCache;

      const shouldTranslateX = maxX * ZOOM_SCALE > screenWidth;
      const shouldTranslateY = maxY * ZOOM_SCALE > screenHeight;

      const offsetX = screenWidth / 2 - ev.clientX;
      const offsetY = screenHeight / 2 - ev.clientY;
      const posX = offsetX * ZOOM_SCALE;
      const posY = offsetY * ZOOM_SCALE;
      const [x, y] = maxBoundsLimiter(posX, posY);

      springApi.start({
        scale: ZOOM_SCALE,
        translateX: shouldTranslateX ? x : undefined,
        translateY: shouldTranslateY ? y : undefined,
      });
    },
    [maxBoundsLimiter, springApi]
  );

  const handleTouchStart = useCallback(
    (ev: TouchEvent) => {
      const [touch] = ev.touches;

      dragCacheRef.current = {
        startX: touch.clientX,
        startY: touch.clientY,
        translateX: translateX.get(),
        translateY: translateY.get(),
      };
    },
    [translateY, translateX]
  );

  const handleTouchMove = useCallback(
    (ev: TouchEvent) => {
      const dragCache = dragCacheRef.current;

      if (!dragCache) {
        return;
      }

      const [touch] = ev.touches;

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

      const x = dragCache.translateX + deltaX;
      const y = dragCache.translateY + deltaY;

      springApi.start({
        scale: ZOOM_SCALE,
        translateX: x,
        translateY: y,
      });
    },
    [springApi]
  );

  const zoomButtonHandler = useCallback(
    (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
      ev.preventDefault();
      ev.stopPropagation();

      const imageNode = imageRef.current;
      const animateNode = animateRef.current;
      if (!imageNode || !animateNode) {
        return;
      }

      if (!isZoomed) {
        const maxX = imageNode.offsetWidth;
        const maxY = imageNode.offsetHeight;
        const screenHeight = window.innerHeight;
        const screenWidth = window.innerWidth;

        zoomCacheRef.current = {
          maxX,
          maxY,
          screenHeight,
          screenWidth,
        };

        const shouldTranslateX = maxX * ZOOM_SCALE > screenWidth;
        const shouldTranslateY = maxY * ZOOM_SCALE > screenHeight;

        const { height, left, top, width } =
          animateNode.getBoundingClientRect();

        const offsetX = ev.clientX - left - width / 2;
        const offsetY = ev.clientY - top - height / 2;
        const posX = -offsetX * ZOOM_SCALE + translateX.get();
        const posY = -offsetY * ZOOM_SCALE + translateY.get();
        const [x, y] = maxBoundsLimiter(posX, posY);

        springApi.start({
          scale: ZOOM_SCALE,
          translateX: shouldTranslateX ? x : undefined,
          translateY: shouldTranslateY ? y : undefined,
        });

        setIsZoomed(true);
      } else {
        springApi.start(INITIAL_IMAGE_TRANSFORM);
        setIsZoomed(false);
      }
    },
    [isZoomed, maxBoundsLimiter, translateX, translateY, springApi]
  );

  useEffect(() => {
    const animateNode = animateRef.current;
    let hasListener = false;

    if (animateNode && isZoomed) {
      hasListener = true;
      document.addEventListener('mousemove', positionImage);
      document.addEventListener('touchmove', handleTouchMove);
      document.addEventListener('touchstart', handleTouchStart);
    }

    return () => {
      if (hasListener) {
        document.removeEventListener('mousemove', positionImage);
        document.removeEventListener('touchmove', handleTouchMove);
        document.removeEventListener('touchstart', handleTouchStart);
      }
    };
  }, [handleTouchMove, handleTouchStart, isZoomed, positionImage]);

  const caption = attachment?.caption;

  let content: JSX.Element;
  if (!contentType) {
    content = <>{children}</>;
  } else {
    const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
    const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
    const isUnsupportedImageType =
      !isImageTypeSupported && isImage(contentType);
    const isUnsupportedVideoType =
      !isVideoTypeSupported && isVideo(contentType);

    if (isImageTypeSupported) {
      if (objectURL) {
        content = (
          <div className="Lightbox__zoomable-container">
            <button
              className="Lightbox__zoom-button"
              onClick={zoomButtonHandler}
              type="button"
            >
              <img
                alt={i18n('lightboxImageAlt')}
                className="Lightbox__object"
                onContextMenu={(ev: React.MouseEvent<HTMLImageElement>) => {
                  // These are the only image types supported by Electron's NativeImage
                  if (
                    ev &&
                    contentType !== IMAGE_PNG &&
                    !/image\/jpe?g/g.test(contentType)
                  ) {
                    ev.preventDefault();
                  }
                }}
                src={objectURL}
                ref={imageRef}
              />
            </button>
          </div>
        );
      } else {
        content = (
          <button
            aria-label={i18n('lightboxImageAlt')}
            className={classNames({
              Lightbox__object: true,
              Lightbox__unsupported: true,
              'Lightbox__unsupported--missing': true,
            })}
            onClick={onClose}
            type="button"
          />
        );
      }
    } else if (isVideoTypeSupported) {
      const shouldLoop = loop || isAttachmentGIF || isViewOnce;

      content = (
        <video
          className="Lightbox__object"
          controls={!shouldLoop}
          key={objectURL}
          loop={shouldLoop}
          ref={setVideoElement}
        >
          <source src={objectURL} />
        </video>
      );
    } else if (isUnsupportedImageType || isUnsupportedVideoType) {
      content = (
        <button
          aria-label={i18n('unsupportedAttachment')}
          className={classNames({
            Lightbox__object: true,
            Lightbox__unsupported: true,
            'Lightbox__unsupported--image': isUnsupportedImageType,
            'Lightbox__unsupported--video': isUnsupportedVideoType,
          })}
          onClick={onClose}
          type="button"
        />
      );
    } else {
      log.info('Lightbox: Unexpected content type', { contentType });

      content = (
        <button
          aria-label={i18n('unsupportedAttachment')}
          className="Lightbox__object Lightbox__unsupported Lightbox__unsupported--file"
          onClick={onClose}
          type="button"
        />
      );
    }
  }

  const hasNext = !isZoomed && selectedIndex < media.length - 1;
  const hasPrevious = !isZoomed && selectedIndex > 0;

  return root
    ? createPortal(
        <div
          className={classNames('Lightbox Lightbox__container', {
            'Lightbox__container--zoom': isZoomed,
          })}
          onClick={(event: React.MouseEvent<HTMLDivElement>) => {
            event.stopPropagation();
            event.preventDefault();

            close();
          }}
          onKeyUp={(event: React.KeyboardEvent<HTMLDivElement>) => {
            if (
              (containerRef && event.target !== containerRef.current) ||
              event.keyCode !== 27
            ) {
              return;
            }

            close();
          }}
          ref={containerRef}
          role="presentation"
        >
          <div className="Lightbox__animated">
            <div
              className="Lightbox__main-container"
              tabIndex={-1}
              ref={focusRef}
            >
              <div className="Lightbox__header">
                {getConversation ? (
                  <LightboxHeader
                    getConversation={getConversation}
                    i18n={i18n}
                    message={message}
                  />
                ) : (
                  <div />
                )}
                <div className="Lightbox__controls">
                  {onForward ? (
                    <button
                      aria-label={i18n('forwardMessage')}
                      className="Lightbox__button Lightbox__button--forward"
                      onClick={handleForward}
                      type="button"
                    />
                  ) : null}
                  {onSave ? (
                    <button
                      aria-label={i18n('save')}
                      className="Lightbox__button Lightbox__button--save"
                      onClick={handleSave}
                      type="button"
                    />
                  ) : null}
                  <button
                    aria-label={i18n('close')}
                    className="Lightbox__button Lightbox__button--close"
                    onClick={close}
                    type="button"
                  />
                </div>
              </div>
              <animated.div
                className={classNames('Lightbox__object--container', {
                  'Lightbox__object--container--zoom': isZoomed,
                })}
                ref={animateRef}
                style={{
                  transform: to(
                    [scale, translateX, translateY],
                    (s, x, y) => `translate(${x}px, ${y}px) scale(${s})`
                  ),
                }}
              >
                {content}
              </animated.div>
              {hasPrevious && (
                <div className="Lightbox__nav-prev">
                  <button
                    aria-label={i18n('previous')}
                    className="Lightbox__button Lightbox__button--previous"
                    onClick={onPrevious}
                    type="button"
                  />
                </div>
              )}
              {hasNext && (
                <div className="Lightbox__nav-next">
                  <button
                    aria-label={i18n('next')}
                    className="Lightbox__button Lightbox__button--next"
                    onClick={onNext}
                    type="button"
                  />
                </div>
              )}
            </div>
            <div className="Lightbox__footer">
              {isViewOnce && videoTime ? (
                <div className="Lightbox__timestamp">
                  {formatDuration(videoTime)}
                </div>
              ) : null}
              {caption ? (
                <div className="Lightbox__caption">{caption}</div>
              ) : null}
              {media.length > 1 && (
                <div className="Lightbox__thumbnails--container">
                  <div
                    className="Lightbox__thumbnails"
                    style={{
                      marginLeft:
                        0 - (selectedIndex * 64 + selectedIndex * 8 + 32),
                    }}
                  >
                    {media.map((item, index) => (
                      <button
                        className={classNames({
                          Lightbox__thumbnail: true,
                          'Lightbox__thumbnail--selected':
                            index === selectedIndex,
                        })}
                        key={item.thumbnailObjectUrl}
                        type="button"
                        onClick={(
                          event: React.MouseEvent<HTMLButtonElement, MouseEvent>
                        ) => {
                          event.stopPropagation();
                          event.preventDefault();

                          setSelectedIndex(index);
                        }}
                      >
                        {item.thumbnailObjectUrl ? (
                          <img
                            alt={i18n('lightboxImageAlt')}
                            src={item.thumbnailObjectUrl}
                          />
                        ) : (
                          <div className="Lightbox__thumbnail--unavailable" />
                        )}
                      </button>
                    ))}
                  </div>
                </div>
              )}
            </div>
          </div>
        </div>,
        root
      )
    : null;
}

function LightboxHeader({
  getConversation,
  i18n,
  message,
}: {
  getConversation: (id: string) => ConversationType;
  i18n: LocalizerType;
  message: MessageAttributesType;
}): JSX.Element {
  const conversation = getConversation(message.conversationId);

  return (
    <div className="Lightbox__header--container">
      <div className="Lightbox__header--avatar">
        <Avatar
          acceptedMessageRequest={conversation.acceptedMessageRequest}
          avatarPath={conversation.avatarPath}
          badge={undefined}
          color={conversation.color}
          conversationType={conversation.type}
          i18n={i18n}
          isMe={conversation.isMe}
          name={conversation.name}
          phoneNumber={conversation.e164}
          profileName={conversation.profileName}
          sharedGroupNames={conversation.sharedGroupNames}
          size={AvatarSize.THIRTY_TWO}
          title={conversation.title}
          unblurredAvatarPath={conversation.unblurredAvatarPath}
        />
      </div>
      <div className="Lightbox__header--content">
        <div className="Lightbox__header--name">{conversation.title}</div>
        <div className="Lightbox__header--timestamp">
          {moment(message.received_at_ms).format('L LT')}
        </div>
      </div>
    </div>
  );
}
