// // ScenarioBPlanner.swift // SportsTime // // Scenario B: Selected games planning. // User selects specific games they MUST see. Those are fixed anchors that cannot be removed. // // Key Features: // - Selected games are "anchors" - they MUST appear in every valid route // - Sliding window logic when only trip duration (no specific dates) is provided // - Additional games from date range can be added if they fit geographically // // Sliding Window Algorithm: // When user provides selected games + day span (e.g., 10 days) without specific dates: // 1. Find first and last selected game dates // 2. Generate all possible windows of the given duration that contain ALL selected games // 3. Window 1: Last selected game is on last day // 4. Window N: First selected game is on first day // 5. Explore routes for each window, return best options // // Example: // Selected games on Jan 5, Jan 8, Jan 12. Day span = 10 days. // - Window 1: Jan 3-12 (Jan 12 is last day) // - Window 2: Jan 4-13 // - Window 3: Jan 5-14 (Jan 5 is first day) // For each window, find all games and explore routes with selected as anchors. // import Foundation import CoreLocation /// Scenario B: Selected games planning /// Input: selected_games, date_range (or trip_duration), optional must_stop /// Output: Itinerary options connecting all selected games with possible bonus games final class ScenarioBPlanner: ScenarioPlanner { // MARK: - ScenarioPlanner Protocol func plan(request: PlanningRequest) -> ItineraryResult { let selectedGames = request.selectedGames // ────────────────────────────────────────────────────────────────── // Step 1: Validate selected games exist // ────────────────────────────────────────────────────────────────── if selectedGames.isEmpty { return .failure( PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: .selectedGames, description: "No games selected", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 2: Generate date ranges (sliding window or single range) // ────────────────────────────────────────────────────────────────── let dateRanges = generateDateRanges( selectedGames: selectedGames, request: request ) if dateRanges.isEmpty { return .failure( PlanningFailure( reason: .missingDateRange, violations: [ ConstraintViolation( type: .dateRange, description: "Cannot determine valid date range for selected games", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 3: For each date range, find routes with anchors // ────────────────────────────────────────────────────────────────── let anchorGameIds = Set(selectedGames.map { $0.id }) var allItineraryOptions: [ItineraryOption] = [] for dateRange in dateRanges { // Find all games in this date range let gamesInRange = request.allGames .filter { dateRange.contains($0.startTime) } .sorted { $0.startTime < $1.startTime } // Skip if no games (shouldn't happen if date range is valid) guard !gamesInRange.isEmpty else { continue } // Verify all selected games are in range let selectedInRange = selectedGames.allSatisfy { game in dateRange.contains(game.startTime) } guard selectedInRange else { continue } // Find all sensible routes that include the anchor games let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes( from: gamesInRange, stadiums: request.stadiums, anchorGameIds: anchorGameIds, stopBuilder: buildStops ) // Build itineraries for each valid route for routeGames in validRoutes { let stops = buildStops(from: routeGames, stadiums: request.stadiums) guard !stops.isEmpty else { continue } // Use shared ItineraryBuilder with arrival time validator guard let itinerary = ItineraryBuilder.build( stops: stops, constraints: request.drivingConstraints, logPrefix: "[ScenarioB]", segmentValidator: ItineraryBuilder.arrivalBeforeGameStart() ) else { continue } let selectedCount = routeGames.filter { anchorGameIds.contains($0.id) }.count let bonusCount = routeGames.count - selectedCount let cities = stops.map { $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: "\(selectedCount) selected + \(bonusCount) bonus games: \(cities)" ) allItineraryOptions.append(option) } } // ────────────────────────────────────────────────────────────────── // Step 4: Return ranked results // ────────────────────────────────────────────────────────────────── if allItineraryOptions.isEmpty { return .failure( PlanningFailure( reason: .constraintsUnsatisfiable, violations: [ ConstraintViolation( type: .geographicSanity, description: "Cannot create a geographically sensible route connecting selected games", severity: .error ) ] ) ) } // Sort by total games (most first), then by driving hours (less first) let sorted = allItineraryOptions.sorted { a, b in if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count { return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count } return a.totalDrivingHours < b.totalDrivingHours } // Re-rank and limit let rankedOptions = sorted.prefix(10).enumerated().map { index, option in ItineraryOption( rank: index + 1, stops: option.stops, travelSegments: option.travelSegments, totalDrivingHours: option.totalDrivingHours, totalDistanceMiles: option.totalDistanceMiles, geographicRationale: option.geographicRationale ) } print("[ScenarioB] Returning \(rankedOptions.count) itinerary options") return .success(Array(rankedOptions)) } // MARK: - Date Range Generation (Sliding Window) /// Generates all valid date ranges for the selected games. /// /// Two modes: /// 1. If explicit date range provided: Use it directly (validate selected games fit) /// 2. If only trip duration provided: Generate sliding windows /// /// Sliding Window Logic: /// Selected games: Jan 5, Jan 8, Jan 12. Duration: 10 days. /// - Window must contain all selected games /// - First window: ends on last selected game date (Jan 3-12) /// - Slide forward one day at a time /// - Last window: starts on first selected game date (Jan 5-14) /// private func generateDateRanges( selectedGames: [Game], request: PlanningRequest ) -> [DateInterval] { // If explicit date range exists, use it if let dateRange = request.dateRange { return [dateRange] } // Otherwise, use trip duration to create sliding windows let duration = request.preferences.effectiveTripDuration guard duration > 0 else { return [] } // Find the span of selected games let sortedGames = selectedGames.sorted { $0.startTime < $1.startTime } guard let firstGame = sortedGames.first, let lastGame = sortedGames.last else { return [] } let firstGameDate = Calendar.current.startOfDay(for: firstGame.startTime) let lastGameDate = Calendar.current.startOfDay(for: lastGame.startTime) // Calculate how many days the selected games span let gameSpanDays = Calendar.current.dateComponents( [.day], from: firstGameDate, to: lastGameDate ).day ?? 0 // If selected games span more days than trip duration, can't fit if gameSpanDays >= duration { // Just return one window that exactly covers the games let start = firstGameDate let end = Calendar.current.date( byAdding: .day, value: gameSpanDays + 1, to: start ) ?? lastGameDate return [DateInterval(start: start, end: end)] } // Generate sliding windows var dateRanges: [DateInterval] = [] // First window: last selected game is on last day of window // Window end = lastGameDate + 1 day (to include the game) // Window start = end - duration days let firstWindowEnd = Calendar.current.date( byAdding: .day, value: 1, to: lastGameDate )! let firstWindowStart = Calendar.current.date( byAdding: .day, value: -duration, to: firstWindowEnd )! // Last window: first selected game is on first day of window // Window start = firstGameDate // Window end = start + duration days let lastWindowStart = firstGameDate let lastWindowEnd = Calendar.current.date( byAdding: .day, value: duration, to: lastWindowStart )! // Slide from first window to last window var currentStart = firstWindowStart while currentStart <= lastWindowStart { let windowEnd = Calendar.current.date( byAdding: .day, value: duration, to: currentStart )! let window = DateInterval(start: currentStart, end: windowEnd) dateRanges.append(window) // Slide forward one day currentStart = Calendar.current.date( byAdding: .day, value: 1, to: currentStart )! } print("[ScenarioB] Generated \(dateRanges.count) sliding windows for \(duration)-day trip") return dateRanges } // MARK: - Stop Building /// Converts a list of games into itinerary stops. /// Groups games by stadium, creates one stop per unique stadium. private func buildStops( from games: [Game], stadiums: [UUID: Stadium] ) -> [ItineraryStop] { // Group games by stadium var stadiumGames: [UUID: [Game]] = [:] for game in games { stadiumGames[game.stadiumId, default: []].append(game) } // Create stops in chronological order (first game at each stadium) var stops: [ItineraryStop] = [] var processedStadiums: Set = [] for game in games { guard !processedStadiums.contains(game.stadiumId) else { continue } processedStadiums.insert(game.stadiumId) let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game] let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime } let stadium = stadiums[game.stadiumId] let city = stadium?.city ?? "Unknown" let state = stadium?.state ?? "" let coordinate = stadium?.coordinate let location = LocationInput( name: city, coordinate: coordinate, address: stadium?.fullAddress ) let stop = ItineraryStop( city: city, state: state, coordinate: coordinate, games: sortedGames.map { $0.id }, arrivalDate: sortedGames.first?.gameDate ?? Date(), departureDate: sortedGames.last?.gameDate ?? Date(), location: location, firstGameStart: sortedGames.first?.startTime ) stops.append(stop) } return stops } }