// // 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 @MainActor final class 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 sportsUpdated: Int let skippedIncompatible: Int let skippedOlder: Int let duration: TimeInterval let wasCancelled: Bool var totalUpdated: Int { stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated } var isEmpty: Bool { totalUpdated == 0 && !wasCancelled } } // 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. /// - Parameters: /// - context: The ModelContext to use for saving data /// - cancellationToken: Optional token to check for cancellation between entity syncs func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult { let startTime = Date() let syncState = SyncState.current(in: context) // Prevent concurrent syncs, but auto-heal stale "in progress" state. if syncState.syncInProgress { let staleSyncTimeout: TimeInterval = 15 * 60 if let lastAttempt = syncState.lastSyncAttempt, Date().timeIntervalSince(lastAttempt) < staleSyncTimeout { throw SyncError.syncAlreadyInProgress } SyncLogger.shared.log("⚠️ [SYNC] Clearing stale syncInProgress flag") syncState.syncInProgress = false } // Check if sync is enabled guard syncState.syncEnabled else { return SyncResult( stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, duration: 0, wasCancelled: false ) } // Check CloudKit availability guard await cloudKitService.isAvailable() else { throw SyncError.cloudKitUnavailable } #if DEBUG SyncStatusMonitor.shared.syncStarted() #endif // 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 totalSports = 0 var totalSkippedIncompatible = 0 var totalSkippedOlder = 0 var nonCriticalErrors: [String] = [] /// Helper to save partial progress and check cancellation func saveProgressAndCheckCancellation() throws -> Bool { try context.save() if cancellationToken?.isCancelled == true { return true } return false } do { // Sync in dependency order, checking cancellation between each entity type // Sport sync (non-critical for schedule rendering) var entityStartTime = Date() do { let (sports, skipIncompat1, skipOlder1) = try await syncSports( context: context, since: syncState.lastSportSync ?? syncState.lastSuccessfulSync, cancellationToken: cancellationToken ) totalSports = sports totalSkippedIncompatible += skipIncompat1 totalSkippedOlder += skipOlder1 syncState.lastSportSync = entityStartTime #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime)) #endif } catch is CancellationError { throw CancellationError() } catch { nonCriticalErrors.append("Sport: \(error.localizedDescription)") #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.sport, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription) #endif SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (Sport): \(error.localizedDescription)") } if try saveProgressAndCheckCancellation() { throw CancellationError() } // Stadium sync entityStartTime = Date() let (stadiums, skipIncompat2, skipOlder2) = try await syncStadiums( context: context, since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync, cancellationToken: cancellationToken ) totalStadiums = stadiums totalSkippedIncompatible += skipIncompat2 totalSkippedOlder += skipOlder2 syncState.lastStadiumSync = entityStartTime #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime)) #endif if try saveProgressAndCheckCancellation() { throw CancellationError() } // Team sync entityStartTime = Date() let (teams, skipIncompat3, skipOlder3) = try await syncTeams( context: context, since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync, cancellationToken: cancellationToken ) totalTeams = teams totalSkippedIncompatible += skipIncompat3 totalSkippedOlder += skipOlder3 syncState.lastTeamSync = entityStartTime #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime)) #endif if try saveProgressAndCheckCancellation() { throw CancellationError() } // Game sync entityStartTime = Date() let (games, skipIncompat4, skipOlder4) = try await syncGames( context: context, since: syncState.lastGameSync ?? syncState.lastSuccessfulSync, cancellationToken: cancellationToken ) totalGames = games totalSkippedIncompatible += skipIncompat4 totalSkippedOlder += skipOlder4 syncState.lastGameSync = entityStartTime #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime)) #endif if try saveProgressAndCheckCancellation() { throw CancellationError() } // League Structure sync (non-critical for schedule rendering) entityStartTime = Date() do { let (leagueStructures, skipIncompat5, skipOlder5) = try await syncLeagueStructure( context: context, since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync, cancellationToken: cancellationToken ) totalLeagueStructures = leagueStructures totalSkippedIncompatible += skipIncompat5 totalSkippedOlder += skipOlder5 syncState.lastLeagueStructureSync = entityStartTime #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime)) #endif } catch is CancellationError { throw CancellationError() } catch { nonCriticalErrors.append("LeagueStructure: \(error.localizedDescription)") #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription) #endif SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (LeagueStructure): \(error.localizedDescription)") } if try saveProgressAndCheckCancellation() { throw CancellationError() } // Team Alias sync (non-critical for schedule rendering) entityStartTime = Date() do { let (teamAliases, skipIncompat6, skipOlder6) = try await syncTeamAliases( context: context, since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync, cancellationToken: cancellationToken ) totalTeamAliases = teamAliases totalSkippedIncompatible += skipIncompat6 totalSkippedOlder += skipOlder6 syncState.lastTeamAliasSync = entityStartTime #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime)) #endif } catch is CancellationError { throw CancellationError() } catch { nonCriticalErrors.append("TeamAlias: \(error.localizedDescription)") #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription) #endif SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (TeamAlias): \(error.localizedDescription)") } if try saveProgressAndCheckCancellation() { throw CancellationError() } // Stadium Alias sync (non-critical for schedule rendering) entityStartTime = Date() do { let (stadiumAliases, skipIncompat7, skipOlder7) = try await syncStadiumAliases( context: context, since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync, cancellationToken: cancellationToken ) totalStadiumAliases = stadiumAliases totalSkippedIncompatible += skipIncompat7 totalSkippedOlder += skipOlder7 syncState.lastStadiumAliasSync = entityStartTime #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime)) #endif } catch is CancellationError { throw CancellationError() } catch { nonCriticalErrors.append("StadiumAlias: \(error.localizedDescription)") #if DEBUG SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription) #endif SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (StadiumAlias): \(error.localizedDescription)") } // Mark sync successful - clear per-entity timestamps since full sync completed syncState.syncInProgress = false syncState.lastSuccessfulSync = startTime syncState.lastSyncError = nonCriticalErrors.isEmpty ? nil : "Non-critical sync warnings: \(nonCriticalErrors.joined(separator: " | "))" syncState.consecutiveFailures = 0 syncState.syncPausedReason = nil // Clear per-entity timestamps - they're only needed for partial recovery syncState.lastStadiumSync = nil syncState.lastTeamSync = nil syncState.lastGameSync = nil syncState.lastLeagueStructureSync = nil syncState.lastTeamAliasSync = nil syncState.lastStadiumAliasSync = nil syncState.lastSportSync = nil try context.save() #if DEBUG SyncStatusMonitor.shared.syncCompleted(totalDuration: Date().timeIntervalSince(startTime)) #endif } catch is CancellationError { // Graceful cancellation - progress already saved syncState.syncInProgress = false syncState.lastSyncError = "Sync cancelled - partial progress saved" try? context.save() #if DEBUG SyncStatusMonitor.shared.syncFailed(error: CancellationError()) #endif return SyncResult( stadiumsUpdated: totalStadiums, teamsUpdated: totalTeams, gamesUpdated: totalGames, leagueStructuresUpdated: totalLeagueStructures, teamAliasesUpdated: totalTeamAliases, stadiumAliasesUpdated: totalStadiumAliases, sportsUpdated: totalSports, skippedIncompatible: totalSkippedIncompatible, skippedOlder: totalSkippedOlder, duration: Date().timeIntervalSince(startTime), wasCancelled: true ) } catch { // Mark sync failed syncState.syncInProgress = false syncState.lastSyncError = error.localizedDescription if isTransientCloudKitError(error) { // Network/account hiccups should not permanently degrade sync health. syncState.consecutiveFailures = 0 syncState.syncPausedReason = nil } else { syncState.consecutiveFailures += 1 // Pause sync after too many failures if syncState.consecutiveFailures >= 5 { #if DEBUG syncState.syncEnabled = false syncState.syncPausedReason = "Too many consecutive failures. Sync paused." #else syncState.consecutiveFailures = 5 syncState.syncPausedReason = nil #endif } } try? context.save() #if DEBUG SyncStatusMonitor.shared.syncFailed(error: error) #endif throw error } return SyncResult( stadiumsUpdated: totalStadiums, teamsUpdated: totalTeams, gamesUpdated: totalGames, leagueStructuresUpdated: totalLeagueStructures, teamAliasesUpdated: totalTeamAliases, stadiumAliasesUpdated: totalStadiumAliases, sportsUpdated: totalSports, skippedIncompatible: totalSkippedIncompatible, skippedOlder: totalSkippedOlder, duration: Date().timeIntervalSince(startTime), wasCancelled: false ) } /// Re-enable sync after it was paused due to failures. func resumeSync(context: ModelContext) { let syncState = SyncState.current(in: context) syncState.syncEnabled = true syncState.syncPausedReason = nil syncState.consecutiveFailures = 0 try? context.save() } nonisolated private func isTransientCloudKitError(_ error: Error) -> Bool { guard let ckError = error as? CKError else { return false } switch ckError.code { case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited, .zoneBusy, .notAuthenticated, .accountTemporarilyUnavailable: return true default: return false } } // MARK: - Individual Sync Methods private func syncStadiums( context: ModelContext, since lastSync: Date?, cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Delta sync: nil = all stadiums, Date = only modified since let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 // Batch-fetch all existing stadiums to avoid N+1 FetchDescriptor lookups let allExistingStadiums = try context.fetch(FetchDescriptor()) let existingStadiumsByCanonicalId = Dictionary(grouping: allExistingStadiums, by: \.canonicalId).compactMapValues(\.first) for syncStadium in syncStadiums { // Use canonical ID directly from CloudKit - no UUID-based generation! let result = try mergeStadium( syncStadium.stadium, canonicalId: syncStadium.canonicalId, existingRecord: existingStadiumsByCanonicalId[syncStadium.canonicalId], context: context ) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } return (updated, skippedIncompatible, skippedOlder) } private func syncTeams( context: ModelContext, since lastSync: Date?, cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Single call for all teams with delta sync let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken) let activeStadiums = try context.fetch( FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil } ) ) var validStadiumIds = Set(activeStadiums.map(\.canonicalId)) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 // Batch-fetch all existing teams to avoid N+1 FetchDescriptor lookups let allExistingTeams = try context.fetch(FetchDescriptor()) let existingTeamsByCanonicalId = Dictionary(grouping: allExistingTeams, by: \.canonicalId).compactMapValues(\.first) 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, validStadiumIds: &validStadiumIds, existingRecord: existingTeamsByCanonicalId[syncTeam.canonicalId], context: context ) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } if skippedIncompatible > 0 { SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible team records") } return (updated, skippedIncompatible, skippedOlder) } private func syncGames( context: ModelContext, since lastSync: Date?, cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Delta sync: nil = all games, Date = only modified since let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken) let activeTeams = try context.fetch( FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil } ) ) let validTeamIds = Set(activeTeams.map(\.canonicalId)) let teamStadiumByTeamId = Dictionary(uniqueKeysWithValues: activeTeams.map { ($0.canonicalId, $0.stadiumCanonicalId) }) let activeStadiums = try context.fetch( FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil } ) ) var validStadiumIds = Set(activeStadiums.map(\.canonicalId)) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 // Batch-fetch all existing games to avoid N+1 FetchDescriptor lookups let allExistingGames = try context.fetch(FetchDescriptor()) let existingGamesByCanonicalId = Dictionary(grouping: allExistingGames, by: \.canonicalId).compactMapValues(\.first) 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, validTeamIds: validTeamIds, teamStadiumByTeamId: teamStadiumByTeamId, validStadiumIds: &validStadiumIds, existingRecord: existingGamesByCanonicalId[syncGame.canonicalId], context: context ) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } if skippedIncompatible > 0 { SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible game records") } return (updated, skippedIncompatible, skippedOlder) } private func syncLeagueStructure( context: ModelContext, since lastSync: Date?, cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync, cancellationToken: cancellationToken) 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) } private func syncTeamAliases( context: ModelContext, since lastSync: Date?, cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync, cancellationToken: cancellationToken) 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) } private func syncStadiumAliases( context: ModelContext, since lastSync: Date?, cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync, cancellationToken: cancellationToken) 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) } private func syncSports( context: ModelContext, since lastSync: Date?, cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for remoteSport in remoteSports { let result = try mergeSport(remoteSport, 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 } private func mergeStadium( _ remote: Stadium, canonicalId: String, existingRecord: CanonicalStadium? = nil, context: ModelContext ) throws -> MergeResult { // Use pre-fetched record if available, otherwise fall back to individual fetch let existing: CanonicalStadium? if let existingRecord { existing = existingRecord } else { let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == canonicalId } ) 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.timezoneIdentifier = remote.timeZoneIdentifier existing.source = .cloudKit existing.lastModified = Date() existing.deprecatedAt = nil existing.deprecationReason = nil existing.replacedByCanonicalId = nil // 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, timezoneIdentifier: remote.timeZoneIdentifier ) context.insert(canonical) return .applied } } private func mergeTeam( _ remote: Team, canonicalId: String, stadiumCanonicalId: String?, validStadiumIds: inout Set, existingRecord: CanonicalTeam? = nil, context: ModelContext ) throws -> MergeResult { // Use pre-fetched record if available, otherwise fall back to individual fetch let existing: CanonicalTeam? if let existingRecord { existing = existingRecord } else { let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == canonicalId } ) existing = try context.fetch(descriptor).first } let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines) let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedStadiumCanonicalId: String if let remoteStadiumId, !remoteStadiumId.isEmpty, validStadiumIds.contains(remoteStadiumId) { resolvedStadiumCanonicalId = remoteStadiumId } else if let existingStadiumId, !existingStadiumId.isEmpty, validStadiumIds.contains(existingStadiumId) { resolvedStadiumCanonicalId = existingStadiumId } else if let remoteStadiumId, !remoteStadiumId.isEmpty { // Keep unresolved remote refs so teams/games can still sync while stadiums catch up. resolvedStadiumCanonicalId = remoteStadiumId } else if let existingStadiumId, !existingStadiumId.isEmpty { resolvedStadiumCanonicalId = existingStadiumId } else { // Last-resort placeholder keeps team records usable for game rendering. resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)" } if !validStadiumIds.contains(resolvedStadiumCanonicalId) { try ensurePlaceholderStadium( canonicalId: resolvedStadiumCanonicalId, sport: remote.sport, context: context ) validStadiumIds.insert(resolvedStadiumCanonicalId) } 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 = resolvedStadiumCanonicalId existing.logoURL = remote.logoURL?.absoluteString existing.primaryColor = remote.primaryColor existing.secondaryColor = remote.secondaryColor existing.conferenceId = remote.conferenceId existing.divisionId = remote.divisionId existing.source = .cloudKit existing.lastModified = Date() existing.deprecatedAt = nil existing.deprecationReason = nil existing.relocatedToCanonicalId = nil // 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: resolvedStadiumCanonicalId, logoURL: remote.logoURL?.absoluteString, primaryColor: remote.primaryColor, secondaryColor: remote.secondaryColor, conferenceId: remote.conferenceId, divisionId: remote.divisionId ) context.insert(canonical) return .applied } } private func mergeGame( _ remote: Game, canonicalId: String, homeTeamCanonicalId: String, awayTeamCanonicalId: String, stadiumCanonicalId: String?, validTeamIds: Set, teamStadiumByTeamId: [String: String], validStadiumIds: inout Set, existingRecord: CanonicalGame? = nil, context: ModelContext ) throws -> MergeResult { // Use pre-fetched record if available, otherwise fall back to individual fetch let existing: CanonicalGame? if let existingRecord { existing = existingRecord } else { let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == canonicalId } ) existing = try context.fetch(descriptor).first } func resolveTeamId(remote: String, existing: String?) -> String? { if validTeamIds.contains(remote) { return remote } if let existing, validTeamIds.contains(existing) { return existing } return nil } guard let resolvedHomeTeamId = resolveTeamId(remote: homeTeamCanonicalId, existing: existing?.homeTeamCanonicalId), let resolvedAwayTeamId = resolveTeamId(remote: awayTeamCanonicalId, existing: existing?.awayTeamCanonicalId) else { return .skippedIncompatible } let trimmedRemoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedExistingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines) let fallbackStadiumFromTeams = ( teamStadiumByTeamId[resolvedHomeTeamId] ?? teamStadiumByTeamId[resolvedAwayTeamId] )?.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedStadiumCanonicalId: String if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty, validStadiumIds.contains(trimmedRemoteStadiumId) { resolvedStadiumCanonicalId = trimmedRemoteStadiumId } else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty, validStadiumIds.contains(fallbackStadiumFromTeams) { // Cloud record can have stale/legacy stadium refs; prefer known team home venue. resolvedStadiumCanonicalId = fallbackStadiumFromTeams } else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty, validStadiumIds.contains(trimmedExistingStadiumId) { // Keep existing local stadium if remote reference is invalid. resolvedStadiumCanonicalId = trimmedExistingStadiumId } else if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty { resolvedStadiumCanonicalId = trimmedRemoteStadiumId } else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty { resolvedStadiumCanonicalId = fallbackStadiumFromTeams } else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty { resolvedStadiumCanonicalId = trimmedExistingStadiumId } else { resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)" } if !validStadiumIds.contains(resolvedStadiumCanonicalId) { try ensurePlaceholderStadium( canonicalId: resolvedStadiumCanonicalId, sport: remote.sport, context: context ) validStadiumIds.insert(resolvedStadiumCanonicalId) } if let existing = existing { // Preserve user fields let savedAttending = existing.userAttending let savedNotes = existing.userNotes // Update system fields existing.homeTeamCanonicalId = resolvedHomeTeamId existing.awayTeamCanonicalId = resolvedAwayTeamId existing.stadiumCanonicalId = resolvedStadiumCanonicalId 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() existing.deprecatedAt = nil existing.deprecationReason = nil existing.rescheduledToCanonicalId = nil // 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: resolvedHomeTeamId, awayTeamCanonicalId: resolvedAwayTeamId, stadiumCanonicalId: resolvedStadiumCanonicalId, dateTime: remote.dateTime, sport: remote.sport.rawValue, season: remote.season, isPlayoff: remote.isPlayoff, broadcastInfo: remote.broadcastInfo ) context.insert(canonical) return .applied } } 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 } } 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 } } 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 } } private func mergeSport( _ remote: CanonicalSport, 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 CanonicalSport) existing.abbreviation = remote.abbreviation existing.displayName = remote.displayName existing.iconName = remote.iconName existing.colorHex = remote.colorHex existing.seasonStartMonth = remote.seasonStartMonth existing.seasonEndMonth = remote.seasonEndMonth existing.isActive = remote.isActive existing.schemaVersion = remote.schemaVersion existing.lastModified = remote.lastModified existing.sourceRaw = DataSource.cloudKit.rawValue return .applied } else { // Insert new context.insert(remote) return .applied } } private func ensurePlaceholderStadium( canonicalId: String, sport: Sport, context: ModelContext ) throws { let trimmedCanonicalId = canonicalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedCanonicalId.isEmpty else { return } let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == trimmedCanonicalId } ) if let existing = try context.fetch(descriptor).first { if existing.deprecatedAt != nil { existing.deprecatedAt = nil existing.deprecationReason = nil existing.replacedByCanonicalId = nil } return } let placeholder = CanonicalStadium( canonicalId: trimmedCanonicalId, schemaVersion: SchemaVersion.current, lastModified: Date(), source: .cloudKit, name: "Venue TBD", city: "Unknown", state: "", latitude: 0, longitude: 0, capacity: 0, sport: sport.rawValue, timezoneIdentifier: nil ) context.insert(placeholder) SyncLogger.shared.log("⚠️ [SYNC] Inserted placeholder stadium for unresolved reference: \(trimmedCanonicalId)") } }