// 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.server.change;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;

import com.google.auto.value.AutoValue;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SetMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmissionId;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.common.ProblemInfo;
import com.google.gerrit.extensions.common.ProblemInfo.Status;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.Accounts;
import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.PatchSetState;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
import com.google.gerrit.server.plugincontext.PluginItemContext;
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.update.RetryHelper;
import com.google.gerrit.server.update.UpdateException;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;

/**
 * Checks changes for various kinds of inconsistency and corruption.
 *
 * <p>A single instance may be reused for checking multiple changes, but not concurrently.
 */
public class ConsistencyChecker {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  @AutoValue
  public abstract static class Result {
    private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
      return new AutoValue_ConsistencyChecker_Result(
          notes.getChangeId(), notes.getChange(), problems);
    }

    public abstract Change.Id id();

    @Nullable
    public abstract Change change();

    public abstract List<ProblemInfo> problems();
  }

  private final ChangeNotes.Factory notesFactory;
  private final Accounts accounts;
  private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
  private final GitRepositoryManager repoManager;
  private final PatchSetInfoFactory patchSetInfoFactory;
  private final PatchSetInserter.Factory patchSetInserterFactory;
  private final PatchSetUtil psUtil;
  private final Provider<CurrentUser> user;
  private final Provider<PersonIdent> serverIdent;
  private final RetryHelper retryHelper;
  private final DynamicItem<UrlFormatter> urlFormatter;

  private BatchUpdate.Factory updateFactory;
  private FixInput fix;
  private ChangeNotes notes;
  private Repository repo;
  private RevWalk rw;
  private ObjectInserter oi;

  private RevCommit tip;
  private SetMultimap<ObjectId, PatchSet> patchSetsBySha;
  private PatchSet currPs;
  private RevCommit currPsCommit;

  private List<ProblemInfo> problems;

  @Inject
  ConsistencyChecker(
      @GerritPersonIdent Provider<PersonIdent> serverIdent,
      ChangeNotes.Factory notesFactory,
      Accounts accounts,
      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
      GitRepositoryManager repoManager,
      PatchSetInfoFactory patchSetInfoFactory,
      PatchSetInserter.Factory patchSetInserterFactory,
      PatchSetUtil psUtil,
      Provider<CurrentUser> user,
      RetryHelper retryHelper,
      DynamicItem<UrlFormatter> urlFormatter) {
    this.accounts = accounts;
    this.accountPatchReviewStore = accountPatchReviewStore;
    this.notesFactory = notesFactory;
    this.patchSetInfoFactory = patchSetInfoFactory;
    this.patchSetInserterFactory = patchSetInserterFactory;
    this.psUtil = psUtil;
    this.repoManager = repoManager;
    this.retryHelper = retryHelper;
    this.serverIdent = serverIdent;
    this.user = user;
    this.urlFormatter = urlFormatter;
    reset();
  }

  private void reset() {
    updateFactory = null;
    notes = null;
    repo = null;
    rw = null;
    problems = new ArrayList<>();
  }

  private Change change() {
    return notes.getChange();
  }

  public Result check(ChangeNotes notes, @Nullable FixInput f) {
    requireNonNull(notes);
    try {
      return retryHelper
          .changeUpdate(
              "checkChangeConsistency",
              buf -> {
                try {
                  reset();
                  this.updateFactory = buf;
                  this.notes = notes;
                  fix = f;
                  checkImpl();
                  return result();
                } finally {
                  if (rw != null) {
                    rw.getObjectReader().close();
                    rw.close();
                    oi.close();
                  }
                  if (repo != null) {
                    repo.close();
                  }
                }
              })
          .call();
    } catch (RestApiException e) {
      return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage());
    } catch (UpdateException e) {
      return logAndReturnOneProblem(e, notes, "Error checking change");
    }
  }

  private Result logAndReturnOneProblem(Exception e, ChangeNotes notes, String problem) {
    logger.atWarning().withCause(e).log("Error checking change %s", notes.getChangeId());
    return Result.create(notes, ImmutableList.of(problem(problem)));
  }

  private void checkImpl() {
    checkOwner();
    checkCurrentPatchSetEntity();

    // All checks that require the repo.
    if (!openRepo()) {
      return;
    }
    if (!checkPatchSets()) {
      return;
    }
    checkMerged();
  }

  private void checkOwner() {
    try {
      if (!accounts.get(change().getOwner()).isPresent()) {
        problem("Missing change owner: " + change().getOwner());
      }
    } catch (IOException | ConfigInvalidException e) {
      ProblemInfo problem = problem("Failed to look up owner");
      logger.atWarning().withCause(e).log(
          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
    }
  }

  private void checkCurrentPatchSetEntity() {
    try {
      currPs = psUtil.current(notes);
      if (currPs == null) {
        problem(
            String.format("Current patch set %d not found", change().currentPatchSetId().get()));
      }
    } catch (StorageException e) {
      ProblemInfo problem = problem("Failed to look up current patch set");
      logger.atWarning().withCause(e).log(
          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
    }
  }

  private boolean openRepo() {
    Project.NameKey project = change().getDest().project();
    try {
      repo = repoManager.openRepository(project);
      oi = repo.newObjectInserter();
      rw = new RevWalk(oi.newReader());
      return true;
    } catch (RepositoryNotFoundException e) {
      ProblemInfo problem = problem("Destination repository not found: " + project);
      logger.atWarning().withCause(e).log(
          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
      return false;
    } catch (IOException e) {
      ProblemInfo problem = problem("Failed to open repository: " + project);
      logger.atWarning().withCause(e).log(
          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
      return false;
    }
  }

  private boolean checkPatchSets() {
    List<PatchSet> all;
    try {
      // Iterate in descending order.
      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(notes));
    } catch (StorageException e) {
      ProblemInfo problem = problem("Failed to look up patch sets");
      logger.atWarning().withCause(e).log(
          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
      return false;
    }
    patchSetsBySha = MultimapBuilder.hashKeys(all.size()).treeSetValues(PS_ID_ORDER).build();

    Map<String, Ref> refs;
    try {
      refs =
          repo.getRefDatabase()
              .exactRef(all.stream().map(ps -> ps.id().toRefName()).toArray(String[]::new));
    } catch (IOException e) {
      ProblemInfo problem = problem("Error reading refs");
      logger.atWarning().withCause(e).log(
          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
      refs = Collections.emptyMap();
    }

    List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
    for (PatchSet ps : all) {
      // Check revision format.
      int psNum = ps.id().get();
      String refName = ps.id().toRefName();
      ObjectId objId = ps.commitId();
      patchSetsBySha.put(objId, ps);

      // Check ref existence.
      ProblemInfo refProblem = null;
      Ref ref = refs.get(refName);
      if (ref == null) {
        refProblem = problem("Ref missing: " + refName);
      } else if (!objId.equals(ref.getObjectId())) {
        String actual = ref.getObjectId() != null ? ref.getObjectId().name() : "null";
        refProblem =
            problem(
                String.format(
                    "Expected %s to point to %s, found %s", ref.getName(), objId.name(), actual));
      }

      // Check object existence.
      RevCommit psCommit = parseCommit(objId, String.format("patch set %d", psNum));
      if (psCommit == null) {
        if (fix != null && fix.deletePatchSetIfCommitMissing) {
          deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.id()));
        }
        continue;
      } else if (refProblem != null && fix != null) {
        fixPatchSetRef(refProblem, ps);
      }
      if (ps.id().equals(change().currentPatchSetId())) {
        currPsCommit = psCommit;
      }
    }

    // Delete any bad patch sets found above, in a single update.
    deletePatchSets(deletePatchSetOps);

    // Check for duplicates.
    for (Map.Entry<ObjectId, Collection<PatchSet>> e : patchSetsBySha.asMap().entrySet()) {
      if (e.getValue().size() > 1) {
        problem(
            String.format(
                "Multiple patch sets pointing to %s: %s",
                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::number)));
      }
    }

    return currPs != null && currPsCommit != null;
  }

  private void checkMerged() {
    String refName = change().getDest().branch();
    Ref dest;
    try {
      dest = repo.getRefDatabase().exactRef(refName);
    } catch (IOException e) {
      problem("Failed to look up destination ref: " + refName);
      return;
    }
    if (dest == null) {
      problem("Destination ref not found (may be new branch): " + refName);
      return;
    }
    tip = parseCommit(dest.getObjectId(), "destination ref " + refName);
    if (tip == null) {
      return;
    }

    if (fix != null && fix.expectMergedAs != null) {
      checkExpectMergedAs();
    } else {
      boolean merged;
      try {
        merged = rw.isMergedInto(currPsCommit, tip);
      } catch (IOException e) {
        problem("Error checking whether patch set " + currPs.id().get() + " is merged");
        return;
      }
      checkMergedBitMatchesStatus(currPs.id(), currPsCommit, merged);
    }
  }

  private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
    String refName = change().getDest().branch();
    return problem(
        formatProblemMessage(
            "Patch set %d (%s) is merged into destination ref %s (%s), but change"
                + " status is %s",
            psId.get(), commit.name(), refName, tip.name()));
  }

  private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) {
    String refName = change().getDest().branch();
    if (merged && !change().isMerged()) {
      ProblemInfo p = wrongChangeStatus(psId, commit);
      if (fix != null) {
        fixMerged(p);
      }
    } else if (!merged && change().isMerged()) {
      problem(
          formatProblemMessage(
              "Patch set %d (%s) is not merged into"
                  + " destination ref %s (%s), but change status is %s",
              currPs.id().get(), commit.name(), refName, tip.name()));
    }
  }

  private String formatProblemMessage(
      String message, int psId, String commitName, String refName, String tipName) {
    return String.format(
        message,
        psId,
        commitName,
        refName,
        tipName,
        ChangeUtil.status(change()).toUpperCase(Locale.US));
  }

  private void checkExpectMergedAs() {
    if (!ObjectId.isId(fix.expectMergedAs)) {
      problem("Invalid revision on expected merged commit: " + fix.expectMergedAs);
      return;
    }
    ObjectId objId = ObjectId.fromString(fix.expectMergedAs);
    RevCommit commit = parseCommit(objId, "expected merged commit");
    if (commit == null) {
      return;
    }

    try {
      if (!rw.isMergedInto(commit, tip)) {
        problem(
            String.format(
                "Expected merged commit %s is not merged into destination ref %s (%s)",
                commit.name(), change().getDest().branch(), tip.name()));
        return;
      }

      List<PatchSet.Id> thisCommitPsIds = new ArrayList<>();
      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(REFS_CHANGES)) {
        if (!ref.getObjectId().equals(commit)) {
          continue;
        }
        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
        if (psId == null) {
          continue;
        }
        try {
          Change c = notesFactory.createChecked(change().getProject(), psId.changeId()).getChange();
          if (!c.getDest().equals(change().getDest())) {
            continue;
          }
        } catch (StorageException e) {
          logger.atWarning().withCause(e).log(
              "Error in consistency check of change %s", notes.getChangeId());
          // Include this patch set; should cause an error below, which is good.
        }
        thisCommitPsIds.add(psId);
      }
      switch (thisCommitPsIds.size()) {
        case 0:
          // No patch set for this commit; insert one.
          rw.parseBody(commit);
          String changeId =
              Iterables.getFirst(
                  ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get()), null);
          // Missing Change-Id footer is ok, but mismatched is not.
          if (changeId != null && !changeId.equals(change().getKey().get())) {
            problem(
                String.format(
                    "Expected merged commit %s has Change-Id: %s, but expected %s",
                    commit.name(), changeId, change().getKey().get()));
            return;
          }
          insertMergedPatchSet(commit, null, false);
          break;

        case 1:
          // Existing patch set ref pointing to this commit.
          PatchSet.Id id = thisCommitPsIds.get(0);
          if (id.equals(change().currentPatchSetId())) {
            // If it's the current patch set, we can just fix the status.
            fixMerged(wrongChangeStatus(id, commit));
          } else if (id.get() > change().currentPatchSetId().get()) {
            // If it's newer than the current patch set, reuse this patch set
            // ID when inserting a new merged patch set.
            insertMergedPatchSet(commit, id, true);
          } else {
            // If it's older than the current patch set, just delete the old
            // ref, and use a new ID when inserting a new merged patch set.
            insertMergedPatchSet(commit, id, false);
          }
          break;

        default:
          problem(
              String.format(
                  "Multiple patch sets for expected merged commit %s: %s",
                  commit.name(),
                  thisCommitPsIds.stream()
                      .sorted(comparing(PatchSet.Id::get))
                      .collect(toImmutableList())));
          break;
      }
    } catch (IOException e) {
      ProblemInfo problem =
          problem("Error looking up expected merged commit " + fix.expectMergedAs);
      logger.atWarning().withCause(e).log(
          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
    }
  }

  private void insertMergedPatchSet(
      final RevCommit commit, @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
    ProblemInfo notFound = problem("No patch set found for merged commit " + commit.name());
    if (!user.get().isIdentifiedUser()) {
      notFound.status = Status.FIX_FAILED;
      notFound.outcome = "Must be called by an identified user to insert new patch set";
      return;
    }
    ProblemInfo insertPatchSetProblem;
    ProblemInfo deleteOldPatchSetProblem;

    if (psIdToDelete == null) {
      insertPatchSetProblem =
          problem(
              String.format(
                  "Expected merged commit %s has no associated patch set", commit.name()));
      deleteOldPatchSetProblem = null;
    } else {
      String msg =
          String.format(
              "Expected merge commit %s corresponds to patch set %s,"
                  + " not the current patch set %s",
              commit.name(), psIdToDelete.get(), change().currentPatchSetId().get());
      // Maybe an identical problem, but different fix.
      deleteOldPatchSetProblem = reuseOldPsId ? null : problem(msg);
      insertPatchSetProblem = problem(msg);
    }

    List<ProblemInfo> currProblems = new ArrayList<>(3);
    currProblems.add(notFound);
    if (deleteOldPatchSetProblem != null) {
      currProblems.add(deleteOldPatchSetProblem);
    }
    currProblems.add(insertPatchSetProblem);

    try {
      PatchSet.Id psId =
          (psIdToDelete != null && reuseOldPsId)
              ? psIdToDelete
              : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
      PatchSetInserter inserter = patchSetInserterFactory.create(notes, psId, commit);
      try (BatchUpdate bu = newBatchUpdate()) {
        bu.setRepository(repo, rw, oi);

        if (psIdToDelete != null) {
          // Delete the given patch set ref. If reuseOldPsId is true,
          // PatchSetInserter will reinsert the same ref, making it a no-op.
          bu.addOp(
              notes.getChangeId(),
              new BatchUpdateOp() {
                @Override
                public void updateRepo(RepoContext ctx) throws IOException {
                  ctx.addRefUpdate(commit, ObjectId.zeroId(), psIdToDelete.toRefName());
                }
              });
          if (!reuseOldPsId) {
            bu.addOp(
                notes.getChangeId(),
                new DeletePatchSetFromDbOp(requireNonNull(deleteOldPatchSetProblem), psIdToDelete));
          }
        }

        bu.setNotify(NotifyResolver.Result.none());
        bu.addOp(
            notes.getChangeId(),
            inserter
                .setValidate(false)
                .setFireRevisionCreated(false)
                .setAllowClosed(true)
                .setMessage("Patch set for merged commit inserted by consistency checker"));
        bu.addOp(notes.getChangeId(), new FixMergedOp(notFound));
        bu.execute();
      }
      notes = notesFactory.createChecked(inserter.getChange());
      insertPatchSetProblem.status = Status.FIXED;
      insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
    } catch (StorageException | IOException | UpdateException | RestApiException e) {
      logger.atWarning().withCause(e).log(
          "Error in consistency check of change %s", notes.getChangeId());
      for (ProblemInfo pi : currProblems) {
        pi.status = Status.FIX_FAILED;
        pi.outcome = "Error inserting merged patch set";
      }
      return;
    }
  }

  private static class FixMergedOp implements BatchUpdateOp {
    private final ProblemInfo p;

    private FixMergedOp(ProblemInfo p) {
      this.p = p;
    }

    @Override
    public boolean updateChange(ChangeContext ctx) {
      ctx.getChange().setStatus(Change.Status.MERGED);
      ctx.getUpdate(ctx.getChange().currentPatchSetId())
          .fixStatusToMerged(new SubmissionId(ctx.getChange()));
      p.status = Status.FIXED;
      p.outcome = "Marked change as merged";
      return true;
    }
  }

  private void fixMerged(ProblemInfo p) {
    try (BatchUpdate bu = newBatchUpdate()) {
      bu.setRepository(repo, rw, oi);
      bu.addOp(notes.getChangeId(), new FixMergedOp(p));
      bu.execute();
    } catch (UpdateException | RestApiException e) {
      logger.atWarning().withCause(e).log("Error marking %s as merged", notes.getChangeId());
      p.status = Status.FIX_FAILED;
      p.outcome = "Error updating status to merged";
    }
  }

  private BatchUpdate newBatchUpdate() {
    return updateFactory.create(change().getProject(), user.get(), TimeUtil.nowTs());
  }

  private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
    try {
      RefUpdate ru = repo.updateRef(ps.id().toRefName());
      ru.setForceUpdate(true);
      ru.setNewObjectId(ps.commitId());
      ru.setRefLogIdent(newRefLogIdent());
      ru.setRefLogMessage("Repair patch set ref", true);
      RefUpdate.Result result = ru.update();
      switch (result) {
        case NEW:
        case FORCED:
        case FAST_FORWARD:
        case NO_CHANGE:
          p.status = Status.FIXED;
          p.outcome = "Repaired patch set ref";
          return;
        case IO_FAILURE:
        case LOCK_FAILURE:
        case NOT_ATTEMPTED:
        case REJECTED:
        case REJECTED_CURRENT_BRANCH:
        case RENAMED:
        case REJECTED_MISSING_OBJECT:
        case REJECTED_OTHER_REASON:
        default:
          p.status = Status.FIX_FAILED;
          p.outcome = "Failed to update patch set ref: " + result;
          return;
      }
    } catch (IOException e) {
      String msg = "Error fixing patch set ref";
      logger.atWarning().withCause(e).log("%s %s", msg, ps.id().toRefName());
      p.status = Status.FIX_FAILED;
      p.outcome = msg;
    }
  }

  private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
    try (BatchUpdate bu = newBatchUpdate()) {
      bu.setRepository(repo, rw, oi);
      for (DeletePatchSetFromDbOp op : ops) {
        checkArgument(op.psId.changeId().equals(notes.getChangeId()));
        bu.addOp(notes.getChangeId(), op);
      }
      bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops));
      bu.execute();
    } catch (NoPatchSetsWouldRemainException e) {
      for (DeletePatchSetFromDbOp op : ops) {
        op.p.status = Status.FIX_FAILED;
        op.p.outcome = e.getMessage();
      }
    } catch (UpdateException | RestApiException e) {
      String msg = "Error deleting patch set";
      logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.changeId());
      for (DeletePatchSetFromDbOp op : ops) {
        // Overwrite existing statuses that were set before the transaction was
        // rolled back.
        op.p.status = Status.FIX_FAILED;
        op.p.outcome = msg;
      }
    }
  }

  private class DeletePatchSetFromDbOp implements BatchUpdateOp {
    private final ProblemInfo p;
    private final PatchSet.Id psId;

    private DeletePatchSetFromDbOp(ProblemInfo p, PatchSet.Id psId) {
      this.p = p;
      this.psId = psId;
    }

    @Override
    public boolean updateChange(ChangeContext ctx) throws PatchSetInfoNotAvailableException {
      // Delete dangling key references.
      accountPatchReviewStore.run(s -> s.clearReviewed(psId));

      // For NoteDb setting the state to deleted is sufficient to filter everything out.
      ctx.getUpdate(psId).setPatchSetState(PatchSetState.DELETED);

      p.status = Status.FIXED;
      p.outcome = "Deleted patch set";
      return true;
    }
  }

  private static class NoPatchSetsWouldRemainException extends RestApiException {
    private static final long serialVersionUID = 1L;

    private NoPatchSetsWouldRemainException() {
      super("Cannot delete patch set; no patch sets would remain");
    }
  }

  private class UpdateCurrentPatchSetOp implements BatchUpdateOp {
    private final Set<PatchSet.Id> toDelete;

    private UpdateCurrentPatchSetOp(List<DeletePatchSetFromDbOp> deleteOps) {
      toDelete = new HashSet<>();
      for (DeletePatchSetFromDbOp op : deleteOps) {
        toDelete.add(op.psId);
      }
    }

    @Override
    public boolean updateChange(ChangeContext ctx)
        throws PatchSetInfoNotAvailableException, NoPatchSetsWouldRemainException {
      if (!toDelete.contains(ctx.getChange().currentPatchSetId())) {
        return false;
      }
      TreeSet<PatchSet.Id> all = new TreeSet<>(comparing(PatchSet.Id::get));
      // Doesn't make any assumptions about the order in which deletes happen
      // and whether they are seen by this op; we are already given the full set
      // of patch sets that will eventually be deleted in this update.
      for (PatchSet ps : psUtil.byChange(ctx.getNotes())) {
        if (!toDelete.contains(ps.id())) {
          all.add(ps.id());
        }
      }
      if (all.isEmpty()) {
        throw new NoPatchSetsWouldRemainException();
      }
      ctx.getChange().setCurrentPatchSet(patchSetInfoFactory.get(ctx.getNotes(), all.last()));
      return true;
    }
  }

  private PersonIdent newRefLogIdent() {
    CurrentUser u = user.get();
    if (u.isIdentifiedUser()) {
      return u.asIdentifiedUser().newRefLogIdent();
    }
    return serverIdent.get();
  }

  private RevCommit parseCommit(ObjectId objId, String desc) {
    try {
      return rw.parseCommit(objId);
    } catch (MissingObjectException e) {
      problem(String.format("Object missing: %s: %s", desc, objId.name()));
    } catch (IncorrectObjectTypeException e) {
      problem(String.format("Not a commit: %s: %s", desc, objId.name()));
    } catch (IOException e) {
      problem(String.format("Failed to look up: %s: %s", desc, objId.name()));
    }
    return null;
  }

  private ProblemInfo problem(String msg) {
    ProblemInfo p = new ProblemInfo();
    p.message = requireNonNull(msg);
    problems.add(p);
    return p;
  }

  private ProblemInfo lastProblem() {
    return problems.get(problems.size() - 1);
  }

  private Result result() {
    return Result.create(notes, problems);
  }
}
