// // ScenarioDPlanner.swift // SportsTime // // Scenario D: Follow Team planning. // User selects a team, we find all their games (home and away) and build routes. // import Foundation import CoreLocation /// Scenario D: Follow Team planning /// /// This scenario builds trips around a specific team's schedule. /// /// Input: /// - followTeamId: Required. The team to follow. /// - date_range: Required. The trip dates. /// - selectedRegions: Optional. Filter to specific regions. /// - useHomeLocation: Whether to start/end from user's home. /// - startLocation: Required if useHomeLocation is true. /// /// Output: /// - Success: Ranked list of itinerary options /// - Failure: Explicit error with reason (no team, no games, etc.) /// /// Example: /// User follows Yankees, Jan 5-15, 2026 /// We find: @Red Sox (Jan 5), @Blue Jays (Jan 8), vs Orioles (Jan 12) /// Output: Route visiting Boston → Toronto → New York /// /// - Expected Behavior: /// - No followTeamId → returns .failure with .missingTeamSelection /// - No date range → returns .failure with .missingDateRange /// - No team games found → returns .failure with .noGamesInRange /// - No games in date range/region → returns .failure with .noGamesInRange /// - filterToTeam returns BOTH home and away games for the team /// - With selectedRegions → only includes games in those regions /// - No valid routes → .failure with .noValidRoutes /// - All routes fail constraints → .failure with .constraintsUnsatisfiable /// - Success → returns sorted itineraries based on leisureLevel /// /// - Invariants: /// - All returned games have homeTeamId == teamId OR awayTeamId == teamId /// - Games are chronologically ordered within each stop /// - Duplicate routes are removed /// final class ScenarioDPlanner: ScenarioPlanner { // MARK: - ScenarioPlanner Protocol /// Main entry point for Scenario D planning. /// /// Flow: /// 1. Validate inputs (team must be selected) /// 2. Filter games to team's schedule (home and away) /// 3. Apply region and date filters /// 4. Apply repeat city constraints /// 5. Build routes and calculate travel /// 6. Return ranked itineraries /// /// Failure cases: /// - No team selected → .missingTeamSelection /// - No date range → .missingDateRange /// - No games found → .noGamesInRange /// - Can't build valid route → .constraintsUnsatisfiable /// func plan(request: PlanningRequest) -> ItineraryResult { // ────────────────────────────────────────────────────────────────── // Step 1: Validate team selection // ────────────────────────────────────────────────────────────────── guard let teamId = request.preferences.followTeamId else { return .failure( PlanningFailure( reason: .missingTeamSelection, violations: [] ) ) } // ────────────────────────────────────────────────────────────────── // Step 2: Validate date range exists // ────────────────────────────────────────────────────────────────── guard let dateRange = request.dateRange else { return .failure( PlanningFailure( reason: .missingDateRange, violations: [] ) ) } // ────────────────────────────────────────────────────────────────── // Step 3: Filter games to team's schedule (home and away) // ────────────────────────────────────────────────────────────────── let teamGames = filterToTeam(request.allGames, teamId: teamId) print("🔍 ScenarioD Step 3: allGames=\(request.allGames.count), teamGames=\(teamGames.count)") print("🔍 ScenarioD: Looking for teamId=\(teamId)") for game in teamGames.prefix(20) { let stadium = request.stadiums[game.stadiumId] let isHome = game.homeTeamId == teamId print("🔍 Game: \(stadium?.city ?? "?") on \(game.gameDate) (\(isHome ? "HOME" : "AWAY"))") } if teamGames.isEmpty { return .failure( PlanningFailure( reason: .noGamesInRange, violations: [ ConstraintViolation( type: .selectedGames, description: "No games found for selected team", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 4: Apply date range and region filters // ────────────────────────────────────────────────────────────────── let selectedRegions = request.preferences.selectedRegions print("🔍 ScenarioD Step 4: dateRange=\(dateRange.start) to \(dateRange.end), selectedRegions=\(selectedRegions)") let filteredGames = teamGames .filter { game in // Must be in date range let inDateRange = dateRange.contains(game.startTime) if !inDateRange { let stadium = request.stadiums[game.stadiumId] print("🔍 FILTERED OUT (date): \(stadium?.city ?? "?") on \(game.gameDate) - startTime=\(game.startTime)") return false } // Must be in selected region (if regions specified) if !selectedRegions.isEmpty { guard let stadium = request.stadiums[game.stadiumId] else { return false } let gameRegion = Region.classify(longitude: stadium.coordinate.longitude) let inRegion = selectedRegions.contains(gameRegion) if !inRegion { print("🔍 FILTERED OUT (region): \(stadium.city) on \(game.gameDate) - region=\(gameRegion)") } return inRegion } return true } .sorted { $0.startTime < $1.startTime } print("🔍 ScenarioD Step 4 result: \(filteredGames.count) games after date/region filter") for game in filteredGames { let stadium = request.stadiums[game.stadiumId] print("🔍 Kept: \(stadium?.city ?? "?") on \(game.gameDate)") } if filteredGames.isEmpty { return .failure( PlanningFailure( reason: .noGamesInRange, violations: [ ConstraintViolation( type: .dateRange, description: "No team games found in selected date range and regions", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 5: Prepare for routing // ────────────────────────────────────────────────────────────────── // NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles // allowRepeatCities internally, which allows it to pick the optimal game // per city for route feasibility (e.g., pick July 29 Anaheim instead of // July 27 if it makes the driving from Chicago feasible). let finalGames = filteredGames print("🔍 ScenarioD Step 5: Passing \(finalGames.count) games to router (allowRepeatCities=\(request.preferences.allowRepeatCities))") print("🔍 ScenarioD: teamGames=\(teamGames.count), filteredGames=\(filteredGames.count), finalGames=\(finalGames.count)") // ────────────────────────────────────────────────────────────────── // Step 6: Find valid routes using DAG router // ────────────────────────────────────────────────────────────────── // Follow Team mode typically has fewer games than Scenario A, // so we can be more exhaustive in route finding. print("🔍 ScenarioD Step 6: Finding routes from \(finalGames.count) games") var validRoutes: [[Game]] = [] let globalRoutes = GameDAGRouter.findAllSensibleRoutes( from: finalGames, stadiums: request.stadiums, allowRepeatCities: request.preferences.allowRepeatCities, stopBuilder: buildStops ) print("🔍 ScenarioD Step 6: GameDAGRouter returned \(globalRoutes.count) routes") for (i, route) in globalRoutes.prefix(5).enumerated() { let cities = route.compactMap { request.stadiums[$0.stadiumId]?.city }.joined(separator: " → ") print("🔍 Route \(i+1): \(route.count) games - \(cities)") } validRoutes.append(contentsOf: globalRoutes) // Deduplicate routes validRoutes = deduplicateRoutes(validRoutes) print("🔍 ScenarioD Step 6 after dedup: \(validRoutes.count) valid routes") if validRoutes.isEmpty { return .failure( PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: .geographicSanity, description: "No geographically sensible route found for team's games", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 7: Build itineraries for each valid route // ────────────────────────────────────────────────────────────────── 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 ) else { // This route fails driving constraints, skip it 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: "Follow Team: \(stops.count) games - \(cities)" ) itineraryOptions.append(option) } // ────────────────────────────────────────────────────────────────── // Step 8: Return ranked results // ────────────────────────────────────────────────────────────────── if itineraryOptions.isEmpty { return .failure( PlanningFailure( reason: .constraintsUnsatisfiable, violations: [ ConstraintViolation( type: .drivingTime, description: "No routes satisfy driving constraints for team's schedule", severity: .error ) ] ) ) } // Sort and rank based on leisure level let leisureLevel = request.preferences.leisureLevel let rankedOptions = ItineraryOption.sortByLeisure( itineraryOptions, leisureLevel: leisureLevel ) print("🔍 ScenarioD: Returning \(rankedOptions.count) options") return .success(rankedOptions) } // MARK: - Team Filtering /// Filters games to those involving the followed team (home or away). private func filterToTeam(_ games: [Game], teamId: String) -> [Game] { games.filter { game in game.homeTeamId == teamId || game.awayTeamId == teamId } } // MARK: - Repeat City Filtering /// When `allowRepeatCities = false`, keeps only the first game per city. private func applyRepeatCityFilter( _ games: [Game], allowRepeat: Bool, stadiums: [String: Stadium] ) -> [Game] { guard !allowRepeat else { print("🔍 applyRepeatCityFilter: allowRepeat=true, returning all \(games.count) games") return games } print("🔍 applyRepeatCityFilter: allowRepeat=false, filtering duplicates") var seenCities: Set = [] return games.filter { game in guard let stadium = stadiums[game.stadiumId] else { print("🔍 Game \(game.id): NO STADIUM FOUND - filtered out") return false } if seenCities.contains(stadium.city) { print("🔍 Game in \(stadium.city) on \(game.gameDate): DUPLICATE CITY - filtered out") return false } print("🔍 Game in \(stadium.city) on \(game.gameDate): KEPT (first in this city)") seenCities.insert(stadium.city) return true } } // MARK: - Stop Building /// Converts a list of games into itinerary stops. /// Same logic as ScenarioAPlanner. private func buildStops( from games: [Game], stadiums: [String: Stadium] ) -> [ItineraryStop] { guard !games.isEmpty else { return [] } let sortedGames = games.sorted { $0.startTime < $1.startTime } var stops: [ItineraryStop] = [] var currentStadiumId: String? = nil var currentGames: [Game] = [] for game in sortedGames { if game.stadiumId == currentStadiumId { currentGames.append(game) } else { 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 ) 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 } }