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

import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { animated, useSpring } from '@react-spring/web';

import { TypingAnimation } from './TypingAnimation';
import { Avatar } from '../Avatar';

import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';

const MAX_AVATARS_COUNT = 3;

type TypingContactType = Pick<
  ConversationType,
  | 'acceptedMessageRequest'
  | 'avatarPath'
  | 'badges'
  | 'color'
  | 'id'
  | 'isMe'
  | 'phoneNumber'
  | 'profileName'
  | 'sharedGroupNames'
  | 'title'
>;

export type TypingBubblePropsType = {
  conversationId: string;
  conversationType: 'group' | 'direct';
  typingContactIdTimestamps: Record<string, number>;
  lastItemAuthorId: string | undefined;
  lastItemTimestamp: number | undefined;
  getConversation: (id: string) => ConversationType;
  getPreferredBadge: PreferredBadgeSelectorType;
  showContactModal: (contactId: string, conversationId?: string) => void;
  i18n: LocalizerType;
  theme: ThemeType;
};

const SPRING_CONFIG = {
  mass: 1,
  tension: 986,
  friction: 64,
  precision: 0,
  velocity: 0,
};

const AVATAR_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
  visible: {
    opacity: 1,
    scale: 1,
    width: '28px',
    x: '0px',
    top: '0px',
  },
  hidden: {
    opacity: 0.5,
    scale: 0.5,
    width: '4px', // Match value of module-message__typing-avatar margin-inline-start
    x: '14px',
    top: '30px',
  },
};

function TypingBubbleAvatar({
  conversationId,
  contact,
  visible,
  getPreferredBadge,
  onContactExit,
  showContactModal,
  i18n,
  theme,
}: {
  conversationId: string;
  contact: TypingContactType | undefined;
  visible: boolean;
  getPreferredBadge: PreferredBadgeSelectorType;
  onContactExit: (id: string | undefined) => void;
  showContactModal: (contactId: string, conversationId?: string) => void;
  i18n: LocalizerType;
  theme: ThemeType;
}): ReactElement | null {
  const [springProps, springApi] = useSpring(
    {
      config: SPRING_CONFIG,
      from: AVATAR_ANIMATION_PROPS[visible ? 'hidden' : 'visible'],
      to: AVATAR_ANIMATION_PROPS[visible ? 'visible' : 'hidden'],
      onRest: () => {
        if (!visible) {
          onContactExit(contact?.id);
        }
      },
    },
    [visible]
  );

  useEffect(() => {
    springApi.stop();
    springApi.start(AVATAR_ANIMATION_PROPS[visible ? 'visible' : 'hidden']);
  }, [visible, springApi]);

  if (!contact) {
    return null;
  }

  return (
    <animated.div className="module-message__typing-avatar" style={springProps}>
      <Avatar
        acceptedMessageRequest={contact.acceptedMessageRequest}
        avatarPath={contact.avatarPath}
        badge={getPreferredBadge(contact.badges)}
        color={contact.color}
        conversationType="direct"
        i18n={i18n}
        isMe={contact.isMe}
        onClick={event => {
          event.stopPropagation();
          event.preventDefault();
          showContactModal(contact.id, conversationId);
        }}
        phoneNumber={contact.phoneNumber}
        profileName={contact.profileName}
        theme={theme}
        title={contact.title}
        sharedGroupNames={contact.sharedGroupNames}
        size={28}
      />
    </animated.div>
  );
}

function TypingBubbleGroupAvatars({
  conversationId,
  typingContactIds,
  getConversation,
  getPreferredBadge,
  showContactModal,
  i18n,
  theme,
}: Pick<
  TypingBubblePropsType,
  | 'conversationId'
  | 'getConversation'
  | 'getPreferredBadge'
  | 'showContactModal'
  | 'i18n'
  | 'theme'
> & {
  typingContactIds: ReadonlyArray<string>;
}): ReactElement {
  const [allContactsById, setAllContactsById] = useState<
    Map<string, TypingContactType>
  >(new Map());

  const onContactExit = useCallback((id: string | undefined) => {
    if (!id) {
      return;
    }

    setAllContactsById(prevMap => {
      const map = new Map([...prevMap]);
      map.delete(id);
      return map;
    });
  }, []);

  const visibleContactIds: Set<string> = useMemo(() => {
    const set = new Set<string>();
    for (const id of typingContactIds) {
      set.add(id);
    }
    return set;
  }, [typingContactIds]);

  useEffect(() => {
    setAllContactsById(prevMap => {
      const map = new Map([...prevMap]);
      for (const id of typingContactIds) {
        map.set(id, getConversation(id));
      }
      return map;
    });
  }, [typingContactIds, getConversation]);

  const typingContactsOverflowCount = Math.max(
    typingContactIds.length - MAX_AVATARS_COUNT,
    0
  );

  // Avatars are rendered Right-to-Left so the leftmost avatars can render on top.
  return (
    <div className="module-message__typing-avatar-container">
      {typingContactsOverflowCount > 0 && (
        <div
          className="module-message__typing-avatar module-message__typing-avatar--overflow-count
        "
        >
          <div
            aria-label={i18n('icu:TypingBubble__avatar--overflow-count', {
              count: typingContactsOverflowCount,
            })}
            className="module-Avatar"
          >
            <div className="module-Avatar__contents">
              <div aria-hidden="true" className="module-Avatar__label">
                +{typingContactsOverflowCount}
              </div>
            </div>
          </div>
        </div>
      )}
      {[...allContactsById.keys()]
        .slice(-1 * MAX_AVATARS_COUNT)
        .map(contactId => (
          <TypingBubbleAvatar
            key={contactId}
            conversationId={conversationId}
            contact={allContactsById.get(contactId)}
            getPreferredBadge={getPreferredBadge}
            showContactModal={showContactModal}
            onContactExit={onContactExit}
            i18n={i18n}
            theme={theme}
            visible={visibleContactIds.has(contactId)}
          />
        ))}
    </div>
  );
}

const OUTER_DIV_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
  visible: { height: '44px' },
  hidden: { height: '0px' },
};
const BUBBLE_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
  visible: {
    opacity: 1,
    scale: 1,
    top: '0px',
  },
  hidden: {
    opacity: 0.5,
    scale: 0.5,
    top: '30px',
  },
};

export function TypingBubble({
  conversationId,
  conversationType,
  typingContactIdTimestamps,
  lastItemAuthorId,
  lastItemTimestamp,
  getConversation,
  getPreferredBadge,
  showContactModal,
  i18n,
  theme,
}: TypingBubblePropsType): ReactElement | null {
  const [isVisible, setIsVisible] = useState(false);

  const typingContactIds = useMemo(
    () => Object.keys(typingContactIdTimestamps),
    [typingContactIdTimestamps]
  );
  const isSomeoneTyping = useMemo(
    () => typingContactIds.length > 0,
    [typingContactIds]
  );
  const [outerDivStyle, outerDivSpringApi] = useSpring(
    {
      from: OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'hidden' : 'visible'],
      to: OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],
      config: SPRING_CONFIG,
    },
    [isSomeoneTyping]
  );
  const [typingAnimationStyle, typingAnimationSpringApi] = useSpring(
    {
      from: BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'hidden' : 'visible'],
      to: BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],
      config: SPRING_CONFIG,
      onRest: () => {
        if (!isSomeoneTyping) {
          setIsVisible(false);
        }
      },
    },
    [isSomeoneTyping]
  );

  useEffect(() => {
    // When typing stops, stay visible to allow time to animate out the bubble.
    if (isSomeoneTyping) {
      setIsVisible(true);
    }
    typingAnimationSpringApi.stop();
    typingAnimationSpringApi.start(
      BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden']
    );
    outerDivSpringApi.stop();
    outerDivSpringApi.start(
      OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden']
    );
  }, [isSomeoneTyping, typingAnimationSpringApi, outerDivSpringApi]);

  // When only one person is typing and they just sent a new message, then instantly
  // hide the bubble without animation to seamlessly transition to their new message.
  useEffect(() => {
    if (
      typingContactIds.length !== 1 ||
      !lastItemAuthorId ||
      !lastItemTimestamp
    ) {
      return;
    }

    const lastTypingContactId = typingContactIds[0];
    const lastTypingTimestamp = typingContactIdTimestamps[lastTypingContactId];
    if (
      lastItemAuthorId === lastTypingContactId &&
      lastItemTimestamp > lastTypingTimestamp
    ) {
      setIsVisible(false);
    }
  }, [
    lastItemAuthorId,
    lastItemTimestamp,
    typingContactIds,
    typingContactIdTimestamps,
  ]);

  if (!isVisible) {
    return null;
  }

  const isGroup = conversationType === 'group';

  return (
    <animated.div
      className="module-timeline__typing-bubble-container"
      style={outerDivStyle}
    >
      <animated.div
        className={classNames(
          'module-message',
          'module-message--incoming',
          'module-message--typing-bubble',
          isGroup ? 'module-message--group' : null
        )}
        style={outerDivStyle}
      >
        {isGroup && (
          <TypingBubbleGroupAvatars
            conversationId={conversationId}
            typingContactIds={typingContactIds}
            getConversation={getConversation}
            getPreferredBadge={getPreferredBadge}
            showContactModal={showContactModal}
            i18n={i18n}
            theme={theme}
          />
        )}
        <div className="module-message__container-outer module-message__container-outer--typing-bubble">
          <animated.div
            className={classNames(
              'module-message__container',
              'module-message__container--incoming'
            )}
            style={typingAnimationStyle}
          >
            <div className="module-message__typing-animation-container">
              <TypingAnimation color="light" i18n={i18n} />
            </div>
          </animated.div>
        </div>
      </animated.div>
    </animated.div>
  );
}
