// // ScheduleMatcher.swift // SportsTime // import Foundation import CoreLocation /// Finds and scores candidate games for trip planning. /// /// Updated for the new scenario-based planning: /// - Scenario A (Date Range): Find games in date range, cluster by region /// - Scenario B (Selected Games): Validate must-see games, find optional additions /// - Scenario C (Start+End): Find games along directional corridor with progress check struct ScheduleMatcher { // MARK: - Find Candidate Games (Legacy + Scenario C Support) /// Finds candidate games along a corridor between start and end. /// Supports directional filtering for Scenario C. /// /// - Parameters: /// - request: Planning request with preferences and games /// - startCoordinate: Starting location /// - endCoordinate: Ending location /// - enforceDirection: If true, only include games that make progress toward end /// - Returns: Array of game candidates sorted by score func findCandidateGames( from request: PlanningRequest, startCoordinate: CLLocationCoordinate2D, endCoordinate: CLLocationCoordinate2D, enforceDirection: Bool = false ) -> [GameCandidate] { var candidates: [GameCandidate] = [] // Calculate the corridor between start and end let corridor = RouteCorridorCalculator( start: startCoordinate, end: endCoordinate, maxDetourFactor: detourFactorFor(request.preferences.leisureLevel) ) for game in request.availableGames { guard let stadium = request.stadiums[game.stadiumId], let homeTeam = request.teams[game.homeTeamId], let awayTeam = request.teams[game.awayTeamId] else { continue } // Check if game is within date range guard game.dateTime >= request.preferences.startDate, game.dateTime <= request.preferences.endDate else { continue } // Check sport filter guard request.preferences.sports.contains(game.sport) else { continue } // Calculate detour distance let detourDistance = corridor.detourDistance(to: stadium.coordinate) // For directional routes, check if this stadium makes progress if enforceDirection { let distanceToEnd = corridor.distanceToEnd(from: stadium.coordinate) let startDistanceToEnd = corridor.directDistance // Skip if stadium is behind the start (going backwards) if distanceToEnd > startDistanceToEnd * 1.1 { // 10% tolerance continue } } // Skip if too far from route (unless must-see) let isMustSee = request.preferences.mustSeeGameIds.contains(game.id) if !isMustSee && detourDistance > corridor.maxDetourDistance { continue } // Score the game let score = scoreGame( game: game, homeTeam: homeTeam, awayTeam: awayTeam, detourDistance: detourDistance, isMustSee: isMustSee, request: request ) let candidate = GameCandidate( id: game.id, game: game, stadium: stadium, homeTeam: homeTeam, awayTeam: awayTeam, detourDistance: detourDistance, score: score ) candidates.append(candidate) } // Sort by score (highest first) return candidates.sorted { $0.score > $1.score } } // MARK: - Directional Game Filtering (Scenario C) /// Finds games along a directional route from start to end. /// Ensures monotonic progress toward destination. /// /// - Parameters: /// - request: Planning request /// - startCoordinate: Starting location /// - endCoordinate: Destination /// - corridorWidthPercent: Width of corridor as percentage of direct distance /// - Returns: Games sorted by their position along the route func findDirectionalGames( from request: PlanningRequest, startCoordinate: CLLocationCoordinate2D, endCoordinate: CLLocationCoordinate2D, corridorWidthPercent: Double = 0.3 ) -> [GameCandidate] { let corridor = RouteCorridorCalculator( start: startCoordinate, end: endCoordinate, maxDetourFactor: 1.0 + corridorWidthPercent ) var candidates: [GameCandidate] = [] for game in request.availableGames { guard let stadium = request.stadiums[game.stadiumId], let homeTeam = request.teams[game.homeTeamId], let awayTeam = request.teams[game.awayTeamId] else { continue } // Date and sport filter guard game.dateTime >= request.preferences.startDate, game.dateTime <= request.preferences.endDate, request.preferences.sports.contains(game.sport) else { continue } // Calculate progress along route (0 = start, 1 = end) let progress = corridor.progressAlongRoute(point: stadium.coordinate) // Only include games that are along the route (positive progress, not behind start) guard progress >= -0.1 && progress <= 1.1 else { continue } // Check corridor width let detourDistance = corridor.detourDistance(to: stadium.coordinate) let isMustSee = request.preferences.mustSeeGameIds.contains(game.id) if !isMustSee && detourDistance > corridor.maxDetourDistance { continue } let score = scoreGame( game: game, homeTeam: homeTeam, awayTeam: awayTeam, detourDistance: detourDistance, isMustSee: isMustSee, request: request ) let candidate = GameCandidate( id: game.id, game: game, stadium: stadium, homeTeam: homeTeam, awayTeam: awayTeam, detourDistance: detourDistance, score: score ) candidates.append(candidate) } // Sort by date (chronological order is the primary constraint) return candidates.sorted { $0.game.dateTime < $1.game.dateTime } } // MARK: - Game Scoring private func scoreGame( game: Game, homeTeam: Team, awayTeam: Team, detourDistance: Double, isMustSee: Bool, request: PlanningRequest ) -> Double { var score: Double = 50.0 // Base score // Must-see bonus if isMustSee { score += 100.0 } // Playoff bonus if game.isPlayoff { score += 30.0 } // Weekend bonus (more convenient) let weekday = Calendar.current.component(.weekday, from: game.dateTime) if weekday == 1 || weekday == 7 { // Sunday or Saturday score += 10.0 } // Evening game bonus (day games harder to schedule around) let hour = Calendar.current.component(.hour, from: game.dateTime) if hour >= 17 { // 5 PM or later score += 5.0 } // Detour penalty let detourMiles = detourDistance * 0.000621371 score -= detourMiles * 0.1 // Lose 0.1 points per mile of detour // Preferred city bonus if request.preferences.preferredCities.contains(homeTeam.city) { score += 15.0 } // Must-stop location bonus if request.preferences.mustStopLocations.contains(where: { $0.name.lowercased() == homeTeam.city.lowercased() }) { score += 25.0 } return max(0, score) } private func detourFactorFor(_ leisureLevel: LeisureLevel) -> Double { switch leisureLevel { case .packed: return 1.3 // 30% detour allowed case .moderate: return 1.5 // 50% detour allowed case .relaxed: return 2.0 // 100% detour allowed } } // MARK: - Find Games at Location func findGames( at stadium: Stadium, within dateRange: ClosedRange, from games: [Game] ) -> [Game] { games.filter { game in game.stadiumId == stadium.id && dateRange.contains(game.dateTime) }.sorted { $0.dateTime < $1.dateTime } } // MARK: - Find Other Sports func findOtherSportsGames( along route: [CLLocationCoordinate2D], excludingSports: Set, within dateRange: ClosedRange, games: [Game], stadiums: [UUID: Stadium], teams: [UUID: Team], maxDetourMiles: Double = 50 ) -> [GameCandidate] { var candidates: [GameCandidate] = [] for game in games { // Skip if sport is already selected if excludingSports.contains(game.sport) { continue } // Skip if outside date range if !dateRange.contains(game.dateTime) { continue } guard let stadium = stadiums[game.stadiumId], let homeTeam = teams[game.homeTeamId], let awayTeam = teams[game.awayTeamId] else { continue } // Check if stadium is near the route let minDistance = route.map { coord in CLLocation(latitude: coord.latitude, longitude: coord.longitude) .distance(from: stadium.location) }.min() ?? .greatestFiniteMagnitude let distanceMiles = minDistance * 0.000621371 if distanceMiles <= maxDetourMiles { let candidate = GameCandidate( id: game.id, game: game, stadium: stadium, homeTeam: homeTeam, awayTeam: awayTeam, detourDistance: minDistance, score: 50.0 - distanceMiles // Score inversely proportional to detour ) candidates.append(candidate) } } return candidates.sorted { $0.score > $1.score } } // MARK: - Validate Games for Scenarios /// Validates that all must-see games are within the date range. /// Used for Scenario B validation. func validateMustSeeGamesInRange( mustSeeGameIds: Set, allGames: [Game], dateRange: ClosedRange ) -> (valid: Bool, outOfRange: [UUID]) { var outOfRange: [UUID] = [] for gameId in mustSeeGameIds { guard let game = allGames.first(where: { $0.id == gameId }) else { continue } if !dateRange.contains(game.dateTime) { outOfRange.append(gameId) } } return (outOfRange.isEmpty, outOfRange) } } // MARK: - Route Corridor Calculator struct RouteCorridorCalculator { let start: CLLocationCoordinate2D let end: CLLocationCoordinate2D let maxDetourFactor: Double var directDistance: CLLocationDistance { CLLocation(latitude: start.latitude, longitude: start.longitude) .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) } var maxDetourDistance: CLLocationDistance { directDistance * (maxDetourFactor - 1.0) } func detourDistance(to point: CLLocationCoordinate2D) -> CLLocationDistance { let startToPoint = CLLocation(latitude: start.latitude, longitude: start.longitude) .distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude)) let pointToEnd = CLLocation(latitude: point.latitude, longitude: point.longitude) .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) let totalViaPoint = startToPoint + pointToEnd return max(0, totalViaPoint - directDistance) } func isWithinCorridor(_ point: CLLocationCoordinate2D) -> Bool { detourDistance(to: point) <= maxDetourDistance } /// Returns the distance from a point to the end location. func distanceToEnd(from point: CLLocationCoordinate2D) -> CLLocationDistance { CLLocation(latitude: point.latitude, longitude: point.longitude) .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) } /// Calculates progress along the route (0 = at start, 1 = at end). /// Can be negative (behind start) or > 1 (past end). func progressAlongRoute(point: CLLocationCoordinate2D) -> Double { guard directDistance > 0 else { return 0 } let distFromStart = CLLocation(latitude: start.latitude, longitude: start.longitude) .distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude)) let distFromEnd = CLLocation(latitude: point.latitude, longitude: point.longitude) .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) // Use the law of cosines to project onto the line // progress = (d_start² + d_total² - d_end²) / (2 * d_total²) let dStart = distFromStart let dEnd = distFromEnd let dTotal = directDistance let numerator = (dStart * dStart) + (dTotal * dTotal) - (dEnd * dEnd) let denominator = 2 * dTotal * dTotal return numerator / denominator } }