// Copyright (C) 2017 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.group.db;

import static com.google.common.collect.ImmutableBiMap.toImmutableBiMap;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.HashMultiset;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Multiset;
import com.google.common.flogger.FluentLogger;
import com.google.common.hash.Hashing;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.git.ObjectIds;
import com.google.gerrit.server.git.meta.VersionedMetaData;
import java.io.IOException;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.notes.Note;
import org.eclipse.jgit.notes.NoteMap;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.ReceiveCommand;

/**
 * An enforcer of unique names for groups in NoteDb.
 *
 * <p>The way groups are stored in NoteDb (see {@link GroupConfig}) doesn't enforce unique names,
 * even though groups in Gerrit must not have duplicate names. The storage format doesn't allow to
 * quickly look up whether a name has already been used either. That's why we additionally keep a
 * map of name/UUID pairs and manage it with this class.
 *
 * <p>To claim the name for a new group, create an instance of {@code GroupNameNotes} via {@link
 * #forNewGroup(Project.NameKey, Repository, AccountGroup.UUID, AccountGroup.NameKey)} and call
 * {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} on it.
 * For renaming, call {@link #forRename(Project.NameKey, Repository, AccountGroup.UUID,
 * AccountGroup.NameKey, AccountGroup.NameKey)} and also commit the returned {@code GroupNameNotes}.
 * Both times, the creation of the {@code GroupNameNotes} will fail if the (new) name is already
 * used. Committing the {@code GroupNameNotes} is necessary to make the adjustments for real.
 *
 * <p>The map has an additional benefit: We can quickly iterate over all group name/UUID pairs
 * without having to load all groups completely (which is costly).
 *
 * <p><em>Internal details</em>
 *
 * <p>The map of names is represented by Git {@link Note notes}. They are stored on the branch
 * {@link RefNames#REFS_GROUPNAMES}. Each commit on the branch reflects one moment in time of the
 * complete map.
 *
 * <p>As key for the notes, we use the SHA-1 of the name. As data, they contain a text version of a
 * JGit {@link Config} file. That config file has two entries:
 *
 * <ul>
 *   <li>the name of the group (as clear text)
 *   <li>the UUID of the group which currently has this name
 * </ul>
 */
public class GroupNameNotes extends VersionedMetaData {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  private static final String SECTION_NAME = "group";
  private static final String UUID_PARAM = "uuid";
  private static final String NAME_PARAM = "name";

  @VisibleForTesting
  static final String UNIQUE_REF_ERROR = "GroupReference collection must contain unique references";

  /**
   * Creates an instance of {@code GroupNameNotes} for use when renaming a group.
   *
   * <p><strong>Note: </strong>The returned instance of {@code GroupNameNotes} has to be committed
   * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in
   * order to claim the new name and free up the old one.
   *
   * @param projectName the name of the project which holds the commits of the notes
   * @param repository the repository which holds the commits of the notes
   * @param groupUuid the UUID of the group which is renamed
   * @param oldName the current name of the group
   * @param newName the new name of the group
   * @return an instance of {@code GroupNameNotes} configured for a specific renaming of a group
   * @throws IOException if the repository can't be accessed for some reason
   * @throws ConfigInvalidException if the note for the specified group doesn't exist or is in an
   *     invalid state
   * @throws DuplicateKeyException if a group with the new name already exists
   */
  public static GroupNameNotes forRename(
      Project.NameKey projectName,
      Repository repository,
      AccountGroup.UUID groupUuid,
      AccountGroup.NameKey oldName,
      AccountGroup.NameKey newName)
      throws IOException, ConfigInvalidException, DuplicateKeyException {
    requireNonNull(oldName);
    requireNonNull(newName);

    GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, oldName, newName);
    groupNameNotes.load(projectName, repository);
    groupNameNotes.ensureNewNameIsNotUsed();
    return groupNameNotes;
  }

  /**
   * Creates an instance of {@code GroupNameNotes} for use when creating a new group.
   *
   * <p><strong>Note: </strong>The returned instance of {@code GroupNameNotes} has to be committed
   * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in
   * order to claim the new name.
   *
   * @param projectName the name of the project which holds the commits of the notes
   * @param repository the repository which holds the commits of the notes
   * @param groupUuid the UUID of the new group
   * @param groupName the name of the new group
   * @return an instance of {@code GroupNameNotes} configured for a specific group creation
   * @throws IOException if the repository can't be accessed for some reason
   * @throws ConfigInvalidException in no case so far
   * @throws DuplicateKeyException if a group with the new name already exists
   */
  public static GroupNameNotes forNewGroup(
      Project.NameKey projectName,
      Repository repository,
      AccountGroup.UUID groupUuid,
      AccountGroup.NameKey groupName)
      throws IOException, ConfigInvalidException, DuplicateKeyException {
    requireNonNull(groupName);

    GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, null, groupName);
    groupNameNotes.load(projectName, repository);
    groupNameNotes.ensureNewNameIsNotUsed();
    return groupNameNotes;
  }

  /**
   * Loads the {@code GroupReference} (name/UUID pair) for the group with the specified name.
   *
   * @param repository the repository which holds the commits of the notes
   * @param groupName the name of the group
   * @return the corresponding {@code GroupReference} if a group/note with the given name exists
   * @throws IOException if the repository can't be accessed for some reason
   * @throws ConfigInvalidException if the note for the specified group is in an invalid state
   */
  public static Optional<GroupReference> loadGroup(
      Repository repository, AccountGroup.NameKey groupName)
      throws IOException, ConfigInvalidException {
    Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES);
    if (ref == null) {
      return Optional.empty();
    }

    try (RevWalk revWalk = new RevWalk(repository);
        ObjectReader reader = revWalk.getObjectReader()) {
      RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId());
      NoteMap noteMap = NoteMap.read(reader, notesCommit);
      ObjectId noteDataBlobId = noteMap.get(getNoteKey(groupName));
      if (noteDataBlobId == null) {
        return Optional.empty();
      }
      return Optional.of(getGroupReference(reader, noteDataBlobId));
    }
  }

  /**
   * Loads the {@code GroupReference}s (name/UUID pairs) for all groups.
   *
   * <p>Even though group UUIDs should be unique, this class doesn't enforce it. For this reason,
   * it's technically possible that two of the {@code GroupReference}s have a duplicate UUID but a
   * different name. In practice, this shouldn't occur unless we introduce a bug in the future.
   *
   * @param repository the repository which holds the commits of the notes
   * @return the {@code GroupReference}s of all existing groups/notes
   * @throws IOException if the repository can't be accessed for some reason
   * @throws ConfigInvalidException if one of the notes is in an invalid state
   */
  public static ImmutableList<GroupReference> loadAllGroups(Repository repository)
      throws IOException, ConfigInvalidException {
    Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES);
    if (ref == null) {
      return ImmutableList.of();
    }
    try (RevWalk revWalk = new RevWalk(repository);
        ObjectReader reader = revWalk.getObjectReader()) {
      RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId());
      NoteMap noteMap = NoteMap.read(reader, notesCommit);

      Multiset<GroupReference> groupReferences = HashMultiset.create();
      for (Note note : noteMap) {
        GroupReference groupReference = getGroupReference(reader, note.getData());
        int numOfOccurrences = groupReferences.add(groupReference, 1);
        if (numOfOccurrences > 1) {
          GroupsNoteDbConsistencyChecker.logConsistencyProblemAsWarning(
              "The UUID of group %s (%s) is duplicate in group name notes",
              groupReference.getName(), groupReference.getUUID());
        }
      }

      return ImmutableList.copyOf(groupReferences);
    }
  }

  /**
   * Replaces the map of name/UUID pairs with a new version which matches exactly the passed {@code
   * GroupReference}s.
   *
   * <p>All old entries are discarded and replaced by the new ones.
   *
   * <p>This operation also works if the previous map has invalid entries or can't be read anymore.
   *
   * <p><strong>Note: </strong>This method doesn't flush the {@code ObjectInserter}. It doesn't
   * execute the {@code BatchRefUpdate} either.
   *
   * @param repository the repository which holds the commits of the notes
   * @param inserter an {@code ObjectInserter} for that repository
   * @param bru a {@code BatchRefUpdate} to which this method adds commands
   * @param groupReferences all {@code GroupReference}s (name/UUID pairs) which should be contained
   *     in the map of name/UUID pairs
   * @param ident the {@code PersonIdent} which is used as author and committer for commits
   * @throws IOException if the repository can't be accessed for some reason
   */
  public static void updateAllGroups(
      Repository repository,
      ObjectInserter inserter,
      BatchRefUpdate bru,
      Collection<GroupReference> groupReferences,
      PersonIdent ident)
      throws IOException {
    // Not strictly necessary for iteration; throws IAE if it encounters duplicates, which is nice.
    ImmutableBiMap<AccountGroup.UUID, String> biMap = toBiMap(groupReferences);

    try (ObjectReader reader = inserter.newReader();
        RevWalk rw = new RevWalk(reader)) {
      // Always start from an empty map, discarding old notes.
      NoteMap noteMap = NoteMap.newEmptyMap();
      Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES);
      RevCommit oldCommit = ref != null ? rw.parseCommit(ref.getObjectId()) : null;

      for (Map.Entry<AccountGroup.UUID, String> e : biMap.entrySet()) {
        AccountGroup.NameKey nameKey = AccountGroup.nameKey(e.getValue());
        ObjectId noteKey = getNoteKey(nameKey);
        noteMap.set(noteKey, getAsNoteData(e.getKey(), nameKey), inserter);
      }

      ObjectId newTreeId = noteMap.writeTree(inserter);
      if (oldCommit != null && newTreeId.equals(oldCommit.getTree())) {
        return;
      }
      CommitBuilder cb = new CommitBuilder();
      if (oldCommit != null) {
        cb.addParentId(oldCommit);
      }
      cb.setTreeId(newTreeId);
      cb.setAuthor(ident);
      cb.setCommitter(ident);
      int n = groupReferences.size();
      cb.setMessage("Store " + n + " group name" + (n != 1 ? "s" : ""));
      ObjectId newId = inserter.insert(cb).copy();

      ObjectId oldId = ObjectIds.copyOrZero(oldCommit);
      bru.addCommand(new ReceiveCommand(oldId, newId, RefNames.REFS_GROUPNAMES));
    }
  }

  // Returns UUID <=> Name bimap.
  private static ImmutableBiMap<AccountGroup.UUID, String> toBiMap(
      Collection<GroupReference> groupReferences) {
    try {
      return groupReferences.stream()
          .collect(toImmutableBiMap(GroupReference::getUUID, GroupReference::getName));
    } catch (IllegalArgumentException e) {
      throw new IllegalArgumentException(UNIQUE_REF_ERROR, e);
    }
  }

  private final AccountGroup.UUID groupUuid;
  private Optional<AccountGroup.NameKey> oldGroupName;
  private Optional<AccountGroup.NameKey> newGroupName;

  private boolean nameConflicting;

  private GroupNameNotes(
      AccountGroup.UUID groupUuid,
      @Nullable AccountGroup.NameKey oldGroupName,
      @Nullable AccountGroup.NameKey newGroupName) {
    this.groupUuid = requireNonNull(groupUuid);

    if (Objects.equals(oldGroupName, newGroupName)) {
      this.oldGroupName = Optional.empty();
      this.newGroupName = Optional.empty();
    } else {
      this.oldGroupName = Optional.ofNullable(oldGroupName);
      this.newGroupName = Optional.ofNullable(newGroupName);
    }
  }

  @Override
  protected String getRefName() {
    return RefNames.REFS_GROUPNAMES;
  }

  @Override
  protected void onLoad() throws IOException, ConfigInvalidException {
    nameConflicting = false;

    logger.atFine().log("Reading group notes");

    if (revision != null) {
      NoteMap noteMap = NoteMap.read(reader, revision);
      if (newGroupName.isPresent()) {
        ObjectId newNameId = getNoteKey(newGroupName.get());
        nameConflicting = noteMap.contains(newNameId);
      }
      ensureOldNameIsPresent(noteMap);
    }
  }

  private void ensureOldNameIsPresent(NoteMap noteMap) throws IOException, ConfigInvalidException {
    if (oldGroupName.isPresent()) {
      AccountGroup.NameKey oldName = oldGroupName.get();
      ObjectId noteKey = getNoteKey(oldName);
      ObjectId noteDataBlobId = noteMap.get(noteKey);
      if (noteDataBlobId == null) {
        throw new ConfigInvalidException(
            String.format("Group name '%s' doesn't exist in the list of all names", oldName));
      }
      GroupReference group = getGroupReference(reader, noteDataBlobId);
      AccountGroup.UUID foundUuid = group.getUUID();
      if (!Objects.equals(groupUuid, foundUuid)) {
        throw new ConfigInvalidException(
            String.format(
                "Name '%s' points to UUID '%s' and not to '%s'", oldName, foundUuid, groupUuid));
      }
    }
  }

  private void ensureNewNameIsNotUsed() throws DuplicateKeyException {
    if (newGroupName.isPresent() && nameConflicting) {
      throw new DuplicateKeyException(
          String.format("Name '%s' is already used", newGroupName.get().get()));
    }
  }

  @Override
  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
    if (!oldGroupName.isPresent() && !newGroupName.isPresent()) {
      return false;
    }

    logger.atFine().log("Updating group notes");

    NoteMap noteMap = revision == null ? NoteMap.newEmptyMap() : NoteMap.read(reader, revision);
    if (oldGroupName.isPresent()) {
      removeNote(noteMap, oldGroupName.get(), inserter);
    }

    if (newGroupName.isPresent()) {
      addNote(noteMap, newGroupName.get(), groupUuid, inserter);
    }

    commit.setTreeId(noteMap.writeTree(inserter));
    commit.setMessage(getCommitMessage());

    oldGroupName = Optional.empty();
    newGroupName = Optional.empty();

    return true;
  }

  private static void removeNote(
      NoteMap noteMap, AccountGroup.NameKey groupName, ObjectInserter inserter) throws IOException {
    ObjectId noteKey = getNoteKey(groupName);
    noteMap.set(noteKey, null, inserter);
  }

  private static void addNote(
      NoteMap noteMap,
      AccountGroup.NameKey groupName,
      AccountGroup.UUID groupUuid,
      ObjectInserter inserter)
      throws IOException {
    ObjectId noteKey = getNoteKey(groupName);
    noteMap.set(noteKey, getAsNoteData(groupUuid, groupName), inserter);
  }

  // Use the same approach as ExternalId.Key.sha1().
  @SuppressWarnings("deprecation")
  @VisibleForTesting
  public static ObjectId getNoteKey(AccountGroup.NameKey groupName) {
    return ObjectId.fromRaw(Hashing.sha1().hashString(groupName.get(), UTF_8).asBytes());
  }

  private static String getAsNoteData(AccountGroup.UUID uuid, AccountGroup.NameKey groupName) {
    Config config = new Config();
    config.setString(SECTION_NAME, null, UUID_PARAM, uuid.get());
    config.setString(SECTION_NAME, null, NAME_PARAM, groupName.get());
    return config.toText();
  }

  private static GroupReference getGroupReference(ObjectReader reader, ObjectId noteDataBlobId)
      throws IOException, ConfigInvalidException {
    byte[] noteData = reader.open(noteDataBlobId, OBJ_BLOB).getCachedBytes();
    return getFromNoteData(noteData);
  }

  static GroupReference getFromNoteData(byte[] noteData) throws ConfigInvalidException {
    Config config = new Config();
    config.fromText(new String(noteData, UTF_8));

    String uuid = config.getString(SECTION_NAME, null, UUID_PARAM);
    String name = Strings.nullToEmpty(config.getString(SECTION_NAME, null, NAME_PARAM));
    if (uuid == null) {
      throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name));
    }

    return GroupReference.create(AccountGroup.uuid(uuid), name);
  }

  private String getCommitMessage() {
    if (oldGroupName.isPresent() && newGroupName.isPresent()) {
      return String.format(
          "Rename group from '%s' to '%s'", oldGroupName.get(), newGroupName.get());
    }
    if (newGroupName.isPresent()) {
      return String.format("Create group '%s'", newGroupName.get());
    }
    if (oldGroupName.isPresent()) {
      return String.format("Delete group '%s'", oldGroupName.get());
    }
    return "No-op";
  }
}
