// // CloudKitService.swift // SportsTime // import Foundation import CloudKit // MARK: - CloudKit Errors enum CloudKitError: Error, LocalizedError { case notSignedIn case networkUnavailable case serverError(String) case quotaExceeded case permissionDenied case recordNotFound case unknown(Error) var errorDescription: String? { switch self { case .notSignedIn: return "Please sign in to iCloud in Settings to sync data." case .networkUnavailable: return "Unable to connect to the server. Check your internet connection." case .serverError(let message): return "Server error: \(message)" case .quotaExceeded: return "iCloud storage quota exceeded." case .permissionDenied: return "Permission denied. Check your iCloud settings." case .recordNotFound: return "Data not found." case .unknown(let error): return "An unexpected error occurred: \(error.localizedDescription)" } } static func from(_ error: Error) -> CloudKitError { if let ckError = error as? CKError { switch ckError.code { case .notAuthenticated: return .notSignedIn case .networkUnavailable, .networkFailure: return .networkUnavailable case .serverResponseLost: return .serverError("Connection lost") case .quotaExceeded: return .quotaExceeded case .permissionFailure: return .permissionDenied case .unknownItem: return .recordNotFound default: return .serverError(ckError.localizedDescription) } } return .unknown(error) } } actor CloudKitService { static let shared = CloudKitService() private let container: CKContainer private let publicDatabase: CKDatabase /// Maximum records per CloudKit query (400 is the default limit) private let recordsPerPage = 400 private init() { self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime") self.publicDatabase = container.publicCloudDatabase } // MARK: - Pagination Helper /// Fetches all records matching a query using cursor-based pagination. /// Checks cancellation token between pages to allow graceful interruption. private func fetchAllRecords( matching query: CKQuery, cancellationToken: SyncCancellationToken? ) async throws -> [CKRecord] { var allRecords: [CKRecord] = [] var cursor: CKQueryOperation.Cursor? // First page let (firstResults, firstCursor) = try await publicDatabase.records( matching: query, resultsLimit: recordsPerPage ) for result in firstResults { if case .success(let record) = result.1 { allRecords.append(record) } } cursor = firstCursor // Continue fetching pages while cursor exists while let currentCursor = cursor { // Check cancellation between pages if cancellationToken?.isCancelled == true { throw CancellationError() } let (results, nextCursor) = try await publicDatabase.records( continuingMatchFrom: currentCursor, resultsLimit: recordsPerPage ) for result in results { if case .success(let record) = result.1 { allRecords.append(record) } } cursor = nextCursor } return allRecords } // MARK: - Sync Types (include canonical IDs from CloudKit) struct SyncStadium { let stadium: Stadium let canonicalId: String } struct SyncTeam { let team: Team let canonicalId: String let stadiumCanonicalId: String } struct SyncGame { let game: Game let canonicalId: String let homeTeamCanonicalId: String let awayTeamCanonicalId: String let stadiumCanonicalId: String } // MARK: - Availability Check func isAvailable() async -> Bool { let status = await checkAccountStatus() return status == .available } func checkAvailabilityWithError() async throws { let status = await checkAccountStatus() switch status { case .available: return case .noAccount: throw CloudKitError.notSignedIn case .restricted: throw CloudKitError.permissionDenied case .couldNotDetermine: throw CloudKitError.networkUnavailable case .temporarilyUnavailable: throw CloudKitError.networkUnavailable @unknown default: throw CloudKitError.networkUnavailable } } // MARK: - Fetch Operations func fetchTeams(for sport: Sport) async throws -> [Team] { let predicate = NSPredicate(format: "sport == %@", sport.rawValue) let query = CKQuery(recordType: CKRecordType.team, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKTeam(record: record).team } } func fetchStadiums() async throws -> [Stadium] { let predicate = NSPredicate(value: true) let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKStadium(record: record).stadium } } func fetchGames( sports: Set, startDate: Date, endDate: Date ) async throws -> [Game] { var allGames: [Game] = [] for sport in sports { let predicate = NSPredicate( format: "sport == %@ AND dateTime >= %@ AND dateTime <= %@", sport.rawValue, startDate as NSDate, endDate as NSDate ) let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) let games = results.compactMap { result -> Game? in guard case .success(let record) = result.1 else { return nil } let ckGame = CKGame(record: record) guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference, let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference else { return nil } let homeId = homeRef.recordID.recordName let awayId = awayRef.recordID.recordName // Stadium ref is optional - use placeholder if not present let stadiumId: String if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference { stadiumId = stadiumRef.recordID.recordName } else { stadiumId = "stadium_placeholder_\(UUID().uuidString)" // Placeholder - will be resolved via team lookup } return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId) } allGames.append(contentsOf: games) } return allGames.sorted { $0.dateTime < $1.dateTime } } func fetchGame(by id: String) async throws -> Game? { let predicate = NSPredicate(format: "gameId == %@", id) let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) guard let result = results.first, case .success(let record) = result.1 else { return nil } let ckGame = CKGame(record: record) guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference, let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference, let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference else { return nil } let homeId = homeRef.recordID.recordName let awayId = awayRef.recordID.recordName let stadiumId = stadiumRef.recordID.recordName return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId) } // MARK: - Sync Fetch Methods (return canonical IDs directly from CloudKit) /// Fetch stadiums for sync operations /// - Parameters: /// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) return records.compactMap { record -> SyncStadium? in let ckStadium = CKStadium(record: record) guard let stadium = ckStadium.stadium, let canonicalId = ckStadium.canonicalId else { return nil } return SyncStadium(stadium: stadium, canonicalId: canonicalId) } } /// Fetch teams for sync operations /// - Parameters: /// - lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.team, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) return records.compactMap { record -> SyncTeam? in let ckTeam = CKTeam(record: record) guard let team = ckTeam.team, let canonicalId = ckTeam.canonicalId, let stadiumCanonicalId = ckTeam.stadiumCanonicalId else { return nil } return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId) } } /// Fetch games for sync operations /// - Parameters: /// - lastSync: If nil, fetches all games. If provided, fetches only games modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] { let log = SyncLogger.shared let predicate: NSPredicate if let lastSync = lastSync { log.log("☁️ [CK] Fetching games modified since \(lastSync.formatted())") predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) } else { log.log("☁️ [CK] Fetching ALL games (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) game records from CloudKit") var validGames: [SyncGame] = [] var skippedMissingIds = 0 var skippedInvalidGame = 0 for record in records { let ckGame = CKGame(record: record) guard let canonicalId = ckGame.canonicalId, let homeTeamCanonicalId = ckGame.homeTeamCanonicalId, let awayTeamCanonicalId = ckGame.awayTeamCanonicalId, let stadiumCanonicalId = ckGame.stadiumCanonicalId else { skippedMissingIds += 1 continue } guard let game = ckGame.game( homeTeamId: homeTeamCanonicalId, awayTeamId: awayTeamCanonicalId, stadiumId: stadiumCanonicalId ) else { skippedInvalidGame += 1 continue } validGames.append(SyncGame( game: game, canonicalId: canonicalId, homeTeamCanonicalId: homeTeamCanonicalId, awayTeamCanonicalId: awayTeamCanonicalId, stadiumCanonicalId: stadiumCanonicalId )) } log.log("☁️ [CK] Parsed \(validGames.count) valid games (skipped: \(skippedMissingIds) missing IDs, \(skippedInvalidGame) invalid)") // Log sport breakdown var bySport: [String: Int] = [:] for g in validGames { bySport[g.game.sport.rawValue, default: 0] += 1 } for (sport, count) in bySport.sorted(by: { $0.key < $1.key }) { log.log("☁️ [CK] \(sport): \(count) games") } return validGames.sorted { $0.game.dateTime < $1.game.dateTime } } // MARK: - League Structure & Team Aliases func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] { let predicate: NSPredicate if let sport = sport { predicate = NSPredicate(format: "sport == %@", sport.rawValue) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.displayOrderKey, ascending: true)] let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKLeagueStructure(record: record).toModel() } } func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] { let predicate: NSPredicate if let teamId = teamCanonicalId { predicate = NSPredicate(format: "teamCanonicalId == %@", teamId) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKTeamAlias(record: record).toModel() } } func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] { let predicate: NSPredicate if let stadiumId = stadiumCanonicalId { predicate = NSPredicate(format: "stadiumCanonicalId == %@", stadiumId) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKStadiumAlias(record: record).toModel() } } // MARK: - Delta Sync (Date-Based for Public Database) /// Fetch league structure records modified after the given date /// - Parameters: /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)] let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) return records.compactMap { record in return CKLeagueStructure(record: record).toModel() } } /// Fetch team alias records modified after the given date /// - Parameters: /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)] let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) return records.compactMap { record in return CKTeamAlias(record: record).toModel() } } /// Fetch stadium alias records modified after the given date /// - Parameters: /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)] let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) return records.compactMap { record in return CKStadiumAlias(record: record).toModel() } } // MARK: - Sport Sync /// Fetch sports for sync operations /// - Parameters: /// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) return records.compactMap { record -> CanonicalSport? in return CKSport(record: record).toCanonical() } } // MARK: - Sync Status func checkAccountStatus() async -> CKAccountStatus { do { return try await container.accountStatus() } catch { return .couldNotDetermine } } // MARK: - Subscriptions func subscribeToScheduleUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.game, predicate: NSPredicate(value: true), subscriptionID: "game-updates", options: [.firesOnRecordCreation, .firesOnRecordUpdate] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await publicDatabase.save(subscription) } func subscribeToLeagueStructureUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.leagueStructure, predicate: NSPredicate(value: true), subscriptionID: "league-structure-updates", options: [.firesOnRecordCreation, .firesOnRecordUpdate] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await publicDatabase.save(subscription) } func subscribeToTeamAliasUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.teamAlias, predicate: NSPredicate(value: true), subscriptionID: "team-alias-updates", options: [.firesOnRecordCreation, .firesOnRecordUpdate] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await publicDatabase.save(subscription) } func subscribeToStadiumAliasUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.stadiumAlias, predicate: NSPredicate(value: true), subscriptionID: "stadium-alias-updates", options: [.firesOnRecordCreation, .firesOnRecordUpdate] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await publicDatabase.save(subscription) } func subscribeToSportUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.sport, predicate: NSPredicate(value: true), subscriptionID: "sport-updates", options: [.firesOnRecordCreation, .firesOnRecordUpdate] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await publicDatabase.save(subscription) } /// Subscribe to all canonical data updates func subscribeToAllUpdates() async throws { try await subscribeToScheduleUpdates() try await subscribeToLeagueStructureUpdates() try await subscribeToTeamAliasUpdates() try await subscribeToStadiumAliasUpdates() try await subscribeToSportUpdates() } }