// // 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 { // 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: !result.isEmpty) } } 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: !syncResult.isEmpty || cleanupSuccess) } } 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)") } } /// 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