// Copyright (C) 2019 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.git;

import static com.google.gerrit.server.project.ProjectCache.illegalState;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.cache.proto.Cache;
import com.google.gerrit.server.cache.proto.Cache.PureRevertKeyProto;
import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.project.ProjectCache;
import com.google.inject.Inject;
import com.google.inject.Module;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import com.google.protobuf.ByteString;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutionException;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.errors.InvalidObjectIdException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.ThreeWayMerger;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.util.io.DisabledOutputStream;

/** Computes and caches if a change is a pure revert of another change. */
@Singleton
public class PureRevertCache {
  private static final String ID_CACHE = "pure_revert";

  public static Module module() {
    return new CacheModule() {
      @Override
      protected void configure() {
        persist(ID_CACHE, Cache.PureRevertKeyProto.class, Boolean.class)
            .maximumWeight(100)
            .loader(Loader.class)
            .version(1)
            .keySerializer(new ProtobufSerializer<>(Cache.PureRevertKeyProto.parser()))
            .valueSerializer(BooleanCacheSerializer.INSTANCE);
      }
    };
  }

  private final LoadingCache<PureRevertKeyProto, Boolean> cache;
  private final ChangeNotes.Factory notesFactory;

  @Inject
  PureRevertCache(
      @Named(ID_CACHE) LoadingCache<PureRevertKeyProto, Boolean> cache,
      ChangeNotes.Factory notesFactory) {
    this.cache = cache;
    this.notesFactory = notesFactory;
  }

  /**
   * Returns {@code true} if {@code claimedRevert} is a pure (clean) revert of the change that is
   * referenced in {@link Change#getRevertOf()}.
   *
   * @return {@code true} if {@code claimedRevert} is a pure (clean) revert.
   * @throws IOException if there was a problem with the storage layer
   * @throws BadRequestException if there is a problem with the provided {@link ChangeNotes}
   */
  public boolean isPureRevert(ChangeNotes claimedRevert) throws IOException, BadRequestException {
    if (claimedRevert.getChange().getRevertOf() == null) {
      throw new BadRequestException("revertOf not set");
    }
    ChangeNotes claimedOriginal =
        notesFactory.createChecked(
            claimedRevert.getProjectName(), claimedRevert.getChange().getRevertOf());
    return isPureRevert(
        claimedRevert.getProjectName(),
        claimedRevert.getCurrentPatchSet().commitId(),
        claimedOriginal.getCurrentPatchSet().commitId());
  }

  /**
   * Returns {@code true} if {@code claimedRevert} is a pure (clean) revert of {@code
   * claimedOriginal}.
   *
   * @return {@code true} if {@code claimedRevert} is a pure (clean) revert of {@code
   *     claimedOriginal}.
   * @throws IOException if there was a problem with the storage layer
   * @throws BadRequestException if there is a problem with the provided {@link ObjectId}s
   */
  public boolean isPureRevert(
      Project.NameKey project, ObjectId claimedRevert, ObjectId claimedOriginal)
      throws IOException, BadRequestException {
    try {
      return cache.get(key(project, claimedRevert, claimedOriginal));
    } catch (ExecutionException e) {
      Throwables.throwIfInstanceOf(e.getCause(), BadRequestException.class);
      throw new IOException(e);
    }
  }

  @VisibleForTesting
  static PureRevertKeyProto key(
      Project.NameKey project, ObjectId claimedRevert, ObjectId claimedOriginal) {
    ByteString original = ObjectIdConverter.create().toByteString(claimedOriginal);
    ByteString revert = ObjectIdConverter.create().toByteString(claimedRevert);
    return PureRevertKeyProto.newBuilder()
        .setProject(project.get())
        .setClaimedOriginal(original)
        .setClaimedRevert(revert)
        .build();
  }

  static class Loader extends CacheLoader<PureRevertKeyProto, Boolean> {
    private final GitRepositoryManager repoManager;
    private final MergeUtilFactory mergeUtilFactory;
    private final ProjectCache projectCache;

    @Inject
    Loader(
        GitRepositoryManager repoManager,
        MergeUtilFactory mergeUtilFactory,
        ProjectCache projectCache) {
      this.repoManager = repoManager;
      this.mergeUtilFactory = mergeUtilFactory;
      this.projectCache = projectCache;
    }

    @Override
    public Boolean load(PureRevertKeyProto key) throws BadRequestException, IOException {
      try (TraceContext.TraceTimer ignored =
          TraceContext.newTimer(
              "Loading pure revert",
              Metadata.builder().cacheKey(key.toString()).projectName(key.getProject()).build())) {
        ObjectId original = ObjectIdConverter.create().fromByteString(key.getClaimedOriginal());
        ObjectId revert = ObjectIdConverter.create().fromByteString(key.getClaimedRevert());
        Project.NameKey project = Project.nameKey(key.getProject());

        try (Repository repo = repoManager.openRepository(project);
            ObjectInserter oi = repo.newObjectInserter();
            RevWalk rw = new RevWalk(repo)) {
          RevCommit claimedOriginalCommit;
          try {
            claimedOriginalCommit = rw.parseCommit(original);
          } catch (InvalidObjectIdException | MissingObjectException e) {
            throw new BadRequestException("invalid object ID", e);
          }
          if (claimedOriginalCommit.getParentCount() == 0) {
            throw new BadRequestException("can't check against initial commit");
          }
          RevCommit claimedRevertCommit = rw.parseCommit(revert);
          if (claimedRevertCommit.getParentCount() == 0) {
            return false;
          }
          // Rebase claimed revert onto claimed original
          ThreeWayMerger merger =
              mergeUtilFactory
                  .create(projectCache.get(project).orElseThrow(illegalState(project)))
                  .newThreeWayMerger(oi, repo.getConfig());
          merger.setBase(claimedRevertCommit.getParent(0));
          boolean success = merger.merge(claimedRevertCommit, claimedOriginalCommit);
          if (!success || merger.getResultTreeId() == null) {
            // Merge conflict during rebase
            return false;
          }

          // Any differences between claimed original's parent and the rebase result indicate that
          // the claimedRevert is not a pure revert but made content changes
          try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
            df.setReader(oi.newReader(), repo.getConfig());
            List<DiffEntry> entries =
                df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
            return entries.isEmpty();
          }
        }
      }
    }
  }
}
