// 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.

#import "ios/chrome/browser/variations/model/ios_chrome_variations_seed_fetcher.h"

#import <string>
#import <string_view>

#import "base/containers/fixed_flat_set.h"
#import "base/feature_list.h"
#import "base/logging.h"
#import "base/metrics/histogram_functions.h"
#import "base/notreached.h"
#import "base/strings/escape.h"
#import "base/strings/strcat.h"
#import "base/strings/string_util.h"
#import "base/strings/sys_string_conversions.h"
#import "base/time/time.h"
#import "build/branding_buildflags.h"
#import "components/variations/seed_response.h"
#import "components/variations/variations_switches.h"
#import "components/variations/variations_url_constants.h"
#import "components/version_info/version_info.h"
#import "ios/chrome/browser/variations/model/constants.h"
#import "ios/chrome/browser/variations/model/ios_chrome_variations_seed_fetcher+testing.h"
#import "ios/chrome/browser/variations/model/ios_chrome_variations_seed_store+fetcher.h"
#import "ios/chrome/browser/variations/model/ios_chrome_variations_seed_store.h"
#import "ios/chrome/common/channel_info.h"
#import "net/http/http_status_code.h"

namespace {

// Maximum time allowed to fetch the seed before the request is cancelled.
const base::TimeDelta kRequestTimeout = base::Seconds(1.5);
// Histogram names for seed fetch time and result.
const char kSeedFetchResultHistogram[] =
    "IOS.Variations.FirstRun.SeedFetchResult";
const char kSeedFetchTimeHistogram[] = "IOS.Variations.FirstRun.SeedFetchTime";

// Whether a current request for variations seed is being made.
//
// This variable exists so that only one instance of the manager updates the
// global seed at one time. It is access in the static serial queue
// "*.first_run_variations_seed_manager" at the start of each task in the queue.
// If the value is NO, it's set to YES and keep executing the task; otherwise,
// it aborts the task to make sure the fetch result won't be overridden.
static BOOL g_seed_fetching_in_progress = NO;

// Returns the platform for which a variations seed should be fetched. Considers
// the --fake-variations-platform switch.
std::string GetOsName(const std::string& forced_platform) {
  if (!forced_platform.empty()) {
    static constexpr auto kPlatforms = base::MakeFixedFlatSet<std::string_view>(
        {"android", "android_webview", "chromeos", "fuchsia", "ios", "linux",
         "mac", "win"});
    if (kPlatforms.contains(forced_platform)) {
      return forced_platform;
    } else {
      DVLOG(1) << "Invalid platform provided: " << forced_platform;
    }
  }
  return "ios";
}

// Returns the trimmed and URL-escaped value of the given string.
std::string GetEscapedValue(std::string_view value) {
  return base::EscapeQueryParamValue(
      base::TrimWhitespaceASCII(value, base::TrimPositions::TRIM_ALL),
      /*use_plus=*/false);
}

}  // namespace

BASE_FEATURE(kVariationsExperimentalCorpus, base::FEATURE_DISABLED_BY_DEFAULT);
BASE_FEATURE(kVariationsRestrictDogfood, base::FEATURE_DISABLED_BY_DEFAULT);

@implementation IOSChromeVariationsSeedFetcher {
  // Whether the current binary should fetch Finch seed for experiment purpose.
  // Accessed on the main thread.
  BOOL _fetchingEnabled;

  // The variations server domain name. Accessed on the main thread.
  std::string _variationsDomain;

  // The forced channel and platform strings retrieved from the command line.
  // Accessed on the main thread.
  std::string _forcedChannel;
  std::string _forcedPlatform;

  // The timestamp when the current seed request starts. This is used for metric
  // reporting, and will be reset to null value when the request finishes.
  // Accessed in the static serial queue "*.first_run_variations_seed_manager".
  base::Time _startTimeOfOngoingSeedRequest;
}

#pragma mark - Public

- (instancetype)initWithArguments:(NSArray<NSString*>*)arguments {
  self = [super init];
  if (self) {
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
    _fetchingEnabled = YES;
#else
    _fetchingEnabled = NO;
#endif
    _variationsDomain = variations::kDefaultServerUrl;
    _forcedChannel = std::string();
    _forcedPlatform = std::string();

    std::string url_switch =
        base::StrCat({"--", variations::switches::kVariationsServerURL, "="});
    std::string channel_switch =
        base::StrCat({"--", variations::switches::kFakeVariationsChannel, "="});
    std::string platform_switch = base::StrCat(
        {"--", variations::switches::kFakeVariationsPlatform, "="});
    for (NSString* a in arguments) {
      std::string arg_string = base::SysNSStringToUTF8(a);

      // Use a view of `arg_string`to avoid unnecessary substr copies.
      std::string_view arg(arg_string);

      if (base::StartsWith(arg, url_switch)) {
        _variationsDomain = arg.substr(url_switch.size());
        if (!_fetchingEnabled && !_variationsDomain.empty()) {
          _fetchingEnabled = YES;
        }
      } else if (base::StartsWith(arg, channel_switch)) {
        _forcedChannel = GetEscapedValue(arg.substr(channel_switch.size()));
      } else if (base::StartsWith(arg, platform_switch)) {
        _forcedPlatform = GetEscapedValue(arg.substr(platform_switch.size()));
      }
    }
  }
  return self;
}

- (instancetype)init {
  return [self initWithArguments:[[NSProcessInfo processInfo] arguments]];
}

- (void)startSeedFetch {
  if (!_fetchingEnabled) {
    // Stops executing if seed fetching is disabled.
    [self notifyDelegateSeedFetchResult:NO];
    return;
  }
  // Set up a serial queue to to avoid concurrent read/write to static data.
  static dispatch_once_t onceToken;
  static dispatch_queue_t queue = nil;
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
  const char* label = "com.google.chrome.first_run_variations_seed_manager";
#else
  const char* label = "org.chromium.first_run_variations_seed_manager";
#endif
  dispatch_once(&onceToken, ^{
    queue = dispatch_queue_create(label, DISPATCH_QUEUE_SERIAL);
  });

  // Adds the task of fetching the seed to the static serial queue, and return
  // from `doActualFetch` immediately. Note that the block will retain `self`.
  dispatch_async(queue, ^{
    if (g_seed_fetching_in_progress) {
      NOTREACHED() << "SeedFetch started while already in progress";
    } else {
      [self doActualFetch];
    }
  });
}

#pragma mark - Private

// The URL of the variations server, including query parameters that identifies
// the request initiator. Accessed in the static serial queue
// "*.first_run_variations_seed_manager".
- (NSURL*)variationsURL {
  // Construct the variations seed request URL. The URL contains the "osname",
  // "milestone", "channel" and "corpus" as parameters. Dogfood experimenting
  // is not supported on Chrome iOS, therefore we do not need the "restrict"
  // parameter.
  const std::string channel =
      _forcedChannel.empty()
          ? (GetChannel() != version_info::Channel::UNKNOWN ? GetChannelString()
                                                            : "")
          : _forcedChannel;
  std::string url =
      base::StrCat({_variationsDomain, "?osname=", GetOsName(_forcedPlatform),
                    "&milestone=", version_info::GetMajorVersionNumber()});
  if (!channel.empty()) {
    base::StrAppend(&url, {"&channel=", channel});
  }
  if (base::FeatureList::IsEnabled(kVariationsExperimentalCorpus)) {
    base::StrAppend(&url, {"&corpus=experimental"});
  }
  if (base::FeatureList::IsEnabled(kVariationsRestrictDogfood)) {
    base::StrAppend(&url, {"&restrict=dogfood"});
  }

  return [NSURL URLWithString:base::SysUTF8ToNSString(url)];
}

// Helper method for `startSeedFetch` that initiates an HTTPS request to the
// Finch server in the static serial queue
// "*.first_run_variations_seed_manager".
//
// Note that if this method is invoked, the seed would be fetched regardless of
// `_fetchingEnabled`, so please use with caution.
- (void)doActualFetch {
  g_seed_fetching_in_progress = YES;
  NSMutableURLRequest* request = [NSMutableURLRequest
       requestWithURL:[self variationsURL]
          cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
      timeoutInterval:kRequestTimeout.InSecondsF()];
  // Pass only "gzip" as an accepted format. Do not pass delta compression
  // ("x-bm"), as it is not applicable on first run (since there is no
  // existing seed).
  [request setValue:@"gzip" forHTTPHeaderField:@"A-IM"];
  NSURLSessionDataTask* task = [[NSURLSession sharedSession]
      dataTaskWithRequest:request
        completionHandler:^(NSData* data, NSURLResponse* response,
                            NSError* error) {
          [self seedRequestDidCompleteWithData:data
                                      response:(NSHTTPURLResponse*)response
                                         error:error];
        }];
  _startTimeOfOngoingSeedRequest = base::Time::Now();
  [task resume];
}

// Completion handler of a URL session task. It generates the seed using the
// HTTPS response sent back from the Finch server, stores them in the shared
// seed, and records relevant metrics.
- (void)seedRequestDidCompleteWithData:(NSData*)data
                              response:(NSHTTPURLResponse*)httpResponse
                                 error:(NSError*)error {
  // Normally net::HTTP_NOT_MODIFIED should be considered as a
  // successful response, but it is not expected when the request does
  // not contain "If-None-Match" header.
  BOOL success = error == nil && httpResponse.statusCode == net::HTTP_OK;
  IOSSeedFetchException exception = IOSSeedFetchException::kNotApplicable;
  if (success) {
    DCHECK(!_startTimeOfOngoingSeedRequest.is_null());
    base::UmaHistogramTimes(kSeedFetchTimeHistogram,
                            base::Time::Now() - _startTimeOfOngoingSeedRequest);
    std::unique_ptr<variations::SeedResponse> seed =
        [self seedResponseForHTTPResponse:httpResponse data:data];
    if (seed) {
      [IOSChromeVariationsSeedStore updateSharedSeed:std::move(seed)];
    } else {
      // Currently, only the IM header is mandatory to create a first run
      // seed, and is the only possible reason that a seed is downloaded but
      // not created.
      exception = IOSSeedFetchException::kInvalidIMHeader;
      success = NO;
    }
  } else if (error.code == NSURLErrorTimedOut) {
    exception = IOSSeedFetchException::kHTTPSRequestTimeout;
  } else if (error.code == NSURLErrorBadURL ||
             error.code == NSURLErrorDNSLookupFailed ||
             error.code == NSURLErrorCannotFindHost) {
    exception = IOSSeedFetchException::kHTTPSRequestBadUrl;
  }
  _startTimeOfOngoingSeedRequest = base::Time();
  g_seed_fetching_in_progress = NO;

  // Log seed fetch result on UMA and notify delegate.
  int seedFetchResultValue = exception == IOSSeedFetchException::kNotApplicable
                                 ? static_cast<int>(httpResponse.statusCode)
                                 : static_cast<int>(exception);
  base::UmaHistogramSparse(kSeedFetchResultHistogram, seedFetchResultValue);
  [self notifyDelegateSeedFetchResult:success];
}

// Generates and returns the SeedResponse by parsing the HTTP response returned
// by the variations server. Returns `nil` if the HTTP response is invalid.
// Invoked in the completion handler of a URL session task.
- (std::unique_ptr<variations::SeedResponse>)
    seedResponseForHTTPResponse:(NSHTTPURLResponse*)httpResponse
                           data:(NSData*)data {
  NSString* signature =
      [httpResponse valueForHTTPHeaderField:@"X-Seed-Signature"];
  NSString* country = [httpResponse valueForHTTPHeaderField:@"X-Country"];
  NSString* dateString = [httpResponse valueForHTTPHeaderField:@"Date"];
  base::Time date;
  BOOL dateParsed = base::Time::FromUTCString(
      base::SysNSStringToUTF8(dateString).c_str(), &date);

  // Returned seed should have been gzip compressed.
  NSCharacterSet* whitespace = [NSCharacterSet whitespaceCharacterSet];
  NSPredicate* nonEmpty = [NSPredicate
      predicateWithBlock:^BOOL(NSString* im, NSDictionary* bindings) {
        return [[im stringByTrimmingCharactersInSet:whitespace] length] > 0;
      }];
  NSArray<NSString*>* instanceManipulations = [[[httpResponse
      valueForHTTPHeaderField:@"IM"] componentsSeparatedByString:@","]
      filteredArrayUsingPredicate:nonEmpty];
  // Only gzip compressed data is supported on first run seed fetching with
  // "gzip" specified in the request.
  if ([instanceManipulations count] == 1 &&
      [[instanceManipulations[0] stringByTrimmingCharactersInSet:whitespace]
          isEqualToString:@"gzip"]) {
    auto seed = std::make_unique<variations::SeedResponse>();
    if (data) {
      // "data" is binary, for which protobuf uses strings.
      seed->data = std::string(reinterpret_cast<const char*>([data bytes]),
                               [data length]);
    }
    if (dateParsed) {
      seed->date = date;
    }
    seed->signature = base::SysNSStringToUTF8(signature);
    seed->country = base::SysNSStringToUTF8(country);
    seed->is_gzip_compressed = YES;
    return seed;
  }
  return nullptr;
}

// Notifies the delegate of the seed fetching result. Since the seed fetch
// request is sent on the background instead of the main queue, this method
// should explicitly dispatch the result back on the main queue.
- (void)notifyDelegateSeedFetchResult:(BOOL)result {
  __weak IOSChromeVariationsSeedFetcher* weakSelf = self;
  dispatch_async(dispatch_get_main_queue(), ^{
    [weakSelf.delegate variationsSeedFetcherDidCompleteFetchWithSuccess:result];
  });
}

// Invoked by the testing code to reset the fetching status after each test. DO
// NOT INVOKE IN PRODUCTION CODE.
+ (void)resetFetchingStatusForTesting {
  g_seed_fetching_in_progress = NO;
}

@end
