// // 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 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 } var matchupDescription: String { "\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)" } var fullMatchupDescription: String { "\(awayTeam.fullName) at \(homeTeam.fullName)" } 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 { // Get stadium matches regardless of game matching var stadiumMatches: [StadiumMatch] = [] if let coordinates = metadata.coordinates { stadiumMatches = proximityMatcher.findNearbyStadiums( coordinates: coordinates, sport: sport ) } let matchResult = await matchGame(metadata: metadata, sport: sport) 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 ) } }