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

import React, { useState, useMemo } from 'react';
import Measure from 'react-measure';
import { takeWhile, chunk, maxBy, flatten } from 'lodash';
import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling';
import { GroupCallRemoteParticipantType } from '../state/ducks/calling';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';

const MIN_RENDERED_HEIGHT = 10;
const PARTICIPANT_MARGIN = 10;

interface Dimensions {
  width: number;
  height: number;
}

interface GridArrangement {
  rows: Array<Array<GroupCallRemoteParticipantType>>;
  scalar: number;
}

interface PropsType {
  createCanvasVideoRenderer: () => CanvasVideoRenderer;
  getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
  remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
}

// This component lays out group call remote participants. It uses a custom layout
//   algorithm (in other words, nothing that the browser provides, like flexbox) in
//   order to animate the boxes as they move around, and to figure out the right fits.
//
// It's worth looking at the UI (or a design of it) to get an idea of how it works. Some
//   things to notice:
//
// * Participants are arranged in 0 or more rows.
// * Each row is the same height, but each participant may have a different width.
// * It's possible, on small screens with lots of participants, to have participants
//   removed from the grid. This is because participants have a minimum rendered height.
//
// There should be more specific comments throughout, but the high-level steps are:
//
// 1. Figure out the maximum number of possible rows that could fit on the screen; this is
//    `maxRowCount`.
// 2. Figure out how many participants should be visible if all participants were rendered
//    at the minimum height. Most of the time, we'll be able to render all of them, but on
//    full calls with lots of participants, there could be some lost.
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
//    distribute participants across the rows at the minimum height. Then find the
//    "scalar": how much can we scale these boxes up while still fitting them on the
//    screen? The biggest scalar wins as the "best arrangement".
// 4. Lay out this arrangement on the screen.
export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
  createCanvasVideoRenderer,
  getGroupCallVideoFrameSource,
  remoteParticipants,
}) => {
  const [containerDimensions, setContainerDimensions] = useState<Dimensions>({
    width: 0,
    height: 0,
  });

  // 1. Figure out the maximum number of possible rows that could fit on the screen.
  //
  // We choose the smaller of these two options:
  //
  // - The number of participants, which means there'd be one participant per row.
  // - The number of possible rows in the container, assuming all participants were
  //   rendered at minimum height. Doesn't rely on the number of participants—it's some
  //   simple division.
  //
  // Could be 0 if (a) there are no participants (b) the container's height is small.
  const maxRowCount = Math.min(
    remoteParticipants.length,
    Math.floor(
      containerDimensions.height / (MIN_RENDERED_HEIGHT + PARTICIPANT_MARGIN)
    )
  );

  // 2. Figure out how many participants should be visible if all participants were
  //   rendered at the minimum height. Most of the time, we'll be able to render all of
  //   them, but on full calls with lots of participants, there could be some lost.
  //
  // This is primarily memoized for clarity, not performance. We only need the result,
  //   not any of the "intermediate" values.
  const visibleParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => {
    // Imagine that we laid out all of the rows end-to-end. That's the maximum total
    //   width. So if there were 5 rows and the container was 100px wide, then we can't
    //   possibly fit more than 500px of participants.
    const maxTotalWidth = maxRowCount * containerDimensions.width;

    // We do the same thing for participants, "laying them out end-to-end" until they
    //   exceed the maximum total width.
    let totalWidth = 0;
    return takeWhile(remoteParticipants, remoteParticipant => {
      totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT;
      return totalWidth < maxTotalWidth;
    });
  }, [maxRowCount, containerDimensions.width, remoteParticipants]);

  // 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
  //   distribute participants across the rows at the minimum height. Then find the
  //   "scalar": how much can we scale these boxes up while still fitting them on the
  //   screen? The biggest scalar wins as the "best arrangement".
  const gridArrangement: GridArrangement = useMemo(() => {
    let bestArrangement: GridArrangement = {
      scalar: -1,
      rows: [],
    };

    if (!visibleParticipants.length) {
      return bestArrangement;
    }

    for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) {
      // We do something pretty naïve here and chunk the visible participants into rows.
      //   For example, if there were 12 visible participants and `rowCount === 3`, there
      //   would be 4 participants per row.
      //
      // This naïve chunking is suboptimal in terms of absolute best fit, but it is much
      //   faster and simpler than trying to do this perfectly. In practice, this works
      //   fine in the UI from our testing.
      const numberOfParticipantsInRow = Math.ceil(
        visibleParticipants.length / rowCount
      );
      const rows = chunk(visibleParticipants, numberOfParticipantsInRow);

      // We need to find the scalar for this arrangement. Imagine that we have these
      //   participants at the minimum heights, and we want to scale everything up until
      //   it's about to overflow.
      //
      // We don't want it to overflow horizontally or vertically, so we calculate a
      //   "width scalar" and "height scalar" and choose the smaller of the two. (Choosing
      //   the LARGER of the two could cause overflow.)
      const widestRow = maxBy(rows, totalRemoteParticipantWidthAtMinHeight);
      if (!widestRow) {
        window.log.error(
          'Unable to find the widest row, which should be impossible'
        );
        continue;
      }
      const widthScalar =
        (containerDimensions.width -
          (widestRow.length + 1) * PARTICIPANT_MARGIN) /
        totalRemoteParticipantWidthAtMinHeight(widestRow);
      const heightScalar =
        (containerDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) /
        (rowCount * MIN_RENDERED_HEIGHT);
      const scalar = Math.min(widthScalar, heightScalar);

      // If this scalar is the best one so far, we use that.
      if (scalar > bestArrangement.scalar) {
        bestArrangement = { scalar, rows };
      }
    }

    return bestArrangement;
  }, [
    visibleParticipants,
    maxRowCount,
    containerDimensions.width,
    containerDimensions.height,
  ]);

  // 4. Lay out this arrangement on the screen.
  const gridParticipantHeight = gridArrangement.scalar * MIN_RENDERED_HEIGHT;
  const gridParticipantHeightWithMargin =
    gridParticipantHeight + PARTICIPANT_MARGIN;
  const gridTotalRowHeightWithMargin =
    gridParticipantHeightWithMargin * gridArrangement.rows.length;
  const gridTopOffset = Math.floor(
    (containerDimensions.height - gridTotalRowHeightWithMargin) / 2
  );

  const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map(
    (remoteParticipantsInRow, index) => {
      const top = gridTopOffset + index * gridParticipantHeightWithMargin;

      const totalRowWidthWithoutMargins =
        totalRemoteParticipantWidthAtMinHeight(remoteParticipantsInRow) *
        gridArrangement.scalar;
      const totalRowWidth =
        totalRowWidthWithoutMargins +
        PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1);
      const leftOffset = (containerDimensions.width - totalRowWidth) / 2;

      let rowWidthSoFar = 0;
      return remoteParticipantsInRow.map(remoteParticipant => {
        const renderedWidth =
          remoteParticipant.videoAspectRatio * gridParticipantHeight;
        const left = rowWidthSoFar + leftOffset;

        rowWidthSoFar += renderedWidth + PARTICIPANT_MARGIN;

        return (
          <GroupCallRemoteParticipant
            key={remoteParticipant.demuxId}
            createCanvasVideoRenderer={createCanvasVideoRenderer}
            demuxId={remoteParticipant.demuxId}
            getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
            hasRemoteAudio={remoteParticipant.hasRemoteAudio}
            hasRemoteVideo={remoteParticipant.hasRemoteVideo}
            height={gridParticipantHeight}
            left={left}
            top={top}
            width={renderedWidth}
          />
        );
      });
    }
  );
  const remoteParticipantElements = flatten(rowElements);

  return (
    <Measure
      bounds
      onResize={({ bounds }) => {
        if (!bounds) {
          window.log.error('We should be measuring the bounds');
          return;
        }
        setContainerDimensions(bounds);
      }}
    >
      {({ measureRef }) => (
        <div className="module-ongoing-call__grid" ref={measureRef}>
          {remoteParticipantElements}
        </div>
      )}
    </Measure>
  );
};

function totalRemoteParticipantWidthAtMinHeight(
  remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>
): number {
  return remoteParticipants.reduce(
    (result, { videoAspectRatio }) =>
      result + videoAspectRatio * MIN_RENDERED_HEIGHT,
    0
  );
}
