// Copyright 2024 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/download/download_warning_desktop_hats_utils.h"

#include <algorithm>
#include <cstdint>
#include <iterator>
#include <string>
#include <vector>

#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/sequenced_task_runner.h"
#include "base/time/time.h"
#include "base/version_info/channel.h"
#include "build/build_config.h"
#include "chrome/browser/download/download_item_model.h"
#include "chrome/browser/download/download_item_warning_data.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/hats/hats_service.h"
#include "chrome/browser/ui/hats/hats_service_factory.h"
#include "chrome/browser/ui/hats/survey_config.h"
#include "chrome/common/channel_info.h"
#include "chrome/common/pref_names.h"
#include "components/download/public/common/download_item.h"
#include "components/prefs/pref_service.h"
#include "components/safe_browsing/buildflags.h"
#include "components/safe_browsing/core/common/features.h"
#include "components/safe_browsing/core/common/safe_browsing_prefs.h"
#include "content/public/browser/download_item_utils.h"

namespace {

// Placeholder strings for fields that are not logged.
constexpr char kNotAvailable[] = "Not available";
constexpr char kNotLoggedNoSafeBrowsing[] =
    "Not logged because Safe Browsing is off";
constexpr char kNotLoggedNoEnhancedProtection[] =
    "Not logged because Enhanced Protection is off";

bool IsDownloadBubbleTrigger(DownloadWarningHatsType type) {
  return type == DownloadWarningHatsType::kDownloadBubbleBypass ||
         type == DownloadWarningHatsType::kDownloadBubbleHeed ||
         type == DownloadWarningHatsType::kDownloadBubbleIgnore;
}

bool IsDownloadsPageTrigger(DownloadWarningHatsType type) {
  return !IsDownloadBubbleTrigger(type);
}

std::string GetOutcomeStringData(DownloadWarningHatsType type) {
  switch (type) {
    case DownloadWarningHatsType::kDownloadBubbleBypass:
    case DownloadWarningHatsType::kDownloadsPageBypass:
      return "Bypassed warning";
    case DownloadWarningHatsType::kDownloadBubbleHeed:
    case DownloadWarningHatsType::kDownloadsPageHeed:
      return "Heeded warning";
    case DownloadWarningHatsType::kDownloadBubbleIgnore:
    case DownloadWarningHatsType::kDownloadsPageIgnore:
      return "Ignored warning";
  }
}

std::string GetSurfaceStringData(DownloadWarningHatsType type) {
  return IsDownloadBubbleTrigger(type) ? "Download bubble" : "Downloads page";
}

std::string GetDangerTypeStringData(const DownloadItemModel& model) {
  std::string danger_type_string =
      GetDownloadDangerTypeString(model.GetDangerType());

  switch (model.GetTailoredWarningType()) {
    case DownloadUIModel::TailoredWarningType::kNoTailoredWarning:
      break;
    case DownloadUIModel::TailoredWarningType::kCookieTheft:
      base::StrAppend(&danger_type_string, {", Cookie theft"});
      break;
    case DownloadUIModel::TailoredWarningType::kSuspiciousArchive:
      base::StrAppend(&danger_type_string, {", Suspicious archive"});
      break;
  }

  return danger_type_string;
}

std::string GetWarningTypeStringData(const DownloadUIModel& model) {
  switch (model.GetDangerUiPattern()) {
    case DownloadUIModel::DangerUiPattern::kNormal:
    case DownloadUIModel::DangerUiPattern::kOther:
      return "None";
    case DownloadUIModel::DangerUiPattern::kDangerous:
      return "Dangerous";
    case DownloadUIModel::DangerUiPattern::kSuspicious:
      return "Suspicious";
  }
}

std::string ElapsedTimeToSecondsString(base::TimeDelta elapsed_time) {
  return base::NumberToString(elapsed_time.InSeconds());
}

std::string SafeBrowsingStateToString(
    safe_browsing::SafeBrowsingState sb_state) {
  switch (sb_state) {
    case safe_browsing::SafeBrowsingState::NO_SAFE_BROWSING:
      return "No Safe Browsing";
    case safe_browsing::SafeBrowsingState::STANDARD_PROTECTION:
      return "Standard Protection";
    case safe_browsing::SafeBrowsingState::ENHANCED_PROTECTION:
      return "Enhanced Protection";
  }
}

// Produces a string consisting of comma-separated action events, each of which
// consists of the surface, action, and relative timestamp (ms) separated by
// colons. The first SHOWN event is included, and is the basis of all
// timestamps, however subsequent SHOWN events are not included.
std::string SerializeWarningActionEvents(
    DownloadItemWarningData::WarningSurface warning_first_shown_surface,
    const std::vector<DownloadItemWarningData::WarningActionEvent>& events) {
  // The first SHOWN event is not stored by DownloadItemWarningData, so we
  // construct it here.
  std::string first_event_string =
      DownloadItemWarningData::WarningActionEvent{
          warning_first_shown_surface,
          DownloadItemWarningData::WarningAction::SHOWN, 0,
          /*is_terminal_action=*/false}
          .ToString();

  std::vector<std::string> event_strings;
  event_strings.reserve(events.size() + 1);
  event_strings.push_back(std::move(first_event_string));
  std::ranges::transform(
      events.begin(), events.end(), std::back_inserter(event_strings),
      [](const DownloadItemWarningData::WarningActionEvent& event) {
        return event.ToString();
      });

  return base::JoinString(std::move(event_strings), ",");
}

}  // namespace

DownloadWarningHatsProductSpecificData::DownloadWarningHatsProductSpecificData(
    DownloadWarningHatsType survey_type)
    : survey_type_(survey_type) {}

DownloadWarningHatsProductSpecificData::DownloadWarningHatsProductSpecificData(
    const DownloadWarningHatsProductSpecificData&) = default;

DownloadWarningHatsProductSpecificData&
DownloadWarningHatsProductSpecificData::operator=(
    const DownloadWarningHatsProductSpecificData&) = default;

DownloadWarningHatsProductSpecificData::DownloadWarningHatsProductSpecificData(
    DownloadWarningHatsProductSpecificData&&) = default;

DownloadWarningHatsProductSpecificData&
DownloadWarningHatsProductSpecificData::operator=(
    DownloadWarningHatsProductSpecificData&&) = default;

DownloadWarningHatsProductSpecificData::
    ~DownloadWarningHatsProductSpecificData() = default;

// static
DownloadWarningHatsProductSpecificData
DownloadWarningHatsProductSpecificData::Create(
    DownloadWarningHatsType survey_type,
    download::DownloadItem* download_item) {
  CHECK(download_item);
  CHECK(CanShowDownloadWarningHatsSurvey(download_item));

  DownloadWarningHatsProductSpecificData psd{survey_type};

  // Add placeholders for the fields that must be added later, to avoid CHECKing
  // even if they are forgotten.
  if (IsDownloadBubbleTrigger(survey_type)) {
    psd.bits_data_.insert({Fields::kPartialViewInteraction, false});
  }
  if (IsDownloadsPageTrigger(survey_type)) {
    psd.string_data_.insert({Fields::kNumPageWarnings, kNotAvailable});
  }

  psd.string_data_.insert(
      {Fields::kChannel,
       std::string(version_info::GetChannelString(chrome::GetChannel()))});

  psd.string_data_.insert(
      {Fields::kOutcome, GetOutcomeStringData(survey_type)});
  psd.string_data_.insert(
      {Fields::kSurface, GetSurfaceStringData(survey_type)});

  psd.string_data_.insert(
      {Fields::kSecondsSinceDownloadStarted,
       ElapsedTimeToSecondsString(base::Time::Now() -
                                  download_item->GetStartTime())});

  base::Time warning_shown_time =
      DownloadItemWarningData::WarningFirstShownTime(download_item);
  if (!warning_shown_time.is_null()) {
    psd.string_data_.insert(
        {Fields::kSecondsSinceWarningShown,
         ElapsedTimeToSecondsString(base::Time::Now() - warning_shown_time)});
  } else {
    psd.string_data_.insert({Fields::kSecondsSinceWarningShown, kNotAvailable});
  }

  psd.bits_data_.insert(
      {Fields::kUserGesture, download_item->HasUserGesture()});

  // This won't be used for generating any strings, so it's ok not to use the
  // correct StatusTextBuilder.
  DownloadItemModel download_model(download_item);

  psd.string_data_.insert(
      {Fields::kDangerType, GetDangerTypeStringData(download_model)});
  psd.string_data_.insert(
      {Fields::kWarningType, GetWarningTypeStringData(download_model)});

  if (survey_type == DownloadWarningHatsType::kDownloadBubbleIgnore) {
    psd.string_data_.insert(
        {Fields::kIgnoreTimeoutSeconds,
         ElapsedTimeToSecondsString(GetIgnoreDownloadBubbleWarningDelay())});
  }

  // Assemble the Profile-dependent PSD.
  Profile* profile = Profile::FromBrowserContext(
      content::DownloadItemUtils::GetBrowserContext(download_item));
  if (!profile) {
    psd.string_data_.insert({Fields::kSafeBrowsingState, kNotAvailable});
    psd.string_data_.insert({Fields::kPartialViewEnabled, kNotAvailable});
    psd.string_data_.insert({Fields::kUrlDownload, kNotAvailable});
    psd.string_data_.insert({Fields::kUrlReferrer, kNotAvailable});
    psd.string_data_.insert({Fields::kFilename, kNotAvailable});
    psd.string_data_.insert({Fields::kWarningInteractions, kNotAvailable});
    return psd;
  }

  psd.string_data_.insert(
      {Fields::kSafeBrowsingState,
       SafeBrowsingStateToString(
           safe_browsing::GetSafeBrowsingState(*profile->GetPrefs()))});

  psd.bits_data_.insert({Fields::kPartialViewEnabled,
                         profile->GetPrefs()->GetBoolean(
                             prefs::kDownloadBubblePartialViewEnabled)});

  // URL and filename logged only for Safe Browsing users.
  if (safe_browsing::IsSafeBrowsingEnabled(*profile->GetPrefs())) {
    psd.string_data_.insert({Fields::kUrlDownload,
                             download_item->GetURL().possibly_invalid_spec()});
    psd.string_data_.insert(
        {Fields::kUrlReferrer,
         download_item->GetReferrerUrl().possibly_invalid_spec()});
    psd.string_data_.insert(
        {Fields::kFilename,
         base::UTF16ToUTF8(
             download_item->GetFileNameToReportUser().LossyDisplayName())});
  } else {
    psd.string_data_.insert({Fields::kUrlDownload, kNotLoggedNoSafeBrowsing});
    psd.string_data_.insert({Fields::kUrlReferrer, kNotLoggedNoSafeBrowsing});
    psd.string_data_.insert({Fields::kFilename, kNotLoggedNoSafeBrowsing});
  }

  // Interaction details logged only for ESB users.
  std::optional<DownloadItemWarningData::WarningSurface>
      warning_first_shown_surface =
          DownloadItemWarningData::WarningFirstShownSurface(download_item);
  if (warning_first_shown_surface &&
      safe_browsing::IsEnhancedProtectionEnabled(*profile->GetPrefs())) {
    std::vector<DownloadItemWarningData::WarningActionEvent>
        warning_action_events =
            DownloadItemWarningData::GetWarningActionEvents(download_item);
    psd.string_data_.insert(
        {Fields::kWarningInteractions,
         SerializeWarningActionEvents(*warning_first_shown_surface,
                                      warning_action_events)});
  } else {
    psd.string_data_.insert(
        {Fields::kWarningInteractions, kNotLoggedNoEnhancedProtection});
  }

  return psd;
}

void DownloadWarningHatsProductSpecificData::AddNumPageWarnings(int num) {
  if (IsDownloadsPageTrigger(survey_type_)) {
    string_data_.insert_or_assign(Fields::kNumPageWarnings,
                                  base::NumberToString(num));
  }
}

void DownloadWarningHatsProductSpecificData::AddPartialViewInteraction(
    bool partial_view_interaction) {
  if (IsDownloadBubbleTrigger(survey_type_)) {
    bits_data_.insert_or_assign(Fields::kPartialViewInteraction,
                                partial_view_interaction);
  }
}

// static
std::vector<std::string>
DownloadWarningHatsProductSpecificData::GetBitsDataFields(
    DownloadWarningHatsType survey_type) {
  std::vector<std::string> fields = {Fields::kPartialViewEnabled,
                                     Fields::kUserGesture};
  if (IsDownloadBubbleTrigger(survey_type)) {
    fields.push_back(Fields::kPartialViewInteraction);
  }
  return fields;
}

// static
std::vector<std::string>
DownloadWarningHatsProductSpecificData::GetStringDataFields(
    DownloadWarningHatsType survey_type) {
  std::vector<std::string> fields = {
      Fields::kOutcome,
      Fields::kSurface,
      Fields::kDangerType,
      Fields::kWarningType,
      Fields::kSafeBrowsingState,
      Fields::kChannel,
      Fields::kWarningInteractions,
      Fields::kSecondsSinceDownloadStarted,
      Fields::kSecondsSinceWarningShown,
      Fields::kUrlDownload,
      Fields::kUrlReferrer,
      Fields::kFilename,
      // TODO(chlily): Add kIgnoreTimeout.
  };
  if (IsDownloadsPageTrigger(survey_type)) {
    fields.push_back(Fields::kNumPageWarnings);
  }
  if (survey_type == DownloadWarningHatsType::kDownloadBubbleIgnore) {
    fields.push_back(Fields::kIgnoreTimeoutSeconds);
  }
  return fields;
}

DelayedDownloadWarningHatsLauncher::Task::Task(
    DelayedDownloadWarningHatsLauncher& hats_launcher,
    download::DownloadItem* download,
    base::OnceClosure task,
    base::TimeDelta delay)
    : observation_(&hats_launcher), task_(std::move(task)) {
  observation_.Observe(download);
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE, base::BindOnce(&Task::RunTask, weak_factory_.GetWeakPtr()),
      delay);
}

DelayedDownloadWarningHatsLauncher::Task::~Task() = default;

// It is expected that the caller will delete this after this executes.
void DelayedDownloadWarningHatsLauncher::Task::RunTask() {
  CHECK(task_);
  std::move(task_).Run();
}

DelayedDownloadWarningHatsLauncher::DelayedDownloadWarningHatsLauncher(
    Profile* profile,
    base::TimeDelta delay,
    PsdCompleter psd_completer)
    : profile_(profile),
      delay_(delay),
      psd_completer_(std::move(psd_completer)) {}

DelayedDownloadWarningHatsLauncher::~DelayedDownloadWarningHatsLauncher() =
    default;

void DelayedDownloadWarningHatsLauncher::OnDownloadUpdated(
    download::DownloadItem* download) {
  // If the formerly-eligible download is no longer eligible, cancel the survey.
  if (!CanShowDownloadWarningHatsSurvey(download)) {
    RemoveTaskIfAny(download);
  }
}

void DelayedDownloadWarningHatsLauncher::OnDownloadDestroyed(
    download::DownloadItem* download) {
  RemoveTaskIfAny(download);
}

void DelayedDownloadWarningHatsLauncher::RecordBrowserActivity() {
  last_activity_ = base::Time::Now();
}

bool DelayedDownloadWarningHatsLauncher::TryScheduleTask(
    DownloadWarningHatsType survey_type,
    download::DownloadItem* download) {
  CHECK(download);
  TaskKey key = GetTaskKey(download);
  if (tasks_.contains(key)) {
    return false;
  }

  if (!CanShowDownloadWarningHatsSurvey(download)) {
    return false;
  }

  auto [_, inserted] = tasks_.try_emplace(
      key, *this, download,
      // Unretained is safe because `this` outlives the Task object.
      // `download` will be valid for as long as the Task lives, because the
      // Observer mechanism will delete the Task if `download` goes away.
      base::BindOnce(&DelayedDownloadWarningHatsLauncher::MaybeLaunchSurveyNow,
                     base::Unretained(this), survey_type, download),
      delay_);
  return inserted;
}

void DelayedDownloadWarningHatsLauncher::RemoveTaskIfAny(
    download::DownloadItem* download) {
  RemoveTaskByKeyIfAny(GetTaskKey(download));
}

DelayedDownloadWarningHatsLauncher::TaskKey
DelayedDownloadWarningHatsLauncher::GetTaskKey(
    download::DownloadItem* download) {
  return reinterpret_cast<TaskKey>(download);
}

void DelayedDownloadWarningHatsLauncher::RemoveTaskByKeyIfAny(TaskKey key) {
  tasks_.erase(key);
}

void DelayedDownloadWarningHatsLauncher::MaybeLaunchSurveyNow(
    DownloadWarningHatsType survey_type,
    download::DownloadItem* download) {
  if (!CanShowDownloadWarningHatsSurvey(download) || !WasUserActive()) {
    RemoveTaskIfAny(download);
    return;
  }

  auto psd =
      DownloadWarningHatsProductSpecificData::Create(survey_type, download);
  if (psd_completer_) {
    psd_completer_.Run(psd);
  }

  MaybeLaunchDownloadWarningHatsSurvey(profile_, psd,
                                       MakeSurveyDoneCallback(download),
                                       MakeSurveyDoneCallback(download));
}

base::OnceClosure DelayedDownloadWarningHatsLauncher::MakeSurveyDoneCallback(
    download::DownloadItem* download) {
  // This is needed to clean up the Task object after the survey runs. It must
  // be bound to a WeakPtr because nothing guarantees that this will be alive
  // when the survey task finishes (it generally takes a few seconds to actually
  // show the survey, and obviously takes much longer for the user to work
  // through the survey). If this callback runs, then `this` must still be
  // alive, which means the DownloadItem::Observer mechanism is maintaining the
  // invariant that any download with an entry in `tasks_` must be alive.
  // Therefore, the DownloadItem* will not be used after it is freed.
  return base::BindOnce(
      &DelayedDownloadWarningHatsLauncher::RemoveTaskByKeyIfAny,
      weak_factory_.GetWeakPtr(), GetTaskKey(download));
}

bool DelayedDownloadWarningHatsLauncher::WasUserActive() const {
  return base::Time::Now() - last_activity_ <= delay_;
}

bool CanShowDownloadWarningHatsSurvey(download::DownloadItem* download) {
  CHECK(download);
  return download->IsDangerous() && !download->IsDone();
}

std::optional<std::string> MaybeGetDownloadWarningHatsTrigger(
    DownloadWarningHatsType survey_type) {
  if (!base::FeatureList::IsEnabled(safe_browsing::kDownloadWarningSurvey)) {
    return std::nullopt;
  }

  const int eligible_survey_type =
      safe_browsing::kDownloadWarningSurveyType.Get();

  // Configuration error.
  if (eligible_survey_type < 0 ||
      eligible_survey_type >
          static_cast<int>(DownloadWarningHatsType::kMaxValue)) {
    return std::nullopt;
  }

  // User is not assigned to be eligible for this type.
  if (static_cast<DownloadWarningHatsType>(eligible_survey_type) !=
      survey_type) {
    return std::nullopt;
  }

  switch (survey_type) {
    case DownloadWarningHatsType::kDownloadBubbleBypass:
      return kHatsSurveyTriggerDownloadWarningBubbleBypass;
    case DownloadWarningHatsType::kDownloadBubbleHeed:
      return kHatsSurveyTriggerDownloadWarningBubbleHeed;
    case DownloadWarningHatsType::kDownloadBubbleIgnore:
      return kHatsSurveyTriggerDownloadWarningBubbleIgnore;
    case DownloadWarningHatsType::kDownloadsPageBypass:
      return kHatsSurveyTriggerDownloadWarningPageBypass;
    case DownloadWarningHatsType::kDownloadsPageHeed:
      return kHatsSurveyTriggerDownloadWarningPageHeed;
    case DownloadWarningHatsType::kDownloadsPageIgnore:
      return kHatsSurveyTriggerDownloadWarningPageIgnore;
  }
}

base::TimeDelta GetIgnoreDownloadBubbleWarningDelay() {
  return base::Seconds(
      safe_browsing::kDownloadWarningSurveyIgnoreDelaySeconds.Get());
}

void MaybeLaunchDownloadWarningHatsSurvey(
    Profile* profile,
    const DownloadWarningHatsProductSpecificData& psd,
    base::OnceClosure success_callback,
    base::OnceClosure failure_callback) {
  std::optional<std::string> trigger =
      MaybeGetDownloadWarningHatsTrigger(psd.survey_type());
  if (!trigger) {
    return;
  }

  HatsService* hats_service =
      HatsServiceFactory::GetForProfile(profile, /*create_if_necessary=*/true);
  if (hats_service) {
    hats_service->LaunchSurvey(*trigger, std::move(success_callback),
                               std::move(failure_callback), psd.bits_data(),
                               psd.string_data());
  }
}
