// // 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 { print("\(logPrefix) Failed to estimate travel: \(fromStop.city) -> \(toStop.city)") return nil } // Run optional validator (e.g., arrival time check for Scenario B) if let validator = segmentValidator { if !validator(segment, fromStop, toStop) { print("\(logPrefix) Segment validation failed: \(fromStop.city) -> \(toStop.city)") return nil } } travelSegments.append(segment) totalDrivingHours += segment.estimatedDrivingHours totalDistance += segment.estimatedDistanceMiles } // ────────────────────────────────────────────────────────────────── // Verify invariant: segments = stops - 1 // ────────────────────────────────────────────────────────────────── guard travelSegments.count == stops.count - 1 else { print("\(logPrefix) Invariant violated: \(travelSegments.count) segments for \(stops.count) stops") 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 { print("[ItineraryBuilder] Cannot arrive in time: earliest arrival \(earliestArrival) > deadline \(deadline)") return false } return true } } }