/**
   * Sets the list of "pinned" endpoints (i.e. endpoints for which video should always be forwarded,
   * regardless of {@code lastN}).
   *
   * @param newPinnedEndpointIds the list of endpoint IDs to set.
   */
  public void setPinnedEndpointIds(List<String> newPinnedEndpointIds) {
    if (logger.isDebugEnabled()) {
      logger.debug("Setting pinned endpoints: " + newPinnedEndpointIds.toString());
    }
    List<String> endpointsToAskForKeyframe = null;
    synchronized (this) {
      // Since we have the lock anyway, call update() inside, so it
      // doesn't have to obtain it again. But keep the call to
      // askForKeyframes() outside.
      if (!pinnedEndpoints.equals(newPinnedEndpointIds)) {
        pinnedEndpoints = Collections.unmodifiableList(newPinnedEndpointIds);

        endpointsToAskForKeyframe = update();
      }
    }

    askForKeyframes(endpointsToAskForKeyframe);
  }
  /**
   * Recalculates the list of forwarded endpoints based on the current values of the various
   * parameters of this instance ({@link #lastN}, {@link #conferenceSpeechActivityEndpoints}, {@link
   * #pinnedEndpoints}).
   *
   * @param newConferenceEndpoints A list of endpoints which entered the conference since the last
   *     call to this method. They need not be asked for keyframes, because they were never filtered
   *     by this {@link #LastNController(VideoChannel)}.
   * @return the list of IDs of endpoints which were added to {@link #forwardedEndpoints} (i.e. of
   *     endpoints * "entering last-n") as a result of this call. Returns {@code null} if no
   *     endpoints were added.
   */
  private synchronized List<String> update(List<String> newConferenceEndpoints) {
    List<String> newForwardedEndpoints = new LinkedList<>();
    String ourEndpointId = getEndpointId();

    if (conferenceSpeechActivityEndpoints == INITIAL_EMPTY_LIST) {
      conferenceSpeechActivityEndpoints =
          getIDs(channel.getConferenceSpeechActivity().getEndpoints());
      newConferenceEndpoints = conferenceSpeechActivityEndpoints;
    }

    if (lastN < 0 && currentLastN < 0) {
      // Last-N is disabled, we forward everything.
      newForwardedEndpoints.addAll(conferenceSpeechActivityEndpoints);
      if (ourEndpointId != null) {
        newForwardedEndpoints.remove(ourEndpointId);
      }
    } else {
      // Here we have lastN >= 0 || currentLastN >= 0 which implies
      // currentLastN >= 0.

      // Pinned endpoints are always forwarded.
      newForwardedEndpoints.addAll(getPinnedEndpoints());
      // As long as they are still endpoints in the conference.
      newForwardedEndpoints.retainAll(conferenceSpeechActivityEndpoints);

      if (newForwardedEndpoints.size() > currentLastN) {
        // What do we want in this case? It looks like a contradictory
        // request from the client, but maybe it makes for a good API
        // on the client to allow the pinned to override last-n.
        // Unfortunately, this will not play well with Adaptive-Last-N
        // or changes to Last-N for other reasons.
      } else if (newForwardedEndpoints.size() < currentLastN) {
        for (String endpointId : conferenceSpeechActivityEndpoints) {
          if (newForwardedEndpoints.size() < currentLastN) {
            if (!endpointId.equals(ourEndpointId) && !newForwardedEndpoints.contains(endpointId)) {
              newForwardedEndpoints.add(endpointId);
            }
          } else {
            break;
          }
        }
      }
    }

    List<String> enteringEndpoints;
    if (forwardedEndpoints.equals(newForwardedEndpoints)) {
      // We want forwardedEndpoints != INITIAL_EMPTY_LIST
      forwardedEndpoints = newForwardedEndpoints;

      enteringEndpoints = null;
    } else {
      enteringEndpoints = new ArrayList<>(newForwardedEndpoints);
      enteringEndpoints.removeAll(forwardedEndpoints);

      if (logger.isDebugEnabled()) {
        logger.debug(
            "Forwarded endpoints changed: "
                + forwardedEndpoints.toString()
                + " -> "
                + newForwardedEndpoints.toString()
                + ". Entering: "
                + enteringEndpoints.toString());
      }

      forwardedEndpoints = Collections.unmodifiableList(newForwardedEndpoints);

      if (lastN >= 0 || currentLastN >= 0) {
        // TODO: we may want to do this asynchronously.
        channel.sendLastNEndpointsChangeEventOnDataChannel(forwardedEndpoints, enteringEndpoints);
      }
    }

    // If lastN is disabled, the endpoints entering forwardedEndpoints were
    // never filtered, so they don't need to be asked for keyframes.
    if (lastN < 0 && currentLastN < 0) {
      enteringEndpoints = null;
    }

    if (enteringEndpoints != null && newConferenceEndpoints != null) {
      // Endpoints just entering the conference need not be asked for
      // keyframes.
      enteringEndpoints.removeAll(newConferenceEndpoints);
    }

    return enteringEndpoints;
  }
/**
 * Manages the set of <tt>Endpoint</tt>s whose video streams are being forwarded to a specific
 * <tt>VideoChannel</tt> (i.e. the <tt>VideoChannel</tt>'s <tt>LastN</tt> set).
 *
 * @author Lyubomir Marinov
 * @author George Politis
 * @author Boris Grozev
 */
public class LastNController {
  /**
   * The <tt>Logger</tt> used by the <tt>VideoChannel</tt> class and its instances to print debug
   * information.
   */
  private static final Logger logger = Logger.getLogger(LastNController.class);

  /** An empty list instance. */
  private static final List<String> INITIAL_EMPTY_LIST =
      Collections.unmodifiableList(new LinkedList<String>());

  /** The set of <tt>Endpoints</tt> whose video streams are currently being forwarded. */
  private List<String> forwardedEndpoints = INITIAL_EMPTY_LIST;

  /**
   * The list of all <tt>Endpoint</tt>s in the conference, ordered by the last time they were
   * elected dominant speaker.
   */
  private List<String> conferenceSpeechActivityEndpoints = INITIAL_EMPTY_LIST;

  /**
   * The list of endpoints which have been explicitly marked as 'pinned' and whose video streams
   * should always be forwarded.
   */
  private List<String> pinnedEndpoints = INITIAL_EMPTY_LIST;

  /**
   * The maximum number of endpoints whose video streams will be forwarded to the endpoint, as
   * externally configured (by the client, by the focus agent, or by default configuration). A value
   * of {@code -1} means that there is no limit, and all endpoints' video streams will be forwarded.
   */
  private int lastN = -1;

  /**
   * The current limit to the number of endpoints whose video streams will be forwarded to the
   * endpoint. This value can be changed by videobridge (i.e. when Adaptive Last N is used), but it
   * must not exceed the value of {@link #lastN}. A value of {@code -1} means that there is no
   * limit, and all endpoints' video streams will be forwarded.
   */
  private int currentLastN = -1;

  /** Whether or not adaptive lastN is in use. */
  private boolean adaptiveLastN = false;

  /** Whether or not adaptive simulcast is in use. */
  private boolean adaptiveSimulcast = false;

  /**
   * The instance which implements <tt>Adaptive LastN</tt> or <tt>Adaptive Simulcast</tt> on our
   * behalf.
   */
  private BitrateController bitrateController = null;

  /** The {@link VideoChannel} which owns this {@link LastNController}. */
  private final VideoChannel channel;

  /** The ID of the endpoint of {@link #channel}. */
  private String endpointId;

  /**
   * Initializes a new {@link LastNController} instance which is to belong to a particular {@link
   * VideoChannel}.
   *
   * @param channel the owning {@link VideoChannel}.
   */
  public LastNController(VideoChannel channel) {
    this.channel = channel;
  }

  /**
   * @return the maximum number of endpoints whose video streams will be forwarded to the endpoint.
   *     A value of {@code -1} means that there is no limit.
   */
  public int getLastN() {
    return lastN;
  }

  /** @return the set of <tt>Endpoints</tt> whose video streams are currently being forwarded. */
  public List<String> getForwardedEndpoints() {
    return forwardedEndpoints;
  }

  /**
   * Sets the value of {@code lastN}, that is, the maximum number of endpoints whose video streams
   * will be forwarded to the endpoint. A value of {@code -1} means that there is no limit.
   *
   * @param lastN the value to set.
   */
  public void setLastN(int lastN) {
    if (logger.isDebugEnabled()) {
      logger.debug("Setting lastN=" + lastN);
    }

    List<String> endpointsToAskForKeyframe = null;
    synchronized (this) {
      // Since we have the lock anyway, call update() inside, so it
      // doesn't have to obtain it again. But keep the call to
      // askForKeyframes() outside.

      if (this.lastN != lastN) {
        // If we're just now enabling lastN, we don't need to ask for
        // keyframes as all streams were being forwarded already.
        boolean update = this.lastN != -1;

        this.lastN = lastN;

        if (lastN >= 0 && (currentLastN < 0 || currentLastN > lastN)) {
          currentLastN = lastN;
        }

        if (update) {
          endpointsToAskForKeyframe = update();
        }
      }
    }

    askForKeyframes(endpointsToAskForKeyframe);
  }

  /** Closes this {@link LastNController}. */
  public void close() {
    if (bitrateController != null) {
      try {
        bitrateController.close();
      } finally {
        bitrateController = null;
      }
    }
  }

  /**
   * Sets the list of "pinned" endpoints (i.e. endpoints for which video should always be forwarded,
   * regardless of {@code lastN}).
   *
   * @param newPinnedEndpointIds the list of endpoint IDs to set.
   */
  public void setPinnedEndpointIds(List<String> newPinnedEndpointIds) {
    if (logger.isDebugEnabled()) {
      logger.debug("Setting pinned endpoints: " + newPinnedEndpointIds.toString());
    }
    List<String> endpointsToAskForKeyframe = null;
    synchronized (this) {
      // Since we have the lock anyway, call update() inside, so it
      // doesn't have to obtain it again. But keep the call to
      // askForKeyframes() outside.
      if (!pinnedEndpoints.equals(newPinnedEndpointIds)) {
        pinnedEndpoints = Collections.unmodifiableList(newPinnedEndpointIds);

        endpointsToAskForKeyframe = update();
      }
    }

    askForKeyframes(endpointsToAskForKeyframe);
  }

  /**
   * Checks whether RTP packets from {@code sourceChannel} should be forwarded to {@link #channel}.
   *
   * @param sourceChannel the channel.
   * @return {@code true} iff RTP packets from {@code sourceChannel} should be forwarded to {@link
   *     #channel}.
   */
  public boolean isForwarded(Channel sourceChannel) {
    if (lastN < 0 && currentLastN < 0) {
      // If Last-N is disabled, we forward everything.
      return true;
    }

    if (sourceChannel == null) {
      logger.warn("Invalid sourceChannel: null.");
      return false;
    }

    Endpoint channelEndpoint = sourceChannel.getEndpoint();
    if (channelEndpoint == null) {
      logger.warn("sourceChannel has no endpoint.");
      return false;
    }

    if (forwardedEndpoints == INITIAL_EMPTY_LIST) {
      // LastN is enabled, but we haven't yet initialized the list of
      // endpoints in the conference.
      initializeConferenceEndpoints();
    }

    // This may look like a place to optimize, because we query an unordered
    // list (in O(n)) and it executes on each video packet if lastN is
    // enabled. However, the size of  forwardedEndpoints is restricted to
    // lastN and so small enough that it is not worth optimizing.
    return forwardedEndpoints.contains(channelEndpoint.getID());
  }

  /** @return the number of streams currently being forwarded. */
  public int getN() {
    return forwardedEndpoints.size();
  }

  /** @return the list of "pinned" endpoints. */
  public List<String> getPinnedEndpoints() {
    return pinnedEndpoints;
  }

  /**
   * Notifies this instance that the ordered list of endpoints in the conference has changed.
   *
   * @param endpoints the new ordered list of endpoints in the conference.
   * @return the list of endpoints which were added to the list of forwarded endpoints as a result
   *     of the call, or {@code null} if none were added.
   */
  public List<Endpoint> speechActivityEndpointsChanged(List<Endpoint> endpoints) {
    List<String> newEndpointIdList = getIDs(endpoints);
    List<String> enteringEndpointIds = speechActivityEndpointIdsChanged(newEndpointIdList);

    if (logger.isDebugEnabled()) {
      logger.debug(
          "New list of conference endpoints: "
              + newEndpointIdList.toString()
              + "; entering endpoints: "
              + (enteringEndpointIds == null ? "none" : enteringEndpointIds.toString()));
    }

    List<Endpoint> ret = new LinkedList<>();
    if (enteringEndpointIds != null) {
      for (Endpoint endpoint : endpoints) {
        if (enteringEndpointIds.contains(endpoint.getID())) {
          ret.add(endpoint);
        }
      }
    }

    return ret;
  }

  /**
   * Notifies this instance that the ordered list of endpoints (specified as a list of endpoint IDs)
   * in the conference has changed.
   *
   * @param endpointIds the new ordered list of endpoints (specified as a list of endpoint IDs) in
   *     the conference.
   * @return the list of IDs of endpoints which were added to the list of forwarded endpoints as a
   *     result of the call.
   */
  private synchronized List<String> speechActivityEndpointIdsChanged(List<String> endpointIds) {
    if (conferenceSpeechActivityEndpoints.equals(endpointIds)) {
      if (logger.isDebugEnabled()) {
        logger.debug("Conference endpoints have not changed.");
      }
      return null;
    } else {
      List<String> newEndpoints = new LinkedList<>(endpointIds);
      newEndpoints.removeAll(conferenceSpeechActivityEndpoints);

      conferenceSpeechActivityEndpoints = endpointIds;

      return update(newEndpoints);
    }
  }

  /**
   * Enables or disables the "adaptive last-n" mode, depending on the value of {@code
   * adaptiveLastN}.
   *
   * @param adaptiveLastN {@code true} to enable, {@code false} to disable
   */
  public void setAdaptiveLastN(boolean adaptiveLastN) {
    if (this.adaptiveLastN != adaptiveLastN) {
      if (adaptiveLastN && bitrateController == null) {
        bitrateController = new BitrateController(this, channel);
      }

      this.adaptiveLastN = adaptiveLastN;
    }
  }

  /**
   * Enables or disables the "adaptive simulcast" mod, depending on the value of {@code
   * adaptiveLastN}.
   *
   * @param adaptiveSimulcast {@code true} to enable, {@code false} to disable.
   */
  public void setAdaptiveSimulcast(boolean adaptiveSimulcast) {
    if (this.adaptiveSimulcast != adaptiveSimulcast) {
      if (adaptiveSimulcast && bitrateController == null) {
        bitrateController = new BitrateController(this, channel);
      }

      this.adaptiveSimulcast = adaptiveSimulcast;
    }
  }

  /** @return {@code true} iff the "adaptive last-n" mode is enabled. */
  public boolean getAdaptiveLastN() {
    return adaptiveLastN;
  }

  /** @return {@code true} iff the "adaptive simulcast" mode is enabled. */
  public boolean getAdaptiveSimulcast() {
    return adaptiveSimulcast;
  }

  /**
   * Recalculates the list of forwarded endpoints based on the current values of the various
   * parameters of this instance ({@link #lastN}, {@link #conferenceSpeechActivityEndpoints}, {@link
   * #pinnedEndpoints}).
   *
   * @return the list of IDs of endpoints which were added to {@link #forwardedEndpoints} (i.e. of
   *     endpoints * "entering last-n") as a result of this call. Returns {@code null} if no
   *     endpoints were added.
   */
  private synchronized List<String> update() {
    return update(null);
  }

  /**
   * Recalculates the list of forwarded endpoints based on the current values of the various
   * parameters of this instance ({@link #lastN}, {@link #conferenceSpeechActivityEndpoints}, {@link
   * #pinnedEndpoints}).
   *
   * @param newConferenceEndpoints A list of endpoints which entered the conference since the last
   *     call to this method. They need not be asked for keyframes, because they were never filtered
   *     by this {@link #LastNController(VideoChannel)}.
   * @return the list of IDs of endpoints which were added to {@link #forwardedEndpoints} (i.e. of
   *     endpoints * "entering last-n") as a result of this call. Returns {@code null} if no
   *     endpoints were added.
   */
  private synchronized List<String> update(List<String> newConferenceEndpoints) {
    List<String> newForwardedEndpoints = new LinkedList<>();
    String ourEndpointId = getEndpointId();

    if (conferenceSpeechActivityEndpoints == INITIAL_EMPTY_LIST) {
      conferenceSpeechActivityEndpoints =
          getIDs(channel.getConferenceSpeechActivity().getEndpoints());
      newConferenceEndpoints = conferenceSpeechActivityEndpoints;
    }

    if (lastN < 0 && currentLastN < 0) {
      // Last-N is disabled, we forward everything.
      newForwardedEndpoints.addAll(conferenceSpeechActivityEndpoints);
      if (ourEndpointId != null) {
        newForwardedEndpoints.remove(ourEndpointId);
      }
    } else {
      // Here we have lastN >= 0 || currentLastN >= 0 which implies
      // currentLastN >= 0.

      // Pinned endpoints are always forwarded.
      newForwardedEndpoints.addAll(getPinnedEndpoints());
      // As long as they are still endpoints in the conference.
      newForwardedEndpoints.retainAll(conferenceSpeechActivityEndpoints);

      if (newForwardedEndpoints.size() > currentLastN) {
        // What do we want in this case? It looks like a contradictory
        // request from the client, but maybe it makes for a good API
        // on the client to allow the pinned to override last-n.
        // Unfortunately, this will not play well with Adaptive-Last-N
        // or changes to Last-N for other reasons.
      } else if (newForwardedEndpoints.size() < currentLastN) {
        for (String endpointId : conferenceSpeechActivityEndpoints) {
          if (newForwardedEndpoints.size() < currentLastN) {
            if (!endpointId.equals(ourEndpointId) && !newForwardedEndpoints.contains(endpointId)) {
              newForwardedEndpoints.add(endpointId);
            }
          } else {
            break;
          }
        }
      }
    }

    List<String> enteringEndpoints;
    if (forwardedEndpoints.equals(newForwardedEndpoints)) {
      // We want forwardedEndpoints != INITIAL_EMPTY_LIST
      forwardedEndpoints = newForwardedEndpoints;

      enteringEndpoints = null;
    } else {
      enteringEndpoints = new ArrayList<>(newForwardedEndpoints);
      enteringEndpoints.removeAll(forwardedEndpoints);

      if (logger.isDebugEnabled()) {
        logger.debug(
            "Forwarded endpoints changed: "
                + forwardedEndpoints.toString()
                + " -> "
                + newForwardedEndpoints.toString()
                + ". Entering: "
                + enteringEndpoints.toString());
      }

      forwardedEndpoints = Collections.unmodifiableList(newForwardedEndpoints);

      if (lastN >= 0 || currentLastN >= 0) {
        // TODO: we may want to do this asynchronously.
        channel.sendLastNEndpointsChangeEventOnDataChannel(forwardedEndpoints, enteringEndpoints);
      }
    }

    // If lastN is disabled, the endpoints entering forwardedEndpoints were
    // never filtered, so they don't need to be asked for keyframes.
    if (lastN < 0 && currentLastN < 0) {
      enteringEndpoints = null;
    }

    if (enteringEndpoints != null && newConferenceEndpoints != null) {
      // Endpoints just entering the conference need not be asked for
      // keyframes.
      enteringEndpoints.removeAll(newConferenceEndpoints);
    }

    return enteringEndpoints;
  }

  /**
   * Sends a keyframe request to the endpoints specified in {@code endpointIds}
   *
   * @param endpointIds the list of IDs of endpoints to which to send a request for a keyframe.
   */
  private void askForKeyframes(List<String> endpointIds) {
    // TODO: Execute asynchronously.
    if (endpointIds != null && !endpointIds.isEmpty()) {
      channel.getContent().askForKeyframesById(endpointIds);
    }
  }

  /** @return the ID of the endpoint of our channel. */
  private String getEndpointId() {
    if (endpointId == null) {
      Endpoint endpoint = channel.getEndpoint();
      if (endpoint != null) {
        endpointId = endpoint.getID();
      }
    }
    return endpointId;
  }

  /**
   * Initializes the local list of endpoints ({@link #speechActivityEndpointsChanged(List)}) with
   * the current endpoints from the conference.
   */
  public synchronized void initializeConferenceEndpoints() {
    speechActivityEndpointsChanged(channel.getConferenceSpeechActivity().getEndpoints());

    if (logger.isDebugEnabled()) {
      logger.debug(
          "Initialized the list of endpoints: " + conferenceSpeechActivityEndpoints.toString());
    }
  }

  /**
   * Extracts a list of endpoint IDs from a list of {@link Endpoint}s.
   *
   * @param endpoints the list of {@link Endpoint}s.
   * @return the list of IDs of endpoints in {@code endpoints}.
   */
  private List<String> getIDs(List<Endpoint> endpoints) {
    if (endpoints != null && !endpoints.isEmpty()) {
      List<String> endpointIds = new LinkedList<>();
      for (Endpoint endpoint : endpoints) {
        endpointIds.add(endpoint.getID());
      }
      return endpointIds;
    }

    return null;
  }

  public int getCurrentLastN() {
    return currentLastN;
  }

  public int setCurrentLastN(int currentLastN) {
    List<String> endpointsToAskForKeyframe;

    synchronized (this) {
      // Since we have the lock anyway, call update() inside, so it
      // doesn't have to obtain it again. But keep the call to
      // askForKeyframes() outside.

      if (lastN >= 0 && lastN < currentLastN) {
        currentLastN = lastN;
      }

      this.currentLastN = currentLastN;

      endpointsToAskForKeyframe = update();
    }

    askForKeyframes(endpointsToAskForKeyframe);

    return currentLastN;
  }
}