// 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.patch.gitfilediff;

import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.server.patch.DiffUtil.stringSize;

import com.google.auto.value.AutoValue;
import com.google.common.base.Converter;
import com.google.common.base.Enums;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.Patch.ChangeType;
import com.google.gerrit.entities.Patch.PatchType;
import com.google.gerrit.proto.Protos;
import com.google.gerrit.server.cache.proto.Cache.GitFileDiffProto;
import com.google.gerrit.server.cache.serialize.CacheSerializer;
import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
import com.google.gerrit.server.patch.filediff.Edit;
import com.google.protobuf.Descriptors.FieldDescriptor;
import java.util.Map;
import java.util.Optional;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.lib.AbbreviatedObjectId;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.patch.FileHeader;

/**
 * Entity representing a modified file (added, deleted, modified, renamed, etc...) between two
 * different git commits.
 */
@AutoValue
public abstract class GitFileDiff {
  private static final Map<FileMode, Patch.FileMode> fileModeMap =
      ImmutableMap.<FileMode, Patch.FileMode>builder()
          .put(FileMode.TREE, Patch.FileMode.TREE)
          .put(FileMode.SYMLINK, Patch.FileMode.SYMLINK)
          .put(FileMode.GITLINK, Patch.FileMode.GITLINK)
          .put(FileMode.REGULAR_FILE, Patch.FileMode.REGULAR_FILE)
          .put(FileMode.EXECUTABLE_FILE, Patch.FileMode.EXECUTABLE_FILE)
          .put(FileMode.MISSING, Patch.FileMode.MISSING)
          .build();

  private static Patch.FileMode mapFileMode(FileMode jgitFileMode) {
    if (!fileModeMap.containsKey(jgitFileMode)) {
      throw new IllegalArgumentException("Unsupported type " + jgitFileMode);
    }
    return fileModeMap.get(jgitFileMode);
  }

  /**
   * Creates a {@link GitFileDiff} using the {@code diffEntry} and the {@code diffFormatter}
   * parameters.
   */
  static GitFileDiff create(DiffEntry diffEntry, FileHeader fileHeader) {
    ImmutableList<Edit> edits =
        fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());

    return builder()
        .edits(edits)
        .oldId(diffEntry.getOldId())
        .newId(diffEntry.getNewId())
        .fileHeader(FileHeaderUtil.toString(fileHeader))
        .oldPath(FileHeaderUtil.getOldPath(fileHeader))
        .newPath(FileHeaderUtil.getNewPath(fileHeader))
        .changeType(FileHeaderUtil.getChangeType(fileHeader))
        .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
        .oldMode(Optional.of(mapFileMode(diffEntry.getOldMode())))
        .newMode(Optional.of(mapFileMode(diffEntry.getNewMode())))
        .build();
  }

  /**
   * Represents an empty file diff, which means that the file was not modified between the two git
   * trees identified by {@link #oldId()} and {@link #newId()}.
   *
   * @param newFilePath the file name at the {@link #newId()} git tree.
   */
  static GitFileDiff empty(
      AbbreviatedObjectId oldId, AbbreviatedObjectId newId, String newFilePath) {
    return builder()
        .oldId(oldId)
        .newId(newId)
        .newPath(Optional.of(newFilePath))
        .changeType(ChangeType.MODIFIED)
        .edits(ImmutableList.of())
        .fileHeader("")
        .build();
  }

  /**
   * Create a negative result to be cached, i.e. if the diff computation did not finish in a
   * reasonable amount of time.
   */
  static GitFileDiff createNegative(
      AbbreviatedObjectId oldId, AbbreviatedObjectId newId, String newFilePath) {
    return empty(oldId, newId, newFilePath).toBuilder().negative(Optional.of(true)).build();
  }

  /** An {@link ImmutableList} of the modified regions in the file. */
  public abstract ImmutableList<Edit> edits();

  /** A string representation of the {@link org.eclipse.jgit.patch.FileHeader}. */
  public abstract String fileHeader();

  /** The file name at the old git tree identified by {@link #oldId()} */
  public abstract Optional<String> oldPath();

  /** The file name at the new git tree identified by {@link #newId()} */
  public abstract Optional<String> newPath();

  /**
   * The 20 bytes SHA-1 object ID of the old git tree of the diff, or {@link ObjectId#zeroId()} if
   * {@link #newId()} was a root git tree (i.e. has no parents).
   */
  public abstract AbbreviatedObjectId oldId();

  /** The 20 bytes SHA-1 object ID of the new git tree of the diff. */
  public abstract AbbreviatedObjectId newId();

  /** The file mode of the old file at the old git tree diff identified by {@link #oldId()}. */
  public abstract Optional<Patch.FileMode> oldMode();

  /** The file mode of the new file at the new git tree diff identified by {@link #newId()}. */
  public abstract Optional<Patch.FileMode> newMode();

  /** The change type associated with the file. */
  public abstract ChangeType changeType();

  /** The patch type associated with the file. */
  public abstract Optional<PatchType> patchType();

  /**
   * Returns {@code true} if the diff computation was not able to compute a diff. We cache negative
   * result in this case.
   */
  public abstract Optional<Boolean> negative();

  /**
   * Returns true if the object was created using the {@link #empty(AbbreviatedObjectId,
   * AbbreviatedObjectId, String)} method.
   */
  public boolean isEmpty() {
    return edits().isEmpty();
  }

  /**
   * Returns {@code true} if the diff computation was not able to compute a diff. We cache negative
   * result in this case.
   */
  public boolean isNegative() {
    return negative().isPresent() && negative().get();
  }

  /** Returns the size of the object in bytes. */
  public int weight() {
    int result = 20 * 2; // oldId and newId
    result += 16 * edits().size(); // each edit contains 4 integers (hence 16 bytes)
    result += stringSize(fileHeader());
    if (oldPath().isPresent()) {
      result += stringSize(oldPath().get());
    }
    if (newPath().isPresent()) {
      result += stringSize(newPath().get());
    }
    result += 4;
    if (patchType().isPresent()) {
      result += 4;
    }
    if (oldMode().isPresent()) {
      result += 4;
    }
    if (newMode().isPresent()) {
      result += 4;
    }
    if (negative().isPresent()) {
      result += 1;
    }
    return result;
  }

  public String getDefaultPath() {
    return oldPath().isPresent() ? oldPath().get() : newPath().get();
  }

  public static Builder builder() {
    return new AutoValue_GitFileDiff.Builder();
  }

  public abstract Builder toBuilder();

  @AutoValue.Builder
  public abstract static class Builder {

    public abstract Builder edits(ImmutableList<Edit> value);

    public abstract Builder fileHeader(String value);

    public abstract Builder oldPath(Optional<String> value);

    public abstract Builder newPath(Optional<String> value);

    public abstract Builder oldId(AbbreviatedObjectId value);

    public abstract Builder newId(AbbreviatedObjectId value);

    public abstract Builder oldMode(Optional<Patch.FileMode> value);

    public abstract Builder newMode(Optional<Patch.FileMode> value);

    public abstract Builder changeType(ChangeType value);

    public abstract Builder patchType(Optional<PatchType> value);

    public abstract Builder negative(Optional<Boolean> value);

    public abstract GitFileDiff build();
  }

  public enum Serializer implements CacheSerializer<GitFileDiff> {
    INSTANCE;

    private static final Converter<String, Patch.FileMode> FILE_MODE_CONVERTER =
        Enums.stringConverter(Patch.FileMode.class);

    private static final Converter<String, Patch.ChangeType> CHANGE_TYPE_CONVERTER =
        Enums.stringConverter(Patch.ChangeType.class);

    private static final Converter<String, Patch.PatchType> PATCH_TYPE_CONVERTER =
        Enums.stringConverter(Patch.PatchType.class);

    private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
        GitFileDiffProto.getDescriptor().findFieldByNumber(3);

    private static final FieldDescriptor NEW_PATH_DESCRIPTOR =
        GitFileDiffProto.getDescriptor().findFieldByNumber(4);

    private static final FieldDescriptor OLD_MODE_DESCRIPTOR =
        GitFileDiffProto.getDescriptor().findFieldByNumber(7);

    private static final FieldDescriptor NEW_MODE_DESCRIPTOR =
        GitFileDiffProto.getDescriptor().findFieldByNumber(8);

    private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
        GitFileDiffProto.getDescriptor().findFieldByNumber(10);

    private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
        GitFileDiffProto.getDescriptor().findFieldByNumber(11);

    @Override
    public byte[] serialize(GitFileDiff gitFileDiff) {
      ObjectIdConverter idConverter = ObjectIdConverter.create();
      GitFileDiffProto.Builder builder =
          GitFileDiffProto.newBuilder()
              .setFileHeader(gitFileDiff.fileHeader())
              .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
              .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
              .setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(gitFileDiff.changeType()));
      gitFileDiff
          .edits()
          .forEach(
              e ->
                  builder.addEdits(
                      GitFileDiffProto.Edit.newBuilder()
                          .setBeginA(e.beginA())
                          .setEndA(e.endA())
                          .setBeginB(e.beginB())
                          .setEndB(e.endB())));
      if (gitFileDiff.oldPath().isPresent()) {
        builder.setOldPath(gitFileDiff.oldPath().get());
      }
      if (gitFileDiff.newPath().isPresent()) {
        builder.setNewPath(gitFileDiff.newPath().get());
      }
      if (gitFileDiff.oldMode().isPresent()) {
        builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.oldMode().get()));
      }
      if (gitFileDiff.newMode().isPresent()) {
        builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.newMode().get()));
      }
      if (gitFileDiff.patchType().isPresent()) {
        builder.setPatchType(PATCH_TYPE_CONVERTER.reverse().convert(gitFileDiff.patchType().get()));
      }
      if (gitFileDiff.negative().isPresent()) {
        builder.setNegative(gitFileDiff.negative().get());
      }
      return Protos.toByteArray(builder.build());
    }

    @Override
    public GitFileDiff deserialize(byte[] in) {
      ObjectIdConverter idConverter = ObjectIdConverter.create();
      GitFileDiffProto proto = Protos.parseUnchecked(GitFileDiffProto.parser(), in);
      GitFileDiff.Builder builder = GitFileDiff.builder();
      builder
          .edits(
              proto.getEditsList().stream()
                  .map(e -> Edit.create(e.getBeginA(), e.getEndA(), e.getBeginB(), e.getEndB()))
                  .collect(toImmutableList()))
          .fileHeader(proto.getFileHeader())
          .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
          .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
          .changeType(CHANGE_TYPE_CONVERTER.convert(proto.getChangeType()));

      if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
        builder.oldPath(Optional.of(proto.getOldPath()));
      }
      if (proto.hasField(NEW_PATH_DESCRIPTOR)) {
        builder.newPath(Optional.of(proto.getNewPath()));
      }
      if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
        builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
      }
      if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
        builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
      }
      if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
        builder.patchType(Optional.of(PATCH_TYPE_CONVERTER.convert(proto.getPatchType())));
      }
      if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
        builder.negative(Optional.of(proto.getNegative()));
      }
      return builder.build();
    }
  }
}
