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

#include "chrome/browser/web_applications/web_app_pref_guardrails.h"

#include <optional>
#include <string>
#include <string_view>

#include "base/auto_reset.h"
#include "base/check.h"
#include "base/json/values_util.h"
#include "base/strings/strcat.h"
#include "base/time/clock.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/web_applications/web_app_constants.h"
#include "chrome/common/chrome_features.h"
#include "chrome/common/pref_names.h"
#include "components/pref_registry/pref_registry_syncable.h"
#include "components/prefs/pref_service.h"
#include "components/prefs/scoped_user_pref_update.h"
#include "components/webapps/browser/features.h"
#include "components/webapps/common/web_app_id.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/common/content_features.h"

namespace web_app {

namespace {
static base::Clock* g_clock = nullptr;

// Returns whether the time occurred within X days.
bool TimeOccurredWithinDays(std::optional<base::Time> time, int days) {
  base::Time now;
  if (g_clock) {
    now = g_clock->Now();
  } else {
    now = base::Time::Now();
  }
  return time && (now - time.value()).InDays() < days;
}

const base::DictValue* GetWebAppDictionary(const PrefService* pref_service,
                                           const webapps::AppId& app_id) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
  const base::DictValue& web_apps_prefs =
      pref_service->GetDict(prefs::kWebAppsPreferences);

  return web_apps_prefs.FindDict(app_id);
}

base::DictValue& UpdateWebAppDictionary(
    ScopedDictPrefUpdate& web_apps_prefs_update,
    const webapps::AppId& app_id) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
  return *web_apps_prefs_update->EnsureDict(app_id);
}

}  // namespace

std::optional<int> GetIntWebAppPref(const PrefService* pref_service,
                                    const webapps::AppId& app_id,
                                    std::string_view path) {
  const base::DictValue* web_app_prefs =
      GetWebAppDictionary(pref_service, app_id);
  if (!web_app_prefs) {
    return std::nullopt;
  }
  return web_app_prefs->FindIntByDottedPath(path);
}

std::optional<base::Time> GetTimeWebAppPref(const PrefService* pref_service,
                                            const webapps::AppId& app_id,
                                            std::string_view path) {
  const auto* web_app_prefs = GetWebAppDictionary(pref_service, app_id);
  if (!web_app_prefs) {
    return std::nullopt;
  }
  const base::Value* time_value = web_app_prefs->FindByDottedPath(path);
  if (!time_value) {
    return std::nullopt;
  }
  return base::ValueToTime(time_value);
}

// static
WebAppPrefGuardrails WebAppPrefGuardrails::GetForDesktopInstallIph(
    PrefService* pref_service) {
  return WebAppPrefGuardrails(pref_service, web_app::kIphGuardrails,
                              web_app::kIphPrefNames,
                              /*max_days_to_store_guardrails=*/std::nullopt);
}

// static
WebAppPrefGuardrails WebAppPrefGuardrails::GetForMlInstallPrompt(
    PrefService* pref_service) {
  return WebAppPrefGuardrails(
      pref_service, web_app::kMlPromoGuardrails, web_app::kMlPromoPrefNames,
      webapps::features::kMaxDaysForMLPromotionGuardrailStorage.Get());
}

// static
WebAppPrefGuardrails WebAppPrefGuardrails::GetForNavigationCapturingIph(
    PrefService* pref_service) {
  return WebAppPrefGuardrails(
      pref_service, web_app::kIPHNavigationCapturingGuardrails,
      web_app::kIPHNavigationCapturingPrefNames,
      features::kNavigationCapturingIPHGuardrailStorageDuration.Get());
}

// static
WebAppPrefGuardrails WebAppPrefGuardrails::GetForDefaultAppUpdateOnStartup(
    PrefService& pref_service) {
  return WebAppPrefGuardrails(&pref_service,
                              web_app::kDefaultAppUpdateOnStartupGuardrails,
                              web_app::kDefaultAppUpdateOnStartupPrefNames,
                              /*max_days_to_store_guardrails=*/std::nullopt);
}

// static
void WebAppPrefGuardrails::RegisterProfilePrefs(
    user_prefs::PrefRegistrySyncable* registry) {
  // LINT.IfChange(WebAppPrefs)
  registry->RegisterDictionaryPref(prefs::kWebAppsPreferences);
  registry->RegisterDictionaryPref(prefs::kWebAppsAppAgnosticIphState);
  registry->RegisterDictionaryPref(prefs::kWebAppsAppAgnosticMlState);
  registry->RegisterDictionaryPref(
      prefs::kWebAppsAppAgnosticIPHLinkCapturingState);
  // LINT.ThenChange(chrome/browser/web_applications/web_app_utils.cc:WebAppPrefs)
}

// static
base::AutoReset<base::Clock*> WebAppPrefGuardrails::SetClockForTesting(
    base::Clock* clock) {
  return base::AutoReset<base::Clock*>(&g_clock, clock);
}

WebAppPrefGuardrails::~WebAppPrefGuardrails() = default;

void WebAppPrefGuardrails::RecordIgnore(const webapps::AppId& app_id,
                                        base::Time time) {
  // The ignore pref keys not being passed is an indication that ignore
  // guardrails need not be measured.
  if (pref_names_->last_ignore_time_name.empty()) {
    return;
  }

  if (guardrail_data_->app_specific_mute_after_ignore_days.has_value()) {
    UpdateAppSpecificNotAcceptedPrefs(app_id, time,
                                      pref_names_->last_ignore_time_name);
  }

  if (guardrail_data_->global_mute_after_ignore_days.has_value()) {
    UpdateGlobalNotAcceptedPrefs(time, pref_names_->last_ignore_time_name);
  }
}

void WebAppPrefGuardrails::RecordDismiss(const webapps::AppId& app_id,
                                         base::Time time) {
  // The dismiss pref keys not being passed is an indication that dismiss
  // guardrails need not be measured.
  if (pref_names_->last_dismiss_time_name.empty()) {
    return;
  }

  if (guardrail_data_->app_specific_mute_after_dismiss_days.has_value()) {
    UpdateAppSpecificNotAcceptedPrefs(app_id, time,
                                      pref_names_->last_dismiss_time_name);
  }

  if (guardrail_data_->global_mute_after_dismiss_days.has_value()) {
    UpdateGlobalNotAcceptedPrefs(time, pref_names_->last_dismiss_time_name);
  }
}

void WebAppPrefGuardrails::RecordAccept(const webapps::AppId& app_id) {
  ScopedDictPrefUpdate update(pref_service_, pref_names_->global_pref_name);
  if (!pref_names_->not_accepted_count_name.empty()) {
    UpdateIntWebAppPref(app_id, pref_names_->not_accepted_count_name, 0);
    update->Set(pref_names_->not_accepted_count_name, 0);
  }

  if (!pref_names_->all_blocked_time_name.empty()) {
    update->Remove(pref_names_->all_blocked_time_name);
  }
}

bool WebAppPrefGuardrails::IsBlockedByGuardrails(const webapps::AppId& app_id) {
  // Since IsBlockedByGuardrails() is called every time to do a check, this is a
  // good place to reset guardrail blocks if any.
  if (ShouldResetGlobalGuardrails()) {
    ResetGlobalGuardrails(app_id);
  }

  std::optional<std::string> app_block_reason = IsAppBlocked(app_id);
  if (app_block_reason.has_value()) {
    if (HasGlobalPrefs()) {
      ScopedDictPrefUpdate global_update(pref_service_,
                                         pref_names_->global_pref_name);
      LogGlobalBlockReason(global_update, app_block_reason.value());
    }
    return true;
  }

  std::optional<std::string> global_block_reason = IsGloballyBlocked();
  if (global_block_reason.has_value()) {
    CHECK(HasGlobalPrefs());
    ScopedDictPrefUpdate global_update(pref_service_,
                                       pref_names_->global_pref_name);
    LogGlobalBlockReason(global_update, global_block_reason.value());
    if (global_block_reason == "global_not_accept_count_exceeded" &&
        !pref_names_->all_blocked_time_name.empty() && !IsGlobalBlockActive()) {
      global_update->Set(pref_names_->all_blocked_time_name,
                         base::TimeToValue(base::Time::Now()));
    }
    return true;
  }

  return false;
}

WebAppPrefGuardrails::WebAppPrefGuardrails(
    PrefService* pref_service,
    const GuardrailData& guardrail_data,
    const GuardrailPrefNames& guardrail_pref_names,
    std::optional<int> max_days_to_store_guardrails)
    : pref_service_(pref_service),
      guardrail_data_(guardrail_data),
      pref_names_(guardrail_pref_names),
      max_days_to_store_guardrails_(max_days_to_store_guardrails) {}

bool WebAppPrefGuardrails::HasGlobalPrefs() const {
  return !pref_names_->global_pref_name.empty();
}

std::optional<std::string> WebAppPrefGuardrails::IsAppBlocked(
    const webapps::AppId& app_id) {
  // Block if user ignored the action for the app N+ times.
  if (guardrail_data_->app_specific_not_accept_count.has_value()) {
    int app_ignored_count =
        GetIntWebAppPref(pref_service_, app_id,
                         pref_names_->not_accepted_count_name)
            .value_or(0);
    if (app_ignored_count >= guardrail_data_->app_specific_not_accept_count) {
      return base::StrCat({"app_specific_not_accept_count_exceeded:", app_id});
    }
  }

  // Block if user ignored the action for the app within N days.
  if (guardrail_data_->app_specific_mute_after_ignore_days.has_value()) {
    auto app_last_ignore = GetTimeWebAppPref(
        pref_service_, app_id, pref_names_->last_ignore_time_name);

    if (TimeOccurredWithinDays(
            app_last_ignore,
            *guardrail_data_->app_specific_mute_after_ignore_days)) {
      return base::StrCat({"app_specific_ignore_days_hit:", app_id});
    }
  }

  // Block if the user dismissed the action for the app within N days.
  if (guardrail_data_->app_specific_mute_after_dismiss_days.has_value()) {
    auto app_last_dismiss_time = GetTimeWebAppPref(
        pref_service_, app_id, pref_names_->last_dismiss_time_name);

    if (TimeOccurredWithinDays(
            app_last_dismiss_time,
            *guardrail_data_->app_specific_mute_after_dismiss_days)) {
      return base::StrCat({"app_specific_dismiss_days_hit:", app_id});
    }
  }

  return std::nullopt;
}

std::optional<std::string> WebAppPrefGuardrails::IsGloballyBlocked() {
  if (!HasGlobalPrefs()) {
    return std::nullopt;
  }
  const base::DictValue& dict =
      pref_service_->GetDict(pref_names_->global_pref_name);

  // Block if user ignored the action last N+ times for any app.
  if (guardrail_data_->global_not_accept_count.has_value()) {
    int global_ignored_count =
        dict.FindInt(pref_names_->not_accepted_count_name).value_or(0);
    if (global_ignored_count >= *guardrail_data_->global_not_accept_count) {
      return "global_not_accept_count_exceeded";
    }
  }

  // Block if user ignored the action for any app within N days.
  if (guardrail_data_->global_mute_after_ignore_days.has_value()) {
    auto global_last_ignore =
        base::ValueToTime(dict.Find(pref_names_->last_ignore_time_name));

    if (TimeOccurredWithinDays(
            global_last_ignore,
            *guardrail_data_->global_mute_after_ignore_days)) {
      return "global_ignore_days_hit";
    }
  }

  // Block if user dismissed the action for any app within N days.
  if (guardrail_data_->global_mute_after_dismiss_days.has_value()) {
    auto global_last_dismiss =
        base::ValueToTime(dict.Find(pref_names_->last_dismiss_time_name));

    if (TimeOccurredWithinDays(
            global_last_dismiss,
            *guardrail_data_->global_mute_after_dismiss_days)) {
      return "global_dismiss_days_hit";
    }
  }
  return std::nullopt;
}

void WebAppPrefGuardrails::UpdateAppSpecificNotAcceptedPrefs(
    const webapps::AppId& app_id,
    base::Time time,
    std::string_view time_path) {
  // TODO(b/313491176): Optimize so that a single ScopedPrefUpdate call takes
  // place instead of 2. Break this up into seaparate functions that increment
  // the integer pref and sset the time pref, and tkaes in a reference to
  // ScopedDictPrefUpdate.
  if (!pref_names_->not_accepted_count_name.empty()) {
    std::optional<int> ignored_count = GetIntWebAppPref(
        pref_service_, app_id, pref_names_->not_accepted_count_name);
    int new_count = base::saturated_cast<int>(1 + ignored_count.value_or(0));
    UpdateIntWebAppPref(app_id, pref_names_->not_accepted_count_name,
                        new_count);
  }
  UpdateTimeWebAppPref(app_id, time_path, time);
}

void WebAppPrefGuardrails::UpdateGlobalNotAcceptedPrefs(
    base::Time time,
    std::string_view time_path) {
  if (!HasGlobalPrefs()) {
    return;
  }
  // TODO(b/313491176): Optimize so that a single ScopedPrefUpdate call takes
  // place instead of 2. Break this up into seaparate functions that increment
  // the integer pref and sset the time pref, and tkaes in a reference to
  // ScopedDictPrefUpdate.
  ScopedDictPrefUpdate update(pref_service_, pref_names_->global_pref_name);
  int global_count =
      update->FindInt(pref_names_->not_accepted_count_name).value_or(0);
  update->Set(pref_names_->not_accepted_count_name,
              base::saturated_cast<int>(global_count + 1));
  update->Set(time_path, base::TimeToValue(time));
}

bool WebAppPrefGuardrails::ShouldResetGlobalGuardrails() {
  // It's possible for a configuration to not have global state.
  if (!HasGlobalPrefs()) {
    return false;
  }
  if (!IsGlobalBlockActive()) {
    return false;
  }

  // If max_days_to_store_guardrails_ is not set, then guardrails need not be
  // reset.
  if (!max_days_to_store_guardrails_.has_value()) {
    return false;
  }

  CHECK(!pref_names_->all_blocked_time_name.empty());
  const base::DictValue& dict =
      pref_service_->GetDict(pref_names_->global_pref_name);
  const base::Value* value =
      dict.FindByDottedPath(pref_names_->all_blocked_time_name);
  if (!value) {
    return false;
  }

  std::optional<base::Time> last_blocked_time = base::ValueToTime(value);

  // We only want to clear the guardrails if max_days_to_store_guardrails_ is
  // crossed.
  return !TimeOccurredWithinDays(last_blocked_time,
                                 *max_days_to_store_guardrails_);
}

void WebAppPrefGuardrails::ResetGlobalGuardrails(const webapps::AppId& app_id) {
  if (!HasGlobalPrefs()) {
    return;
  }
  ScopedDictPrefUpdate update(pref_service_, pref_names_->global_pref_name);
  if (!pref_names_->all_blocked_time_name.empty()) {
    update->Remove(pref_names_->all_blocked_time_name);
  }

  if (!pref_names_->block_reason_name.empty()) {
    update->Remove(pref_names_->block_reason_name);
  }

  if (!pref_names_->not_accepted_count_name.empty()) {
    update->Set(pref_names_->not_accepted_count_name, 0);
  }
}

bool WebAppPrefGuardrails::IsGlobalBlockActive() {
  if (!HasGlobalPrefs()) {
    return false;
  }
  if (pref_names_->all_blocked_time_name.empty()) {
    return false;
  }
  const base::DictValue& dict =
      pref_service_->GetDict(pref_names_->global_pref_name);
  return dict.contains(pref_names_->all_blocked_time_name);
}

void WebAppPrefGuardrails::LogGlobalBlockReason(
    ScopedDictPrefUpdate& global_update,
    const std::string& reason) {
  if (pref_names_->block_reason_name.empty() || !HasGlobalPrefs()) {
    return;
  }

  global_update->Set(pref_names_->block_reason_name, reason);
}

void WebAppPrefGuardrails::UpdateTimeWebAppPref(const webapps::AppId& app_id,
                                                std::string_view path,
                                                base::Time value) {
  ScopedDictPrefUpdate update(pref_service_, prefs::kWebAppsPreferences);

  auto& web_app_prefs = UpdateWebAppDictionary(update, app_id);
  web_app_prefs.SetByDottedPath(path, base::TimeToValue(value));
}

void WebAppPrefGuardrails::UpdateIntWebAppPref(const webapps::AppId& app_id,
                                               std::string_view path,
                                               int value) {
  ScopedDictPrefUpdate update(pref_service_, prefs::kWebAppsPreferences);

  base::DictValue& web_app_prefs = UpdateWebAppDictionary(update, app_id);
  web_app_prefs.SetByDottedPath(path, value);
}

}  // namespace web_app
