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

#include "components/permissions/notifications_engagement_service.h"

#include "base/metrics/histogram.h"
#include "base/numerics/safe_conversions.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/values.h"
#include "components/permissions/permissions_client.h"
#include "url/gurl.h"

namespace permissions {

namespace {

// For each origin that has the  |ContentSettingsType::NOTIFICATIONS|
// permission, we record the number of notifications that were displayed,
// interacted with, and replaced by a suspicious content warning.
// The data is stored in the website setting |NOTIFICATION_INTERACTIONS|  keyed
// to the same origin. The internal structure of this metadata is a dictionary:
//
//   {"1644163200": {"display_count": 3},  # Implied click_count,
//   suspicious_count = 0.
//    "1644768000": {"display_count": 6, "click_count": 1, "suspicious_count":
//    2}}
//
// Currently, entries will be recorded daily.

constexpr char kEngagementKey[] = "click_count";
constexpr char kDisplayedKey[] = "display_count";
constexpr char kSuspiciousKey[] = "suspicious_count";

// Entries in notifications engagement expire after they become this old.
constexpr base::TimeDelta kMaxAge = base::Days(30);

// Discards notification interactions stored in `engagement` for time
// periods older than |kMaxAge|.
void EraseStaleEntries(base::DictValue& engagement) {
  const base::Time cutoff = base::Time::Now() - kMaxAge;

  for (auto it = engagement.begin(); it != engagement.end();) {
    const auto& [key, value] = *it;

    std::optional<base::Time> last_time =
        NotificationsEngagementService::ParsePeriodBeginFromBucketLabel(key);
    if (!last_time.has_value() || last_time.value() < cutoff) {
      it = engagement.erase(it);
      continue;
    }
    ++it;
  }
}

int ExtractNotificationCount(const base::DictValue& engagement,
                             std::string date) {
  const base::DictValue* bucket = engagement.FindDict(date);
  if (!bucket) {
    return 0;
  }
  return bucket->FindInt(kDisplayedKey).value_or(0);
}

void ReportNotificationEngagement(std::string_view type,
                                  int daily_notification_count,
                                  double site_engagement_score,
                                  int count) {
  if (count <= 0) {
    return;
  }

  std::string_view volume;
  if (daily_notification_count < 0) {
    return;
  } else if (daily_notification_count == 0) {
    volume = "Volume0";
  } else if (daily_notification_count == 1) {
    volume = "Volume1";
  } else if (daily_notification_count <= 5) {
    volume = "Volume5";
  } else if (daily_notification_count <= 10) {
    volume = "Volume10";
  } else if (daily_notification_count <= 20) {
    volume = "Volume20";
  } else {
    volume = "VolumeAbove20";
  }

  // `site_engagement_score` is a double between 0 and 100, and starts being
  // interested when greater than 0.5. We record `site_engagement_score * 2` in
  // order to be able to distinguish positive values from zero.
  int site_engagement_score_to_report =
      base::saturated_cast<int>(site_engagement_score * 2);
  base::HistogramBase* histogram = base::Histogram::FactoryGet(
      base::StrCat({"Notifications.Engagement.", type, ".", volume}), 1, 201,
      50, base::HistogramBase::kUmaTargetedHistogramFlag);
  histogram->AddCount(site_engagement_score_to_report, count);
}

}  // namespace

NotificationsEngagementService::NotificationsEngagementService(
    content::BrowserContext* context,
    PrefService* pref_service)
    : pref_service_(pref_service),
      browser_context_(context),
      settings_map_(PermissionsClient::Get()->GetSettingsMap(context)) {}

void NotificationsEngagementService::Shutdown() {
  settings_map_ = nullptr;
  browser_context_ = nullptr;
}

void NotificationsEngagementService::RecordNotificationDisplayed(
    const GURL& url) {
  IncrementCounts(url, 1 /*display_count_delta*/, 0 /*click_count_delta*/,
                  0 /*suspicious_count_delta*/);
}

void NotificationsEngagementService::RecordNotificationDisplayed(
    const GURL& url,
    int display_count) {
  IncrementCounts(url, display_count, 0 /*click_count_delta*/,
                  0 /*suspicious_count_delta*/);
}

void NotificationsEngagementService::RecordNotificationInteraction(
    const GURL& url) {
  IncrementCounts(url, 0 /*display_count_delta*/, 1 /*click_count_delta*/,
                  0 /*suspicious_count_delta*/);
}

void NotificationsEngagementService::RecordNotificationSuspicious(
    const GURL& url) {
  IncrementCounts(url, 0 /*display_count_delta*/, 0 /*click_count_delta*/,
                  1 /*suspicious_count_delta*/);
}

// static
int NotificationsEngagementService::GetDailyAverageNotificationCount(
    const base::DictValue& engagement) {
  // Calculate daily average count for the past week.
  base::Time date = base::Time::Now();
  int notification_count_total = 0;

  static constexpr int kDays = 7;

  for (int day = 0; day < kDays; ++day) {
    notification_count_total += ExtractNotificationCount(
        engagement, GetBucketLabel(date - base::Days(day)));
  }

  return std::ceil(notification_count_total / kDays);
}

// static
int NotificationsEngagementService::GetDailyAverageNotificationCount(
    const ContentSettingPatternSource& setting) {
  if (!setting.setting_value.is_dict()) {
    return 0;
  }
  return GetDailyAverageNotificationCount(setting.setting_value.GetDict());
}

// static
int NotificationsEngagementService::GetSuspiciousNotificationCountForPeriod(
    const base::DictValue& engagement,
    int days) {
  base::Time date = base::Time::Now();
  int suspicious_notification_count = 0;

  for (int day = 0; day < days; ++day) {
    const base::DictValue* bucket =
        engagement.FindDict(GetBucketLabel(date - base::Days(day)));
    if (bucket) {
      suspicious_notification_count +=
          bucket->FindInt(kSuspiciousKey).value_or(0);
    }
  }

  return suspicious_notification_count;
}

// static
std::map<std::pair<ContentSettingsPattern, ContentSettingsPattern>, int>
NotificationsEngagementService::GetNotificationCountMapPerPatternPair(
    const HostContentSettingsMap* hcsm) {
  std::map<std::pair<ContentSettingsPattern, ContentSettingsPattern>, int>
      result;
  for (auto& item : hcsm->GetSettingsForOneType(
           ContentSettingsType::NOTIFICATION_INTERACTIONS)) {
    result[std::pair{item.primary_pattern, item.secondary_pattern}] =
        GetDailyAverageNotificationCount(item);
  }

  return result;
}

void NotificationsEngagementService::IncrementCounts(
    const GURL& url,
    int display_count_delta,
    int click_count_delta,
    int suspicious_count_delta) {
  base::Value engagement_as_value = settings_map_->GetWebsiteSetting(
      url, GURL(), ContentSettingsType::NOTIFICATION_INTERACTIONS);

  base::DictValue engagement;

  if (engagement_as_value.is_dict()) {
    engagement = std::move(engagement_as_value).TakeDict();
  }

  std::string date = GetBucketLabel(base::Time::Now());
  if (date == std::string()) {
    return;
  }

  EraseStaleEntries(engagement);
  base::DictValue* bucket = engagement.FindDict(date);
  if (!bucket) {
    bucket = &engagement.Set(date, base::DictValue())->GetDict();
  }
  if (display_count_delta) {
    bucket->Set(kDisplayedKey, display_count_delta +
                                   bucket->FindInt(kDisplayedKey).value_or(0));
  }
  if (click_count_delta) {
    bucket->Set(
        kEngagementKey,
        click_count_delta + bucket->FindInt(kEngagementKey).value_or(0));
  }
  if (suspicious_count_delta) {
    bucket->Set(
        kSuspiciousKey,
        suspicious_count_delta + bucket->FindInt(kSuspiciousKey).value_or(0));
  }

  int daily_notification_count = GetDailyAverageNotificationCount(engagement);
  double site_engagement_score =
      PermissionsClient::Get()->GetSiteEngagementScore(browser_context_, url);
  ReportNotificationEngagement("Displayed", daily_notification_count,
                               site_engagement_score, display_count_delta);
  ReportNotificationEngagement("Clicked", daily_notification_count,
                               site_engagement_score, click_count_delta);
  ReportNotificationEngagement("Suspicious", daily_notification_count,
                               site_engagement_score, suspicious_count_delta);

  // Set the website setting of this origin with the updated |engagement|.
  settings_map_->SetWebsiteSettingDefaultScope(
      url, GURL(), ContentSettingsType::NOTIFICATION_INTERACTIONS,
      base::Value(std::move(engagement)));
}

// static
std::string NotificationsEngagementService::GetBucketLabel(base::Time date) {
  // For human-readability, return the UTC midnight on the same date as
  // local midnight.
  base::Time local_date = date.LocalMidnight();

  base::Time::Exploded local_date_exploded;
  local_date.LocalExplode(&local_date_exploded);
  // Intentionally converting a locally exploded time, to an UTC time, so that
  // the Midnight in UTC is on the same date the date on local time.
  base::Time last_date;
  bool converted = base::Time::FromUTCExploded(local_date_exploded, &last_date);

  if (converted) {
    return base::NumberToString(last_date.base::Time::ToTimeT());
  }

  return std::string();
}

// static
std::optional<base::Time>
NotificationsEngagementService::ParsePeriodBeginFromBucketLabel(
    const std::string& label) {
  int maybe_engagement_time;
  base::Time local_period_begin;

  // Store the time as local time.
  if (base::StringToInt(label.c_str(), &maybe_engagement_time)) {
    base::Time::Exploded date_exploded;
    base::Time::FromTimeT(maybe_engagement_time).UTCExplode(&date_exploded);
    if (base::Time::FromLocalExploded(date_exploded, &local_period_begin)) {
      return local_period_begin;
    }
  }

  return std::nullopt;
}

}  // namespace permissions
