// // 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) { let remoteStadiums = try await cloudKitService.fetchStadiums() var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for remoteStadium in remoteStadiums { // For now, fetch full list and merge - CloudKit public DB doesn't have delta sync // In future, could add lastModified filtering on CloudKit query let canonicalId = "stadium_\(remoteStadium.sport.rawValue.lowercased())_\(remoteStadium.id.uuidString.prefix(8))" let result = try mergeStadium( remoteStadium, canonicalId: 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) { // Fetch teams for all sports var allTeams: [Team] = [] for sport in Sport.allCases { let teams = try await cloudKitService.fetchTeams(for: sport) allTeams.append(contentsOf: teams) } var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for remoteTeam in allTeams { let canonicalId = "team_\(remoteTeam.sport.rawValue.lowercased())_\(remoteTeam.abbreviation.lowercased())" let result = try mergeTeam( remoteTeam, canonicalId: canonicalId, 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) { // Fetch games for the next 6 months from all sports let startDate = lastSync ?? Date() let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date() let remoteGames = try await cloudKitService.fetchGames( sports: Set(Sport.allCases), startDate: startDate, endDate: endDate ) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for remoteGame in remoteGames { let result = try mergeGame( remoteGame, canonicalId: remoteGame.id.uuidString, 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 canonical = CanonicalStadium( canonicalId: canonicalId, uuid: remote.id, 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, context: ModelContext ) throws -> MergeResult { let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == canonicalId } ) let existing = try context.fetch(descriptor).first // Find stadium canonical ID let remoteStadiumId = remote.stadiumId let stadiumDescriptor = FetchDescriptor( predicate: #Predicate { $0.uuid == remoteStadiumId } ) let stadium = try context.fetch(stadiumDescriptor).first let stadiumCanonicalId = stadium?.canonicalId ?? "unknown" 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 { let canonical = CanonicalTeam( canonicalId: canonicalId, uuid: remote.id, 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, context: ModelContext ) throws -> MergeResult { let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == canonicalId } ) let existing = try context.fetch(descriptor).first // Look up canonical IDs for teams and stadium let remoteHomeTeamId = remote.homeTeamId let remoteAwayTeamId = remote.awayTeamId let remoteStadiumId = remote.stadiumId let homeTeamDescriptor = FetchDescriptor( predicate: #Predicate { $0.uuid == remoteHomeTeamId } ) let awayTeamDescriptor = FetchDescriptor( predicate: #Predicate { $0.uuid == remoteAwayTeamId } ) let stadiumDescriptor = FetchDescriptor( predicate: #Predicate { $0.uuid == remoteStadiumId } ) let homeTeam = try context.fetch(homeTeamDescriptor).first let awayTeam = try context.fetch(awayTeamDescriptor).first let stadium = try context.fetch(stadiumDescriptor).first let homeTeamCanonicalId = homeTeam?.canonicalId ?? "unknown" let awayTeamCanonicalId = awayTeam?.canonicalId ?? "unknown" let stadiumCanonicalId = stadium?.canonicalId ?? "unknown" 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 { let canonical = CanonicalGame( canonicalId: canonicalId, uuid: remote.id, 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 } } }