// // GameMatcher.swift // SportsTime // // Deterministic game matching from photo metadata. // import Foundation import CoreLocation // MARK: - No Match Reason enum NoMatchReason: Sendable { case noStadiumNearby case noGamesOnDate case metadataMissing(MetadataMissingReason) enum MetadataMissingReason: Sendable { case noLocation case noDate case noBoth } var description: String { switch self { case .noStadiumNearby: return "No stadium found nearby" case .noGamesOnDate: return "No games found on this date" case .metadataMissing(let reason): switch reason { case .noLocation: return "Photo has no location data" case .noDate: return "Photo has no date information" case .noBoth: return "Photo has no location or date data" } } } } // MARK: - Game Match Result struct GameMatchCandidate: Identifiable, Sendable { let id: UUID let game: Game let stadium: Stadium let homeTeam: Team let awayTeam: Team let confidence: PhotoMatchConfidence // Scraped score components (stored separately for flexible formatting) let scrapedHomeScore: Int? let scrapedAwayScore: Int? init(game: Game, stadium: Stadium, homeTeam: Team, awayTeam: Team, confidence: PhotoMatchConfidence) { self.id = game.id self.game = game self.stadium = stadium self.homeTeam = homeTeam self.awayTeam = awayTeam self.confidence = confidence self.scrapedHomeScore = nil self.scrapedAwayScore = nil } /// Initialize from a scraped historical game init(scrapedGame: ScrapedGame, stadium: Stadium) { self.id = UUID() self.stadium = stadium // Create synthetic Team objects from scraped names // Scraped names already include city (e.g., "Chicago Cubs"), so we use empty city // to avoid duplication in fullName computed property self.homeTeam = Team( id: UUID(), name: scrapedGame.homeTeam, abbreviation: String(scrapedGame.homeTeam.suffix(3)).uppercased(), sport: scrapedGame.sport, city: "", stadiumId: stadium.id ) self.awayTeam = Team( id: UUID(), name: scrapedGame.awayTeam, abbreviation: String(scrapedGame.awayTeam.suffix(3)).uppercased(), sport: scrapedGame.sport, city: "", stadiumId: stadium.id ) // Create synthetic Game object let year = Calendar.current.component(.year, from: scrapedGame.date) self.game = Game( id: self.id, homeTeamId: self.homeTeam.id, awayTeamId: self.awayTeam.id, stadiumId: stadium.id, dateTime: scrapedGame.date, sport: scrapedGame.sport, season: "\(year)" ) // High confidence since we found the exact game from web self.confidence = PhotoMatchConfidence( spatial: .high, temporal: .exactDay ) // Store scraped scores separately for flexible formatting self.scrapedHomeScore = scrapedGame.homeScore self.scrapedAwayScore = scrapedGame.awayScore } var matchupDescription: String { "\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)" } var fullMatchupDescription: String { "\(awayTeam.fullName) at \(homeTeam.fullName)" } /// Final score in "away-home" format for storage (e.g., "5-3") var formattedFinalScore: String? { guard let home = scrapedHomeScore, let away = scrapedAwayScore else { return nil } return "\(away)-\(home)" } /// Score for display with team names (e.g., "Pirates 5\nCubs 3") var displayScore: String? { guard let home = scrapedHomeScore, let away = scrapedAwayScore else { return nil } return "\(awayTeam.fullName) \(away)\n\(homeTeam.fullName) \(home)" } var gameDateTime: String { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short return formatter.string(from: game.dateTime) } } enum GameMatchResult: Sendable { case singleMatch(GameMatchCandidate) // Auto-select case multipleMatches([GameMatchCandidate]) // User selects (doubleheader, nearby stadiums) case noMatches(NoMatchReason) // Manual entry required var hasMatch: Bool { switch self { case .singleMatch, .multipleMatches: return true case .noMatches: return false } } } // MARK: - Photo Import Result struct PhotoImportCandidate: Identifiable, Sendable { let id: UUID let metadata: PhotoMetadata let matchResult: GameMatchResult let stadiumMatches: [StadiumMatch] init(metadata: PhotoMetadata, matchResult: GameMatchResult, stadiumMatches: [StadiumMatch]) { self.id = UUID() self.metadata = metadata self.matchResult = matchResult self.stadiumMatches = stadiumMatches } /// Best stadium match if available var bestStadiumMatch: StadiumMatch? { stadiumMatches.first } /// Whether this can be auto-processed without user input var canAutoProcess: Bool { if case .singleMatch(let candidate) = matchResult { return candidate.confidence.combined == .autoSelect } return false } } // MARK: - Game Matcher @MainActor final class GameMatcher { static let shared = GameMatcher() private let dataProvider = AppDataProvider.shared private let proximityMatcher = StadiumProximityMatcher.shared private init() {} // MARK: - Primary Matching /// Match photo metadata to a game /// Uses deterministic rules - never guesses func matchGame( metadata: PhotoMetadata, sport: Sport? = nil ) async -> GameMatchResult { // 1. Check for required metadata guard metadata.hasValidLocation else { let reason: NoMatchReason.MetadataMissingReason = metadata.hasValidDate ? .noLocation : .noBoth return .noMatches(.metadataMissing(reason)) } guard metadata.hasValidDate, let photoDate = metadata.captureDate else { return .noMatches(.metadataMissing(.noDate)) } guard let coordinates = metadata.coordinates else { return .noMatches(.metadataMissing(.noLocation)) } // 2. Find nearby stadiums let stadiumMatches = proximityMatcher.findNearbyStadiums( coordinates: coordinates, sport: sport ) guard !stadiumMatches.isEmpty else { return .noMatches(.noStadiumNearby) } // 3. Find games at those stadiums on/around that date var candidates: [GameMatchCandidate] = [] for stadiumMatch in stadiumMatches { let games = await findGames( at: stadiumMatch.stadium, around: photoDate, sport: sport ) for game in games { // Look up teams guard let homeTeam = dataProvider.teams.first(where: { $0.id == game.homeTeamId }), let awayTeam = dataProvider.teams.first(where: { $0.id == game.awayTeamId }) else { continue } // Calculate confidence let confidence = proximityMatcher.calculateMatchConfidence( stadiumMatch: stadiumMatch, photoDate: photoDate, gameDate: game.dateTime ) // Only include if temporal confidence is acceptable if confidence.temporal != .outOfRange { candidates.append(GameMatchCandidate( game: game, stadium: stadiumMatch.stadium, homeTeam: homeTeam, awayTeam: awayTeam, confidence: confidence )) } } } // 4. Return based on matches found if candidates.isEmpty { return .noMatches(.noGamesOnDate) } else if candidates.count == 1 { return .singleMatch(candidates[0]) } else { // Sort by confidence (best first) let sorted = candidates.sorted { c1, c2 in c1.confidence.combined > c2.confidence.combined } return .multipleMatches(sorted) } } // MARK: - Full Import Processing /// Process a photo for import, returning full match context func processPhotoForImport( metadata: PhotoMetadata, sport: Sport? = nil ) async -> PhotoImportCandidate { print("🎯 [GameMatcher] Processing photo for import") print("🎯 [GameMatcher] - hasValidDate: \(metadata.hasValidDate)") print("🎯 [GameMatcher] - captureDate: \(metadata.captureDate?.description ?? "nil")") print("🎯 [GameMatcher] - hasValidLocation: \(metadata.hasValidLocation)") if let coords = metadata.coordinates { print("🎯 [GameMatcher] - coordinates: \(coords.latitude), \(coords.longitude)") } // Get stadium matches regardless of game matching var stadiumMatches: [StadiumMatch] = [] if let coordinates = metadata.coordinates { stadiumMatches = proximityMatcher.findNearbyStadiums( coordinates: coordinates, sport: sport ) print("🎯 [GameMatcher] - nearby stadiums found: \(stadiumMatches.count)") for match in stadiumMatches.prefix(3) { print("🎯 [GameMatcher] • \(match.stadium.name) (\(String(format: "%.1f", match.distance / 1609.34)) mi)") } } else { print("🎯 [GameMatcher] - no coordinates, skipping stadium proximity search") } var matchResult = await matchGame(metadata: metadata, sport: sport) switch matchResult { case .singleMatch(let match): print("🎯 [GameMatcher] - result: singleMatch - \(match.homeTeam.fullName) vs \(match.awayTeam.fullName)") case .multipleMatches(let matches): print("🎯 [GameMatcher] - result: multipleMatches (\(matches.count) games)") case .noMatches(let reason): print("🎯 [GameMatcher] - result: noMatches - \(reason)") // FALLBACK: Try scraping historical game data if we have stadium + date // Try all nearby stadiums (important for multi-sport venues like American Airlines Center) if let captureDate = metadata.captureDate, !stadiumMatches.isEmpty { print("🎯 [GameMatcher] - Trying historical scraper fallback...") for stadiumMatch in stadiumMatches { let stadium = stadiumMatch.stadium print("🎯 [GameMatcher] - Trying \(stadium.name) (\(stadium.sport.rawValue))...") if let scrapedGame = await HistoricalGameScraper.shared.scrapeGame( stadium: stadium, date: captureDate ) { print("🎯 [GameMatcher] - ✅ Scraper found: \(scrapedGame.awayTeam) @ \(scrapedGame.homeTeam)") // Convert ScrapedGame to a match result matchResult = .singleMatch(GameMatchCandidate( scrapedGame: scrapedGame, stadium: stadium )) break // Found a game, stop searching } } if case .noMatches = matchResult { print("🎯 [GameMatcher] - Scraper found no game at any stadium") } } } return PhotoImportCandidate( metadata: metadata, matchResult: matchResult, stadiumMatches: stadiumMatches ) } /// Process multiple photos for import func processPhotosForImport( _ metadataList: [PhotoMetadata], sport: Sport? = nil ) async -> [PhotoImportCandidate] { var results: [PhotoImportCandidate] = [] for metadata in metadataList { let candidate = await processPhotoForImport(metadata: metadata, sport: sport) results.append(candidate) } return results } // MARK: - Private Helpers /// Find games at a stadium around a given date (±1 day for timezone/tailgating) private func findGames( at stadium: Stadium, around date: Date, sport: Sport? ) async -> [Game] { let calendar = Calendar.current // Search window: ±1 day guard let startDate = calendar.date(byAdding: .day, value: -1, to: date), let endDate = calendar.date(byAdding: .day, value: 2, to: date) else { return [] } // Determine which sports to query let sports: Set = sport != nil ? [sport!] : Set(Sport.allCases) do { let allGames = try await dataProvider.fetchGames(sports: sports, startDate: startDate, endDate: endDate) // Filter by stadium let games = allGames.filter { $0.stadiumId == stadium.id } return games } catch { return [] } } } // MARK: - Batch Processing Helpers extension GameMatcher { /// Separate photos into categories for UI struct CategorizedImports: Sendable { let autoProcessable: [PhotoImportCandidate] let needsConfirmation: [PhotoImportCandidate] let needsManualEntry: [PhotoImportCandidate] } nonisolated func categorizeImports(_ candidates: [PhotoImportCandidate]) -> CategorizedImports { var auto: [PhotoImportCandidate] = [] var confirm: [PhotoImportCandidate] = [] var manual: [PhotoImportCandidate] = [] for candidate in candidates { switch candidate.matchResult { case .singleMatch(let match): if match.confidence.combined == .autoSelect { auto.append(candidate) } else { confirm.append(candidate) } case .multipleMatches: confirm.append(candidate) case .noMatches: manual.append(candidate) } } return CategorizedImports( autoProcessable: auto, needsConfirmation: confirm, needsManualEntry: manual ) } }