// // BootstrapService.swift // SportsTime // // Bootstraps canonical data from bundled JSON files into SwiftData. // Runs once on first launch, then relies on CloudKit for updates. // import Foundation import SwiftData import CryptoKit actor BootstrapService { // MARK: - Errors enum BootstrapError: Error, LocalizedError { case bundledResourceNotFound(String) case jsonDecodingFailed(String, Error) case saveFailed(Error) var errorDescription: String? { switch self { case .bundledResourceNotFound(let resource): return "Bundled resource not found: \(resource)" case .jsonDecodingFailed(let resource, let error): return "Failed to decode \(resource): \(error.localizedDescription)" case .saveFailed(let error): return "Failed to save bootstrap data: \(error.localizedDescription)" } } } // MARK: - JSON Models (match bundled JSON structure) // MARK: - Canonical JSON Models (from canonicalization pipeline) private struct JSONCanonicalStadium: Codable { let canonical_id: String let name: String let city: String let state: String let latitude: Double let longitude: Double let capacity: Int let sport: String let primary_team_abbrevs: [String] let year_opened: Int? let timezone_identifier: String? } private struct JSONCanonicalTeam: Codable { let canonical_id: String let name: String let abbreviation: String let sport: String let city: String let stadium_canonical_id: String let conference_id: String? let division_id: String? let primary_color: String? let secondary_color: String? } private struct JSONCanonicalGame: Codable { let canonical_id: String let sport: String let season: String let date: String let time: String? let home_team_canonical_id: String let away_team_canonical_id: String let stadium_canonical_id: String let is_playoff: Bool let broadcast_info: String? } private struct JSONStadiumAlias: Codable { let alias_name: String let stadium_canonical_id: String let valid_from: String? let valid_until: String? } // MARK: - Legacy JSON Models (for backward compatibility) private struct JSONStadium: Codable { let id: String let name: String let city: String let state: String let latitude: Double let longitude: Double let capacity: Int let sport: String let team_abbrevs: [String] let source: String let year_opened: Int? } private struct JSONGame: Codable { let id: String let sport: String let season: String let date: String let time: String? let home_team: String let away_team: String let home_team_abbrev: String let away_team_abbrev: String let venue: String let source: String let is_playoff: Bool let broadcast: String? } private struct JSONLeagueStructure: Codable { let id: String let sport: String let type: String // "conference", "division", "league" let name: String let abbreviation: String? let parent_id: String? let display_order: Int } private struct JSONTeamAlias: Codable { let id: String let team_canonical_id: String let alias_type: String // "abbreviation", "name", "city" let alias_value: String let valid_from: String? let valid_until: String? } // MARK: - Public Methods /// Bootstrap canonical data from bundled JSON if not already done. /// This is the main entry point called at app launch. /// /// Prefers new canonical format files (*_canonical.json) from the pipeline, /// falls back to legacy format for backward compatibility. @MainActor func bootstrapIfNeeded(context: ModelContext) async throws { let syncState = SyncState.current(in: context) // Skip if already bootstrapped guard !syncState.bootstrapCompleted else { return } // Bootstrap in dependency order: // 1. Stadiums (no dependencies) // 2. Stadium aliases (depends on stadiums) // 3. League structure (no dependencies) // 4. Teams (depends on stadiums) // 5. Team aliases (depends on teams) // 6. Games (depends on teams + stadiums) try await bootstrapStadiums(context: context) try await bootstrapStadiumAliases(context: context) try await bootstrapLeagueStructure(context: context) try await bootstrapTeams(context: context) try await bootstrapTeamAliases(context: context) try await bootstrapGames(context: context) // Mark bootstrap complete syncState.bootstrapCompleted = true syncState.bundledSchemaVersion = SchemaVersion.current syncState.lastBootstrap = Date() do { try context.save() } catch { throw BootstrapError.saveFailed(error) } } // MARK: - Bootstrap Steps @MainActor private func bootstrapStadiums(context: ModelContext) async throws { // Try canonical format first, fall back to legacy if let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "json") { try await bootstrapStadiumsCanonical(url: url, context: context) } else if let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") { try await bootstrapStadiumsLegacy(url: url, context: context) } else { throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json or stadiums.json") } } @MainActor private func bootstrapStadiumsCanonical(url: URL, context: ModelContext) async throws { let data: Data let stadiums: [JSONCanonicalStadium] do { data = try Data(contentsOf: url) stadiums = try JSONDecoder().decode([JSONCanonicalStadium].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error) } for jsonStadium in stadiums { let canonical = CanonicalStadium( canonicalId: jsonStadium.canonical_id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.stadiums, source: .bundled, name: jsonStadium.name, city: jsonStadium.city, state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state, latitude: jsonStadium.latitude, longitude: jsonStadium.longitude, capacity: jsonStadium.capacity, yearOpened: jsonStadium.year_opened, sport: jsonStadium.sport, timezoneIdentifier: jsonStadium.timezone_identifier ) context.insert(canonical) } } @MainActor private func bootstrapStadiumsLegacy(url: URL, context: ModelContext) async throws { let data: Data let stadiums: [JSONStadium] do { data = try Data(contentsOf: url) stadiums = try JSONDecoder().decode([JSONStadium].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("stadiums.json", error) } for jsonStadium in stadiums { let canonical = CanonicalStadium( canonicalId: jsonStadium.id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.stadiums, source: .bundled, name: jsonStadium.name, city: jsonStadium.city, state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state, latitude: jsonStadium.latitude, longitude: jsonStadium.longitude, capacity: jsonStadium.capacity, yearOpened: jsonStadium.year_opened, sport: jsonStadium.sport ) context.insert(canonical) // Legacy format: create stadium alias for the current name let alias = StadiumAlias( aliasName: jsonStadium.name, stadiumCanonicalId: jsonStadium.id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.stadiums ) alias.stadium = canonical context.insert(alias) } } @MainActor private func bootstrapStadiumAliases(context: ModelContext) async throws { // Stadium aliases are loaded from stadium_aliases.json (from canonical pipeline) guard let url = Bundle.main.url(forResource: "stadium_aliases", withExtension: "json") else { // Aliases are optional - legacy format creates them inline return } let data: Data let aliases: [JSONStadiumAlias] do { data = try Data(contentsOf: url) aliases = try JSONDecoder().decode([JSONStadiumAlias].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error) } // Build stadium lookup let stadiumDescriptor = FetchDescriptor() let stadiums = (try? context.fetch(stadiumDescriptor)) ?? [] let stadiumsByCanonicalId = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.canonicalId, $0) }) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" for jsonAlias in aliases { let alias = StadiumAlias( aliasName: jsonAlias.alias_name, stadiumCanonicalId: jsonAlias.stadium_canonical_id, validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) }, validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) }, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.stadiums ) // Link to stadium if found if let stadium = stadiumsByCanonicalId[jsonAlias.stadium_canonical_id] { alias.stadium = stadium } context.insert(alias) } } @MainActor private func bootstrapLeagueStructure(context: ModelContext) async throws { // Load league structure if file exists guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else { // League structure is optional for MVP - create basic structure from known sports createDefaultLeagueStructure(context: context) return } let data: Data let structures: [JSONLeagueStructure] do { data = try Data(contentsOf: url) structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("league_structure.json", error) } for structure in structures { let structureType: LeagueStructureType switch structure.type.lowercased() { case "conference": structureType = .conference case "division": structureType = .division case "league": structureType = .league default: structureType = .division } let model = LeagueStructureModel( id: structure.id, sport: structure.sport, structureType: structureType, name: structure.name, abbreviation: structure.abbreviation, parentId: structure.parent_id, displayOrder: structure.display_order, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.leagueStructure ) context.insert(model) } } @MainActor private func bootstrapTeams(context: ModelContext) async throws { // Try canonical format first, fall back to legacy extraction from games if let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "json") { try await bootstrapTeamsCanonical(url: url, context: context) } else { // Legacy: Teams will be extracted from games during bootstrapGames // This path is deprecated but maintained for backward compatibility } } @MainActor private func bootstrapTeamsCanonical(url: URL, context: ModelContext) async throws { let data: Data let teams: [JSONCanonicalTeam] do { data = try Data(contentsOf: url) teams = try JSONDecoder().decode([JSONCanonicalTeam].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error) } for jsonTeam in teams { let team = CanonicalTeam( canonicalId: jsonTeam.canonical_id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games, source: .bundled, name: jsonTeam.name, abbreviation: jsonTeam.abbreviation, sport: jsonTeam.sport, city: jsonTeam.city, stadiumCanonicalId: jsonTeam.stadium_canonical_id, conferenceId: jsonTeam.conference_id, divisionId: jsonTeam.division_id ) context.insert(team) } } @MainActor private func bootstrapGames(context: ModelContext) async throws { // Try canonical format first, fall back to legacy if let url = Bundle.main.url(forResource: "games_canonical", withExtension: "json") { try await bootstrapGamesCanonical(url: url, context: context) } else if let url = Bundle.main.url(forResource: "games", withExtension: "json") { try await bootstrapGamesLegacy(url: url, context: context) } else { throw BootstrapError.bundledResourceNotFound("games_canonical.json or games.json") } } @MainActor private func bootstrapGamesCanonical(url: URL, context: ModelContext) async throws { let data: Data let games: [JSONCanonicalGame] do { data = try Data(contentsOf: url) games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("games_canonical.json", error) } var seenGameIds = Set() for jsonGame in games { // Deduplicate guard !seenGameIds.contains(jsonGame.canonical_id) else { continue } seenGameIds.insert(jsonGame.canonical_id) guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else { continue } let game = CanonicalGame( canonicalId: jsonGame.canonical_id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games, source: .bundled, homeTeamCanonicalId: jsonGame.home_team_canonical_id, awayTeamCanonicalId: jsonGame.away_team_canonical_id, stadiumCanonicalId: jsonGame.stadium_canonical_id, dateTime: dateTime, sport: jsonGame.sport, season: jsonGame.season, isPlayoff: jsonGame.is_playoff, broadcastInfo: jsonGame.broadcast_info ) context.insert(game) } } @MainActor private func bootstrapGamesLegacy(url: URL, context: ModelContext) async throws { let data: Data let games: [JSONGame] do { data = try Data(contentsOf: url) games = try JSONDecoder().decode([JSONGame].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("games.json", error) } // Build stadium lookup for legacy venue matching let stadiumDescriptor = FetchDescriptor() let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? [] var stadiumsByVenue: [String: CanonicalStadium] = [:] for stadium in canonicalStadiums { stadiumsByVenue[stadium.name.lowercased()] = stadium } // Check if teams already exist (from teams_canonical.json) let teamDescriptor = FetchDescriptor() let existingTeams = (try? context.fetch(teamDescriptor)) ?? [] var teamsCreated: [String: CanonicalTeam] = Dictionary( uniqueKeysWithValues: existingTeams.map { ($0.canonicalId, $0) } ) let teamsAlreadyLoaded = !existingTeams.isEmpty var seenGameIds = Set() for jsonGame in games { let sport = jsonGame.sport.uppercased() // Legacy team extraction (only if teams not already loaded) if !teamsAlreadyLoaded { let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())" if teamsCreated[homeTeamCanonicalId] == nil { let stadiumCanonicalId = findStadiumCanonicalId( venue: jsonGame.venue, sport: sport, stadiumsByVenue: stadiumsByVenue ) let team = CanonicalTeam( canonicalId: homeTeamCanonicalId, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games, source: .bundled, name: extractTeamName(from: jsonGame.home_team), abbreviation: jsonGame.home_team_abbrev, sport: sport, city: extractCity(from: jsonGame.home_team), stadiumCanonicalId: stadiumCanonicalId ) context.insert(team) teamsCreated[homeTeamCanonicalId] = team } let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())" if teamsCreated[awayTeamCanonicalId] == nil { let team = CanonicalTeam( canonicalId: awayTeamCanonicalId, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games, source: .bundled, name: extractTeamName(from: jsonGame.away_team), abbreviation: jsonGame.away_team_abbrev, sport: sport, city: extractCity(from: jsonGame.away_team), stadiumCanonicalId: "unknown" ) context.insert(team) teamsCreated[awayTeamCanonicalId] = team } } // Deduplicate games guard !seenGameIds.contains(jsonGame.id) else { continue } seenGameIds.insert(jsonGame.id) guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else { continue } let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())" let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())" let stadiumCanonicalId = findStadiumCanonicalId( venue: jsonGame.venue, sport: sport, stadiumsByVenue: stadiumsByVenue ) let game = CanonicalGame( canonicalId: jsonGame.id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games, source: .bundled, homeTeamCanonicalId: homeTeamCanonicalId, awayTeamCanonicalId: awayTeamCanonicalId, stadiumCanonicalId: stadiumCanonicalId, dateTime: dateTime, sport: sport, season: jsonGame.season, isPlayoff: jsonGame.is_playoff, broadcastInfo: jsonGame.broadcast ) context.insert(game) } } @MainActor private func bootstrapTeamAliases(context: ModelContext) async throws { // Team aliases are optional - load if file exists guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else { return } let data: Data let aliases: [JSONTeamAlias] do { data = try Data(contentsOf: url) aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("team_aliases.json", error) } let dateFormatter = ISO8601DateFormatter() for jsonAlias in aliases { let aliasType: TeamAliasType switch jsonAlias.alias_type.lowercased() { case "abbreviation": aliasType = .abbreviation case "name": aliasType = .name case "city": aliasType = .city default: aliasType = .name } let alias = TeamAlias( id: jsonAlias.id, teamCanonicalId: jsonAlias.team_canonical_id, aliasType: aliasType, aliasValue: jsonAlias.alias_value, validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) }, validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) }, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games ) context.insert(alias) } } // MARK: - Helpers @MainActor private func createDefaultLeagueStructure(context: ModelContext) { // Create minimal league structure for supported sports let timestamp = BundledDataTimestamp.leagueStructure // MLB context.insert(LeagueStructureModel( id: "mlb_league", sport: "MLB", structureType: .league, name: "Major League Baseball", abbreviation: "MLB", displayOrder: 0, schemaVersion: SchemaVersion.current, lastModified: timestamp )) // NBA context.insert(LeagueStructureModel( id: "nba_league", sport: "NBA", structureType: .league, name: "National Basketball Association", abbreviation: "NBA", displayOrder: 0, schemaVersion: SchemaVersion.current, lastModified: timestamp )) // NHL context.insert(LeagueStructureModel( id: "nhl_league", sport: "NHL", structureType: .league, name: "National Hockey League", abbreviation: "NHL", displayOrder: 0, schemaVersion: SchemaVersion.current, lastModified: timestamp )) } // Venue name aliases for stadiums that changed names private static let venueAliases: [String: String] = [ "daikin park": "minute maid park", "rate field": "guaranteed rate field", "george m. steinbrenner field": "tropicana field", "loandepot park": "loandepot park", ] nonisolated private func findStadiumCanonicalId( venue: String, sport: String, stadiumsByVenue: [String: CanonicalStadium] ) -> String { var venueLower = venue.lowercased() // Check for known aliases if let aliasedName = Self.venueAliases[venueLower] { venueLower = aliasedName } // Try exact match if let stadium = stadiumsByVenue[venueLower] { return stadium.canonicalId } // Try partial match for (name, stadium) in stadiumsByVenue { if name.contains(venueLower) || venueLower.contains(name) { return stadium.canonicalId } } // Generate deterministic ID for unknown venues return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))" } nonisolated private func parseDateTime(date: String, time: String) -> Date? { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") // Parse date formatter.dateFormat = "yyyy-MM-dd" guard let dateOnly = formatter.date(from: date) else { return nil } // Parse time (e.g., "7:30p", "10:00p", "1:05p") var hour = 12 var minute = 0 let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "") let isPM = cleanTime.contains("p") let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "") let components = timeWithoutAMPM.split(separator: ":") if !components.isEmpty, let h = Int(components[0]) { hour = h if isPM && hour != 12 { hour += 12 } else if !isPM && hour == 12 { hour = 0 } } if components.count > 1, let m = Int(components[1]) { minute = m } return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly) } nonisolated private func extractTeamName(from fullName: String) -> String { // "Boston Celtics" -> "Celtics" let parts = fullName.split(separator: " ") if parts.count > 1 { return parts.dropFirst().joined(separator: " ") } return fullName } nonisolated private func extractCity(from fullName: String) -> String { // "Boston Celtics" -> "Boston" // "New York Knicks" -> "New York" let knownCities = [ "New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio", "New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City", "St. Louis", "St Louis" ] for city in knownCities { if fullName.hasPrefix(city) { return city } } // Default: first word return String(fullName.split(separator: " ").first ?? Substring(fullName)) } nonisolated private func stateFromCity(_ city: String) -> String { let cityToState: [String: String] = [ "Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC", "Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO", "Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA", "Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN", "New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL", "Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA", "San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON", "Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA", "Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO", "Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA", "Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT" ] return cityToState[city] ?? "" } }