// 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 com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.exceptions.NoSuchGroupException;
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.GroupCache;
import com.google.gerrit.server.account.GroupIncludeCache;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.group.GroupAuditService;
import com.google.gerrit.server.group.InternalGroup;
import com.google.gerrit.server.index.group.GroupIndexer;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;

/**
 * A database accessor for write calls related to groups.
 *
 * <p>All calls which write group related details to the database are gathered here. Other classes
 * should always use this class instead of accessing the database directly. There are a few
 * exceptions though: schema classes, wrapper classes, and classes executed during init. The latter
 * ones should use {@code GroupsOnInit} instead.
 *
 * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
 */
public class GroupsUpdate {
  public interface Factory {
    /**
     * Creates a {@code GroupsUpdate} which uses the identity of the specified user to mark database
     * modifications executed by it. For NoteDb, this identity is used as author and committer for
     * all related commits.
     *
     * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@code GroupsUpdate}
     * instead.
     *
     * @param currentUser the user to which modifications should be attributed
     */
    GroupsUpdate create(IdentifiedUser currentUser);

    /**
     * Creates a {@code GroupsUpdate} which uses the server identity to mark database modifications
     * executed by it. For NoteDb, this identity is used as author and committer for all related
     * commits.
     *
     * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@code
     * GroupsUpdate} instead.
     */
    GroupsUpdate createWithServerIdent();
  }

  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  private final GitRepositoryManager repoManager;
  private final AllUsersName allUsersName;
  private final GroupCache groupCache;
  private final GroupIncludeCache groupIncludeCache;
  private final Provider<GroupIndexer> indexer;
  private final GroupAuditService groupAuditService;
  private final RenameGroupOp.Factory renameGroupOpFactory;
  private final Optional<IdentifiedUser> currentUser;
  private final AuditLogFormatter auditLogFormatter;
  private final PersonIdent authorIdent;
  private final MetaDataUpdateFactory metaDataUpdateFactory;
  private final GitReferenceUpdated gitRefUpdated;
  private final RetryHelper retryHelper;

  @AssistedInject
  GroupsUpdate(
      GitRepositoryManager repoManager,
      AllUsersName allUsersName,
      GroupBackend groupBackend,
      GroupCache groupCache,
      GroupIncludeCache groupIncludeCache,
      Provider<GroupIndexer> indexer,
      GroupAuditService auditService,
      AccountCache accountCache,
      RenameGroupOp.Factory renameGroupOpFactory,
      @GerritServerId String serverId,
      @GerritPersonIdent PersonIdent serverIdent,
      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
      GitReferenceUpdated gitRefUpdated,
      RetryHelper retryHelper) {
    this(
        repoManager,
        allUsersName,
        groupBackend,
        groupCache,
        groupIncludeCache,
        indexer,
        auditService,
        accountCache,
        renameGroupOpFactory,
        serverId,
        serverIdent,
        metaDataUpdateInternalFactory,
        gitRefUpdated,
        retryHelper,
        Optional.empty());
  }

  @AssistedInject
  GroupsUpdate(
      GitRepositoryManager repoManager,
      AllUsersName allUsersName,
      GroupBackend groupBackend,
      GroupCache groupCache,
      GroupIncludeCache groupIncludeCache,
      Provider<GroupIndexer> indexer,
      GroupAuditService auditService,
      AccountCache accountCache,
      RenameGroupOp.Factory renameGroupOpFactory,
      @GerritServerId String serverId,
      @GerritPersonIdent PersonIdent serverIdent,
      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
      GitReferenceUpdated gitRefUpdated,
      RetryHelper retryHelper,
      @Assisted IdentifiedUser currentUser) {
    this(
        repoManager,
        allUsersName,
        groupBackend,
        groupCache,
        groupIncludeCache,
        indexer,
        auditService,
        accountCache,
        renameGroupOpFactory,
        serverId,
        serverIdent,
        metaDataUpdateInternalFactory,
        gitRefUpdated,
        retryHelper,
        Optional.of(currentUser));
  }

  private GroupsUpdate(
      GitRepositoryManager repoManager,
      AllUsersName allUsersName,
      GroupBackend groupBackend,
      GroupCache groupCache,
      GroupIncludeCache groupIncludeCache,
      Provider<GroupIndexer> indexer,
      GroupAuditService auditService,
      AccountCache accountCache,
      RenameGroupOp.Factory renameGroupOpFactory,
      @GerritServerId String serverId,
      @GerritPersonIdent PersonIdent serverIdent,
      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
      GitReferenceUpdated gitRefUpdated,
      RetryHelper retryHelper,
      Optional<IdentifiedUser> currentUser) {
    this.repoManager = repoManager;
    this.allUsersName = allUsersName;
    this.groupCache = groupCache;
    this.groupIncludeCache = groupIncludeCache;
    this.indexer = indexer;
    this.groupAuditService = auditService;
    this.renameGroupOpFactory = renameGroupOpFactory;
    this.gitRefUpdated = gitRefUpdated;
    this.retryHelper = retryHelper;
    this.currentUser = currentUser;

    auditLogFormatter = AuditLogFormatter.createBackedBy(accountCache, groupBackend, serverId);
    metaDataUpdateFactory =
        getMetaDataUpdateFactory(
            metaDataUpdateInternalFactory, currentUser, serverIdent, auditLogFormatter);
    authorIdent = getAuthorIdent(serverIdent, currentUser);
  }

  private static MetaDataUpdateFactory getMetaDataUpdateFactory(
      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
      Optional<IdentifiedUser> currentUser,
      PersonIdent serverIdent,
      AuditLogFormatter auditLogFormatter) {
    return (projectName, repository, batchRefUpdate) -> {
      MetaDataUpdate metaDataUpdate =
          metaDataUpdateInternalFactory.create(projectName, repository, batchRefUpdate);
      metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
      PersonIdent authorIdent;
      if (currentUser.isPresent()) {
        metaDataUpdate.setAuthor(currentUser.get());
        authorIdent =
            auditLogFormatter.getParsableAuthorIdent(currentUser.get().getAccount(), serverIdent);
      } else {
        authorIdent = serverIdent;
      }
      metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
      return metaDataUpdate;
    };
  }

  private static PersonIdent getAuthorIdent(
      PersonIdent serverIdent, Optional<IdentifiedUser> currentUser) {
    return currentUser.map(user -> createPersonIdent(serverIdent, user)).orElse(serverIdent);
  }

  private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
    return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
  }

  /**
   * Creates the specified group for the specified members (accounts).
   *
   * @param groupCreation an {@code InternalGroupCreation} which specifies all mandatory properties
   *     of the group
   * @param groupUpdate an {@code InternalGroupUpdate} which specifies optional properties of the
   *     group. If this {@code InternalGroupUpdate} updates a property which was already specified
   *     by the {@code InternalGroupCreation}, the value of this {@code InternalGroupUpdate} wins.
   * @throws DuplicateKeyException if a group with the chosen name already exists
   * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
   * @return the created {@code InternalGroup}
   */
  public InternalGroup createGroup(
      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
      throws DuplicateKeyException, IOException, ConfigInvalidException {
    try (TraceTimer timer =
        TraceContext.newTimer(
            "Creating group",
            Metadata.builder()
                .groupName(groupUpdate.getName().orElseGet(groupCreation::getNameKey).get())
                .build())) {
      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupUpdate);
      evictCachesOnGroupCreation(createdGroup);
      dispatchAuditEventsOnGroupCreation(createdGroup);
      return createdGroup;
    }
  }

  /**
   * Updates the specified group.
   *
   * @param groupUuid the UUID of the group to update
   * @param groupUpdate an {@code InternalGroupUpdate} which indicates the desired updates on the
   *     group
   * @throws DuplicateKeyException if the new name of the group is used by another group
   * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
   * @throws NoSuchGroupException if the specified group doesn't exist
   */
  public void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
      throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
    try (TraceTimer timer =
        TraceContext.newTimer(
            "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
      Optional<Timestamp> updatedOn = groupUpdate.getUpdatedOn();
      if (!updatedOn.isPresent()) {
        updatedOn = Optional.of(TimeUtil.nowTs());
        groupUpdate = groupUpdate.toBuilder().setUpdatedOn(updatedOn.get()).build();
      }

      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupUpdate);
      updateNameInProjectConfigsIfNecessary(result);
      evictCachesOnGroupUpdate(result);
      dispatchAuditEventsOnGroupUpdate(result, updatedOn.get());
    }
  }

  private InternalGroup createGroupInNoteDbWithRetry(
      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
      throws IOException, ConfigInvalidException, DuplicateKeyException {
    try {
      return retryHelper
          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupUpdate))
          .call();
    } catch (Exception e) {
      Throwables.throwIfUnchecked(e);
      Throwables.throwIfInstanceOf(e, IOException.class);
      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
      Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
      throw new IOException(e);
    }
  }

  @VisibleForTesting
  public InternalGroup createGroupInNoteDb(
      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
      throws IOException, ConfigInvalidException, DuplicateKeyException {
    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
      AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
      GroupNameNotes groupNameNotes =
          GroupNameNotes.forNewGroup(
              allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);

      GroupConfig groupConfig =
          GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
      groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);

      commit(allUsersRepo, groupConfig, groupNameNotes);

      return groupConfig
          .getLoadedGroup()
          .orElseThrow(
              () -> new IllegalStateException("Created group wasn't automatically loaded"));
    }
  }

  private UpdateResult updateGroupInNoteDbWithRetry(
      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
      throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
    try {
      return retryHelper
          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupUpdate))
          .call();
    } catch (Exception e) {
      Throwables.throwIfUnchecked(e);
      Throwables.throwIfInstanceOf(e, IOException.class);
      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
      Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
      Throwables.throwIfInstanceOf(e, NoSuchGroupException.class);
      throw new IOException(e);
    }
  }

  @VisibleForTesting
  public UpdateResult updateGroupInNoteDb(
      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
      throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
      GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
      groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
      if (!groupConfig.getLoadedGroup().isPresent()) {
        throw new NoSuchGroupException(groupUuid);
      }

      InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
      GroupNameNotes groupNameNotes = null;
      if (groupUpdate.getName().isPresent()) {
        AccountGroup.NameKey oldName = originalGroup.getNameKey();
        AccountGroup.NameKey newName = groupUpdate.getName().get();
        groupNameNotes =
            GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
      }

      commit(allUsersRepo, groupConfig, groupNameNotes);

      InternalGroup updatedGroup =
          groupConfig
              .getLoadedGroup()
              .orElseThrow(
                  () -> new IllegalStateException("Updated group wasn't automatically loaded"));
      return getUpdateResult(originalGroup, updatedGroup);
    }
  }

  private static UpdateResult getUpdateResult(
      InternalGroup originalGroup, InternalGroup updatedGroup) {
    Set<Account.Id> addedMembers =
        Sets.difference(updatedGroup.getMembers(), originalGroup.getMembers());
    Set<Account.Id> deletedMembers =
        Sets.difference(originalGroup.getMembers(), updatedGroup.getMembers());
    Set<AccountGroup.UUID> addedSubgroups =
        Sets.difference(updatedGroup.getSubgroups(), originalGroup.getSubgroups());
    Set<AccountGroup.UUID> deletedSubgroups =
        Sets.difference(originalGroup.getSubgroups(), updatedGroup.getSubgroups());

    UpdateResult.Builder resultBuilder =
        UpdateResult.builder()
            .setGroupUuid(updatedGroup.getGroupUUID())
            .setGroupId(updatedGroup.getId())
            .setGroupName(updatedGroup.getNameKey())
            .setAddedMembers(addedMembers)
            .setDeletedMembers(deletedMembers)
            .setAddedSubgroups(addedSubgroups)
            .setDeletedSubgroups(deletedSubgroups);
    if (!Objects.equals(originalGroup.getNameKey(), updatedGroup.getNameKey())) {
      resultBuilder.setPreviousGroupName(originalGroup.getNameKey());
    }
    return resultBuilder.build();
  }

  private void commit(
      Repository allUsersRepo, GroupConfig groupConfig, @Nullable GroupNameNotes groupNameNotes)
      throws IOException {
    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
    try (MetaDataUpdate metaDataUpdate =
        metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
      groupConfig.commit(metaDataUpdate);
    }
    if (groupNameNotes != null) {
      // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
      try (MetaDataUpdate metaDataUpdate =
          metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
        groupNameNotes.commit(metaDataUpdate);
      }
    }

    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
    gitRefUpdated.fire(
        allUsersName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
  }

  private void evictCachesOnGroupCreation(InternalGroup createdGroup) {
    logger.atFine().log("evict caches on creation of group %s", createdGroup.getGroupUUID());
    // By UUID is used for the index and hence should be evicted before refreshing the index.
    groupCache.evict(createdGroup.getGroupUUID());
    indexer.get().index(createdGroup.getGroupUUID());
    // These caches use the result from the index and hence must be evicted after refreshing the
    // index.
    groupCache.evict(createdGroup.getId());
    groupCache.evict(createdGroup.getNameKey());
    createdGroup.getMembers().forEach(groupIncludeCache::evictGroupsWithMember);
    createdGroup.getSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
  }

  private void evictCachesOnGroupUpdate(UpdateResult result) {
    logger.atFine().log("evict caches on update of group %s", result.getGroupUuid());
    // By UUID is used for the index and hence should be evicted before refreshing the index.
    groupCache.evict(result.getGroupUuid());
    indexer.get().index(result.getGroupUuid());
    // These caches use the result from the index and hence must be evicted after refreshing the
    // index.
    groupCache.evict(result.getGroupId());
    groupCache.evict(result.getGroupName());
    result.getPreviousGroupName().ifPresent(groupCache::evict);

    result.getAddedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
    result.getDeletedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
    result.getAddedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
    result.getDeletedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
  }

  private void updateNameInProjectConfigsIfNecessary(UpdateResult result) {
    if (result.getPreviousGroupName().isPresent()) {
      AccountGroup.NameKey previousName = result.getPreviousGroupName().get();

      @SuppressWarnings("unused")
      Future<?> possiblyIgnoredError =
          renameGroupOpFactory
              .create(
                  authorIdent,
                  result.getGroupUuid(),
                  previousName.get(),
                  result.getGroupName().get())
              .start(0, TimeUnit.MILLISECONDS);
    }
  }

  private void dispatchAuditEventsOnGroupCreation(InternalGroup createdGroup) {
    if (!currentUser.isPresent()) {
      return;
    }

    if (!createdGroup.getMembers().isEmpty()) {
      groupAuditService.dispatchAddMembers(
          currentUser.get().getAccountId(),
          createdGroup.getGroupUUID(),
          createdGroup.getMembers(),
          createdGroup.getCreatedOn());
    }
    if (!createdGroup.getSubgroups().isEmpty()) {
      groupAuditService.dispatchAddSubgroups(
          currentUser.get().getAccountId(),
          createdGroup.getGroupUUID(),
          createdGroup.getSubgroups(),
          createdGroup.getCreatedOn());
    }
  }

  private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Timestamp updatedOn) {
    if (!currentUser.isPresent()) {
      return;
    }

    if (!result.getAddedMembers().isEmpty()) {
      groupAuditService.dispatchAddMembers(
          currentUser.get().getAccountId(),
          result.getGroupUuid(),
          result.getAddedMembers(),
          updatedOn);
    }
    if (!result.getDeletedMembers().isEmpty()) {
      groupAuditService.dispatchDeleteMembers(
          currentUser.get().getAccountId(),
          result.getGroupUuid(),
          result.getDeletedMembers(),
          updatedOn);
    }
    if (!result.getAddedSubgroups().isEmpty()) {
      groupAuditService.dispatchAddSubgroups(
          currentUser.get().getAccountId(),
          result.getGroupUuid(),
          result.getAddedSubgroups(),
          updatedOn);
    }
    if (!result.getDeletedSubgroups().isEmpty()) {
      groupAuditService.dispatchDeleteSubgroups(
          currentUser.get().getAccountId(),
          result.getGroupUuid(),
          result.getDeletedSubgroups(),
          updatedOn);
    }
  }

  @FunctionalInterface
  private interface MetaDataUpdateFactory {
    MetaDataUpdate create(
        Project.NameKey projectName, Repository repository, BatchRefUpdate batchRefUpdate)
        throws IOException;
  }

  @AutoValue
  abstract static class UpdateResult {
    abstract AccountGroup.UUID getGroupUuid();

    abstract AccountGroup.Id getGroupId();

    abstract AccountGroup.NameKey getGroupName();

    abstract Optional<AccountGroup.NameKey> getPreviousGroupName();

    abstract ImmutableSet<Account.Id> getAddedMembers();

    abstract ImmutableSet<Account.Id> getDeletedMembers();

    abstract ImmutableSet<AccountGroup.UUID> getAddedSubgroups();

    abstract ImmutableSet<AccountGroup.UUID> getDeletedSubgroups();

    static Builder builder() {
      return new AutoValue_GroupsUpdate_UpdateResult.Builder();
    }

    @AutoValue.Builder
    abstract static class Builder {
      abstract Builder setGroupUuid(AccountGroup.UUID groupUuid);

      abstract Builder setGroupId(AccountGroup.Id groupId);

      abstract Builder setGroupName(AccountGroup.NameKey name);

      abstract Builder setPreviousGroupName(AccountGroup.NameKey previousName);

      abstract Builder setAddedMembers(Set<Account.Id> addedMembers);

      abstract Builder setDeletedMembers(Set<Account.Id> deletedMembers);

      abstract Builder setAddedSubgroups(Set<AccountGroup.UUID> addedSubgroups);

      abstract Builder setDeletedSubgroups(Set<AccountGroup.UUID> deletedSubgroups);

      abstract UpdateResult build();
    }
  }
}
