// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#import "FirebasePerformance/Sources/Instrumentation/FPRNetworkTrace.h"
#import "FirebasePerformance/Sources/Instrumentation/FPRNetworkTrace+Private.h"

#import "FirebasePerformance/Sources/AppActivity/FPRSessionManager.h"
#import "FirebasePerformance/Sources/Common/FPRConstants.h"
#import "FirebasePerformance/Sources/Common/FPRDiagnostics.h"
#import "FirebasePerformance/Sources/Configurations/FPRConfigurations.h"
#import "FirebasePerformance/Sources/FPRClient.h"
#import "FirebasePerformance/Sources/FPRConsoleLogger.h"
#import "FirebasePerformance/Sources/FPRDataUtils.h"
#import "FirebasePerformance/Sources/FPRURLFilter.h"
#import "FirebasePerformance/Sources/Gauges/FPRGaugeManager.h"

#import <GoogleUtilities/GULObjectSwizzler.h>

NSString *const kFPRNetworkTracePropertyName = @"fpr_networkTrace";

@interface FPRNetworkTrace ()

@property(nonatomic, readwrite) NSURLRequest *URLRequest;

@property(nonatomic, readwrite, nullable) NSError *responseError;

/** State to know if the trace has started. */
@property(nonatomic) BOOL traceStarted;

/** State to know if the trace has completed. */
@property(nonatomic) BOOL traceCompleted;

/** Background activity tracker to know the background state of the trace. */
@property(nonatomic) FPRTraceBackgroundActivityTracker *backgroundActivityTracker;

/** Custom attribute managed internally. */
@property(nonatomic) NSMutableDictionary<NSString *, NSString *> *customAttributes;

/** @brief Serial queue to manage the updation of session Ids. */
@property(nonatomic, readwrite) dispatch_queue_t sessionIdSerialQueue;

/**
 * Updates the current trace with the current session details.
 * @param sessionDetails Updated session details of the currently active session.
 */
- (void)updateTraceWithCurrentSession:(FPRSessionDetails *)sessionDetails;

@end

@implementation FPRNetworkTrace {
  /**
   * @brief Object containing different states of the network request. Stores the information about
   * the state of a network request (defined in FPRNetworkTraceCheckpointState) and the time at
   * which the event happened.
   */
  NSMutableDictionary<NSString *, NSNumber *> *_states;
}

- (nullable instancetype)initWithURLRequest:(NSURLRequest *)URLRequest {
  if (URLRequest.URL == nil) {
    FPRLogError(kFPRNetworkTraceInvalidInputs, @"Invalid URL. URL is nil.");
    return nil;
  }

  // Fail early instead of creating a trace here.
  // IMPORTANT: Order is important here. This check needs to be done before looking up on remote
  // config. Reference bug: b/141861005.
  if (![[FPRURLFilter sharedInstance] shouldInstrumentURL:URLRequest.URL.absoluteString]) {
    return nil;
  }

  BOOL tracingEnabled = [FPRConfigurations sharedInstance].isDataCollectionEnabled;
  if (!tracingEnabled) {
    FPRLogInfo(kFPRTraceDisabled, @"Trace feature is disabled.");
    return nil;
  }

  BOOL sdkEnabled = [[FPRConfigurations sharedInstance] sdkEnabled];
  if (!sdkEnabled) {
    FPRLogInfo(kFPRTraceDisabled, @"Dropping event since Performance SDK is disabled.");
    return nil;
  }

  NSString *trimmedURLString = [FPRNetworkTrace stringByTrimmingURLString:URLRequest];
  if (!trimmedURLString || trimmedURLString.length <= 0) {
    FPRLogWarning(kFPRNetworkTraceURLLengthExceeds, @"URL length outside limits, returning nil.");
    return nil;
  }

  if (![URLRequest.URL.absoluteString isEqualToString:trimmedURLString]) {
    FPRLogInfo(kFPRNetworkTraceURLLengthTruncation,
               @"URL length exceeds limits, truncating recorded URL - %@.", trimmedURLString);
  }

  self = [super init];
  if (self) {
    _URLRequest = URLRequest;
    _trimmedURLString = trimmedURLString;
    _states = [[NSMutableDictionary<NSString *, NSNumber *> alloc] init];
    _hasValidResponseCode = NO;
    _customAttributes = [[NSMutableDictionary<NSString *, NSString *> alloc] init];
    _syncQueue =
        dispatch_queue_create("com.google.perf.networkTrace.metric", DISPATCH_QUEUE_SERIAL);
    _sessionIdSerialQueue =
        dispatch_queue_create("com.google.perf.sessionIds.networkTrace", DISPATCH_QUEUE_SERIAL);
    _activeSessions = [[NSMutableArray<FPRSessionDetails *> alloc] init];
    if (![FPRNetworkTrace isCompleteAndValidTrimmedURLString:_trimmedURLString
                                                  URLRequest:_URLRequest]) {
      return nil;
    };
  }
  return self;
}

- (instancetype)init {
  FPRAssert(NO, @"Not a designated initializer.");
  return nil;
}

- (void)dealloc {
  // Safety net to ensure the notifications are not received anymore.
  FPRSessionManager *sessionManager = [FPRSessionManager sharedInstance];
  [sessionManager.sessionNotificationCenter removeObserver:self
                                                      name:kFPRSessionIdUpdatedNotification
                                                    object:sessionManager];
}

- (NSString *)description {
  return [NSString stringWithFormat:@"Request: %@", _URLRequest];
}

- (void)sessionChanged:(NSNotification *)notification {
  if (self.traceStarted && !self.traceCompleted) {
    NSDictionary<NSString *, FPRSessionDetails *> *userInfo = notification.userInfo;
    FPRSessionDetails *sessionDetails = [userInfo valueForKey:kFPRSessionIdNotificationKey];
    if (sessionDetails) {
      [self updateTraceWithCurrentSession:sessionDetails];
    }
  }
}

- (void)updateTraceWithCurrentSession:(FPRSessionDetails *)sessionDetails {
  if (sessionDetails != nil) {
    dispatch_sync(self.sessionIdSerialQueue, ^{
      [self.activeSessions addObject:sessionDetails];
    });
  }
}

- (NSArray<FPRSessionDetails *> *)sessions {
  __block NSArray<FPRSessionDetails *> *sessionInfos = nil;
  dispatch_sync(self.sessionIdSerialQueue, ^{
    sessionInfos = [self.activeSessions copy];
  });
  return sessionInfos;
}

- (NSDictionary<NSString *, NSNumber *> *)checkpointStates {
  __block NSDictionary<NSString *, NSNumber *> *copiedStates;
  dispatch_sync(self.syncQueue, ^{
    copiedStates = [_states copy];
  });
  return copiedStates;
}

- (void)checkpointState:(FPRNetworkTraceCheckpointState)state {
  if (!self.traceCompleted && self.traceStarted) {
    NSString *stateKey = @(state).stringValue;
    if (stateKey) {
      dispatch_sync(self.syncQueue, ^{
        NSNumber *existingState = _states[stateKey];

        if (existingState == nil) {
          double intervalSinceEpoch = [[NSDate date] timeIntervalSince1970];
          [_states setObject:@(intervalSinceEpoch) forKey:stateKey];
        }
      });
    } else {
      FPRAssert(NO, @"stateKey wasn't created for checkpoint state %ld", (long)state);
    }
  }
}

- (void)start {
  if (!self.traceCompleted) {
    [[FPRGaugeManager sharedInstance] collectAllGauges];
    self.traceStarted = YES;
    self.backgroundActivityTracker = [[FPRTraceBackgroundActivityTracker alloc] init];
    [self checkpointState:FPRNetworkTraceCheckpointStateInitiated];

    if ([self.URLRequest.HTTPMethod isEqualToString:@"POST"] ||
        [self.URLRequest.HTTPMethod isEqualToString:@"PUT"]) {
      self.requestSize = self.URLRequest.HTTPBody.length;
    }
    FPRSessionManager *sessionManager = [FPRSessionManager sharedInstance];
    [self updateTraceWithCurrentSession:[sessionManager.sessionDetails copy]];
    [sessionManager.sessionNotificationCenter addObserver:self
                                                 selector:@selector(sessionChanged:)
                                                     name:kFPRSessionIdUpdatedNotification
                                                   object:sessionManager];
  }
}

- (FPRTraceState)backgroundTraceState {
  FPRTraceBackgroundActivityTracker *backgroundActivityTracker = self.backgroundActivityTracker;
  if (backgroundActivityTracker) {
    return backgroundActivityTracker.traceBackgroundState;
  }

  return FPRTraceStateUnknown;
}

- (NSTimeInterval)startTimeSinceEpoch {
  NSString *stateKey =
      [NSString stringWithFormat:@"%lu", (unsigned long)FPRNetworkTraceCheckpointStateInitiated];
  __block NSTimeInterval timeSinceEpoch;
  dispatch_sync(self.syncQueue, ^{
    timeSinceEpoch = [[_states objectForKey:stateKey] doubleValue];
  });
  return timeSinceEpoch;
}

#pragma mark - Overrides

- (void)setResponseCode:(int32_t)responseCode {
  _responseCode = responseCode;
  if (responseCode != 0) {
    _hasValidResponseCode = YES;
  }
}

#pragma mark - FPRNetworkResponseHandler methods

- (void)didCompleteRequestWithResponse:(NSURLResponse *)response error:(NSError *)error {
  if (!self.traceCompleted && self.traceStarted) {
    // Extract needed fields for the trace object.
    if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
      NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
      self.responseCode = (int32_t)HTTPResponse.statusCode;
    }
    self.responseError = error;
    self.responseContentType = response.MIMEType;
    [self checkpointState:FPRNetworkTraceCheckpointStateResponseCompleted];

    // Send the network trace for logging.
    [[FPRGaugeManager sharedInstance] collectAllGauges];
    [[FPRClient sharedInstance] logNetworkTrace:self];

    self.traceCompleted = YES;
  }

  FPRSessionManager *sessionManager = [FPRSessionManager sharedInstance];
  [sessionManager.sessionNotificationCenter removeObserver:self
                                                      name:kFPRSessionIdUpdatedNotification
                                                    object:sessionManager];
}

- (void)didUploadFileWithURL:(NSURL *)URL {
  NSNumber *value = nil;
  NSError *error = nil;

  if ([URL getResourceValue:&value forKey:NSURLFileSizeKey error:&error]) {
    if (error) {
      FPRLogNotice(kFPRNetworkTraceFileError, @"Unable to determine the size of file.");
    } else {
      self.requestSize = value.unsignedIntegerValue;
    }
  }
}

- (void)didReceiveData:(NSData *)data {
  self.responseSize = data.length;
}

- (void)didReceiveFileURL:(NSURL *)URL {
  NSNumber *value = nil;
  NSError *error = nil;

  if ([URL getResourceValue:&value forKey:NSURLFileSizeKey error:&error]) {
    if (error) {
      FPRLogNotice(kFPRNetworkTraceFileError, @"Unable to determine the size of file.");
    } else {
      self.responseSize = value.unsignedIntegerValue;
    }
  }
}

- (NSTimeInterval)timeIntervalBetweenCheckpointState:(FPRNetworkTraceCheckpointState)startState
                                            andState:(FPRNetworkTraceCheckpointState)endState {
  __block NSNumber *startStateTime;
  __block NSNumber *endStateTime;
  dispatch_sync(self.syncQueue, ^{
    startStateTime = [_states objectForKey:[@(startState) stringValue]];
    endStateTime = [_states objectForKey:[@(endState) stringValue]];
  });
  // Fail fast. If any of the times do not exist, return 0.
  if (startStateTime == nil || endStateTime == nil) {
    return 0;
  }

  NSTimeInterval timeDiff = (endStateTime.doubleValue - startStateTime.doubleValue);
  return timeDiff;
}

/** Trims and validates the URL string of a given NSURLRequest.
 *
 *  @param URLRequest The NSURLRequest containing the URL string to trim.
 *  @return The trimmed string.
 */
+ (NSString *)stringByTrimmingURLString:(NSURLRequest *)URLRequest {
  NSURLComponents *components = [NSURLComponents componentsWithURL:URLRequest.URL
                                           resolvingAgainstBaseURL:NO];
  components.query = nil;
  components.fragment = nil;
  components.user = nil;
  components.password = nil;
  NSURL *trimmedURL = [components URL];
  NSString *truncatedURLString = FPRTruncatedURLString(trimmedURL.absoluteString);

  NSURL *truncatedURL = [NSURL URLWithString:truncatedURLString];
  if (!truncatedURL || truncatedURL.host == nil) {
    return nil;
  }
  return truncatedURLString;
}

/** Validates the trace object by checking that it's http or https, and not a denied URL.
 *
 *  @param trimmedURLString A trimmed URL string from the URLRequest.
 *  @param URLRequest The NSURLRequest that this trace will operate on.
 *  @return YES if the trace object is valid, NO otherwise.
 */
+ (BOOL)isCompleteAndValidTrimmedURLString:(NSString *)trimmedURLString
                                URLRequest:(NSURLRequest *)URLRequest {
  if (![[FPRURLFilter sharedInstance] shouldInstrumentURL:trimmedURLString]) {
    return NO;
  }

  // Check the URL begins with http or https.
  NSURLComponents *components = [NSURLComponents componentsWithURL:URLRequest.URL
                                           resolvingAgainstBaseURL:NO];
  NSString *scheme = components.scheme;
  if (!scheme || !([scheme caseInsensitiveCompare:@"HTTP"] == NSOrderedSame ||
                   [scheme caseInsensitiveCompare:@"HTTPS"] == NSOrderedSame)) {
    FPRLogError(kFPRNetworkTraceInvalidInputs, @"Invalid URL - %@, returning nil.", URLRequest.URL);
    return NO;
  }

  return YES;
}

#pragma mark - Custom attributes related methods

- (NSDictionary<NSString *, NSString *> *)attributes {
  return [self.customAttributes copy];
}

- (void)setValue:(NSString *)value forAttribute:(nonnull NSString *)attribute {
  BOOL canAddAttribute = YES;
  if (self.traceCompleted) {
    FPRLogError(kFPRTraceAlreadyStopped,
                @"Failed to set attribute %@ because network request %@ has already stopped.",
                attribute, self.URLRequest.URL);
    canAddAttribute = NO;
  }

  NSString *validatedName = FPRReservableAttributeName(attribute);
  NSString *validatedValue = FPRValidatedAttributeValue(value);

  if (validatedName == nil) {
    FPRLogError(kFPRAttributeNoName,
                @"Failed to initialize because of a nil or zero length attribute name.");
    canAddAttribute = NO;
  }

  if (validatedValue == nil) {
    FPRLogError(kFPRAttributeNoValue,
                @"Failed to initialize because of a nil or zero length attribute value.");
    canAddAttribute = NO;
  }

  if (self.customAttributes.allKeys.count >= kFPRMaxGlobalCustomAttributesCount) {
    FPRLogError(kFPRMaxAttributesReached,
                @"Only %d attributes allowed. Already reached maximum attribute count.",
                kFPRMaxGlobalCustomAttributesCount);
    canAddAttribute = NO;
  }

  if (canAddAttribute) {
    // Ensure concurrency during update of attributes.
    dispatch_sync(self.syncQueue, ^{
      self.customAttributes[validatedName] = validatedValue;
      FPRLogDebug(kFPRClientMetricLogged, @"Setting attribute %@ to %@ on network request %@",
                  validatedName, validatedValue, self.URLRequest.URL);
    });
  }
}

- (NSString *)valueForAttribute:(NSString *)attribute {
  // TODO(b/175053654): Should this be happening on the serial queue for thread safety?
  return self.customAttributes[attribute];
}

- (void)removeAttribute:(NSString *)attribute {
  if (self.traceCompleted) {
    FPRLogError(kFPRTraceAlreadyStopped,
                @"Failed to remove attribute %@ because network request %@ has already stopped.",
                attribute, self.URLRequest.URL);
    return;
  }

  [self.customAttributes removeObjectForKey:attribute];
}

#pragma mark - Class methods related to object association.

+ (void)addNetworkTrace:(FPRNetworkTrace *)networkTrace toObject:(id)object {
  if (object != nil && networkTrace != nil) {
    [GULObjectSwizzler setAssociatedObject:object
                                       key:kFPRNetworkTracePropertyName
                                     value:networkTrace
                               association:GUL_ASSOCIATION_RETAIN_NONATOMIC];
  }
}

+ (FPRNetworkTrace *)networkTraceFromObject:(id)object {
  FPRNetworkTrace *networkTrace = nil;
  if (object != nil) {
    id traceObject = [GULObjectSwizzler getAssociatedObject:object
                                                        key:kFPRNetworkTracePropertyName];
    if ([traceObject isKindOfClass:[FPRNetworkTrace class]]) {
      networkTrace = (FPRNetworkTrace *)traceObject;
    }
  }

  return networkTrace;
}

+ (void)removeNetworkTraceFromObject:(id)object {
  if (object != nil) {
    [GULObjectSwizzler setAssociatedObject:object
                                       key:kFPRNetworkTracePropertyName
                                     value:nil
                               association:GUL_ASSOCIATION_RETAIN_NONATOMIC];
  }
}

- (BOOL)isValid {
  return _hasValidResponseCode;
}

@end
