// // StubDataProvider.swift // SportsTime // // Provides real data from bundled JSON files for Simulator testing // import Foundation import CryptoKit actor StubDataProvider: DataProvider { // MARK: - JSON Models 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 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? } // MARK: - Cached Data private var cachedGames: [Game]? private var cachedTeams: [Team]? private var cachedStadiums: [Stadium]? private var teamsByAbbrev: [String: Team] = [:] private var stadiumsByVenue: [String: Stadium] = [:] // MARK: - DataProvider Protocol func fetchTeams(for sport: Sport) async throws -> [Team] { try await loadAllDataIfNeeded() return cachedTeams?.filter { $0.sport == sport } ?? [] } func fetchAllTeams() async throws -> [Team] { try await loadAllDataIfNeeded() return cachedTeams ?? [] } func fetchStadiums() async throws -> [Stadium] { try await loadAllDataIfNeeded() return cachedStadiums ?? [] } func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { try await loadAllDataIfNeeded() return (cachedGames ?? []).filter { game in sports.contains(game.sport) && game.dateTime >= startDate && game.dateTime <= endDate } } func fetchGame(by id: UUID) async throws -> Game? { try await loadAllDataIfNeeded() return cachedGames?.first { $0.id == id } } func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { try await loadAllDataIfNeeded() 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: - Data Loading private func loadAllDataIfNeeded() async throws { guard cachedGames == nil else { return } // Load stadiums first let jsonStadiums = try loadStadiumsJSON() cachedStadiums = jsonStadiums.map { convertStadium($0) } // Build stadium lookup by venue name for stadium in cachedStadiums ?? [] { stadiumsByVenue[stadium.name.lowercased()] = stadium } // Load games and extract teams let jsonGames = try loadGamesJSON() // Build teams from games data var teamsDict: [String: Team] = [:] for jsonGame in jsonGames { let sport = parseSport(jsonGame.sport) // Home team let homeKey = "\(sport.rawValue)_\(jsonGame.home_team_abbrev)" if teamsDict[homeKey] == nil { let stadiumId = findStadiumId(venue: jsonGame.venue, sport: sport) let team = Team( id: deterministicUUID(from: homeKey), name: extractTeamName(from: jsonGame.home_team), abbreviation: jsonGame.home_team_abbrev, sport: sport, city: extractCity(from: jsonGame.home_team), stadiumId: stadiumId ) teamsDict[homeKey] = team teamsByAbbrev[homeKey] = team } // Away team let awayKey = "\(sport.rawValue)_\(jsonGame.away_team_abbrev)" if teamsDict[awayKey] == nil { // Away teams might not have a stadium in our data yet let team = Team( id: deterministicUUID(from: awayKey), name: extractTeamName(from: jsonGame.away_team), abbreviation: jsonGame.away_team_abbrev, sport: sport, city: extractCity(from: jsonGame.away_team), stadiumId: UUID() // Placeholder, will be updated when they're home team ) teamsDict[awayKey] = team teamsByAbbrev[awayKey] = team } } cachedTeams = Array(teamsDict.values) // Convert games (deduplicate by ID - JSON may have duplicate entries) var seenGameIds = Set() let uniqueJsonGames = jsonGames.filter { game in if seenGameIds.contains(game.id) { return false } seenGameIds.insert(game.id) return true } cachedGames = uniqueJsonGames.compactMap { convertGame($0) } print("StubDataProvider loaded: \(cachedGames?.count ?? 0) games, \(cachedTeams?.count ?? 0) teams, \(cachedStadiums?.count ?? 0) stadiums") } private func loadGamesJSON() throws -> [JSONGame] { guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else { print("Warning: games.json not found in bundle") return [] } let data = try Data(contentsOf: url) do { return try JSONDecoder().decode([JSONGame].self, from: data) } catch let DecodingError.keyNotFound(key, context) { print("❌ Games JSON missing key '\(key.stringValue)' at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))") throw DecodingError.keyNotFound(key, context) } catch let DecodingError.typeMismatch(type, context) { print("❌ Games JSON type mismatch for \(type) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))") throw DecodingError.typeMismatch(type, context) } catch { print("❌ Games JSON decode error: \(error)") throw error } } private func loadStadiumsJSON() throws -> [JSONStadium] { guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else { print("Warning: stadiums.json not found in bundle") return [] } let data = try Data(contentsOf: url) do { return try JSONDecoder().decode([JSONStadium].self, from: data) } catch let DecodingError.keyNotFound(key, context) { print("❌ Stadiums JSON missing key '\(key.stringValue)' at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))") throw DecodingError.keyNotFound(key, context) } catch let DecodingError.typeMismatch(type, context) { print("❌ Stadiums JSON type mismatch for \(type) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))") throw DecodingError.typeMismatch(type, context) } catch { print("❌ Stadiums JSON decode error: \(error)") throw error } } // MARK: - Conversion Helpers private func convertStadium(_ json: JSONStadium) -> Stadium { Stadium( id: deterministicUUID(from: json.id), name: json.name, city: json.city, state: json.state.isEmpty ? stateFromCity(json.city) : json.state, latitude: json.latitude, longitude: json.longitude, capacity: json.capacity, yearOpened: json.year_opened ) } private func convertGame(_ json: JSONGame) -> Game? { let sport = parseSport(json.sport) let homeKey = "\(sport.rawValue)_\(json.home_team_abbrev)" let awayKey = "\(sport.rawValue)_\(json.away_team_abbrev)" guard let homeTeam = teamsByAbbrev[homeKey], let awayTeam = teamsByAbbrev[awayKey] else { return nil } let stadiumId = findStadiumId(venue: json.venue, sport: sport) guard let dateTime = parseDateTime(date: json.date, time: json.time ?? "7:00p") else { return nil } return Game( id: deterministicUUID(from: json.id), homeTeamId: homeTeam.id, awayTeamId: awayTeam.id, stadiumId: stadiumId, dateTime: dateTime, sport: sport, season: json.season, isPlayoff: json.is_playoff, broadcastInfo: json.broadcast ) } private func parseSport(_ sport: String) -> Sport { switch sport.uppercased() { case "MLB": return .mlb case "NBA": return .nba case "NHL": return .nhl case "NFL": return .nfl case "MLS": return .mls default: return .mlb } } 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) } // Venue name aliases for stadiums that changed names private static let venueAliases: [String: String] = [ "daikin park": "minute maid park", // Houston Astros (renamed 2024) "rate field": "guaranteed rate field", // Chicago White Sox "george m. steinbrenner field": "tropicana field", // Tampa Bay spring training → main stadium "loandepot park": "loandepot park", // Miami - ensure case match ] private func findStadiumId(venue: String, sport: Sport) -> UUID { 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.id } // Try partial match for (name, stadium) in stadiumsByVenue { if name.contains(venueLower) || venueLower.contains(name) { return stadium.id } } // Generate deterministic ID for unknown venues print("[StubDataProvider] No stadium match for venue: '\(venue)'") return deterministicUUID(from: "venue_\(venue)") } private func deterministicUUID(from string: String) -> UUID { // Create a deterministic UUID using SHA256 (truly deterministic across launches) let data = Data(string.utf8) let hash = SHA256.hash(data: data) let hashBytes = Array(hash) // Use first 16 bytes of SHA256 hash var bytes = Array(hashBytes.prefix(16)) // Set UUID version (4) and variant bits bytes[6] = (bytes[6] & 0x0F) | 0x40 bytes[8] = (bytes[8] & 0x3F) | 0x80 return UUID(uuid: ( bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] )) } 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 } private func extractCity(from fullName: String) -> String { // "Boston Celtics" -> "Boston" // "New York Knicks" -> "New York" // "Los Angeles Lakers" -> "Los Angeles" 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)) } 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] ?? "" } }