// // ItineraryBuilder.swift // SportsTime // // Shared utility for building itineraries with travel segments. // Used by all scenario planners to convert stops into complete itineraries. // import Foundation /// Result of building an itinerary from stops. struct BuiltItinerary { let stops: [ItineraryStop] let travelSegments: [TravelSegment] let totalDrivingHours: Double let totalDistanceMiles: Double } /// Shared logic for building itineraries across all scenario planners. enum ItineraryBuilder { /// Validation that can be performed on each travel segment. /// Return `true` if the segment is valid, `false` to reject the itinerary. typealias SegmentValidator = (TravelSegment, _ fromStop: ItineraryStop, _ toStop: ItineraryStop) -> Bool /// Builds a complete itinerary with travel segments between consecutive stops. /// /// Algorithm: /// 1. Handle edge case: single stop = no travel needed /// 2. For each consecutive pair of stops, estimate travel /// 3. Optionally validate each segment with custom validator /// 4. Accumulate driving hours and distance /// 5. Verify invariant: travelSegments.count == stops.count - 1 /// /// - Parameters: /// - stops: The stops to connect with travel segments /// - constraints: Driving constraints (drivers, max hours per day) /// - logPrefix: Prefix for log messages (e.g., "[ScenarioA]") /// - segmentValidator: Optional validation for each segment /// /// - Returns: Built itinerary if successful, nil if any segment fails /// static func build( stops: [ItineraryStop], constraints: DrivingConstraints, logPrefix: String = "[ItineraryBuilder]", segmentValidator: SegmentValidator? = nil ) -> BuiltItinerary? { // ────────────────────────────────────────────────────────────────── // Edge case: Single stop or empty = no travel needed // ────────────────────────────────────────────────────────────────── if stops.count <= 1 { return BuiltItinerary( stops: stops, travelSegments: [], totalDrivingHours: 0, totalDistanceMiles: 0 ) } // ────────────────────────────────────────────────────────────────── // Build travel segments between consecutive stops // ────────────────────────────────────────────────────────────────── var travelSegments: [TravelSegment] = [] var totalDrivingHours: Double = 0 var totalDistance: Double = 0 for index in 0..<(stops.count - 1) { let fromStop = stops[index] let toStop = stops[index + 1] // Estimate travel for this segment guard let segment = TravelEstimator.estimate( from: fromStop, to: toStop, constraints: constraints ) else { return nil } // Run optional validator (e.g., arrival time check for Scenario B) if let validator = segmentValidator { if !validator(segment, fromStop, toStop) { return nil } } travelSegments.append(segment) totalDrivingHours += segment.estimatedDrivingHours totalDistance += segment.estimatedDistanceMiles } // ────────────────────────────────────────────────────────────────── // Verify invariant: segments = stops - 1 // ────────────────────────────────────────────────────────────────── guard travelSegments.count == stops.count - 1 else { return nil } return BuiltItinerary( stops: stops, travelSegments: travelSegments, totalDrivingHours: totalDrivingHours, totalDistanceMiles: totalDistance ) } // MARK: - EV Charger Enrichment /// Enriches an itinerary option with EV charging stops along each route segment. /// /// This is a post-processing step that can be called after the sync planning completes. /// It fetches real routes from MapKit and searches for EV chargers along each segment. /// /// - Parameter option: The itinerary option to enrich /// - Returns: A new itinerary option with EV chargers populated in travel segments /// static func enrichWithEVChargers(_ option: ItineraryOption) async -> ItineraryOption { var enrichedSegments: [TravelSegment] = [] for segment in option.travelSegments { // Get coordinates guard let fromCoord = segment.fromLocation.coordinate, let toCoord = segment.toLocation.coordinate else { // No coordinates - keep segment as-is enrichedSegments.append(segment) continue } do { // Fetch real route from MapKit let routeInfo = try await LocationService.shared.calculateDrivingRoute( from: fromCoord, to: toCoord ) // Find EV chargers along the route var evChargers: [EVChargingStop] = [] if let polyline = routeInfo.polyline { do { evChargers = try await EVChargingService.shared.findChargersAlongRoute( polyline: polyline, searchRadiusMiles: 5.0, intervalMiles: 100.0 ) } catch { // EV charger search failed - continue without chargers } } // Create enriched segment with real route data and EV chargers let enrichedSegment = TravelSegment( id: segment.id, fromLocation: segment.fromLocation, toLocation: segment.toLocation, travelMode: segment.travelMode, distanceMeters: routeInfo.distance, durationSeconds: routeInfo.expectedTravelTime, scenicScore: segment.scenicScore, evChargingStops: evChargers ) enrichedSegments.append(enrichedSegment) } catch { // Route calculation failed - keep original segment enrichedSegments.append(segment) } } // Recalculate totals with enriched segments let totalDrivingHours = enrichedSegments.reduce(0.0) { $0 + $1.estimatedDrivingHours } let totalDistanceMiles = enrichedSegments.reduce(0.0) { $0 + $1.estimatedDistanceMiles } return ItineraryOption( rank: option.rank, stops: option.stops, travelSegments: enrichedSegments, totalDrivingHours: totalDrivingHours, totalDistanceMiles: totalDistanceMiles, geographicRationale: option.geographicRationale ) } /// Enriches multiple itinerary options with EV chargers. /// Processes in parallel for better performance. static func enrichWithEVChargers(_ options: [ItineraryOption]) async -> [ItineraryOption] { await withTaskGroup(of: (Int, ItineraryOption).self) { group in for (index, option) in options.enumerated() { group.addTask { let enriched = await enrichWithEVChargers(option) return (index, enriched) } } var results = [(Int, ItineraryOption)]() for await result in group { results.append(result) } // Sort by original index to maintain order return results.sorted { $0.0 < $1.0 }.map { $0.1 } } } // MARK: - Async Build with EV Chargers /// Builds an itinerary with real route data and optional EV charger discovery. /// /// This async version uses MapKit to get actual driving routes (instead of Haversine estimates) /// and can optionally search for EV charging stations along each route segment. /// /// - Parameters: /// - stops: The stops to connect with travel segments /// - constraints: Driving constraints (drivers, max hours per day) /// - includeEVChargers: Whether to search for EV chargers along routes /// - logPrefix: Prefix for log messages /// - segmentValidator: Optional validation for each segment /// /// - Returns: Built itinerary if successful, nil if any segment fails /// static func buildAsync( stops: [ItineraryStop], constraints: DrivingConstraints, includeEVChargers: Bool = false, logPrefix: String = "[ItineraryBuilder]", segmentValidator: SegmentValidator? = nil ) async -> BuiltItinerary? { // Edge case: Single stop or empty = no travel needed if stops.count <= 1 { return BuiltItinerary( stops: stops, travelSegments: [], totalDrivingHours: 0, totalDistanceMiles: 0 ) } var travelSegments: [TravelSegment] = [] var totalDrivingHours: Double = 0 var totalDistance: Double = 0 for index in 0..<(stops.count - 1) { let fromStop = stops[index] let toStop = stops[index + 1] // Get coordinates guard let fromCoord = fromStop.coordinate, let toCoord = toStop.coordinate else { // Fall back to estimate guard let segment = TravelEstimator.estimate( from: fromStop, to: toStop, constraints: constraints ) else { return nil } travelSegments.append(segment) totalDrivingHours += segment.estimatedDrivingHours totalDistance += segment.estimatedDistanceMiles continue } // Fetch real route from MapKit do { let routeInfo = try await LocationService.shared.calculateDrivingRoute( from: fromCoord, to: toCoord ) // Find EV chargers if requested and polyline is available var evChargers: [EVChargingStop] = [] if includeEVChargers, let polyline = routeInfo.polyline { do { evChargers = try await EVChargingService.shared.findChargersAlongRoute( polyline: polyline, searchRadiusMiles: 5.0, intervalMiles: 100.0 ) } catch { // Continue without chargers - not a critical failure } } let segment = TravelSegment( fromLocation: fromStop.location, toLocation: toStop.location, travelMode: .drive, distanceMeters: routeInfo.distance, durationSeconds: routeInfo.expectedTravelTime, evChargingStops: evChargers ) // Run optional validator if let validator = segmentValidator { if !validator(segment, fromStop, toStop) { return nil } } travelSegments.append(segment) totalDrivingHours += segment.estimatedDrivingHours totalDistance += segment.estimatedDistanceMiles } catch { // Fall back to estimate guard let segment = TravelEstimator.estimate( from: fromStop, to: toStop, constraints: constraints ) else { return nil } travelSegments.append(segment) totalDrivingHours += segment.estimatedDrivingHours totalDistance += segment.estimatedDistanceMiles } } // Verify invariant guard travelSegments.count == stops.count - 1 else { return nil } return BuiltItinerary( stops: stops, travelSegments: travelSegments, totalDrivingHours: totalDrivingHours, totalDistanceMiles: totalDistance ) } // MARK: - Common Validators /// Validator that ensures travel duration allows arrival before game start. /// Used by Scenario B where selected games have fixed start times. /// /// This checks if the travel duration is short enough that the user could /// theoretically leave after the previous game and arrive before the next. /// /// - Parameter bufferSeconds: Time buffer before game start (default 1 hour) /// - Returns: Validator closure /// static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator { return { segment, fromStop, toStop in guard let gameStart = toStop.firstGameStart else { return true // No game = no constraint } // Check if there's enough time between departure point and game start // Departure assumed after previous day's activities (use departure date as baseline) let earliestDeparture = fromStop.departureDate let travelDuration = segment.durationSeconds let earliestArrival = earliestDeparture.addingTimeInterval(travelDuration) let deadline = gameStart.addingTimeInterval(-bufferSeconds) if earliestArrival > deadline { return false } return true } } }