HealthProbe / HealthProbe / ViewModels / DashboardViewModel.swift
1 contributor
560 lines | 23.002kb
import Foundation
import SwiftData

enum LegacySwiftDataBridgeError: LocalizedError {
    case notConfigured

    var errorDescription: String? {
        switch self {
        case .notConfigured:
            return "Legacy SwiftData bridge is not configured."
        }
    }
}

enum LegacySwiftDataBridge {
    private static var container: ModelContainer?
    private static var context: ModelContext?

    static func configure(container: ModelContainer) {
        self.container = container
        context = ModelContext(container)
    }

    static func modelContext() throws -> ModelContext {
        if let context { return context }
        guard let container else {
            throw LegacySwiftDataBridgeError.notConfigured
        }
        let context = ModelContext(container)
        self.context = context
        return context
    }
}

@Observable
final class DashboardViewModel {
    var isRequestingAuth = false
    var isCreatingSnapshot = false
    var authError: String?
    var snapshotError: String?
    var snapshotProgress: SnapshotProgress = .idle
    var snapshotProgressMessage: String = ""
    var snapshotProgressDetail: String = ""
    var showProgressSheet = false
    var canRetryWithPermissions = false
    var permissionsAlreadyRequested = false
    var fetchProgress: SnapshotFetchProgress?
    var fetchDurationSeconds: TimeInterval? = nil
    var fetchStartDate: Date? = nil
    var operationID: UUID? = nil
    var completedSnapshotID: UUID? = nil
    var completedSnapshotTimestamp: Date? = nil
    var completedSnapshotDeviceID: String? = nil
    var completedSnapshotTriggerReason: String? = nil
    var completedSnapshotRetryOfSnapshotID: UUID? = nil
    var ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric] = []
    var latestArchiveObservation: CachedArchiveObservationRow?
    var archiveObservationRows: [CachedArchiveObservationRow] = []
    var archiveHealthStatus: CachedArchiveHealthStatus?
    var archiveCacheError: String?

    private let healthKit = HealthKitService.shared
    private var pendingPartialSnapshot: HealthSnapshot?
    private var pendingAmbiguousSnapshot: HealthSnapshot?

    func requestAuthorization() async {
        isRequestingAuth = true
        authError = nil
        defer { isRequestingAuth = false }
        do {
            try await healthKit.requestAuthorization()
        } catch {
            authError = error.localizedDescription
        }
    }

    func createSnapshot(
        selectedTypeIDs: Set<String>,
        adaptiveTimeoutsEnabled: Bool,
        triggerReason: String = "manual",
        retryOfSnapshotID: UUID? = nil,
        timeoutMultiplier: Double = 1
    ) async {
        guard !selectedTypeIDs.isEmpty else {
            snapshotError = "No health data types selected. Please select types to monitor in Settings."
            return
        }

        let context: ModelContext
        do {
            context = try LegacySwiftDataBridge.modelContext()
        } catch {
            snapshotError = "Failed to access legacy snapshot cache: \(error.localizedDescription)"
            snapshotProgress = .idle
            showProgressSheet = true
            return
        }

        isCreatingSnapshot = true
        snapshotError = nil
        snapshotProgress = .fetching
        showProgressSheet = true
        fetchStartDate = Date()
        fetchDurationSeconds = nil
        operationID = UUID()
        completedSnapshotID = nil
        completedSnapshotTimestamp = nil
        completedSnapshotDeviceID = nil
        completedSnapshotTriggerReason = nil
        completedSnapshotRetryOfSnapshotID = nil
        pendingPartialSnapshot = nil
        pendingAmbiguousSnapshot = nil
        ambiguousDisappearedMetrics = []
        snapshotProgressMessage = ""
        snapshotProgressDetail = ""
        canRetryWithPermissions = false
        permissionsAlreadyRequested = false

        let monitoredTypes = HealthKitService.allTypes
            .filter { selectedTypeIDs.contains($0.id) }
            .sortedByFetchDisplayNameDescending()
            .map { (id: $0.id, displayName: $0.displayName) }
        fetchProgress = SnapshotFetchProgress(monitoredTypes: monitoredTypes)
        fetchProgress?.updateConfiguration(
            perTypeTimeoutSeconds: HealthKitService.defaultInitialTimeoutSeconds,
            maxConcurrentTypeFetches: HealthKitService.maxConcurrentTypeFetches,
            adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled
        )

        defer { isCreatingSnapshot = false }

        do {
            let concurrentBatches = ceil(Double(selectedTypeIDs.count) / Double(HealthKitService.maxConcurrentTypeFetches))
            let historyImportTimeout = concurrentBatches * HealthKitService.fullHistoryImportTimeoutSeconds + 30
            let learnedMetricTimeout = Double(selectedTypeIDs.count) * HealthKitService.maximumTimeoutSeconds + 30
            let operationTimeout = max(120, historyImportTimeout, learnedMetricTimeout)
            let snapshot = try await withTimeout(seconds: operationTimeout) {
                try await self.healthKit.createSnapshot(
                    in: context,
                    selectedTypeIDs: selectedTypeIDs,
                    adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
                    triggerReason: triggerReason,
                    retryOfSnapshotID: retryOfSnapshotID,
                    timeoutMultiplier: timeoutMultiplier,
                    reviewAmbiguousCompleteDisappearedTypes: triggerReason == "manual",
                    progress: self.fetchProgress
                )
            }

            fetchDurationSeconds = fetchStartDate.map { Date().timeIntervalSince($0) }
            completedSnapshotID = snapshot.id
            completedSnapshotTimestamp = snapshot.timestamp
            completedSnapshotDeviceID = snapshot.deviceID
            completedSnapshotTriggerReason = snapshot.triggerReason
            completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID
            fetchProgress?.updateChainContext(
                previousSnapshotID: snapshot.previousSnapshotID,
                isChainStart: snapshot.isChainStart,
                snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
                monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
                monitoredRegistryVersion: snapshot.monitoredRegistryVersion
            )

            reflectUnavailableMetricsInProgress(snapshot: snapshot)

            if snapshot.snapshotQuality != SnapshotQuality.complete {
                pendingPartialSnapshot = snapshot

                let typeCounts = snapshot.typeCounts ?? []
                let unauthorizedCount = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }.count
                let failedCount = typeCounts.filter { $0.quality == SnapshotQuality.failed }.count

                let failedTypes = typeCounts.filter { $0.quality == SnapshotQuality.failed }
                let unauthorizedTypes = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }

                snapshotProgress = .incomplete
                snapshotProgressMessage = "Incomplete snapshot"
                permissionsAlreadyRequested = healthKit.hasRequestedPermissionsBefore

                if shouldAutoSaveKnownUnauthorizedPartial(snapshot: snapshot, failedCount: failedCount, context: context) {
                    snapshotProgressMessage = "Known unavailable metrics"
                    snapshotProgressDetail = "Snapshot was auto-saved as partial because unavailable metrics were already confirmed in the previous snapshot."
                    await savePartialSnapshot(keepSheetOpenForReview: true)
                    return
                }

                if unauthorizedCount > 0 {
                    snapshotProgressMessage = "Missing permissions (\(unauthorizedCount) type\(unauthorizedCount == 1 ? "" : "s"))"
                    let typesList = unauthorizedTypes.map { $0.displayName }.joined(separator: ", ")

                    if permissionsAlreadyRequested {
                        snapshotProgressDetail = """
                        Not authorized: \(typesList)

                        You need to grant access to health data. Open Settings and enable HealthKit access for the types you want to monitor.
                        """
                        canRetryWithPermissions = false
                    } else {
                        snapshotProgressDetail = "Some health data types are not authorized. You can request permissions and try again."
                        canRetryWithPermissions = true
                    }
                } else if failedCount > 0 {
                    snapshotProgressMessage = "Data load failed (\(failedCount) type\(failedCount == 1 ? "" : "s"))"
                    let typesList = failedTypes.map { $0.displayName }.joined(separator: ", ")
                    snapshotProgressDetail = """
                    Failed to load: \(typesList)

                    This may indicate a temporary issue. Try again or check HealthKit availability.
                    """
                    canRetryWithPermissions = false
                } else {
                    snapshotProgressDetail = "Snapshot created with quality: \(snapshot.snapshotQuality.rawValue). Some data may be incomplete."
                    canRetryWithPermissions = false
                }

                showProgressSheet = true
                return
            }

            let ambiguousMetrics = findAmbiguousDisappearedMetrics(snapshot: snapshot, context: context)
            if !ambiguousMetrics.isEmpty {
                pendingAmbiguousSnapshot = snapshot
                ambiguousDisappearedMetrics = ambiguousMetrics
                snapshotProgress = .requiresResolution
                snapshotProgressMessage = "Metric access needs review"
                snapshotProgressDetail = "One or more metrics returned zero samples after previously having data. Choose how HealthProbe should classify this before saving the snapshot."
                showProgressSheet = true
                return
            }

            let snapshotID = snapshot.id
            var descriptor = FetchDescriptor<HealthSnapshot>(
                predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
            )
            descriptor.fetchLimit = 1
            let exists = try !context.fetch(descriptor).isEmpty

            if !exists {
                throw SnapshotCreationError.snapshotNotSaved
            }

            snapshotProgress = .complete
            refreshArchiveCache()
        } catch is CancellationError {
            snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available."
            snapshotProgress = .idle
            showProgressSheet = true
        } catch let error as SnapshotCreationError {
            snapshotError = error.message
            snapshotProgress = .idle
            showProgressSheet = true
        } catch {
            snapshotError = "Failed to create snapshot: \(error.localizedDescription)"
            snapshotProgress = .idle
            showProgressSheet = true
        }
    }

    func treatAmbiguousMetricsAsUnauthorized() async {
        guard let snapshot = pendingAmbiguousSnapshot else { return }
        let ambiguousIDs = Set(ambiguousDisappearedMetrics.map(\.id))
        let typeCounts = snapshot.typeCounts ?? []

        for typeCount in typeCounts where ambiguousIDs.contains(typeCount.typeIdentifier) {
            typeCount.count = -1
            typeCount.contentHash = ""
            typeCount.earliestDate = nil
            typeCount.latestDate = nil
            typeCount.quality = .unauthorized
            typeCount.yearlyCounts = []
        }
        snapshot.snapshotQuality = healthKit.deriveSnapshotQuality(from: typeCounts)

        do {
            let context = try LegacySwiftDataBridge.modelContext()
            let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
            finishSavedReviewedSnapshot(saved)
        } catch {
            snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)"
            showProgressSheet = true
        }
    }

    func treatAmbiguousMetricsAsDeleted() async {
        guard let snapshot = pendingAmbiguousSnapshot else { return }

        do {
            let context = try LegacySwiftDataBridge.modelContext()
            let saved = try await healthKit.saveReviewedCompleteSnapshot(snapshot, in: context)
            finishSavedReviewedSnapshot(saved)
        } catch {
            snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)"
            showProgressSheet = true
        }
    }

    func retryWithPermissions(selectedTypeIDs: Set<String>, adaptiveTimeoutsEnabled: Bool) async {
        isRequestingAuth = true
        defer { isRequestingAuth = false }
        do {
            try await healthKit.requestAuthorization()
            await createSnapshot(selectedTypeIDs: selectedTypeIDs, adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled)
        } catch {
            snapshotError = "Failed to request permissions: \(error.localizedDescription)"
            snapshotProgress = .idle
            showProgressSheet = false
        }
    }

    func retryFailedMetricsWithExtendedTimeout(
        selectedTypeIDs: Set<String>,
        adaptiveTimeoutsEnabled: Bool
    ) async {
        guard let progress = fetchProgress else { return }
        let hasTimeout = progress.types.contains { type in
            guard case .failed(let reason) = type.status else { return false }
            return reason == "Timeout"
        }
        guard hasTimeout else { return }

        await createSnapshot(
            selectedTypeIDs: selectedTypeIDs,
            adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
            triggerReason: "retryFailedMetrics",
            timeoutMultiplier: 2
        )
    }

    func savePartialSnapshot(keepSheetOpenForReview: Bool = false) async {
        guard let snapshot = pendingPartialSnapshot else {
            if !keepSheetOpenForReview {
                fetchProgress = nil
                showProgressSheet = false
                snapshotProgress = .idle
            }
            return
        }

        do {
            let context = try LegacySwiftDataBridge.modelContext()
            let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
            completedSnapshotID = saved.id
            completedSnapshotTimestamp = saved.timestamp
            completedSnapshotDeviceID = saved.deviceID
            completedSnapshotTriggerReason = saved.triggerReason
            completedSnapshotRetryOfSnapshotID = saved.retryOfSnapshotID
            pendingPartialSnapshot = nil
            fetchProgress?.updateChainContext(
                previousSnapshotID: saved.previousSnapshotID,
                isChainStart: saved.isChainStart,
                snapshotChecksum: HashService.snapshotChecksum(typeCounts: saved.typeCounts ?? []),
                monitoredTypeSetHash: saved.monitoredTypeSetHash,
                monitoredRegistryVersion: saved.monitoredRegistryVersion
            )
            refreshArchiveCache()
        } catch {
            snapshotError = "Failed to save partial snapshot: \(error.localizedDescription)"
            showProgressSheet = true
            return
        }

        if keepSheetOpenForReview {
            snapshotProgress = .incomplete
            showProgressSheet = true
            return
        }

        fetchProgress = nil
        showProgressSheet = false
        snapshotProgress = .idle
    }

    func discardSnapshot() async {
        pendingPartialSnapshot = nil
        pendingAmbiguousSnapshot = nil
        ambiguousDisappearedMetrics = []
        if let snapshotID = completedSnapshotID {
            do {
                let context = try LegacySwiftDataBridge.modelContext()
                var descriptor = FetchDescriptor<HealthSnapshot>(
                    predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
                )
                descriptor.fetchLimit = 1
                if let snapshot = try context.fetch(descriptor).first {
                    context.delete(snapshot)
                }
            } catch { }
        }
        completedSnapshotID = nil
        fetchProgress = nil
        showProgressSheet = false
        snapshotProgress = .idle
    }

    func loadArchiveCacheStatus() {
        do {
            let cache = try CoreDataArchiveCacheStore()
            archiveObservationRows = try cache.observationRows(limit: 2)
            latestArchiveObservation = archiveObservationRows.first
            archiveHealthStatus = try cache.latestArchiveHealthStatus()
            archiveCacheError = nil
        } catch {
            archiveObservationRows = []
            latestArchiveObservation = nil
            archiveHealthStatus = nil
            archiveCacheError = error.localizedDescription
        }
    }

    private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
        try await withThrowingTaskGroup(of: T.self) { group in
            group.addTask { try await operation() }
            group.addTask {
                try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
                throw CancellationError()
            }
            let result = try await group.next()!
            group.cancelAll()
            return result
        }
    }

    private func findAmbiguousDisappearedMetrics(snapshot: HealthSnapshot, context: ModelContext) -> [AmbiguousDisappearedMetric] {
        guard let previousID = snapshot.previousSnapshotID else { return [] }
        let descriptor = FetchDescriptor<HealthSnapshot>(
            predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
        )
        guard let previous = try? context.fetch(descriptor).first else { return [] }

        let previousByType = Dictionary(
            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
        )

        return (snapshot.typeCounts ?? []).compactMap { current in
            guard current.quality == .complete,
                  current.count == 0,
                  let previous = previousByType[current.typeIdentifier],
                  previous.quality == .complete,
                  previous.count > 0 else {
                return nil
            }
            return AmbiguousDisappearedMetric(
                id: current.typeIdentifier,
                displayName: current.displayName,
                previousCount: previous.count
            )
        }.sorted { $0.displayName < $1.displayName }
    }

    private func reflectUnavailableMetricsInProgress(snapshot: HealthSnapshot) {
        guard let fetchProgress else { return }
        for typeCount in snapshot.typeCounts ?? [] where typeCount.quality == .unauthorized {
            fetchProgress.markUnavailable(typeCount.typeIdentifier)
        }
    }

    private func shouldAutoSaveKnownUnauthorizedPartial(snapshot: HealthSnapshot, failedCount: Int, context: ModelContext) -> Bool {
        guard failedCount == 0,
              let previousID = snapshot.previousSnapshotID else {
            return false
        }

        let currentUnauthorized = (snapshot.typeCounts ?? []).filter { $0.quality == .unauthorized }
        guard !currentUnauthorized.isEmpty else { return false }

        let descriptor = FetchDescriptor<HealthSnapshot>(
            predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
        )
        guard let previous = try? context.fetch(descriptor).first else {
            return false
        }

        let previousByType = Dictionary(
            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
        )

        return currentUnauthorized.allSatisfy { previousByType[$0.typeIdentifier]?.quality == .unauthorized }
    }

    private func finishSavedReviewedSnapshot(_ snapshot: HealthSnapshot) {
        completedSnapshotID = snapshot.id
        completedSnapshotTimestamp = snapshot.timestamp
        completedSnapshotDeviceID = snapshot.deviceID
        completedSnapshotTriggerReason = snapshot.triggerReason
        completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID
        pendingAmbiguousSnapshot = nil
        pendingPartialSnapshot = nil
        ambiguousDisappearedMetrics = []
        fetchProgress?.updateChainContext(
            previousSnapshotID: snapshot.previousSnapshotID,
            isChainStart: snapshot.isChainStart,
            snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
            monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
            monitoredRegistryVersion: snapshot.monitoredRegistryVersion
        )
        fetchProgress = nil
        showProgressSheet = false
        snapshotProgress = .idle
        refreshArchiveCache()
    }

    private func refreshArchiveCache() {
        do {
            let cache = try CoreDataArchiveCacheStore()
            _ = try cache.rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL)
            archiveObservationRows = try cache.observationRows(limit: 2)
            latestArchiveObservation = archiveObservationRows.first
            archiveHealthStatus = try cache.latestArchiveHealthStatus()
            archiveCacheError = nil
        } catch {
            archiveObservationRows = []
            latestArchiveObservation = nil
            archiveCacheError = error.localizedDescription
        }
    }
}

struct AmbiguousDisappearedMetric: Identifiable, Equatable {
    let id: String
    let displayName: String
    let previousCount: Int
}

enum SnapshotProgress {
    case idle
    case fetching
    case processing
    case complete
    case incomplete
    case requiresResolution

    var message: String {
        switch self {
        case .idle: return ""
        case .fetching: return "Fetching health data..."
        case .processing: return "Processing snapshot..."
        case .complete: return "Snapshot created successfully!"
        case .incomplete: return "Snapshot created with issues"
        case .requiresResolution: return "Snapshot needs review"
        }
    }

    var isError: Bool {
        switch self {
        case .incomplete, .requiresResolution: return true
        default: return false
        }
    }
}

enum SnapshotCreationError: Error {
    case snapshotNotSaved

    var message: String {
        switch self {
        case .snapshotNotSaved:
            return "Snapshot was not saved to database. This may indicate a HealthKit permission issue or data corruption. Try requesting health access again in the Actions section."
        }
    }
}