#import "SentryNetworkTracker.h"
#import "SentryBaggage.h"
#import "SentryBreadcrumb.h"
#import "SentryClient+Private.h"
#import "SentryDefaultThreadInspector.h"
#import "SentryDsn.h"
#import "SentryEvent.h"
#import "SentryException.h"
#import "SentryHttpStatusCodeRange+Private.h"
#import "SentryHttpStatusCodeRange.h"
#import "SentryHub+Private.h"
#import "SentryInternalCDefines.h"
#import "SentryLogC.h"
#import "SentryMechanism.h"
#import "SentryNoOpSpan.h"
#import "SentryOptions.h"
#import "SentryPropagationContext.h"
#import "SentryRequest.h"
#import "SentrySDK+Private.h"
#import "SentryScope+Private.h"
#import "SentryScope+PrivateSwift.h"
#import "SentrySerialization.h"
#import "SentrySpanOperation.h"
#import "SentryStacktrace.h"
#import "SentrySwift.h"
#import "SentryThread.h"
#import "SentryTraceContext.h"
#import "SentryTraceHeader.h"
#import "SentryTraceOrigin.h"
#import "SentryTracePropagation.h"
#import "SentryTracer.h"
#import "SentryUser.h"
#import <objc/runtime.h>

static NSString *const SentryNetworkTrackerThreadSanitizerMessage
    = @"We accept race conditions for turning flags on and off to avoid acquiring locks, which can "
      @"significantly slow down many HTTP requests running in parallel.";

/**
 * WARNING: We had issues in the past with this code on older iOS versions. We don't run unit tests
 * on all the iOS versions our SDK supports. When adding this comment on April 12th, 2023, we
 * decided to remove running unit tests on iOS 12 simulators. Check the develop-docs decision log
 * for more information https://github.com/getsentry/sentry-cocoa/blob/main/develop-docs/README.md.
 * Back then, the code worked correctly on all iOS versions. Please evaluate if your changes could
 * break on specific iOS versions to ensure it works properly when modifying this file. If they
 * could, please add UI tests and run them on older iOS versions.
 */
@interface SentryNetworkTracker ()

@property (nonatomic, assign) BOOL isNetworkTrackingEnabled;
@property (nonatomic, assign) BOOL isNetworkBreadcrumbEnabled;
@property (nonatomic, assign) BOOL isCaptureFailedRequestsEnabled;
@property (nonatomic, assign) BOOL isGraphQLOperationTrackingEnabled;

@end

@implementation SentryNetworkTracker

+ (SentryNetworkTracker *)sharedInstance
{
    static SentryNetworkTracker *instance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{ instance = [[self alloc] init]; });
    return instance;
}

- (instancetype)init
{
    if (self = [super init]) {
        _isNetworkTrackingEnabled = NO;
        _isNetworkBreadcrumbEnabled = NO;
        _isCaptureFailedRequestsEnabled = NO;
        _isGraphQLOperationTrackingEnabled = NO;
    }
    return self;
}

- (void)enableNetworkTracking SENTRY_DISABLE_THREAD_SANITIZER(
    SentryNetworkTrackerThreadSanitizerMessage)
{
    _isNetworkTrackingEnabled = YES;
}

- (void)enableNetworkBreadcrumbs SENTRY_DISABLE_THREAD_SANITIZER(
    SentryNetworkTrackerThreadSanitizerMessage)
{
    _isNetworkBreadcrumbEnabled = YES;
}

- (void)enableCaptureFailedRequests SENTRY_DISABLE_THREAD_SANITIZER(
    SentryNetworkTrackerThreadSanitizerMessage)
{
    _isCaptureFailedRequestsEnabled = YES;
}

- (void)enableGraphQLOperationTracking SENTRY_DISABLE_THREAD_SANITIZER(
    SentryNetworkTrackerThreadSanitizerMessage)
{

    _isGraphQLOperationTrackingEnabled = YES;
}

- (void)disable SENTRY_DISABLE_THREAD_SANITIZER(SentryNetworkTrackerThreadSanitizerMessage)
{
    _isNetworkBreadcrumbEnabled = NO;
    _isNetworkTrackingEnabled = NO;
    _isCaptureFailedRequestsEnabled = NO;
    _isGraphQLOperationTrackingEnabled = NO;
}

- (void)urlSessionTaskResume:(NSURLSessionTask *)sessionTask
{
    NSURLSessionTaskState sessionState = sessionTask.state;
    if (sessionState == NSURLSessionTaskStateCompleted
        || sessionState == NSURLSessionTaskStateCanceling) {
        return;
    }

    if (![self isTaskSupported:sessionTask])
        return;

    // SDK not enabled no need to continue
    if (SentrySDKInternal.options == nil) {
        return;
    }

    NSURL *url = [[sessionTask currentRequest] URL];

    if (url == nil) {
        return;
    }

    // Don't measure requests to Sentry's backend
    NSURL *apiUrl = SentrySDKInternal.options.parsedDsn.url;
    if ([url.host isEqualToString:apiUrl.host] && [url.path containsString:apiUrl.path]) {
        return;
    }

    // Register request start date in the sessionTask to use for breadcrumb
    if (self.isNetworkBreadcrumbEnabled) {
        objc_setAssociatedObject(sessionTask, &SENTRY_NETWORK_REQUEST_START_DATE, [NSDate date],
            OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }

    if (!self.isNetworkTrackingEnabled) {
        [self addTraceWithoutTransactionToTask:sessionTask];
        return;
    }

    UrlSanitized *safeUrl = [[UrlSanitized alloc] initWithURL:url];
    @synchronized(sessionTask) {
        __block id<SentrySpan> _Nullable span;
        __block id<SentrySpan> _Nullable netSpan;
        netSpan = objc_getAssociatedObject(sessionTask, &SENTRY_NETWORK_REQUEST_TRACKER_SPAN);

        // The task already has a span. Nothing to do.
        if (netSpan != nil) {
            return;
        }

        id<SentrySpan> _Nullable currentSpan = [SentrySDKInternal.currentHub.scope span];
        if (currentSpan != nil) {
            span = currentSpan;
            netSpan = [span startChildWithOperation:SentrySpanOperationNetworkRequestOperation
                                        description:[NSString stringWithFormat:@"%@ %@",
                                                        sessionTask.currentRequest.HTTPMethod,
                                                        safeUrl.sanitizedUrl]];
            netSpan.origin = SentryTraceOriginAutoHttpNSURLSession;

            [netSpan setDataValue:sessionTask.currentRequest.HTTPMethod
                           forKey:@"http.request.method"];
            [netSpan setDataValue:safeUrl.sanitizedUrl forKey:@"url"];
            [netSpan setDataValue:@"fetch" forKey:@"type"];

            if (safeUrl.queryItems && safeUrl.queryItems.count > 0) {
                [netSpan setDataValue:safeUrl.query forKey:@"http.query"];
            }
            if (safeUrl.fragment != nil) {
                [netSpan setDataValue:safeUrl.fragment forKey:@"http.fragment"];
            }
        }

        // We only create a span if there is a transaction in the scope,
        // otherwise we have nothing else to do here.
        if (netSpan == nil || [netSpan isKindOfClass:[SentryNoOpSpan class]]) {
            SENTRY_LOG_DEBUG(@"No transaction bound to scope. Won't track network operation.");
            [self addTraceWithoutTransactionToTask:sessionTask];
            return;
        }

        SentryBaggage *baggage = [[[SentryTracer getTracer:span] traceContext] toBaggage];
        [SentryTracePropagation addBaggageHeader:baggage
                                     traceHeader:[netSpan toTraceHeader]
                         tracePropagationTargets:SentrySDKInternal.options.tracePropagationTargets
                                       toRequest:sessionTask];

        SENTRY_LOG_DEBUG(
            @"SentryNetworkTracker automatically started HTTP span for sessionTask: %@",
            netSpan.description);

        objc_setAssociatedObject(sessionTask, &SENTRY_NETWORK_REQUEST_TRACKER_SPAN, netSpan,
            OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
}

- (void)addTraceWithoutTransactionToTask:(NSURLSessionTask *)sessionTask
{
    SentryPropagationContext *propagationContext
        = SentrySDKInternal.currentHub.scope.propagationContext;

#if !SDK_V9
    NSString *segment = nil;
#    pragma clang diagnostic push
#    pragma clang diagnostic ignored "-Wdeprecated-declarations"
    segment = SentrySDKInternal.currentHub.scope.userObject.segment;
#    pragma clang diagnostic pop
#endif

    SentryTraceContext *traceContext =
        [[SentryTraceContext alloc] initWithTraceId:propagationContext.traceId
                                            options:SentrySDKInternal.currentHub.client.options
#if !SDK_V9
                                        userSegment:segment
#endif
                                           replayId:SentrySDKInternal.currentHub.scope.replayId];

    [SentryTracePropagation addBaggageHeader:[traceContext toBaggage]
                                 traceHeader:[propagationContext traceHeader]
                     tracePropagationTargets:SentrySDKInternal.options.tracePropagationTargets
                                   toRequest:sessionTask];
}

- (void)urlSessionTask:(NSURLSessionTask *)sessionTask setState:(NSURLSessionTaskState)newState
{
    if (!self.isNetworkTrackingEnabled && !self.isNetworkBreadcrumbEnabled
        && !self.isCaptureFailedRequestsEnabled) {
        return;
    }

    if (![self isTaskSupported:sessionTask]) {
        return;
    }

    if (newState == NSURLSessionTaskStateRunning) {
        return;
    }

    NSURL *url = [[sessionTask currentRequest] URL];

    if (url == nil) {
        return;
    }

    // Don't measure requests to Sentry's backend
    NSURL *apiUrl = SentrySDKInternal.options.parsedDsn.url;
    if ([url.host isEqualToString:apiUrl.host] && [url.path containsString:apiUrl.path]) {
        return;
    }

    id<SentrySpan> netSpan;
    @synchronized(sessionTask) {
        netSpan = objc_getAssociatedObject(sessionTask, &SENTRY_NETWORK_REQUEST_TRACKER_SPAN);
        // We'll just go through once
        objc_setAssociatedObject(sessionTask, &SENTRY_NETWORK_REQUEST_TRACKER_SPAN, nil,
            OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }

    if (sessionTask.state == NSURLSessionTaskStateRunning) {
        [self captureFailedRequests:sessionTask];

        [self addBreadcrumbForSessionTask:sessionTask];

        NSInteger responseStatusCode = [self urlResponseStatusCode:sessionTask.response];

        if (responseStatusCode != -1) {
            NSNumber *statusCode = [NSNumber numberWithInteger:responseStatusCode];

            if (netSpan != nil) {
                [netSpan setDataValue:[NSString stringWithFormat:@"%@", statusCode]
                               forKey:@"http.response.status_code"];
            }
        }
    }

    if (netSpan == nil) {
        return;
    }

    [netSpan finishWithStatus:[self statusForSessionTask:sessionTask state:newState]];
    SENTRY_LOG_DEBUG(@"SentryNetworkTracker finished HTTP span for sessionTask");
}

- (void)captureFailedRequests:(NSURLSessionTask *)sessionTask
{
    if (!self.isCaptureFailedRequestsEnabled) {
        SENTRY_LOG_DEBUG(
            @"captureFailedRequestsEnabled is disabled, not capturing HTTP Client errors.");
        return;
    }

    // if request or response are null, we can't raise the event
    if (sessionTask.currentRequest == nil || sessionTask.response == nil) {
        SENTRY_LOG_DEBUG(@"Request or Response are null, not capturing HTTP Client errors.");
        return;
    }

    // some properties are only available if the response is of the NSHTTPURLResponse type
    // bail if not
    if (![sessionTask.response isKindOfClass:[NSHTTPURLResponse class]]) {
        SENTRY_LOG_DEBUG(@"Response isn't a known type, not capturing HTTP Client errors.");
        return;
    }
    NSHTTPURLResponse *myResponse = (NSHTTPURLResponse *)sessionTask.response;
    NSURLRequest *myRequest = sessionTask.currentRequest;
    NSNumber *responseStatusCode = @(myResponse.statusCode);

    if (![self containsStatusCode:myResponse.statusCode]) {
        SENTRY_LOG_DEBUG(@"Response status code isn't within the allowed ranges, not capturing "
                         @"HTTP Client errors.");
        return;
    }

    if (![SentryTracePropagation isTargetMatch:myRequest.URL
                                   withTargets:SentrySDKInternal.options.failedRequestTargets]) {
        SENTRY_LOG_DEBUG(
            @"Request url isn't within the request targets, not capturing HTTP Client errors.");
        return;
    }

    NSString *message = [NSString
        stringWithFormat:@"HTTP Client Error with status code: %ld", (long)myResponse.statusCode];

    SentryEvent *event = [[SentryEvent alloc] initWithLevel:kSentryLevelError];

    SentryDefaultThreadInspector *threadInspector
        = SentrySDKInternal.currentHub.getClient.threadInspector;
    NSArray<SentryThread *> *threads = [threadInspector getCurrentThreads];

    // sessionTask.error isn't used because it's not about network errors but rather
    // requests that are considered failed depending on the HTTP status code
    SentryException *sentryException = [[SentryException alloc] initWithValue:message
                                                                         type:@"HTTPClientError"];
    sentryException.mechanism = [[SentryMechanism alloc] initWithType:@"HTTPClientError"];

    for (SentryThread *thread in threads) {
        if ([thread.current boolValue]) {
            SentryStacktrace *sentryStacktrace = [thread stacktrace];
            sentryStacktrace.snapshot = @(YES);

            sentryException.stacktrace = sentryStacktrace;

            break;
        }
    }

    SentryRequest *request = [[SentryRequest alloc] init];

    UrlSanitized *url = [[UrlSanitized alloc] initWithURL:[[sessionTask currentRequest] URL]];

    request.url = url.sanitizedUrl;
    request.method = myRequest.HTTPMethod;
    request.fragment = url.fragment;
    request.queryString = url.query;
    request.bodySize = [NSNumber numberWithLongLong:sessionTask.countOfBytesSent];
    if (nil != myRequest.allHTTPHeaderFields) {
        NSDictionary<NSString *, NSString *> *headers = myRequest.allHTTPHeaderFields.copy;
        request.headers = [HTTPHeaderSanitizer sanitizeHeaders:headers];
    }

    event.exceptions = @[ sentryException ];
    event.request = request;

    NSMutableDictionary<NSString *, id> *context = [[NSMutableDictionary alloc] init];
    NSMutableDictionary<NSString *, id> *response = [[NSMutableDictionary alloc] init];

    [response setValue:responseStatusCode forKey:@"status_code"];
    if (nil != myResponse.allHeaderFields) {
        NSDictionary<NSString *, NSString *> *headers =
            [HTTPHeaderSanitizer sanitizeHeaders:myResponse.allHeaderFields];
        [response setValue:headers forKey:@"headers"];
    }
    if (sessionTask.countOfBytesReceived != 0) {
        [response setValue:[NSNumber numberWithLongLong:sessionTask.countOfBytesReceived]
                    forKey:@"body_size"];
    }

    context[@"response"] = response;

    if (self.isGraphQLOperationTrackingEnabled) {
        NSString *operationName = [URLSessionTaskHelper getGraphQLOperationNameFrom:sessionTask];
        if (operationName != nil) {
            context[@"graphql"] = @{ @"operation_name" : operationName };
        }
    }

    event.context = context;

    [SentrySDK captureEvent:event];
}

- (BOOL)containsStatusCode:(NSInteger)statusCode
{
    for (SentryHttpStatusCodeRange *range in SentrySDKInternal.options.failedRequestStatusCodes) {
        if ([range isInRange:statusCode]) {
            return YES;
        }
    }

    return NO;
}

- (void)addBreadcrumbForSessionTask:(NSURLSessionTask *)sessionTask
{
    if (!self.isNetworkBreadcrumbEnabled) {
        return;
    }

    id hasBreadcrumb
        = objc_getAssociatedObject(sessionTask, &SENTRY_NETWORK_REQUEST_TRACKER_BREADCRUMB);
    if (hasBreadcrumb && [hasBreadcrumb isKindOfClass:NSNumber.class] &&
        [hasBreadcrumb boolValue]) {
        return;
    }
    NSDate *requestStart
        = objc_getAssociatedObject(sessionTask, &SENTRY_NETWORK_REQUEST_START_DATE);

    NSInteger responseStatusCode = [self urlResponseStatusCode:sessionTask.response];
    SentryLevel breadcrumbLevel = [self getBreadcrumbLevel:sessionTask
                                        responseStatusCode:responseStatusCode];

    SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] initWithLevel:breadcrumbLevel
                                                                  category:@"http"];

    UrlSanitized *urlComponents = [[UrlSanitized alloc] initWithURL:sessionTask.currentRequest.URL];

    breadcrumb.type = @"http";
    NSMutableDictionary<NSString *, id> *breadcrumbData = [NSMutableDictionary new];
    breadcrumbData[@"url"] = urlComponents.sanitizedUrl;
    breadcrumbData[@"method"] = sessionTask.currentRequest.HTTPMethod;
    breadcrumbData[@"request_start"] = requestStart;
    breadcrumbData[@"request_body_size"] =
        [NSNumber numberWithLongLong:sessionTask.countOfBytesSent];
    breadcrumbData[@"response_body_size"] =
        [NSNumber numberWithLongLong:sessionTask.countOfBytesReceived];

    if (responseStatusCode != -1) {
        NSNumber *statusCode = [NSNumber numberWithInteger:responseStatusCode];
        breadcrumbData[@"status_code"] = statusCode;
        breadcrumbData[@"reason"] =
            [NSHTTPURLResponse localizedStringForStatusCode:responseStatusCode];

        if (self.isGraphQLOperationTrackingEnabled) {
            breadcrumbData[@"graphql_operation_name"] =
                [URLSessionTaskHelper getGraphQLOperationNameFrom:sessionTask];
        }
    }

    if (urlComponents.query != nil) {
        breadcrumbData[@"http.query"] = urlComponents.query;
    }

    if (urlComponents.fragment != nil) {
        breadcrumbData[@"http.fragment"] = urlComponents.fragment;
    }

    breadcrumb.data = breadcrumbData;
    [SentrySDK addBreadcrumb:breadcrumb];

    objc_setAssociatedObject(sessionTask, &SENTRY_NETWORK_REQUEST_TRACKER_BREADCRUMB,
        [NSNumber numberWithBool:YES], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSInteger)urlResponseStatusCode:(NSURLResponse *)response
{
    if (response != nil && [response isKindOfClass:[NSHTTPURLResponse class]]) {
        return ((NSHTTPURLResponse *)response).statusCode;
    }
    return -1;
}

- (SentrySpanStatus)statusForSessionTask:(NSURLSessionTask *)task state:(NSURLSessionTaskState)state
{
    switch (state) {
    case NSURLSessionTaskStateSuspended:
        return kSentrySpanStatusAborted;
    case NSURLSessionTaskStateCanceling:
        return kSentrySpanStatusCancelled;
    case NSURLSessionTaskStateCompleted:
        return task.error != nil
            ? kSentrySpanStatusUnknownError
            : [self spanStatusForHttpResponseStatusCode:[self urlResponseStatusCode:task.response]];
    case NSURLSessionTaskStateRunning:
        break;
    }
    return kSentrySpanStatusUndefined;
}

- (BOOL)isTaskSupported:(NSURLSessionTask *)task
{
    // Since streams are usually created to stay connected we don't measure this type of data
    // transfer.
    return [task isKindOfClass:[NSURLSessionDataTask class]] ||
        [task isKindOfClass:[NSURLSessionDownloadTask class]] ||
        [task isKindOfClass:[NSURLSessionUploadTask class]];
}

// https://develop.sentry.dev/sdk/event-payloads/span/
- (SentrySpanStatus)spanStatusForHttpResponseStatusCode:(NSInteger)statusCode
{
    if (statusCode >= 200 && statusCode < 300) {
        return kSentrySpanStatusOk;
    }

    switch (statusCode) {
    case 400:
        return kSentrySpanStatusInvalidArgument;
    case 401:
        return kSentrySpanStatusUnauthenticated;
    case 403:
        return kSentrySpanStatusPermissionDenied;
    case 404:
        return kSentrySpanStatusNotFound;
    case 409:
        return kSentrySpanStatusAborted;
    case 429:
        return kSentrySpanStatusResourceExhausted;
    case 500:
        return kSentrySpanStatusInternalError;
    case 501:
        return kSentrySpanStatusUnimplemented;
    case 503:
        return kSentrySpanStatusUnavailable;
    case 504:
        return kSentrySpanStatusDeadlineExceeded;
    }
    return kSentrySpanStatusUndefined;
}

- (SentryLevel)getBreadcrumbLevel:(NSURLSessionTask *)sessionTask
               responseStatusCode:(NSInteger)responseStatusCode
{
    SentryLevel breadcrumbLevel = kSentryLevelInfo;
    if (responseStatusCode >= 400 && responseStatusCode < 500) {
        breadcrumbLevel = kSentryLevelWarning;
    } else if (responseStatusCode >= 500 && responseStatusCode < 600) {
        breadcrumbLevel = kSentryLevelError;
    }

    if (sessionTask.error != nil) {
        breadcrumbLevel = kSentryLevelError;
    }

    return breadcrumbLevel;
}

@end
