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

package com.google.gerrit.server.notedb;

import static com.google.common.collect.ImmutableList.toImmutableList;

import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Change;
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.ReceiveCommand;

/**
 * This class can be used to clean zombie draft comments refs. More context in <a
 * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
 * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
 *
 * <p>An earlier bug in the deletion of draft comments {@code
 * refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain in Git
 * and not get deleted. These refs point to an empty tree.
 */
public class DeleteZombieCommentsRefs {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
  private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";

  // Number of refs deleted at once in a batch ref-update.
  // Log progress after deleting every CHUNK_SIZE refs
  private static final int CHUNK_SIZE = 3000;

  private final GitRepositoryManager repoManager;
  private final AllUsersName allUsers;
  private final int cleanupPercentage;
  private Repository allUsersRepo;
  private final Consumer<String> uiConsumer;

  public interface Factory {
    DeleteZombieCommentsRefs create(int cleanupPercentage);
  }

  @Inject
  public DeleteZombieCommentsRefs(
      AllUsersName allUsers,
      GitRepositoryManager repoManager,
      @Assisted Integer cleanupPercentage) {
    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
  }

  public DeleteZombieCommentsRefs(
      AllUsersName allUsers,
      GitRepositoryManager repoManager,
      Integer cleanupPercentage,
      Consumer<String> uiConsumer) {
    this.allUsers = allUsers;
    this.repoManager = repoManager;
    this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
    this.uiConsumer = uiConsumer;
  }

  public void execute() throws IOException {
    allUsersRepo = repoManager.openRepository(allUsers);

    List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
    List<Ref> zombieRefs = filterZombieRefs(draftRefs);

    logInfo(
        String.format(
            "Found a total of %d zombie draft refs in %s repo.",
            zombieRefs.size(), allUsers.get()));

    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
    zombieRefs =
        zombieRefs.stream()
            .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
            .collect(toImmutableList());
    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));

    long zombieRefsCnt = zombieRefs.size();
    long deletedRefsCnt = 0;
    long startTime = System.currentTimeMillis();

    for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
      deleteBatchZombieRefs(refsBatch);
      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
      deletedRefsCnt += refsBatch.size();
      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
    }
  }

  private void deleteBatchZombieRefs(List<Ref> refsBatch) throws IOException {
    List<ReceiveCommand> deleteCommands =
        refsBatch.stream()
            .map(
                zombieRef ->
                    new ReceiveCommand(
                        zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
            .collect(toImmutableList());
    BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
    bru.setAtomic(true);
    bru.addCommand(deleteCommands);
    RefUpdateUtil.executeChecked(bru, allUsersRepo);
  }

  private List<Ref> filterZombieRefs(List<Ref> allDraftRefs) throws IOException {
    List<Ref> zombieRefs = new ArrayList<>((int) (allDraftRefs.size() * 0.5));
    for (Ref ref : allDraftRefs) {
      if (isZombieRef(ref)) {
        zombieRefs.add(ref);
      }
    }
    return zombieRefs;
  }

  private boolean isZombieRef(Ref ref) throws IOException {
    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
  }

  private void logInfo(String message) {
    logger.atInfo().log(message);
    uiConsumer.accept(message);
  }

  private void logProgress(long deletedRefsCount, long allRefsCount, long elapsed) {
    logInfo(
        String.format(
            "Deleted %d/%d zombie draft refs (%d seconds)",
            deletedRefsCount, allRefsCount, elapsed));
  }
}
