//
//  TNLResponse.m
//  TwitterNetworkLayer
//
//  Created on 5/23/14.
//  Copyright © 2020 Twitter, Inc. All rights reserved.
//

#import <mach/mach_time.h>

#import "NSCoder+TNLAdditions.h"
#import "NSDictionary+TNLAdditions.h"
#import "NSURLResponse+TNLAdditions.h"
#import "NSURLSessionTaskMetrics+TNLAdditions.h"
#import "TNL_Project.h"
#import "TNLAttemptMetaData.h"
#import "TNLAttemptMetrics_Project.h"
#import "TNLHTTP.h"
#import "TNLRequest.h"
#import "TNLResponse_Project.h"
#import "TNLTemporaryFile_Project.h"
#import "TNLTiming.h"

@implementation TNLResponse

@synthesize operationError = _operationError;
@synthesize originalRequest = _originalRequest;
@synthesize info = _info;
@synthesize metrics = _metrics;

+ (instancetype)responseWithResponse:(TNLResponse *)response
{
    TNLResponse *newResponse = [[[self class] alloc] initInternalWithRequest:response.originalRequest
                                                              operationError:response.operationError
                                                                        info:response.info
                                                                     metrics:response.metrics];
    [newResponse prepare];
    return newResponse;
}

+ (instancetype)responseWithRequest:(id<TNLRequest>)originalRequest
                     operationError:(NSError *)operationError
                               info:(TNLResponseInfo *)info
                            metrics:(TNLResponseMetrics *)metrics
{
    TNLResponse *response = [[[self class] alloc] initInternalWithRequest:originalRequest
                                                           operationError:operationError
                                                                     info:info
                                                                  metrics:metrics];
    [response prepare];
    [metrics finalizeMetrics];
    return response;
}

- (instancetype)initInternalWithRequest:(id<TNLRequest>)originalRequest
                         operationError:(NSError *)operationError
                                   info:(TNLResponseInfo *)info
                                metrics:(TNLResponseMetrics *)metrics
{
    if (self = [super init]) {
        _originalRequest = [originalRequest conformsToProtocol:@protocol(NSCopying)] ?
                                [(NSObject *)originalRequest copy] :
                                originalRequest;
        _operationError = operationError;
        _info = info;
        _metrics = metrics;
    }
    return self;
}

- (instancetype)init
{
    [self doesNotRecognizeSelector:_cmd];
    abort();
    return nil;
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder
{
    NSError *operationError = [aDecoder decodeObjectOfClass:[NSError class]
                                                     forKey:@"operationError"];
    TNLResponseInfo *info = [aDecoder decodeObjectOfClass:[TNLResponseInfo class]
                                                   forKey:@"info"];
    TNLResponseMetrics *metrics = [aDecoder decodeObjectOfClass:[TNLResponseMetrics class]
                                                         forKey:@"metrics"];

    id<TNLRequest> request = nil;
    {
        NSString *originalRequestClassName = [aDecoder decodeObjectOfClass:[NSString class]
                                                                    forKey:@"originalRequestClass"];
        Class originalRequestClass = Nil;
        if (originalRequestClassName != nil) {
            // we have an archived "request class name", try to convert into a concrete class to decode

            // does the decoder support "class name to class" mapping?
            if ([aDecoder respondsToSelector:@selector(classForClassName:)]) {
                // has mapping support, load via mapping
                originalRequestClass = [(id)aDecoder classForClassName:originalRequestClassName];
            }
            // did we not load a mapping and have a class level mapping?
            if (Nil == originalRequestClass && [[aDecoder class] respondsToSelector:@selector(classForClassName:)]) {
                // has class level mapping support, load via mapping
                originalRequestClass = [[aDecoder class] classForClassName:originalRequestClassName];
            }
            // did we not load via any mappings?
            if (Nil == originalRequestClass) {
                // load directly via NSClassFromString
                originalRequestClass = NSClassFromString(originalRequestClassName);
            }
        }

        // If we have a class...
        if (originalRequestClass != Nil) {
            // ... try to decode the request
            request = [aDecoder decodeObjectOfClass:originalRequestClass
                                             forKey:@"originalRequest"];
        }
    }

    self = [self initInternalWithRequest:request
                          operationError:operationError
                                    info:info
                                 metrics:metrics];
    if (self) {
        [self prepare];
        [metrics finalizeMetrics];
    }
    return self;
}

- (void)encodeWithCoder:(NSCoder *)aCoder
{
    [aCoder encodeObject:TNLErrorToSecureCodingError(_operationError) forKey:@"operationError"];
    [aCoder encodeObject:_info forKey:@"info"];
    [aCoder encodeObject:_metrics forKey:@"metrics"];

    id<TNLRequest, NSSecureCoding> originalRequest = nil;
    if ([_originalRequest conformsToProtocol:@protocol(NSSecureCoding)] && [[_originalRequest class] supportsSecureCoding]) {
        originalRequest = (id<TNLRequest, NSSecureCoding>)_originalRequest;
    } else {
        originalRequest = [[TNLResponseEncodedRequest alloc] initWithSourceRequest:_originalRequest];
    }
    [aCoder encodeObject:NSStringFromClass([originalRequest class]) forKey:@"originalRequestClass"];
    [aCoder encodeObject:originalRequest forKey:@"originalRequest"];
}

- (void)prepare
{
}

+ (BOOL)supportsSecureCoding
{
    return YES;
}

- (NSString *)description
{
    NSMutableString *description = [NSMutableString stringWithFormat:@"<%@: %p {", NSStringFromClass([self class]), self];

    NSString *requestDescription = nil;
    if (self.originalRequest) {
        if ([self.originalRequest respondsToSelector:@selector(URL)]) {
            requestDescription = [[self.originalRequest URL] description];
        } else {
            requestDescription = NSStringFromClass([self.originalRequest class]);
        }
    }
    [description appendFormat:@" Request: %@", requestDescription];

    [description appendFormat:@", HTTP: %ld", (long)self.info.statusCode];

    if (self.operationError) {
        [description appendFormat:@", Operation-Error: %@", self.operationError];
    }

    if (self.info.source == TNLResponseSourceLocalCache) {
        [description appendString:@", Cache-Hit: YES"];
    }

    if (self.metrics) {
        [description appendFormat:@", Metrics: %@", self.metrics];
    }

    [description appendString:@" }>"];
    return description;
}

- (NSUInteger)hash
{
    return (NSUInteger)((NSInteger)self.operationError.domain.hash + self.operationError.code) +
           self.info.hash +
           self.metrics.hash; // ignore the original request hash
}

- (BOOL)isEqual:(id)object
{
    if ([super isEqual:object]) {
        return YES;
    }

    TNLResponse *other = object;
    if (![other isKindOfClass:[TNLResponse class]]) {
        return NO;
    }

    if (!TNLSecureCodingErrorsAreEqual(self.operationError, other.operationError)) {
        return NO;
    }

    if (self.originalRequest != nil) {
        if ([self.originalRequest respondsToSelector:@selector(isEqualToRequest:)]) {
            if (![self.originalRequest isEqualToRequest:other.originalRequest]) {
                return NO;
            }
        } else {
            if (!TNLRequestEqualToRequest(self.originalRequest, other.originalRequest, YES /*quickBodyCheck*/)) {
                return NO;
            }
        }
    } else if (other.originalRequest != nil) {
        return NO;
    }

    IS_EQUAL_OBJ_PROP_CHECK(self, other, info);
    IS_EQUAL_OBJ_PROP_CHECK(self, other, metrics);

    return YES;
}

@end

@implementation TNLResponseInfo
{
    @protected
    NSString *_rawRetryAfterValue;
    id _parsedRetryAfterValue;
    NSDate *_retryAfterDate;
    NSDictionary *_cachedLowercaseHeaderFields;
}

- (instancetype)init
{
    [self doesNotRecognizeSelector:_cmd];
    abort();
}

- (instancetype)initWithFinalURLRequest:(NSURLRequest *)finalURLRequest
                            URLResponse:(NSHTTPURLResponse *)URLResponse
                                 source:(TNLResponseSource)source
                                   data:(NSData *)data
                     temporarySavedFile:(id<TNLTemporaryFile>)temporarySavedFile
{
    if (self = [super init]) {
        _URLResponse = URLResponse;
        _finalURLRequest = [finalURLRequest copy];
        _data = data;
        _temporarySavedFile = temporarySavedFile;
        _source = source;
        _cachedLowercaseHeaderFields = [URLResponse.allHeaderFields tnl_copyWithLowercaseKeys];
        {
            // We want to precache the retry after date on construction.
            // This is because the "Retry-After" header could provide a "delay from now" value (in seconds)
            // which we'll want to apply to the current time ([NSDate dateWithTimeIntervalSinceNow:retryAfterDelayInSecondsInteger])
            // and not some future time.
            (void)self.retryAfterDate;
        }
    }
    return self;
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder
{
    NSHTTPURLResponse *URLResponse = [aDecoder decodeObjectOfClass:[NSURLResponse class]
                                                            forKey:@"URLResponse"];
    NSURLRequest *URLRequest = [aDecoder decodeObjectOfClass:[NSURLRequest class]
                                                      forKey:@"finalURLRequest"];
    TNLResponseSource source = [aDecoder decodeIntegerForKey:@"source"];
    NSData *data = [aDecoder decodeObjectOfClass:[NSData class]
                                          forKey:@"data"];

    id<TNLTemporaryFile> temporarySavedFile = nil;
    NSString *temporarySavedFilePath = [aDecoder decodeObjectOfClass:[NSString class]
                                                              forKey:@"temporarySavedFilePath"];
    if (temporarySavedFilePath) {
        temporarySavedFile = [[TNLExpiredTemporaryFile alloc] initWithFilePath:(temporarySavedFilePath.length > 0) ? temporarySavedFilePath : nil];
    }

    return [self initWithFinalURLRequest:URLRequest
                             URLResponse:URLResponse
                                  source:source
                                    data:data
                      temporarySavedFile:temporarySavedFile];
}

- (void)encodeWithCoder:(NSCoder *)aCoder
{
    [aCoder encodeObject:_URLResponse forKey:@"URLResponse"];
    [aCoder encodeObject:_finalURLRequest forKey:@"finalURLRequest"];
    [aCoder encodeInteger:_source forKey:@"source"];
    [aCoder encodeObject:_data forKey:@"data"];

    NSString *temporarySavedFilePath = nil;
    if (_temporarySavedFile) {
        if ([(NSObject *)_temporarySavedFile respondsToSelector:@selector(path)]) {
            temporarySavedFilePath = [(id)_temporarySavedFile path];
        } else {
            temporarySavedFilePath = @"";
        }
    }
    [aCoder encodeObject:temporarySavedFilePath forKey:@"temporarySavedFilePath"];
}

+ (BOOL)supportsSecureCoding
{
    return YES;
}

- (NSUInteger)hash
{
    return self.URLResponse.hash +
           self.finalURLRequest.hash +
           (NSUInteger)self.source +
           self.data.hash +
           ((self.temporarySavedFile == nil) ? 11 : 3);
}

- (BOOL)isEqual:(id)object
{
    if ([super isEqual:object]) {
        return YES;
    }

    TNLResponseInfo *other = object;
    if (![other isKindOfClass:[TNLResponseInfo class]]) {
        return NO;
    }

    if (self.URLResponse) {
        if (![self.URLResponse tnl_isEqualToResponse:other.URLResponse]) {
            return NO;
        }
    } else if (other.URLResponse) {
        return NO;
    }

    IS_EQUAL_OBJ_PROP_CHECK(self, other, finalURLRequest);

    if (self.source != other.source) {
        return NO;
    }

    if ((self.temporarySavedFile != nil) || (other.temporarySavedFile != nil)) {
        // No 2 temporary saved files are "equal" so if either TNLResponseInfo has a temp file,
        // they are not equal
        return NO;
    }

    IS_EQUAL_OBJ_PROP_CHECK(self, other, data);

    return YES;
}

@end

@implementation TNLResponseInfo (Convenience)

- (TNLHTTPStatusCode)statusCode
{
    return _URLResponse.statusCode;
}

- (NSURL *)finalURL
{
    return _URLResponse.URL ?: _finalURLRequest.URL;
}

- (NSDictionary *)allHTTPHeaderFields
{
    return _URLResponse.allHeaderFields;
}

- (NSString *)valueForResponseHeaderField:(NSString *)headerField
{
    if (!headerField) {
        return nil;
    }

    return _cachedLowercaseHeaderFields[[headerField lowercaseString]];
}

- (nullable NSDictionary<NSString *, NSString *> *)allHTTPHeaderFieldsWithLowerCaseKeys
{
    return _cachedLowercaseHeaderFields;
}

@end

@implementation TNLResponseEncodedRequest
{
    NSURL *_URL;
    TNLHTTPMethod _HTTPMethodValue;
    NSDictionary<NSString *, NSString *> *_allHTTPHeaderFields;
}

+ (BOOL)supportsSecureCoding
{
    return YES;
}

- (instancetype)initWithSourceRequest:(id<TNLRequest>)request
{
    if (self = [super init]) {
        _encodedSourceRequestHadBody = TNLRequestHasBody(request);
        _encodedSourceRequestClassName = [NSStringFromClass([request class]) copy];
        _URL = request.URL;
        _HTTPMethodValue = TNLRequestGetHTTPMethodValue(request);
        _allHTTPHeaderFields = [request respondsToSelector:@selector(allHTTPHeaderFields)] ? [request.allHTTPHeaderFields copy] : nil;
    }
    return self;
}

- (instancetype)initWithCoder:(NSCoder *)coder
{
    if (self = [super init]) {
        _encodedSourceRequestHadBody = [coder decodeBoolForKey:@"hasBody"];
        _encodedSourceRequestClassName = [coder decodeObjectOfClass:[NSString class] forKey:@"sourceClass"];
        _URL = [coder decodeObjectOfClass:[NSURL class] forKey:@"URL"];
        _HTTPMethodValue = [coder decodeIntegerForKey:@"HTTPMethod"];
        _allHTTPHeaderFields = [coder decodeObjectOfClass:[NSDictionary class] forKey:@"HTTPHeaders"];
    }
    return self;
}

- (void)encodeWithCoder:(NSCoder *)coder
{
    [coder encodeBool:_encodedSourceRequestHadBody forKey:@"hasBody"];
    [coder encodeObject:_encodedSourceRequestClassName forKey:@"sourceClass"];
    [coder encodeObject:_URL forKey:@"URL"];
    [coder encodeInteger:_HTTPMethodValue forKey:@"HTTPMethod"];
    [coder encodeObject:_allHTTPHeaderFields forKey:@"HTTPHeaders"];
}

- (nullable NSURL *)URL
{
    return _URL;
}

- (TNLHTTPMethod)HTTPMethodValue
{
    return _HTTPMethodValue;
}

- (nullable NSDictionary<NSString *, NSString *> *)allHTTPHeaderFields
{
    return _allHTTPHeaderFields;
}

- (nullable NSData *)HTTPBody
{
    if (_encodedSourceRequestHadBody) {
        /*
         In order to preserve "isEqual" comparison between the encoded request
         and the source request, we need to have a non-empty body.

         We're arbitrarily using a valid JSON payload as the body, but it could
         really be any payload.
         */
        return [@"{\"body\":true}" dataUsingEncoding:NSUTF8StringEncoding];
    }
    return nil;
}

@end

@implementation TNLResponseMetrics
{
    BOOL _final;
    NSArray<TNLAttemptMetrics *> *_attemptMetrics;
}

@synthesize attemptMetrics = _attemptMetrics;

- (instancetype)init
{
    return [self initWithEnqueueDate:[NSDate dateWithTimeIntervalSince1970:0]
                         enqueueTime:0
                        completeDate:nil
                        completeTime:0
                      attemptMetrics:nil];
}

- (instancetype)initWithEnqueueDate:(NSDate *)enqueueDate
                        enqueueTime:(uint64_t)enqueueTime
                       completeDate:(nullable NSDate *)completeDate
                       completeTime:(uint64_t)completeTime
                     attemptMetrics:(nullable NSArray<TNLAttemptMetrics *> *)attemptMetrics
{
    if (self = [super init]) {
        _final = NO;
        _enqueueDate = enqueueDate;
        _enqueueMachTime = enqueueTime;
        _completeDate = completeDate;
        _completeMachTime = completeTime;
        _attemptMetrics = [attemptMetrics mutableCopy] ?: [NSMutableArray array];
        if (gTwitterNetworkLayerAssertEnabled) {
            if (_attemptMetrics.count > 0) {
                TNLAssert([_attemptMetrics[0] attemptType] == TNLAttemptTypeInitial);
            }
        }
    }
    return self;
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder
{
    NSArray<TNLAttemptMetrics *> *attemptMetrics = [aDecoder tnl_decodeArrayOfItemsOfClass:[TNLAttemptMetrics class] forKey:@"attemptMetrics"];
    NSDate *enqueueDate = [aDecoder decodeObjectOfClass:[NSDate class] forKey:@"enqueueDate"] ?: [NSDate dateWithTimeIntervalSince1970:0];
    const uint64_t enqueueTime = (uint64_t)[aDecoder decodeInt64ForKey:@"enqueueTime"];
    const uint64_t completeTime = (uint64_t)[aDecoder decodeInt64ForKey:@"completeTime"];
    NSDate *completeDate = [aDecoder decodeObjectOfClass:[NSDate class] forKey:@"completeDate"];
    if (!completeDate && completeTime) {
        completeDate = [enqueueDate dateByAddingTimeInterval:TNLComputeDuration(enqueueTime, completeTime)];
    }
    return [self initWithEnqueueDate:enqueueDate
                         enqueueTime:enqueueTime
                        completeDate:completeDate
                        completeTime:completeTime
                      attemptMetrics:attemptMetrics];
}

- (void)encodeWithCoder:(NSCoder *)aCoder
{
    [aCoder encodeObject:_attemptMetrics forKey:@"attemptMetrics"];
    [aCoder encodeObject:_enqueueDate forKey:@"enqueueDate"];
    [aCoder encodeInt64:(int64_t)_enqueueMachTime forKey:@"enqueueTime"];
    [aCoder encodeObject:_completeDate forKey:@"completeDate"];
    [aCoder encodeInt64:(int64_t)_completeMachTime forKey:@"completeTime"];
}

- (void)finalizeMetrics
{
    if (_final) {
        return;
    }
    _final = YES;
    _attemptMetrics = [_attemptMetrics copy];
    [_attemptMetrics enumerateObjectsUsingBlock:^(TNLAttemptMetrics * _Nonnull metricObj, NSUInteger idx, BOOL * _Nonnull stop) {
        [metricObj finalizeMetrics]; // direct method, so we cannot use `makeObjectsPerformSelector:`
    }];
}

+ (BOOL)supportsSecureCoding
{
    return YES;
}

- (NSUInteger)attemptCount
{
    return _attemptMetrics.count;
}

- (NSUInteger)retryCount
{
    NSUInteger count = 0;
    for (TNLAttemptMetrics *metrics in _attemptMetrics) {
        if (TNLAttemptTypeRetry == metrics.attemptType) {
            count++;
        }
    }
    return count;
}

- (NSUInteger)redirectCount
{
    NSUInteger count = 0;
    for (TNLAttemptMetrics *metrics in _attemptMetrics) {
        if (TNLAttemptTypeRedirect == metrics.attemptType) {
            count++;
        }
    }
    return count;
}

- (void)didEnqueue
{
    if (_final && _enqueueMachTime) {
        return;
    }

    _enqueueMachTime = mach_absolute_time();
    _enqueueDate = [NSDate date];
}

- (nullable NSDate *)firstAttemptStartDate
{
    TNLAttemptMetrics *attemptMetrics = _attemptMetrics.firstObject;
    return attemptMetrics.startDate;
}

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-implementations"
- (uint64_t)firstAttemptStartMachTime
#pragma clang diagnostic pop
{
    TNLAttemptMetrics *attemptMetrics = _attemptMetrics.firstObject;
    return attemptMetrics.startMachTime;
}

- (nullable NSDate *)currentAttemptStartDate
{
    TNLAttemptMetrics *attemptMetrics = _attemptMetrics.lastObject;
    return attemptMetrics.startDate;
}

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-implementations"
- (uint64_t)currentAttemptStartMachTime
#pragma clang diagnostic pop
{
    TNLAttemptMetrics *attemptMetrics = _attemptMetrics.lastObject;
    return attemptMetrics.startMachTime;
}

- (nullable NSDate *)currentAttemptEndDate
{
    TNLAttemptMetrics *attemptMetrics = _attemptMetrics.lastObject;
    return attemptMetrics.endDate;
}

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-implementations"
- (uint64_t)currentAttemptEndMachTime
#pragma clang diagnostic pop
{
    TNLAttemptMetrics *attemptMetrics = _attemptMetrics.lastObject;
    return attemptMetrics.endMachTime;
}

- (void)setCompleteDate:(NSDate *)date machTime:(uint64_t)time
{
    // support providing a complete time if not already set when already final
    if (_final && _completeMachTime) {
        return;
    }
    _completeMachTime = time;
    _completeDate = date;
}

- (void)addInitialStartWithDate:(NSDate *)date machTime:(uint64_t)machTime request:(NSURLRequest *)request
{
    TNLAssert(_attemptMetrics.count == 0);
    [self _addAttemptStart:TNLAttemptTypeInitial date:date machTime:machTime request:request];
}

- (void)addRetryStartWithDate:(NSDate *)date machTime:(uint64_t)machTime request:(NSURLRequest *)request
{
    [self _addAttemptStart:TNLAttemptTypeRetry date:date machTime:machTime request:request];
}

- (void)addRedirectStartWithDate:(NSDate *)date machTime:(uint64_t)machTime request:(NSURLRequest *)request
{
    [self _addAttemptStart:TNLAttemptTypeRedirect date:date machTime:machTime request:request];
}

- (void)_addAttemptStart:(TNLAttemptType)type
                    date:(NSDate *)date
                machTime:(uint64_t)machTime
                 request:(NSURLRequest *)request
{
    if (_final) {
        return;
    }

    TNLAssert(request != nil);
    TNLAssert(_attemptMetrics != nil);
    TNLAssert(date != nil);
    TNLAttemptMetrics *lastMetrics = _attemptMetrics.lastObject;
    if (TNLAttemptTypeInitial == type) {
        TNLAssert(lastMetrics == nil);
    } else {
        TNLAssert(lastMetrics != nil);
        TNLAssert(lastMetrics.endMachTime != 0 && "addEnd:response:operationError: should have been called first!");
        if (!lastMetrics.endMachTime) {
            [lastMetrics setEndDate:date machTime:machTime];
        }
    }

    TNLAttemptMetrics *metrics = [[TNLAttemptMetrics alloc] initWithType:type
                                                               startDate:date
                                                           startMachTime:machTime
                                                                 endDate:nil
                                                             endMachTime:0
                                                                metaData:nil
                                                              URLRequest:request
                                                             URLResponse:nil
                                                          operationError:nil];
    [(NSMutableArray *)_attemptMetrics addObject:metrics];
}

- (void)updateCurrentRequest:(NSURLRequest *)request
{
    TNLAttemptMetrics *metrics = _attemptMetrics.lastObject;
    [metrics updateRequest:request];
}

- (void)addEndDate:(NSDate *)date
          machTime:(uint64_t)time
          response:(NSHTTPURLResponse *)response
    operationError:(NSError *)error
{
    TNLAttemptMetrics *lastMetrics = _attemptMetrics.lastObject;
    if (lastMetrics && !lastMetrics.endMachTime) {
        [lastMetrics setEndDate:date machTime:time];
        lastMetrics.URLResponse = response;
        lastMetrics.operationError = error;
    }
}

- (void)addMetaData:(TNLAttemptMetaData *)metaData taskMetrics:(NSURLSessionTaskMetrics *)taskMetrics
{
    TNLAttemptMetrics *lastMetrics = _attemptMetrics.lastObject;
    if (lastMetrics && !lastMetrics.metaData) {
        lastMetrics.metaData = metaData;
    }

    if (taskMetrics) {
        const NSUInteger transactionMetricsCount = taskMetrics.transactionMetrics.count;
        const NSUInteger attemptMetricsCount = self.attemptMetrics.count;

        TNLAttemptType followingType = TNLAttemptTypeRedirect;
        NSUInteger taskMetricsIndex = 0;
        NSUInteger attemptMetricsIndex = 0;
        while (taskMetricsIndex < transactionMetricsCount && attemptMetricsIndex < attemptMetricsCount) {
            NSURLSessionTaskTransactionMetrics *transactionMetrics = taskMetrics.transactionMetrics[transactionMetricsCount - taskMetricsIndex - 1];
            TNLAttemptMetrics *attemptMetrics = self.attemptMetrics[attemptMetricsCount - attemptMetricsIndex - 1];
            const NSURLSessionTaskMetricsResourceFetchType expectedFetchType = attemptMetrics.metaData.localCacheHit ? NSURLSessionTaskMetricsResourceFetchTypeLocalCache : NSURLSessionTaskMetricsResourceFetchTypeNetworkLoad;
            if (expectedFetchType == transactionMetrics.resourceFetchType) {
                if (followingType != TNLAttemptTypeRedirect) {
                    break;
                }
                [attemptMetrics setTaskTransactionMetrics:transactionMetrics];

                followingType = attemptMetrics.attemptType;
                attemptMetricsIndex++;
            }

            taskMetricsIndex++;
        }
    }
}

- (NSDictionary *)dictionaryDescription:(BOOL)verbose
{
    NSMutableDictionary *topDictionary = [NSMutableDictionary dictionary];
    topDictionary[@"complete"] = (_completeMachTime != 0) ? @"true" : @"false";
    topDictionary[@"duration"] = @(self.totalDuration);
    if (verbose) {
        topDictionary[@"attemptTime"] = @(self.currentAttemptDuration);
        topDictionary[@"queueTime"] = @(self.queuedDuration);
        topDictionary[@"allAttemptsTime"] = @(self.allAttemptsDuration);
    }

    NSMutableArray *attempts = [NSMutableArray arrayWithCapacity:self.attemptMetrics.count];
    for (TNLAttemptMetrics *metrics in self.attemptMetrics) {
        NSDictionary *attemptDict = [metrics dictionaryDescription:verbose];
        [attempts addObject:attemptDict];
    }
    if (attempts.count > 0) {
        topDictionary[@"attempts"] = attempts;
    }
    return topDictionary;
}

- (NSString *)description
{
    return [NSString stringWithFormat:@"<%@ %p: %@>", NSStringFromClass([self class]), self, [self dictionaryDescription:NO]];
}

- (NSUInteger)hash
{
    if (!_completeMachTime) {
        return NSUIntegerMax;
    }
    return (NSUInteger)(self.totalDuration * 1000ull); // the total duration is a good gauge for hashing
}

- (BOOL)isEqual:(id)object
{
    if ([super isEqual:object]) {
        return YES;
    }

    TNLResponseMetrics *other = object;
    if (![other isKindOfClass:[TNLResponseMetrics class]]) {
        return NO;
    }

    if (self.attemptCount != other.attemptCount) {
        return NO;
    }

    TNLAssert(self.attemptCount == self.attemptMetrics.count);
    TNLAssert(other.attemptCount == other.attemptMetrics.count);

    NSDate *selfStartDate = self.firstAttemptStartDate;
    NSDate *otherStartDate = other.firstAttemptStartDate;
    for (NSUInteger i = 0; i < self.attemptCount; i++) {
        TNLAttemptMetrics *selfAttemptMetrics = self.attemptMetrics[i];
        TNLAttemptMetrics *otherAttemptMetrics = other.attemptMetrics[i];

        if (![selfAttemptMetrics isEqual:otherAttemptMetrics]) {
            return NO;
        }

        if (fabs([selfAttemptMetrics.startDate timeIntervalSinceDate:selfStartDate] - [otherAttemptMetrics.startDate timeIntervalSinceDate:otherStartDate]) > kTNLTimeEpsilon) {
            return NO;
        }
    }

    if (fabs(self.totalDuration - other.totalDuration) > kTNLTimeEpsilon) {
        return NO;
    }

    if (fabs(self.queuedDuration - other.queuedDuration) > kTNLTimeEpsilon) {
        return NO;
    }

    if (fabs(self.allAttemptsDuration - other.allAttemptsDuration) > kTNLTimeEpsilon) {
        return NO;
    }

    if (fabs(self.currentAttemptDuration - other.currentAttemptDuration) > kTNLTimeEpsilon) {
        return NO;
    }

    return YES;
}

- (NSTimeInterval)totalDuration
{
    return [(_completeDate ?: [NSDate date]) timeIntervalSinceDate:_enqueueDate];
}

- (NSTimeInterval)queuedDuration
{
    return [(self.firstAttemptStartDate ?: [NSDate date]) timeIntervalSinceDate:_enqueueDate];
}

- (NSTimeInterval)allAttemptsDuration
{
    return [self.currentAttemptEndDate ?: [NSDate date] timeIntervalSinceDate:self.firstAttemptStartDate ?: [NSDate date]];
}

- (NSTimeInterval)currentAttemptDuration
{
    return [self.currentAttemptEndDate ?: [NSDate date] timeIntervalSinceDate:self.currentAttemptStartDate ?: [NSDate date]];
}

- (TNLResponseMetrics *)deepCopyAndTrimIncompleteAttemptMetrics:(BOOL)trimIncompleteAttemptMetrics
{
    NSMutableArray *dupeSubmetrics = [NSMutableArray arrayWithCapacity:_attemptMetrics.count];
    for (TNLAttemptMetrics *submetric in _attemptMetrics) {
        if (trimIncompleteAttemptMetrics && !submetric.endMachTime) {
            break;
        }
        TNLAttemptMetrics *dupeSubmetric = [submetric copy];
        [dupeSubmetrics addObject:dupeSubmetric];
    }

    TNLResponseMetrics *metrics = [[TNLResponseMetrics alloc] initWithEnqueueDate:self.enqueueDate
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
                                                                      enqueueTime:self.enqueueMachTime
#pragma clang diagnostic pop
                                                                     completeDate:self.completeDate
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
                                                                     completeTime:self.completeMachTime
#pragma clang diagnostic pop
                                                                   attemptMetrics:dupeSubmetrics];
    return metrics;
}

@end

@implementation TNLResponseMetrics (UnitTesting)

+ (instancetype)fakeMetricsForDuration:(NSTimeInterval)duration
                            URLRequest:(NSURLRequest *)request
                           URLResponse:(nullable NSHTTPURLResponse *)URLResponse
                        operationError:(nullable NSError *)error
{
    uint64_t absoluteDiff = TNLAbsoluteFromTimeInterval(duration);
    NSDate *startDate = [NSDate dateWithTimeIntervalSince1970:0];
    NSDate *endDate = [startDate dateByAddingTimeInterval:duration];
    TNLResponseMetrics *metrics = [[TNLResponseMetrics alloc] initWithEnqueueDate:startDate
                                                                      enqueueTime:0
                                                                     completeDate:endDate
                                                                     completeTime:absoluteDiff
                                                                   attemptMetrics:nil];
    [metrics addInitialStartWithDate:startDate
                            machTime:0
                             request:request];
    [metrics addEndDate:endDate
               machTime:absoluteDiff
               response:URLResponse
         operationError:error];
    return metrics;
}

@end

@implementation TNLResponseInfo (RetryAfter)

- (BOOL)hasRetryAfterHeader
{
    return self.retryAfterRawValue != nil;
}

- (NSString *)retryAfterRawValue
{
    if (!_rawRetryAfterValue) {
        _rawRetryAfterValue = [self valueForResponseHeaderField:@"Retry-After"] ?: (id)[NSNull null];
    }
    return ([NSNull null] == (id)_rawRetryAfterValue) ? nil : _rawRetryAfterValue;
}

- (NSTimeInterval)retryAfterDelayFromNow
{
    NSDate *retryAfterDate = self.retryAfterDate;
    if (!retryAfterDate) {
        return NSTimeIntervalSince1970;
    }
    return [retryAfterDate timeIntervalSinceDate:[NSDate date]];
}

- (NSDate *)retryAfterDate
{
    if (!_parsedRetryAfterValue) {
        NSString *retryAfterStringValue = self.retryAfterRawValue;
        _parsedRetryAfterValue = [NSHTTPURLResponse tnl_parseRetryAfterValueFromString:retryAfterStringValue];
        if ([_parsedRetryAfterValue isKindOfClass:[NSNumber class]]) {
            _retryAfterDate = [NSDate dateWithTimeIntervalSinceNow:[(NSNumber *)_parsedRetryAfterValue doubleValue]];
        } else if ([_parsedRetryAfterValue isKindOfClass:[NSDate class]]) {
            _retryAfterDate = _parsedRetryAfterValue;
        } else {
            _parsedRetryAfterValue = (id)[NSNull null];
            if (retryAfterStringValue.length > 0) {
                TNLLogError(@"'Retry-After' header of response provided with invalid value: '%@'", retryAfterStringValue);
            }
        }
    }

    TNLAssert(_parsedRetryAfterValue != nil);
    return _retryAfterDate;
}

@end

NSString *TNLAttemptTypeToString(TNLAttemptType type)
{
    switch (type) {
        case TNLAttemptTypeInitial:
            return @"initial";
        case TNLAttemptTypeRedirect:
            return @"redirect";
        case TNLAttemptTypeRetry:
            return @"retry";
    }
    return nil;
}
