// // BackgroundSyncManager.swift // SportsTime // // Manages background tasks for nightly CloudKit sync and database cleanup. // Uses BGTaskScheduler to run sync operations even when app is not active. // import Foundation import BackgroundTasks import SwiftData import os /// Manages background refresh and processing tasks for CloudKit sync. @MainActor final class BackgroundSyncManager { enum SyncTriggerError: Error, LocalizedError { case modelContainerNotConfigured var errorDescription: String? { switch self { case .modelContainerNotConfigured: return "Sync is unavailable because the model container is not configured." } } } // MARK: - Task Identifiers /// Background app refresh task - runs periodically (system decides frequency) static let refreshTaskIdentifier = "com.88oakapps.SportsTime.refresh" /// Background processing task - runs during optimal conditions (plugged in, overnight) static let processingTaskIdentifier = "com.88oakapps.SportsTime.db-cleanup" // MARK: - Singleton static let shared = BackgroundSyncManager() // MARK: - Properties private var modelContainer: ModelContainer? private var currentCancellationToken: BackgroundTaskCancellationToken? private let logger = Logger(subsystem: "com.88oakapps.SportsTime", category: "BackgroundSyncManager") private init() {} // MARK: - Configuration /// Configure the manager with the model container. /// Must be called before registering tasks. func configure(with container: ModelContainer) { self.modelContainer = container } // MARK: - Task Registration /// Register all background tasks with the system. /// Must be called early in app lifecycle, before `applicationDidFinishLaunching` returns. func registerTasks() { // Register refresh task (for periodic CloudKit sync) BGTaskScheduler.shared.register( forTaskWithIdentifier: Self.refreshTaskIdentifier, using: nil ) { task in guard let task = task as? BGAppRefreshTask else { return } Task { @MainActor in await self.handleRefreshTask(task) } } // Register processing task (for overnight heavy sync/cleanup) BGTaskScheduler.shared.register( forTaskWithIdentifier: Self.processingTaskIdentifier, using: nil ) { task in guard let task = task as? BGProcessingTask else { return } Task { @MainActor in await self.handleProcessingTask(task) } } print("Background tasks registered") } // MARK: - Task Scheduling /// Schedule the next background refresh. /// System will run this periodically based on app usage patterns. func scheduleRefresh() { let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier) // Request to run no earlier than 1 hour from now // System will actually schedule based on usage patterns, battery, network, etc. request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) do { try BGTaskScheduler.shared.submit(request) print("Background refresh scheduled") } catch { print("Failed to schedule background refresh: \(error.localizedDescription)") } } /// Schedule overnight processing task for heavy sync/cleanup. /// Runs when device is charging and connected to WiFi. func scheduleProcessingTask() { let request = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier) // Schedule for overnight - earliest 2 AM let calendar = Calendar.current var components = calendar.dateComponents([.year, .month, .day], from: Date()) components.hour = 2 components.minute = 0 // If it's past 2 AM, schedule for tomorrow var targetDate = calendar.date(from: components) ?? Date() if targetDate < Date() { targetDate = calendar.date(byAdding: .day, value: 1, to: targetDate) ?? Date() } request.earliestBeginDate = targetDate request.requiresNetworkConnectivity = true request.requiresExternalPower = false // Don't require power, but prefer it do { try BGTaskScheduler.shared.submit(request) print("Overnight processing task scheduled for \(targetDate)") } catch { print("Failed to schedule processing task: \(error.localizedDescription)") } } /// Schedule all background tasks. /// Call this after app finishes launching and when app enters background. func scheduleAllTasks() { scheduleRefresh() scheduleProcessingTask() } // MARK: - Task Handlers /// Handle the background refresh task. @MainActor private func handleRefreshTask(_ task: BGAppRefreshTask) async { logger.info("Background refresh task started") // Schedule the next refresh before we start (in case we get terminated) scheduleRefresh() // Create cancellation token for this task let cancellationToken = BackgroundTaskCancellationToken() currentCancellationToken = cancellationToken // Set up expiration handler - cancel sync gracefully task.expirationHandler = { [weak self] in self?.logger.warning("Background refresh task expiring - cancelling sync") cancellationToken.cancel() } guard let container = modelContainer else { logger.error("Background refresh: No model container configured") task.setTaskCompleted(success: false) return } do { let result = try await performSync(context: container.mainContext, cancellationToken: cancellationToken) currentCancellationToken = nil if result.wasCancelled { logger.info("Background refresh cancelled with partial progress: \(result.totalUpdated) items synced") task.setTaskCompleted(success: true) // Partial success - progress was saved } else { logger.info("Background refresh completed: \(result.totalUpdated) items updated") task.setTaskCompleted(success: true) } } catch { currentCancellationToken = nil logger.error("Background refresh failed: \(error.localizedDescription)") task.setTaskCompleted(success: false) } } /// Handle the background processing task (overnight heavy sync). @MainActor private func handleProcessingTask(_ task: BGProcessingTask) async { logger.info("Background processing task started") // Schedule the next processing task scheduleProcessingTask() // Create cancellation token for this task let cancellationToken = BackgroundTaskCancellationToken() currentCancellationToken = cancellationToken // Set up expiration handler - cancel sync gracefully task.expirationHandler = { [weak self] in self?.logger.warning("Background processing task expiring - cancelling sync") cancellationToken.cancel() } guard let container = modelContainer else { logger.error("Background processing: No model container configured") task.setTaskCompleted(success: false) return } do { // 1. Full sync from CloudKit let syncResult = try await performSync(context: container.mainContext, cancellationToken: cancellationToken) currentCancellationToken = nil // 2. Clean up old data (games older than 1 year) - only if sync wasn't cancelled var cleanupSuccess = false if !syncResult.wasCancelled { cleanupSuccess = await performCleanup(context: container.mainContext) } if syncResult.wasCancelled { logger.info("Background processing cancelled with partial progress: \(syncResult.totalUpdated) items synced") task.setTaskCompleted(success: true) // Partial success } else { logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)") task.setTaskCompleted(success: true) } } catch { currentCancellationToken = nil logger.error("Background processing failed: \(error.localizedDescription)") task.setTaskCompleted(success: false) } } // MARK: - Sync Operations /// Perform CloudKit sync operation. /// - Parameters: /// - context: The ModelContext to use /// - cancellationToken: Optional token for graceful cancellation /// - Returns: The sync result with counts and cancellation status @MainActor private func performSync( context: ModelContext, cancellationToken: SyncCancellationToken? = nil ) async throws -> CanonicalSyncService.SyncResult { let syncService = CanonicalSyncService() do { let result = try await syncService.syncAll(context: context, cancellationToken: cancellationToken) // Reload DataProvider if data changed if !result.isEmpty || result.wasCancelled { await AppDataProvider.shared.loadInitialData() logger.info("Sync completed: \(result.totalUpdated) items updated, cancelled: \(result.wasCancelled)") } return result } catch CanonicalSyncService.SyncError.cloudKitUnavailable { // No network - this is expected, not a failure logger.info("CloudKit unavailable (offline)") return CanonicalSyncService.SyncResult( stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, duration: 0, wasCancelled: false ) } catch CanonicalSyncService.SyncError.syncAlreadyInProgress { // Another sync is running - that's fine logger.info("Sync already in progress") return CanonicalSyncService.SyncResult( stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, duration: 0, wasCancelled: false ) } } /// Trigger sync from network restoration (called by NetworkMonitor). /// This is a non-background task sync, so no cancellation token is needed. @MainActor func triggerSyncFromNetworkRestoration() async { guard let container = modelContainer else { logger.error("Network sync: No model container configured") return } logger.info("Triggering sync from network restoration") do { let result = try await performSync(context: container.mainContext) if !result.isEmpty { logger.info("Network restoration sync completed: \(result.totalUpdated) items updated") } else { logger.info("Network restoration sync: no updates needed") } } catch { logger.error("Network restoration sync failed: \(error.localizedDescription)") } } /// Trigger sync from a CloudKit push notification. /// Returns true if local data changed and UI should refresh. @MainActor func triggerSyncFromPushNotification(subscriptionID: String?) async -> Bool { guard let container = modelContainer else { logger.error("Push sync: No model container configured") return false } if let subscriptionID { logger.info("Triggering sync from CloudKit push (\(subscriptionID, privacy: .public))") } else { logger.info("Triggering sync from CloudKit push") } do { let result = try await performSync(context: container.mainContext) return !result.isEmpty || result.wasCancelled } catch { logger.error("Push-triggered sync failed: \(error.localizedDescription)") return false } } /// Apply a targeted local deletion for records removed remotely. /// This covers deletions which are not returned by date-based delta queries. @MainActor func applyDeletionHint(recordType: String?, recordName: String) async -> Bool { guard let container = modelContainer else { logger.error("Deletion hint: No model container configured") return false } let context = container.mainContext var changed = false do { switch recordType { case CKRecordType.game: let descriptor = FetchDescriptor( predicate: #Predicate { game in game.canonicalId == recordName && game.deprecatedAt == nil } ) if let game = try context.fetch(descriptor).first { game.deprecatedAt = Date() game.deprecationReason = "Deleted in CloudKit" changed = true } case CKRecordType.team: let descriptor = FetchDescriptor( predicate: #Predicate { team in team.canonicalId == recordName && team.deprecatedAt == nil } ) if let team = try context.fetch(descriptor).first { team.deprecatedAt = Date() team.deprecationReason = "Deleted in CloudKit" changed = true } // Avoid dangling schedule entries when a referenced team disappears. let impactedGames = FetchDescriptor( predicate: #Predicate { game in game.deprecatedAt == nil && (game.homeTeamCanonicalId == recordName || game.awayTeamCanonicalId == recordName) } ) for game in try context.fetch(impactedGames) { game.deprecatedAt = Date() game.deprecationReason = "Dependent team deleted in CloudKit" changed = true } case CKRecordType.stadium: let descriptor = FetchDescriptor( predicate: #Predicate { stadium in stadium.canonicalId == recordName && stadium.deprecatedAt == nil } ) if let stadium = try context.fetch(descriptor).first { stadium.deprecatedAt = Date() stadium.deprecationReason = "Deleted in CloudKit" changed = true } // Avoid dangling schedule entries when a referenced stadium disappears. let impactedGames = FetchDescriptor( predicate: #Predicate { game in game.deprecatedAt == nil && game.stadiumCanonicalId == recordName } ) for game in try context.fetch(impactedGames) { game.deprecatedAt = Date() game.deprecationReason = "Dependent stadium deleted in CloudKit" changed = true } case CKRecordType.leagueStructure: let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == recordName } ) let records = try context.fetch(descriptor) for record in records { context.delete(record) changed = true } case CKRecordType.teamAlias: let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == recordName } ) let records = try context.fetch(descriptor) for record in records { context.delete(record) changed = true } case CKRecordType.stadiumAlias: let descriptor = FetchDescriptor( predicate: #Predicate { $0.aliasName == recordName } ) let records = try context.fetch(descriptor) for record in records { context.delete(record) changed = true } case CKRecordType.sport: let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == recordName } ) if let sport = try context.fetch(descriptor).first, sport.isActive { sport.isActive = false sport.lastModified = Date() changed = true } default: break } guard changed else { return false } try context.save() await AppDataProvider.shared.loadInitialData() logger.info("Applied CloudKit deletion hint for \(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)") return true } catch { logger.error("Failed to apply deletion hint (\(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)): \(error.localizedDescription)") return false } } /// Trigger a manual full sync from settings or other explicit user action. @MainActor func triggerManualSync() async throws -> CanonicalSyncService.SyncResult { guard let container = modelContainer else { throw SyncTriggerError.modelContainerNotConfigured } let context = container.mainContext let syncService = CanonicalSyncService() let syncState = SyncState.current(in: context) if !syncState.syncEnabled { syncService.resumeSync(context: context) } return try await performSync(context: context) } /// Register CloudKit subscriptions for canonical data updates. @MainActor func ensureCanonicalSubscriptions() async { do { try await CloudKitService.shared.subscribeToAllUpdates() logger.info("CloudKit subscriptions ensured for canonical sync") } catch { logger.error("Failed to register CloudKit subscriptions: \(error.localizedDescription)") } } /// Clean up old game data (older than 1 year). @MainActor private func performCleanup(context: ModelContext) async -> Bool { let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date()) ?? Date() do { // Fetch old games let descriptor = FetchDescriptor( predicate: #Predicate { game in game.dateTime < oneYearAgo && game.deprecatedAt == nil } ) let oldGames = try context.fetch(descriptor) guard !oldGames.isEmpty else { return false } // Soft-delete old games (mark as deprecated) for game in oldGames { game.deprecatedAt = Date() game.deprecationReason = "Auto-archived: game older than 1 year" } try context.save() print("Background cleanup: Archived \(oldGames.count) old games") return true } catch { print("Background cleanup failed: \(error.localizedDescription)") return false } } } // MARK: - Debug Helpers #if DEBUG extension BackgroundSyncManager { /// Manually trigger refresh task for testing. /// Run in debugger: `e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.88oakapps.SportsTime.refresh"]` func debugTriggerRefresh() { print("Debug: Use lldb command to trigger background refresh:") print("e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@\"com.88oakapps.SportsTime.refresh\"]") } /// Manually trigger processing task for testing. func debugTriggerProcessing() { print("Debug: Use lldb command to trigger background processing:") print("e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@\"com.88oakapps.SportsTime.db-cleanup\"]") } } #endif