// // CanonicalDataProvider.swift // SportsTime // // DataProvider implementation that reads from SwiftData canonical models. // This is the primary data source after bootstrap completes. // import Foundation import SwiftData actor CanonicalDataProvider: DataProvider { // MARK: - Properties private let modelContainer: ModelContainer // Caches for converted domain objects (rebuilt on first access) private var cachedTeams: [Team]? private var cachedStadiums: [Stadium]? private var teamsByCanonicalId: [String: Team] = [:] private var stadiumsByCanonicalId: [String: Stadium] = [:] private var teamUUIDByCanonicalId: [String: UUID] = [:] private var stadiumUUIDByCanonicalId: [String: UUID] = [:] // MARK: - Initialization init(modelContainer: ModelContainer) { self.modelContainer = modelContainer } // MARK: - DataProvider Protocol func fetchTeams(for sport: Sport) async throws -> [Team] { try await loadCachesIfNeeded() return cachedTeams?.filter { $0.sport == sport } ?? [] } func fetchAllTeams() async throws -> [Team] { try await loadCachesIfNeeded() return cachedTeams ?? [] } func fetchStadiums() async throws -> [Stadium] { try await loadCachesIfNeeded() return cachedStadiums ?? [] } func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { try await loadCachesIfNeeded() let context = ModelContext(modelContainer) // Fetch canonical games within date range let sportStrings = sports.map { $0.rawValue } let descriptor = FetchDescriptor( predicate: #Predicate { game in sportStrings.contains(game.sport) && game.dateTime >= startDate && game.dateTime <= endDate && game.deprecatedAt == nil }, sortBy: [SortDescriptor(\.dateTime)] ) let canonicalGames = try context.fetch(descriptor) // Convert to domain models return canonicalGames.compactMap { canonical -> Game? in guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId], let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId], let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { return nil } return Game( id: canonical.uuid, homeTeamId: homeTeamUUID, awayTeamId: awayTeamUUID, stadiumId: stadiumUUID, dateTime: canonical.dateTime, sport: canonical.sportEnum ?? .mlb, season: canonical.season, isPlayoff: canonical.isPlayoff, broadcastInfo: canonical.broadcastInfo ) } } func fetchGame(by id: UUID) async throws -> Game? { try await loadCachesIfNeeded() let context = ModelContext(modelContainer) // Search by UUID let descriptor = FetchDescriptor( predicate: #Predicate { game in game.uuid == id && game.deprecatedAt == nil } ) guard let canonical = try context.fetch(descriptor).first else { return nil } guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId], let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId], let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { return nil } return Game( id: canonical.uuid, homeTeamId: homeTeamUUID, awayTeamId: awayTeamUUID, stadiumId: stadiumUUID, dateTime: canonical.dateTime, sport: canonical.sportEnum ?? .mlb, season: canonical.season, isPlayoff: canonical.isPlayoff, broadcastInfo: canonical.broadcastInfo ) } func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { try await loadCachesIfNeeded() let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) }) let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) }) return games.compactMap { game in guard let homeTeam = teamsById[game.homeTeamId], let awayTeam = teamsById[game.awayTeamId], let stadium = stadiumsById[game.stadiumId] else { return nil } return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) } } // MARK: - Additional Queries /// Fetch stadium by canonical ID (useful for visit tracking) func fetchStadium(byCanonicalId canonicalId: String) async throws -> Stadium? { try await loadCachesIfNeeded() return stadiumsByCanonicalId[canonicalId] } /// Fetch team by canonical ID func fetchTeam(byCanonicalId canonicalId: String) async throws -> Team? { try await loadCachesIfNeeded() return teamsByCanonicalId[canonicalId] } /// Find stadium by name (matches aliases) func findStadium(byName name: String) async throws -> Stadium? { let context = ModelContext(modelContainer) // Precompute lowercased name outside the predicate let lowercasedName = name.lowercased() // First try exact alias match let aliasDescriptor = FetchDescriptor( predicate: #Predicate { alias in alias.aliasName == lowercasedName } ) if let alias = try context.fetch(aliasDescriptor).first, let stadiumCanonicalId = Optional(alias.stadiumCanonicalId) { return try await fetchStadium(byCanonicalId: stadiumCanonicalId) } return nil } /// Invalidate caches (call after sync completes) func invalidateCaches() { cachedTeams = nil cachedStadiums = nil teamsByCanonicalId.removeAll() stadiumsByCanonicalId.removeAll() teamUUIDByCanonicalId.removeAll() stadiumUUIDByCanonicalId.removeAll() } // MARK: - Private Helpers private func loadCachesIfNeeded() async throws { guard cachedTeams == nil else { return } let context = ModelContext(modelContainer) // Load stadiums let stadiumDescriptor = FetchDescriptor( predicate: #Predicate { stadium in stadium.deprecatedAt == nil } ) let canonicalStadiums = try context.fetch(stadiumDescriptor) cachedStadiums = canonicalStadiums.map { canonical in let stadium = canonical.toDomain() stadiumsByCanonicalId[canonical.canonicalId] = stadium stadiumUUIDByCanonicalId[canonical.canonicalId] = stadium.id return stadium } // Load teams let teamDescriptor = FetchDescriptor( predicate: #Predicate { team in team.deprecatedAt == nil } ) let canonicalTeams = try context.fetch(teamDescriptor) cachedTeams = canonicalTeams.compactMap { canonical -> Team? in guard let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { // Generate a placeholder UUID for teams without known stadiums let placeholderUUID = CanonicalStadium.deterministicUUID(from: canonical.stadiumCanonicalId) let team = canonical.toDomain(stadiumUUID: placeholderUUID) teamsByCanonicalId[canonical.canonicalId] = team teamUUIDByCanonicalId[canonical.canonicalId] = team.id return team } let team = canonical.toDomain(stadiumUUID: stadiumUUID) teamsByCanonicalId[canonical.canonicalId] = team teamUUIDByCanonicalId[canonical.canonicalId] = team.id return team } } }