 public let YapImageManagerImageAttributesCollection: String = "image_attributes"
 
 public struct YapImageManagerConfiguration {
-  
-  /// The name of the image database, excluding the path
-  var databaseName: String
-  /// The name of the image attributes database, excluding the path
-  var attributesDatabaseName: String
-  /// Time to expire image and remove from the database, in seconds
-  var expireImagesAfter: TimeInterval
-  /// Time to expire image attributes and remove from the database, in seconds. This should be >= expireImageAfter
-  var expireImageAttributesAfter: TimeInterval
-  /// When decoding images, if no size is specified decode up to max/width height
-  var maxDecodeWidthHeight: Float
-  /// When saving images to database, reduce the file size for any image greater than specified max width/height. Note: re-encoding to JPG is an expensive operation, so use a max width/height to limit conversions
-  var maxSaveToDatabaseWidthHeight: Float
-  /// When maxSaveToDatabaseWidthHeight is exceeded, resize to specified max width/height, preserving aspect ratio
-  var widthToResizeIfDatabaseMaxWidthHeightExceeded: Float
-  /// The number of simultaneous image requests
-  var maxSimultaneousImageRequests: Int
-  /// Timeout interval for image downloads
-  var timeoutIntervalForRequest: TimeInterval
-  /// An array of acceptable image content types, for example ["image/jpeg", "image/jpg", ...]
-  var acceptableContentTypes: [String]
+	
+	/// The name of the image database, excluding the path
+	var databaseName: String
+	/// The name of the image attributes database, excluding the path
+	var attributesDatabaseName: String
+	/// Time to expire image and remove from the database, in seconds
+	var expireImagesAfter: TimeInterval
+	/// Time to expire image attributes and remove from the database, in seconds. This should be >= expireImageAfter
+	var expireImageAttributesAfter: TimeInterval
+	/// When decoding images, if no size is specified decode up to max/width height
+	var maxDecodeWidthHeight: Float
+	/// When saving images to database, reduce the file size for any image greater than specified max width/height. Note: re-encoding to JPG is an expensive operation, so use a max width/height to limit conversions
+	var maxSaveToDatabaseWidthHeight: Float
+	/// When maxSaveToDatabaseWidthHeight is exceeded, resize to specified max width/height, preserving aspect ratio
+	var widthToResizeIfDatabaseMaxWidthHeightExceeded: Float
+	/// The number of simultaneous image requests
+	var maxSimultaneousImageRequests: Int
+	/// Timeout interval for image downloads
+	var timeoutIntervalForRequest: TimeInterval
+	/// An array of acceptable image content types, for example ["image/jpeg", "image/jpg", ...]
+	var acceptableContentTypes: [String]
 }
 
 /// A unique ticket for an image request, used to cancel request

public typealias ImageRequestCompletion = (_ result: ImageResponse) -> Void

public enum ImageResponseCacheType {
  /// The image was downloaded, not returned from the memory or database cache
  case none
  /// The image was returned from the memory cache
  case cache
  /// The image was returned from the database cache
  case databaseCache
}

public enum ImageEncodeType {

		return ((activeRequests.count < configuration.maxSimultaneousImageRequests) && isReachable)
	}
	
	private func processImageQueue() {
		
		guard isReadyForRequest()  else { return }
		
		// process image
		if !queuedRequests.isEmpty {
			if let imageRequest = nextImageRequest() {
				activeRequests.append(imageRequest)
				// start image download
				downloadImage(forRequest: imageRequest)
				DDLogVerbose("Download image \(imageRequest.URLString) (active:\(activeRequests.count) queued: \(queuedRequests.count))")
			}
		}
	}
	
	// MARK: - Image download
	
	private func downloadImage(forRequest imageRequest: ImageRequest) {
		
		let request = sessionManager.request(imageRequest.URLString)
			.validate(contentType: configuration.acceptableContentTypes)
			.responseData() { response in
				switch response.result {
				case .success:
					var imageData = response.data
					if imageData != nil {
						
						if let downloadstartTime = imageRequest.downloadstartTime {
							DDLogVerbose(String(format: "Image downloaded in %2.2f seconds %@", -downloadstartTime.timeIntervalSinceNow, imageRequest.URLString))
						}
						
						self.imageDecodeQueue.async(execute: {() -> Void in
							// update the image attributes, if necessary
							let image = UIImage(data: imageData!)
							let imageAttributes = self.imageAttributes(forURLString: imageRequest.URLString)
							var imageSize = CGSize.zero
							var shouldUpdateImageAttributes = false
							
							if let imageAttributes = imageAttributes {
								imageSize = self.imageSize(withAttributes: imageAttributes)
								
							} else if let image = image {
								// update image size attribute with actual image size; this should only be required if we were unable to pick up the image dimensions from the headers during download
								let scale: CGFloat = UIScreen.main.scale
								imageSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
								shouldUpdateImageAttributes = true
							}
							
							// Resize image to max database size, if necessary
							if let _ = self.maxSizeForImage(withSize: imageSize, maxWidthHeight: CGFloat(self.configuration.maxSaveToDatabaseWidthHeight)) {
								imageSize = self.maxSizeForImage(withSize: imageSize, maxWidthHeight: CGFloat(self.configuration.widthToResizeIfDatabaseMaxWidthHeightExceeded)) ?? imageSize
								DDLogVerbose("Image exceeded max size for database; resizing \(imageRequest.URLString)");
								if let resizedImage = self.filteredImage(forImage: image, size: imageSize) {
									shouldUpdateImageAttributes = true
									imageData = UIImageJPEGRepresentation(resizedImage, 0.8)
								}
							}
							
							// write the image attributes, if necessary
							if shouldUpdateImageAttributes {
								DDLogVerbose("Update image attributes \(imageRequest.URLString)")
								self.updateImageAttributes(with: imageSize, forURLString: imageRequest.URLString, expiresAfter: self.configuration.expireImageAttributesAfter)
							}
							
							// decode all images and save to cache
							if let image = image {
								for completion in imageRequest.responses {
									let filteredImage = self.filteredImage(forImage: image, resizedTo: completion.size, filters: completion.filters)
									
									if let filteredImage = filteredImage {
										// save image in cache
										let key = self.keyForImage(withURLString: imageRequest.URLString, size: completion.size, filters: completion.filters)
										DDLogVerbose("Save filtered image to memory cache \(imageRequest.URLString) key=\(key)")
										self.imagesCache?.setObject(filteredImage, forKey: key as NSString)
									}
								}
							}
							
							// dispatch next request on main thread
							DispatchQueue.main.async(execute: {() -> Void in
								// remove queue item
								self.removeQueuedImageRequest(forURLString: imageRequest.URLString)
								imageRequest.progress = nil
								self.activeRequests.remove(at: self.activeRequests.index(of: imageRequest)!)
								if let imageData = imageData {
									self.saveImageToDatabase(imageData, forURLString: imageRequest.URLString, expiresAfter: self.configuration.expireImagesAfter)
								}
								
								// call completion blocks
								for imageResponse in imageRequest.responses {
									
									let key = self.keyForImage(withURLString: imageRequest.URLString, size: imageResponse.size, filters: imageResponse.filters)
									if let cachedImage = self.imagesCache?.object(forKey: key as NSString) {
										DDLogDebug("Return decoded image after download \(imageRequest.URLString) key=\(key)")
										imageResponse.completion(ImageResponse(URLString: imageRequest.URLString, ticket: imageResponse.ticket, image: cachedImage))
									} else {
										// An error occurred
										imageResponse.completion(ImageResponse(URLString: imageRequest.URLString, ticket: imageResponse.ticket, image: nil, error: YapImageManagerError.imageDecodeFailure))
									}
								}
								
								// POST notification
								let userInfo: [AnyHashable: Any] = [YapImageManagerURLKey: imageRequest.URLString]
								NotificationCenter.default.post(name: YapImageManagerUpdatedNotification, object: self, userInfo: userInfo)
								
								self.processImageQueue()
							})
						})
					} else {
						// TODO: write NULL to database to prevent repetitive download requests
					}
				case .failure(let error):
					// remove queue item
					self.removeQueuedImageRequest(forURLString: imageRequest.URLString)
					imageRequest.progress = nil
					self.activeRequests.remove(at: self.activeRequests.index(of: imageRequest)!)
					
					// call failed completion blocks
					for imageResponse in imageRequest.responses {
						// An error occurred
						imageResponse.completion(ImageResponse(URLString: imageRequest.URLString, ticket: imageResponse.ticket, image: nil, error: error))
					}
					
					// POST failed notification
					let userInfo: [AnyHashable: Any] = [YapImageManagerURLKey: imageRequest.URLString]
					NotificationCenter.default.post(name: YapImageManagerFailedNotification, object: self, userInfo: userInfo)
					DDLogError("Error downloading \(imageRequest.URLString) - \(error.localizedDescription)")
					
					// process next download
					self.processImageQueue()
				}
		}
		imageRequest.progress = request.progress
		imageRequest.downloadstartTime = Date()
		
		let userInfo: [AnyHashable: Any] = [YapImageManagerURLKey: imageRequest.URLString]
		NotificationCenter.default.post(name: YapImageManagerImageWillBeginDownloadingNotification, object: self, userInfo: userInfo)
	}
	
	private func saveImageToDatabase(_ imageData: Data, forURLString URLString: String, expiresAfter: TimeInterval? = nil) {
		
		let created = Date()
		let expires: Date? = expiresAfter != nil ? created.addingTimeInterval(expiresAfter!) : nil
		let imageMetadata = ImageMetadata(created: created, expires: expires)
		pendingWritesDict[keyForImage(withURLString: URLString)] = imageData
		
		backgroundDatabaseConnection.asyncReadWrite ({ transaction in
			transaction.setObject(imageData, forKey: self.keyForImage(withURLString: URLString), inCollection: YapImageManagerImageCollection, withMetadata: imageMetadata)
		}, completionBlock: { () -> Void in
			self.pendingWritesDict.removeValue(forKey: self.keyForImage(withURLString: URLString))
			DDLogVerbose("Save image to database \(URLString)")
		})
	}
	
	/// Returns a unique key for the image request, with a hash of the url, size, and all image filters
	func keyForImage(withURLString URLString: String?, size: CGSize? = nil, filters: [YapImageFilter]? = nil) -> String {
		var key = String()
		key += String(format: "\(URLString ?? "<na>")_%0.5f_%0.5f", size?.width ?? 0.0, size?.height ?? 0.0)
		
		if let filters = filters {
			for filter in filters {
				key += filter.key
			}
		}
		return key.md5Hash()
	}
	
	// MARK: Image filters
	
	private func filteredImage(forImage image: UIImage, resizedTo size: CGSize? = nil, filters: [YapImageFilter]? = nil) -> UIImage? {
		
		var filteredImage: UIImage?
		
		if let size = size {
			// resize image to size specified
			filteredImage = self.filteredImage(forImage: image, size: size, filters: filters)
		}	else {
			// otherwise, decode up to a max size
			if let maxSize = self.maxSizeForImage(withSize: image.size, maxWidthHeight: CGFloat(configuration.maxDecodeWidthHeight)) {
				filteredImage = self.filteredImage(forImage: image, size: maxSize, filters: filters)
			} else {
				filteredImage = self.filteredImage(forImage: image, size: image.size, filters: filters)
			}
		}
		return filteredImage
	}
	
	private func filteredImage(forImage image: UIImage?, size: CGSize, filters: [YapImageFilter]? = nil) -> UIImage? {
		
		var aspectFillImage: UIImage?
		UIGraphicsBeginImageContextWithOptions(size, false, UIScreen.main.scale)
		let imageRect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
		guard let context = UIGraphicsGetCurrentContext() else { return nil }
		context.translateBy(x: 0.0, y: size.height)
		context.scaleBy(x: 1.0, y: -1.0)
		context.setBlendMode(.normal)
		context.interpolationQuality = .default
		
		if let filters = filters, !filters.isEmpty {
			for filter in filters {
				filter.draw(inContext: context, image: image, rect: imageRect, imageRect: imageRect)
			}
		} else {
			// if no filters were specifed, just render the image aspect fill
			let filter = YapAspectFillFilter()
			filter.draw(inContext: context, image: image, rect: imageRect, imageRect: imageRect)
		}
		
		// capture the image
		aspectFillImage = UIGraphicsGetImageFromCurrentImageContext()
		UIGraphicsEndImageContext()
		return aspectFillImage
	}
	
	// return nil if image does not need resizing, otherwise a new size with same aspect
	func maxSizeForImage(withSize imageSize: CGSize, maxWidthHeight: CGFloat) -> CGSize? {
		
		var maxSize: CGSize?
		if imageSize.width > maxWidthHeight || imageSize.height > maxWidthHeight {
			if imageSize.width > imageSize.height {
				maxSize = CGSize(
					width: maxWidthHeight,
					height: (maxWidthHeight * imageSize.height / imageSize.width).rounded()
				)
			}
			else {
				maxSize = CGSize(
					width: (maxWidthHeight * imageSize.width / imageSize.height).rounded(),
					height: maxWidthHeight
				)
			}
		}
		return maxSize
	}
	
	// MARK: - Image attributes
	
	/// NOTE: this feature is still in being ported from the Objective C version and will be made a public.
	/// The image size and width is decoded during download from the headers of jpeg, gif, and png files and
	/// a notification is posted. This is extremely useful for applications that need the image size for
	/// layout in advance of the image download.
	
	/// Returns image attributes
	func imageAttributes(forURLString URLString: String) -> NSDictionary? {
		// check the database
		var imageAttributes: NSDictionary?
		imageAttributes = attributesCache?.object(forKey: URLString as NSString)
		if imageAttributes == nil {
			// check database
			if !isImageQueued(forURLString: URLString) {
				attributesDatabaseConnection.read { transaction in
					imageAttributes = transaction.object(forKey: URLString, inCollection: YapImageManagerImageAttributesCollection) as? NSDictionary
				}
				if let imageAttributes = imageAttributes {
					// save in cache
					attributesCache?.setObject(imageAttributes, forKey: URLString as NSString)
				}
			}
		}
		return imageAttributes
	}
	
	func imageSize(withAttributes imageAttributes: NSDictionary) -> CGSize {
		var imageSize = CGSize.zero
		if let width = (imageAttributes["image_width"] as? NSNumber), let height = (imageAttributes["image_height"] as? NSNumber) {
			imageSize = CGSize()
			imageSize.width = CGFloat(width.floatValue)
			imageSize.height = CGFloat(height.floatValue)
		}
		return imageSize
	}
	
	func postImageAttributesNotification(_ imageAttributes: NSDictionary, forURLString URLString: String) {
		let block: () -> Void = { () -> Void in
			let attributes: [AnyHashable: Any] = [YapImageManagerURLKey: URLString, YapImageManagerImageAttributesKey: imageAttributes]
			NotificationCenter.default.post(name: YapImageManagerImageAttributesUpdatedNotification, object: self, userInfo: attributes)
		}
		if Thread.isMainThread {
			block()
		}
		else {
			DispatchQueue.main.async(execute: block)
		}
	}
	
	func setImageAttributes(_ imageAttributes: NSDictionary, forURLString URLString: String, expiresAfter: TimeInterval? = nil) {
		attributesCache?.setObject(imageAttributes, forKey: URLString as NSString)
		postImageAttributesNotification(imageAttributes, forURLString: URLString)
		// save to database
		let created = Date()
		let expires: Date? = expiresAfter != nil ? created.addingTimeInterval(expiresAfter!) : nil
		let attributesMetadata = ImageMetadata(created: created, expires: expires)
		
		backgroundAttributesDatabaseConnection.asyncReadWrite({ transaction in
			transaction.setObject(imageAttributes, forKey: URLString, inCollection: YapImageManagerImageAttributesCollection, withMetadata: attributesMetadata)
		}, completionBlock: nil)
	}
	
	func updateImageAttributes(with size: CGSize, forURLString URLString: String, expiresAfter: TimeInterval? = nil) {
		if let imageAttributes = self.imageAttributes(forURLString: URLString) {
			if
				let width = imageAttributes["image_width"] as? NSNumber,
				let height = imageAttributes["image_height"] as? NSNumber
			{
				// update only if dimensions have changed
				let isSame = CGFloat(width.floatValue) == size.width && CGFloat(height.floatValue) == size.height
				if !isSame {
					let updatedAttributes = NSMutableDictionary(dictionary: imageAttributes)
					updatedAttributes["image_width"] = Int(size.width)
					updatedAttributes["image_height"] = Int(size.height)
					setImageAttributes(updatedAttributes, forURLString: URLString, expiresAfter: expiresAfter)
				}
			}
		} else {
			// no image attribute data exists, so set size and width and save to database
			let imageAttributes = ["image_width": Int(size.width), "image_height": Int(size.height)] as NSDictionary
			setImageAttributes(imageAttributes, forURLString: URLString, expiresAfter: expiresAfter)
		}
	}
	
	// MARK: Database cleanup
	
	private func vacuumDatabaseIfNeeded() {
		
		if backgroundDatabaseConnection.pragmaAutoVacuum() == "NONE" {
			// We don't vacuum right away.
			// The app just launched, so it could be pulling down stuff from the server.
			// Instead, we queue up the vacuum operation to run after a slight delay.
			DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: {() -> Void in
				self.backgroundDatabaseConnection.asyncVacuum {
					DDLogInfo("VACUUM complete (upgrading database auto_vacuum setting)")
				}
			})
		}
	}
	
	private func removeExpiredImages() {
		DispatchQueue.main.async(execute: {() -> Void in
			
			// fetch the expired images
			var imageDeleteKeys = [String]()
			self.databaseConnection.read({ transaction in
				transaction.enumerateKeysAndMetadata(inCollection: YapImageManagerImageCollection, using: { (key: String, metadata: Any?, stop: UnsafeMutablePointer<ObjCBool>) in
					if let imageMetadata = metadata as? ImageMetadata, let expires = imageMetadata.expires {
						let timeSince = -expires.timeIntervalSinceNow
						if timeSince > 0 {
							DDLogVerbose("Remove expired image \(key)")
							imageDeleteKeys.append(key)
						}
					}
				})
			})
			
			// fetch the expired image attributes
			var imageAttributeDeleteKeys = [String]()
			self.attributesDatabaseConnection.read({ transaction in
				transaction.enumerateKeysAndMetadata(inCollection: YapImageManagerImageAttributesCollection, using: { (key: String, metadata: Any?, stop: UnsafeMutablePointer<ObjCBool>) in
					if let attributesMetadata = metadata as? ImageMetadata, let expires = attributesMetadata.expires {
						let timeSince = -expires.timeIntervalSinceNow
						if timeSince > 0 {
							DDLogVerbose("Remove expired image attributes \(key)")
							imageAttributeDeleteKeys.append(key)
						}
					}
				})
			})
			
			// remove expired images and attributes, images first so you never end up with images without attributes
			self.backgroundDatabaseConnection.asyncReadWrite({ transaction  in
				// remove images
				transaction.removeObjects(forKeys: imageDeleteKeys, inCollection: YapImageManagerImageCollection)
			}, completionBlock: {() -> Void in
				// remove image attributes after removing images has completed
				self.backgroundAttributesDatabaseConnection.asyncReadWrite( { transaction in
					transaction.removeObjects(forKeys: imageAttributeDeleteKeys, inCollection: YapImageManagerImageAttributesCollection)
				}, completionBlock: {() -> Void in
					// DEBUG
					//[self validateDatabaseIntegrity];
				})
			})
			// DEBUG
			//[self validateDatabaseIntegrity];
		})
	}
	
	private func validateDatabaseIntegrity() {
		var imageKeys = Set<String>()
		var imageAttributeKeys = Set<String>()
		databaseConnection.read({ transaction in
			transaction.enumerateKeysAndMetadata(inCollection: YapImageManagerImageCollection, using: { (key: String, metadata: Any?, stop: UnsafeMutablePointer<ObjCBool>) in
				imageKeys.insert(key)
			})
		})
		attributesDatabaseConnection.read({transaction in
			transaction.enumerateKeysAndMetadata(inCollection: YapImageManagerImageAttributesCollection, using: { (key: String, metadata: Any?, stop: UnsafeMutablePointer<ObjCBool>) in
				imageAttributeKeys.insert(key)
			})
		})
		// there can be more image attributes than images, in the case of cancelled downloads since we pull size info out of the image header block
		if imageKeys.isSubset(of: imageAttributeKeys) {
			DDLogInfo("- IMAGE CHECKSUM SUCCESS")
		}
		else {
			DDLogInfo("- IMAGE CHECKSUM FAILURE")
		}
	}
}

