// // 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. One or more locations the route must include /// /// 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 /// /// - Expected Behavior: /// - No date range → returns .failure with .missingDateRange /// - No games in date range → returns .failure with .noGamesInRange /// - With selectedRegions → only includes games in those regions /// - With mustStopLocations → route must include at least one game in each must-stop city /// - Missing games for any must-stop city → .failure with .noGamesInRange /// - No valid routes from GameDAGRouter → .failure with .noValidRoutes /// - All routes fail ItineraryBuilder → .failure with .constraintsUnsatisfiable /// - Success → returns sorted itineraries based on leisureLevel /// /// - Invariants: /// - Returned games are always within the date range /// - Returned games are always chronologically ordered within each stop /// - buildStops groups consecutive games at the same stadium /// - Visiting A→B→A creates 3 separate stops (not 2) /// 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 and selected regions // ────────────────────────────────────────────────────────────────── // Get all games that fall within the user's travel dates. // Sort by start time so we visit them in chronological order. let selectedRegions = request.preferences.selectedRegions var gamesWithMissingStadium = 0 let gamesInRange = request.allGames .filter { game in // Must be in date range guard dateRange.contains(game.startTime) else { return false } // Track games with missing stadium data guard request.stadiums[game.stadiumId] != nil else { gamesWithMissingStadium += 1 return false } // Must be in selected region (if regions specified) if !selectedRegions.isEmpty { let stadium = request.stadiums[game.stadiumId]! let gameRegion = Region.classify(longitude: stadium.coordinate.longitude) return selectedRegions.contains(gameRegion) } return true } .sorted { $0.startTime < $1.startTime } // No games? Nothing to plan. if gamesInRange.isEmpty { var violations: [ConstraintViolation] = [] if gamesWithMissingStadium > 0 { violations.append(ConstraintViolation( type: .missingData, description: "\(gamesWithMissingStadium) game(s) excluded due to missing stadium data", severity: .warning )) } return .failure( PlanningFailure( reason: .noGamesInRange, violations: violations ) ) } // ────────────────────────────────────────────────────────────────── // Step 2b: Filter by must-stop locations (if any) // ────────────────────────────────────────────────────────────────── // Must-stops are route constraints, not exclusive filters. // Keep all games in range, then require routes to include each must-stop city. let requiredMustStops = request.preferences.mustStopLocations.filter { stop in !normalizeCityName(stop.name).isEmpty } if !requiredMustStops.isEmpty { let missingMustStops = requiredMustStops.filter { mustStop in !gamesInRange.contains { game in gameMatchesCity(game, cityName: mustStop.name, stadiums: request.stadiums) } } if !missingMustStops.isEmpty { let violations = missingMustStops.map { missing in ConstraintViolation( type: .mustStop, description: "No home games found in \(missing.name) during selected dates", severity: .error ) } return .failure( PlanningFailure( reason: .noGamesInRange, violations: violations ) ) } } let filteredGames = gamesInRange // ────────────────────────────────────────────────────────────────── // 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 GameDAGRouter for polynomial-time beam search. // // Run beam search BOTH globally AND per-region to get diverse routes: // - Global search finds cross-region routes // - Per-region search ensures we have good regional options too // Travel style filtering happens at UI layer. // var validRoutes: [[Game]] = [] // Global beam search (finds cross-region routes) let globalRoutes = GameDAGRouter.findAllSensibleRoutes( from: filteredGames, stadiums: request.stadiums, allowRepeatCities: request.preferences.allowRepeatCities, routePreference: request.preferences.routePreference, stopBuilder: buildStops ) validRoutes.append(contentsOf: globalRoutes) // Per-region beam search (ensures good regional options) let regionalRoutes = findRoutesPerRegion( games: filteredGames, stadiums: request.stadiums, allowRepeatCities: request.preferences.allowRepeatCities, routePreference: request.preferences.routePreference ) validRoutes.append(contentsOf: regionalRoutes) // Deduplicate routes (same game IDs) validRoutes = deduplicateRoutes(validRoutes) // Enforce must-stop coverage after route generation so non-must-stop games can // still be included as connective "bonus" cities. if !requiredMustStops.isEmpty { validRoutes = validRoutes.filter { route in routeSatisfiesMustStops( route, mustStops: requiredMustStops, stadiums: request.stadiums ) } } #if DEBUG print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)") if let firstRoute = validRoutes.first { print("🔍 ScenarioA: First route has \(firstRoute.count) games") let cities = firstRoute.compactMap { request.stadiums[$0.stadiumId]?.city } print("🔍 ScenarioA: Route cities: \(cities)") } #endif if validRoutes.isEmpty { let noMustStopSatisfyingRoutes = !requiredMustStops.isEmpty return .failure( PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: noMustStopSatisfyingRoutes ? .mustStop : .geographicSanity, description: noMustStopSatisfyingRoutes ? "No valid route can include all required must-stop cities" : "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] = [] var routesAttempted = 0 var routesFailed = 0 for (index, routeGames) in validRoutes.enumerated() { routesAttempted += 1 // Build stops for this route let stops = buildStops(from: routeGames, stadiums: request.stadiums) #if DEBUG // Debug: show stops created from games let stopCities = stops.map { "\($0.city)(\($0.games.count)g)" } print("🔍 ScenarioA: Route \(index) - \(routeGames.count) games → \(stops.count) stops: \(stopCities)") #endif guard !stops.isEmpty else { routesFailed += 1 #if DEBUG print("⚠️ ScenarioA: Route \(index) - buildStops returned empty") #endif continue } // Calculate travel segments using shared ItineraryBuilder guard let itinerary = ItineraryBuilder.build( stops: stops, constraints: request.drivingConstraints ) else { // This route fails driving constraints, skip it routesFailed += 1 #if DEBUG print("⚠️ ScenarioA: Route \(index) - ItineraryBuilder.build failed") #endif 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: "\(routeGames.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 ) ] ) ) } // Sort and rank based on leisure level let leisureLevel = request.preferences.leisureLevel #if DEBUG // Debug: show all options before sorting print("🔍 ScenarioA: \(itineraryOptions.count) itinerary options before sorting:") for (i, opt) in itineraryOptions.enumerated() { print(" Option \(i): \(opt.stops.count) stops, \(opt.totalGames) games, \(String(format: "%.1f", opt.totalDrivingHours))h driving") } #endif let rankedOptions = ItineraryOption.sortByLeisure( itineraryOptions, leisureLevel: leisureLevel ) #if DEBUG print("🔍 ScenarioA: Returning \(rankedOptions.count) options after sorting") if let first = rankedOptions.first { print("🔍 ScenarioA: First option has \(first.stops.count) stops") } #endif 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) /// /// Note: If you visit the same city, leave, and come back, that creates /// separate stops (one for each visit). /// private func buildStops( from games: [Game], stadiums: [String: 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 into stops // If you visit A, then B, then A again, that's 3 stops (A, B, A) 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: - Route Deduplication /// Removes duplicate routes (routes with identical game IDs). private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] { var seen = Set() var unique: [[Game]] = [] for route in routes { let key = route.map { $0.id }.sorted().joined(separator: "-") if !seen.contains(key) { seen.insert(key) unique.append(route) } } return unique } private func routeSatisfiesMustStops( _ route: [Game], mustStops: [LocationInput], stadiums: [String: Stadium] ) -> Bool { mustStops.allSatisfy { mustStop in route.contains { game in gameMatchesCity(game, cityName: mustStop.name, stadiums: stadiums) } } } private func gameMatchesCity( _ game: Game, cityName: String, stadiums: [String: Stadium] ) -> Bool { guard let stadium = stadiums[game.stadiumId] else { return false } let targetCity = normalizeCityName(cityName) let stadiumCity = normalizeCityName(stadium.city) guard !targetCity.isEmpty, !stadiumCity.isEmpty else { return false } // Use exact match after normalization to avoid false positives // (e.g., "New York" matching "York" via .contains()) return stadiumCity == targetCity } private func normalizeCityName(_ value: String) -> String { let cityPart = value.split(separator: ",", maxSplits: 1).first.map(String.init) ?? value return cityPart .lowercased() .replacingOccurrences(of: ".", with: "") .split(whereSeparator: \.isWhitespace) .joined(separator: " ") } // MARK: - Regional Route Finding /// Finds routes by running beam search separately for each geographic region. /// This ensures we get diverse options from East, Central, and West coasts. private func findRoutesPerRegion( games: [Game], stadiums: [String: Stadium], allowRepeatCities: Bool, routePreference: RoutePreference = .balanced ) -> [[Game]] { // Partition games by region var gamesByRegion: [Region: [Game]] = [:] for game in games { guard let stadium = stadiums[game.stadiumId] else { continue } let coord = stadium.coordinate let region = Region.classify(longitude: coord.longitude) // Only consider actual regions, not cross-country if region != .crossCountry { gamesByRegion[region, default: []].append(game) } } #if DEBUG print("🔍 ScenarioA Regional: Partitioned \(games.count) games into \(gamesByRegion.count) regions") for (region, regionGames) in gamesByRegion { print(" \(region.shortName): \(regionGames.count) games") } #endif // Run beam search for each region var allRoutes: [[Game]] = [] for (region, regionGames) in gamesByRegion { guard !regionGames.isEmpty else { continue } let regionRoutes = GameDAGRouter.findAllSensibleRoutes( from: regionGames, stadiums: stadiums, allowRepeatCities: allowRepeatCities, routePreference: routePreference, stopBuilder: buildStops ) #if DEBUG print("🔍 ScenarioA Regional: \(region.shortName) produced \(regionRoutes.count) routes") #endif allRoutes.append(contentsOf: regionRoutes) } return allRoutes } }