// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "components/ntp_snippets/remote/remote_suggestions_provider_impl.h"

#include <algorithm>
#include <iterator>
#include <utility>

#include "base/bind.h"
#include "base/command_line.h"
#include "base/location.h"
#include "base/metrics/field_trial_params.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/stl_util.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/stringprintf.h"
#include "base/threading/thread_task_runner_handle.h"
#include "base/time/default_clock.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "base/values.h"
#include "components/data_use_measurement/core/data_use_user_data.h"
#include "components/image_fetcher/core/image_fetcher.h"
#include "components/ntp_snippets/breaking_news/breaking_news_listener.h"
#include "components/ntp_snippets/category_rankers/category_ranker.h"
#include "components/ntp_snippets/features.h"
#include "components/ntp_snippets/pref_names.h"
#include "components/ntp_snippets/remote/remote_suggestions_database.h"
#include "components/ntp_snippets/remote/remote_suggestions_scheduler.h"
#include "components/ntp_snippets/remote/remote_suggestions_status_service_impl.h"
#include "components/ntp_snippets/switches.h"
#include "components/ntp_snippets/time_serialization.h"
#include "components/prefs/pref_registry_simple.h"
#include "components/prefs/pref_service.h"
#include "components/strings/grit/components_strings.h"
#include "components/variations/variations_associated_data.h"
#include "ui/gfx/image/image.h"

namespace ntp_snippets {

namespace {

// Maximal number of suggestions we expect to receive from the server during a
// normal (not fetch-more) fetch. Consider replacing sparse UMA histograms with
// COUNTS() if this number increases beyond 50.
// TODO(vitaliii): Either support requesting a given number of suggestions on
// the server or delete this constant (this will require moving the UMA
// reporting below to content_suggestions_metrics).
const int kMaxNormalFetchSuggestionCount = 20;

// Number of archived suggestions we keep around in memory.
const int kMaxArchivedSuggestionCount = 200;

// Maximal number of dismissed suggestions to exclude when fetching new
// suggestions from the server. This limit exists to decrease data usage.
const int kMaxExcludedDismissedIds = 100;

// Keys for storing CategoryContent info in prefs.
const char kCategoryContentId[] = "id";
const char kCategoryContentTitle[] = "title";
const char kCategoryContentProvidedByServer[] = "provided_by_server";
const char kCategoryContentAllowFetchingMore[] = "allow_fetching_more";

// Variation parameter for ordering new remote categories based on their
// position in the response relative to "Article for you" category.
const char kOrderNewRemoteCategoriesBasedOnArticlesCategory[] =
    "order_new_remote_categories_based_on_articles_category";

// Variation parameter for additional prefetched suggestions quantity. Not more
// than this number of prefetched suggestions will be kept longer.
const char kMaxAdditionalPrefetchedSuggestionsParamName[] =
    "max_additional_prefetched_suggestions";

const int kDefaultMaxAdditionalPrefetchedSuggestions = 5;

// Variation parameter for additional prefetched suggestions age. Only
// prefetched suggestions fetched not later than this are considered to be kept
// longer.
const char kMaxAgeForAdditionalPrefetchedSuggestionParamName[] =
    "max_age_for_additional_prefetched_suggestion_minutes";

const int kDefaultMaxAgeForAdditionalPrefetchedSuggestionMinutes = 36 * 60;

bool IsOrderingNewRemoteCategoriesBasedOnArticlesCategoryEnabled() {
  // TODO(vitaliii): Use GetFieldTrialParamByFeature(As.*)? from
  // base/metrics/field_trial_params.h. GetVariationParamByFeature(As.*)? are
  // deprecated.
  return variations::GetVariationParamByFeatureAsBool(
      kArticleSuggestionsFeature,
      kOrderNewRemoteCategoriesBasedOnArticlesCategory,
      /*default_value=*/false);
}

void AddFetchedCategoriesToRankerBasedOnArticlesCategory(
    CategoryRanker* ranker,
    const FetchedCategoriesVector& fetched_categories,
    Category articles_category) {
  DCHECK(IsOrderingNewRemoteCategoriesBasedOnArticlesCategoryEnabled());
  // Insert categories which precede "Articles" in the response.
  for (const FetchedCategory& fetched_category : fetched_categories) {
    if (fetched_category.category == articles_category) {
      break;
    }
    ranker->InsertCategoryBeforeIfNecessary(fetched_category.category,
                                            articles_category);
  }
  // Insert categories which follow "Articles" in the response. Note that we
  // insert them in reversed order, because they are inserted right after
  // "Articles", which reverses the order.
  for (auto fetched_category_it = fetched_categories.rbegin();
       fetched_category_it != fetched_categories.rend();
       ++fetched_category_it) {
    if (fetched_category_it->category == articles_category) {
      return;
    }
    ranker->InsertCategoryAfterIfNecessary(fetched_category_it->category,
                                           articles_category);
  }
  NOTREACHED() << "Articles category was not found.";
}

bool IsKeepingPrefetchedSuggestionsEnabled() {
  return base::FeatureList::IsEnabled(kKeepPrefetchedContentSuggestions);
}

int GetMaxAdditionalPrefetchedSuggestions() {
  return base::GetFieldTrialParamByFeatureAsInt(
      kKeepPrefetchedContentSuggestions,
      kMaxAdditionalPrefetchedSuggestionsParamName,
      kDefaultMaxAdditionalPrefetchedSuggestions);
}

base::TimeDelta GetMaxAgeForAdditionalPrefetchedSuggestion() {
  return base::TimeDelta::FromMinutes(base::GetFieldTrialParamByFeatureAsInt(
      kKeepPrefetchedContentSuggestions,
      kMaxAgeForAdditionalPrefetchedSuggestionParamName,
      kDefaultMaxAgeForAdditionalPrefetchedSuggestionMinutes));
}

// Whether notifications for fetched suggestions are enabled. Note that this
// param does not overwrite other switches which could disable these
// notifications.
const bool kEnableFetchedSuggestionsNotificationsDefault = true;
const char kEnableFetchedSuggestionsNotificationsParamName[] =
    "enable_fetched_suggestions_notifications";

bool IsFetchedSuggestionsNotificationsEnabled() {
  return base::GetFieldTrialParamByFeatureAsBool(
      kNotificationsFeature, kEnableFetchedSuggestionsNotificationsParamName,
      kEnableFetchedSuggestionsNotificationsDefault);
}

// Whether notifications for pushed (prepended) suggestions are enabled. Note
// that this param does not overwrite other switches which could disable these
// notifications.
const bool kEnablePushedSuggestionsNotificationsDefault = false;
const char kEnablePushedSuggestionsNotificationsParamName[] =
    "enable_pushed_suggestions_notifications";

bool IsPushedSuggestionsNotificationsEnabled() {
  return base::GetFieldTrialParamByFeatureAsBool(
      kNotificationsFeature, kEnablePushedSuggestionsNotificationsParamName,
      kEnablePushedSuggestionsNotificationsDefault);
}

// Whether signed-in users should be subscribed for pushed suggestions.
const bool kEnableSignedInUsersSubscriptionForPushedSuggestionsDefault = true;
const char kEnableSignedInUsersSubscriptionForPushedSuggestionsParamName[] =
    "enable_signed_in_users_subscription_for_pushed_suggestions";

bool IsSignedInUsersSubscriptionForPushedSuggestionsEnabled() {
  return base::GetFieldTrialParamByFeatureAsBool(
      kBreakingNewsPushFeature,
      kEnableSignedInUsersSubscriptionForPushedSuggestionsParamName,
      kEnableSignedInUsersSubscriptionForPushedSuggestionsDefault);
}

// Whether signed-out users should be subscribed for pushed suggestions.
const bool kEnableSignedOutUsersSubscriptionForPushedSuggestionsDefault = false;
const char kEnableSignedOutUsersSubscriptionForPushedSuggestionsParamName[] =
    "enable_signed_out_users_subscription_for_pushed_suggestions";

bool IsSignedOutUsersSubscriptionForPushedSuggestionsEnabled() {
  return base::GetFieldTrialParamByFeatureAsBool(
      kBreakingNewsPushFeature,
      kEnableSignedOutUsersSubscriptionForPushedSuggestionsParamName,
      kEnableSignedOutUsersSubscriptionForPushedSuggestionsDefault);
}

// Whether notification info is overriden for fetched suggestions. Note that
// this param does not overwrite other switches which could disable these
// notifications.
const bool kForceFetchedSuggestionsNotificationsDefault = false;
const char kForceFetchedSuggestionsNotificationsParamName[] =
    "force_fetched_suggestions_notifications";

bool ShouldForceFetchedSuggestionsNotifications() {
  return base::GetFieldTrialParamByFeatureAsBool(
      kNotificationsFeature, kForceFetchedSuggestionsNotificationsParamName,
      kForceFetchedSuggestionsNotificationsDefault);
}

// Variation parameter for number of suggestions to request when fetching more.
const char kFetchMoreSuggestionsCountParamName[] =
    "fetch_more_suggestions_count";
const int kFetchMoreSuggestionsCountDefault = 25;

int GetFetchMoreSuggestionsCount() {
  return base::GetFieldTrialParamByFeatureAsInt(
      kArticleSuggestionsFeature, kFetchMoreSuggestionsCountParamName,
      kFetchMoreSuggestionsCountDefault);
}

// Variation parameter for the timeout when fetching suggestions with a loading
// indicator. If the fetch takes too long and the timeout is over, the category
// status is forced back to AVAILABLE. If there are existing (possibly stale)
// suggestions, they get notified.
// This timeout is not used for fetching more as the signature of Fetch()
// provides no way to deliver results later after the timeout.
const char kTimeoutForLoadingIndicatorSecondsParamName[] =
    "timeout_for_loading_indicator_seconds";

const int kDefaultTimeoutForLoadingIndicatorSeconds = 5;

base::TimeDelta GetTimeoutForLoadingIndicator() {
  return base::TimeDelta::FromSeconds(base::GetFieldTrialParamByFeatureAsInt(
      ntp_snippets::kArticleSuggestionsFeature,
      kTimeoutForLoadingIndicatorSecondsParamName,
      kDefaultTimeoutForLoadingIndicatorSeconds));
}

template <typename SuggestionPtrContainer>
std::unique_ptr<std::vector<std::string>> GetSuggestionIDVector(
    const SuggestionPtrContainer& suggestions) {
  auto result = std::make_unique<std::vector<std::string>>();
  for (const auto& suggestion : suggestions) {
    result->push_back(suggestion->id());
  }
  return result;
}

bool HasIntersection(const std::vector<std::string>& a,
                     const std::set<std::string>& b) {
  for (const std::string& item : a) {
    if (b.count(item)) {
      return true;
    }
  }
  return false;
}

void EraseByPrimaryID(RemoteSuggestion::PtrVector* suggestions,
                      const std::vector<std::string>& ids) {
  std::set<std::string> ids_lookup(ids.begin(), ids.end());
  base::EraseIf(
      *suggestions,
      [&ids_lookup](const std::unique_ptr<RemoteSuggestion>& suggestion) {
        return ids_lookup.count(suggestion->id());
      });
}

void EraseMatchingSuggestions(
    RemoteSuggestion::PtrVector* suggestions,
    const RemoteSuggestion::PtrVector& compare_against) {
  std::set<std::string> compare_against_ids;
  for (const std::unique_ptr<RemoteSuggestion>& suggestion : compare_against) {
    const std::vector<std::string>& suggestion_ids = suggestion->GetAllIDs();
    compare_against_ids.insert(suggestion_ids.begin(), suggestion_ids.end());
  }
  base::EraseIf(
      *suggestions, [&compare_against_ids](
                        const std::unique_ptr<RemoteSuggestion>& suggestion) {
        return HasIntersection(suggestion->GetAllIDs(), compare_against_ids);
      });
}

void RemoveNullPointers(RemoteSuggestion::PtrVector* suggestions) {
  base::EraseIf(*suggestions,
                [](const std::unique_ptr<RemoteSuggestion>& suggestion) {
                  return !suggestion;
                });
}

void RemoveIncompleteSuggestions(RemoteSuggestion::PtrVector* suggestions) {
  if (base::CommandLine::ForCurrentProcess()->HasSwitch(
          switches::kAddIncompleteSnippets)) {
    return;
  }
  int num_suggestions = suggestions->size();
  // Remove suggestions that do not have all the info we need to display it to
  // the user.
  base::EraseIf(*suggestions,
                [](const std::unique_ptr<RemoteSuggestion>& suggestion) {
                  return !suggestion->is_complete();
                });
  int num_suggestions_removed = num_suggestions - suggestions->size();
  UMA_HISTOGRAM_BOOLEAN("NewTabPage.Snippets.IncompleteSnippetsAfterFetch",
                        num_suggestions_removed > 0);
  if (num_suggestions_removed > 0) {
    base::UmaHistogramSparse("NewTabPage.Snippets.NumIncompleteSnippets",
                             num_suggestions_removed);
  }
}

std::vector<ContentSuggestion> ConvertToContentSuggestions(
    Category category,
    const RemoteSuggestion::PtrVector& suggestions) {
  std::vector<ContentSuggestion> result;
  for (const std::unique_ptr<RemoteSuggestion>& suggestion : suggestions) {
    // TODO(sfiera): if a suggestion is not going to be displayed, move it
    // directly to content.dismissed on fetch. Otherwise, we might prune
    // other suggestions to get down to kMaxSuggestionCount, only to hide one of
    // the incomplete ones we kept.
    if (!suggestion->is_complete()) {
      continue;
    }
    result.emplace_back(suggestion->ToContentSuggestion(category));
  }
  return result;
}

void CallWithEmptyResults(FetchDoneCallback callback, const Status& status) {
  if (callback.is_null()) {
    return;
  }
  std::move(callback).Run(status, std::vector<ContentSuggestion>());
}

void AddDismissedIdsToRequest(const RemoteSuggestion::PtrVector& dismissed,
                              RequestParams* request_params) {
  // The latest ids are added first, because they are more relevant.
  for (auto it = dismissed.rbegin(); it != dismissed.rend(); ++it) {
    if (request_params->excluded_ids.size() == kMaxExcludedDismissedIds) {
      break;
    }
    request_params->excluded_ids.insert((*it)->id());
  }
}

void AddDismissedArchivedIdsToRequest(
    const base::circular_deque<std::unique_ptr<RemoteSuggestion>>& archived,
    RequestParams* request_params) {
  // We add all archived, dismissed IDs to the request (the archive is limited
  // to kMaxArchivedSuggestionCount suggestions). They don't get persisted,
  // which means that the user very recently dismissed them and that they are
  // usually not many.
  for (auto it = archived.begin(); it != archived.end(); ++it) {
    const RemoteSuggestion& suggestion = **it;
    if (suggestion.is_dismissed()) {
      request_params->excluded_ids.insert(suggestion.id());
    }
  }
}

}  // namespace

RemoteSuggestionsProviderImpl::RemoteSuggestionsProviderImpl(
    Observer* observer,
    PrefService* pref_service,
    const std::string& application_language_code,
    CategoryRanker* category_ranker,
    RemoteSuggestionsScheduler* scheduler,
    std::unique_ptr<RemoteSuggestionsFetcher> suggestions_fetcher,
    std::unique_ptr<image_fetcher::ImageFetcher> image_fetcher,
    std::unique_ptr<RemoteSuggestionsDatabase> database,
    std::unique_ptr<RemoteSuggestionsStatusService> status_service,
    std::unique_ptr<PrefetchedPagesTracker> prefetched_pages_tracker,
    std::unique_ptr<BreakingNewsListener> breaking_news_raw_data_provider,
    Logger* debug_logger,
    std::unique_ptr<base::OneShotTimer> fetch_timeout_timer)
    : RemoteSuggestionsProvider(observer),
      state_(State::NOT_INITED),
      pref_service_(pref_service),
      articles_category_(
          Category::FromKnownCategory(KnownCategories::ARTICLES)),
      application_language_code_(application_language_code),
      category_ranker_(category_ranker),
      remote_suggestions_scheduler_(scheduler),
      suggestions_fetcher_(std::move(suggestions_fetcher)),
      database_(std::move(database)),
      image_fetcher_(std::move(image_fetcher), pref_service, database_.get()),
      status_service_(std::move(status_service)),
      clear_history_dependent_state_when_initialized_(false),
      clear_cached_suggestions_when_initialized_(false),
      clock_(base::DefaultClock::GetInstance()),
      prefetched_pages_tracker_(std::move(prefetched_pages_tracker)),
      breaking_news_raw_data_provider_(
          std::move(breaking_news_raw_data_provider)),
      debug_logger_(debug_logger),
      fetch_timeout_timer_(std::move(fetch_timeout_timer)),
      request_status_(FetchRequestStatus::NONE) {
  DCHECK(debug_logger_);
  DCHECK(fetch_timeout_timer_);
  RestoreCategoriesFromPrefs();
  // The articles category always exists. Add it if we didn't get it from prefs.
  // TODO(treib): Rethink this.
  category_contents_.insert(
      std::make_pair(articles_category_,
                     CategoryContent(BuildArticleCategoryInfo(base::nullopt))));
  // Tell the observer about all the categories.
  for (const auto& entry : category_contents_) {
    observer->OnCategoryStatusChanged(this, entry.first, entry.second.status);
  }

  if (database_->IsErrorState()) {
    EnterState(State::ERROR_OCCURRED);
    UpdateAllCategoryStatus(CategoryStatus::LOADING_ERROR);
    return;
  }

  database_->SetErrorCallback(base::Bind(
      &RemoteSuggestionsProviderImpl::OnDatabaseError, base::Unretained(this)));

  // We transition to other states while finalizing the initialization, when the
  // database is done loading.
  database_load_start_ = base::TimeTicks::Now();
  database_->LoadSnippets(
      base::BindOnce(&RemoteSuggestionsProviderImpl::OnDatabaseLoaded,
                     base::Unretained(this)));
}

RemoteSuggestionsProviderImpl::~RemoteSuggestionsProviderImpl() {
  if (breaking_news_raw_data_provider_ &&
      breaking_news_raw_data_provider_->IsListening()) {
    breaking_news_raw_data_provider_->StopListening();
  }
}

// static
void RemoteSuggestionsProviderImpl::RegisterProfilePrefs(
    PrefRegistrySimple* registry) {
  registry->RegisterListPref(prefs::kRemoteSuggestionCategories);
  registry->RegisterInt64Pref(prefs::kLastSuccessfulBackgroundFetchTime, 0);

  RemoteSuggestionsStatusServiceImpl::RegisterProfilePrefs(registry);
}

void RemoteSuggestionsProviderImpl::ReloadSuggestions() {
  if (!remote_suggestions_scheduler_->AcquireQuotaForInteractiveFetch()) {
    return;
  }
  auto callback = base::BindOnce(
      [](RemoteSuggestionsScheduler* scheduler, Status status_code) {
        scheduler->OnInteractiveFetchFinished(status_code);
      },
      base::Unretained(remote_suggestions_scheduler_));

  if (AreArticlesEmpty()) {
    // No reason to use a timeout to hide the loading indicator before the fetch
    // definitely fails as we have nothing else to display.
    FetchSuggestionsWithLoadingIndicator(
        /*interactive_request=*/true, std::move(callback),
        /*enable_loading_indication_timeout=*/false);
    return;
  }
  FetchSuggestions(
      /*interactive_request=*/true, std::move(callback));
}

void RemoteSuggestionsProviderImpl::RefetchInTheBackground(
    FetchStatusCallback callback) {
  if (AreArticlesEmpty()) {
    // We want to have a loading indicator even for a background fetch as the
    // user might open an NTP before the fetch finishes.
    // No reason to use a timeout to hide the loading indicator before the fetch
    // definitely fails as we have nothing else to display.
    FetchSuggestionsWithLoadingIndicator(
        /*interactive_request=*/false, std::move(callback),
        /*enable_loading_indication_timeout=*/false);
    return;
  }
  FetchSuggestions(/*interactive_request=*/false, std::move(callback));
}

void RemoteSuggestionsProviderImpl::RefetchWhileDisplaying(
    FetchStatusCallback callback) {
  // We also have existing suggestions in place, so we want to use a timeout
  // after which we fall back to the existing suggestions. This way, we do not
  // annoy users by too much of waiting on poor connection.
  FetchSuggestionsWithLoadingIndicator(
      /*interactive_request=*/true, std::move(callback),
      /*enable_loading_indication_timeout=*/true);
}

const RemoteSuggestionsFetcher*
RemoteSuggestionsProviderImpl::suggestions_fetcher_for_debugging() const {
  return suggestions_fetcher_.get();
}

GURL RemoteSuggestionsProviderImpl::GetUrlWithFavicon(
    const ContentSuggestion::ID& suggestion_id) const {
  DCHECK(base::ContainsKey(category_contents_, suggestion_id.category()));

  const CategoryContent& content =
      category_contents_.at(suggestion_id.category());
  const RemoteSuggestion* suggestion =
      content.FindSuggestion(suggestion_id.id_within_category());
  if (!suggestion) {
    return GURL();
  }
  return ContentSuggestion::GetFaviconDomain(suggestion->url());
}

bool RemoteSuggestionsProviderImpl::IsDisabled() const {
  return state_ == State::DISABLED;
}

bool RemoteSuggestionsProviderImpl::ready() const {
  return state_ == State::READY;
}

void RemoteSuggestionsProviderImpl::FetchSuggestionsWithLoadingIndicator(
    bool interactive_request,
    FetchStatusCallback callback,
    bool enable_loading_indication_timeout) {
  if (!AreArticlesAvailable()) {
    // If the article section is not AVAILABLE, we cannot safely flip its status
    // to AVAILABLE_LOADING and back. Instead, we fallback to the standard
    // background refetch.
    debug_logger_->Log(
        FROM_HERE,
        "fallback because the articles category is not displayed yet");
    FetchSuggestions(interactive_request, std::move(callback));
    return;
  }

  NotifyFetchWithLoadingIndicatorStarted();

  if (enable_loading_indication_timeout) {
    // |fetch_timeout_timer_| makes sure the UI stops waiting after a certain
    // time period (the UI also falls back to previous suggestions, if there are
    // any).
    fetch_timeout_timer_->Start(
        FROM_HERE, GetTimeoutForLoadingIndicator(),
        base::BindOnce(&RemoteSuggestionsProviderImpl::
                           NotifyFetchWithLoadingIndicatorFailedOrTimeouted,
                       base::Unretained(this)));
  }

  FetchStatusCallback callback_wrapped =
      base::BindOnce(&RemoteSuggestionsProviderImpl::
                         OnFetchSuggestionsWithLoadingIndicatorFinished,
                     base::Unretained(this), std::move(callback));

  FetchSuggestions(interactive_request, std::move(callback_wrapped));
}

void RemoteSuggestionsProviderImpl::
    OnFetchSuggestionsWithLoadingIndicatorFinished(FetchStatusCallback callback,
                                                   Status status) {
  fetch_timeout_timer_->Stop();
  // If the fetch succeeds, it already notified new results.
  if (!status.IsSuccess()) {
    NotifyFetchWithLoadingIndicatorFailedOrTimeouted();
  }
  if (callback) {
    std::move(callback).Run(status);
  }
}

void RemoteSuggestionsProviderImpl::FetchSuggestions(
    bool interactive_request,
    FetchStatusCallback callback) {
  debug_logger_->Log(FROM_HERE, /*message=*/std::string());
  if (!ready()) {
    if (callback) {
      std::move(callback).Run(
          Status(StatusCode::TEMPORARY_ERROR,
                 "RemoteSuggestionsProvider is not ready!"));
    }
    return;
  }
  if (request_status_ == FetchRequestStatus::NONE) {
    // We cannot rule out concurrent requests although they are rare as the user
    // has to trigger ReloadSuggestions() while the scheduler decides for a
    // background fetch. Although preventing concurrent fetches would be
    // desireable, it's not worth the effort (also see TODO() in
    // OnFetchFinished()).
    request_status_ = FetchRequestStatus::IN_PROGRESS;
  }

  // |count_to_fetch| is actually ignored, because the server does not support
  // this functionality.
  RequestParams params = BuildFetchParams(/*fetched_category=*/base::nullopt,
                                          /*count_to_fetch=*/10);
  params.interactive_request = interactive_request;
  suggestions_fetcher_->FetchSnippets(
      params, base::BindOnce(&RemoteSuggestionsProviderImpl::OnFetchFinished,
                             base::Unretained(this), std::move(callback),
                             interactive_request));
}

void RemoteSuggestionsProviderImpl::Fetch(
    const Category& category,
    const std::set<std::string>& known_suggestion_ids,
    FetchDoneCallback callback) {
  debug_logger_->Log(FROM_HERE, /*message=*/std::string());

  if (!ready()) {
    CallWithEmptyResults(std::move(callback),
                         Status(StatusCode::TEMPORARY_ERROR,
                                "RemoteSuggestionsProvider is not ready!"));
    return;
  }
  if (!remote_suggestions_scheduler_->AcquireQuotaForInteractiveFetch()) {
    CallWithEmptyResults(
        std::move(callback),
        Status(StatusCode::TEMPORARY_ERROR, "Interactive quota exceeded!"));
    return;
  }
  // Make sure after the fetch, the scheduler is informed about the status.
  FetchDoneCallback callback_wrapper = base::BindOnce(
      [](RemoteSuggestionsScheduler* scheduler, FetchDoneCallback callback,
         Status status_code, std::vector<ContentSuggestion> suggestions) {
        scheduler->OnInteractiveFetchFinished(status_code);
        std::move(callback).Run(status_code, std::move(suggestions));
      },
      base::Unretained(remote_suggestions_scheduler_), std::move(callback));

  RequestParams params = BuildFetchParams(
      category, /*count_to_fetch=*/GetFetchMoreSuggestionsCount());
  params.excluded_ids.insert(known_suggestion_ids.begin(),
                             known_suggestion_ids.end());
  params.interactive_request = true;
  params.exclusive_category = category;

  suggestions_fetcher_->FetchSnippets(
      params,
      base::BindOnce(&RemoteSuggestionsProviderImpl::OnFetchMoreFinished,
                     base::Unretained(this), std::move(callback_wrapper)));
}

// Builds default fetcher params.
RequestParams RemoteSuggestionsProviderImpl::BuildFetchParams(
    base::Optional<Category> fetched_category,
    int count_to_fetch) const {
  RequestParams result;
  result.language_code = application_language_code_;
  result.count_to_fetch = count_to_fetch;
  // If this is a fetch for a specific category, its dismissed suggestions are
  // added first to truncate them less.
  if (fetched_category.has_value()) {
    DCHECK(category_contents_.count(*fetched_category));
    const CategoryContent& content =
        category_contents_.find(*fetched_category)->second;
    AddDismissedIdsToRequest(content.dismissed, &result);
    AddDismissedArchivedIdsToRequest(content.archived, &result);
  }
  for (const auto& map_entry : category_contents_) {
    if (fetched_category.has_value() && map_entry.first == *fetched_category) {
      continue;
    }
    AddDismissedIdsToRequest(map_entry.second.dismissed, &result);
    AddDismissedArchivedIdsToRequest(map_entry.second.archived, &result);
  }
  return result;
}

bool RemoteSuggestionsProviderImpl::AreArticlesEmpty() const {
  if (!ready()) {
    return false;
  }
  auto articles_it = category_contents_.find(articles_category_);
  DCHECK(articles_it != category_contents_.end());
  const CategoryContent& content = articles_it->second;
  return content.suggestions.empty();
}

bool RemoteSuggestionsProviderImpl::AreArticlesAvailable() const {
  if (!ready()) {
    return false;
  }
  auto articles_it = category_contents_.find(articles_category_);
  DCHECK(articles_it != category_contents_.end());
  const CategoryContent& content = articles_it->second;
  return content.status == CategoryStatus::AVAILABLE;
}

void RemoteSuggestionsProviderImpl::NotifyFetchWithLoadingIndicatorStarted() {
  auto articles_it = category_contents_.find(articles_category_);
  DCHECK(articles_it != category_contents_.end());
  CategoryContent& content = articles_it->second;
  DCHECK_EQ(content.status, CategoryStatus::AVAILABLE);
  UpdateCategoryStatus(articles_it->first, CategoryStatus::AVAILABLE_LOADING);
}

void RemoteSuggestionsProviderImpl::
    NotifyFetchWithLoadingIndicatorFailedOrTimeouted() {
  auto articles_it = category_contents_.find(articles_category_);
  DCHECK(articles_it != category_contents_.end());
  CategoryContent& content = articles_it->second;
  DCHECK(content.status == CategoryStatus::AVAILABLE_LOADING ||
         content.status == CategoryStatus::CATEGORY_EXPLICITLY_DISABLED);
  UpdateCategoryStatus(articles_it->first, CategoryStatus::AVAILABLE);
  // TODO(jkrcal): Technically, we have no new suggestions; we should not
  // notify. This is a work-around before crbug.com/768410 gets fixed.
  NotifyNewSuggestions(articles_category_, content.suggestions);
}

CategoryStatus RemoteSuggestionsProviderImpl::GetCategoryStatus(
    Category category) {
  auto content_it = category_contents_.find(category);
  DCHECK(content_it != category_contents_.end());
  return content_it->second.status;
}

CategoryInfo RemoteSuggestionsProviderImpl::GetCategoryInfo(Category category) {
  auto content_it = category_contents_.find(category);
  DCHECK(content_it != category_contents_.end());
  return content_it->second.info;
}

void RemoteSuggestionsProviderImpl::DismissSuggestion(
    const ContentSuggestion::ID& suggestion_id) {
  if (!ready()) {
    return;
  }

  auto content_it = category_contents_.find(suggestion_id.category());
  DCHECK(content_it != category_contents_.end());
  CategoryContent* content = &content_it->second;
  DismissSuggestionFromCategoryContent(content,
                                       suggestion_id.id_within_category());
}

void RemoteSuggestionsProviderImpl::ClearHistory(
    base::Time begin,
    base::Time end,
    const base::Callback<bool(const GURL& url)>& filter) {
  // Both time range and the filter are ignored and all suggestions are removed,
  // because it is not known which history entries were used for the suggestions
  // personalization.
  ClearHistoryDependentState();
  if (request_status_ == FetchRequestStatus::IN_PROGRESS ||
      request_status_ == FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH) {
    request_status_ = FetchRequestStatus::IN_PROGRESS_CANCELED;
  }
}

void RemoteSuggestionsProviderImpl::ClearCachedSuggestions() {
  ClearCachedSuggestionsImpl();
  if (request_status_ == FetchRequestStatus::IN_PROGRESS) {
    // Called by external cache-cleared trigger. As this can be caused by
    // language change, we need to refetch a potentiall ongoing fetch.
    request_status_ = FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH;
  }
}

void RemoteSuggestionsProviderImpl::OnSignInStateChanged(bool has_signed_in) {
  // Make sure the status service is registered and we already initialised its
  // start state.
  if (!initialized()) {
    return;
  }

  status_service_->OnSignInStateChanged(has_signed_in);
}

void RemoteSuggestionsProviderImpl::GetDismissedSuggestionsForDebugging(
    Category category,
    DismissedSuggestionsCallback callback) {
  auto content_it = category_contents_.find(category);
  DCHECK(content_it != category_contents_.end());
  std::move(callback).Run(
      ConvertToContentSuggestions(category, content_it->second.dismissed));
}

void RemoteSuggestionsProviderImpl::ClearDismissedSuggestionsForDebugging(
    Category category) {
  auto content_it = category_contents_.find(category);
  DCHECK(content_it != category_contents_.end());
  CategoryContent* content = &content_it->second;

  if (!initialized()) {
    return;
  }

  if (!content->dismissed.empty()) {
    database_->DeleteSnippets(GetSuggestionIDVector(content->dismissed));
    // The image got already deleted when the suggestion was dismissed.
    content->dismissed.clear();
  }

  // Update the archive.
  for (const auto& suggestion : content->archived) {
    suggestion->set_dismissed(false);
  }
}

// static
int RemoteSuggestionsProviderImpl::
    GetMaxNormalFetchSuggestionCountForTesting() {
  return kMaxNormalFetchSuggestionCount;
}

////////////////////////////////////////////////////////////////////////////////
// Private methods

GURL RemoteSuggestionsProviderImpl::FindSuggestionImageUrl(
    const ContentSuggestion::ID& suggestion_id) const {
  DCHECK(base::ContainsKey(category_contents_, suggestion_id.category()));

  const CategoryContent& content =
      category_contents_.at(suggestion_id.category());
  const RemoteSuggestion* suggestion =
      content.FindSuggestion(suggestion_id.id_within_category());
  if (!suggestion) {
    return GURL();
  }
  return suggestion->salient_image_url();
}

void RemoteSuggestionsProviderImpl::OnDatabaseLoaded(
    RemoteSuggestion::PtrVector suggestions) {
  if (state_ == State::ERROR_OCCURRED) {
    return;
  }
  DCHECK(state_ == State::NOT_INITED);
  DCHECK(base::ContainsKey(category_contents_, articles_category_));

  base::TimeDelta database_load_time =
      base::TimeTicks::Now() - database_load_start_;
  UMA_HISTOGRAM_MEDIUM_TIMES("NewTabPage.Snippets.DatabaseLoadTime",
                             database_load_time);

  RemoteSuggestion::PtrVector to_delete;
  for (std::unique_ptr<RemoteSuggestion>& suggestion : suggestions) {
    Category suggestion_category =
        Category::FromRemoteCategory(suggestion->remote_category_id());
    auto content_it = category_contents_.find(suggestion_category);
    // We should already know about the category.
    if (content_it == category_contents_.end()) {
      DLOG(WARNING) << "Loaded a suggestion for unknown category "
                    << suggestion_category << " from the DB; deleting";
      to_delete.emplace_back(std::move(suggestion));
      continue;
    }
    CategoryContent* content = &content_it->second;
    if (suggestion->is_dismissed()) {
      content->dismissed.emplace_back(std::move(suggestion));
    } else {
      content->suggestions.emplace_back(std::move(suggestion));
    }
  }
  if (!to_delete.empty()) {
    database_->DeleteSnippets(GetSuggestionIDVector(to_delete));
    database_->DeleteImages(GetSuggestionIDVector(to_delete));
  }

  // Sort the suggestions in each category.
  for (auto& entry : category_contents_) {
    CategoryContent* content = &entry.second;
    std::sort(content->suggestions.begin(), content->suggestions.end(),
              [](const std::unique_ptr<RemoteSuggestion>& lhs,
                 const std::unique_ptr<RemoteSuggestion>& rhs) {
                if (lhs->rank() != rhs->rank()) {
                  return lhs->rank() < rhs->rank();
                }
                // Suggestion created before the rank was introduced have rank
                // equal to INT_MAX by default. Sort them by score.
                // TODO(vitaliii): Remove this fallback (and its test) in M64.
                return lhs->score() > rhs->score();
              });
  }

  // TODO(tschumann): If I move ClearExpiredDismissedSuggestions() to the
  // beginning of the function, it essentially does nothing but tests are still
  // green. Fix this!
  ClearExpiredDismissedSuggestions();
  ClearOrphanedImages();
  FinishInitialization();
}

void RemoteSuggestionsProviderImpl::OnDatabaseError() {
  EnterState(State::ERROR_OCCURRED);
  UpdateAllCategoryStatus(CategoryStatus::LOADING_ERROR);
}

void RemoteSuggestionsProviderImpl::OnFetchMoreFinished(
    FetchDoneCallback fetching_callback,
    Status status,
    RemoteSuggestionsFetcher::OptionalFetchedCategories fetched_categories) {
  if (!fetched_categories) {
    DCHECK(!status.IsSuccess());
    CallWithEmptyResults(std::move(fetching_callback), status);
    return;
  }
  if (fetched_categories->size() != 1u) {
    LOG(DFATAL) << "Requested one exclusive category but received "
                << fetched_categories->size() << " categories.";
    CallWithEmptyResults(std::move(fetching_callback),
                         Status(StatusCode::PERMANENT_ERROR,
                                "RemoteSuggestionsProvider received more "
                                "categories than requested."));
    return;
  }
  auto& fetched_category = (*fetched_categories)[0];
  Category category = fetched_category.category;
  CategoryContent* existing_content =
      UpdateCategoryInfo(category, fetched_category.info);
  SanitizeReceivedSuggestions(existing_content->dismissed,
                              &fetched_category.suggestions);
  std::vector<ContentSuggestion> result =
      ConvertToContentSuggestions(category, fetched_category.suggestions);
  // Store the additional suggestions into the archive to be able to fetch
  // images and favicons for them. Note that ArchiveSuggestions clears
  // |fetched_category.suggestions|.
  ArchiveSuggestions(existing_content, &fetched_category.suggestions);

  std::move(fetching_callback).Run(Status::Success(), std::move(result));
}

void RemoteSuggestionsProviderImpl::OnFetchFinished(
    FetchStatusCallback callback,
    bool interactive_request,
    Status status,
    RemoteSuggestionsFetcher::OptionalFetchedCategories fetched_categories) {
  debug_logger_->Log(FROM_HERE, /*message=*/std::string());

  FetchRequestStatus request_status = request_status_;
  // TODO(jkrcal): This is potentially incorrect if there is another concurrent
  // request in progress; when it finishes we will treat it as a standard
  // request even though it may need to be refetched/disregarded. Even though
  // the scheduler never triggers two concurrent requests, the user can trigger
  // the second request via the UI. If cache/history gets cleared before neither
  // of the two finishes, we can get outdated results afterwards. Low chance &
  // low risk, feels safe to ignore.
  request_status_ = FetchRequestStatus::NONE;

  if (!ready()) {
    // TODO(tschumann): What happens if this was a user-triggered, interactive
    // request? Is the UI waiting indefinitely now?
    return;
  }

  if (request_status == FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH) {
    // Disregard the results and start a fetch again.
    FetchSuggestions(interactive_request, std::move(callback));
    return;
  } else if (request_status == FetchRequestStatus::IN_PROGRESS_CANCELED) {
    // Disregard the results.
    return;
  }

  if (!status.IsSuccess()) {
    if (callback) {
      std::move(callback).Run(status);
    }
    return;
  }

  if (IsKeepingPrefetchedSuggestionsEnabled() && prefetched_pages_tracker_ &&
      !prefetched_pages_tracker_->IsInitialized()) {
    // Wait until the tracker is initialized.
    prefetched_pages_tracker_->Initialize(base::BindOnce(
        &RemoteSuggestionsProviderImpl::OnFetchFinished, base::Unretained(this),
        std::move(callback), interactive_request, status,
        std::move(fetched_categories)));
    return;
  }

  if (fetched_categories) {
    for (FetchedCategory& fetched_category : *fetched_categories) {
      for (std::unique_ptr<RemoteSuggestion>& suggestion :
           fetched_category.suggestions) {
        if (ShouldForceFetchedSuggestionsNotifications() &&
            IsFetchedSuggestionsNotificationsEnabled()) {
          suggestion->set_should_notify(true);
          suggestion->set_notification_deadline(clock_->Now() +
                                                base::TimeDelta::FromDays(7));
        }
        if (!IsFetchedSuggestionsNotificationsEnabled()) {
          suggestion->set_should_notify(false);
        }
      }
    }
  }

  // Record the fetch time of a successfull background fetch.
  if (!interactive_request) {
    pref_service_->SetInt64(prefs::kLastSuccessfulBackgroundFetchTime,
                            SerializeTime(clock_->Now()));
  }

  // Mark all categories as not provided by the server in the latest fetch. The
  // ones we got will be marked again below.
  for (auto& item : category_contents_) {
    CategoryContent* content = &item.second;
    content->included_in_last_server_response = false;
  }

  // Clear up expired dismissed suggestions before we use them to filter new
  // ones.
  ClearExpiredDismissedSuggestions();

  // If suggestions were fetched successfully, update our |category_contents_|
  // from each category provided by the server.
  if (fetched_categories) {
    if (Logger::IsLoggingEnabled()) {
      debug_logger_->Log(
          FROM_HERE,
          base::StringPrintf("fetched categories count = %d",
                             static_cast<int>(fetched_categories->size())));
    }

    // TODO(treib): Reorder |category_contents_| to match the order we received
    // from the server. crbug.com/653816
    bool response_includes_article_category = false;
    for (FetchedCategory& fetched_category : *fetched_categories) {
      if (fetched_category.category == articles_category_) {
        base::UmaHistogramSparse(
            "NewTabPage.Snippets.NumArticlesFetched",
            std::min(fetched_category.suggestions.size(),
                     static_cast<size_t>(kMaxNormalFetchSuggestionCount)));
        response_includes_article_category = true;

        if (Logger::IsLoggingEnabled()) {
          debug_logger_->Log(
              FROM_HERE,
              base::StringPrintf(
                  "articles category size = %d",
                  static_cast<int>(fetched_category.suggestions.size())));
        }
      }

      CategoryContent* content =
          UpdateCategoryInfo(fetched_category.category, fetched_category.info);
      content->included_in_last_server_response = true;
      SanitizeReceivedSuggestions(content->dismissed,
                                  &fetched_category.suggestions);
      IntegrateSuggestions(fetched_category.category, content,
                           std::move(fetched_category.suggestions));
    }

    // Add new remote categories to the ranker.
    if (IsOrderingNewRemoteCategoriesBasedOnArticlesCategoryEnabled() &&
        response_includes_article_category) {
      AddFetchedCategoriesToRankerBasedOnArticlesCategory(
          category_ranker_, *fetched_categories, articles_category_);
    } else {
      for (const FetchedCategory& fetched_category : *fetched_categories) {
        category_ranker_->AppendCategoryIfNecessary(fetched_category.category);
      }
    }

    // Delete categories not present in this fetch.
    std::vector<Category> categories_to_delete;
    for (auto& item : category_contents_) {
      Category category = item.first;
      CategoryContent* content = &item.second;
      if (!content->included_in_last_server_response &&
          category != articles_category_) {
        categories_to_delete.push_back(category);
      }
    }
    DeleteCategories(categories_to_delete);
  }

  // We might have gotten new categories (or updated the titles of existing
  // ones), so update the pref.
  StoreCategoriesToPrefs();

  for (auto& item : category_contents_) {
    Category category = item.first;
    UpdateCategoryStatus(category, CategoryStatus::AVAILABLE);
    // TODO(sfiera): notify only when a category changed above.
    NotifyNewSuggestions(category, item.second.suggestions);

    // The suggestions may be reused (e.g. when prepending an article), avoid
    // trigering notifications for the second time.
    for (std::unique_ptr<RemoteSuggestion>& suggestion :
         item.second.suggestions) {
      suggestion->set_should_notify(false);
    }
  }

  // TODO(sfiera): equivalent metrics for non-articles.
  auto content_it = category_contents_.find(articles_category_);
  DCHECK(content_it != category_contents_.end());
  const CategoryContent& content = content_it->second;
  base::UmaHistogramSparse("NewTabPage.Snippets.NumArticles",
                           content.suggestions.size());
  if (content.suggestions.empty() && !content.dismissed.empty()) {
    UMA_HISTOGRAM_COUNTS_1M("NewTabPage.Snippets.NumArticlesZeroDueToDiscarded",
                            content.dismissed.size());
  }

  if (callback) {
    std::move(callback).Run(status);
  }
}

void RemoteSuggestionsProviderImpl::ArchiveSuggestions(
    CategoryContent* content,
    RemoteSuggestion::PtrVector* to_archive) {
  // Archive previous suggestions - move them at the beginning of the list.
  content->archived.insert(content->archived.begin(),
                           std::make_move_iterator(to_archive->begin()),
                           std::make_move_iterator(to_archive->end()));
  to_archive->clear();

  // If there are more archived suggestions than we want to keep, delete the
  // oldest ones by their fetch time (which are always in the back).
  if (content->archived.size() > kMaxArchivedSuggestionCount) {
    RemoteSuggestion::PtrVector to_delete(
        std::make_move_iterator(content->archived.begin() +
                                kMaxArchivedSuggestionCount),
        std::make_move_iterator(content->archived.end()));
    content->archived.resize(kMaxArchivedSuggestionCount);
    database_->DeleteImages(GetSuggestionIDVector(to_delete));
  }
}

void RemoteSuggestionsProviderImpl::SanitizeReceivedSuggestions(
    const RemoteSuggestion::PtrVector& dismissed,
    RemoteSuggestion::PtrVector* suggestions) {
  DCHECK(ready());
  EraseMatchingSuggestions(suggestions, dismissed);
  RemoveIncompleteSuggestions(suggestions);
}

void RemoteSuggestionsProviderImpl::IntegrateSuggestions(
    Category category,
    CategoryContent* content,
    RemoteSuggestion::PtrVector new_suggestions) {
  DCHECK(ready());

  // It's entirely possible that the newly fetched suggestions contain articles
  // that have been present before.
  // We need to make sure to only delete and archive suggestions that don't
  // appear with the same ID in the new suggestions (it's fine for additional
  // IDs though).
  EraseByPrimaryID(&content->suggestions,
                   *GetSuggestionIDVector(new_suggestions));

  // If enabled, keep some older prefetched article suggestions, otherwise the
  // user has little time to see them.
  if (IsKeepingPrefetchedSuggestionsEnabled() &&
      category == articles_category_ && prefetched_pages_tracker_) {
    DCHECK(prefetched_pages_tracker_->IsInitialized());

    // Select suggestions to keep.
    std::sort(content->suggestions.begin(), content->suggestions.end(),
              [](const std::unique_ptr<RemoteSuggestion>& first,
                 const std::unique_ptr<RemoteSuggestion>& second) {
                return first->fetch_date() > second->fetch_date();
              });
    std::vector<std::unique_ptr<RemoteSuggestion>>
        additional_prefetched_suggestions, other_suggestions;
    for (auto& remote_suggestion : content->suggestions) {
      const GURL& url = remote_suggestion->amp_url().is_empty()
                            ? remote_suggestion->url()
                            : remote_suggestion->amp_url();
      if (prefetched_pages_tracker_->PrefetchedOfflinePageExists(url) &&
          clock_->Now() - remote_suggestion->fetch_date() <
              GetMaxAgeForAdditionalPrefetchedSuggestion() &&
          static_cast<int>(additional_prefetched_suggestions.size()) <
              GetMaxAdditionalPrefetchedSuggestions()) {
        additional_prefetched_suggestions.push_back(
            std::move(remote_suggestion));
      } else {
        other_suggestions.push_back(std::move(remote_suggestion));
      }
    }

    // Mix them into the new set according to their score.
    for (auto& remote_suggestion : additional_prefetched_suggestions) {
      new_suggestions.push_back(std::move(remote_suggestion));
    }
    std::sort(new_suggestions.begin(), new_suggestions.end(),
              [](const std::unique_ptr<RemoteSuggestion>& first,
                 const std::unique_ptr<RemoteSuggestion>& second) {
                return first->score() > second->score();
              });

    // Treat remaining suggestions as usual.
    content->suggestions = std::move(other_suggestions);
  }

  // Do not delete the thumbnail images as they are still handy on open NTPs.
  database_->DeleteSnippets(GetSuggestionIDVector(content->suggestions));
  // Note, that ArchiveSuggestions will clear |content->suggestions|.
  ArchiveSuggestions(content, &content->suggestions);

  // TODO(vitaliii): Move rank logic into the database. It will set ranks and
  // return already sorted suggestions.
  for (size_t i = 0; i < new_suggestions.size(); ++i) {
    new_suggestions[i]->set_rank(i);
  }
  database_->SaveSnippets(new_suggestions);

  content->suggestions = std::move(new_suggestions);
}

void RemoteSuggestionsProviderImpl::PrependArticleSuggestion(
    std::unique_ptr<RemoteSuggestion> remote_suggestion) {
  if (!ready()) {
    return;
  }

  if (!IsPushedSuggestionsNotificationsEnabled()) {
    remote_suggestion->set_should_notify(false);
  }

  ClearExpiredDismissedSuggestions();

  DCHECK_EQ(articles_category_, Category::FromRemoteCategory(
                                    remote_suggestion->remote_category_id()));

  auto content_it = category_contents_.find(articles_category_);
  if (content_it == category_contents_.end()) {
    return;
  }
  CategoryContent* content = &content_it->second;

  std::vector<std::unique_ptr<RemoteSuggestion>> suggestions;
  suggestions.push_back(std::move(remote_suggestion));

  // Ignore the pushed suggestion if:
  //
  // - Incomplete.
  // - Has been dismissed.
  // - Present in the current suggestions.
  // - Possibly shown on older surfaces (i.e. archived).
  //
  // We do not check the database, because it just persists current suggestions.

  RemoveIncompleteSuggestions(&suggestions);
  EraseMatchingSuggestions(&suggestions, content->dismissed);
  EraseMatchingSuggestions(&suggestions, content->suggestions);

  // Check archived suggestions.
  base::EraseIf(
      suggestions,
      [&content](const std::unique_ptr<RemoteSuggestion>& suggestion) {
        const std::vector<std::string>& ids = suggestion->GetAllIDs();
        for (const auto& archived_suggestion : content->archived) {
          if (base::ContainsValue(ids, archived_suggestion->id())) {
            return true;
          }
        }
        return false;
      });

  if (!suggestions.empty()) {
    content->suggestions.insert(content->suggestions.begin(),
                                std::move(suggestions[0]));

    for (size_t i = 0; i < content->suggestions.size(); ++i) {
      content->suggestions[i]->set_rank(i);
    }

    if (IsCategoryStatusAvailable(content->status)) {
      NotifyNewSuggestions(articles_category_, content->suggestions);
    }

    // Avoid triggering the pushed suggestion notification for the second time
    // (e.g. when another suggestions is pushed).
    content->suggestions[0]->set_should_notify(false);

    database_->SaveSnippets(content->suggestions);
  }
}

void RemoteSuggestionsProviderImpl::
    RefreshSuggestionsUponPushToRefreshRequest() {
  RefetchInTheBackground({});
}

void RemoteSuggestionsProviderImpl::DismissSuggestionFromCategoryContent(
    CategoryContent* content,
    const std::string& id_within_category) {
  auto id_predicate = [&id_within_category](
                          const std::unique_ptr<RemoteSuggestion>& suggestion) {
    return suggestion->id() == id_within_category;
  };

  auto it = std::find_if(content->suggestions.begin(),
                         content->suggestions.end(), id_predicate);
  if (it != content->suggestions.end()) {
    (*it)->set_dismissed(true);
    database_->SaveSnippet(**it);
    content->dismissed.push_back(std::move(*it));
    content->suggestions.erase(it);
  } else {
    // Check the archive.
    auto archive_it = std::find_if(content->archived.begin(),
                                   content->archived.end(), id_predicate);
    if (archive_it != content->archived.end()) {
      (*archive_it)->set_dismissed(true);
    }
  }
}

void RemoteSuggestionsProviderImpl::DeleteCategories(
    const std::vector<Category>& categories) {
  for (Category category : categories) {
    auto it = category_contents_.find(category);
    if (it == category_contents_.end()) {
      continue;
    }
    const CategoryContent& content = it->second;

    UpdateCategoryStatus(category, CategoryStatus::NOT_PROVIDED);

    if (!content.suggestions.empty()) {
      database_->DeleteImages(GetSuggestionIDVector(content.suggestions));
      database_->DeleteSnippets(GetSuggestionIDVector(content.suggestions));
    }
    if (!content.dismissed.empty()) {
      database_->DeleteImages(GetSuggestionIDVector(content.dismissed));
      database_->DeleteSnippets(GetSuggestionIDVector(content.dismissed));
    }
    category_contents_.erase(it);
  }
}

void RemoteSuggestionsProviderImpl::ClearExpiredDismissedSuggestions() {
  std::vector<Category> categories_to_delete;

  const base::Time now = base::Time::Now();

  for (auto& item : category_contents_) {
    Category category = item.first;
    CategoryContent* content = &item.second;

    RemoteSuggestion::PtrVector to_delete;
    // Move expired dismissed suggestions over into |to_delete|.
    for (std::unique_ptr<RemoteSuggestion>& suggestion : content->dismissed) {
      if (suggestion->expiry_date() <= now) {
        to_delete.emplace_back(std::move(suggestion));
      }
    }
    RemoveNullPointers(&content->dismissed);

    if (!to_delete.empty()) {
      // Delete the images.
      database_->DeleteImages(GetSuggestionIDVector(to_delete));
      // Delete the removed article suggestions from the DB.
      database_->DeleteSnippets(GetSuggestionIDVector(to_delete));
    }

    if (content->suggestions.empty() && content->dismissed.empty() &&
        category != articles_category_ &&
        !content->included_in_last_server_response) {
      categories_to_delete.push_back(category);
    }
  }

  DeleteCategories(categories_to_delete);
  StoreCategoriesToPrefs();
}

void RemoteSuggestionsProviderImpl::ClearOrphanedImages() {
  auto alive_suggestions = std::make_unique<std::set<std::string>>();
  for (const auto& entry : category_contents_) {
    const CategoryContent& content = entry.second;
    for (const auto& suggestion_ptr : content.suggestions) {
      alive_suggestions->insert(suggestion_ptr->id());
    }
    for (const auto& suggestion_ptr : content.dismissed) {
      alive_suggestions->insert(suggestion_ptr->id());
    }
  }
  database_->GarbageCollectImages(std::move(alive_suggestions));
}

void RemoteSuggestionsProviderImpl::ClearHistoryDependentState() {
  if (!initialized()) {
    clear_history_dependent_state_when_initialized_ = true;
    return;
  }

  debug_logger_->Log(FROM_HERE, /*message=*/std::string());
  NukeAllSuggestions();
  remote_suggestions_scheduler_->OnHistoryCleared();
}

void RemoteSuggestionsProviderImpl::ClearCachedSuggestionsImpl() {
  if (!initialized()) {
    clear_cached_suggestions_when_initialized_ = true;
    return;
  }

  debug_logger_->Log(FROM_HERE, /*message=*/std::string());
  NukeAllSuggestions();
  remote_suggestions_scheduler_->OnSuggestionsCleared();
}

void RemoteSuggestionsProviderImpl::NukeAllSuggestions() {
  DCHECK(initialized());
  // TODO(tschumann): Should Nuke also cancel outstanding requests? Or should we
  // only block the results of such outstanding requests?
  for (auto& item : category_contents_) {
    Category category = item.first;
    CategoryContent* content = &item.second;

    // TODO(tschumann): We do the unnecessary checks for .empty() in many places
    // before calling database methods. Change the RemoteSuggestionsDatabase to
    // return early for those and remove the many if statements in this file.
    if (!content->suggestions.empty()) {
      database_->DeleteSnippets(GetSuggestionIDVector(content->suggestions));
      database_->DeleteImages(GetSuggestionIDVector(content->suggestions));
      content->suggestions.clear();
    }
    if (!content->archived.empty()) {
      database_->DeleteSnippets(GetSuggestionIDVector(content->archived));
      database_->DeleteImages(GetSuggestionIDVector(content->archived));
      content->archived.clear();
    }

    // Update listeners about the new (empty) state.
    if (IsCategoryStatusAvailable(content->status)) {
      NotifyNewSuggestions(category, content->suggestions);
    }
    // TODO(tschumann): We should not call debug code from production code.
    ClearDismissedSuggestionsForDebugging(category);
  }

  StoreCategoriesToPrefs();
}

GURL RemoteSuggestionsProviderImpl::GetImageURLToFetch(
    const ContentSuggestion::ID& suggestion_id) const {
  if (!base::ContainsKey(category_contents_, suggestion_id.category())) {
    return GURL();
  }
  return FindSuggestionImageUrl(suggestion_id);
}

void RemoteSuggestionsProviderImpl::FetchSuggestionImage(
    const ContentSuggestion::ID& suggestion_id,
    ImageFetchedCallback callback) {
  GURL image_url = GetImageURLToFetch(suggestion_id);
  if (image_url.is_empty()) {
    // As we don't know the corresponding suggestion anymore, we don't expect to
    // find it in the database (and also can't fetch it remotely). Cut the
    // lookup short and return directly.
    base::ThreadTaskRunnerHandle::Get()->PostTask(
        FROM_HERE, base::BindOnce(std::move(callback), gfx::Image()));
    return;
  }
  image_fetcher_.FetchSuggestionImage(suggestion_id, image_url,
                                      ImageDataFetchedCallback(),
                                      std::move(callback));
}

void RemoteSuggestionsProviderImpl::FetchSuggestionImageData(
    const ContentSuggestion::ID& suggestion_id,
    ImageDataFetchedCallback callback) {
  GURL image_url = GetImageURLToFetch(suggestion_id);
  if (image_url.is_empty()) {
    // As we don't know the corresponding suggestion anymore, we don't expect to
    // find it in the database (and also can't fetch it remotely). Cut the
    // lookup short and return directly.
    base::ThreadTaskRunnerHandle::Get()->PostTask(
        FROM_HERE, base::BindOnce(std::move(callback), std::string()));
    return;
  }
  image_fetcher_.FetchSuggestionImage(suggestion_id, image_url,
                                      std::move(callback),
                                      ntp_snippets::ImageFetchedCallback());
}

void RemoteSuggestionsProviderImpl::
    UpdatePushedSuggestionsSubscriptionDueToStatusChange(
        RemoteSuggestionsStatus new_status) {
  if (!breaking_news_raw_data_provider_) {
    return;
  }

  bool should_be_subscribed = false;
  switch (new_status) {
    case RemoteSuggestionsStatus::ENABLED_AND_SIGNED_IN:
      should_be_subscribed =
          IsSignedInUsersSubscriptionForPushedSuggestionsEnabled();
      break;

    case RemoteSuggestionsStatus::ENABLED_AND_SIGNED_OUT:
      should_be_subscribed =
          IsSignedOutUsersSubscriptionForPushedSuggestionsEnabled();
      break;

    case RemoteSuggestionsStatus::EXPLICITLY_DISABLED:
      should_be_subscribed = false;
      break;
  }

  if (should_be_subscribed) {
    if (!breaking_news_raw_data_provider_->IsListening()) {
      breaking_news_raw_data_provider_->StartListening(
          base::Bind(&RemoteSuggestionsProviderImpl::PrependArticleSuggestion,
                     base::Unretained(this)),
          base::Bind(&RemoteSuggestionsProviderImpl::
                         RefreshSuggestionsUponPushToRefreshRequest,
                     base::Unretained(this)));
    }
  } else {
    if (breaking_news_raw_data_provider_->IsListening()) {
      breaking_news_raw_data_provider_->StopListening();
    }
  }
}

void RemoteSuggestionsProviderImpl::FinishInitialization() {
  // Note: Initializing the status service will run the callback right away with
  // the current state.
  status_service_->Init(base::Bind(
      &RemoteSuggestionsProviderImpl::OnStatusChanged, base::Unretained(this)));

  // Always notify here even if we got nothing from the database, because we
  // don't know how long the fetch will take or if it will even complete.
  for (const auto& item : category_contents_) {
    Category category = item.first;
    const CategoryContent& content = item.second;
    // Note: We might be in a non-available status here, e.g. DISABLED due to
    // enterprise policy.
    if (IsCategoryStatusAvailable(content.status)) {
      NotifyNewSuggestions(category, content.suggestions);
    }
  }
}

void RemoteSuggestionsProviderImpl::OnStatusChanged(
    RemoteSuggestionsStatus old_status,
    RemoteSuggestionsStatus new_status) {
  switch (new_status) {
    case RemoteSuggestionsStatus::ENABLED_AND_SIGNED_IN:
      if (old_status == RemoteSuggestionsStatus::ENABLED_AND_SIGNED_OUT) {
        DCHECK(state_ == State::READY);
        // Clear nonpersonalized suggestions (and notify the scheduler there are
        // no suggestions).
        ClearCachedSuggestionsImpl();
        if (request_status_ == FetchRequestStatus::IN_PROGRESS) {
          request_status_ = FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH;
        }
      } else {
        EnterState(State::READY);
      }
      break;

    case RemoteSuggestionsStatus::ENABLED_AND_SIGNED_OUT:
      if (old_status == RemoteSuggestionsStatus::ENABLED_AND_SIGNED_IN) {
        DCHECK(state_ == State::READY);
        // Clear personalized suggestions (and notify the scheduler there are
        // no suggestions).
        ClearCachedSuggestionsImpl();
        if (request_status_ == FetchRequestStatus::IN_PROGRESS) {
          request_status_ = FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH;
        }
      } else {
        EnterState(State::READY);
      }
      break;

    case RemoteSuggestionsStatus::EXPLICITLY_DISABLED:
      EnterState(State::DISABLED);
      break;
  }

  UpdatePushedSuggestionsSubscriptionDueToStatusChange(new_status);
}

void RemoteSuggestionsProviderImpl::EnterState(State state) {
  if (state == state_) {
    return;
  }

  UMA_HISTOGRAM_ENUMERATION("NewTabPage.Snippets.EnteredState",
                            static_cast<int>(state),
                            static_cast<int>(State::COUNT));

  switch (state) {
    case State::NOT_INITED:
      // Initial state, it should not be possible to get back there.
      NOTREACHED();
      break;

    case State::READY:
      DCHECK(state_ == State::NOT_INITED || state_ == State::DISABLED);

      DVLOG(1) << "Entering state: READY";
      state_ = State::READY;

      DCHECK(category_contents_.find(articles_category_) !=
             category_contents_.end());

      UpdateAllCategoryStatus(CategoryStatus::AVAILABLE);

      if (clear_cached_suggestions_when_initialized_) {
        ClearCachedSuggestionsImpl();
        clear_cached_suggestions_when_initialized_ = false;
      }
      if (clear_history_dependent_state_when_initialized_) {
        clear_history_dependent_state_when_initialized_ = false;
        ClearHistoryDependentState();
      }
      // This notification may cause the scheduler to ask the provider to do a
      // refetch. We want to do it as the last step, when the state change here
      // in the provider is completed.
      NotifyStateChanged();
      break;

    case State::DISABLED:
      DCHECK(state_ == State::NOT_INITED || state_ == State::READY);

      DVLOG(1) << "Entering state: DISABLED";
      state_ = State::DISABLED;
      // Notify the state change to disable the scheduler. Clearing history /
      // suggestions below tells the scheduler to fetch them again if the
      // scheduler is not disabled. It is disabled; thus the calls are ignored.
      NotifyStateChanged();
      if (clear_history_dependent_state_when_initialized_) {
        clear_history_dependent_state_when_initialized_ = false;
        ClearHistoryDependentState();
      }
      ClearCachedSuggestionsImpl();
      clear_cached_suggestions_when_initialized_ = false;

      if (breaking_news_raw_data_provider_ &&
          breaking_news_raw_data_provider_->IsListening()) {
        breaking_news_raw_data_provider_->StopListening();
      }
      UpdateAllCategoryStatus(CategoryStatus::CATEGORY_EXPLICITLY_DISABLED);
      break;

    case State::ERROR_OCCURRED:
      DVLOG(1) << "Entering state: ERROR_OCCURRED";
      state_ = State::ERROR_OCCURRED;
      NotifyStateChanged();
      status_service_.reset();
      break;

    case State::COUNT:
      NOTREACHED();
      break;
  }
}

void RemoteSuggestionsProviderImpl::NotifyStateChanged() {
  switch (state_) {
    case State::NOT_INITED:
      // Initial state, not sure yet whether active or not.
      break;
    case State::READY:
      remote_suggestions_scheduler_->OnProviderActivated();
      break;
    case State::DISABLED:
      remote_suggestions_scheduler_->OnProviderDeactivated();
      break;
    case State::ERROR_OCCURRED:
      remote_suggestions_scheduler_->OnProviderDeactivated();
      break;
    case State::COUNT:
      NOTREACHED();
      break;
  }
}

void RemoteSuggestionsProviderImpl::NotifyNewSuggestions(
    Category category,
    const RemoteSuggestion::PtrVector& suggestions) {
  DCHECK(category_contents_.find(category) != category_contents_.end());
  DCHECK(IsCategoryStatusAvailable(
      category_contents_.find(category)->second.status));

  std::vector<ContentSuggestion> result =
      ConvertToContentSuggestions(category, suggestions);

  DVLOG(1) << "NotifyNewSuggestions(): " << result.size()
           << " items in category " << category;
  observer()->OnNewSuggestions(this, category, std::move(result));
}

void RemoteSuggestionsProviderImpl::UpdateCategoryStatus(
    Category category,
    CategoryStatus status) {
  auto content_it = category_contents_.find(category);
  DCHECK(content_it != category_contents_.end());
  CategoryContent& content = content_it->second;

  if (status == content.status) {
    return;
  }

  DVLOG(1) << "UpdateCategoryStatus(): " << category.id() << ": "
           << static_cast<int>(content.status) << " -> "
           << static_cast<int>(status);
  content.status = status;
  observer()->OnCategoryStatusChanged(this, category, content.status);
}

void RemoteSuggestionsProviderImpl::UpdateAllCategoryStatus(
    CategoryStatus status) {
  for (const auto& category : category_contents_) {
    UpdateCategoryStatus(category.first, status);
  }
}

namespace {

template <typename T>
typename T::const_iterator FindSuggestionInContainer(
    const T& container,
    const std::string& id_within_category) {
  return std::find_if(container.begin(), container.end(),
                      [&id_within_category](
                          const std::unique_ptr<RemoteSuggestion>& suggestion) {
                        return suggestion->id() == id_within_category;
                      });
}

}  // namespace

const RemoteSuggestion*
RemoteSuggestionsProviderImpl::CategoryContent::FindSuggestion(
    const std::string& id_within_category) const {
  // Search for the suggestion in current and archived suggestions.
  auto it = FindSuggestionInContainer(suggestions, id_within_category);
  if (it != suggestions.end()) {
    return it->get();
  }
  auto archived_it = FindSuggestionInContainer(archived, id_within_category);
  if (archived_it != archived.end()) {
    return archived_it->get();
  }
  auto dismissed_it = FindSuggestionInContainer(dismissed, id_within_category);
  if (dismissed_it != dismissed.end()) {
    return dismissed_it->get();
  }
  return nullptr;
}

RemoteSuggestionsProviderImpl::CategoryContent*
RemoteSuggestionsProviderImpl::UpdateCategoryInfo(Category category,
                                                  const CategoryInfo& info) {
  auto content_it = category_contents_.find(category);
  if (content_it == category_contents_.end()) {
    content_it = category_contents_
                     .insert(std::make_pair(category, CategoryContent(info)))
                     .first;
  } else {
    content_it->second.info = info;
  }
  return &content_it->second;
}

void RemoteSuggestionsProviderImpl::RestoreCategoriesFromPrefs() {
  // This must only be called at startup, before there are any categories.
  DCHECK(category_contents_.empty());

  const base::ListValue* list =
      pref_service_->GetList(prefs::kRemoteSuggestionCategories);
  for (const base::Value& entry : *list) {
    const base::DictionaryValue* dict = nullptr;
    if (!entry.GetAsDictionary(&dict)) {
      DLOG(WARNING) << "Invalid category pref value: " << entry;
      continue;
    }
    int id = 0;
    if (!dict->GetInteger(kCategoryContentId, &id)) {
      DLOG(WARNING) << "Invalid category pref value, missing '"
                    << kCategoryContentId << "': " << entry;
      continue;
    }
    base::string16 title;
    if (!dict->GetString(kCategoryContentTitle, &title)) {
      DLOG(WARNING) << "Invalid category pref value, missing '"
                    << kCategoryContentTitle << "': " << entry;
      continue;
    }
    bool included_in_last_server_response = false;
    if (!dict->GetBoolean(kCategoryContentProvidedByServer,
                          &included_in_last_server_response)) {
      DLOG(WARNING) << "Invalid category pref value, missing '"
                    << kCategoryContentProvidedByServer << "': " << entry;
      continue;
    }
    bool allow_fetching_more_results = false;
    // This wasn't always around, so it's okay if it's missing.
    dict->GetBoolean(kCategoryContentAllowFetchingMore,
                     &allow_fetching_more_results);

    Category category = Category::FromIDValue(id);
    // The ranker may not persist the order of remote categories.
    category_ranker_->AppendCategoryIfNecessary(category);
    // TODO(tschumann): The following has a bad smell that category
    // serialization / deserialization should not be done inside this
    // class. We should move that into a central place that also knows how to
    // parse data we received from remote backends.
    CategoryInfo info =
        category == articles_category_
            ? BuildArticleCategoryInfo(title)
            : BuildRemoteCategoryInfo(title, allow_fetching_more_results);
    CategoryContent* content = UpdateCategoryInfo(category, info);
    content->included_in_last_server_response =
        included_in_last_server_response;
  }
}

void RemoteSuggestionsProviderImpl::StoreCategoriesToPrefs() {
  // Collect all the CategoryContents.
  std::vector<std::pair<Category, const CategoryContent*>> to_store;
  for (const auto& entry : category_contents_) {
    to_store.emplace_back(entry.first, &entry.second);
  }
  // The ranker may not persist the order, thus, it is stored by the provider.
  std::sort(to_store.begin(), to_store.end(),
            [this](const std::pair<Category, const CategoryContent*>& left,
                   const std::pair<Category, const CategoryContent*>& right) {
              return category_ranker_->Compare(left.first, right.first);
            });
  // Convert the relevant info into a base::ListValue for storage.
  base::ListValue list;
  for (const auto& entry : to_store) {
    const Category& category = entry.first;
    const CategoryContent& content = *entry.second;
    auto dict = std::make_unique<base::DictionaryValue>();
    dict->SetInteger(kCategoryContentId, category.id());
    // TODO(tschumann): Persist other properties of the CategoryInfo.
    dict->SetString(kCategoryContentTitle, content.info.title());
    dict->SetBoolean(kCategoryContentProvidedByServer,
                     content.included_in_last_server_response);
    bool has_fetch_action = content.info.additional_action() ==
                            ContentSuggestionsAdditionalAction::FETCH;
    dict->SetBoolean(kCategoryContentAllowFetchingMore, has_fetch_action);
    list.Append(std::move(dict));
  }
  // Finally, store the result in the pref service.
  pref_service_->Set(prefs::kRemoteSuggestionCategories, list);
}

RemoteSuggestionsProviderImpl::CategoryContent::CategoryContent(
    const CategoryInfo& info)
    : info(info) {}

RemoteSuggestionsProviderImpl::CategoryContent::CategoryContent(
    CategoryContent&&) = default;

RemoteSuggestionsProviderImpl::CategoryContent::~CategoryContent() = default;

RemoteSuggestionsProviderImpl::CategoryContent&
RemoteSuggestionsProviderImpl::CategoryContent::operator=(CategoryContent&&) =
    default;

}  // namespace ntp_snippets
