// // ScenarioAPlanner.swift // SportsTime // // Scenario A: Date range only planning. // User provides a date range, we find all games and build chronological routes. // import Foundation import CoreLocation /// Scenario A: Date range planning /// /// This is the simplest scenario - user just picks a date range and we find games. /// /// Input: /// - date_range: Required. The trip dates (e.g., Jan 5-15) /// - must_stop: Optional. A location they must visit (not yet implemented) /// /// Output: /// - Success: Ranked list of itinerary options /// - Failure: Explicit error with reason (no games, dates invalid, etc.) /// /// Example: /// User selects Jan 5-10, 2026 /// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9) /// Output: Single itinerary visiting LA → SF → Sacramento in order /// final class ScenarioAPlanner: ScenarioPlanner { // MARK: - ScenarioPlanner Protocol /// Main entry point for Scenario A planning. /// /// Flow: /// 1. Validate inputs (date range must exist) /// 2. Find all games within the date range /// 3. Convert games to stops (grouping by stadium) /// 4. Calculate travel between stops /// 5. Return the complete itinerary /// /// Failure cases: /// - No date range provided → .missingDateRange /// - No games in date range → .noGamesInRange /// - Can't build valid route → .constraintsUnsatisfiable /// func plan(request: PlanningRequest) -> ItineraryResult { // ────────────────────────────────────────────────────────────────── // Step 1: Validate date range exists // ────────────────────────────────────────────────────────────────── // Scenario A requires a date range. Without it, we can't filter games. guard let dateRange = request.dateRange else { return .failure( PlanningFailure( reason: .missingDateRange, violations: [] ) ) } // ────────────────────────────────────────────────────────────────── // Step 2: Filter games within date range // ────────────────────────────────────────────────────────────────── // Get all games that fall within the user's travel dates. // Sort by start time so we visit them in chronological order. let gamesInRange = request.allGames .filter { dateRange.contains($0.startTime) } .sorted { $0.startTime < $1.startTime } // No games? Nothing to plan. if gamesInRange.isEmpty { return .failure( PlanningFailure( reason: .noGamesInRange, violations: [] ) ) } // ────────────────────────────────────────────────────────────────── // Step 3: Find ALL geographically sensible route variations // ────────────────────────────────────────────────────────────────── // Not all games in the date range may form a sensible route. // Example: NY (Jan 5), TX (Jan 6), SC (Jan 7), CA (Jan 8) // - Including all = zig-zag nightmare // - Option 1: NY, TX, DEN, NM, CA (skip SC) // - Option 2: NY, SC, DEN, NM, CA (skip TX) // - etc. // // We explore ALL valid combinations and return multiple options. // Uses shared GeographicRouteExplorer for tree exploration. // let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes( from: gamesInRange, stadiums: request.stadiums, stopBuilder: buildStops ) if validRoutes.isEmpty { return .failure( PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: .geographicSanity, description: "No geographically sensible route found for games in this date range", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 4: Build itineraries for each valid route // ────────────────────────────────────────────────────────────────── // For each valid game combination, build stops and calculate travel. // Some routes may fail driving constraints - filter those out. // var itineraryOptions: [ItineraryOption] = [] for (index, routeGames) in validRoutes.enumerated() { // Build stops for this route let stops = buildStops(from: routeGames, stadiums: request.stadiums) guard !stops.isEmpty else { continue } // Calculate travel segments using shared ItineraryBuilder guard let itinerary = ItineraryBuilder.build( stops: stops, constraints: request.drivingConstraints, logPrefix: "[ScenarioA]" ) else { // This route fails driving constraints, skip it print("[ScenarioA] Route \(index + 1) failed driving constraints, skipping") continue } // Create the option let cities = stops.map { $0.city }.joined(separator: " → ") let option = ItineraryOption( rank: index + 1, stops: itinerary.stops, travelSegments: itinerary.travelSegments, totalDrivingHours: itinerary.totalDrivingHours, totalDistanceMiles: itinerary.totalDistanceMiles, geographicRationale: "\(stops.count) games: \(cities)" ) itineraryOptions.append(option) } // ────────────────────────────────────────────────────────────────── // Step 5: Return ranked results // ────────────────────────────────────────────────────────────────── // If no routes passed all constraints, fail. // Otherwise, return all valid options for the user to choose from. // if itineraryOptions.isEmpty { return .failure( PlanningFailure( reason: .constraintsUnsatisfiable, violations: [ ConstraintViolation( type: .drivingTime, description: "No routes satisfy driving constraints", severity: .error ) ] ) ) } // Re-rank by number of games (already sorted, but update rank numbers) let rankedOptions = itineraryOptions.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("[ScenarioA] Returning \(rankedOptions.count) itinerary options") return .success(rankedOptions) } // MARK: - Stop Building /// Converts a list of games into itinerary stops. /// /// The goal: Create one stop per stadium, in the order we first encounter each stadium /// when walking through games chronologically. /// /// Example: /// Input games (already sorted by date): /// 1. Jan 5 - Lakers @ Staples Center (LA) /// 2. Jan 6 - Clippers @ Staples Center (LA) <- same stadium as #1 /// 3. Jan 8 - Warriors @ Chase Center (SF) /// /// Output stops: /// Stop 1: Los Angeles (contains game 1 and 2) /// Stop 2: San Francisco (contains game 3) /// private func buildStops( from games: [Game], stadiums: [UUID: Stadium] ) -> [ItineraryStop] { // Step 1: Group all games by their stadium // This lets us find ALL games at a stadium when we create that stop // Result: { stadiumId: [game1, game2, ...], ... } var stadiumGames: [UUID: [Game]] = [:] for game in games { stadiumGames[game.stadiumId, default: []].append(game) } // Step 2: Walk through games in chronological order // When we hit a stadium for the first time, create a stop with ALL games at that stadium var stops: [ItineraryStop] = [] var processedStadiums: Set = [] // Track which stadiums we've already made stops for for game in games { // Skip if we already created a stop for this stadium guard !processedStadiums.contains(game.stadiumId) else { continue } processedStadiums.insert(game.stadiumId) // Get ALL games at this stadium (not just this one) let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game] let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime } // Look up stadium info for location data 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 ) // Create the stop // - arrivalDate: when we need to arrive (first game at this stop) // - departureDate: when we can leave (after last game at this stop) // - games: IDs of all games we'll attend at this stop 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 } }