// // ScenarioCPlanner.swift // SportsTime // // Scenario C: Start + End location planning. // User specifies starting city and ending city. We find games along the route. // // Key Features: // - Start/End are cities with stadiums from user's selected sports // - Directional filtering: stadiums that "generally move toward" the end // - When only day span provided (no dates): generate date ranges from games at start/end // - Uses GeographicRouteExplorer for sensible route exploration // - Returns top 5 options with most games // // Date Range Generation (when only day span provided): // 1. Find all games at start city's stadiums // 2. Find all games at end city's stadiums // 3. For each start game + end game combo within day span: create a date range // 4. Explore routes for each date range // // Directional Filtering: // - Find stadiums that make forward progress from start to end // - "Forward progress" = distance to end decreases (with tolerance) // - Filter games to only those at directional stadiums // // Example: // Start: Chicago, End: New York, Day span: 7 days // Start game: Jan 5 at Chicago // End game: Jan 10 at New York // Date range: Jan 5-10 // Directional stadiums: Detroit, Cleveland, Pittsburgh (moving east) // NOT directional: Minneapolis, St. Louis (moving away from NY) // import Foundation import CoreLocation /// Scenario C: Directional route planning from start city to end city /// Input: start_location, end_location, day_span (or date_range) /// Output: Top 5 itinerary options with games along the directional route final class ScenarioCPlanner: ScenarioPlanner { // MARK: - Configuration /// Tolerance for "forward progress" - allow small increases in distance to end /// A stadium is "directional" if it doesn't increase distance to end by more than this ratio private let forwardProgressTolerance = 0.15 // 15% tolerance // MARK: - ScenarioPlanner Protocol func plan(request: PlanningRequest) -> ItineraryResult { // ────────────────────────────────────────────────────────────────── // Step 1: Validate start and end locations // ────────────────────────────────────────────────────────────────── guard let startLocation = request.startLocation else { return .failure( PlanningFailure( reason: .missingLocations, violations: [ ConstraintViolation( type: .general, description: "Start location is required for Scenario C", severity: .error ) ] ) ) } guard let endLocation = request.endLocation else { return .failure( PlanningFailure( reason: .missingLocations, violations: [ ConstraintViolation( type: .general, description: "End location is required for Scenario C", severity: .error ) ] ) ) } guard let startCoord = startLocation.coordinate, let endCoord = endLocation.coordinate else { return .failure( PlanningFailure( reason: .missingLocations, violations: [ ConstraintViolation( type: .general, description: "Start and end locations must have coordinates", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 2: Find stadiums at start and end cities // ────────────────────────────────────────────────────────────────── let startStadiums = findStadiumsInCity( cityName: startLocation.name, stadiums: request.stadiums ) let endStadiums = findStadiumsInCity( cityName: endLocation.name, stadiums: request.stadiums ) if startStadiums.isEmpty { return .failure( PlanningFailure( reason: .noGamesInRange, violations: [ ConstraintViolation( type: .general, description: "No stadiums found in start city: \(startLocation.name)", severity: .error ) ] ) ) } if endStadiums.isEmpty { return .failure( PlanningFailure( reason: .noGamesInRange, violations: [ ConstraintViolation( type: .general, description: "No stadiums found in end city: \(endLocation.name)", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 3: Generate date ranges // ────────────────────────────────────────────────────────────────── let dateRanges = generateDateRanges( startStadiumIds: Set(startStadiums.map { $0.id }), endStadiumIds: Set(endStadiums.map { $0.id }), allGames: request.allGames, request: request ) if dateRanges.isEmpty { return .failure( PlanningFailure( reason: .missingDateRange, violations: [ ConstraintViolation( type: .dateRange, description: "No valid date ranges found with games at both start and end cities", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 4: Find directional stadiums (moving from start toward end) // ────────────────────────────────────────────────────────────────── let directionalStadiums = findDirectionalStadiums( from: startCoord, to: endCoord, stadiums: request.stadiums ) // ────────────────────────────────────────────────────────────────── // Step 5: For each date range, explore routes // ────────────────────────────────────────────────────────────────── var allItineraryOptions: [ItineraryOption] = [] for dateRange in dateRanges { // Find games at directional stadiums within date range let gamesInRange = request.allGames .filter { dateRange.contains($0.startTime) } .filter { directionalStadiums.contains($0.stadiumId) } .sorted { $0.startTime < $1.startTime } guard !gamesInRange.isEmpty else { continue } // Use GameDAGRouter for polynomial-time beam search let validRoutes = GameDAGRouter.findAllSensibleRoutes( from: gamesInRange, stadiums: request.stadiums, anchorGameIds: [], // No anchors in Scenario C stopBuilder: buildStops ) // Build itineraries for each valid route for routeGames in validRoutes { let stops = buildStopsWithEndpoints( start: startLocation, end: endLocation, games: routeGames, stadiums: request.stadiums ) guard !stops.isEmpty else { continue } // Validate monotonic progress toward end guard validateMonotonicProgress( stops: stops, toward: endCoord ) else { continue } // Use shared ItineraryBuilder guard let itinerary = ItineraryBuilder.build( stops: stops, constraints: request.drivingConstraints, logPrefix: "[ScenarioC]" ) else { continue } let gameCount = routeGames.count let cities = stops.compactMap { $0.games.isEmpty ? nil : $0.city }.joined(separator: " → ") let option = ItineraryOption( rank: 0, // Will re-rank later stops: itinerary.stops, travelSegments: itinerary.travelSegments, totalDrivingHours: itinerary.totalDrivingHours, totalDistanceMiles: itinerary.totalDistanceMiles, geographicRationale: "\(startLocation.name) → \(gameCount) games → \(endLocation.name): \(cities)" ) allItineraryOptions.append(option) } } // ────────────────────────────────────────────────────────────────── // Step 6: Return top 5 ranked results // ────────────────────────────────────────────────────────────────── if allItineraryOptions.isEmpty { return .failure( PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: .geographicSanity, description: "No valid directional routes found from \(startLocation.name) to \(endLocation.name)", severity: .error ) ] ) ) } // Sort and rank based on leisure level let leisureLevel = request.preferences.leisureLevel let rankedOptions = ItineraryOption.sortByLeisure( allItineraryOptions, leisureLevel: leisureLevel ) return .success(Array(rankedOptions)) } // MARK: - Stadium Finding /// Finds all stadiums in a given city (case-insensitive match). private func findStadiumsInCity( cityName: String, stadiums: [UUID: Stadium] ) -> [Stadium] { let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces) return stadiums.values.filter { stadium in stadium.city.lowercased().trimmingCharacters(in: .whitespaces) == normalizedCity } } /// Finds stadiums that make forward progress from start to end. /// /// A stadium is "directional" if visiting it doesn't significantly increase /// the distance to the end point. This filters out stadiums that would /// require backtracking. /// /// Algorithm: /// 1. Calculate distance from start to end /// 2. For each stadium, calculate: distance(start, stadium) + distance(stadium, end) /// 3. If this "detour distance" is reasonable (within tolerance), include it /// /// The tolerance allows for stadiums slightly off the direct path. /// private func findDirectionalStadiums( from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D, stadiums: [UUID: Stadium] ) -> Set { let directDistance = distanceBetween(start, end) // Allow detours up to 50% longer than direct distance let maxDetourDistance = directDistance * 1.5 var directionalIds: Set = [] for (id, stadium) in stadiums { let stadiumCoord = stadium.coordinate // Calculate the detour: start → stadium → end let toStadium = distanceBetween(start, stadiumCoord) let fromStadium = distanceBetween(stadiumCoord, end) let detourDistance = toStadium + fromStadium // Also check that stadium is making progress (closer to end than start is) let distanceToEnd = distanceBetween(stadiumCoord, end) // Stadium should be within the "cone" from start to end // Either closer to end than start, or the detour is acceptable if detourDistance <= maxDetourDistance { // Additional check: don't include if it's behind the start point // (i.e., distance to end is greater than original distance) if distanceToEnd <= directDistance * (1 + forwardProgressTolerance) { // Final check: exclude stadiums that are beyond the end point // A stadium is beyond the end if it's farther from start than end is if toStadium <= directDistance * (1 + forwardProgressTolerance) { directionalIds.insert(id) } } } } return directionalIds } // MARK: - Date Range Generation /// Generates date ranges for Scenario C. /// /// Two modes: /// 1. Explicit date range provided: Use it directly /// 2. Only day span provided: Find game combinations at start/end cities /// /// For mode 2: /// - Find all games at start city stadiums /// - Find all games at end city stadiums /// - For each (start_game, end_game) pair where end_game - start_game <= day_span: /// Create a date range from start_game.date to end_game.date /// private func generateDateRanges( startStadiumIds: Set, endStadiumIds: Set, allGames: [Game], request: PlanningRequest ) -> [DateInterval] { // If explicit date range exists, use it if let dateRange = request.dateRange { return [dateRange] } // Otherwise, use day span to find valid combinations let daySpan = request.preferences.effectiveTripDuration guard daySpan > 0 else { return [] } // Find games at start and end cities let startGames = allGames .filter { startStadiumIds.contains($0.stadiumId) } .sorted { $0.startTime < $1.startTime } let endGames = allGames .filter { endStadiumIds.contains($0.stadiumId) } .sorted { $0.startTime < $1.startTime } if startGames.isEmpty || endGames.isEmpty { return [] } // Generate all valid (start_game, end_game) combinations var dateRanges: [DateInterval] = [] let calendar = Calendar.current for startGame in startGames { let startDate = calendar.startOfDay(for: startGame.startTime) for endGame in endGames { let endDate = calendar.startOfDay(for: endGame.startTime) // End must be after start guard endDate >= startDate else { continue } // Calculate days between let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0 // Must be within day span guard daysBetween < daySpan else { continue } // Create date range (end date + 1 day to include the end game) let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate let range = DateInterval(start: startDate, end: rangeEnd) // Avoid duplicate ranges if !dateRanges.contains(where: { $0.start == range.start && $0.end == range.end }) { dateRanges.append(range) } } } return dateRanges } // MARK: - Stop Building /// Converts games to stops (used by GeographicRouteExplorer callback). /// Groups consecutive games at the same stadium into one stop. /// Creates separate stops when visiting the same city with other cities in between. private func buildStops( from games: [Game], stadiums: [UUID: Stadium] ) -> [ItineraryStop] { guard !games.isEmpty else { return [] } // Sort games chronologically let sortedGames = games.sorted { $0.startTime < $1.startTime } // Group consecutive games at the same stadium var stops: [ItineraryStop] = [] var currentStadiumId: UUID? = nil var currentGames: [Game] = [] for game in sortedGames { if game.stadiumId == currentStadiumId { // Same stadium as previous game - add to current group currentGames.append(game) } else { // Different stadium - finalize previous stop (if any) and start new one if let stadiumId = currentStadiumId, !currentGames.isEmpty { if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) { stops.append(stop) } } currentStadiumId = game.stadiumId currentGames = [game] } } // Don't forget the last group if let stadiumId = currentStadiumId, !currentGames.isEmpty { if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) { stops.append(stop) } } return stops } /// Creates an ItineraryStop from a group of games at the same stadium. private func createStop( from games: [Game], stadiumId: UUID, stadiums: [UUID: Stadium] ) -> ItineraryStop? { guard !games.isEmpty else { return nil } let sortedGames = games.sorted { $0.startTime < $1.startTime } let stadium = stadiums[stadiumId] let city = stadium?.city ?? "Unknown" let state = stadium?.state ?? "" let coordinate = stadium?.coordinate let location = LocationInput( name: city, coordinate: coordinate, address: stadium?.fullAddress ) // departureDate is day AFTER last game (we leave the next morning) let lastGameDate = sortedGames.last?.gameDate ?? Date() let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate return ItineraryStop( city: city, state: state, coordinate: coordinate, games: sortedGames.map { $0.id }, arrivalDate: sortedGames.first?.gameDate ?? Date(), departureDate: departureDateValue, location: location, firstGameStart: sortedGames.first?.startTime ) } /// Builds stops with start and end location endpoints. private func buildStopsWithEndpoints( start: LocationInput, end: LocationInput, games: [Game], stadiums: [UUID: Stadium] ) -> [ItineraryStop] { var stops: [ItineraryStop] = [] // Start stop (no games) let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date() let startStop = ItineraryStop( city: start.name, state: "", coordinate: start.coordinate, games: [], arrivalDate: startArrival, departureDate: startArrival, location: start, firstGameStart: nil ) stops.append(startStop) // Game stops let gameStops = buildStops(from: games, stadiums: stadiums) stops.append(contentsOf: gameStops) // End stop (no games) let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date() let endStop = ItineraryStop( city: end.name, state: "", coordinate: end.coordinate, games: [], arrivalDate: endArrival, departureDate: endArrival, location: end, firstGameStart: nil ) stops.append(endStop) return stops } // MARK: - Monotonic Progress Validation /// Validates that the route makes generally forward progress toward the end. /// /// Each stop should be closer to (or not significantly farther from) the end /// than the previous stop. Small detours are allowed within tolerance. /// private func validateMonotonicProgress( stops: [ItineraryStop], toward end: CLLocationCoordinate2D ) -> Bool { var previousDistance: Double? for stop in stops { guard let stopCoord = stop.coordinate else { continue } let currentDistance = distanceBetween(stopCoord, end) if let prev = previousDistance { // Allow increases up to tolerance percentage let allowedIncrease = prev * forwardProgressTolerance if currentDistance > prev + allowedIncrease { return false } } previousDistance = currentDistance } return true } // MARK: - Geometry Helpers /// Distance between two coordinates in miles using Haversine formula. private func distanceBetween( _ coord1: CLLocationCoordinate2D, _ coord2: CLLocationCoordinate2D ) -> Double { let earthRadiusMiles = 3958.8 let lat1 = coord1.latitude * .pi / 180 let lat2 = coord2.latitude * .pi / 180 let deltaLat = (coord2.latitude - coord1.latitude) * .pi / 180 let deltaLon = (coord2.longitude - coord1.longitude) * .pi / 180 let a = sin(deltaLat / 2) * sin(deltaLat / 2) + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) let c = 2 * atan2(sqrt(a), sqrt(1 - a)) return earthRadiusMiles * c } }