// // ScenarioEPlanner.swift // SportsTime // // Scenario E: Team-First planning. // User selects teams (not dates), we find optimal trip windows across the season. // // Key Features: // - Selected teams determine which home games to visit // - Sliding window logic generates all N-day windows across the season // - Windows are filtered to those where ALL selected teams have home games // - Routes are built and ranked by duration + miles // // Sliding Window Algorithm: // 1. Fetch all home games for selected teams (full season) // 2. Generate all N-day windows (N = selectedTeamIds.count * 2) // 3. Filter to windows where each selected team has at least 1 home game // 4. Cap at 50 windows (sample if more exist) // 5. For each valid window, find routes using GameDAGRouter with team games as anchors // 6. Rank by shortest duration + minimal miles // 7. Return top 10 results // // Example: // User selects: Yankees, Red Sox, Phillies // Window duration: 3 * 2 = 6 days // Valid windows: Any 6-day span where all 3 teams have a home game // Output: Top 10 trip options, ranked by efficiency // import Foundation import CoreLocation /// Scenario E: Team-First planning /// Input: selectedTeamIds, sports (for fetching games) /// Output: Top 10 trip windows with routes visiting all selected teams' home stadiums /// /// - Expected Behavior: /// - No selected teams → returns .failure with .missingTeamSelection /// - Fewer than 2 teams → returns .failure with .missingTeamSelection /// - No home games found → returns .failure with .noGamesInRange /// - No valid windows (no overlap) → returns .failure with .noValidRoutes /// - Each returned route visits ALL selected teams' home stadiums /// - Routes ranked by duration + miles (shorter is better) /// - Returns maximum of 10 options /// /// - Invariants: /// - All routes contain at least one home game per selected team /// - Window duration = selectedTeamIds.count * 2 days /// - Maximum 50 windows evaluated (sampled if more exist) /// - Maximum 10 results returned /// final class ScenarioEPlanner: ScenarioPlanner { // MARK: - Configuration /// Maximum number of windows to evaluate (to prevent combinatorial explosion) private let maxWindowsToEvaluate = 50 /// Maximum number of results to return private let maxResultsToReturn = 10 // MARK: - ScenarioPlanner Protocol func plan(request: PlanningRequest) -> ItineraryResult { // ────────────────────────────────────────────────────────────────── // Step 1: Validate team selection // ────────────────────────────────────────────────────────────────── let selectedTeamIds = request.preferences.selectedTeamIds if selectedTeamIds.count < 2 { return .failure( PlanningFailure( reason: .missingTeamSelection, violations: [ ConstraintViolation( type: .general, description: "Team-First mode requires at least 2 teams selected", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 2: Collect all HOME games for selected teams // ────────────────────────────────────────────────────────────────── // For Team-First mode, we only care about home games because // the user wants to visit each team's home stadium. var homeGamesByTeam: [String: [Game]] = [:] var allHomeGames: [Game] = [] for game in request.allGames { if selectedTeamIds.contains(game.homeTeamId) { homeGamesByTeam[game.homeTeamId, default: []].append(game) allHomeGames.append(game) } } // Verify each team has at least one home game for teamId in selectedTeamIds { if homeGamesByTeam[teamId]?.isEmpty ?? true { let teamName = request.teams[teamId]?.fullName ?? teamId return .failure( PlanningFailure( reason: .noGamesInRange, violations: [ ConstraintViolation( type: .selectedGames, description: "No home games found for \(teamName)", severity: .error ) ] ) ) } } print("🔍 ScenarioE Step 2: Found \(allHomeGames.count) total home games across \(selectedTeamIds.count) teams") for (teamId, games) in homeGamesByTeam { let teamName = request.teams[teamId]?.fullName ?? teamId print("🔍 \(teamName): \(games.count) home games") } // ────────────────────────────────────────────────────────────────── // Step 3: Generate sliding windows across the season // ────────────────────────────────────────────────────────────────── let windowDuration = request.preferences.teamFirstMaxDays print("🔍 ScenarioE Step 3: Window duration = \(windowDuration) days") let validWindows = generateValidWindows( homeGamesByTeam: homeGamesByTeam, windowDurationDays: windowDuration, selectedTeamIds: selectedTeamIds ) if validWindows.isEmpty { return .failure( PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: .dateRange, description: "No \(windowDuration)-day windows found where all selected teams have home games", severity: .error ) ] ) ) } print("🔍 ScenarioE Step 3: Found \(validWindows.count) valid windows") // Sample windows if too many exist let windowsToEvaluate: [DateInterval] if validWindows.count > maxWindowsToEvaluate { windowsToEvaluate = sampleWindows(validWindows, count: maxWindowsToEvaluate) print("🔍 ScenarioE Step 3: Sampled down to \(windowsToEvaluate.count) windows") } else { windowsToEvaluate = validWindows } // ────────────────────────────────────────────────────────────────── // Step 4: For each valid window, find routes // ────────────────────────────────────────────────────────────────── var allItineraryOptions: [ItineraryOption] = [] for (windowIndex, window) in windowsToEvaluate.enumerated() { // Collect games in this window // Use one home game per team as anchors (the best one for route efficiency) var gamesInWindow: [Game] = [] var anchorGameIds = Set() for teamId in selectedTeamIds { guard let teamGames = homeGamesByTeam[teamId] else { continue } // Get all home games for this team in the window let teamGamesInWindow = teamGames.filter { window.contains($0.startTime) } if teamGamesInWindow.isEmpty { // Window doesn't have a game for this team - skip this window // This shouldn't happen since we pre-filtered windows continue } // Add all games to the pool gamesInWindow.append(contentsOf: teamGamesInWindow) // Mark the earliest game as anchor (must visit this team) if let earliestGame = teamGamesInWindow.sorted(by: { $0.startTime < $1.startTime }).first { anchorGameIds.insert(earliestGame.id) } } // Skip if we don't have anchors for all teams guard anchorGameIds.count == selectedTeamIds.count else { continue } // Remove duplicate games (same game could be added multiple times if team plays multiple home games) let uniqueGames = Array(Set(gamesInWindow)).sorted { $0.startTime < $1.startTime } // Find routes using GameDAGRouter with anchor games let validRoutes = GameDAGRouter.findAllSensibleRoutes( from: uniqueGames, stadiums: request.stadiums, anchorGameIds: anchorGameIds, allowRepeatCities: request.preferences.allowRepeatCities, stopBuilder: buildStops ) // Build itineraries for valid routes 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: "[ScenarioE]", segmentValidator: ItineraryBuilder.arrivalBeforeGameStart() ) else { continue } // Format window dates for rationale let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMM d" let windowDesc = "\(dateFormatter.string(from: window.start)) - \(dateFormatter.string(from: window.end))" let teamsVisited = routeGames.compactMap { game -> String? in if anchorGameIds.contains(game.id) { return request.teams[game.homeTeamId]?.abbreviation ?? game.homeTeamId } return nil }.joined(separator: ", ") 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: "\(windowDesc): \(teamsVisited) - \(cities)" ) allItineraryOptions.append(option) } // Early exit if we have enough options if allItineraryOptions.count >= maxResultsToReturn * 5 { print("🔍 ScenarioE: Early exit at window \(windowIndex + 1) with \(allItineraryOptions.count) options") break } } // ────────────────────────────────────────────────────────────────── // Step 5: Rank and return top results // ────────────────────────────────────────────────────────────────── if allItineraryOptions.isEmpty { return .failure( PlanningFailure( reason: .constraintsUnsatisfiable, violations: [ ConstraintViolation( type: .geographicSanity, description: "No routes found that can visit all selected teams within driving constraints", severity: .error ) ] ) ) } // Deduplicate options (same stops in same order) let uniqueOptions = deduplicateOptions(allItineraryOptions) // Sort by: shortest duration first, then fewest miles let sortedOptions = uniqueOptions.sorted { optionA, optionB in // Calculate trip duration in days let daysA = tripDurationDays(for: optionA) let daysB = tripDurationDays(for: optionB) if daysA != daysB { return daysA < daysB // Shorter trips first } return optionA.totalDistanceMiles < optionB.totalDistanceMiles // Fewer miles second } // Take top N and re-rank let topOptions = Array(sortedOptions.prefix(maxResultsToReturn)) let rankedOptions = topOptions.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("🔍 ScenarioE: Returning \(rankedOptions.count) options") return .success(rankedOptions) } // MARK: - Window Generation /// Generates all valid N-day windows across the season where ALL selected teams have home games. /// /// Algorithm: /// 1. Find the overall date range (earliest to latest game across all teams) /// 2. Slide a window across this range, one day at a time /// 3. Keep windows where each team has at least one home game /// private func generateValidWindows( homeGamesByTeam: [String: [Game]], windowDurationDays: Int, selectedTeamIds: Set ) -> [DateInterval] { // Find overall date range let allGames = homeGamesByTeam.values.flatMap { $0 } guard let earliest = allGames.map({ $0.startTime }).min(), let latest = allGames.map({ $0.startTime }).max() else { return [] } let calendar = Calendar.current let earliestDay = calendar.startOfDay(for: earliest) let latestDay = calendar.startOfDay(for: latest) // Generate sliding windows var validWindows: [DateInterval] = [] var currentStart = earliestDay while let windowEnd = calendar.date(byAdding: .day, value: windowDurationDays, to: currentStart), windowEnd <= calendar.date(byAdding: .day, value: 1, to: latestDay)! { let window = DateInterval(start: currentStart, end: windowEnd) // Check if all teams have at least one home game in this window let allTeamsHaveGame = selectedTeamIds.allSatisfy { teamId in guard let teamGames = homeGamesByTeam[teamId] else { return false } return teamGames.contains { window.contains($0.startTime) } } if allTeamsHaveGame { validWindows.append(window) } // Slide window by one day guard let nextStart = calendar.date(byAdding: .day, value: 1, to: currentStart) else { break } currentStart = nextStart } return validWindows } /// Samples windows evenly across the season to avoid clustering. private func sampleWindows(_ windows: [DateInterval], count: Int) -> [DateInterval] { guard windows.count > count else { return windows } // Take evenly spaced samples var sampled: [DateInterval] = [] let step = Double(windows.count) / Double(count) for i in 0.. [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: String? = 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: String, stadiums: [String: 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 same day as last game let lastGameDate = sortedGames.last?.gameDate ?? Date() return ItineraryStop( city: city, state: state, coordinate: coordinate, games: sortedGames.map { $0.id }, arrivalDate: sortedGames.first?.gameDate ?? Date(), departureDate: lastGameDate, location: location, firstGameStart: sortedGames.first?.startTime ) } // MARK: - Deduplication /// Removes duplicate itinerary options (same stops in same order). private func deduplicateOptions(_ options: [ItineraryOption]) -> [ItineraryOption] { var seen = Set() var unique: [ItineraryOption] = [] for option in options { // Create key from stop cities in order let key = option.stops.map { $0.city }.joined(separator: "-") if !seen.contains(key) { seen.insert(key) unique.append(option) } } return unique } // MARK: - Trip Duration Calculation /// Calculates trip duration in days for an itinerary option. private func tripDurationDays(for option: ItineraryOption) -> Int { guard let firstStop = option.stops.first, let lastStop = option.stops.last else { return 0 } let calendar = Calendar.current let days = calendar.dateComponents( [.day], from: calendar.startOfDay(for: firstStop.arrivalDate), to: calendar.startOfDay(for: lastStop.departureDate) ).day ?? 0 return max(1, days + 1) } }