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

import React, { useCallback } from 'react';
import type { RefObject } from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
import { animated, useSpring } from '@react-spring/web';

import type { LocalizerType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { isDownloaded } from '../../types/Attachment';
import type { DirectionType, MessageStatusType } from './Message';

import type { ComputePeaksResult } from '../VoiceNotesPlaybackContext';
import { MessageMetadata } from './MessageMetadata';
import * as log from '../../logging/log';
import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
import { PlaybackRateButton } from '../PlaybackRateButton';
import { PlaybackButton } from '../PlaybackButton';
import { WaveformScrubber } from './WaveformScrubber';
import { useComputePeaks } from '../../hooks/useComputePeaks';
import { durationToPlaybackText } from '../../util/durationToPlaybackText';
import { shouldNeverBeCalled } from '../../util/shouldNeverBeCalled';

export type OwnProps = Readonly<{
  active:
    | Pick<
        ActiveAudioPlayerStateType,
        'currentTime' | 'duration' | 'playing' | 'playbackRate'
      >
    | undefined;
  buttonRef: RefObject<HTMLButtonElement>;
  i18n: LocalizerType;
  attachment: AttachmentType;
  collapseMetadata: boolean;
  withContentAbove: boolean;
  withContentBelow: boolean;

  // Message properties. Many are needed for rendering metadata
  direction: DirectionType;
  expirationLength?: number;
  expirationTimestamp?: number;
  id: string;
  played: boolean;
  status?: MessageStatusType;
  textPending?: boolean;
  timestamp: number;
  kickOffAttachmentDownload(): void;
  onCorrupted(): void;
  computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
  onPlayMessage: (id: string, position: number) => void;
}>;

export type DispatchProps = Readonly<{
  pushPanelForConversation: PushPanelForConversationActionType;
  setPosition: (positionAsRatio: number) => void;
  setPlaybackRate: (rate: number) => void;
  setIsPlaying: (value: boolean) => void;
}>;

export type Props = OwnProps & DispatchProps;

enum State {
  NotDownloaded = 'NotDownloaded',
  Pending = 'Pending',
  Computing = 'Computing',
  Normal = 'Normal',
}

// Constants

const CSS_BASE = 'module-message__audio-attachment';
const BAR_COUNT = 47;
const BAR_NOT_DOWNLOADED_HEIGHT = 2;
const BAR_MIN_HEIGHT = 4;
const BAR_MAX_HEIGHT = 20;

const SPRING_CONFIG = {
  mass: 0.5,
  tension: 350,
  friction: 20,
  velocity: 0.01,
};

const DOT_DIV_WIDTH = 14;

function PlayedDot({
  played,
  onHide,
}: {
  played: boolean;
  onHide: () => void;
}) {
  const start = played ? 1 : 0;
  const end = played ? 0 : 1;

  const [animProps] = useSpring(
    {
      config: SPRING_CONFIG,
      from: { scale: start, opacity: start, width: start },
      to: { scale: end, opacity: end, width: end * DOT_DIV_WIDTH },
      onRest: () => {
        if (played) {
          onHide();
        }
      },
    },
    [played]
  );

  return (
    <animated.div
      style={animProps}
      aria-hidden="true"
      className={classNames(
        `${CSS_BASE}__dot`,
        `${CSS_BASE}__dot--${played ? 'played' : 'unplayed'}`
      )}
    />
  );
}

/**
 * Display message audio attachment along with its waveform, duration, and
 * toggle Play/Pause button.
 *
 * A global audio player is used for playback and access is managed by the
 * `active.content.current.id` and the `active.content.context` properties. Whenever both
 * are equal to `id` and `context` respectively the instance of the `MessageAudio`
 * assumes the ownership of the `Audio` instance and fully manages it.
 *
 * `context` is required for displaying separate MessageAudio instances in
 * MessageDetails and Message React components.
 */
export function MessageAudio(props: Props): JSX.Element {
  const {
    active,
    buttonRef,
    i18n,
    attachment,
    collapseMetadata,
    withContentAbove,
    withContentBelow,

    direction,
    expirationLength,
    expirationTimestamp,
    id,
    played,
    status,
    textPending,
    timestamp,

    kickOffAttachmentDownload,
    onCorrupted,
    setPlaybackRate,
    onPlayMessage,
    pushPanelForConversation,
    setPosition,
    setIsPlaying,
  } = props;

  const isPlaying = active?.playing ?? false;

  const [isPlayedDotVisible, setIsPlayedDotVisible] = React.useState(!played);

  const audioUrl = isDownloaded(attachment) ? attachment.url : undefined;

  const { duration, hasPeaks, peaks } = useComputePeaks({
    audioUrl,
    activeDuration: active?.duration,
    barCount: BAR_COUNT,
    onCorrupted,
  });

  let state: State;

  if (attachment.pending) {
    state = State.Pending;
  } else if (!isDownloaded(attachment)) {
    state = State.NotDownloaded;
  } else if (!hasPeaks) {
    state = State.Computing;
  } else {
    state = State.Normal;
  }

  const toggleIsPlaying = useCallback(() => {
    if (!isPlaying) {
      if (!attachment.url) {
        throw new Error(
          'Expected attachment url in the MessageAudio with ' +
            `state: ${state}`
        );
      }

      if (active) {
        setIsPlaying(true);
      } else {
        onPlayMessage(id, 0);
      }
    } else {
      setIsPlaying(false);
    }
  }, [
    isPlaying,
    attachment.url,
    active,
    state,
    setIsPlaying,
    id,
    onPlayMessage,
  ]);

  const currentTimeOrZero = active?.currentTime ?? 0;

  const updatePosition = useCallback(
    (newPosition: number) => {
      if (active) {
        setPosition(newPosition);
        if (!active.playing) {
          setIsPlaying(true);
        }
        return;
      }

      if (attachment.url) {
        onPlayMessage(id, newPosition);
      } else {
        log.warn('Waveform clicked on attachment with no url');
      }
    },
    [active, attachment.url, id, onPlayMessage, setIsPlaying, setPosition]
  );

  const handleWaveformClick = useCallback(
    (positionAsRatio: number) => {
      if (state !== State.Normal) {
        return;
      }

      updatePosition(positionAsRatio);
    },
    [state, updatePosition]
  );

  const handleWaveformScrub = useCallback(
    (amountInSeconds: number) => {
      const currentPosition = currentTimeOrZero / duration;
      const positionIncrement = amountInSeconds / duration;

      updatePosition(
        Math.min(Math.max(0, currentPosition + positionIncrement), duration)
      );
    },
    [currentTimeOrZero, duration, updatePosition]
  );

  const waveform = (
    <WaveformScrubber
      i18n={i18n}
      peaks={peaks}
      duration={duration}
      currentTime={currentTimeOrZero}
      barMinHeight={
        state !== State.Normal ? BAR_NOT_DOWNLOADED_HEIGHT : BAR_MIN_HEIGHT
      }
      barMaxHeight={BAR_MAX_HEIGHT}
      onClick={handleWaveformClick}
      onScrub={handleWaveformScrub}
    />
  );

  let button: React.ReactElement;
  if (state === State.Pending || state === State.Computing) {
    // Not really a button, but who cares?
    button = (
      <PlaybackButton
        variant="message"
        mod="pending"
        onClick={noop}
        label={i18n('icu:MessageAudio--pending')}
        context={direction}
      />
    );
  } else if (state === State.NotDownloaded) {
    button = (
      <PlaybackButton
        ref={buttonRef}
        variant="message"
        mod="download"
        label={i18n('icu:MessageAudio--download')}
        onClick={kickOffAttachmentDownload}
        context={direction}
      />
    );
  } else {
    // State.Normal
    button = (
      <PlaybackButton
        ref={buttonRef}
        variant="message"
        mod={isPlaying ? 'pause' : 'play'}
        label={
          isPlaying
            ? i18n('icu:MessageAudio--pause')
            : i18n('icu:MessageAudio--play')
        }
        onClick={toggleIsPlaying}
        context={direction}
      />
    );
  }

  const countDown = Math.max(0, duration - (active?.currentTime ?? 0));

  const metadata = (
    <div className={`${CSS_BASE}__metadata`}>
      <div
        aria-hidden="true"
        className={classNames(
          `${CSS_BASE}__countdown`,
          `${CSS_BASE}__countdown--${played ? 'played' : 'unplayed'}`
        )}
      >
        {durationToPlaybackText(countDown)}
      </div>

      <div className={`${CSS_BASE}__controls`}>
        <PlayedDot
          played={played}
          onHide={() => setIsPlayedDotVisible(false)}
        />

        <PlaybackRateButton
          i18n={i18n}
          variant={`message-${direction}`}
          playbackRate={active?.playbackRate}
          visible={isPlaying && (!played || !isPlayedDotVisible)}
          onClick={() => {
            if (active) {
              setPlaybackRate(
                PlaybackRateButton.nextPlaybackRate(active.playbackRate)
              );
            }
          }}
        />
      </div>

      {!withContentBelow && !collapseMetadata && (
        <MessageMetadata
          direction={direction}
          expirationLength={expirationLength}
          expirationTimestamp={expirationTimestamp}
          hasText={withContentBelow}
          i18n={i18n}
          id={id}
          isShowingImage={false}
          isSticker={false}
          isTapToViewExpired={false}
          pushPanelForConversation={pushPanelForConversation}
          retryMessageSend={shouldNeverBeCalled}
          status={status}
          textPending={textPending}
          timestamp={timestamp}
        />
      )}
    </div>
  );

  return (
    <div
      className={classNames(
        CSS_BASE,
        `${CSS_BASE}--${direction}`,
        withContentBelow ? `${CSS_BASE}--with-content-below` : null,
        withContentAbove ? `${CSS_BASE}--with-content-above` : null
      )}
    >
      <div className={`${CSS_BASE}__button-and-waveform`}>
        {button}
        {waveform}
      </div>
      {metadata}
    </div>
  );
}