//
//  CarbStore.swift
//  CarbKit
//
//  Created by Nathan Racklyeft on 1/3/16.
//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
//

import Foundation
import CoreData
import HealthKit
import os.log


public enum CarbStoreResult<T> {
    case success(T)
    case failure(CarbStore.CarbStoreError)
}

public protocol CarbStoreDelegate: class {

    /// Informs the delegate that an internal error occurred
    ///
    /// - parameter carbStore: The carb store
    /// - parameter error:     The error describing the issue
    func carbStore(_ carbStore: CarbStore, didError error: CarbStore.CarbStoreError)
}

public protocol CarbStoreSyncDelegate: class {

    /// Asks the delegate to upload recently-added carb entries not yet marked as uploaded.
    ///
    /// The completion handler must be called in all circumstances with each entry passed to the delegate
    ///
    /// - parameter carbStore:  The store instance
    /// - parameter entries:    The carb entries
    /// - parameter completion: The closure to execute when the upload attempt(s) have completed. The closure takes a single argument of an array of entries. Populate `externalID` and set `isUploaded` for each entry that was uploaded, or pass back the entry unmodified for each entry that failed to upload.
    func carbStore(_ carbStore: CarbStore, hasEntriesNeedingUpload entries: [StoredCarbEntry], completion: @escaping (_ entries: [StoredCarbEntry]) -> Void)

    /// Asks the delegate to delete carb entries that were previously uploaded.
    ///
    /// The completion handler must be called in all circumstances with each entry passed to the delegate
    ///
    /// - parameter carbStore:  The store instance
    /// - parameter entries:    The deleted entries
    /// - parameter completion: The closure to execute when the deletion attempt(s) have finished. The closure takes a single argument of an array of entries. Set `isUploaded` to true for each entry that was uploaded, or pass back the entry unmodified for each entry that failed to upload.
    func carbStore(_ carbStore: CarbStore, hasDeletedEntries entries: [DeletedCarbEntry], completion: @escaping (_ entries: [DeletedCarbEntry]) -> Void)
}

extension NSNotification.Name {
    /// Notification posted when carb entries were changed, either via add/replace/delete methods or from HealthKit
    public static let CarbEntriesDidUpdate = NSNotification.Name(rawValue: "com.loudnate.CarbKit.CarbEntriesDidUpdateNotification")
}


/**
 Manages storage, retrieval, and calculation of carbohydrate data.

 There are two tiers of storage:

 * Short-term persistant cache, stored in Core Data, used to ensure access if the app is suspended and re-launched while the Health database is protected
 ```
 0       [cacheLength]
 |––––––––––––|
 ```
 * HealthKit data, managed by the current application and persisted indefinitely
 ```
 0
 |––––––––––––––––––--->
 ```
 */
public final class CarbStore: HealthKitSampleStore {
    public typealias DefaultAbsorptionTimes = (fast: TimeInterval, medium: TimeInterval, slow: TimeInterval)

    public static let defaultAbsorptionTimes: DefaultAbsorptionTimes = (fast: TimeInterval(hours: 2), medium: TimeInterval(hours: 3), slow: TimeInterval(hours: 4))

    /// The default longest expected absorption time interval for carbohydrates: 8 hours.
    public static var defaultMaximumAbsorptionTimeInterval: TimeInterval {
        return defaultAbsorptionTimes.slow * 2
    }

    public enum CarbStoreError: Error {
        // The store isn't correctly configured for the requested operation
        case notConfigured
        // The health store request returned an error
        case healthStoreError(Error)
        // The requested sample can't be modified by this store
        case unauthorized
        // No data was found to match the specified request
        case noData
    }

    private let carbType = HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.dietaryCarbohydrates)!

    /// The preferred unit. iOS currently only supports grams for dietary carbohydrates.
    public override var preferredUnit: HKUnit! {
        return super.preferredUnit
    }

    /// A history of recently applied schedule overrides.
    private let overrideHistory: TemporaryScheduleOverrideHistory?

    /// Carbohydrate-to-insulin ratio
    public var carbRatioSchedule: CarbRatioSchedule? {
        get {
            return lockedCarbRatioSchedule.value
        }
        set {
            lockedCarbRatioSchedule.value = newValue
        }
    }
    private let lockedCarbRatioSchedule: Locked<CarbRatioSchedule?>

    /// The carb ratio schedule, applying recent overrides relative to the current moment in time.
    public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? {
        if let carbRatioSchedule = carbRatioSchedule {
            return overrideHistory?.resolvingRecentCarbRatioSchedule(carbRatioSchedule)
        } else {
            return nil
        }
    }

    /// A trio of default carbohydrate absorption times. Defaults to 2, 3, and 4 hours.
    public let defaultAbsorptionTimes: DefaultAbsorptionTimes

    /// Insulin-to-glucose sensitivity
    public var insulinSensitivitySchedule: InsulinSensitivitySchedule? {
        get {
            return lockedInsulinSensitivitySchedule.value
        }
        set {
            lockedInsulinSensitivitySchedule.value = newValue
        }
    }
    private let lockedInsulinSensitivitySchedule:  Locked<InsulinSensitivitySchedule?>

    /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time.
    public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? {
        if let insulinSensitivitySchedule = insulinSensitivitySchedule {
            return overrideHistory?.resolvingRecentInsulinSensitivitySchedule(insulinSensitivitySchedule)
        } else {
            return nil
        }
    }

    /// The computed carbohydrate sensitivity schedule based on the insulin sensitivity and carb ratio schedules.
    public var carbSensitivitySchedule: CarbSensitivitySchedule? {
        guard let insulinSensitivitySchedule = insulinSensitivitySchedule, let carbRatioSchedule = carbRatioSchedule else {
            return nil
        }
        return .carbSensitivitySchedule(insulinSensitivitySchedule: insulinSensitivitySchedule, carbRatioSchedule: carbRatioSchedule)
    }

    /// The expected delay in the appearance of glucose effects, accounting for both digestion and sensor lag
    public let delay: TimeInterval

    /// The interval between effect values to use for the calculated timelines.
    public let delta: TimeInterval

    /// The factor by which the entered absorption time can be extended to accomodate slower-than-expected absorption
    public let absorptionTimeOverrun: Double

    /// The interval of carb data to keep in cache
    public let cacheLength: TimeInterval

    public let cacheStore: PersistenceController

    /// The sync version used for new samples written to HealthKit
    /// Choose a lower or higher sync version if the same sample might be written twice (e.g. from an extension and from an app) for deterministic conflict resolution
    public let syncVersion: Int

    public weak var delegate: CarbStoreDelegate?

    public weak var syncDelegate: CarbStoreSyncDelegate?

    private let queue = DispatchQueue(label: "com.loudnate.CarbKit.dataAccessQueue", qos: .utility)

    private let log = OSLog(category: "CarbStore")

    /**
     Initializes a new instance of the store.

     - returns: A new instance of the store
     */
    public init(
        healthStore: HKHealthStore,
        cacheStore: PersistenceController,
        observationEnabled: Bool = true,
        cacheLength: TimeInterval = defaultAbsorptionTimes.slow * 2,
        defaultAbsorptionTimes: DefaultAbsorptionTimes = defaultAbsorptionTimes,
        carbRatioSchedule: CarbRatioSchedule? = nil,
        insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil,
        overrideHistory: TemporaryScheduleOverrideHistory? = nil,
        syncVersion: Int = 1,
        absorptionTimeOverrun: Double = 1.5,
        calculationDelta: TimeInterval = 5 /* minutes */ * 60,
        effectDelay: TimeInterval = 10 /* minutes */ * 60
    ) {
        self.cacheStore = cacheStore
        self.defaultAbsorptionTimes = defaultAbsorptionTimes
        self.lockedCarbRatioSchedule = Locked(carbRatioSchedule)
        self.lockedInsulinSensitivitySchedule = Locked(insulinSensitivitySchedule)
        self.overrideHistory = overrideHistory
        self.syncVersion = syncVersion
        self.absorptionTimeOverrun = absorptionTimeOverrun
        self.delta = calculationDelta
        self.delay = effectDelay
        self.cacheLength = max(cacheLength, defaultAbsorptionTimes.slow * 2)

        super.init(healthStore: healthStore, type: carbType, observationStart: Date(timeIntervalSinceNow: -cacheLength), observationEnabled: observationEnabled)

        cacheStore.onReady { (error) in
            guard error == nil else { return }

            // Migrate modifiedCarbEntries and deletedCarbEntryIDs
            self.cacheStore.managedObjectContext.perform {
                for entry in UserDefaults.standard.modifiedCarbEntries ?? [] {
                    let object = CachedCarbObject(context: self.cacheStore.managedObjectContext)
                    object.update(from: entry)
                }


                for externalID in UserDefaults.standard.deletedCarbEntryIds ?? [] {
                    let object = DeletedCarbObject(context: self.cacheStore.managedObjectContext)
                    object.externalID = externalID
                }

                self.cacheStore.save()
            }

            UserDefaults.standard.purgeLegacyCarbEntryKeys()

            // TODO: Consider resetting uploadState.uploading
        }
    }

    // MARK: - HealthKitSampleStore

    public override func processResults(from query: HKAnchoredObjectQuery, added: [HKSample], deleted: [HKDeletedObject], error: Error?) {
        if let error = error {
            self.delegate?.carbStore(self, didError: .healthStoreError(error))
            return
        }

        queue.async {
            var notificationRequired = false

            // Append the new samples
            if let samples = added as? [HKQuantitySample] {
                for sample in samples {
                    if self.addCachedObject(for: sample) {
                        self.log.debug("Saved sample %@ into cache from HKAnchoredObjectQuery", sample.uuid.uuidString)
                        notificationRequired = true
                    }
                }
            }

            // Remove deleted samples
            for sample in deleted {
                if self.deleteCachedObject(for: sample) {
                    self.log.debug("Deleted sample %@ from cache from HKAnchoredObjectQuery", sample.uuid.uuidString)
                    notificationRequired = true
                }
            }

            // Notify listeners only if a meaningful change was made
            if notificationRequired {
                self.cacheStore.save()
                self.syncExternalDB()

                NotificationCenter.default.post(name: .CarbEntriesDidUpdate, object: self, userInfo: [CarbStore.notificationUpdateSourceKey: UpdateSource.queriedByHealthKit.rawValue])
            }
        }
    }
}


// MARK: - Fetching
extension CarbStore {
    /// Fetches samples from HealthKit
    ///
    /// - Parameters:
    ///   - start: The earliest date of samples to retrieve
    ///   - end: The latest date of samples to retrieve, if provided
    ///   - completion: A closure called once the samples have been retrieved
    ///   - result: An array of samples, in chronological order by startDate
    private func getCarbSamples(start: Date, end: Date? = nil, completion: @escaping (_ result: CarbStoreResult<[StoredCarbEntry]>) -> Void) {
        let predicate = HKQuery.predicateForSamples(withStart: start, end: end)
        let sortDescriptors = [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]

        let query = HKSampleQuery(sampleType: carbType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: sortDescriptors) { (query, samples, error) in
            if let error = error {
                completion(.failure(.healthStoreError(error)))
            } else {
                completion(.success((samples as? [HKQuantitySample] ?? []).map { StoredCarbEntry(sample: $0) }))
            }
        }

        healthStore.execute(query)
    }

    /// Fetches samples from HealthKit, if available, or returns from cache.
    ///
    /// - Parameters:
    ///   - start: The earliest date of samples to retrieve
    ///   - end: The latest date of samples to retrieve, if provided
    ///   - completion: A closure called once the samples have been retrieved
    ///   - samples: An array of samples, in chronological order by startDate
    public func getCachedCarbSamples(start: Date, end: Date? = nil, completion: @escaping (_ samples: [StoredCarbEntry]) -> Void) {
        #if os(iOS)
        // If we're within our cache duration, skip the HealthKit query
        guard start <= earliestCacheDate else {
            self.queue.async {
                completion(self.getCachedCarbEntries().filterDateRange(start, end))
            }
            return
        }
        #endif

        getCarbSamples(start: start, end: end) { (result) in
            switch result {
            case .success(let samples):
                completion(samples)
            case .failure:
                self.queue.async {
                    completion(self.getCachedCarbEntries().filterDateRange(start, end))
                }
            }
        }
    }

    /// Retrieves carb entries from HealthKit within the specified date range
    ///
    /// - Parameters:
    ///   - start: The earliest date of values to retrieve
    ///   - end: The latest date of values to retrieve, if provided
    ///   - completion: A closure calld once the values have been retrieved
    ///   - result: An array of carb entries, in chronological order by startDate
    public func getCarbEntries(start: Date, end: Date? = nil, completion: @escaping (_ result: CarbStoreResult<[StoredCarbEntry]>) -> Void) {
        getCarbSamples(start: start, end: end) { (result) in
            switch result {
            case .success(let samples):
                completion(.success(samples))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }

    /// Retrieves carb entries from HealthKit within the specified date range and interprets their
    /// absorption status based on the provided glucose effect
    ///
    /// - Parameters:
    ///   - start: The earliest date of values to retrieve
    ///   - end: The latest date of values to retrieve, if provided
    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
    ///   - completion: A closure calld once the values have been retrieved
    ///   - result: An array of carb entries, in chronological order by startDate
    public func getCarbStatus(
        start: Date,
        end: Date? = nil,
        effectVelocities: [GlucoseEffectVelocity]? = nil,
        completion: @escaping (_ result: CarbStoreResult<[CarbStatus<StoredCarbEntry>]>) -> Void
    ) {
        getCarbSamples(start: start, end: end) { (result) in
            switch result {
            case .success(let samples):
                let status = samples.map(
                    to: effectVelocities ?? [],
                    carbRatio: self.carbRatioScheduleApplyingOverrideHistory,
                    insulinSensitivity: self.insulinSensitivityScheduleApplyingOverrideHistory,
                    absorptionTimeOverrun: self.absorptionTimeOverrun,
                    defaultAbsorptionTime: self.defaultAbsorptionTimes.medium,
                    delay: self.delay
                )

                completion(.success(status))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}


// MARK: - Modification
extension CarbStore {
    public func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult<StoredCarbEntry>) -> Void) {
        let sample = entry.createSample(from: nil, syncVersion: syncVersion)
        let stored = StoredCarbEntry(sample: sample, createdByCurrentApp: true)

        healthStore.save(sample) { (completed, error) -> Void in
            self.queue.async {
                if completed {
                    self.addCachedObject(for: stored)
                    completion(.success(stored))
                    NotificationCenter.default.post(name: .CarbEntriesDidUpdate, object: self, userInfo: [CarbStore.notificationUpdateSourceKey: UpdateSource.changedInApp.rawValue])
                    self.syncExternalDB()
                } else if let error = error {
                    self.log.error("Error saving entry %@: %@", sample.uuid.uuidString, String(describing: error))
                    completion(.failure(.healthStoreError(error)))
                } else {
                    assertionFailure()
                }
            }
        }
    }

    public func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult<StoredCarbEntry>) -> Void) {
        guard oldEntry.createdByCurrentApp else {
            completion(.failure(.unauthorized))
            return
        }

        let sample = newEntry.createSample(from: oldEntry, syncVersion: syncVersion)
        let stored = StoredCarbEntry(sample: sample, createdByCurrentApp: true)

        healthStore.save(sample) { (completed, error) -> Void in
            self.queue.async {
                if completed {
                    self.replaceCachedObject(for: oldEntry, with: stored)
                    completion(.success(stored))
                    NotificationCenter.default.post(name: .CarbEntriesDidUpdate, object: self, userInfo: [CarbStore.notificationUpdateSourceKey: UpdateSource.changedInApp.rawValue])
                    self.syncExternalDB()
                } else if let error = error {
                    self.log.error("Error replacing entry %@: %@", oldEntry.sampleUUID.uuidString, String(describing: error))
                    completion(.failure(.healthStoreError(error)))
                } else {
                    assertionFailure()
                }
            }
        }
    }

    public func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult<Bool>) -> Void) {
        guard entry.createdByCurrentApp else {
            completion(.failure(.unauthorized))
            return
        }

        let predicate = HKQuery.predicateForObject(with: entry.sampleUUID)
        self.healthStore.deleteObjects(of: carbType, predicate: predicate) { (success, count, error) in
            self.queue.async {
                if success {
                    self.deleteCachedObject(for: entry)
                    completion(.success(true))
                    NotificationCenter.default.post(name: .CarbEntriesDidUpdate, object: self, userInfo: [CarbStore.notificationUpdateSourceKey: UpdateSource.changedInApp.rawValue])
                    self.syncExternalDB()
                } else if let error = error {
                    self.log.error("Error deleting entry %@: %@", entry.sampleUUID.uuidString, String(describing: error))
                    completion(.failure(.healthStoreError(error)))
                } else {
                    assertionFailure()
                }
            }
        }
    }
}


extension NSManagedObjectContext {
    fileprivate func cachedCarbObjectsWithUUID(_ uuid: UUID, fetchLimit: Int? = nil) -> [CachedCarbObject] {
        let request: NSFetchRequest<CachedCarbObject> = CachedCarbObject.fetchRequest()
        if let limit = fetchLimit {
            request.fetchLimit = limit
        }
        request.predicate = NSPredicate(format: "uuid == %@", uuid as NSUUID)

        return (try? fetch(request)) ?? []
    }

    fileprivate func deleteObjects<T>(matching fetchRequest: NSFetchRequest<T>) throws -> Int where T: NSManagedObject {
        let objects = try fetch(fetchRequest)

        for object in objects {
            delete(object)
        }

        if hasChanges {
            try save()
        }

        return objects.count
    }
}


// MARK: - Cache management
extension CarbStore {
    private var earliestCacheDate: Date {
        return Date(timeIntervalSinceNow: -cacheLength)
    }

    @discardableResult
    private func addCachedObject(for sample: HKQuantitySample) -> Bool {
        return addCachedObject(for: StoredCarbEntry(sample: sample))
    }

    @discardableResult
    private func addCachedObject(for entry: StoredCarbEntry) -> Bool {
        dispatchPrecondition(condition: .onQueue(queue))

        var created = false

        cacheStore.managedObjectContext.performAndWait {
            guard self.cacheStore.managedObjectContext.cachedCarbObjectsWithUUID(entry.sampleUUID, fetchLimit: 1).count == 0 else {
                return
            }

            let object = CachedCarbObject(context: self.cacheStore.managedObjectContext)
            object.update(from: entry)

            self.cacheStore.save()
            created = true
        }

        return created
    }

    private func replaceCachedObject(for oldEntry: StoredCarbEntry, with newEntry: StoredCarbEntry) {
        dispatchPrecondition(condition: .onQueue(queue))

        cacheStore.managedObjectContext.performAndWait {
            for object in self.cacheStore.managedObjectContext.cachedCarbObjectsWithUUID(oldEntry.sampleUUID) {
                object.update(from: newEntry)
                object.uploadState = .notUploaded
            }

            self.cacheStore.save()
        }
    }

    @discardableResult
    private func deleteCachedObject(for sample: HKDeletedObject) -> Bool {
        return deleteCachedObject(forSampleUUID: sample.uuid)
    }

    @discardableResult
    private func deleteCachedObject(for entry: StoredCarbEntry) -> Bool {
        return deleteCachedObject(forSampleUUID: entry.sampleUUID)
    }

    @discardableResult
    private func deleteCachedObject(forSampleUUID uuid: UUID) -> Bool {
        dispatchPrecondition(condition: .onQueue(queue))

        var deleted = false

        cacheStore.managedObjectContext.performAndWait {
            for object in self.cacheStore.managedObjectContext.cachedCarbObjectsWithUUID(uuid) {
                if let externalID = object.externalID {
                    let deletedObject = DeletedCarbObject(context: self.cacheStore.managedObjectContext)
                    deletedObject.externalID = externalID
                }

                self.cacheStore.managedObjectContext.delete(object)
                deleted = true
            }

            self.cacheStore.save()
        }

        return deleted
    }

    private var cachedDeletedCarbEntries: [DeletedCarbEntry] {
        dispatchPrecondition(condition: .onQueue(queue))
        var entries: [DeletedCarbEntry] = []
        
        cacheStore.managedObjectContext.performAndWait {
            let request: NSFetchRequest<DeletedCarbObject> = DeletedCarbObject.fetchRequest()

            guard let objects = try? self.cacheStore.managedObjectContext.fetch(request) else {
                return
            }
            
            entries = objects.compactMap { DeletedCarbEntry(managedObject: $0) }
        }
        
        return entries
    }

    private func purgeCachedCarbEntries() {
        dispatchPrecondition(condition: .onQueue(queue))

        cacheStore.managedObjectContext.performAndWait {
            let predicate = NSPredicate(format: "startDate < %@", earliestCacheDate as NSDate)

            do {
                let fetchRequest: NSFetchRequest<CachedCarbObject> = CachedCarbObject.fetchRequest()
                fetchRequest.predicate = predicate
                let count = try self.cacheStore.managedObjectContext.deleteObjects(matching: fetchRequest)
                self.log.info("Deleted %d CachedCarbObjects", count)
            } catch let error {
                self.log.error("Unable to purge CachedCarbObjects: %@", String(describing: error))
            }

            do {
                let fetchRequest: NSFetchRequest<DeletedCarbObject> = DeletedCarbObject.fetchRequest()
                fetchRequest.predicate = predicate
                let count = try self.cacheStore.managedObjectContext.deleteObjects(matching: fetchRequest)
                self.log.info("Deleted %d DeletedCarbObjects", count)
            } catch let error {
                self.log.error("Unable to purge DeletedCarbObjects: %@", String(describing: error))
            }
        }
    }

    private func syncExternalDB() {
        dispatchPrecondition(condition: .onQueue(queue))

        self.purgeCachedCarbEntries()

        guard let syncDelegate = self.syncDelegate else {
            return
        }

        var entriesToUpload: [StoredCarbEntry] = []
        var entriesToDelete: [DeletedCarbEntry] = []

        cacheStore.managedObjectContext.performAndWait {
            let notUploaded = NSPredicate(format: "uploadState == %d", UploadState.notUploaded.rawValue)

            let cachedRequest: NSFetchRequest<CachedCarbObject> = CachedCarbObject.fetchRequest()
            cachedRequest.predicate = notUploaded

            if let objectsToUpload = try? self.cacheStore.managedObjectContext.fetch(cachedRequest) {
                entriesToUpload = objectsToUpload.map { StoredCarbEntry(managedObject: $0) }
                objectsToUpload.forEach { $0.uploadState = .uploading }
            }

            let deletedRequest: NSFetchRequest<DeletedCarbObject> = DeletedCarbObject.fetchRequest()
            deletedRequest.predicate = notUploaded

            if let objectsToDelete = try? self.cacheStore.managedObjectContext.fetch(deletedRequest) {
                entriesToDelete = objectsToDelete.compactMap { DeletedCarbEntry(managedObject: $0) }
                objectsToDelete.forEach { $0.uploadState = .uploading }
            }

            self.cacheStore.save()
        }

        if entriesToUpload.count > 0 {
            syncDelegate.carbStore(self, hasEntriesNeedingUpload: entriesToUpload) { (entries) in
                self.cacheStore.managedObjectContext.perform {
                    var hasMissingObjects = false

                    for entry in entries {
                        let objects = self.cacheStore.managedObjectContext.cachedCarbObjectsWithUUID(entry.sampleUUID)
                        for object in objects {
                            object.externalID = entry.externalID
                            object.uploadState = entry.isUploaded ? .uploaded : .notUploaded
                        }

                        // If our delegate sent back uploaded entries we no longer know about,
                        // consider them needing deletion.
                        if  objects.count == 0,
                            entry.isUploaded,
                            entry.startDate > self.earliestCacheDate,
                            let externalID = entry.externalID
                        {
                            self.log.info("Uploaded entry %@ not found in cache", entry.sampleUUID.uuidString)
                            let deleted = DeletedCarbObject(context: self.cacheStore.managedObjectContext)
                            deleted.externalID = externalID
                            hasMissingObjects = true
                        }
                    }

                    self.cacheStore.save()

                    if hasMissingObjects {
                        self.queue.async {
                            self.syncExternalDB()
                        }
                    }
                }
            }
        }

        if entriesToDelete.count > 0 {
            syncDelegate.carbStore(self, hasDeletedEntries: entriesToDelete) { (entries) in
                self.cacheStore.managedObjectContext.perform {
                    for entry in entries {
                        let request: NSFetchRequest<DeletedCarbObject> = DeletedCarbObject.fetchRequest()
                        request.predicate = NSPredicate(format: "externalID == %@", entry.externalID)

                        if let objects = try? self.cacheStore.managedObjectContext.fetch(request) {
                            for object in objects {
                                if entry.isUploaded {
                                    self.cacheStore.managedObjectContext.delete(object)
                                } else {
                                    object.uploadState = .notUploaded
                                }
                            }
                        }
                    }

                    self.cacheStore.save()
                }
            }
        }
    }

    // MARK: - Helpers

    /// Fetches carb entries from the cache that match the given predicate
    ///
    /// - Parameter predicate: The predicate to apply to the objects
    /// - Returns: An array of carb entries, in chronological order by startDate
    private func getCachedCarbEntries(matching predicate: NSPredicate? = nil) -> [StoredCarbEntry] {
        dispatchPrecondition(condition: .onQueue(queue))
        var entries: [StoredCarbEntry] = []

        cacheStore.managedObjectContext.performAndWait {
            let request: NSFetchRequest<CachedCarbObject> = CachedCarbObject.fetchRequest()
            request.predicate = predicate
            request.sortDescriptors = [NSSortDescriptor(key: "startDate", ascending: true)]

            guard let objects = try? self.cacheStore.managedObjectContext.fetch(request) else {
                return
            }

            entries = objects.map { StoredCarbEntry(managedObject: $0) }
        }

        return entries
    }
}


// MARK: - Math
extension CarbStore {
    /// The longest expected absorption time interval for carbohydrates. Defaults to 8 hours.
    public var maximumAbsorptionTimeInterval: TimeInterval {
        return defaultAbsorptionTimes.slow * 2
    }

    /// Retrieves the single carbs on-board value occuring just prior or equal to the specified date
    ///
    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
    ///
    /// - Parameters:
    ///   - date: The date of the value to retrieve
    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
    ///   - completion: A closure called once the value has been retrieved
    ///   - result: The carbs on-board value
    public func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]? = nil, completion: @escaping (_ result: CarbStoreResult<CarbValue>) -> Void) {
        getCarbsOnBoardValues(start: date.addingTimeInterval(-delta), end: date, effectVelocities: effectVelocities) { (values) in
            guard let value = values.closestPrior(to: date) else {
                completion(.failure(.noData))
                return
            }
            completion(.success(value))
        }
    }

    /// Retrieves a timeline of unabsorbed carbohydrates
    ///
    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
    ///
    /// - Parameters:
    ///   - start: The earliest date of values to retrieve
    ///   - end: The latest date of values to retrieve, if provided
    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
    ///   - completion: A closure called once the values have been retrieved
    ///   - values: A timeline of carb values, in chronological order
    public func getCarbsOnBoardValues(start: Date, end: Date? = nil, effectVelocities: [GlucoseEffectVelocity]? = nil, completion: @escaping (_ values: [CarbValue]) -> Void) {
        // To know COB at the requested start date, we need to fetch samples that might still be absorbing
        let foodStart = start.addingTimeInterval(-maximumAbsorptionTimeInterval)
        getCachedCarbSamples(start: foodStart, end: end) { (samples) in
            let carbsOnBoard: [CarbValue]

            if let velocities = effectVelocities, let carbRatioSchedule = self.carbRatioScheduleApplyingOverrideHistory, let insulinSensitivitySchedule = self.insulinSensitivityScheduleApplyingOverrideHistory {
                carbsOnBoard = samples.map(
                    to: velocities,
                    carbRatio: carbRatioSchedule,
                    insulinSensitivity: insulinSensitivitySchedule,
                    absorptionTimeOverrun: self.absorptionTimeOverrun,
                    defaultAbsorptionTime: self.defaultAbsorptionTimes.medium,
                    delay: self.delay
                ).dynamicCarbsOnBoard(
                    from: start,
                    to: end,
                    defaultAbsorptionTime: self.defaultAbsorptionTimes.medium,
                    delay: self.delay,
                    delta: self.delta
                )
            } else {
                carbsOnBoard = samples.carbsOnBoard(
                    from: start,
                    to: end,
                    defaultAbsorptionTime: self.defaultAbsorptionTimes.medium,
                    delay: self.delay,
                    delta: self.delta
                )
            }

            completion(carbsOnBoard)
        }
    }

    /// Retrieves a timeline of effect on blood glucose from carbohydrates
    ///
    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
    ///
    /// - Parameters:
    ///   - start: The earliest date of effects to retrieve
    ///   - end: The latest date of effects to retrieve, if provided
    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
    ///   - completion: A closure called once the effects have been retrieved
    ///   - result: An array of effects, in chronological order
    public func getGlucoseEffects(start: Date, end: Date? = nil, effectVelocities: [GlucoseEffectVelocity]? = nil, completion: @escaping(_ result: CarbStoreResult<[GlucoseEffect]>) -> Void) {
        queue.async {
            guard let carbRatioSchedule = self.carbRatioScheduleApplyingOverrideHistory, let insulinSensitivitySchedule = self.insulinSensitivityScheduleApplyingOverrideHistory else {
                completion(.failure(.notConfigured))
                return
            }

            // To know glucose effects at the requested start date, we need to fetch samples that might still be absorbing
            let foodStart = start.addingTimeInterval(-self.maximumAbsorptionTimeInterval)
            let defaultAbsorptionTimes = self.defaultAbsorptionTimes
            let absorptionTimeOverrun = self.absorptionTimeOverrun
            let delay = self.delay
            let delta = self.delta
            
            self.getCachedCarbSamples(start: foodStart, end: end) { (samples) in
                let effects: [GlucoseEffect]

                if let effectVelocities = effectVelocities {
                    effects = samples.map(
                        to: effectVelocities,
                        carbRatio: carbRatioSchedule,
                        insulinSensitivity: insulinSensitivitySchedule,
                        absorptionTimeOverrun: absorptionTimeOverrun,
                        defaultAbsorptionTime: defaultAbsorptionTimes.medium,
                        delay: delay
                    ).dynamicGlucoseEffects(
                        from: start,
                        to: end,
                        carbRatios: carbRatioSchedule,
                        insulinSensitivities: insulinSensitivitySchedule,
                        defaultAbsorptionTime: defaultAbsorptionTimes.medium,
                        delay: delay,
                        delta: delta
                    )
                } else {
                    effects = samples.glucoseEffects(
                        from: start,
                        to: end,
                        carbRatios: carbRatioSchedule,
                        insulinSensitivities: insulinSensitivitySchedule,
                        defaultAbsorptionTime: defaultAbsorptionTimes.medium,
                        delay: delay,
                        delta: delta
                    )
                }

                completion(.success(effects))
            }
        }
    }

    /// Retrieves the total number of recorded carbohydrates for the specified period.
    ///
    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
    ///
    /// - Parameters:
    ///   - start: The earliest date of samples to include.
    ///   - completion: A closure called once the value has been retrieved.
    ///   - result: The total carbs recorded and the date of the first sample
    public func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult<CarbValue>) -> Void) {
        getCarbSamples(start: start) { (result) in
            switch result {
            case .success(let samples):
                let total = samples.totalCarbs ?? CarbValue(
                    startDate: start,
                    quantity: HKQuantity(unit: self.preferredUnit, doubleValue: 0)
                )

                completion(.success(total))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}


extension CarbStore {
    /// Generates a diagnostic report about the current state
    ///
    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
    ///
    /// - parameter completionHandler: A closure called once the report has been generated. The closure takes a single argument of the report string.
    public func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) {
        queue.async {
            var report: [String] = [
                "## CarbStore",
                "",
                "* carbRatioSchedule: \(self.carbRatioSchedule?.debugDescription ?? "")",
                "* carbRatioScheduleApplyingOverrideHistory: \(self.carbRatioScheduleApplyingOverrideHistory?.debugDescription ?? "nil")",
                "* defaultAbsorptionTimes: \(self.defaultAbsorptionTimes)",
                "* insulinSensitivitySchedule: \(self.insulinSensitivitySchedule?.debugDescription ?? "")",
                "* insulinSensitivityScheduleApplyingOverrideHistory: \(self.insulinSensitivityScheduleApplyingOverrideHistory?.debugDescription ?? "nil")",
                "* overrideHistory: \(self.overrideHistory.map(String.init(describing:)) ?? "nil")",
                "* carbSensitivitySchedule: \(self.carbSensitivitySchedule?.debugDescription ?? "nil")",
                "* delay: \(self.delay)",
                "* delta: \(self.delta)",
                "* absorptionTimeOverrun: \(self.absorptionTimeOverrun)",
                super.debugDescription,
                "",
                "cachedCarbEntries: [",
                "\tStoredCarbEntry(sampleUUID, syncIdentifier, syncVersion, startDate, quantity, foodType, absorptionTime, createdByCurrentApp, externalID, isUploaded)"
            ]

            let carbEntries = self.getCachedCarbEntries()

            report.append(carbEntries.map({ (entry) -> String in
                return [
                    "\t",
                    String(describing: entry.sampleUUID),
                    entry.syncIdentifier ?? "",
                    String(describing: entry.syncVersion),
                    String(describing: entry.startDate),
                    String(describing: entry.quantity),
                    entry.foodType ?? "",
                    String(describing: entry.absorptionTime ?? self.defaultAbsorptionTimes.medium),
                    String(describing: entry.createdByCurrentApp),
                    entry.externalID ?? "",
                    String(describing: entry.isUploaded),
                ].joined(separator: ", ")
            }).joined(separator: "\n"))
            report.append("]")
            report.append("")

            report.append("deletedCarbEntries: [")
            report.append("\tDeletedCarbEntry(externalID, isUploaded)")
            for entry in self.cachedDeletedCarbEntries {
                report.append("\t\(entry.externalID), \(entry.isUploaded)")
            }
            report.append("]")
            report.append("")

            completionHandler(report.joined(separator: "\n"))
        }
    }
}
