// // StadiumProximityMatcher.swift // SportsTime // // Service for matching GPS coordinates to nearby stadiums. // import Foundation import CoreLocation // MARK: - Match Confidence enum MatchConfidence: Sendable { case high // < 500m from stadium center case medium // 500m - 2km case low // 2km - 5km case none // > 5km or no coordinates nonisolated var description: String { switch self { case .high: return "High (within 500m)" case .medium: return "Medium (500m - 2km)" case .low: return "Low (2km - 5km)" case .none: return "No match" } } /// Should auto-select this match without user confirmation? nonisolated var shouldAutoSelect: Bool { switch self { case .high: return true default: return false } } } // Explicit nonisolated Equatable and Comparable conformance extension MatchConfidence: Equatable { nonisolated static func == (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool { switch (lhs, rhs) { case (.high, .high), (.medium, .medium), (.low, .low), (.none, .none): return true default: return false } } } extension MatchConfidence: Comparable { nonisolated static func < (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool { let order: [MatchConfidence] = [.none, .low, .medium, .high] guard let lhsIndex = order.firstIndex(of: lhs), let rhsIndex = order.firstIndex(of: rhs) else { return false } return lhsIndex < rhsIndex } } // MARK: - Stadium Match struct StadiumMatch: Identifiable, Sendable { let id: String let stadium: Stadium let distance: CLLocationDistance let confidence: MatchConfidence init(stadium: Stadium, distance: CLLocationDistance) { self.id = stadium.id self.stadium = stadium self.distance = distance self.confidence = Self.calculateConfidence(for: distance) } var formattedDistance: String { if distance < 1000 { return String(format: "%.0fm away", distance) } else { return String(format: "%.1f km away", distance / 1000) } } private static func calculateConfidence(for distance: CLLocationDistance) -> MatchConfidence { switch distance { case 0..<500: return .high case 500..<2000: return .medium case 2000..<5000: return .low default: return .none } } } // MARK: - Temporal Confidence enum TemporalConfidence: Sendable { case exactDay // Same local date as game case adjacentDay // ±1 day (tailgating, next morning) case outOfRange // >1 day difference nonisolated var description: String { switch self { case .exactDay: return "Same day" case .adjacentDay: return "Adjacent day (±1)" case .outOfRange: return "Out of range" } } } extension TemporalConfidence: Equatable { nonisolated static func == (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool { switch (lhs, rhs) { case (.exactDay, .exactDay), (.adjacentDay, .adjacentDay), (.outOfRange, .outOfRange): return true default: return false } } } extension TemporalConfidence: Comparable { nonisolated static func < (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool { let order: [TemporalConfidence] = [.outOfRange, .adjacentDay, .exactDay] guard let lhsIndex = order.firstIndex(of: lhs), let rhsIndex = order.firstIndex(of: rhs) else { return false } return lhsIndex < rhsIndex } } // MARK: - Combined Confidence enum CombinedConfidence: Sendable { case autoSelect // High spatial + exactDay → auto-select case userConfirm // Medium spatial OR adjacentDay → user confirms case manualOnly // Low spatial OR outOfRange → manual entry nonisolated var description: String { switch self { case .autoSelect: return "Auto-select" case .userConfirm: return "Needs confirmation" case .manualOnly: return "Manual entry required" } } nonisolated static func combine(spatial: MatchConfidence, temporal: TemporalConfidence) -> CombinedConfidence { // Low spatial or out of range → manual only switch spatial { case .low, .none: return .manualOnly default: break } switch temporal { case .outOfRange: return .manualOnly default: break } // High spatial + exact day → auto-select switch (spatial, temporal) { case (.high, .exactDay): return .autoSelect default: break } // Everything else needs user confirmation return .userConfirm } } extension CombinedConfidence: Equatable { nonisolated static func == (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool { switch (lhs, rhs) { case (.autoSelect, .autoSelect), (.userConfirm, .userConfirm), (.manualOnly, .manualOnly): return true default: return false } } } extension CombinedConfidence: Comparable { nonisolated static func < (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool { let order: [CombinedConfidence] = [.manualOnly, .userConfirm, .autoSelect] guard let lhsIndex = order.firstIndex(of: lhs), let rhsIndex = order.firstIndex(of: rhs) else { return false } return lhsIndex < rhsIndex } } // MARK: - Photo Match Confidence struct PhotoMatchConfidence: Sendable { let spatial: MatchConfidence let temporal: TemporalConfidence let combined: CombinedConfidence nonisolated init(spatial: MatchConfidence, temporal: TemporalConfidence) { self.spatial = spatial self.temporal = temporal self.combined = CombinedConfidence.combine(spatial: spatial, temporal: temporal) } } // MARK: - Proximity Constants /// Configuration constants for stadium proximity matching (outside @MainActor for default parameter use) enum ProximityConstants: Sendable { static let highConfidenceRadius: CLLocationDistance = 500 // 500m static let mediumConfidenceRadius: CLLocationDistance = 2000 // 2km static let searchRadius: CLLocationDistance = 5000 // 5km default static let dateToleranceDays: Int = 1 // ±1 day for timezone/tailgating } // MARK: - Stadium Proximity Matcher @MainActor final class StadiumProximityMatcher { static let shared = StadiumProximityMatcher() private let dataProvider = AppDataProvider.shared private init() {} // MARK: - Stadium Matching /// Find stadiums within radius of coordinates func findNearbyStadiums( coordinates: CLLocationCoordinate2D, radius: CLLocationDistance = ProximityConstants.searchRadius, sport: Sport? = nil ) -> [StadiumMatch] { let photoLocation = CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude) var stadiums = dataProvider.stadiums // Filter by sport if specified if let sport = sport { let sportTeams = dataProvider.teams.filter { $0.sport == sport } let stadiumIds = Set(sportTeams.map { $0.stadiumId }) stadiums = stadiums.filter { stadiumIds.contains($0.id) } } // Calculate distances and filter by radius var matches: [StadiumMatch] = [] for stadium in stadiums { let stadiumLocation = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude) let distance = photoLocation.distance(from: stadiumLocation) if distance <= radius { matches.append(StadiumMatch(stadium: stadium, distance: distance)) } } // Sort by distance (closest first) return matches.sorted { $0.distance < $1.distance } } /// Find best matching stadium (single result) func findBestMatch( coordinates: CLLocationCoordinate2D, sport: Sport? = nil ) -> StadiumMatch? { let matches = findNearbyStadiums(coordinates: coordinates, sport: sport) return matches.first } /// Check if coordinates are near any stadium func isNearStadium( coordinates: CLLocationCoordinate2D, radius: CLLocationDistance = ProximityConstants.searchRadius ) -> Bool { let matches = findNearbyStadiums(coordinates: coordinates, radius: radius) return !matches.isEmpty } // MARK: - Temporal Matching /// Calculate temporal confidence between photo date and game date nonisolated func calculateTemporalConfidence(photoDate: Date, gameDate: Date) -> TemporalConfidence { let calendar = Calendar.current // Normalize to day boundaries let photoDay = calendar.startOfDay(for: photoDate) let gameDay = calendar.startOfDay(for: gameDate) let daysDifference = abs(calendar.dateComponents([.day], from: photoDay, to: gameDay).day ?? Int.max) switch daysDifference { case 0: return .exactDay case 1: return .adjacentDay default: return .outOfRange } } /// Calculate combined confidence for a photo-stadium-game match nonisolated func calculateMatchConfidence( stadiumMatch: StadiumMatch, photoDate: Date?, gameDate: Date? ) -> PhotoMatchConfidence { let spatial = stadiumMatch.confidence let temporal: TemporalConfidence if let photoDate = photoDate, let gameDate = gameDate { temporal = calculateTemporalConfidence(photoDate: photoDate, gameDate: gameDate) } else { // Missing date information temporal = .outOfRange } return PhotoMatchConfidence(spatial: spatial, temporal: temporal) } } // MARK: - Batch Processing extension StadiumProximityMatcher { /// Find matches for multiple photos func findMatchesForPhotos( _ metadata: [PhotoMetadata], sport: Sport? = nil ) -> [(metadata: PhotoMetadata, matches: [StadiumMatch])] { var results: [(metadata: PhotoMetadata, matches: [StadiumMatch])] = [] for photo in metadata { if let coordinates = photo.coordinates { let matches = findNearbyStadiums(coordinates: coordinates, sport: sport) results.append((metadata: photo, matches: matches)) } else { // No coordinates - empty matches results.append((metadata: photo, matches: [])) } } return results } }