// // 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 private init() { self.container = CKContainer(identifier: "iCloud.com.sportstime.app") self.publicDatabase = container.publicCloudDatabase } // 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 /// - Parameter lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date. func fetchStadiumsForSync(since lastSync: Date?) 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 (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result -> SyncStadium? in guard case .success(let record) = result.1 else { return nil } 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 /// - Parameter lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date. func fetchTeamsForSync(since lastSync: Date?) 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 (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result -> SyncTeam? in guard case .success(let record) = result.1 else { return nil } 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 /// - Parameter lastSync: If nil, fetches all games. If provided, fetches only games modified since that date. func fetchGamesForSync(since lastSync: Date?) async throws -> [SyncGame] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result -> SyncGame? in guard case .success(let record) = result.1 else { return nil } let ckGame = CKGame(record: record) guard let canonicalId = ckGame.canonicalId, let homeTeamCanonicalId = ckGame.homeTeamCanonicalId, let awayTeamCanonicalId = ckGame.awayTeamCanonicalId, let stadiumCanonicalId = ckGame.stadiumCanonicalId else { return nil } guard let game = ckGame.game( homeTeamId: homeTeamCanonicalId, awayTeamId: awayTeamCanonicalId, stadiumId: stadiumCanonicalId ) else { return nil } return SyncGame( game: game, canonicalId: canonicalId, homeTeamCanonicalId: homeTeamCanonicalId, awayTeamCanonicalId: awayTeamCanonicalId, stadiumCanonicalId: stadiumCanonicalId ) }.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 func fetchLeagueStructureChanges(since lastSync: Date?) 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 (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() } } /// Fetch team alias records modified after the given date func fetchTeamAliasChanges(since lastSync: Date?) 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 (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() } } /// Fetch stadium alias records modified after the given date func fetchStadiumAliasChanges(since lastSync: Date?) 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 (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: - 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) } /// Subscribe to all canonical data updates func subscribeToAllUpdates() async throws { try await subscribeToScheduleUpdates() try await subscribeToLeagueStructureUpdates() try await subscribeToTeamAliasUpdates() try await subscribeToStadiumAliasUpdates() } }