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

import {
  useRef,
  useState,
  useCallback,
  useEffect,
  useMemo,
  useLayoutEffect,
  type RefObject,
  type JSX,
} from 'react';
import classNames from 'classnames';
import lodash, { clamp } from 'lodash';

import type { VideoFrameSource } from '@signalapp/ringrtc';

import { missingCaseError } from '../util/missingCaseError.std.ts';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall.std.ts';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting.std.ts';
import { CallMode } from '../types/CallDisposition.std.ts';
import { TooltipPlacement } from './Tooltip.dom.tsx';
import { CallingButton, CallingButtonType } from './CallingButton.dom.tsx';
import { CallingPipRemoteVideo } from './CallingPipRemoteVideo.dom.tsx';
import { CallBackgroundBlur } from './CallBackgroundBlur.dom.tsx';

import type { LocalizerType } from '../types/Util.std.ts';
import type {
  ActiveCallType,
  GroupCallVideoRequest,
} from '../types/Calling.std.ts';
import type { SetRendererCanvasType } from '../state/ducks/calling.preload.ts';
import type { CallingImageDataCache } from './CallManager.dom.tsx';
import type { ConversationType } from '../state/ducks/conversations.preload.ts';
import { Avatar, AvatarSize } from './Avatar.dom.tsx';
import { AvatarColors } from '../types/Colors.std.ts';
import type { SetLocalPreviewContainerType } from '../services/calling.preload.ts';
import { usePreviousDeprecated } from '../hooks/usePrevious.std.ts';
import type { SizeCallbackType } from '../calling/VideoSupport.preload.ts';
import { MAX_FRAME_HEIGHT } from '../calling/constants.std.ts';

const { minBy, debounce, noop } = lodash;

enum PositionMode {
  BeingDragged,
  SnapToBottom,
  SnapToLeft,
  SnapToRight,
  SnapToTop,
}

type PositionState =
  | {
      mode: PositionMode.BeingDragged;
      mouseX: number;
      mouseY: number;
      dragOffsetX: number;
      dragOffsetY: number;
    }
  | {
      mode: PositionMode.SnapToLeft | PositionMode.SnapToRight;
      offsetY: number;
    }
  | {
      mode: PositionMode.SnapToTop | PositionMode.SnapToBottom;
      offsetX: number;
    };

type SnapCandidate = {
  mode:
    | PositionMode.SnapToBottom
    | PositionMode.SnapToLeft
    | PositionMode.SnapToRight
    | PositionMode.SnapToTop;
  distanceToEdge: number;
};

export type PropsType = {
  activeCall: ActiveCallType;
  getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
  hangUpActiveCall: (reason: string) => void;
  i18n: LocalizerType;
  imageDataCache: RefObject<CallingImageDataCache>;
  me: Readonly<
    Pick<
      ConversationType,
      | 'avatarUrl'
      | 'avatarPlaceholderGradient'
      | 'color'
      | 'type'
      | 'phoneNumber'
      | 'profileName'
      | 'title'
    >
  >;
  setGroupCallVideoRequest: (
    _: Array<GroupCallVideoRequest>,
    speakerHeight: number
  ) => void;
  setLocalPreviewContainer: (options: SetLocalPreviewContainerType) => void;
  setRendererCanvas: (_: SetRendererCanvasType) => void;
  switchToPresentationView: () => void;
  switchFromPresentationView: () => void;
  toggleAudio: () => void;
  togglePip: () => void;
  toggleVideo: () => void;
};

const PIP_STARTING_HEIGHT_NORMAL = 286;
const PIP_STARTING_HEIGHT_LARGE = 400;
const LARGE_THRESHOLD = 1200;

export const PIP_WIDTH_NORMAL = 160;
const PIP_WIDTH_LARGE = 224;
const PIP_TOP_MARGIN = 78;
const PIP_PADDING = 15;

// Receiving portrait video will cause the PIP to update to match that video size, but
// we need limits
export const PIP_MINIMUM_HEIGHT_MULTIPLIER = 1.2;
export const PIP_MAXIMUM_HEIGHT_MULTIPLIER = 2;

const LOCAL_VIDEO_LARGE_WIDTH = 120;
const LOCAL_VIDEO_LARGE_HEIGHT = 90;
const LOCAL_VIDEO_NORMAL_WIDTH = 80;
const LOCAL_VIDEO_NORMAL_HEIGHT = 60;

export const PIP_MINIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER = 0.5;
export const PIP_MAXIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER = 2;

export function CallingPip({
  activeCall,
  getGroupCallVideoFrameSource,
  hangUpActiveCall,
  imageDataCache,
  i18n,
  me,
  setGroupCallVideoRequest,
  setLocalPreviewContainer,
  setRendererCanvas,
  switchToPresentationView,
  switchFromPresentationView,
  toggleAudio,
  togglePip,
  toggleVideo,
}: PropsType): JSX.Element {
  const isRTL = i18n.getLocaleDirection() === 'rtl';

  const videoContainerRef = useRef<null | HTMLDivElement>(null);

  const [windowWidth, setWindowWidth] = useState(window.innerWidth);
  const [windowHeight, setWindowHeight] = useState(window.innerHeight);
  const [positionState, setPositionState] = useState<PositionState>({
    mode: PositionMode.SnapToRight,
    offsetY: PIP_TOP_MARGIN,
  });

  const isWindowLarge = windowWidth >= LARGE_THRESHOLD;
  const [height, setHeight] = useState(
    isWindowLarge ? PIP_STARTING_HEIGHT_LARGE : PIP_STARTING_HEIGHT_NORMAL
  );
  const [width, setWidth] = useState(
    isWindowLarge ? PIP_WIDTH_LARGE : PIP_WIDTH_NORMAL
  );
  const [localVideoWidth, setLocalVideoWidth] = useState(
    isWindowLarge ? LOCAL_VIDEO_LARGE_WIDTH : LOCAL_VIDEO_NORMAL_WIDTH
  );
  const [localVideoHeight, setLocalVideoHeight] = useState(
    isWindowLarge ? LOCAL_VIDEO_LARGE_HEIGHT : LOCAL_VIDEO_NORMAL_HEIGHT
  );

  useActivateSpeakerViewOnPresenting({
    remoteParticipants: activeCall.remoteParticipants,
    switchToPresentationView,
    switchFromPresentationView,
  });

  const hangUp = useCallback(() => {
    hangUpActiveCall('pip button click');
  }, [hangUpActiveCall]);

  const handleMouseMove = useCallback(
    (ev: MouseEvent) => {
      if (positionState.mode === PositionMode.BeingDragged) {
        setPositionState(oldState => ({
          ...oldState,
          mouseX: ev.clientX,
          mouseY: ev.clientY,
        }));
        ev.preventDefault();
        ev.stopPropagation();
      }
    },
    [positionState]
  );

  const handleMouseUp = useCallback(() => {
    if (positionState.mode === PositionMode.BeingDragged) {
      const { mouseX, mouseY, dragOffsetX, dragOffsetY } = positionState;
      const { innerHeight, innerWidth } = window;

      const offsetX = mouseX - dragOffsetX;
      const offsetY = mouseY - dragOffsetY;

      let distanceToLeftEdge: number;
      let distanceToRightEdge: number;
      if (isRTL) {
        distanceToLeftEdge = innerWidth - (offsetX + width);
        distanceToRightEdge = offsetX;
      } else {
        distanceToLeftEdge = offsetX;
        distanceToRightEdge = innerWidth - (offsetX + width);
      }

      const snapCandidates = [
        {
          mode: PositionMode.SnapToLeft,
          distanceToEdge: distanceToLeftEdge,
        },
        {
          mode: PositionMode.SnapToRight,
          distanceToEdge: distanceToRightEdge,
        },
        {
          mode: PositionMode.SnapToTop,
          distanceToEdge: offsetY - PIP_TOP_MARGIN,
        },
        {
          mode: PositionMode.SnapToBottom,
          distanceToEdge: innerHeight - (offsetY + height),
        },
      ] as const satisfies Array<SnapCandidate>;

      // This fallback is mostly for TypeScript, because `minBy` says it can return
      //   `undefined`.
      const snapTo =
        minBy(snapCandidates, candidate => candidate.distanceToEdge) ||
        snapCandidates[0];

      switch (snapTo.mode) {
        case PositionMode.SnapToLeft:
        case PositionMode.SnapToRight:
          setPositionState({
            mode: snapTo.mode,
            offsetY,
          });
          break;
        case PositionMode.SnapToTop:
        case PositionMode.SnapToBottom:
          setPositionState({
            mode: snapTo.mode,
            offsetX: isRTL ? innerWidth - (offsetX + width) : offsetX,
          });
          break;
        default:
          throw missingCaseError(snapTo);
      }
    }
  }, [height, isRTL, positionState, setPositionState, width]);

  useEffect(() => {
    if (positionState.mode === PositionMode.BeingDragged) {
      document.addEventListener('mousemove', handleMouseMove, false);
      document.addEventListener('mouseup', handleMouseUp, false);

      return () => {
        document.removeEventListener('mouseup', handleMouseUp, false);
        document.removeEventListener('mousemove', handleMouseMove, false);
      };
    }
    return noop;
  }, [positionState.mode, handleMouseMove, handleMouseUp]);

  useEffect(() => {
    const handleWindowResize = debounce(
      () => {
        setWindowWidth(window.innerWidth);
        setWindowHeight(window.innerHeight);
      },
      100,
      {
        maxWait: 3000,
      }
    );

    window.addEventListener('resize', handleWindowResize, false);
    return () => {
      window.removeEventListener('resize', handleWindowResize, false);
    };
  }, []);

  const previousIsWindowLarge = usePreviousDeprecated(
    isWindowLarge,
    isWindowLarge
  );
  // This only runs when isWindowLarge changes, so we aggressively change height + width
  useEffect(() => {
    if (previousIsWindowLarge === isWindowLarge) {
      return;
    }
    const existingPortraitAspectRatio = height / width;

    if (isWindowLarge) {
      setWidth(PIP_WIDTH_LARGE);
      setHeight(PIP_WIDTH_LARGE * existingPortraitAspectRatio);
    } else {
      setWidth(PIP_WIDTH_NORMAL);
      setHeight(PIP_WIDTH_NORMAL * existingPortraitAspectRatio);
    }

    const existingLocalVideoPortraitAspectRatio = localVideoHeight;
    if (isWindowLarge) {
      setLocalVideoWidth(LOCAL_VIDEO_LARGE_WIDTH);
      setLocalVideoHeight(
        LOCAL_VIDEO_LARGE_WIDTH * existingLocalVideoPortraitAspectRatio
      );
    } else {
      setLocalVideoWidth(LOCAL_VIDEO_NORMAL_WIDTH);
      setLocalVideoHeight(
        LOCAL_VIDEO_NORMAL_WIDTH * existingLocalVideoPortraitAspectRatio
      );
    }
  }, [
    height,
    isWindowLarge,
    localVideoHeight,
    localVideoWidth,
    previousIsWindowLarge,
    setHeight,
    setLocalVideoHeight,
    setLocalVideoWidth,
    setWidth,
    width,
  ]);

  const [translateX, translateY] = useMemo<[number, number]>(() => {
    const topMin = PIP_TOP_MARGIN;
    const bottomMax = windowHeight - PIP_PADDING - height;

    const leftScrollPadding = isRTL ? 1 : 0;
    const leftMin = PIP_PADDING + leftScrollPadding;

    const rightScrollPadding = isRTL ? 0 : 1;
    const rightMax = windowWidth - PIP_PADDING - width - rightScrollPadding;

    switch (positionState.mode) {
      case PositionMode.BeingDragged:
        return [
          isRTL
            ? windowWidth -
              positionState.mouseX -
              (width - positionState.dragOffsetX)
            : positionState.mouseX - positionState.dragOffsetX,
          positionState.mouseY - positionState.dragOffsetY,
        ];
      case PositionMode.SnapToLeft:
        return [
          leftMin,
          Math.max(topMin, Math.min(positionState.offsetY, bottomMax)),
        ];
      case PositionMode.SnapToRight:
        return [
          rightMax,
          Math.max(topMin, Math.min(positionState.offsetY, bottomMax)),
        ];
      case PositionMode.SnapToTop:
        return [
          Math.max(leftMin, Math.min(positionState.offsetX, rightMax)),
          topMin,
        ];
      case PositionMode.SnapToBottom:
        return [
          Math.max(leftMin, Math.min(positionState.offsetX, rightMax)),
          bottomMax,
        ];
      default:
        throw missingCaseError(positionState);
    }
  }, [height, isRTL, width, windowWidth, windowHeight, positionState]);
  const localizedTranslateX = isRTL ? -translateX : translateX;

  const [showControls, setShowControls] = useState(false);
  const onMouseEnter = useCallback(() => {
    setShowControls(true);
  }, [setShowControls]);
  const onMouseMove = useCallback(() => {
    setShowControls(true);
  }, [setShowControls]);

  const [controlsHover, setControlsHover] = useState(false);
  const onControlsMouseEnter = useCallback(() => {
    setControlsHover(true);
  }, [setControlsHover]);

  const onControlsMouseLeave = useCallback(() => {
    setControlsHover(false);
  }, [setControlsHover]);

  useEffect(() => {
    if (!showControls) {
      return;
    }
    if (controlsHover) {
      return;
    }

    const timer = setTimeout(() => {
      setShowControls(false);
    }, 2000);
    return clearTimeout.bind(null, timer);
  }, [showControls, controlsHover, setShowControls]);

  const localVideoClassName = activeCall.presentingSource
    ? 'module-calling-pip__video--local-presenting'
    : 'module-calling-pip__video--local';

  let raisedHandsCount = 0;
  let callJoinRequests = 0;
  if (isGroupOrAdhocActiveCall(activeCall)) {
    raisedHandsCount = activeCall.raisedHands.size;
    callJoinRequests = activeCall.pendingParticipants.length;
  }

  let videoButtonType: CallingButtonType;
  if (activeCall.presentingSource) {
    videoButtonType = CallingButtonType.VIDEO_DISABLED;
  } else if (activeCall.hasLocalVideo) {
    videoButtonType = CallingButtonType.VIDEO_ON;
  } else {
    videoButtonType = CallingButtonType.VIDEO_OFF;
  }
  const audioButtonType = activeCall.hasLocalAudio
    ? CallingButtonType.AUDIO_ON
    : CallingButtonType.AUDIO_OFF;
  const hangupButtonType =
    activeCall.callMode === CallMode.Direct
      ? CallingButtonType.HANGUP_DIRECT
      : CallingButtonType.HANGUP_GROUP;

  let remoteVideoNode: JSX.Element;
  const isLonelyInCall = !activeCall.remoteParticipants.length;
  const isSendingVideo =
    activeCall.hasLocalVideo || activeCall.presentingSource;
  const avatarSize = isWindowLarge
    ? AvatarSize.NINETY_SIX
    : AvatarSize.SIXTY_FOUR;

  const lonelyCallPreviewRef = useRef<HTMLDivElement | null>(null);
  const localPreviewRef = useRef<HTMLDivElement | null>(null);

  const handleSize = useCallback(
    (size: Parameters<SizeCallbackType>[0]) => {
      const portraitRatio = size.height / size.width;

      if (isLonelyInCall) {
        const newHeight = clamp(
          Math.floor(width * portraitRatio),
          1,
          MAX_FRAME_HEIGHT
        );

        if (
          newHeight !== height &&
          portraitRatio >= PIP_MINIMUM_HEIGHT_MULTIPLIER &&
          portraitRatio <= PIP_MAXIMUM_HEIGHT_MULTIPLIER
        ) {
          setHeight(newHeight);
        }

        return;
      }

      const newLocalVideoHeight = localVideoWidth * portraitRatio;
      if (
        newLocalVideoHeight !== localVideoHeight &&
        portraitRatio >= PIP_MINIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER &&
        portraitRatio <= PIP_MAXIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER
      ) {
        setLocalVideoHeight(newLocalVideoHeight);
      }
    },
    [
      height,
      isLonelyInCall,
      setHeight,
      width,
      localVideoWidth,
      localVideoHeight,
    ]
  );

  useLayoutEffect(() => {
    if (!isSendingVideo) {
      return;
    }
    if (isLonelyInCall && !lonelyCallPreviewRef.current) {
      return;
    }
    if (!isLonelyInCall && !localPreviewRef.current) {
      return;
    }

    if (lonelyCallPreviewRef.current) {
      setLocalPreviewContainer({
        container: lonelyCallPreviewRef.current,
        sizeCallback: handleSize,
      });
    }
    if (localPreviewRef.current) {
      setLocalPreviewContainer({
        container: localPreviewRef.current,
        sizeCallback: handleSize,
      });
    }
  }, [isSendingVideo, handleSize, isLonelyInCall, setLocalPreviewContainer]);

  if (isLonelyInCall) {
    remoteVideoNode = (
      <div className="module-calling-pip__video--remote">
        {isSendingVideo ? (
          <>
            <CallBackgroundBlur avatarUrl={me.avatarUrl} darken />
            <div
              className={classNames(
                'module-calling-pip__full-size-local-preview',
                activeCall.presentingSource
                  ? 'module-calling-pip__full-size-local-preview--presenting'
                  : undefined
              )}
              ref={lonelyCallPreviewRef}
            />
          </>
        ) : (
          <CallBackgroundBlur avatarUrl={me.avatarUrl}>
            <div className="module-calling-pip__video--avatar">
              <Avatar
                avatarPlaceholderGradient={me.avatarPlaceholderGradient}
                avatarUrl={me.avatarUrl}
                badge={undefined}
                color={me.color || AvatarColors[0]}
                noteToSelf={false}
                conversationType={me.type}
                i18n={i18n}
                phoneNumber={me.phoneNumber}
                profileName={me.profileName}
                title={me.title}
                size={avatarSize}
              />
            </div>
          </CallBackgroundBlur>
        )}
      </div>
    );
  } else {
    remoteVideoNode = (
      <CallingPipRemoteVideo
        activeCall={activeCall}
        getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
        imageDataCache={imageDataCache}
        i18n={i18n}
        setRendererCanvas={setRendererCanvas}
        setGroupCallVideoRequest={setGroupCallVideoRequest}
        height={height}
        width={width}
        updateHeight={(newHeight: number) => {
          setHeight(newHeight);
        }}
      />
    );
  }

  return (
    // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
    <div
      className="module-calling-pip"
      onMouseEnter={onMouseEnter}
      onMouseMove={onMouseMove}
      onMouseDown={ev => {
        const node = videoContainerRef.current;
        if (!node) {
          return;
        }

        const targetNode = ev.target as Element;
        if (targetNode?.tagName === 'BUTTON') {
          return;
        }
        const parentNode = targetNode.parentNode as Element;
        if (parentNode?.tagName === 'BUTTON') {
          return;
        }

        const rect = node.getBoundingClientRect();
        const dragOffsetX = ev.clientX - rect.left;
        const dragOffsetY = ev.clientY - rect.top;

        setPositionState({
          mode: PositionMode.BeingDragged,
          mouseX: ev.clientX,
          mouseY: ev.clientY,
          dragOffsetX,
          dragOffsetY,
        });
      }}
      ref={videoContainerRef}
      style={{
        height: `${height}px`,
        width: `${width}px`,
        cursor:
          positionState.mode === PositionMode.BeingDragged
            ? '-webkit-grabbing'
            : '-webkit-grab',
        transform: `translate3d(${localizedTranslateX}px,calc(${translateY}px), 0)`,
        transition:
          positionState.mode === PositionMode.BeingDragged
            ? 'none'
            : 'transform ease-out 300ms',
      }}
    >
      {remoteVideoNode}

      {!isLonelyInCall && activeCall.hasLocalVideo ? (
        <div
          style={{
            height: `${localVideoHeight}px`,
            width: `${localVideoWidth}px`,
          }}
          className={localVideoClassName}
          ref={localPreviewRef}
        />
      ) : null}

      <div
        className={classNames(
          'module-calling-pip__un-pip-container',
          showControls
            ? 'module-calling-pip__un-pip-container--visible'
            : undefined
        )}
      >
        <CallingButton
          buttonType={CallingButtonType.FULL_SCREEN_CALL}
          i18n={i18n}
          onMouseEnter={onControlsMouseEnter}
          onMouseLeave={onControlsMouseLeave}
          onClick={togglePip}
          tooltipDirection={TooltipPlacement.Top}
        />
      </div>
      {raisedHandsCount || callJoinRequests ? (
        <div
          className={classNames(
            'module-calling-pip__pills',
            !showControls ? 'module-calling-pip__pills--no-controls' : undefined
          )}
        >
          {raisedHandsCount ? (
            <div className="module-calling-pip__pill">
              <div
                className={classNames(
                  'module-calling-pip__pill-icon',
                  'module-calling-pip__pill-icon__raised-hands'
                )}
              />
              {raisedHandsCount}
            </div>
          ) : undefined}
          {callJoinRequests ? (
            <div className="module-calling-pip__pill">
              <div
                className={classNames(
                  'module-calling-pip__pill-icon',
                  'module-calling-pip__pill-icon__group-join'
                )}
              />
              {callJoinRequests}
            </div>
          ) : undefined}
        </div>
      ) : undefined}
      <div
        className={classNames(
          'module-calling-pip__actions',
          showControls ? 'module-calling-pip__actions--visible' : undefined
        )}
      >
        <div className="module-calling-pip__actions__spacer" />
        <div className="module-calling-pip__actions__button">
          <CallingButton
            buttonType={videoButtonType}
            i18n={i18n}
            onMouseEnter={onControlsMouseEnter}
            onMouseLeave={onControlsMouseLeave}
            onClick={toggleVideo}
            tooltipDirection={TooltipPlacement.Top}
          />
        </div>
        <div className="module-calling-pip__actions__button module-calling-pip__actions__middle-button">
          <CallingButton
            buttonType={audioButtonType}
            i18n={i18n}
            onMouseEnter={onControlsMouseEnter}
            onMouseLeave={onControlsMouseLeave}
            onClick={toggleAudio}
            tooltipDirection={TooltipPlacement.Top}
          />
        </div>
        <div className="module-calling-pip__actions__button">
          <CallingButton
            buttonType={hangupButtonType}
            i18n={i18n}
            onMouseEnter={onControlsMouseEnter}
            onMouseLeave={onControlsMouseLeave}
            onClick={hangUp}
            tooltipDirection={TooltipPlacement.Top}
          />
        </div>
        <div className="module-calling-pip__actions__spacer" />
      </div>
    </div>
  );
}
