// Copyright (C) 2013 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 com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.cache.Cache;
import com.google.common.cache.Weigher;
import com.google.common.collect.FluentIterable;
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.exceptions.StorageException;
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.proto.Protos;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
import com.google.gerrit.server.cache.serialize.CacheSerializer;
import com.google.gerrit.server.cache.serialize.EnumCacheSerializer;
import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.InMemoryInserter;
import com.google.gerrit.server.git.MergeUtil;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import com.google.inject.Module;
import com.google.inject.name.Named;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import org.eclipse.jgit.errors.LargeObjectException;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.Config;
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;

public class ChangeKindCacheImpl implements ChangeKindCache {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  private static final String ID_CACHE = "change_kind";

  public static Module module() {
    return new CacheModule() {
      @Override
      protected void configure() {
        bind(ChangeKindCache.class).to(ChangeKindCacheImpl.class);
        persist(ID_CACHE, Key.class, ChangeKind.class)
            .maximumWeight(2 << 20)
            .weigher(ChangeKindWeigher.class)
            .version(1)
            .keySerializer(new Key.Serializer())
            .valueSerializer(new EnumCacheSerializer<>(ChangeKind.class));
      }
    };
  }

  public static class NoCache implements ChangeKindCache {
    private final boolean useRecursiveMerge;
    private final ChangeData.Factory changeDataFactory;
    private final GitRepositoryManager repoManager;

    @Inject
    NoCache(
        @GerritServerConfig Config serverConfig,
        ChangeData.Factory changeDataFactory,
        GitRepositoryManager repoManager) {
      this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig);
      this.changeDataFactory = changeDataFactory;
      this.repoManager = repoManager;
    }

    @Override
    public ChangeKind getChangeKind(
        Project.NameKey project,
        @Nullable RevWalk rw,
        @Nullable Config repoConfig,
        ObjectId prior,
        ObjectId next) {
      try {
        Key key = Key.create(prior, next, useRecursiveMerge);
        return new Loader(key, repoManager, project, rw, repoConfig).call();
      } catch (IOException e) {
        logger.atWarning().withCause(e).log(
            "Cannot check trivial rebase of new patch set %s in %s", next.name(), project);
        return ChangeKind.REWORK;
      }
    }

    @Override
    public ChangeKind getChangeKind(Change change, PatchSet patch) {
      return getChangeKindInternal(this, change, patch, changeDataFactory, repoManager);
    }

    @Override
    public ChangeKind getChangeKind(
        @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) {
      return getChangeKindInternal(this, rw, repoConfig, cd, patch);
    }
  }

  @AutoValue
  public abstract static class Key {
    public static Key create(AnyObjectId prior, AnyObjectId next, String strategyName) {
      return new AutoValue_ChangeKindCacheImpl_Key(prior.copy(), next.copy(), strategyName);
    }

    private static Key create(AnyObjectId prior, AnyObjectId next, boolean useRecursiveMerge) {
      return create(prior, next, MergeUtil.mergeStrategyName(true, useRecursiveMerge));
    }

    public abstract ObjectId prior();

    public abstract ObjectId next();

    public abstract String strategyName();

    @VisibleForTesting
    static class Serializer implements CacheSerializer<Key> {
      @Override
      public byte[] serialize(Key object) {
        ObjectIdConverter idConverter = ObjectIdConverter.create();
        return Protos.toByteArray(
            ChangeKindKeyProto.newBuilder()
                .setPrior(idConverter.toByteString(object.prior()))
                .setNext(idConverter.toByteString(object.next()))
                .setStrategyName(object.strategyName())
                .build());
      }

      @Override
      public Key deserialize(byte[] in) {
        ChangeKindKeyProto proto = Protos.parseUnchecked(ChangeKindKeyProto.parser(), in);
        ObjectIdConverter idConverter = ObjectIdConverter.create();
        return create(
            idConverter.fromByteString(proto.getPrior()),
            idConverter.fromByteString(proto.getNext()),
            proto.getStrategyName());
      }
    }
  }

  private static class Loader implements Callable<ChangeKind> {
    private final Key key;
    private final GitRepositoryManager repoManager;
    private final Project.NameKey projectName;
    private final RevWalk alreadyOpenRw;
    private final Config repoConfig;

    private Loader(
        Key key,
        GitRepositoryManager repoManager,
        Project.NameKey projectName,
        @Nullable RevWalk rw,
        @Nullable Config repoConfig) {
      checkArgument(
          (rw == null && repoConfig == null) || (rw != null && repoConfig != null),
          "must either provide both revwalk/config, or neither; got %s/%s",
          rw,
          repoConfig);
      this.key = key;
      this.repoManager = repoManager;
      this.projectName = projectName;
      this.alreadyOpenRw = rw;
      this.repoConfig = repoConfig;
    }

    @SuppressWarnings("resource") // Resources are manually managed.
    @Override
    public ChangeKind call() throws IOException {
      if (Objects.equals(key.prior(), key.next())) {
        return ChangeKind.NO_CODE_CHANGE;
      }

      RevWalk rw = alreadyOpenRw;
      Config config = repoConfig;
      Repository repo = null;
      if (alreadyOpenRw == null) {
        repo = repoManager.openRepository(projectName);
        rw = new RevWalk(repo);
        config = repo.getConfig();
      }
      try {
        RevCommit prior = rw.parseCommit(key.prior());
        rw.parseBody(prior);
        RevCommit next = rw.parseCommit(key.next());
        rw.parseBody(next);

        if (!next.getFullMessage().equals(prior.getFullMessage())) {
          if (isSameDeltaAndTree(rw, prior, next)) {
            return ChangeKind.NO_CODE_CHANGE;
          }
          return ChangeKind.REWORK;
        }

        if (isSameDeltaAndTree(rw, prior, next)) {
          return ChangeKind.NO_CHANGE;
        }

        if (prior.getParentCount() == 0 || next.getParentCount() == 0) {
          // At this point we have considered all the kinds that could be applicable to root
          // commits; the remainder of the checks in this method all assume that both commits have
          // at least one parent.
          return ChangeKind.REWORK;
        }

        if ((prior.getParentCount() > 1 || next.getParentCount() > 1)
            && !onlyFirstParentChanged(prior, next)) {
          // Trivial rebases done by machine only work well on 1 parent.
          return ChangeKind.REWORK;
        }

        // A trivial rebase can be detected by looking for the next commit
        // having the same tree as would exist when the prior commit is
        // cherry-picked onto the next commit's new first parent.
        try (ObjectInserter ins = new InMemoryInserter(rw.getObjectReader())) {
          ThreeWayMerger merger = MergeUtil.newThreeWayMerger(ins, config, key.strategyName());
          merger.setBase(prior.getParent(0));
          if (merger.merge(next.getParent(0), prior)
              && merger.getResultTreeId().equals(next.getTree())) {
            if (prior.getParentCount() == 1) {
              return ChangeKind.TRIVIAL_REBASE;
            }
            return ChangeKind.MERGE_FIRST_PARENT_UPDATE;
          }
        } catch (LargeObjectException e) {
          // Some object is too large for the merge attempt to succeed. Assume
          // it was a rework.
        }
        return ChangeKind.REWORK;
      } finally {
        if (repo != null) {
          rw.close();
          repo.close();
        }
      }
    }

    public static boolean onlyFirstParentChanged(RevCommit prior, RevCommit next) {
      return !sameFirstParents(prior, next) && sameRestOfParents(prior, next);
    }

    private static boolean sameFirstParents(RevCommit prior, RevCommit next) {
      if (prior.getParentCount() == 0) {
        return next.getParentCount() == 0;
      }
      return prior.getParent(0).equals(next.getParent(0));
    }

    private static boolean sameRestOfParents(RevCommit prior, RevCommit next) {
      Set<RevCommit> priorRestParents = allExceptFirstParent(prior.getParents());
      Set<RevCommit> nextRestParents = allExceptFirstParent(next.getParents());
      return priorRestParents.equals(nextRestParents);
    }

    private static Set<RevCommit> allExceptFirstParent(RevCommit[] parents) {
      return FluentIterable.from(Arrays.asList(parents)).skip(1).toSet();
    }

    private static boolean isSameDeltaAndTree(RevWalk rw, RevCommit prior, RevCommit next)
        throws IOException {
      if (!Objects.equals(next.getTree(), prior.getTree())) {
        return false;
      }

      if (prior.getParentCount() != next.getParentCount()) {
        return false;
      } else if (prior.getParentCount() == 0) {
        return true;
      }

      // Make sure that the prior/next delta is the same - not just the tree.
      // This is done by making sure that the parent trees are equal.
      for (int i = 0; i < prior.getParentCount(); i++) {
        // Parse parent commits so that their trees are available.
        rw.parseCommit(prior.getParent(i));
        rw.parseCommit(next.getParent(i));

        if (!Objects.equals(next.getParent(i).getTree(), prior.getParent(i).getTree())) {
          return false;
        }
      }
      return true;
    }
  }

  public static class ChangeKindWeigher implements Weigher<Key, ChangeKind> {
    @Override
    public int weigh(Key key, ChangeKind changeKind) {
      return 16
          + 2 * 36
          + 2 * key.strategyName().length() // Size of Key, 64 bit JVM
          + 2 * changeKind.name().length(); // Size of ChangeKind, 64 bit JVM
    }
  }

  private final Cache<Key, ChangeKind> cache;
  private final boolean useRecursiveMerge;
  private final ChangeData.Factory changeDataFactory;
  private final GitRepositoryManager repoManager;

  @Inject
  ChangeKindCacheImpl(
      @GerritServerConfig Config serverConfig,
      @Named(ID_CACHE) Cache<Key, ChangeKind> cache,
      ChangeData.Factory changeDataFactory,
      GitRepositoryManager repoManager) {
    this.cache = cache;
    this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig);
    this.changeDataFactory = changeDataFactory;
    this.repoManager = repoManager;
  }

  @Override
  public ChangeKind getChangeKind(
      Project.NameKey project,
      @Nullable RevWalk rw,
      @Nullable Config repoConfig,
      ObjectId prior,
      ObjectId next) {
    try {
      Key key = Key.create(prior, next, useRecursiveMerge);
      ChangeKind kind = cache.get(key, new Loader(key, repoManager, project, rw, repoConfig));
      logger.atFine().log("Change kind of new patch set %s in %s: %s", next.name(), project, kind);
      return kind;
    } catch (ExecutionException e) {
      logger.atWarning().withCause(e).log(
          "Cannot check change kind of new patch set %s in %s", next.name(), project);
      return ChangeKind.REWORK;
    }
  }

  @Override
  public ChangeKind getChangeKind(Change change, PatchSet patch) {
    return getChangeKindInternal(this, change, patch, changeDataFactory, repoManager);
  }

  @Override
  public ChangeKind getChangeKind(
      @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) {
    return getChangeKindInternal(this, rw, repoConfig, cd, patch);
  }

  private static ChangeKind getChangeKindInternal(
      ChangeKindCache cache,
      @Nullable RevWalk rw,
      @Nullable Config repoConfig,
      ChangeData change,
      PatchSet patch) {
    ChangeKind kind = ChangeKind.REWORK;
    // Trivial case: if we're on the first patch, we don't need to use
    // the repository.
    if (patch.id().get() > 1) {
      try {
        Collection<PatchSet> patchSetCollection = change.patchSets();
        PatchSet priorPs = patch;
        for (PatchSet ps : patchSetCollection) {
          if (ps.id().get() < patch.id().get()
              && (ps.id().get() > priorPs.id().get() || priorPs == patch)) {
            // We only want the previous patch set, so walk until the last one
            priorPs = ps;
          }
        }

        // If we still think the previous patch is the current patch,
        // we only have one patch set.  Return the default.
        // This can happen if a user creates a draft, uploads a second patch,
        // and deletes the draft.
        if (priorPs != patch) {
          kind =
              cache.getChangeKind(
                  change.project(), rw, repoConfig, priorPs.commitId(), patch.commitId());
        }
      } catch (StorageException e) {
        // Do nothing; assume we have a complex change
        logger.atWarning().withCause(e).log(
            "Unable to get change kind for patchSet %s of change %s",
            patch.number(), change.getId());
      }
    }
    logger.atFine().log(
        "Change kind for patchSet %s of change %s: %s", patch.number(), change.getId(), kind);
    return kind;
  }

  private static ChangeKind getChangeKindInternal(
      ChangeKindCache cache,
      Change change,
      PatchSet patch,
      ChangeData.Factory changeDataFactory,
      GitRepositoryManager repoManager) {
    // TODO - dborowitz: add NEW_CHANGE type for default.
    ChangeKind kind = ChangeKind.REWORK;
    // Trivial case: if we're on the first patch, we don't need to open
    // the repository.
    if (patch.id().get() > 1) {
      try (Repository repo = repoManager.openRepository(change.getProject());
          RevWalk rw = new RevWalk(repo)) {
        kind =
            getChangeKindInternal(
                cache, rw, repo.getConfig(), changeDataFactory.create(change), patch);
      } catch (IOException e) {
        // Do nothing; assume we have a complex change
        logger.atWarning().withCause(e).log(
            "Unable to get change kind for patchSet %s of change %s",
            patch.number(), change.getChangeId());
      }
    }
    logger.atFine().log(
        "Change kind for patchSet %s of change %s: %s", patch.number(), change.getChangeId(), kind);
    return kind;
  }
}
