// Copyright (C) 2014 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.server.change;

import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIXED;
import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIX_FAILED;
import static com.google.gerrit.testing.TestChanges.newPatchSet;
import static java.util.Objects.requireNonNull;

import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmissionId;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ProblemInfo;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.change.ChangeInserter;
import com.google.gerrit.server.change.ConsistencyChecker;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.notedb.ChangeNoteUtil;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.RepoContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.TestChanges;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.RefUpdate.Result;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.Before;
import org.junit.Test;

@NoHttpd
public class ConsistencyCheckerIT extends AbstractDaemonTest {
  @Inject private ChangeNotes.Factory changeNotesFactory;

  @Inject private Provider<ConsistencyChecker> checkerProvider;

  @Inject private IdentifiedUser.GenericFactory userFactory;

  @Inject private ChangeInserter.Factory changeInserterFactory;

  @Inject private PatchSetInserter.Factory patchSetInserterFactory;

  @Inject private ChangeNoteUtil noteUtil;

  @Inject private Sequences sequences;

  private RevCommit tip;
  private Account.Id adminId;
  private ConsistencyChecker checker;
  private TestRepository<InMemoryRepository> serverSideTestRepo;

  @Before
  public void setUp() throws Exception {
    serverSideTestRepo =
        new TestRepository<>((InMemoryRepository) repoManager.openRepository(project));
    tip =
        serverSideTestRepo
            .getRevWalk()
            .parseCommit(serverSideTestRepo.getRepository().exactRef("HEAD").getObjectId());
    adminId = admin.id();
    checker = checkerProvider.get();
  }

  @Test
  public void validNewChange() throws Exception {
    assertNoProblems(insertChange(), null);
  }

  @Test
  public void validMergedChange() throws Exception {
    ChangeNotes notes = mergeChange(incrementPatchSet(insertChange()));
    assertNoProblems(notes, null);
  }

  @Test
  public void missingOwner() throws Exception {
    TestAccount owner = accountCreator.create("missing");
    ChangeNotes notes = insertChange(owner);
    deleteUserBranch(owner.id());

    assertProblems(notes, null, problem("Missing change owner: " + owner.id()));
  }

  // No test for ref existing but object missing; InMemoryRepository won't let
  // us do such a thing.

  @Test
  public void patchSetObjectAndRefMissing() throws Exception {
    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
    ChangeNotes notes = insertChange();
    PatchSet ps = insertMissingPatchSet(notes, rev);
    notes = reload(notes);
    assertProblems(
        notes,
        null,
        problem("Ref missing: " + ps.id().toRefName()),
        problem("Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
  }

  @Test
  public void patchSetObjectAndRefMissingWithFix() throws Exception {
    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
    ChangeNotes notes = insertChange();
    PatchSet ps = insertMissingPatchSet(notes, rev);
    notes = reload(notes);

    String refName = ps.id().toRefName();
    assertProblems(
        notes,
        new FixInput(),
        problem("Ref missing: " + refName),
        problem("Object missing: patch set 2: " + rev));
  }

  @Test
  public void patchSetRefMissing() throws Exception {
    ChangeNotes notes = insertChange();
    serverSideTestRepo.update("refs/other/foo", psUtil.current(notes).commitId());
    String refName = notes.getChange().currentPatchSetId().toRefName();
    deleteRef(refName);

    assertProblems(notes, null, problem("Ref missing: " + refName));
  }

  @Test
  public void patchSetRefMissingWithFix() throws Exception {
    ChangeNotes notes = insertChange();
    ObjectId commitId = psUtil.current(notes).commitId();
    serverSideTestRepo.update("refs/other/foo", commitId);
    String refName = notes.getChange().currentPatchSetId().toRefName();
    deleteRef(refName);

    assertProblems(
        notes, new FixInput(), problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
    assertThat(serverSideTestRepo.getRepository().exactRef(refName).getObjectId())
        .isEqualTo(commitId);
  }

  @Test
  public void patchSetObjectAndRefMissingWithDeletingPatchSet() throws Exception {
    ChangeNotes notes = insertChange();
    PatchSet ps1 = psUtil.current(notes);

    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
    PatchSet ps2 = insertMissingPatchSet(notes, rev2);
    notes = reload(notes);

    FixInput fix = new FixInput();
    fix.deletePatchSetIfCommitMissing = true;
    assertProblems(
        notes,
        fix,
        problem("Ref missing: " + ps2.id().toRefName()),
        problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"));

    notes = reload(notes);
    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
    assertThat(psUtil.get(notes, ps1.id())).isNotNull();
    assertThat(psUtil.get(notes, ps2.id())).isNull();
  }

  @Test
  public void patchSetMultipleObjectsMissingWithDeletingPatchSets() throws Exception {
    ChangeNotes notes = insertChange();
    PatchSet ps1 = psUtil.current(notes);

    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
    PatchSet ps2 = insertMissingPatchSet(notes, rev2);

    notes = incrementPatchSet(reload(notes));
    PatchSet ps3 = psUtil.current(notes);

    String rev4 = "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee";
    PatchSet ps4 = insertMissingPatchSet(notes, rev4);
    notes = reload(notes);

    FixInput fix = new FixInput();
    fix.deletePatchSetIfCommitMissing = true;
    assertProblems(
        notes,
        fix,
        problem("Ref missing: " + ps2.id().toRefName()),
        problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"),
        problem("Ref missing: " + ps4.id().toRefName()),
        problem("Object missing: patch set 4: " + rev4, FIXED, "Deleted patch set"));

    notes = reload(notes);
    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(3);
    assertThat(psUtil.get(notes, ps1.id())).isNotNull();
    assertThat(psUtil.get(notes, ps2.id())).isNull();
    assertThat(psUtil.get(notes, ps3.id())).isNotNull();
    assertThat(psUtil.get(notes, ps4.id())).isNull();
  }

  @Test
  public void onlyPatchSetObjectMissingWithFix() throws Exception {
    Change c = TestChanges.newChange(project, admin.id(), sequences.nextChangeId());

    PatchSet.Id psId = c.currentPatchSetId();
    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
    PatchSet ps = newPatchSet(psId, rev, adminId);

    addNoteDbCommit(
        c.getId(),
        "Create change\n"
            + "\n"
            + "Patch-set: 1\n"
            + "Branch: "
            + c.getDest().branch()
            + "\n"
            + "Change-id: "
            + c.getKey().get()
            + "\n"
            + "Subject: Bogus subject\n"
            + "Commit: "
            + rev
            + "\n"
            + "Groups: "
            + rev
            + "\n");
    indexer.index(c.getProject(), c.getId());
    ChangeNotes notes = changeNotesFactory.create(c.getProject(), c.getId());

    FixInput fix = new FixInput();
    fix.deletePatchSetIfCommitMissing = true;
    assertProblems(
        notes,
        fix,
        problem("Ref missing: " + ps.id().toRefName()),
        problem(
            "Object missing: patch set 1: " + rev,
            FIX_FAILED,
            "Cannot delete patch set; no patch sets would remain"));

    notes = reload(notes);
    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
    assertThat(psUtil.current(notes)).isNotNull();
  }

  @Test
  public void duplicatePatchSetRevisions() throws Exception {
    ChangeNotes notes = insertChange();
    PatchSet ps1 = psUtil.current(notes);

    notes = incrementPatchSet(notes, serverSideTestRepo.getRevWalk().parseCommit(ps1.commitId()));

    assertProblems(
        notes,
        null,
        problem("Multiple patch sets pointing to " + ps1.commitId().name() + ": [1, 2]"));
  }

  @Test
  public void missingDestRef() throws Exception {
    ChangeNotes notes = insertChange();

    String ref = "refs/heads/master";
    // Detach head so we're allowed to delete ref.
    serverSideTestRepo.reset(serverSideTestRepo.getRepository().exactRef(ref).getObjectId());
    RefUpdate ru = serverSideTestRepo.getRepository().updateRef(ref);
    ru.setForceUpdate(true);
    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);

    assertProblems(notes, null, problem("Destination ref not found (may be new branch): " + ref));
  }

  @Test
  public void mergedChangeIsNotMerged() throws Exception {
    ChangeNotes notes = insertChange();

    try (BatchUpdate bu = newUpdate(adminId)) {
      bu.addOp(
          notes.getChangeId(),
          new BatchUpdateOp() {
            @Override
            public boolean updateChange(ChangeContext ctx) {
              ctx.getChange().setStatus(Change.Status.MERGED);
              ctx.getUpdate(ctx.getChange().currentPatchSetId())
                  .fixStatusToMerged(new SubmissionId(ctx.getChange()));
              return true;
            }
          });
      bu.execute();
    }
    notes = reload(notes);

    ObjectId tip = getDestRef(notes);
    assertProblems(
        notes,
        null,
        problem(
            "Patch set 1 ("
                + psUtil.current(notes).commitId().name()
                + ") is not merged into destination ref"
                + " refs/heads/master ("
                + tip.name()
                + "), but change status is MERGED"));
  }

  @Test
  public void newChangeIsMerged() throws Exception {
    ChangeNotes notes = insertChange();
    ObjectId commitId = psUtil.current(notes).commitId();
    serverSideTestRepo
        .branch(notes.getChange().getDest().branch())
        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));

    assertProblems(
        notes,
        null,
        problem(
            "Patch set 1 ("
                + commitId.name()
                + ") is merged into destination ref"
                + " refs/heads/master ("
                + commitId.name()
                + "), but change status is NEW"));
  }

  @Test
  public void newChangeIsMergedWithFix() throws Exception {
    ChangeNotes notes = insertChange();
    ObjectId commitId = psUtil.current(notes).commitId();
    serverSideTestRepo
        .branch(notes.getChange().getDest().branch())
        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));

    assertProblems(
        notes,
        new FixInput(),
        problem(
            "Patch set 1 ("
                + commitId.name()
                + ") is merged into destination ref"
                + " refs/heads/master ("
                + commitId.name()
                + "), but change status is NEW",
            FIXED,
            "Marked change as merged"));

    notes = reload(notes);
    assertThat(notes.getChange().isMerged()).isTrue();
    assertNoProblems(notes, null);
  }

  @Test
  public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
    ChangeNotes notes = insertChange();
    ObjectId commitId = psUtil.current(notes).commitId();
    serverSideTestRepo
        .branch(notes.getChange().getDest().branch())
        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));

    ChangeInfo info = gApi.changes().id(notes.getChangeId().get()).info();
    assertThat(info.status).isEqualTo(ChangeStatus.NEW);

    info = gApi.changes().id(notes.getChangeId().get()).check(new FixInput());
    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
  }

  @Test
  public void expectedMergedCommitIsLatestPatchSet() throws Exception {
    ChangeNotes notes = insertChange();
    ObjectId commitId = psUtil.current(notes).commitId();
    serverSideTestRepo
        .branch(notes.getChange().getDest().branch())
        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));

    FixInput fix = new FixInput();
    fix.expectMergedAs = commitId.name();
    assertProblems(
        notes,
        fix,
        problem(
            "Patch set 1 ("
                + commitId.name()
                + ") is merged into destination ref"
                + " refs/heads/master ("
                + commitId.name()
                + "), but change status is NEW",
            FIXED,
            "Marked change as merged"));

    notes = reload(notes);
    assertThat(notes.getChange().isMerged()).isTrue();
    assertNoProblems(notes, null);
  }

  @Test
  public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
    ChangeNotes notes = insertChange();
    RevCommit commit =
        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit);

    FixInput fix = new FixInput();
    RevCommit other = serverSideTestRepo.commit().message(commit.getFullMessage()).create();
    fix.expectMergedAs = other.name();
    assertProblems(
        notes,
        fix,
        problem(
            "Expected merged commit "
                + other.name()
                + " is not merged into destination ref refs/heads/master"
                + " ("
                + commit.name()
                + ")"));
  }

  @Test
  public void createNewPatchSetForExpectedMergeCommitWithNoChangeId() throws Exception {
    ChangeNotes notes = insertChange();
    String dest = notes.getChange().getDest().branch();
    RevCommit commit =
        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());

    RevCommit mergedAs =
        serverSideTestRepo
            .commit()
            .parent(commit.getParent(0))
            .message(commit.getShortMessage())
            .create();
    serverSideTestRepo.getRevWalk().parseBody(mergedAs);
    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).isEmpty();
    serverSideTestRepo.update(dest, mergedAs);

    assertNoProblems(notes, null);

    FixInput fix = new FixInput();
    fix.expectMergedAs = mergedAs.name();
    assertProblems(
        notes,
        fix,
        problem(
            "No patch set found for merged commit " + mergedAs.name(),
            FIXED,
            "Marked change as merged"),
        problem(
            "Expected merged commit " + mergedAs.name() + " has no associated patch set",
            FIXED,
            "Inserted as patch set 2"));

    notes = reload(notes);
    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(mergedAs);

    assertNoProblems(notes, null);
  }

  @Test
  public void createNewPatchSetForExpectedMergeCommitWithChangeId() throws Exception {
    ChangeNotes notes = insertChange();
    String dest = notes.getChange().getDest().branch();
    RevCommit commit =
        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());

    RevCommit mergedAs =
        serverSideTestRepo
            .commit()
            .parent(commit.getParent(0))
            .message(
                commit.getShortMessage()
                    + "\n"
                    + "\n"
                    + "Change-Id: "
                    + notes.getChange().getKey().get()
                    + "\n")
            .create();
    serverSideTestRepo.getRevWalk().parseBody(mergedAs);
    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
        .containsExactly(notes.getChange().getKey().get());
    serverSideTestRepo.update(dest, mergedAs);

    assertNoProblems(notes, null);

    FixInput fix = new FixInput();
    fix.expectMergedAs = mergedAs.name();
    assertProblems(
        notes,
        fix,
        problem(
            "No patch set found for merged commit " + mergedAs.name(),
            FIXED,
            "Marked change as merged"),
        problem(
            "Expected merged commit " + mergedAs.name() + " has no associated patch set",
            FIXED,
            "Inserted as patch set 2"));

    notes = reload(notes);
    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(mergedAs);

    assertNoProblems(notes, null);
  }

  @Test
  public void expectedMergedCommitIsOldPatchSetOfSameChange() throws Exception {
    ChangeNotes notes = insertChange();
    ObjectId commitId1 = psUtil.current(notes).commitId();
    notes = incrementPatchSet(notes);
    PatchSet ps2 = psUtil.current(notes);
    serverSideTestRepo
        .branch(notes.getChange().getDest().branch())
        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId1));

    FixInput fix = new FixInput();
    fix.expectMergedAs = commitId1.name();
    assertProblems(
        notes,
        fix,
        problem(
            "No patch set found for merged commit " + commitId1.name(),
            FIXED,
            "Marked change as merged"),
        problem(
            "Expected merge commit "
                + commitId1.name()
                + " corresponds to patch set 1,"
                + " not the current patch set 2",
            FIXED,
            "Deleted patch set"),
        problem(
            "Expected merge commit "
                + commitId1.name()
                + " corresponds to patch set 1,"
                + " not the current patch set 2",
            FIXED,
            "Inserted as patch set 3"));

    notes = reload(notes);
    PatchSet.Id psId3 = PatchSet.id(notes.getChangeId(), 3);
    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId3);
    assertThat(notes.getChange().isMerged()).isTrue();
    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps2.id(), psId3);
    assertThat(psUtil.get(notes, psId3).commitId()).isEqualTo(commitId1);
  }

  @Test
  public void expectedMergedCommitIsDanglingPatchSetOlderThanCurrent() throws Exception {
    ChangeNotes notes = insertChange();
    PatchSet ps1 = psUtil.current(notes);

    // Create dangling ref so next ID in the database becomes 3.
    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
    RevCommit commit2 = patchSetCommit(psId2);
    serverSideTestRepo.branch(psId2.toRefName()).update(commit2);

    notes = incrementPatchSet(notes);
    PatchSet ps3 = psUtil.current(notes);
    assertThat(ps3.id().get()).isEqualTo(3);

    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit2);

    FixInput fix = new FixInput();
    fix.expectMergedAs = commit2.name();
    assertProblems(
        notes,
        fix,
        problem(
            "No patch set found for merged commit " + commit2.name(),
            FIXED,
            "Marked change as merged"),
        problem(
            "Expected merge commit "
                + commit2.name()
                + " corresponds to patch set 2,"
                + " not the current patch set 3",
            FIXED,
            "Deleted patch set"),
        problem(
            "Expected merge commit "
                + commit2.name()
                + " corresponds to patch set 2,"
                + " not the current patch set 3",
            FIXED,
            "Inserted as patch set 4"));

    notes = reload(notes);
    PatchSet.Id psId4 = PatchSet.id(notes.getChangeId(), 4);
    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId4);
    assertThat(notes.getChange().isMerged()).isTrue();
    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.id(), ps3.id(), psId4);
    assertThat(psUtil.get(notes, psId4).commitId()).isEqualTo(commit2);
  }

  @Test
  public void expectedMergedCommitIsDanglingPatchSetNewerThanCurrent() throws Exception {
    ChangeNotes notes = insertChange();
    PatchSet ps1 = psUtil.current(notes);

    // Create dangling ref with no patch set.
    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
    RevCommit commit2 = patchSetCommit(psId2);
    serverSideTestRepo.branch(psId2.toRefName()).update(commit2);

    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit2);

    FixInput fix = new FixInput();
    fix.expectMergedAs = commit2.name();
    assertProblems(
        notes,
        fix,
        problem(
            "No patch set found for merged commit " + commit2.name(),
            FIXED,
            "Marked change as merged"),
        problem(
            "Expected merge commit "
                + commit2.name()
                + " corresponds to patch set 2,"
                + " not the current patch set 1",
            FIXED,
            "Inserted as patch set 2"));

    notes = reload(notes);
    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
    assertThat(notes.getChange().isMerged()).isTrue();
    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.id(), psId2);
    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(commit2);
  }

  @Test
  public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
    ChangeNotes notes = insertChange();
    String dest = notes.getChange().getDest().branch();
    RevCommit parent = serverSideTestRepo.branch(dest).commit().message("parent").create();
    RevCommit commit =
        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
    serverSideTestRepo.branch(dest).update(commit);

    String badId = "I0000000000000000000000000000000000000000";
    RevCommit mergedAs =
        serverSideTestRepo
            .commit()
            .parent(parent)
            .message(commit.getShortMessage() + "\n\nChange-Id: " + badId + "\n")
            .create();
    serverSideTestRepo.getRevWalk().parseBody(mergedAs);
    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).containsExactly(badId);
    serverSideTestRepo.update(dest, mergedAs);

    assertNoProblems(notes, null);

    FixInput fix = new FixInput();
    fix.expectMergedAs = mergedAs.name();
    assertProblems(
        notes,
        fix,
        problem(
            "Expected merged commit "
                + mergedAs.name()
                + " has Change-Id: "
                + badId
                + ", but expected "
                + notes.getChange().getKey().get()));
  }

  @Test
  public void expectedMergedCommitMatchesMultiplePatchSets() throws Exception {
    ChangeNotes notes1 = insertChange();
    PatchSet.Id psId1 = psUtil.current(notes1).id();
    String dest = notes1.getChange().getDest().branch();
    RevCommit commit =
        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes1).commitId());
    serverSideTestRepo.branch(dest).update(commit);

    ChangeNotes notes2 = insertChange();
    notes2 = incrementPatchSet(notes2, commit);
    PatchSet.Id psId2 = psUtil.current(notes2).id();

    ChangeNotes notes3 = insertChange();
    notes3 = incrementPatchSet(notes3, commit);
    PatchSet.Id psId3 = psUtil.current(notes3).id();

    FixInput fix = new FixInput();
    fix.expectMergedAs = commit.name();
    assertProblems(
        notes1,
        fix,
        problem(
            "Multiple patch sets for expected merged commit "
                + commit.name()
                + ": ["
                + psId1
                + ", "
                + psId2
                + ", "
                + psId3
                + "]"));
  }

  private BatchUpdate newUpdate(Account.Id owner) {
    return batchUpdateFactory.create(project, userFactory.create(owner), TimeUtil.nowTs());
  }

  private ChangeNotes insertChange() throws Exception {
    return insertChange(admin);
  }

  private ChangeNotes insertChange(TestAccount owner) throws Exception {
    return insertChange(owner, "refs/heads/master");
  }

  private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
    Change.Id id = Change.id(sequences.nextChangeId());
    ChangeInserter ins;
    try (BatchUpdate bu = newUpdate(owner.id())) {
      RevCommit commit = patchSetCommit(PatchSet.id(id, 1));
      bu.setNotify(NotifyResolver.Result.none());
      ins =
          changeInserterFactory
              .create(id, commit, dest)
              .setValidate(false)
              .setFireRevisionCreated(false)
              .setSendMail(false);
      bu.insertChange(ins).execute();
    }
    return changeNotesFactory.create(project, ins.getChange().getId());
  }

  private PatchSet.Id nextPatchSetId(ChangeNotes notes) throws Exception {
    return ChangeUtil.nextPatchSetId(
        serverSideTestRepo.getRepository(), notes.getChange().currentPatchSetId());
  }

  private ChangeNotes incrementPatchSet(ChangeNotes notes) throws Exception {
    return incrementPatchSet(notes, patchSetCommit(nextPatchSetId(notes)));
  }

  private ChangeNotes incrementPatchSet(ChangeNotes notes, RevCommit commit) throws Exception {
    PatchSetInserter ins;
    try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
      bu.setNotify(NotifyResolver.Result.none());
      ins =
          patchSetInserterFactory
              .create(notes, nextPatchSetId(notes), commit)
              .setValidate(false)
              .setFireRevisionCreated(false);
      bu.addOp(notes.getChangeId(), ins).execute();
    }
    return reload(notes);
  }

  private ChangeNotes reload(ChangeNotes notes) throws Exception {
    return changeNotesFactory.create(notes.getChange().getProject(), notes.getChangeId());
  }

  private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
    RevCommit c = serverSideTestRepo.commit().parent(tip).message("Change " + psId).create();
    return serverSideTestRepo.parseBody(c);
  }

  private PatchSet insertMissingPatchSet(ChangeNotes notes, String rev) throws Exception {
    // Don't use BatchUpdate since we're manually updating the meta ref rather
    // than using ChangeUpdate.
    String subject = "Subject for missing commit";
    Change c = new Change(notes.getChange());
    PatchSet.Id psId = nextPatchSetId(notes);
    c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
    PatchSet ps = newPatchSet(psId, rev, adminId);

    addNoteDbCommit(
        c.getId(),
        "Update patch set "
            + psId.get()
            + "\n"
            + "\n"
            + "Patch-set: "
            + psId.get()
            + "\n"
            + "Commit: "
            + rev
            + "\n"
            + "Subject: "
            + subject
            + "\n");
    indexer.index(c.getProject(), c.getId());

    return ps;
  }

  private void deleteRef(String refName) throws Exception {
    RefUpdate ru = serverSideTestRepo.getRepository().updateRef(refName, true);
    ru.setForceUpdate(true);
    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
  }

  private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
    PersonIdent committer = serverIdent.get();
    PersonIdent author =
        noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
    serverSideTestRepo
        .branch(RefNames.changeMetaRef(id))
        .commit()
        .author(author)
        .committer(committer)
        .message(commitMessage)
        .create();
  }

  private ObjectId getDestRef(ChangeNotes notes) throws Exception {
    return serverSideTestRepo
        .getRepository()
        .exactRef(notes.getChange().getDest().branch())
        .getObjectId();
  }

  private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
    ObjectId oldId = getDestRef(notes);
    ObjectId newId = psUtil.current(notes).commitId();
    String dest = notes.getChange().getDest().branch();

    try (BatchUpdate bu = newUpdate(adminId)) {
      bu.addOp(
          notes.getChangeId(),
          new BatchUpdateOp() {
            @Override
            public void updateRepo(RepoContext ctx) throws IOException {
              ctx.addRefUpdate(oldId, newId, dest);
            }

            @Override
            public boolean updateChange(ChangeContext ctx) {
              ctx.getChange().setStatus(Change.Status.MERGED);
              ctx.getUpdate(ctx.getChange().currentPatchSetId())
                  .fixStatusToMerged(new SubmissionId(ctx.getChange()));
              return true;
            }
          });
      bu.execute();
    }
    return reload(notes);
  }

  private static ProblemInfo problem(String message) {
    ProblemInfo p = new ProblemInfo();
    p.message = message;
    return p;
  }

  private static ProblemInfo problem(String message, ProblemInfo.Status status, String outcome) {
    ProblemInfo p = problem(message);
    p.status = requireNonNull(status);
    p.outcome = requireNonNull(outcome);
    return p;
  }

  private void assertProblems(
      ChangeNotes notes, @Nullable FixInput fix, ProblemInfo first, ProblemInfo... rest)
      throws Exception {
    List<ProblemInfo> expected = new ArrayList<>(1 + rest.length);
    expected.add(first);
    expected.addAll(Arrays.asList(rest));
    assertThat(checker.check(notes, fix).problems()).containsExactlyElementsIn(expected).inOrder();
  }

  private void assertNoProblems(ChangeNotes notes, @Nullable FixInput fix) throws Exception {
    assertThat(checker.check(notes, fix).problems()).isEmpty();
  }

  private void deleteUserBranch(Account.Id accountId) throws IOException {
    try (Repository repo = repoManager.openRepository(allUsers)) {
      String refName = RefNames.refsUsers(accountId);
      Ref ref = repo.exactRef(refName);
      if (ref == null) {
        return;
      }

      RefUpdate ru = repo.updateRef(refName);
      ru.setExpectedOldObjectId(ref.getObjectId());
      ru.setNewObjectId(ObjectId.zeroId());
      ru.setForceUpdate(true);
      Result result = ru.delete();
      if (result != Result.FORCED) {
        throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
      }
    }
  }
}
