// Copyright (C) 2016 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.restapi.change;

import static com.google.common.flogger.LazyArgs.lazy;
import static java.util.stream.Collectors.toList;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
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.GroupReference;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.GroupBaseInfo;
import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.index.FieldDef;
import com.google.gerrit.index.IndexConfig;
import com.google.gerrit.index.QueryOptions;
import com.google.gerrit.index.query.FieldBundle;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.index.query.ResultSet;
import com.google.gerrit.index.query.TooManyTermsInQueryException;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.Description.Units;
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AccountControl;
import com.google.gerrit.server.account.AccountDirectory.FillOptions;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.GroupMembers;
import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.change.ReviewerAdder;
import com.google.gerrit.server.index.account.AccountField;
import com.google.gerrit.server.index.account.AccountIndexCollection;
import com.google.gerrit.server.index.account.AccountIndexRewriter;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.account.AccountPredicates;
import com.google.gerrit.server.query.account.AccountQueryBuilder;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;

public class ReviewersUtil {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  @Singleton
  private static class Metrics {
    final Timer0 queryAccountsLatency;
    final Timer0 recommendAccountsLatency;
    final Timer0 loadAccountsLatency;
    final Timer0 queryGroupsLatency;
    final Timer0 filterVisibility;

    @Inject
    Metrics(MetricMaker metricMaker) {
      queryAccountsLatency =
          metricMaker.newTimer(
              "reviewer_suggestion/query_accounts",
              new Description("Latency for querying accounts for reviewer suggestion")
                  .setCumulative()
                  .setUnit(Units.MILLISECONDS));
      recommendAccountsLatency =
          metricMaker.newTimer(
              "reviewer_suggestion/recommend_accounts",
              new Description("Latency for recommending accounts for reviewer suggestion")
                  .setCumulative()
                  .setUnit(Units.MILLISECONDS));
      loadAccountsLatency =
          metricMaker.newTimer(
              "reviewer_suggestion/load_accounts",
              new Description("Latency for loading accounts for reviewer suggestion")
                  .setCumulative()
                  .setUnit(Units.MILLISECONDS));
      queryGroupsLatency =
          metricMaker.newTimer(
              "reviewer_suggestion/query_groups",
              new Description("Latency for querying groups for reviewer suggestion")
                  .setCumulative()
                  .setUnit(Units.MILLISECONDS));
      filterVisibility =
          metricMaker.newTimer(
              "reviewer_suggestion/filter_visibility",
              new Description("Latency for removing users that can't see the change")
                  .setCumulative()
                  .setUnit(Units.MILLISECONDS));
    }
  }

  private final AccountLoader.Factory accountLoaderFactory;
  private final AccountQueryBuilder accountQueryBuilder;
  private final AccountIndexRewriter accountIndexRewriter;
  private final GroupBackend groupBackend;
  private final GroupMembers groupMembers;
  private final ReviewerRecommender reviewerRecommender;
  private final Metrics metrics;
  private final AccountIndexCollection accountIndexes;
  private final IndexConfig indexConfig;
  private final AccountControl.Factory accountControlFactory;
  private final Provider<CurrentUser> self;
  private final ServiceUserClassifier serviceUserClassifier;

  @Inject
  ReviewersUtil(
      AccountLoader.Factory accountLoaderFactory,
      AccountQueryBuilder accountQueryBuilder,
      AccountIndexRewriter accountIndexRewriter,
      GroupBackend groupBackend,
      GroupMembers groupMembers,
      ReviewerRecommender reviewerRecommender,
      Metrics metrics,
      AccountIndexCollection accountIndexes,
      IndexConfig indexConfig,
      AccountControl.Factory accountControlFactory,
      Provider<CurrentUser> self,
      ServiceUserClassifier serviceUserClassifier) {
    this.accountLoaderFactory = accountLoaderFactory;
    this.accountQueryBuilder = accountQueryBuilder;
    this.accountIndexRewriter = accountIndexRewriter;
    this.groupBackend = groupBackend;
    this.groupMembers = groupMembers;
    this.reviewerRecommender = reviewerRecommender;
    this.metrics = metrics;
    this.accountIndexes = accountIndexes;
    this.indexConfig = indexConfig;
    this.accountControlFactory = accountControlFactory;
    this.self = self;
    this.serviceUserClassifier = serviceUserClassifier;
  }

  public interface VisibilityControl {
    boolean isVisibleTo(Account.Id account);
  }

  public List<SuggestedReviewerInfo> suggestReviewers(
      ReviewerState reviewerState,
      @Nullable ChangeNotes changeNotes,
      SuggestReviewers suggestReviewers,
      ProjectState projectState,
      VisibilityControl visibilityControl,
      boolean excludeGroups)
      throws IOException, ConfigInvalidException, PermissionBackendException, BadRequestException {
    CurrentUser currentUser = self.get();
    if (changeNotes != null) {
      logger.atFine().log(
          "Suggesting reviewers for change %s to user %s.",
          changeNotes.getChangeId().get(), currentUser.getLoggableName());
    } else {
      logger.atFine().log(
          "Suggesting default reviewers for project %s to user %s.",
          projectState.getName(), currentUser.getLoggableName());
    }

    String query = suggestReviewers.getQuery();
    logger.atFine().log("Query: %s", query);
    int limit = suggestReviewers.getLimit();

    if (!suggestReviewers.getSuggestAccounts()) {
      logger.atFine().log("Reviewer suggestion is disabled.");
      return Collections.emptyList();
    }

    List<Account.Id> candidateList = new ArrayList<>();
    if (!Strings.isNullOrEmpty(query)) {
      candidateList = suggestAccounts(suggestReviewers);
      logger.atFine().log("Candidate list: %s", candidateList);
    }

    List<Account.Id> sortedRecommendations =
        recommendAccounts(
            reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
    logger.atFine().log("Sorted recommendations: %s", sortedRecommendations);

    // Filter accounts by visibility, skip service users and enforce limit
    List<Account.Id> filteredRecommendations = new ArrayList<>();
    try (Timer0.Context ctx = metrics.filterVisibility.start()) {
      for (Account.Id reviewer : sortedRecommendations) {
        if (filteredRecommendations.size() >= limit) {
          break;
        }
        if (suggestReviewers.isSkipServiceUsers()
            && serviceUserClassifier.isServiceUser(reviewer)) {
          continue;
        }
        // Check if change is visible to reviewer and if the current user can see reviewer
        if (visibilityControl.isVisibleTo(reviewer)
            && accountControlFactory.get().canSee(reviewer)) {
          filteredRecommendations.add(reviewer);
        }
      }
    }
    logger.atFine().log("Filtered recommendations: %s", filteredRecommendations);

    List<SuggestedReviewerInfo> suggestedReviewers =
        suggestReviewers(
            suggestReviewers,
            projectState,
            visibilityControl,
            excludeGroups,
            filteredRecommendations);
    logger.atFine().log(
        "Suggested reviewers: %s", lazy(() -> formatSuggestedReviewers(suggestedReviewers)));
    return suggestedReviewers;
  }

  private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
    if (useLegacyNumericFields) {
      return Account.id(f.getValue(AccountField.ID).intValue());
    }
    return Account.id(Integer.valueOf(f.getValue(AccountField.ID_STR)));
  }

  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers)
      throws BadRequestException {
    try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
      // For performance reasons we don't use AccountQueryProvider as it would always load the
      // complete account from the cache (or worse, from NoteDb) even though we only need the ID
      // which we can directly get from the returned results.
      Predicate<AccountState> pred =
          Predicate.and(
              AccountPredicates.isActive(),
              accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
      logger.atFine().log("accounts index query: %s", pred);
      accountIndexRewriter.validateMaxTermsInQuery(pred);
      boolean useLegacyNumericFields =
          accountIndexes.getSearchIndex().getSchema().useLegacyNumericFields();
      FieldDef<AccountState, ?> idField =
          useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
      ResultSet<FieldBundle> result =
          accountIndexes
              .getSearchIndex()
              .getSource(
                  pred,
                  QueryOptions.create(
                      indexConfig,
                      0,
                      suggestReviewers.getLimit(),
                      ImmutableSet.of(idField.getName())))
              .readRaw();
      List<Account.Id> matches =
          result.toList().stream()
              .map(f -> fromIdField(f, useLegacyNumericFields))
              .collect(toList());
      logger.atFine().log("Matches: %s", matches);
      return matches;
    } catch (TooManyTermsInQueryException e) {
      throw new BadRequestException(e.getMessage());
    } catch (QueryParseException e) {
      logger.atWarning().withCause(e).log("Suggesting accounts failed, return empty result.");
      return ImmutableList.of();
    } catch (StorageException e) {
      if (e.getCause() instanceof TooManyTermsInQueryException) {
        throw new BadRequestException(e.getMessage());
      }
      if (e.getCause() instanceof QueryParseException) {
        return ImmutableList.of();
      }
      throw e;
    }
  }

  private List<SuggestedReviewerInfo> suggestReviewers(
      SuggestReviewers suggestReviewers,
      ProjectState projectState,
      VisibilityControl visibilityControl,
      boolean excludeGroups,
      List<Account.Id> filteredRecommendations)
      throws PermissionBackendException, IOException {
    List<SuggestedReviewerInfo> suggestedReviewers = loadAccounts(filteredRecommendations);

    int limit = suggestReviewers.getLimit();
    if (!excludeGroups
        && suggestedReviewers.size() < limit
        && !Strings.isNullOrEmpty(suggestReviewers.getQuery())) {
      // Add groups at the end as individual accounts are usually more
      // important.
      suggestedReviewers.addAll(
          suggestAccountGroups(
              suggestReviewers,
              projectState,
              visibilityControl,
              limit - suggestedReviewers.size()));
    }

    if (suggestedReviewers.size() > limit) {
      suggestedReviewers = suggestedReviewers.subList(0, limit);
      logger.atFine().log("Limited suggested reviewers to %d accounts.", limit);
    }
    return suggestedReviewers;
  }

  private List<Account.Id> recommendAccounts(
      ReviewerState reviewerState,
      @Nullable ChangeNotes changeNotes,
      SuggestReviewers suggestReviewers,
      ProjectState projectState,
      List<Account.Id> candidateList)
      throws IOException, ConfigInvalidException {
    try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
      return reviewerRecommender.suggestReviewers(
          reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
    }
  }

  private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds)
      throws PermissionBackendException {
    Set<FillOptions> fillOptions =
        Sets.union(AccountLoader.DETAILED_OPTIONS, EnumSet.of(FillOptions.SECONDARY_EMAILS));
    AccountLoader accountLoader = accountLoaderFactory.create(fillOptions);

    try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
      List<SuggestedReviewerInfo> reviewer =
          accountIds.stream()
              .map(accountLoader::get)
              .filter(Objects::nonNull)
              .map(
                  a -> {
                    SuggestedReviewerInfo info = new SuggestedReviewerInfo();
                    info.account = a;
                    info.count = 1;
                    return info;
                  })
              .collect(toList());
      accountLoader.fill();
      return reviewer;
    }
  }

  private List<SuggestedReviewerInfo> suggestAccountGroups(
      SuggestReviewers suggestReviewers,
      ProjectState projectState,
      VisibilityControl visibilityControl,
      int limit)
      throws IOException {
    try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
      List<SuggestedReviewerInfo> groups = new ArrayList<>();
      for (GroupReference g : suggestAccountGroups(suggestReviewers, projectState)) {
        GroupAsReviewer result =
            suggestGroupAsReviewer(
                suggestReviewers, projectState.getProject(), g, visibilityControl);
        if (result.allowed || result.allowedWithConfirmation) {
          GroupBaseInfo info = new GroupBaseInfo();
          info.id = Url.encode(g.getUUID().get());
          info.name = g.getName();
          SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
          suggestedReviewerInfo.group = info;
          suggestedReviewerInfo.count = result.size;
          if (result.allowedWithConfirmation) {
            suggestedReviewerInfo.confirm = true;
          }
          groups.add(suggestedReviewerInfo);
          if (groups.size() >= limit) {
            break;
          }
        }
      }
      return groups;
    }
  }

  private List<GroupReference> suggestAccountGroups(
      SuggestReviewers suggestReviewers, ProjectState projectState) {
    return groupBackend.suggest(suggestReviewers.getQuery(), projectState).stream()
        .limit(suggestReviewers.getLimit())
        .collect(toList());
  }

  private static class GroupAsReviewer {
    boolean allowed;
    boolean allowedWithConfirmation;
    int size;
  }

  private GroupAsReviewer suggestGroupAsReviewer(
      SuggestReviewers suggestReviewers,
      Project project,
      GroupReference group,
      VisibilityControl visibilityControl)
      throws IOException {
    GroupAsReviewer result = new GroupAsReviewer();
    int maxAllowed = suggestReviewers.getMaxAllowed();
    int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
    logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);

    if (!ReviewerAdder.isLegalReviewerGroup(group.getUUID())) {
      logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
      return result;
    }

    try {
      Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey());

      if (members.isEmpty()) {
        logger.atFine().log("Ignore group %s since it has no members", group.getUUID());
        return result;
      }

      result.size = members.size();
      if (maxAllowed > 0 && result.size > maxAllowed) {
        return result;
      }

      boolean needsConfirmation =
          maxAllowedWithoutConfirmation > 0 && result.size > maxAllowedWithoutConfirmation;
      if (needsConfirmation) {
        logger.atFine().log(
            "group %s needs confirmation to be added as reviewer, it has %d members",
            group.getUUID(), result.size);
      }

      // require that at least one member in the group can see the change
      for (Account account : members) {
        if (visibilityControl.isVisibleTo(account.id())) {
          if (needsConfirmation) {
            result.allowedWithConfirmation = true;
          } else {
            result.allowed = true;
          }
          logger.atFine().log("Suggest group %s", group.getUUID());
          return result;
        }
      }
      logger.atFine().log(
          "Ignore group %s since none of its members can see the change", group.getUUID());
    } catch (NoSuchProjectException e) {
      return result;
    }

    return result;
  }

  private static String formatSuggestedReviewers(List<SuggestedReviewerInfo> suggestedReviewers) {
    return suggestedReviewers.stream()
        .map(
            r -> {
              if (r.account != null) {
                return "a/" + r.account._accountId;
              } else if (r.group != null) {
                return "g/" + r.group.id;
              } else {
                return "";
              }
            })
        .collect(toList())
        .toString();
  }
}
