/* Copyright (c) 2010 Google Inc.
 *
 * 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.
 */

//
//  GTMHTTPFetchHistory.m
//

#define GTMHTTPFETCHHISTORY_DEFINE_GLOBALS 1

#import "GTMHTTPFetchHistory.h"

const NSTimeInterval kCachedURLReservationInterval = 60.0; // 1 minute
static NSString* const kGTMIfNoneMatchHeader = @"If-None-Match";
static NSString* const kGTMETagHeader = @"Etag";

@implementation GTMCookieStorage

- (id)init {
  self = [super init];
  if (self != nil) {
    cookies_ = [[NSMutableArray alloc] init];
  }
  return self;
}

- (void)dealloc {
  [cookies_ release];
  [super dealloc];
}

// add all cookies in the new cookie array to the storage,
// replacing stored cookies as appropriate
//
// Side effect: removes expired cookies from the storage array
- (void)setCookies:(NSArray *)newCookies {

  @synchronized(cookies_) {
    [self removeExpiredCookies];

    for (NSHTTPCookie *newCookie in newCookies) {
      if ([[newCookie name] length] > 0
          && [[newCookie domain] length] > 0
          && [[newCookie path] length] > 0) {

        // remove the cookie if it's currently in the array
        NSHTTPCookie *oldCookie = [self cookieMatchingCookie:newCookie];
        if (oldCookie) {
          [cookies_ removeObjectIdenticalTo:oldCookie];
        }

        // make sure the cookie hasn't already expired
        NSDate *expiresDate = [newCookie expiresDate];
        if ((!expiresDate) || [expiresDate timeIntervalSinceNow] > 0) {
          [cookies_ addObject:newCookie];
        }

      } else {
        NSAssert1(NO, @"Cookie incomplete: %@", newCookie);
      }
    }
  }
}

- (void)deleteCookie:(NSHTTPCookie *)cookie {
  @synchronized(cookies_) {
    NSHTTPCookie *foundCookie = [self cookieMatchingCookie:cookie];
    if (foundCookie) {
      [cookies_ removeObjectIdenticalTo:foundCookie];
    }
  }
}

// retrieve all cookies appropriate for the given URL, considering
// domain, path, cookie name, expiration, security setting.
// Side effect: removed expired cookies from the storage array
- (NSArray *)cookiesForURL:(NSURL *)theURL {

  NSMutableArray *foundCookies = nil;

  @synchronized(cookies_) {
    [self removeExpiredCookies];

    // we'll prepend "." to the desired domain, since we want the
    // actual domain "nytimes.com" to still match the cookie domain
    // ".nytimes.com" when we check it below with hasSuffix
    NSString *host = [[theURL host] lowercaseString];
    NSString *path = [theURL path];
    NSString *scheme = [theURL scheme];

    NSString *domain = nil;
    BOOL isLocalhostRetrieval = NO;

    if ([host isEqual:@"localhost"]) {
      isLocalhostRetrieval = YES;
    } else {
      if (host) {
        domain = [@"." stringByAppendingString:host];
      }
    }

    NSUInteger numberOfCookies = [cookies_ count];
    for (NSUInteger idx = 0; idx < numberOfCookies; idx++) {

      NSHTTPCookie *storedCookie = [cookies_ objectAtIndex:idx];

      NSString *cookieDomain = [[storedCookie domain] lowercaseString];
      NSString *cookiePath = [storedCookie path];
      BOOL cookieIsSecure = [storedCookie isSecure];

      BOOL isDomainOK;

      if (isLocalhostRetrieval) {
        // prior to 10.5.6, the domain stored into NSHTTPCookies for localhost
        // is "localhost.local"
        isDomainOK = [cookieDomain isEqual:@"localhost"]
          || [cookieDomain isEqual:@"localhost.local"];
      } else {
        isDomainOK = [domain hasSuffix:cookieDomain];
      }

      BOOL isPathOK = [cookiePath isEqual:@"/"] || [path hasPrefix:cookiePath];
      BOOL isSecureOK = (!cookieIsSecure) || [scheme isEqual:@"https"];

      if (isDomainOK && isPathOK && isSecureOK) {
        if (foundCookies == nil) {
          foundCookies = [NSMutableArray arrayWithCapacity:1];
        }
        [foundCookies addObject:storedCookie];
      }
    }
  }
  return foundCookies;
}

// return a cookie from the array with the same name, domain, and path as the
// given cookie, or else return nil if none found
//
// Both the cookie being tested and all cookies in the storage array should
// be valid (non-nil name, domains, paths)
//
// note: this should only be called from inside a @synchronized(cookies_) block
- (NSHTTPCookie *)cookieMatchingCookie:(NSHTTPCookie *)cookie {

  NSUInteger numberOfCookies = [cookies_ count];
  NSString *name = [cookie name];
  NSString *domain = [cookie domain];
  NSString *path = [cookie path];

  NSAssert3(name && domain && path, @"Invalid cookie (name:%@ domain:%@ path:%@)",
            name, domain, path);

  for (NSUInteger idx = 0; idx < numberOfCookies; idx++) {

    NSHTTPCookie *storedCookie = [cookies_ objectAtIndex:idx];

    if ([[storedCookie name] isEqual:name]
        && [[storedCookie domain] isEqual:domain]
        && [[storedCookie path] isEqual:path]) {

      return storedCookie;
    }
  }
  return nil;
}


// internal routine to remove any expired cookies from the array, excluding
// cookies with nil expirations
//
// note: this should only be called from inside a @synchronized(cookies_) block
- (void)removeExpiredCookies {

  // count backwards since we're deleting items from the array
  for (NSInteger idx = [cookies_ count] - 1; idx >= 0; idx--) {

    NSHTTPCookie *storedCookie = [cookies_ objectAtIndex:idx];

    NSDate *expiresDate = [storedCookie expiresDate];
    if (expiresDate && [expiresDate timeIntervalSinceNow] < 0) {
      [cookies_ removeObjectAtIndex:idx];
    }
  }
}

- (void)removeAllCookies {
  @synchronized(cookies_) {
    [cookies_ removeAllObjects];
  }
}
@end

//
// GTMCachedURLResponse
//

@implementation GTMCachedURLResponse

@synthesize response = response_;
@synthesize data = data_;
@synthesize reservationDate = reservationDate_;
@synthesize useDate = useDate_;

- (id)initWithResponse:(NSURLResponse *)response data:(NSData *)data {
  self = [super init];
  if (self != nil) {
    response_ = [response retain];
    data_ = [data retain];
    useDate_ = [[NSDate alloc] init];
  }
  return self;
}

- (void)dealloc {
  [response_ release];
  [data_ release];
  [useDate_ release];
  [reservationDate_ release];
  [super dealloc];
}

- (NSString *)description {
  NSString *reservationStr = reservationDate_ ?
    [NSString stringWithFormat:@" resDate:%@", reservationDate_] : @"";

  return [NSString stringWithFormat:@"%@ %p: {bytes:%@ useDate:%@%@}",
          [self class], self,
          data_ ? [NSNumber numberWithInt:(int)[data_ length]] : nil,
          useDate_,
          reservationStr];
}

- (NSComparisonResult)compareUseDate:(GTMCachedURLResponse *)other {
  return [useDate_ compare:[other useDate]];
}

@end

//
// GTMURLCache
//

@implementation GTMURLCache

@dynamic memoryCapacity;

- (id)init {
  return [self initWithMemoryCapacity:kGTMDefaultETaggedDataCacheMemoryCapacity];
}

- (id)initWithMemoryCapacity:(NSUInteger)totalBytes {
  self = [super init];
  if (self != nil) {
    memoryCapacity_ = totalBytes;

    responses_ = [[NSMutableDictionary alloc] initWithCapacity:5];

    reservationInterval_ = kCachedURLReservationInterval;
  }
  return self;
}

- (void)dealloc {
  [responses_ release];
  [super dealloc];
}

- (NSString *)description {
  return [NSString stringWithFormat:@"%@ %p: {responses:%@}",
          [self class], self, [responses_ allValues]];
}

// setters/getters

- (void)pruneCacheResponses {
  // internal routine to remove the least-recently-used responses when the
  // cache has grown too large
  if (memoryCapacity_ >= totalDataSize_) return;

  // sort keys by date
  SEL sel = @selector(compareUseDate:);
  NSArray *sortedKeys = [responses_ keysSortedByValueUsingSelector:sel];

  // the least-recently-used keys are at the beginning of the sorted array;
  // remove those (except ones still reserved) until the total data size is
  // reduced sufficiently
  for (NSURL *key in sortedKeys) {
    GTMCachedURLResponse *response = [responses_ objectForKey:key];

    NSDate *resDate = [response reservationDate];
    BOOL isResponseReserved = (resDate != nil)
      && ([resDate timeIntervalSinceNow] > -reservationInterval_);

    if (!isResponseReserved) {
      // we can remove this response from the cache
      NSUInteger storedSize = [[response data] length];
      totalDataSize_ -= storedSize;
      [responses_ removeObjectForKey:key];
    }

    // if we've removed enough response data, then we're done
    if (memoryCapacity_ >= totalDataSize_) break;
  }
}

- (void)storeCachedResponse:(GTMCachedURLResponse *)cachedResponse
                 forRequest:(NSURLRequest *)request {
  @synchronized(self) {
    // remove any previous entry for this request
    [self removeCachedResponseForRequest:request];

    // cache this one only if it's not bigger than our cache
    NSUInteger storedSize = [[cachedResponse data] length];
    if (storedSize < memoryCapacity_) {

      NSURL *key = [request URL];
      [responses_ setObject:cachedResponse forKey:key];
      totalDataSize_ += storedSize;

      [self pruneCacheResponses];
    }
  }
}

- (GTMCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request {
  GTMCachedURLResponse *response;

  @synchronized(self) {
    NSURL *key = [request URL];
    response = [[[responses_ objectForKey:key] retain] autorelease];

    // touch the date to indicate this was recently retrieved
    [response setUseDate:[NSDate date]];
  }
  return response;
}

- (void)removeCachedResponseForRequest:(NSURLRequest *)request {
  @synchronized(self) {
    NSURL *key = [request URL];
    totalDataSize_ -= [[[responses_ objectForKey:key] data] length];
    [responses_ removeObjectForKey:key];
  }
}

- (void)removeAllCachedResponses {
  @synchronized(self) {
    [responses_ removeAllObjects];
    totalDataSize_ = 0;
  }
}

- (NSUInteger)memoryCapacity {
  return memoryCapacity_;
}

- (void)setMemoryCapacity:(NSUInteger)totalBytes {
  @synchronized(self) {
    BOOL didShrink = (totalBytes < memoryCapacity_);
    memoryCapacity_ = totalBytes;

    if (didShrink) {
      [self pruneCacheResponses];
    }
  }
}

// methods for unit testing
- (void)setReservationInterval:(NSTimeInterval)secs {
  reservationInterval_ = secs;
}

- (NSDictionary *)responses {
  return responses_;
}

- (NSUInteger)totalDataSize {
  return totalDataSize_;
}

@end

//
// GTMHTTPFetchHistory
//

@interface GTMHTTPFetchHistory ()
- (NSString *)cachedETagForRequest:(NSURLRequest *)request;
- (void)removeCachedDataForRequest:(NSURLRequest *)request;
@end

@implementation GTMHTTPFetchHistory

@synthesize cookieStorage = cookieStorage_;

@dynamic shouldRememberETags;
@dynamic shouldCacheETaggedData;
@dynamic memoryCapacity;

- (id)init {
 return [self initWithMemoryCapacity:kGTMDefaultETaggedDataCacheMemoryCapacity
              shouldCacheETaggedData:NO];
}

- (id)initWithMemoryCapacity:(NSUInteger)totalBytes
      shouldCacheETaggedData:(BOOL)shouldCacheETaggedData {
  self = [super init];
  if (self != nil) {
    etaggedDataCache_ = [[GTMURLCache alloc] initWithMemoryCapacity:totalBytes];
    shouldRememberETags_ = shouldCacheETaggedData;
    shouldCacheETaggedData_ = shouldCacheETaggedData;
    cookieStorage_ = [[GTMCookieStorage alloc] init];
  }
  return self;
}

- (void)dealloc {
  [etaggedDataCache_ release];
  [cookieStorage_ release];
  [super dealloc];
}

- (void)updateRequest:(NSMutableURLRequest *)request isHTTPGet:(BOOL)isHTTPGet {
  if ([self shouldRememberETags]) {
    // If this URL is in the history, and no ETag has been set, then
    // set the ETag header field

    // if we have a history, we're tracking across fetches, so we don't
    // want to pull results from any other cache
    [request setCachePolicy:NSURLRequestReloadIgnoringCacheData];

    if (isHTTPGet) {
      // we'll only add an ETag if there's no ETag specified in the user's
      // request
      NSString *specifiedETag = [request valueForHTTPHeaderField:kGTMIfNoneMatchHeader];
      if (specifiedETag == nil) {
        // no ETag: extract the previous ETag for this request from the
        // fetch history, and add it to the request
        NSString *cachedETag = [self cachedETagForRequest:request];

        if (cachedETag != nil) {
          [request addValue:cachedETag forHTTPHeaderField:kGTMIfNoneMatchHeader];
        }
      } else {
        // has an ETag: remove any stored response in the fetch history
        // for this request, as the If-None-Match header could lead to
        // a 304 Not Modified, and we want that error delivered to the
        // user since they explicitly specified the ETag
        [self removeCachedDataForRequest:request];
      }
    }
  }
}

- (void)updateFetchHistoryWithRequest:(NSURLRequest *)request
                             response:(NSURLResponse *)response
                       downloadedData:(NSData *)downloadedData {
  if (![self shouldRememberETags]) return;

  if (![response respondsToSelector:@selector(allHeaderFields)]) return;

  NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode];

  if (statusCode != kGTMHTTPFetcherStatusNotModified) {
    // save this ETag string for successful results (<300)
    // If there's no last modified string, clear the dictionary
    // entry for this URL. Also cache or delete the data, if appropriate
    // (when etaggedDataCache is non-nil.)
    NSDictionary *headers = [(NSHTTPURLResponse *)response allHeaderFields];
    NSString* etag = [headers objectForKey:kGTMETagHeader];

    if (etag != nil && statusCode < 300) {

      // we want to cache responses for the headers, even if the client
      // doesn't want the response body data caches
      NSData *dataToStore = shouldCacheETaggedData_ ? downloadedData : nil;

      GTMCachedURLResponse *cachedResponse;
      cachedResponse = [[[GTMCachedURLResponse alloc] initWithResponse:response
                                                                    data:dataToStore] autorelease];
      [etaggedDataCache_ storeCachedResponse:cachedResponse
                                forRequest:request];
    } else {
      [etaggedDataCache_ removeCachedResponseForRequest:request];
    }
  }
}

- (NSString *)cachedETagForRequest:(NSURLRequest *)request {
  GTMCachedURLResponse *cachedResponse;
  cachedResponse = [etaggedDataCache_ cachedResponseForRequest:request];

  NSURLResponse *response = [cachedResponse response];
  NSDictionary *headers = [(NSHTTPURLResponse *)response allHeaderFields];
  NSString *cachedETag = [headers objectForKey:kGTMETagHeader];
  if (cachedETag) {
    // since the request having an ETag implies this request is about
    // to be fetched again, reserve the cached response to ensure that
    // that it will be around at least until the fetch completes
    //
    // when the fetch completes, either the cached response will be replaced
    // with a new response, or the cachedDataForRequest: method below will
    // clear the reservation
    [cachedResponse setReservationDate:[NSDate date]];
  }
  return cachedETag;
}

- (NSData *)cachedDataForRequest:(NSURLRequest *)request {
  GTMCachedURLResponse *cachedResponse;
  cachedResponse = [etaggedDataCache_ cachedResponseForRequest:request];

  NSData *cachedData = [cachedResponse data];

  // since the data for this cached request is being obtained from the cache,
  // we can clear the reservation as the fetch has completed
  [cachedResponse setReservationDate:nil];

  return cachedData;
}

- (void)removeCachedDataForRequest:(NSURLRequest *)request {
  [etaggedDataCache_ removeCachedResponseForRequest:request];
}

- (void)clearETaggedDataCache {
  [etaggedDataCache_ removeAllCachedResponses];
}

- (void)clearHistory {
  [self clearETaggedDataCache];
  [cookieStorage_ removeAllCookies];
}

- (void)removeAllCookies {
  [cookieStorage_ removeAllCookies];
}

- (BOOL)shouldRememberETags {
  return shouldRememberETags_;
}

- (void)setShouldRememberETags:(BOOL)flag {
  BOOL wasRemembering = shouldRememberETags_;
  shouldRememberETags_ = flag;

  if (wasRemembering && !flag) {
    // free up the cache memory
    [self clearETaggedDataCache];
  }
}

- (BOOL)shouldCacheETaggedData {
  return shouldCacheETaggedData_;
}

- (void)setShouldCacheETaggedData:(BOOL)flag {
  BOOL wasCaching = shouldCacheETaggedData_;
  shouldCacheETaggedData_ = flag;

  if (flag) {
    self.shouldRememberETags = YES;
  }

  if (wasCaching && !flag) {
    // users expect turning off caching to free up the cache memory
    [self clearETaggedDataCache];
  }
}

- (NSUInteger)memoryCapacity {
  return [etaggedDataCache_ memoryCapacity];
}

- (void)setMemoryCapacity:(NSUInteger)totalBytes {
  [etaggedDataCache_ setMemoryCapacity:totalBytes];
}

@end
