         dataCompletionManager.cancel(identifier, token: token)
     }
     
+    /// Populate the cache for a given URL
     @objc public func prefetch(withURL url: URL?) {
         prefetch(withURL: url) { }
     }
     
+    /// Populate the cache for a given URL
     @objc public func prefetch(withURL url: URL?, completion: @escaping () -> Void) {
         let _ =  fetchImage(withURL: url, priority: URLSessionTask.lowPriority, failure: { (error) in }) { (download) in }
     }
     
+    // MARK: Identifiers
+    
+    /// Unique identifier for a given image URL. All thumbnails and alternative sizes of images should have the same cacheKey.
+    /// - Parameter url: An image URL from a Wikimedia project
+    /// - Returns: A string to use as the key for this URL
+    fileprivate func cacheKeyForURL(_ url: URL) -> String {
+        guard let host = url.host, let imageName = WMFParseImageNameFromSourceURL(url) else {
+            return url.absoluteString.precomposedStringWithCanonicalMapping
+        }
+        return (host + "__" + imageName).precomposedStringWithCanonicalMapping
+    }
+    
+    /// Size variant for a given image URL.
+    /// - Parameter url: An image URL from a Wikimedia project
+    /// - Returns: The width of the image in pixles or 0 if it's the original URL
+    fileprivate func variantForURL(_ url: URL) -> Int64 {
+        let sizePrefix = WMFParseSizePrefixFromSourceURL(url)
+        return Int64(sizePrefix == NSNotFound ? 0 : sizePrefix)
+    }
+    
+    /// Unique identifier for a given image URL. Takes into account size and image name to generate a unique identifier.
+    /// - Parameter url: An image URL from a Wikimedia project
+    /// - Returns: A unique string to use as the key for this URL
+    fileprivate func identifierForURL(_ url: URL) -> String {
+        let key = cacheKeyForURL(url)
+        let variant = variantForURL(url)
+        return identifierForKey(key, variant: variant)
+    }
+    
+    /// Unique identifier for a given key and variant
+    /// - Parameter key: The key for a given image
+    /// - Parameter variant: The size variant for a given image
+    /// - Returns: A unique string to use as the key for this URL
+    fileprivate func identifierForKey(_ key: String, variant: Int64) -> String {
+        return "\(key)__\(variant)".precomposedStringWithCanonicalMapping
+    }
+    
+    /// File URL for saving a given key and variant to disk
+    fileprivate func permanentCacheFileURL(key: String, variant: Int64) -> URL {
+        let identifier = identifierForKey(key, variant: variant)
+        let component = identifier.sha256 ?? identifier
+        return self.permanentStorageDirectory.appendingPathComponent(component, isDirectory: false)
+    }
+    
+    // MARK: Core Data
+    
+    /// Get the individual cache item associated with a key and variant
+    fileprivate func fetchCacheItem(key: String, variant: Int64, moc: NSManagedObjectContext) -> CacheItem? {
+        let itemRequest: NSFetchRequest<CacheItem> = CacheItem.fetchRequest()
+        itemRequest.predicate = NSPredicate(format: "key == %@ && variant == %lli", key, variant)
+        itemRequest.fetchLimit = 1
+        do {
+            let items = try moc.fetch(itemRequest)
+            return items.first
+        } catch let error {
+            DDLogError("Error fetching cache item: \(error)")
+        }
+        return nil
+    }
+    
+    /// Get the group of cache items associated with a given key
+    fileprivate func fetchCacheGroup(key: String, moc: NSManagedObjectContext) -> CacheGroup? {
+        let groupRequest: NSFetchRequest<CacheGroup> = CacheGroup.fetchRequest()
+        groupRequest.predicate = NSPredicate(format: "key == %@", key)
+        groupRequest.fetchLimit = 1
+        do {
+            let groups = try moc.fetch(groupRequest)
+            return groups.first
+        } catch let error {
+            DDLogError("Error fetching cache group: \(error)")
+        }
+        return nil
+    }
+    
+    fileprivate func createCacheItem(key: String, variant: Int64, moc: NSManagedObjectContext) -> CacheItem? {
+        guard let entity = NSEntityDescription.entity(forEntityName: "CacheItem", in: moc) else {
+            return nil
+        }
+        let item = CacheItem(entity: entity, insertInto: moc)
+        item.key = key
+        item.variant = variant
+        item.date = NSDate()
+        return item
+    }
+    
+    fileprivate func createCacheGroup(key: String, moc: NSManagedObjectContext) -> CacheGroup? {
+        guard let entity = NSEntityDescription.entity(forEntityName: "CacheGroup", in: moc) else {
+            return nil
+        }
+        let group = CacheGroup(entity: entity, insertInto: moc)
+        group.key = key
+        return group
+    }
+    
+    fileprivate func fetchOrCreateCacheItem(key: String, variant: Int64, moc: NSManagedObjectContext) -> CacheItem? {
+        return fetchCacheItem(key: key, variant: variant, moc:moc) ?? createCacheItem(key: key, variant: variant, moc: moc)
+    }
+    
+    fileprivate func fetchOrCreateCacheGroup(key: String, moc: NSManagedObjectContext) -> CacheGroup? {
+        return fetchCacheGroup(key: key, moc: moc) ?? createCacheGroup(key: key, moc: moc)
+    }
+    
+    fileprivate func save(moc: NSManagedObjectContext) {
+        guard moc.hasChanges else {
+            return
+        }
+        do {
+            try moc.save()
+        } catch let error {
+            DDLogError("Error saving cache moc: \(error)")
+        }
+    }
+    
+    private func perform(_ block: @escaping (_ moc: NSManagedObjectContext) -> Void) {
+        let moc = self.managedObjectContext
+        moc.perform {
+            block(moc)
+        }
+    }
+    
+    // MARK: Utilities
+    
+    private func getUIImageOrientation(from imageSource: CGImageSource, options: CFDictionary) -> UIImage.Orientation? {
+        guard
+            let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, options) as? [String: Any],
+            let orientationRawValue = properties[kCGImagePropertyOrientation as String] as? UInt32,
+            let cgOrientation = CGImagePropertyOrientation(rawValue: orientationRawValue)
+            else {
+                return nil
+        }
+        switch cgOrientation {
+        case .up: return .up
+        case .upMirrored: return .upMirrored
+        case .down: return .down
+        case .downMirrored: return .downMirrored
+        case .left: return .left
+        case .leftMirrored: return .leftMirrored
+        case .right:  return .right
+        case .rightMirrored: return .rightMirrored
+        }
+    }
+    
+    private func createImage(data: Data, mimeType: String?) -> Image? {
+        if mimeType == "image/gif", let animatedImage = FLAnimatedImage.wmf_animatedImage(with: data), let staticImage = animatedImage.wmf_staticImage {
+            return Image(staticImage: staticImage, animatedImage: animatedImage)
+        }
+        guard let source = CGImageSourceCreateWithData(data as CFData, nil), CGImageSourceGetCount(source) > 0 else {
+            return nil
+        }
+        let options = [kCGImageSourceShouldCache as String: NSNumber(value: true)] as CFDictionary
+        guard let cgImage = CGImageSourceCreateImageAtIndex(source, 0, options) else {
+            return nil
+        }
+        let image: UIImage
+        if let orientation = getUIImageOrientation(from: source, options: options) {
+            image = UIImage(cgImage: cgImage, scale: 1, orientation: orientation)
+        } else {
+            image = UIImage(cgImage: cgImage)
+        }
+        return Image(staticImage: image, animatedImage: nil)
+    }
+    
+    fileprivate func isCancellationError(_ error: Error?) -> Bool {
+        return error?.isCancellationError ?? false
+    }
+    
+    
     @objc public func deleteTemporaryCache() {
         cache.removeAllCachedResponses()
     }
     
-    // MARK: - Migration from SDWebImage
-    
-//    fileprivate var legacyCacheFolderURL: URL {
-//        get {
-//            return fileManager.wmf_containerURL().appendingPathComponent("Cache").appendingPathComponent("com.hackemist.SDWebImageCache.default")
-//        }
-//    }
-//
-//    @objc public func migrateLegacyImageURLs(_ imageURLs: [URL], intoGroup group: String, completion: @escaping () -> Void) {
-//        let legacyCacheFolderURL = self.legacyCacheFolderURL
-//        let legacyCacheFolderPath = legacyCacheFolderURL.path
-//        perform { (moc) in
-//            let group = self.fetchOrCreateCacheGroup(key: group, moc: moc)
-//            for imageURL in imageURLs {
-//                let key = self.cacheKeyForURL(imageURL)
-//                let variant = self.variantForURL(imageURL)
-//                if let existingItem = self.fetchCacheItem(key: key, variant: variant, moc: moc) {
-//                    group?.addToCacheItems(existingItem)
-//                    continue
-//                }
-//                guard let legacyKey = (imageURL as NSURL).wmf_schemelessURLString(),
-//                    let legacyPath = WMFLegacyImageCache.cachePath(forKey: legacyKey, inPath: legacyCacheFolderPath) else {
-//                        continue
-//                }
-//
-//                let fileURL = self.permanentCacheFileURL(key: key, variant: variant)
-//                let legacyFileURL = URL(fileURLWithPath: legacyPath, isDirectory: false)
-//                var createItem = false
-//                do {
-//                    try self.fileManager.moveItem(at: legacyFileURL, to: fileURL)
-//                    createItem = true
-//                } catch let error as NSError {
-//                    if error.domain == NSCocoaErrorDomain && error.code == NSFileWriteFileExistsError { // file exists
-//                        createItem = true
-//                    }
-//                } catch let error {
-//                    DDLogError("Error moving cached file: \(error)")
-//                }
-//
-//                if !createItem {
-//                    let legacyPermanentCacheURL = self.legacyPermanentCacheFileURL(key: key, variant: variant)
-//                    do {
-//                        try self.fileManager.moveItem(at: legacyPermanentCacheURL, to: fileURL)
-//                        createItem = true
-//                    } catch let error as NSError {
-//                        if error.domain == NSCocoaErrorDomain && error.code == NSFileWriteFileExistsError { // file exists
-//                            createItem = true
-//                        }
-//                    } catch let error {
-//                        DDLogError("Error moving cached file: \(error)")
-//                    }
-//                }
-//
-//                guard createItem else {
-//                    continue
-//                }
-//                if let item = self.fetchOrCreateCacheItem(key: key, variant: variant, moc: moc) {
-//                    group?.addToCacheItems(item)
-//                }
-//            }
-//            self.save(moc: moc)
-//            completion()
-//        }
-//    }
-//
-//    @objc public func removeLegacyCache() {
-//        do {
-//            try fileManager.removeItem(at: legacyCacheFolderURL)
-//        } catch let error as NSError {
-//            guard error.domain != NSCocoaErrorDomain || error.code != NSFileNoSuchFileError else {
-//                return
-//            }
-//            DDLogError("Error removing legacy cache \(error)")
-//        }
-//    }
-//
-//    public var temporaryCacheSize: Int64 {
-//        return FileManager.default.sizeOfDirectory(at: legacyCacheFolderURL) + Int64(cache.currentDiskUsage)
-//    }
+    // MARK: Testing
+    
+    /// Temporary controller for testing
+    @objc public static func temporaryController() -> ImageController {
+        let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
+        let imageControllerDirectory = temporaryDirectory.appendingPathComponent("ImageController-" + UUID().uuidString)
+        let config = Session.defaultConfiguration
+        let cache = URLCache(memoryCapacity: 1000000000, diskCapacity: 1000000000, diskPath: imageControllerDirectory.path)
+        config.urlCache = cache
+        let session = URLSession(configuration: config)
+        let fileManager = FileManager.default
+        let permanentStorageDirectory = imageControllerDirectory.appendingPathComponent("Permanent Image Cache", isDirectory: true)
+        do {
+            try fileManager.createDirectory(at: permanentStorageDirectory, withIntermediateDirectories: true, attributes: nil)
+        } catch let error {
+            DDLogError("Error creating permanent cache: \(error)")
+        }
+        return ImageController(session: session, cache: cache, fileManager: fileManager, permanentStorageDirectory: permanentStorageDirectory)
+    }
+    
 }
+
+fileprivate extension Error {
+    var isCancellationError: Bool {
+        get {
+            let potentialCancellationError = self as NSError
+            return potentialCancellationError.domain == NSURLErrorDomain && potentialCancellationError.code == NSURLErrorCancelled
+        }
+    }
+}

        }
        return ImageController(session: session, cache: cache, fileManager: fileManager, permanentStorageDirectory: permanentStorageDirectory)
    }()
    
    
    @objc public static func temporaryController() -> ImageController {
        let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
        let imageControllerDirectory = temporaryDirectory.appendingPathComponent("ImageController-" + UUID().uuidString)
        let config = Session.defaultConfiguration
        let cache = URLCache(memoryCapacity: 1000000000, diskCapacity: 1000000000, diskPath: imageControllerDirectory.path)
        config.urlCache = cache
        let session = URLSession(configuration: config)
        let fileManager = FileManager.default
        let permanentStorageDirectory = imageControllerDirectory.appendingPathComponent("Permanent Image Cache", isDirectory: true)
        do {
            try fileManager.createDirectory(at: permanentStorageDirectory, withIntermediateDirectories: true, attributes: nil)
        } catch let error {
            DDLogError("Error creating permanent cache: \(error)")
        }
        return ImageController(session: session, cache: cache, fileManager: fileManager, permanentStorageDirectory: permanentStorageDirectory)
    }
    
    
    fileprivate let session: URLSession
    fileprivate let cache: URLCache

        
    }
    
    /// Adds images to the permanent cache
    /// - Parameter urls: The image URLs
    /// - Parameter groupKey: The group to associate this image with (the article key)
    /// - Parameter failure block
    /// - Parameter success block
    @objc public func permanentlyCacheInBackground(urls: [URL], groupKey: String,  failure: @escaping (Error) -> Void, success: @escaping () -> Void) {
        let cacheGroup = WMFTaskGroup()
        var errors = [NSError]()
