// Copyright (C) 2020 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.server.restapi.change;

import static com.google.common.collect.ImmutableSet.toImmutableSet;

import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.change.AddToAttentionSetOp;
import com.google.gerrit.server.change.AttentionSetUnchangedOp;
import com.google.gerrit.server.change.CommentThread;
import com.google.gerrit.server.change.CommentThreads;
import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;

/**
 * This class is used to update the attention set when performing a review or replying on a change.
 */
public class ReplyAttentionSetUpdates {

  private final PermissionBackend permissionBackend;
  private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
  private final RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory;
  private final ApprovalsUtil approvalsUtil;
  private final AccountResolver accountResolver;
  private final ServiceUserClassifier serviceUserClassifier;
  private final CommentsUtil commentsUtil;

  @Inject
  ReplyAttentionSetUpdates(
      PermissionBackend permissionBackend,
      AddToAttentionSetOp.Factory addToAttentionSetOpFactory,
      RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory,
      ApprovalsUtil approvalsUtil,
      AccountResolver accountResolver,
      ServiceUserClassifier serviceUserClassifier,
      CommentsUtil commentsUtil) {
    this.permissionBackend = permissionBackend;
    this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
    this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
    this.approvalsUtil = approvalsUtil;
    this.accountResolver = accountResolver;
    this.serviceUserClassifier = serviceUserClassifier;
    this.commentsUtil = commentsUtil;
  }

  /** Adjusts the attention set but only based on the automatic rules. */
  public void processAutomaticAttentionSetRulesOnReply(
      BatchUpdate bu,
      ChangeNotes changeNotes,
      boolean readyForReview,
      CurrentUser currentUser,
      List<HumanComment> commentsToBePublished) {
    if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
      return;
    }
    processRules(
        bu,
        changeNotes,
        readyForReview,
        currentUser,
        commentsToBePublished.stream().collect(toImmutableSet()));
  }

  /**
   * Adjusts the attention set by adding and removing users. If the same user should be added and
   * removed or added/removed twice, the user will only be added/removed once, based on first
   * addition/removal.
   */
  public void updateAttentionSet(
      BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser)
      throws BadRequestException, IOException, PermissionBackendException,
          UnprocessableEntityException, ConfigInvalidException {
    processManualUpdates(bu, changeNotes, input);
    if (input.ignoreAutomaticAttentionSetRules) {

      // If we ignore automatic attention set rules it means we need to pass this information to
      // ChangeUpdate. Also, we should stop all other attention set updates that are part of
      // this method and happen in PostReview.
      bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
      return;
    }
    if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
      botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
      return;
    }

    processRules(
        bu,
        changeNotes,
        isReadyForReview(changeNotes, input),
        currentUser,
        getAllNewComments(changeNotes, input, currentUser));
  }

  private ImmutableSet<HumanComment> getAllNewComments(
      ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser) {
    Set<HumanComment> newComments = new HashSet<>();
    if (input.comments != null) {
      for (ReviewInput.CommentInput commentInput :
          input.comments.values().stream().flatMap(x -> x.stream()).collect(Collectors.toList())) {
        newComments.add(
            commentsUtil.newHumanComment(
                changeNotes,
                currentUser,
                TimeUtil.nowTs(),
                commentInput.path,
                commentInput.patchSet == null
                    ? changeNotes.getChange().currentPatchSetId()
                    : PatchSet.id(changeNotes.getChange().getId(), commentInput.patchSet),
                commentInput.side(),
                commentInput.message,
                commentInput.unresolved,
                commentInput.inReplyTo));
      }
    }
    List<HumanComment> drafts = new ArrayList<>();
    if (input.drafts == ReviewInput.DraftHandling.PUBLISH) {
      drafts =
          commentsUtil.draftByPatchSetAuthor(
              changeNotes.getChange().currentPatchSetId(), currentUser.getAccountId(), changeNotes);
    }
    if (input.drafts == ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS) {
      drafts = commentsUtil.draftByChangeAuthor(changeNotes, currentUser.getAccountId());
    }
    return Stream.concat(newComments.stream(), drafts.stream()).collect(toImmutableSet());
  }

  /**
   * Process the automatic rules of the attention set. All of the automatic rules except
   * adding/removing reviewers and entering/exiting WIP state are done here, and the rest are done
   * in {@link ChangeUpdate}
   */
  private void processRules(
      BatchUpdate bu,
      ChangeNotes changeNotes,
      boolean readyForReview,
      CurrentUser currentUser,
      ImmutableSet<HumanComment> allNewComments) {
    // Replying removes the publishing user from the attention set.
    removeFromAttentionSet(bu, changeNotes, currentUser.getAccountId(), "removed on reply", false);

    Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
    Account.Id owner = changeNotes.getChange().getOwner();

    // The rest of the conditions only apply if the change is open.
    if (changeNotes.getChange().getStatus().isClosed()) {
      // We still add the owner if a new comment thread was created, on closed changes.
      if (allNewComments.stream().anyMatch(c -> c.parentUuid == null)) {
        addToAttentionSet(bu, changeNotes, owner, "A new comment thread was created", false);
      }
      return;
    }
    // The rest of the conditions only apply if the change is ready for review.
    if (!readyForReview) {
      return;
    }

    if (!currentUser.getAccountId().equals(owner)) {
      addToAttentionSet(bu, changeNotes, owner, "Someone else replied on the change", false);
    }
    if (!owner.equals(uploader) && !currentUser.getAccountId().equals(uploader)) {
      addToAttentionSet(bu, changeNotes, uploader, "Someone else replied on the change", false);
    }

    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
  }

  /** Adds all authors of all comment threads that received a reply during this update */
  private void addAllAuthorsOfCommentThreads(
      BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
    List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
    ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
        CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);

    ImmutableSet<Account.Id> repliedToUsers =
        repliedToCommentThreads.stream()
            .map(CommentThread::comments)
            .flatMap(Collection::stream)
            .map(comment -> comment.author.getId())
            .collect(toImmutableSet());
    ImmutableSet<Account.Id> possibleUsersToAdd = approvalsUtil.getReviewers(changeNotes).all();
    SetView<Account.Id> usersToAdd = Sets.intersection(possibleUsersToAdd, repliedToUsers);

    for (Account.Id user : usersToAdd) {
      addToAttentionSet(
          bu, changeNotes, user, "Someone else replied on a comment you posted", false);
    }
  }

  /** Process the manual updates of the attention set. */
  private void processManualUpdates(BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input)
      throws BadRequestException, IOException, PermissionBackendException,
          UnprocessableEntityException, ConfigInvalidException {
    Set<Account.Id> accountsChangedInCommit = new HashSet<>();
    // If we specify a user to remove, and the user is in the attention set, we remove it.
    if (input.removeFromAttentionSet != null) {
      for (AttentionSetInput remove : input.removeFromAttentionSet) {
        removeFromAttentionSet(bu, changeNotes, remove, accountsChangedInCommit);
      }
    }

    // If we don't specify a user to remove, but we specify addition for that user, the user will be
    // added if they are not in the attention set yet.
    if (input.addToAttentionSet != null) {
      for (AttentionSetInput add : input.addToAttentionSet) {
        addToAttentionSet(bu, changeNotes, add, accountsChangedInCommit);
      }
    }
  }

  /**
   * Bots don't process automatic rules, the only attention set change they do is this rule: Add
   * owner and uploader when a bot votes negatively.
   */
  private void botsWithNegativeLabelsAddOwnerAndUploader(
      BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
    if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
      Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
      Account.Id owner = changeNotes.getChange().getOwner();
      addToAttentionSet(bu, changeNotes, owner, "A robot voted negatively on a label", false);
      if (!owner.equals(uploader)) {
        addToAttentionSet(bu, changeNotes, uploader, "A robot voted negatively on a label", false);
      }
    }
  }

  /**
   * Adds the user to the attention set
   *
   * @param bu BatchUpdate to perform the updates to the attention set
   * @param changeNotes current change
   * @param user user to add to the attention set
   * @param reason reason for adding
   * @param notify whether or not to notify about this addition
   */
  private void addToAttentionSet(
      BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
    AddToAttentionSetOp addOwnerToAttentionSet =
        addToAttentionSetOpFactory.create(user, reason, notify);
    bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
  }

  /**
   * Removes the user from the attention set
   *
   * @param bu BatchUpdate to perform the updates to the attention set.
   * @param changeNotes current change.
   * @param user user to add remove from the attention set.
   * @param reason reason for removing.
   * @param notify whether or not to notify about this removal.
   */
  private void removeFromAttentionSet(
      BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
    RemoveFromAttentionSetOp removeFromAttentionSetOp =
        removeFromAttentionSetOpFactory.create(user, reason, notify);
    bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
  }

  private static boolean isReadyForReview(ChangeNotes changeNotes, ReviewInput input) {
    return (!changeNotes.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
  }

  private void addToAttentionSet(
      BatchUpdate bu,
      ChangeNotes changeNotes,
      AttentionSetInput add,
      Set<Account.Id> accountsChangedInCommit)
      throws BadRequestException, IOException, PermissionBackendException,
          UnprocessableEntityException, ConfigInvalidException {
    AttentionSetUtil.validateInput(add);
    try {
      Account.Id attentionUserId =
          getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
      addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
    } catch (AccountResolver.UnresolvableAccountException ex) {
      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
      // message here, then it would be possible to probe whether an account exists.
    }
  }

  private void removeFromAttentionSet(
      BatchUpdate bu,
      ChangeNotes changeNotes,
      AttentionSetInput remove,
      Set<Account.Id> accountsChangedInCommit)
      throws BadRequestException, IOException, PermissionBackendException,
          UnprocessableEntityException, ConfigInvalidException {
    AttentionSetUtil.validateInput(remove);
    try {
      Account.Id attentionUserId =
          getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
      removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
    } catch (AccountResolver.UnresolvableAccountException ex) {
      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
      // message here, then it would be possible to probe whether an account exists.
    }
  }

  private Account.Id getAccountId(ChangeNotes changeNotes, String user)
      throws ConfigInvalidException, IOException, UnprocessableEntityException,
          PermissionBackendException {
    Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
    try {
      permissionBackend
          .absentUser(attentionUserId)
          .change(changeNotes)
          .check(ChangePermission.READ);
    } catch (AuthException e) {
      if (!changeNotes.getChange().isPrivate()) {
        // If the change is private, it is okay to add the user to the attention set since that
        // person will be granted visibility when a reviewer.
        throw new UnprocessableEntityException(
            "Can't add to attention set: Read not permitted for " + attentionUserId, e);
      }
    }
    return attentionUserId;
  }

  private Account.Id getAccountIdAndValidateUser(
      ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
      throws ConfigInvalidException, IOException, PermissionBackendException,
          UnprocessableEntityException, BadRequestException {
    try {
      Account.Id attentionUserId = getAccountId(changeNotes, user);
      if (accountsChangedInCommit.contains(attentionUserId)) {
        throw new BadRequestException(
            String.format(
                "%s can not be added/removed twice, and can not be added and "
                    + "removed at the same time",
                user));
      }
      accountsChangedInCommit.add(attentionUserId);
      return attentionUserId;
    } catch (AccountResolver.UnresolvableAccountException ex) {
      // This can only happen if this user can't see the account or the account doesn't exist.
      // Silently modify the account's attention set anyway, if the account exists.
      return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
    }
  }
}
