// // CanonicalSyncService.swift // SportsTime // // Orchestrates syncing canonical data from CloudKit into SwiftData. // Uses date-based delta sync for public database efficiency. // import Foundation import SwiftData import CloudKit actor CanonicalSyncService { // MARK: - Errors enum SyncError: Error, LocalizedError { case cloudKitUnavailable case syncAlreadyInProgress case saveFailed(Error) case schemaVersionTooNew(Int) var errorDescription: String? { switch self { case .cloudKitUnavailable: return "CloudKit is not available. Check your internet connection and iCloud settings." case .syncAlreadyInProgress: return "A sync operation is already in progress." case .saveFailed(let error): return "Failed to save synced data: \(error.localizedDescription)" case .schemaVersionTooNew(let version): return "Data requires app version supporting schema \(version). Please update the app." } } } // MARK: - Sync Result struct SyncResult { let stadiumsUpdated: Int let teamsUpdated: Int let gamesUpdated: Int let leagueStructuresUpdated: Int let teamAliasesUpdated: Int let stadiumAliasesUpdated: Int let skippedIncompatible: Int let skippedOlder: Int let duration: TimeInterval var totalUpdated: Int { stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated } var isEmpty: Bool { totalUpdated == 0 } } // MARK: - Properties private let cloudKitService: CloudKitService // MARK: - Initialization init(cloudKitService: CloudKitService = .shared) { self.cloudKitService = cloudKitService } // MARK: - Public Sync Methods /// Perform a full sync of all canonical data types. /// This is the main entry point for background sync. @MainActor func syncAll(context: ModelContext) async throws -> SyncResult { let startTime = Date() let syncState = SyncState.current(in: context) // Prevent concurrent syncs guard !syncState.syncInProgress else { throw SyncError.syncAlreadyInProgress } // Check if sync is enabled guard syncState.syncEnabled else { return SyncResult( stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, duration: 0 ) } // Check CloudKit availability guard await cloudKitService.isAvailable() else { throw SyncError.cloudKitUnavailable } // Mark sync in progress syncState.syncInProgress = true syncState.lastSyncAttempt = Date() var totalStadiums = 0 var totalTeams = 0 var totalGames = 0 var totalLeagueStructures = 0 var totalTeamAliases = 0 var totalStadiumAliases = 0 var totalSkippedIncompatible = 0 var totalSkippedOlder = 0 do { // Sync in dependency order let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums( context: context, since: syncState.lastSuccessfulSync ) totalStadiums = stadiums totalSkippedIncompatible += skipIncompat1 totalSkippedOlder += skipOlder1 let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure( context: context, since: syncState.lastSuccessfulSync ) totalLeagueStructures = leagueStructures totalSkippedIncompatible += skipIncompat2 totalSkippedOlder += skipOlder2 let (teams, skipIncompat3, skipOlder3) = try await syncTeams( context: context, since: syncState.lastSuccessfulSync ) totalTeams = teams totalSkippedIncompatible += skipIncompat3 totalSkippedOlder += skipOlder3 let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases( context: context, since: syncState.lastSuccessfulSync ) totalTeamAliases = teamAliases totalSkippedIncompatible += skipIncompat4 totalSkippedOlder += skipOlder4 let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases( context: context, since: syncState.lastSuccessfulSync ) totalStadiumAliases = stadiumAliases totalSkippedIncompatible += skipIncompat5 totalSkippedOlder += skipOlder5 let (games, skipIncompat6, skipOlder6) = try await syncGames( context: context, since: syncState.lastSuccessfulSync ) totalGames = games totalSkippedIncompatible += skipIncompat6 totalSkippedOlder += skipOlder6 // Mark sync successful syncState.syncInProgress = false syncState.lastSuccessfulSync = Date() syncState.lastSyncError = nil syncState.consecutiveFailures = 0 try context.save() } catch { // Mark sync failed syncState.syncInProgress = false syncState.lastSyncError = error.localizedDescription syncState.consecutiveFailures += 1 // Pause sync after too many failures if syncState.consecutiveFailures >= 5 { syncState.syncEnabled = false syncState.syncPausedReason = "Too many consecutive failures. Sync paused." } try? context.save() throw error } return SyncResult( stadiumsUpdated: totalStadiums, teamsUpdated: totalTeams, gamesUpdated: totalGames, leagueStructuresUpdated: totalLeagueStructures, teamAliasesUpdated: totalTeamAliases, stadiumAliasesUpdated: totalStadiumAliases, skippedIncompatible: totalSkippedIncompatible, skippedOlder: totalSkippedOlder, duration: Date().timeIntervalSince(startTime) ) } /// Re-enable sync after it was paused due to failures. @MainActor func resumeSync(context: ModelContext) { let syncState = SyncState.current(in: context) syncState.syncEnabled = true syncState.syncPausedReason = nil syncState.consecutiveFailures = 0 try? context.save() } // MARK: - Individual Sync Methods @MainActor private func syncStadiums( context: ModelContext, since lastSync: Date? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Use sync method that returns canonical IDs directly from CloudKit let syncStadiums = try await cloudKitService.fetchStadiumsForSync() var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for syncStadium in syncStadiums { // Use canonical ID directly from CloudKit - no UUID-based generation! let result = try mergeStadium( syncStadium.stadium, canonicalId: syncStadium.canonicalId, context: context ) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } return (updated, skippedIncompatible, skippedOlder) } @MainActor private func syncTeams( context: ModelContext, since lastSync: Date? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Use sync method that returns canonical IDs directly from CloudKit var allSyncTeams: [CloudKitService.SyncTeam] = [] for sport in Sport.allCases { let syncTeams = try await cloudKitService.fetchTeamsForSync(for: sport) allSyncTeams.append(contentsOf: syncTeams) } var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for syncTeam in allSyncTeams { // Use canonical IDs directly from CloudKit - no UUID lookups! let result = try mergeTeam( syncTeam.team, canonicalId: syncTeam.canonicalId, stadiumCanonicalId: syncTeam.stadiumCanonicalId, context: context ) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } return (updated, skippedIncompatible, skippedOlder) } @MainActor private func syncGames( context: ModelContext, since lastSync: Date? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Use sync method that returns canonical IDs directly from CloudKit let startDate = lastSync ?? Date() let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date() let syncGames = try await cloudKitService.fetchGamesForSync( sports: Set(Sport.allCases), startDate: startDate, endDate: endDate ) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for syncGame in syncGames { // Use canonical IDs directly from CloudKit - no UUID lookups! let result = try mergeGame( syncGame.game, canonicalId: syncGame.canonicalId, homeTeamCanonicalId: syncGame.homeTeamCanonicalId, awayTeamCanonicalId: syncGame.awayTeamCanonicalId, stadiumCanonicalId: syncGame.stadiumCanonicalId, context: context ) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } return (updated, skippedIncompatible, skippedOlder) } @MainActor private func syncLeagueStructure( context: ModelContext, since lastSync: Date? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for remoteStructure in remoteStructures { let result = try mergeLeagueStructure(remoteStructure, context: context) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } return (updated, skippedIncompatible, skippedOlder) } @MainActor private func syncTeamAliases( context: ModelContext, since lastSync: Date? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for remoteAlias in remoteAliases { let result = try mergeTeamAlias(remoteAlias, context: context) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } return (updated, skippedIncompatible, skippedOlder) } @MainActor private func syncStadiumAliases( context: ModelContext, since lastSync: Date? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for remoteAlias in remoteAliases { let result = try mergeStadiumAlias(remoteAlias, context: context) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } return (updated, skippedIncompatible, skippedOlder) } // MARK: - Merge Logic private enum MergeResult { case applied case skippedIncompatible case skippedOlder } @MainActor private func mergeStadium( _ remote: Stadium, canonicalId: String, context: ModelContext ) throws -> MergeResult { // Look up existing let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == canonicalId } ) let existing = try context.fetch(descriptor).first if let existing = existing { // Preserve user fields let savedNickname = existing.userNickname let savedNotes = existing.userNotes let savedFavorite = existing.isFavorite // Update system fields existing.name = remote.name existing.city = remote.city existing.state = remote.state existing.latitude = remote.latitude existing.longitude = remote.longitude existing.capacity = remote.capacity existing.yearOpened = remote.yearOpened existing.imageURL = remote.imageURL?.absoluteString existing.sport = remote.sport.rawValue existing.source = .cloudKit existing.lastModified = Date() // Restore user fields existing.userNickname = savedNickname existing.userNotes = savedNotes existing.isFavorite = savedFavorite return .applied } else { // Insert new - let init() generate deterministic UUID from canonicalId let canonical = CanonicalStadium( canonicalId: canonicalId, // uuid: omitted - will be generated deterministically from canonicalId schemaVersion: SchemaVersion.current, lastModified: Date(), source: .cloudKit, name: remote.name, city: remote.city, state: remote.state, latitude: remote.latitude, longitude: remote.longitude, capacity: remote.capacity, yearOpened: remote.yearOpened, imageURL: remote.imageURL?.absoluteString, sport: remote.sport.rawValue ) context.insert(canonical) return .applied } } @MainActor private func mergeTeam( _ remote: Team, canonicalId: String, stadiumCanonicalId: String, context: ModelContext ) throws -> MergeResult { let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == canonicalId } ) let existing = try context.fetch(descriptor).first // Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed! if let existing = existing { // Preserve user fields let savedNickname = existing.userNickname let savedFavorite = existing.isFavorite // Update system fields existing.name = remote.name existing.abbreviation = remote.abbreviation existing.sport = remote.sport.rawValue existing.city = remote.city existing.stadiumCanonicalId = stadiumCanonicalId existing.logoURL = remote.logoURL?.absoluteString existing.primaryColor = remote.primaryColor existing.secondaryColor = remote.secondaryColor existing.source = .cloudKit existing.lastModified = Date() // Restore user fields existing.userNickname = savedNickname existing.isFavorite = savedFavorite return .applied } else { // Insert new - let init() generate deterministic UUID from canonicalId let canonical = CanonicalTeam( canonicalId: canonicalId, // uuid: omitted - will be generated deterministically from canonicalId schemaVersion: SchemaVersion.current, lastModified: Date(), source: .cloudKit, name: remote.name, abbreviation: remote.abbreviation, sport: remote.sport.rawValue, city: remote.city, stadiumCanonicalId: stadiumCanonicalId, logoURL: remote.logoURL?.absoluteString, primaryColor: remote.primaryColor, secondaryColor: remote.secondaryColor ) context.insert(canonical) return .applied } } @MainActor private func mergeGame( _ remote: Game, canonicalId: String, homeTeamCanonicalId: String, awayTeamCanonicalId: String, stadiumCanonicalId: String, context: ModelContext ) throws -> MergeResult { let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == canonicalId } ) let existing = try context.fetch(descriptor).first // All canonical IDs are passed directly from CloudKit - no UUID lookups needed! if let existing = existing { // Preserve user fields let savedAttending = existing.userAttending let savedNotes = existing.userNotes // Update system fields existing.homeTeamCanonicalId = homeTeamCanonicalId existing.awayTeamCanonicalId = awayTeamCanonicalId existing.stadiumCanonicalId = stadiumCanonicalId existing.dateTime = remote.dateTime existing.sport = remote.sport.rawValue existing.season = remote.season existing.isPlayoff = remote.isPlayoff existing.broadcastInfo = remote.broadcastInfo existing.source = .cloudKit existing.lastModified = Date() // Restore user fields existing.userAttending = savedAttending existing.userNotes = savedNotes return .applied } else { // Insert new - let init() generate deterministic UUID from canonicalId let canonical = CanonicalGame( canonicalId: canonicalId, // uuid: omitted - will be generated deterministically from canonicalId schemaVersion: SchemaVersion.current, lastModified: Date(), source: .cloudKit, homeTeamCanonicalId: homeTeamCanonicalId, awayTeamCanonicalId: awayTeamCanonicalId, stadiumCanonicalId: stadiumCanonicalId, dateTime: remote.dateTime, sport: remote.sport.rawValue, season: remote.season, isPlayoff: remote.isPlayoff, broadcastInfo: remote.broadcastInfo ) context.insert(canonical) return .applied } } @MainActor private func mergeLeagueStructure( _ remote: LeagueStructureModel, context: ModelContext ) throws -> MergeResult { // Schema version check guard remote.schemaVersion <= SchemaVersion.current else { return .skippedIncompatible } let remoteId = remote.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == remoteId } ) let existing = try context.fetch(descriptor).first if let existing = existing { // lastModified check guard remote.lastModified > existing.lastModified else { return .skippedOlder } // Update all fields (no user fields on LeagueStructure) existing.sport = remote.sport existing.structureTypeRaw = remote.structureTypeRaw existing.name = remote.name existing.abbreviation = remote.abbreviation existing.parentId = remote.parentId existing.displayOrder = remote.displayOrder existing.schemaVersion = remote.schemaVersion existing.lastModified = remote.lastModified return .applied } else { // Insert new context.insert(remote) return .applied } } @MainActor private func mergeTeamAlias( _ remote: TeamAlias, context: ModelContext ) throws -> MergeResult { // Schema version check guard remote.schemaVersion <= SchemaVersion.current else { return .skippedIncompatible } let remoteId = remote.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == remoteId } ) let existing = try context.fetch(descriptor).first if let existing = existing { // lastModified check guard remote.lastModified > existing.lastModified else { return .skippedOlder } // Update all fields (no user fields on TeamAlias) existing.teamCanonicalId = remote.teamCanonicalId existing.aliasTypeRaw = remote.aliasTypeRaw existing.aliasValue = remote.aliasValue existing.validFrom = remote.validFrom existing.validUntil = remote.validUntil existing.schemaVersion = remote.schemaVersion existing.lastModified = remote.lastModified return .applied } else { // Insert new context.insert(remote) return .applied } } @MainActor private func mergeStadiumAlias( _ remote: StadiumAlias, context: ModelContext ) throws -> MergeResult { // Schema version check guard remote.schemaVersion <= SchemaVersion.current else { return .skippedIncompatible } let remoteAliasName = remote.aliasName let descriptor = FetchDescriptor( predicate: #Predicate { $0.aliasName == remoteAliasName } ) let existing = try context.fetch(descriptor).first if let existing = existing { // lastModified check guard remote.lastModified > existing.lastModified else { return .skippedOlder } // Update all fields (no user fields on StadiumAlias) existing.stadiumCanonicalId = remote.stadiumCanonicalId existing.validFrom = remote.validFrom existing.validUntil = remote.validUntil existing.schemaVersion = remote.schemaVersion existing.lastModified = remote.lastModified return .applied } else { // Insert new context.insert(remote) return .applied } } }