// Copyright 2017 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/assist_ranker/ranker_model_loader_impl.h"

#include <utility>

#include <memory>

#include "base/bind.h"
#include "base/bind_helpers.h"
#include "base/command_line.h"
#include "base/files/file_util.h"
#include "base/files/important_file_writer.h"
#include "base/macros.h"
#include "base/metrics/histogram_macros.h"
#include "base/sequenced_task_runner.h"
#include "base/strings/string_util.h"
#include "base/task_runner_util.h"
#include "base/task_scheduler/post_task.h"
#include "base/threading/sequenced_task_runner_handle.h"
#include "components/assist_ranker/proto/ranker_model.pb.h"
#include "components/assist_ranker/ranker_url_fetcher.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"

namespace assist_ranker {
namespace {

// The minimum duration, in minutes, between download attempts.
constexpr int kMinRetryDelayMins = 3;

// Suffixes for the various histograms produced by the backend.
const char kWriteTimerHistogram[] = ".Timer.WriteModel";
const char kReadTimerHistogram[] = ".Timer.ReadModel";
const char kDownloadTimerHistogram[] = ".Timer.DownloadModel";
const char kParsetimerHistogram[] = ".Timer.ParseModel";
const char kModelStatusHistogram[] = ".Model.Status";

// Helper function to UMA log a timer histograms.
void RecordTimerHistogram(const std::string& name, base::TimeDelta duration) {
  base::HistogramBase* counter = base::Histogram::FactoryTimeGet(
      name, base::TimeDelta::FromMilliseconds(10),
      base::TimeDelta::FromMilliseconds(200000), 100,
      base::HistogramBase::kUmaTargetedHistogramFlag);
  DCHECK(counter);
  counter->AddTime(duration);
}

// A helper class to produce a scoped timer histogram that supports using a
// non-static-const name.
class MyScopedHistogramTimer {
 public:
  MyScopedHistogramTimer(const base::StringPiece& name)
      : name_(name.begin(), name.end()), start_(base::TimeTicks::Now()) {}

  ~MyScopedHistogramTimer() {
    RecordTimerHistogram(name_, base::TimeTicks::Now() - start_);
  }

 private:
  const std::string name_;
  const base::TimeTicks start_;

  DISALLOW_COPY_AND_ASSIGN(MyScopedHistogramTimer);
};

std::string LoadFromFile(const base::FilePath& model_path) {
  DCHECK(!model_path.empty());
  DVLOG(2) << "Reading data from: " << model_path.value();
  std::string data;
  if (!base::ReadFileToString(model_path, &data) || data.empty()) {
    DVLOG(2) << "Failed to read data from: " << model_path.value();
    data.clear();
  }
  return data;
}

void SaveToFile(const GURL& model_url,
                const base::FilePath& model_path,
                const std::string& model_data,
                const std::string& uma_prefix) {
  DVLOG(2) << "Saving model from '" << model_url << "'' to '"
           << model_path.value() << "'.";
  MyScopedHistogramTimer timer(uma_prefix + kWriteTimerHistogram);
  base::ImportantFileWriter::WriteFileAtomically(model_path, model_data);
}

}  // namespace

RankerModelLoaderImpl::RankerModelLoaderImpl(
    ValidateModelCallback validate_model_cb,
    OnModelAvailableCallback on_model_available_cb,
    scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
    base::FilePath model_path,
    GURL model_url,
    std::string uma_prefix)
    : background_task_runner_(base::CreateSequencedTaskRunnerWithTraits(
          {base::MayBlock(), base::TaskPriority::BACKGROUND,
           base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN})),
      validate_model_cb_(std::move(validate_model_cb)),
      on_model_available_cb_(std::move(on_model_available_cb)),
      url_loader_factory_(std::move(url_loader_factory)),
      model_path_(std::move(model_path)),
      model_url_(std::move(model_url)),
      uma_prefix_(std::move(uma_prefix)),
      url_fetcher_(std::make_unique<RankerURLFetcher>()),
      weak_ptr_factory_(this) {}

RankerModelLoaderImpl::~RankerModelLoaderImpl() {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
}

void RankerModelLoaderImpl::NotifyOfRankerActivity() {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  switch (state_) {
    case LoaderState::NOT_STARTED:
      if (!model_path_.empty()) {
        StartLoadFromFile();
        break;
      }
      // There was no configured model path. Switch the state to IDLE and
      // fall through to consider the URL.
      state_ = LoaderState::IDLE;
      FALLTHROUGH;
    case LoaderState::IDLE:
      if (model_url_.is_valid()) {
        StartLoadFromURL();
        break;
      }
      // There was no configured model URL. Switch the state to FINISHED and
      // fall through.
      state_ = LoaderState::FINISHED;
      FALLTHROUGH;
    case LoaderState::FINISHED:
    case LoaderState::LOADING_FROM_FILE:
    case LoaderState::LOADING_FROM_URL:
      // Nothing to do.
      break;
  }
}

void RankerModelLoaderImpl::StartLoadFromFile() {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  DCHECK_EQ(state_, LoaderState::NOT_STARTED);
  DCHECK(!model_path_.empty());
  state_ = LoaderState::LOADING_FROM_FILE;
  load_start_time_ = base::TimeTicks::Now();
  base::PostTaskAndReplyWithResult(
      background_task_runner_.get(), FROM_HERE,
      base::BindOnce(&LoadFromFile, model_path_),
      base::BindOnce(&RankerModelLoaderImpl::OnFileLoaded,
                     weak_ptr_factory_.GetWeakPtr()));
}

void RankerModelLoaderImpl::OnFileLoaded(const std::string& data) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  DCHECK_EQ(state_, LoaderState::LOADING_FROM_FILE);

  // Record the duration of the download.
  RecordTimerHistogram(uma_prefix_ + kReadTimerHistogram,
                       base::TimeTicks::Now() - load_start_time_);

  // Empty data means |model_path| wasn't successfully read. Otherwise,
  // parse and validate the model.
  std::unique_ptr<RankerModel> model;
  if (data.empty()) {
    ReportModelStatus(RankerModelStatus::LOAD_FROM_CACHE_FAILED);
  } else {
    model = CreateAndValidateModel(data);
  }

  // If |model| is nullptr, then data is empty or the parse failed. Transition
  // to IDLE, from which URL download can be attempted.
  if (!model) {
    state_ = LoaderState::IDLE;
  } else {
    // The model is valid. The client is willing/able to use it. Keep track
    // of where it originated and whether or not is has expired.
    std::string url_spec = model->GetSourceURL();
    bool is_expired = model->IsExpired();
    bool is_finished = url_spec == model_url_.spec() && !is_expired;

    DVLOG(2) << (is_expired ? "Expired m" : "M") << "odel in '"
             << model_path_.value() << "' was originally downloaded from '"
             << url_spec << "'.";

    // If the cached model came from currently configured |model_url_| and has
    // not expired, transition to FINISHED, as there is no need for a model
    // download; otherwise, transition to IDLE.
    state_ = is_finished ? LoaderState::FINISHED : LoaderState::IDLE;

    // Transfer the model to the client.
    on_model_available_cb_.Run(std::move(model));
  }

  // Notify the state machine. This will immediately kick off a download if
  // one is required, instead of waiting for the next organic detection of
  // ranker activity.
  NotifyOfRankerActivity();
}

void RankerModelLoaderImpl::StartLoadFromURL() {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  DCHECK_EQ(state_, LoaderState::IDLE);
  DCHECK(model_url_.is_valid());

  // Do nothing if the download attempts should be throttled.
  if (base::TimeTicks::Now() < next_earliest_download_time_) {
    DVLOG(2) << "Last download attempt was too recent.";
    ReportModelStatus(RankerModelStatus::DOWNLOAD_THROTTLED);
    return;
  }

  // Kick off the next download attempt and reset the time of the next earliest
  // allowable download attempt.
  DVLOG(2) << "Downloading model from: " << model_url_;
  state_ = LoaderState::LOADING_FROM_URL;
  load_start_time_ = base::TimeTicks::Now();
  next_earliest_download_time_ =
      load_start_time_ + base::TimeDelta::FromMinutes(kMinRetryDelayMins);
  bool request_started =
      url_fetcher_->Request(model_url_,
                            base::BindOnce(&RankerModelLoaderImpl::OnURLFetched,
                                           weak_ptr_factory_.GetWeakPtr()),
                            url_loader_factory_.get());

  // |url_fetcher_| maintains a request retry counter. If all allowed attempts
  // have already been exhausted, then the loader is finished and has abandoned
  // loading the model.
  if (!request_started) {
    DVLOG(2) << "Model download abandoned.";
    ReportModelStatus(RankerModelStatus::MODEL_LOADING_ABANDONED);
    state_ = LoaderState::FINISHED;
  }
}

void RankerModelLoaderImpl::OnURLFetched(bool success,
                                         const std::string& data) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  DCHECK_EQ(state_, LoaderState::LOADING_FROM_URL);

  // Record the duration of the download.
  RecordTimerHistogram(uma_prefix_ + kDownloadTimerHistogram,
                       base::TimeTicks::Now() - load_start_time_);

  // On request failure, transition back to IDLE. The loader will retry, or
  // enforce the max download attempts, later.
  if (!success || data.empty()) {
    DVLOG(2) << "Download from '" << model_url_ << "'' failed.";
    ReportModelStatus(RankerModelStatus::DOWNLOAD_FAILED);
    state_ = LoaderState::IDLE;
    return;
  }

  // Attempt to loads the model. If this fails, transition back to IDLE. The
  // loader will retry, or enfore the max download attempts, later.
  auto model = CreateAndValidateModel(data);
  if (!model) {
    DVLOG(2) << "Model from '" << model_url_ << "'' not valid.";
    state_ = LoaderState::IDLE;
    return;
  }

  // The model is valid. Update the metadata to track the source URL and
  // download timestamp.
  auto* metadata = model->mutable_proto()->mutable_metadata();
  metadata->set_source(model_url_.spec());
  metadata->set_last_modified_sec(
      (base::Time::Now() - base::Time()).InSeconds());

  // Cache the model to model_path_, in the background.
  if (!model_path_.empty()) {
    background_task_runner_->PostTask(
        FROM_HERE, base::BindOnce(&SaveToFile, model_url_, model_path_,
                                  model->SerializeAsString(), uma_prefix_));
  }

  // The loader is finished. Transfer the model to the client.
  state_ = LoaderState::FINISHED;
  on_model_available_cb_.Run(std::move(model));
}

std::unique_ptr<RankerModel> RankerModelLoaderImpl::CreateAndValidateModel(
    const std::string& data) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  MyScopedHistogramTimer timer(uma_prefix_ + kParsetimerHistogram);
  auto model = RankerModel::FromString(data);
  if (ReportModelStatus(model ? validate_model_cb_.Run(*model)
                              : RankerModelStatus::PARSE_FAILED) !=
      RankerModelStatus::OK) {
    return nullptr;
  }
  return model;
}

RankerModelStatus RankerModelLoaderImpl::ReportModelStatus(
    RankerModelStatus model_status) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  base::HistogramBase* histogram = base::LinearHistogram::FactoryGet(
      uma_prefix_ + kModelStatusHistogram, 1,
      static_cast<int>(RankerModelStatus::MAX),
      static_cast<int>(RankerModelStatus::MAX) + 1,
      base::HistogramBase::kUmaTargetedHistogramFlag);
  if (histogram)
    histogram->Add(static_cast<int>(model_status));
  return model_status;
}

}  // namespace assist_ranker
