// Copyright (C) 2016 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.acceptance.api.change;

import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
import static com.google.gerrit.extensions.client.ChangeKind.NO_CHANGE;
import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
import static com.google.gerrit.extensions.client.ChangeKind.REWORK;
import static com.google.gerrit.extensions.client.ChangeKind.TRIVIAL_REBASE;
import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
import static com.google.gerrit.server.project.testing.TestLabels.value;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.util.Comparator.comparing;

import com.google.common.cache.Cache;
import com.google.common.collect.Iterables;
import com.google.common.collect.MoreCollectors;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.ExtensionRegistry;
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.GitUtil;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelFunction;
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.FileInfo;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.server.change.ChangeKindCacheImpl;
import com.google.gerrit.server.project.testing.TestLabels;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.rules.SubmitRule;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.ObjectId;
import org.junit.Before;
import org.junit.Test;

@NoHttpd
public class StickyApprovalsIT extends AbstractDaemonTest {
  @Inject private ProjectOperations projectOperations;
  @Inject private GroupOperations groupOperations;
  @Inject private RequestScopeOperations requestScopeOperations;
  @Inject private ChangeOperations changeOperations;
  @Inject private ChangeKindCreator changeKindCreator;
  @Inject private ExtensionRegistry extensionRegistry;

  @Inject
  @Named("change_kind")
  private Cache<ChangeKindCacheImpl.Key, ChangeKind> changeKindCache;

  @Before
  public void setup() throws Exception {
    try (ProjectConfigUpdate u = updateProject(project)) {
      // Overwrite "Code-Review" label that is inherited from All-Projects.
      // This way changes to the "Code Review" label don't affect other tests.
      LabelType.Builder codeReview =
          labelBuilder(
              LabelId.CODE_REVIEW,
              value(2, "Looks good to me, approved"),
              value(1, "Looks good to me, but someone else must approve"),
              value(0, "No score"),
              value(-1, "I would prefer this is not submitted as is"),
              value(-2, "This shall not be submitted"));
      u.getConfig().upsertLabelType(codeReview.build());

      LabelType.Builder verified =
          labelBuilder(
              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
      u.getConfig().upsertLabelType(verified.build());

      u.save();
    }
    projectOperations
        .project(project)
        .forUpdate()
        .add(
            allowLabel(TestLabels.codeReview().getName())
                .ref(RefNames.REFS_HEADS + "*")
                .group(REGISTERED_USERS)
                .range(-2, 2))
        .add(
            allowLabel(TestLabels.verified().getName())
                .ref(RefNames.REFS_HEADS + "*")
                .group(REGISTERED_USERS)
                .range(-1, 1))
        .update();
  }

  @Test
  public void notSticky() throws Exception {
    assertNotSticky(
        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE));
  }

  @Test
  public void stickyOnAnyScore() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));

    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:REWORK"));

    // changekind:REWORK should match all kind of changes so that approvals are always copied.
    // This means setting changekind:REWORK is equivalent to setting is:ANY and we can do the same
    // assertions for both cases.
    testStickyOnAnyScore();
  }

  private void testStickyOnAnyScore() throws Exception {
    for (ChangeKind changeKind :
        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
      testRepo.reset(projectOperations.project(project).getHead("master"));

      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
      vote(admin, changeId, 2, 1);
      vote(user, changeId, 1, -1);

      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
      ChangeInfo c = detailedChange(changeId);
      assertVotes(c, admin, 2, 0, changeKind);
      assertVotes(c, user, 1, 0, changeKind);
    }
  }

  @Test
  public void stickyOnMinScore() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:min"));

    for (ChangeKind changeKind :
        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
      testRepo.reset(projectOperations.project(project).getHead("master"));

      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
      vote(admin, changeId, -1, 1);
      vote(user, changeId, -2, -1);

      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
      ChangeInfo c = detailedChange(changeId);
      assertVotes(c, admin, 0, 0, changeKind);
      assertVotes(c, user, -2, 0, changeKind);
    }
  }

  @Test
  public void stickyOnMaxScore() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:max"));

    for (ChangeKind changeKind :
        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
      testRepo.reset(projectOperations.project(project).getHead("master"));

      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
      vote(admin, changeId, 2, 1);
      vote(user, changeId, 1, -1);

      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
      ChangeInfo c = detailedChange(changeId);
      assertVotes(c, admin, 2, 0, changeKind);
      assertVotes(c, user, 0, 0, changeKind);
    }
  }

  @Test
  public void stickyOnCopyValues() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:\"-1\" OR is:1"));

    TestAccount user2 = accountCreator.user2();

    for (ChangeKind changeKind :
        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
      testRepo.reset(projectOperations.project(project).getHead("master"));

      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
      vote(admin, changeId, -1, 1);
      vote(user, changeId, -2, -1);
      vote(user2, changeId, 1, -1);

      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
      ChangeInfo c = detailedChange(changeId);
      assertVotes(c, admin, -1, 0, changeKind);
      assertVotes(c, user, 0, 0, changeKind);
      assertVotes(c, user2, 1, 0, changeKind);
    }
  }

  @Test
  public void stickyOnTrivialRebase() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));

    String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
    vote(admin, changeId, 2, 1);
    vote(user, changeId, -2, -1);

    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
    ChangeInfo c = detailedChange(changeId);
    assertVotes(c, admin, 2, 0, NO_CHANGE);
    assertVotes(c, user, -2, 0, NO_CHANGE);

    changeKindCreator.updateChange(changeId, TRIVIAL_REBASE, testRepo, admin, project);
    c = detailedChange(changeId);
    assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
    assertVotes(c, user, -2, 0, TRIVIAL_REBASE);

    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));

    // check that votes are sticky when trivial rebase is done by cherry-pick
    testRepo.reset(projectOperations.project(project).getHead("master"));
    changeId = createChange().getChangeId();
    vote(admin, changeId, 2, 1);
    vote(user, changeId, -2, -1);

    String cherryPickChangeId =
        changeKindCreator.cherryPick(changeId, TRIVIAL_REBASE, testRepo, admin, project);
    c = detailedChange(cherryPickChangeId);
    assertVotes(c, admin, 2, 0);
    assertVotes(c, user, -2, 0);

    // check that votes are not sticky when rework is done by cherry-pick
    testRepo.reset(projectOperations.project(project).getHead("master"));
    changeId = createChange().getChangeId();
    vote(admin, changeId, 2, 1);
    vote(user, changeId, -2, -1);

    cherryPickChangeId = changeKindCreator.cherryPick(changeId, REWORK, testRepo, admin, project);
    c = detailedChange(cherryPickChangeId);
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);
  }

  @Test
  public void stickyOnNoCodeChange() throws Exception {
    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));

    String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
    vote(admin, changeId, 2, 1);
    vote(user, changeId, -2, -1);

    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
    ChangeInfo c = detailedChange(changeId);
    assertVotes(c, admin, 0, 1, NO_CHANGE);
    assertVotes(c, user, 0, -1, NO_CHANGE);

    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
    c = detailedChange(changeId);
    assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
    assertVotes(c, user, 0, -1, NO_CODE_CHANGE);

    assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, MERGE_FIRST_PARENT_UPDATE));
  }

  @Test
  public void stickyOnMergeFirstParentUpdate() throws Exception {
    updateCodeReviewLabel(
        b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));

    String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
    vote(admin, changeId, 2, 1);
    vote(user, changeId, -2, -1);

    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
    ChangeInfo c = detailedChange(changeId);
    assertVotes(c, admin, 2, 0, NO_CHANGE);
    assertVotes(c, user, -2, 0, NO_CHANGE);

    changeKindCreator.updateChange(changeId, MERGE_FIRST_PARENT_UPDATE, testRepo, admin, project);
    c = detailedChange(changeId);
    assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
    assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);

    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, TRIVIAL_REBASE));
  }

  @Test
  public void notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate()
      throws Exception {
    updateCodeReviewLabel(
        b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));

    // Create a change with a non-merge commit
    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
    vote(admin, changeId, 2, 1);
    vote(user, changeId, -2, -1);

    // Make a NO_CHANGE update (expect that votes are not copied since it's not a merge change).
    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
    ChangeInfo c = detailedChange(changeId);
    assertVotes(c, admin, 0, 0, NO_CHANGE);
    assertVotes(c, user, -0, 0, NO_CHANGE);
  }

  @Test
  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + NO_CHANGE.name()));

    String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
    vote(admin, changeId, 2, 1);
    vote(user, changeId, -2, -1);

    changeKindCreator.updateSecondParent(changeId, testRepo, admin);
    ChangeInfo c = detailedChange(changeId);
    assertVotes(c, admin, 0, 0, null);
    assertVotes(c, user, 0, 0, null);
  }

  @Test
  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
      throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    Change.Id changeId =
        changeOperations.newChange().project(project).file("file").content("content").create();
    vote(admin, changeId.toString(), 2, 1);
    vote(user, changeId.toString(), -2, -1);

    changeOperations
        .change(changeId)
        .newPatchset()
        .file("new file")
        .content("new content")
        .create();
    ChangeInfo c = detailedChange(changeId.toString());

    // no votes are copied since the list of files changed.
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);
  }

  @Test
  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
      throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    // create "existing file" and submit it.
    String existingFile = "existing file";
    Change.Id prep =
        changeOperations
            .newChange()
            .project(project)
            .file(existingFile)
            .content("content")
            .create();
    vote(admin, prep.toString(), 2, 1);
    gApi.changes().id(prep.get()).current().submit();

    Change.Id changeId = changeOperations.newChange().project(project).create();
    vote(admin, changeId.toString(), 2, 1);
    vote(user, changeId.toString(), -2, -1);

    changeOperations
        .change(changeId)
        .newPatchset()
        .file(existingFile)
        .content("new content")
        .create();
    ChangeInfo c = detailedChange(changeId.toString());

    // no votes are copied since the list of files changed ("existing file" was added to the
    // change).
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);
  }

  @Test
  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
      throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    Change.Id changeId =
        changeOperations.newChange().project(project).file("file").content("content").create();
    vote(admin, changeId.toString(), 2, 1);
    vote(user, changeId.toString(), -2, -1);

    changeOperations.change(changeId).newPatchset().file("file").delete().create();
    ChangeInfo c = detailedChange(changeId.toString());

    // no votes are copied since the list of files changed.
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);
  }

  @Test
  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
      throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    Change.Id changeId =
        changeOperations.newChange().project(project).file("file").content("content").create();
    vote(admin, changeId.toString(), 2, 1);
    vote(user, changeId.toString(), -2, -1);

    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
    ChangeInfo c = detailedChange(changeId.toString());

    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
    // configured for that label, and list of files didn't change.
    assertVotes(c, admin, 2, 0);
    assertVotes(c, user, -2, 0);
  }

  @Test
  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase()
      throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    // Create two changes both with the same parent
    PushOneCommit.Result r = createChange();
    testRepo.reset("HEAD~1");
    PushOneCommit.Result r2 = createChange();

    // Modify f.txt in change 1. Approve and submit the first change
    gApi.changes().id(r.getChangeId()).edit().modifyFile("f.txt", RawInputUtil.create("content"));
    gApi.changes().id(r.getChangeId()).edit().publish();
    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
    revision.review(ReviewInput.approve().label(LabelId.VERIFIED, 1));
    revision.submit();

    // Add an approval whose score should be copied on change 2.
    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());

    // Rebase the second change. The rebase adds f1.txt.
    gApi.changes().id(r2.getChangeId()).rebase();

    // The code-review approval is copied for the second change between PS1 and PS2 since the only
    // modified file is due to rebase.
    List<PatchSetApproval> patchSetApprovals =
        r2.getChange().notes().getApprovals().all().values().stream()
            .sorted(comparing(a -> a.patchSetId().get()))
            .collect(toImmutableList());
    PatchSetApproval nonCopied = patchSetApprovals.get(0);
    PatchSetApproval copied = patchSetApprovals.get(1);
    assertCopied(nonCopied, /* psId= */ 1, LabelId.CODE_REVIEW, (short) 1, false);
    assertCopied(copied, /* psId= */ 2, LabelId.CODE_REVIEW, (short) 1, true);
  }

  @Test
  @TestProjectInput(createEmptyCommit = false)
  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
      throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    Change.Id changeId =
        changeOperations.newChange().project(project).file("file").content("content").create();
    vote(admin, changeId.toString(), 2, 1);
    vote(user, changeId.toString(), -2, -1);

    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
    ChangeInfo c = detailedChange(changeId.toString());

    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
    // configured for that label, and list of files didn't change.
    assertVotes(c, admin, 2, 0);
    assertVotes(c, user, -2, 0);
  }

  @Test
  public void
      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
          throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    Change.Id changeId =
        changeOperations.newChange().project(project).file("file").content("content").create();
    vote(admin, changeId.toString(), 2, 1);
    vote(user, changeId.toString(), -2, -1);

    changeOperations.change(changeId).newPatchset().file("new file").content("content").create();
    changeOperations
        .change(changeId)
        .newPatchset()
        .file("new file")
        .content("new content")
        .create();
    ChangeInfo c = detailedChange(changeId.toString());

    // Don't copy over votes since ps1->ps2 should copy over, but ps2->ps3 should not.
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);
  }

  @Test
  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
      throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    Change.Id changeId =
        changeOperations.newChange().project(project).file("file").content("content").create();
    vote(admin, changeId.toString(), 2, 1);
    vote(user, changeId.toString(), -2, -1);

    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
    ChangeInfo c = detailedChange(changeId.toString());

    // no votes are copied since the list of files changed (rename).
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);
  }

  @Test
  public void copyWithListOfFilesUnchanged() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    Change.Id changeId =
        changeOperations.newChange().project(project).file("file").content("content").create();
    vote(admin, changeId.toString(), 2, 1);
    vote(user, changeId.toString(), -2, -1);

    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
    ChangeInfo c = detailedChange(changeId.toString());

    // Code-Review votes are copied over from ps1-> ps2 since the list of files were unchanged.
    assertVotes(c, admin, 2, 0);
    assertVotes(c, user, -2, 0);

    changeOperations
        .change(changeId)
        .newPatchset()
        .file("file")
        .content("very new content")
        .create();
    c = detailedChange(changeId.toString());

    // Code-Review votes are copied over from ps1-> ps3 since the list of files were unchanged.
    assertVotes(c, admin, 2, 0);
    assertVotes(c, user, -2, 0);

    changeOperations
        .change(changeId)
        .newPatchset()
        .file("new file")
        .content("new content")
        .create();

    c = detailedChange(changeId.toString());
    // Code-Review votes are not copied over from ps1-> ps4 since a file was added.
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);

    changeOperations.change(changeId).newPatchset().file("file").content("content").create();

    c = detailedChange(changeId.toString());
    // Code-Review votes are not copied over from ps1 -> ps5 since a file was added on ps4.
    // Although the list of files is the same between ps4->ps5, we don't copy votes from before
    // ps4.
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);
  }

  // This test doesn't work without copy condition (when
  // setCopyAllScoresIfListOfFilesDidNotChange=true).
  // This is because magic files are not ignored for setCopyAllScoresIfListOfFilesDidNotChange.
  @Test
  public void stickyIfFilesUnchanged_magicFilesAreIgnored() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));

    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
    Change.Id dummyParentChangeId = changeOperations.newChange().project(project).create();
    Change.Id changeId =
        changeOperations
            .newChange()
            .project(project)
            .mergeOf()
            .change(parent1ChangeId)
            .and()
            .change(parent2ChangeId)
            .create();

    // The change is for a merge commit. It doesn't touch any files, but contains /COMMIT_MSG and
    // /MERGE_LIST as magic files.
    Map<String, FileInfo> changedFilesFirstPatchset =
        gApi.changes().id(changeId.get()).current().files();
    assertThat(changedFilesFirstPatchset.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST");

    // Add a Code-Review+2 vote.
    approve(changeId.toString());

    // Create a new patch set with a non-merge commit.
    changeOperations
        .change(changeId)
        .newPatchset()
        .parent()
        .patchset(PatchSet.id(dummyParentChangeId, 1))
        .create();

    // Since the new patch set is not a merge commit, it no longer contains the magic /MERGE_LIST
    // file.
    Map<String, FileInfo> changedFilesSecondPatchset =
        gApi.changes().id(changeId.get()).current().files();
    assertThat(changedFilesSecondPatchset.keySet())
        .containsExactly("/COMMIT_MSG"); // /MERGE_LIST is no longer present

    // The only file difference between the 2 patch sets is the magic /MERGE_LIST file.
    // Since magic files are ignored when checking whether the files between the patch sets differ,
    // has:unchanged-file should evaluate to true and we expect that the vote was copied.
    ChangeInfo c = detailedChange(changeId.toString());
    assertVotes(c, admin, 2, 0);
  }

  @Test
  public void approvalsAreStickyIfUploaderMatchesUploaderinCondition() throws Exception {
    TestAccount userForWhomApprovalsAreSticky =
        accountCreator.create(
            /* username= */ null,
            /* email= */ "userForWhomApprovalsAreSticky@example.com",
            /* fullName= */ "User For Whom Approvals Are Sticky",
            /* displayName= */ null);
    String usersForWhomApprovalsAreStickyUuid =
        groupOperations
            .newGroup()
            .name("Users-for-whom-approvals-are-sticky")
            .addMember(userForWhomApprovalsAreSticky.id())
            .create()
            .get();

    updateCodeReviewLabel(
        b -> b.setCopyCondition("uploaderin:" + usersForWhomApprovalsAreStickyUuid));

    PushOneCommit.Result r = createChange();

    // Add Code-Review+2 by the admin user.
    approve(r.getChangeId());

    // Add Code-Review+1 by user.
    requestScopeOperations.setApiUser(user.id());
    recommend(r.getChangeId());

    // Create a new patch set by userForWhomApprovalsAreSticky (approavls are sticky).
    TestRepository<InMemoryRepository> userTestRepo =
        cloneProject(project, userForWhomApprovalsAreSticky);
    GitUtil.fetch(userTestRepo, r.getPatchSet().refName() + ":ps");
    userTestRepo.reset("ps");
    amendChange(r.getChangeId(), "refs/for/master", userForWhomApprovalsAreSticky, userTestRepo)
        .assertOkStatus();
    ChangeInfo c = detailedChange(r.getChangeId());
    assertThat(c.revisions.get(c.currentRevision).uploader._accountId)
        .isEqualTo(userForWhomApprovalsAreSticky.id().get());

    // Approvals are sticky.
    assertVotes(c, admin, 2, 0);
    assertVotes(c, user, 1, 0);

    // Create a new patch set by admin user (approvals are not sticky).
    amendChange(r.getChangeId()).assertOkStatus();

    // Approvals are not sticky.
    c = detailedChange(r.getChangeId());
    assertVotes(c, admin, 0, 0);
    assertVotes(c, user, 0, 0);
  }

  @Test
  public void approvalsThatMatchApproverinConditionAreSticky() throws Exception {
    TestAccount userWhoseApprovalsAreSticky = accountCreator.create();
    String usersWhoseApprovalsAreStickyUuid =
        groupOperations
            .newGroup()
            .name("Users-whose-approvals-are-sticky")
            .addMember(userWhoseApprovalsAreSticky.id())
            .create()
            .get();

    updateCodeReviewLabel(
        b -> b.setCopyCondition("approverin:" + usersWhoseApprovalsAreStickyUuid));

    PushOneCommit.Result r = createChange();

    // Add Code-Review+2 by the admin user (not sticky).
    approve(r.getChangeId());

    // Add Code-Review+1 by userWhoseApprovalsAreSticky (sticky).
    requestScopeOperations.setApiUser(userWhoseApprovalsAreSticky.id());
    recommend(r.getChangeId());

    requestScopeOperations.setApiUser(admin.id());
    amendChange(r.getChangeId()).assertOkStatus();

    ChangeInfo c = detailedChange(r.getChangeId());
    assertVotes(c, admin, 0, 0);
    assertVotes(c, userWhoseApprovalsAreSticky, 1, 0);
  }

  @Test
  public void approvalsThatMatchApproverinConditionAreStickyEvenIfUserCantSeeTheGroup()
      throws Exception {
    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;

    // Verify that user can't see the admin group.
    requestScopeOperations.setApiUser(user.id());
    ResourceNotFoundException notFound =
        assertThrows(
            ResourceNotFoundException.class, () -> gApi.groups().id(administratorsUUID).get());
    assertThat(notFound).hasMessageThat().isEqualTo("Not found: " + administratorsUUID);

    requestScopeOperations.setApiUser(admin.id());
    updateCodeReviewLabel(b -> b.setCopyCondition("approverin:" + administratorsUUID));

    PushOneCommit.Result r = createChange();

    // Add Code-Review+2 by the admin user.
    approve(r.getChangeId());

    // Create a new patch set by user.
    // Approvals are copied on creation of the new patch set. The approval of the admin user is
    // expected to be sticky although the group that is configured for the 'approverin' predicate is
    // not visible to the user.
    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
    GitUtil.fetch(userTestRepo, r.getPatchSet().refName() + ":ps");
    userTestRepo.reset("ps");
    amendChange(r.getChangeId(), "refs/for/master", user, userTestRepo).assertOkStatus();

    ChangeInfo c = detailedChange(r.getChangeId());
    assertThat(c.revisions.get(c.currentRevision).uploader._accountId).isEqualTo(user.id().get());

    // Assert that the approval of the admin user was copied to the new patch set.
    assertVotes(c, admin, 2, 0);
  }

  @Test
  public void removedVotesNotSticky() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));

    for (ChangeKind changeKind :
        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
      testRepo.reset(projectOperations.project(project).getHead("master"));

      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
      vote(admin, changeId, 2, 1);
      vote(user, changeId, -2, -1);

      // Remove votes by re-voting with 0
      vote(admin, changeId, 0, 0);
      vote(user, changeId, 0, 0);
      ChangeInfo c = detailedChange(changeId);
      assertVotes(c, admin, 0, 0, null);
      assertVotes(c, user, 0, 0, null);

      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
      c = detailedChange(changeId);
      assertVotes(c, admin, 0, 0, changeKind);
      assertVotes(c, user, 0, 0, changeKind);
    }
  }

  @Test
  public void stickyAcrossMultiplePatchSets() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));

    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
    vote(admin, changeId, 2, 1);

    for (int i = 0; i < 5; i++) {
      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
      ChangeInfo c = detailedChange(changeId);
      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
    }

    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
    ChangeInfo c = detailedChange(changeId);
    assertVotes(c, admin, 2, 0, REWORK);
  }

  @Test
  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
    // The purpose of this test is to make sure that we compute change kind only against the parent
    // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
    // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
    // work in O(num-patch-sets). This test ensures that we aren't regressing.

    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));

    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
    vote(admin, changeId, 2, 1);
    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);

    Map<Integer, ObjectId> revisions = new HashMap<>();
    gApi.changes()
        .id(changeId)
        .get()
        .revisions
        .forEach(
            (revId, revisionInfo) ->
                revisions.put(revisionInfo._number, ObjectId.fromString(revId)));
    assertThat(revisions.size()).isEqualTo(4);
    assertChangeKindCacheContains(revisions.get(3), revisions.get(4));
    assertChangeKindCacheContains(revisions.get(2), revisions.get(3));
    assertChangeKindCacheContains(revisions.get(1), revisions.get(2));

    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(4));
    assertChangeKindCacheDoesNotContain(revisions.get(2), revisions.get(4));
    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(3));
  }

  @Test
  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX OR is:MIN"));

    // Vote max score on PS1
    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
    vote(admin, changeId, 2, 1);

    // Have someone else vote min score on PS2
    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
    vote(user, changeId, -2, 0);
    ChangeInfo c = detailedChange(changeId);
    assertVotes(c, admin, 2, 0, REWORK);
    assertVotes(c, user, -2, 0, REWORK);

    // No vote changes on PS3
    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
    c = detailedChange(changeId);
    assertVotes(c, admin, 2, 0, REWORK);
    assertVotes(c, user, -2, 0, REWORK);

    // Both users revote on PS4
    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
    vote(admin, changeId, 1, 1);
    vote(user, changeId, 1, 1);
    c = detailedChange(changeId);
    assertVotes(c, admin, 1, 1, REWORK);
    assertVotes(c, user, 1, 1, REWORK);

    // New approvals shouldn't carry through to PS5
    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
    c = detailedChange(changeId);
    assertVotes(c, admin, 0, 0, REWORK);
    assertVotes(c, user, 0, 0, REWORK);
  }

  @Test
  public void deleteStickyVote() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));

    String label = LabelId.CODE_REVIEW;

    // Vote max score on PS1
    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
    vote(admin, changeId, label, 2);
    assertVotes(detailedChange(changeId), admin, label, 2, null);
    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
    assertVotes(detailedChange(changeId), admin, label, 2, REWORK);

    // Delete vote that was copied via sticky approval
    deleteVote(admin, changeId, label);
    assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
  }

  @Test
  public void canVoteMultipleTimesOnNewPatchsets() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    PushOneCommit.Result r = createChange();

    // Add a new vote.
    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
    gApi.changes().id(r.getChangeId()).current().review(input);

    // Make a new patchset, keeping the Code-Review +2 vote.
    r = amendChange(r.getChangeId());

    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
    // rather than the approvals() method since the latter one filters out copied approvals).
    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
    assertThat(approvalsPs2).hasSize(1);
    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();

    // Post without changing the vote.
    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
    gApi.changes().id(r.getChangeId()).current().review(input);

    // There is a vote both on patch set 1 and on patch set 2, although both votes are Code-Review
    // +2. The approval on patch set 2 is no longer copied since it was reapplied.
    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
    approvalsPs2 = r.getChange().currentApprovals();
    assertThat(approvalsPs2).hasSize(1);
    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
  }

  @Test
  public void copiedVoteIsNotReapplied_onVoteOnOtherLabel() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    PushOneCommit.Result r = createChange();

    // Add vote that will be copied.
    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
    gApi.changes().id(r.getChangeId()).current().review(input);

    // Create a new patchset, the Code-Review +2 vote is copied.
    r = amendChange(r.getChangeId());

    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
    // rather than the approvals() method since the latter one filters out copied approvals).
    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
    assertThat(approvalsPs2).hasSize(1);
    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();

    // Vote on another label. This shouldn't touch the copied approval.
    input = new ReviewInput().label(LabelId.VERIFIED, 1);
    gApi.changes().id(r.getChangeId()).current().review(input);

    // Patch set 2 has 2 approvals now, one copied approval for the Code-Review label and one
    // non-copied
    // approval for the Verified label.
    approvalsPs2 = r.getChange().currentApprovals();
    assertThat(approvalsPs2).hasSize(2);
    assertThat(
            Iterables.getOnlyElement(
                    approvalsPs2.stream()
                        .filter(psa -> LabelId.CODE_REVIEW.equals(psa.label()))
                        .collect(toImmutableList()))
                .copied())
        .isTrue();
    assertThat(
            Iterables.getOnlyElement(
                    approvalsPs2.stream()
                        .filter(psa -> LabelId.VERIFIED.equals(psa.label()))
                        .collect(toImmutableList()))
                .copied())
        .isFalse();
  }

  @Test
  public void copiedVoteIsNotReapplied_onRebase() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    PushOneCommit.Result r = createChange();

    // Create a sibling change
    testRepo.reset("HEAD~1");
    PushOneCommit.Result r2 = createChange();

    // Add vote that will be copied.
    approve(r2.getChangeId());

    // Verify that that the approval exists and is not copied.
    List<PatchSetApproval> approvalsPs2 = r2.getChange().currentApprovals();
    assertThat(approvalsPs2).hasSize(1);
    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();

    // Approve, verify and submit the first change.
    approve(r.getChangeId());
    gApi.changes()
        .id(r.getChangeId())
        .current()
        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
    gApi.changes().id(r.getChangeId()).current().submit();

    // Rebase the second change, the approval should be sticky.
    gApi.changes().id(r2.getChangeId()).rebase();

    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
    // rather than the approvals() method since the latter one filters out copied approvals).
    approvalsPs2 = changeDataFactory.create(project, r2.getChange().getId()).currentApprovals();
    assertThat(approvalsPs2).hasSize(1);
    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
  }

  @Test
  public void stickyVoteStoredOnUpload() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    PushOneCommit.Result r = createChange();
    // Add a new vote.
    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
    input.tag = "tag";
    gApi.changes().id(r.getChangeId()).current().review(input);

    // Make new patchsets, keeping the Code-Review +2 vote.
    for (int i = 0; i < 9; i++) {
      amendChange(r.getChangeId());
    }

    List<PatchSetApproval> patchSetApprovals =
        r.getChange().notes().getApprovals().all().values().stream()
            .sorted(comparing(a -> a.patchSetId().get()))
            .collect(toImmutableList());

    for (int i = 0; i < 10; i++) {
      int patchSet = i + 1;
      assertThat(patchSetApprovals.get(i).patchSetId().get()).isEqualTo(patchSet);
      assertThat(patchSetApprovals.get(i).accountId().get()).isEqualTo(admin.id().get());
      assertThat(patchSetApprovals.get(i).realAccountId().get()).isEqualTo(admin.id().get());
      assertThat(patchSetApprovals.get(i).label()).isEqualTo(LabelId.CODE_REVIEW);
      assertThat(patchSetApprovals.get(i).value()).isEqualTo((short) 2);
      assertThat(patchSetApprovals.get(i).tag().get()).isEqualTo("tag");
      if (patchSet == 1) {
        assertThat(patchSetApprovals.get(i).copied()).isFalse();
      } else {
        assertThat(patchSetApprovals.get(i).copied()).isTrue();
      }
    }
  }

  @Test
  public void stickyVoteStoredOnRebase() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    // Create two changes both with the same parent
    PushOneCommit.Result r = createChange();
    testRepo.reset("HEAD~1");
    PushOneCommit.Result r2 = createChange();

    // Approve and submit the first change
    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
    revision.review(ReviewInput.approve().label(LabelId.VERIFIED, 1));
    revision.submit();

    // Add an approval whose score should be copied.
    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());

    // Rebase the second change
    gApi.changes().id(r2.getChangeId()).rebase();

    List<PatchSetApproval> patchSetApprovals =
        r2.getChange().notes().getApprovals().all().values().stream()
            .sorted(comparing(a -> a.patchSetId().get()))
            .collect(toImmutableList());
    PatchSetApproval nonCopied = patchSetApprovals.get(0);
    PatchSetApproval copied = patchSetApprovals.get(1);
    assertCopied(nonCopied, 1, LabelId.CODE_REVIEW, (short) 1, /* copied= */ false);
    assertCopied(copied, 2, LabelId.CODE_REVIEW, (short) 1, /* copied= */ true);
  }

  @Test
  public void stickyVoteStoredOnUploadWithRealAccount() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    // Give "user" permission to vote on behalf of other users.
    projectOperations
        .project(project)
        .forUpdate()
        .add(
            allowLabel(TestLabels.codeReview().getName())
                .impersonation(true)
                .ref("refs/heads/*")
                .group(REGISTERED_USERS)
                .range(-1, 1))
        .update();

    PushOneCommit.Result r = createChange();

    // Add a new vote as user
    requestScopeOperations.setApiUser(user.id());
    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
    input.onBehalfOf = admin.email();
    gApi.changes().id(r.getChangeId()).current().review(input);

    // Make a new patchset, keeping the Code-Review +1 vote.
    amendChange(r.getChangeId());

    List<PatchSetApproval> patchSetApprovals =
        r.getChange().notes().getApprovals().all().values().stream()
            .sorted(comparing(a -> a.patchSetId().get()))
            .collect(toImmutableList());

    PatchSetApproval nonCopied = patchSetApprovals.get(0);
    assertThat(nonCopied.patchSetId().get()).isEqualTo(1);
    assertThat(nonCopied.accountId().get()).isEqualTo(admin.id().get());
    assertThat(nonCopied.realAccountId().get()).isEqualTo(user.id().get());
    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
    assertThat(nonCopied.value()).isEqualTo((short) 1);
    assertThat(nonCopied.copied()).isFalse();

    PatchSetApproval copied = patchSetApprovals.get(1);
    assertThat(copied.patchSetId().get()).isEqualTo(2);
    assertThat(copied.accountId().get()).isEqualTo(admin.id().get());
    assertThat(copied.realAccountId().get()).isEqualTo(user.id().get());
    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
    assertThat(copied.value()).isEqualTo((short) 1);
    assertThat(copied.copied()).isTrue();
  }

  @Test
  public void stickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    // Give "user" permission to vote on behalf of other users.
    projectOperations
        .project(project)
        .forUpdate()
        .add(
            allowLabel(TestLabels.codeReview().getName())
                .impersonation(true)
                .ref("refs/heads/*")
                .group(REGISTERED_USERS)
                .range(-1, 1))
        .update();

    PushOneCommit.Result r = createChange();

    // Add a new vote as user
    requestScopeOperations.setApiUser(user.id());
    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
    input.onBehalfOf = admin.email();
    input.tag = "tag";
    gApi.changes().id(r.getChangeId()).current().review(input);

    // Make a new patchset, keeping the Code-Review +1 vote.
    amendChange(r.getChangeId());

    List<PatchSetApproval> patchSetApprovals =
        r.getChange().notes().getApprovals().all().values().stream()
            .sorted(comparing(a -> a.patchSetId().get()))
            .collect(toImmutableList());

    PatchSetApproval nonCopied = patchSetApprovals.get(0);
    assertThat(nonCopied.patchSetId().get()).isEqualTo(1);
    assertThat(nonCopied.accountId().get()).isEqualTo(admin.id().get());
    assertThat(nonCopied.realAccountId().get()).isEqualTo(user.id().get());
    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
    assertThat(nonCopied.value()).isEqualTo((short) 1);
    assertThat(nonCopied.tag().get()).isEqualTo("tag");
    assertThat(nonCopied.copied()).isFalse();

    PatchSetApproval copied = patchSetApprovals.get(1);
    assertThat(copied.patchSetId().get()).isEqualTo(2);
    assertThat(copied.accountId().get()).isEqualTo(admin.id().get());
    assertThat(copied.realAccountId().get()).isEqualTo(user.id().get());
    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
    assertThat(copied.value()).isEqualTo((short) 1);
    assertThat(nonCopied.tag().get()).isEqualTo("tag");
    assertThat(copied.copied()).isTrue();
  }

  @Test
  public void stickyVoteStoredCanBeRemoved() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    PushOneCommit.Result r = createChange();

    // Add a new vote
    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
    gApi.changes().id(r.getChangeId()).current().review(input);

    // Make a new patchset, keeping the Code-Review +2 vote.
    amendChange(r.getChangeId());
    assertVotes(detailedChange(r.getChangeId()), admin, LabelId.CODE_REVIEW, 2, null);

    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());

    PatchSetApproval nonCopiedSecondPatchsetRemovedVote =
        Iterables.getOnlyElement(
            r.getChange()
                .notes()
                .getApprovals()
                .all()
                .get(r.getChange().change().currentPatchSetId()));

    assertThat(nonCopiedSecondPatchsetRemovedVote.patchSetId().get()).isEqualTo(2);
    assertThat(nonCopiedSecondPatchsetRemovedVote.accountId().get()).isEqualTo(admin.id().get());
    assertThat(nonCopiedSecondPatchsetRemovedVote.label()).isEqualTo(LabelId.CODE_REVIEW);
    // The vote got removed since the latest patch-set only has one vote and it's "0".
    assertThat(nonCopiedSecondPatchsetRemovedVote.value()).isEqualTo((short) 0);
    assertThat(nonCopiedSecondPatchsetRemovedVote.copied()).isFalse();
  }

  @Test
  public void reviewerStickyVotingCanBeRemoved() throws Exception {
    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));

    PushOneCommit.Result r = createChange();

    // Add a new vote by user
    requestScopeOperations.setApiUser(user.id());
    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
    requestScopeOperations.setApiUser(admin.id());

    // Make a new patchset, keeping the Code-Review +1 vote.
    amendChange(r.getChangeId());
    assertVotes(detailedChange(r.getChangeId()), user, LabelId.CODE_REVIEW, 1, null);

    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();

    assertThat(r.getChange().notes().getApprovals().all()).isEmpty();

    // Changes message has info about vote removed.
    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
        .contains("Code-Review+1 by User");
  }

  @Test
  public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
    updateVerifiedLabel(b -> b.setFunction(LabelFunction.NO_BLOCK));

    // This test is covering the backfilling logic for changes which have been submitted, based on
    // copied approvals, before Gerrit persisted copied votes as Copied-Label footers in NoteDb. It
    // verifies that for such changes copied approvals are returned from the API even if the copied
    // votes were not persisted as Copied-Label footers.
    //
    // In other words, this test verifies that given a change that was approved by a copied vote and
    // then submitted and for which the copied approval is not persisted as a Copied-Label footer in
    // NoteDb the copied approval is backfilled from the corresponding Submitted-With footer that
    // got written to NoteDb on submit.
    //
    // Creating such a change would be possible by running the old Gerrit code from before Gerrit
    // persisted copied labels as Copied-Label footers. However since this old Gerrit code is no
    // longer available, the test needs to apply a trick to create a change in this state. It
    // configures a fake submit rule, that pretends that an approval for a non-sticky label from an
    // old patch set is still present on the current patch set and allows to submit the change.
    // Since the label is non-sticky no Copied-Label footer is written for it. On submit the fake
    // submit rule results in a Submitted-With footer that records the label as approved (although
    // the label is actually not present on the current patch set). This is exactly the change state
    // that we would have had by running the old code if submit was based on a copied label. As
    // result of the backfilling logic we expect that this "copied" label (the label that is
    // mentioned in the Submitted-With footer) is returned from the API.
    try (Registration registration =
        extensionRegistry.newRegistration().add(new TestSubmitRule(user.id()))) {
      // We want to add a vote on PS1, then not copy it to PS2, but include it in submit records
      PushOneCommit.Result r = createChange();
      String changeId = r.getChangeId();

      // Vote on patch-set 1
      vote(admin, changeId, 2, 1);
      vote(user, changeId, 1, -1);

      // Upload patch-set 2. Change user's "Verified" vote on PS2.
      changeOperations
          .change(Change.id(r.getChange().getId().get()))
          .newPatchset()
          .file("new_file")
          .content("content")
          .commitMessage("Upload PS2")
          .create();
      vote(admin, changeId, 2, 1);
      vote(user, changeId, 1, 1);

      // Upload patch-set 3
      changeOperations
          .change(Change.id(r.getChange().getId().get()))
          .newPatchset()
          .file("another_file")
          .content("content")
          .commitMessage("Upload PS3")
          .create();
      vote(admin, changeId, 2, 1);

      List<PatchSetApproval> patchSetApprovals =
          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
              .sorted(comparing(a -> a.patchSetId().get()))
              .collect(toImmutableList());

      // There's no verified approval on PS#3.
      assertThat(
              patchSetApprovals.stream()
                  .filter(
                      a ->
                          a.accountId().equals(user.id())
                              && a.label().equals(TestLabels.verified().getName())
                              && a.patchSetId().get() == 3)
                  .collect(Collectors.toList()))
          .isEmpty();

      // Submit the change. The TestSubmitRule will store a "submit record" containing a label
      // voted by user, but the latest patch-set does not have an approval for this user, hence
      // it will be copied if we request approvals after the change is merged.
      requestScopeOperations.setApiUser(admin.id());
      gApi.changes().id(changeId).current().submit();

      patchSetApprovals =
          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
              .sorted(comparing(a -> a.patchSetId().get()))
              .collect(toImmutableList());

      // Get the copied approval for user on PS3 for the "Verified" label.
      PatchSetApproval verifiedApproval =
          patchSetApprovals.stream()
              .filter(
                  a ->
                      a.accountId().equals(user.id())
                          && a.label().equals(TestLabels.verified().getName())
                          && a.patchSetId().get() == 3)
              .collect(MoreCollectors.onlyElement());

      assertCopied(
          verifiedApproval,
          /* psId= */ 3,
          TestLabels.verified().getName(),
          (short) 1,
          /* copied= */ true);
    }
  }

  private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
    ChangeKind kind =
        changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
    assertThat(kind).isNotNull();
  }

  private void assertChangeKindCacheDoesNotContain(ObjectId prior, ObjectId next) {
    ChangeKind kind =
        changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
    assertThat(kind).isNull();
  }

  private ChangeInfo detailedChange(String changeId) throws Exception {
    return gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT);
  }

  private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
    for (ChangeKind changeKind : changeKinds) {
      testRepo.reset(projectOperations.project(project).getHead("master"));

      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
      vote(admin, changeId, +2, 1);
      vote(user, changeId, -2, -1);

      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
      ChangeInfo c = detailedChange(changeId);
      assertVotes(c, admin, 0, 0, changeKind);
      assertVotes(c, user, 0, 0, changeKind);
    }
  }

  private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
    requestScopeOperations.setApiUser(user.id());
    gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));
  }

  private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
      throws Exception {
    requestScopeOperations.setApiUser(user.id());
    ReviewInput in =
        new ReviewInput()
            .label(LabelId.CODE_REVIEW, codeReviewVote)
            .label(LabelId.VERIFIED, verifiedVote);
    gApi.changes().id(changeId).current().review(in);
  }

  private void deleteVote(TestAccount user, String changeId, String label) throws Exception {
    requestScopeOperations.setApiUser(user.id());
    gApi.changes().id(changeId).reviewer(user.id().toString()).deleteVote(label);
  }

  private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote) {
    assertVotes(c, user, codeReviewVote, verifiedVote, null);
  }

  private void assertVotes(
      ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote, ChangeKind changeKind) {
    assertVotes(c, user, LabelId.CODE_REVIEW, codeReviewVote, changeKind);
    assertVotes(c, user, LabelId.VERIFIED, verifiedVote, changeKind);
  }

  private void assertVotes(
      ChangeInfo c, TestAccount user, String label, int expectedVote, ChangeKind changeKind) {
    Integer vote = 0;
    if (c.labels.get(label) != null && c.labels.get(label).all != null) {
      for (ApprovalInfo approval : c.labels.get(label).all) {
        if (approval._accountId == user.id().get()) {
          vote = approval.value;
          break;
        }
      }
    }

    String name = "label = " + label;
    if (changeKind != null) {
      name += "; changeKind = " + changeKind.name();
    }
    assertWithMessage(name).that(vote).isEqualTo(expectedVote);
  }

  private void assertCopied(
      PatchSetApproval approval, int psId, String label, short value, boolean copied) {
    assertThat(approval.patchSetId().get()).isEqualTo(psId);
    assertThat(approval.label()).isEqualTo(label);
    assertThat(approval.value()).isEqualTo(value);
    assertThat(approval.copied()).isEqualTo(copied);
  }

  private void updateCodeReviewLabel(Consumer<LabelType.Builder> update) throws Exception {
    updateLabel(LabelId.CODE_REVIEW, update);
  }

  private void updateVerifiedLabel(Consumer<LabelType.Builder> update) throws Exception {
    updateLabel(LabelId.VERIFIED, update);
  }

  private void updateLabel(String labelName, Consumer<LabelType.Builder> update) throws Exception {
    try (ProjectConfigUpdate u = updateProject(project)) {
      u.getConfig().updateLabelType(labelName, update);
      u.save();
    }
  }

  /**
   * Test submit rule that always return a passing record with a "Verified" label applied by {@link
   * TestSubmitRule#userAccountId}.
   */
  private static class TestSubmitRule implements SubmitRule {
    Account.Id userAccountId;

    TestSubmitRule(Account.Id userAccountId) {
      this.userAccountId = userAccountId;
    }

    @Override
    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
      SubmitRecord record = new SubmitRecord();
      record.ruleName = "testSubmitRule";
      record.status = SubmitRecord.Status.OK;
      SubmitRecord.Label label = new SubmitRecord.Label();
      label.label = "Verified";
      label.status = SubmitRecord.Label.Status.OK;
      label.appliedBy = userAccountId;
      record.labels = Arrays.asList(label);
      return Optional.of(record);
    }
  }
}
