// // TripPlanningEngine.swift // SportsTime // // Thin orchestrator that delegates to scenario-specific planners. // import Foundation /// Main entry point for trip planning. /// Delegates to scenario-specific planners via the ScenarioPlanner protocol. /// /// - Expected Behavior: /// - Uses ScenarioPlannerFactory.planner(for:) to select the right planner /// - Delegates entirely to the selected scenario planner /// - Applies repeat city filter to successful results /// - If all options violate repeat city constraint → .failure with .repeatCityViolation /// - Passes through failures from scenario planners unchanged /// /// - Invariants: /// - Never modifies the logic of scenario planners /// - Always returns a result (success or failure), never throws /// - Repeat city filter only applied when allowRepeatCities is false /// final class TripPlanningEngine { /// Warnings generated during the last planning run. /// Populated when options are filtered out but valid results remain. private(set) var warnings: [ConstraintViolation] = [] /// Plans itineraries based on the request inputs. /// Automatically detects which scenario applies and delegates to the appropriate planner. /// /// - Parameter request: The planning request containing all inputs /// - Returns: Ranked itineraries on success, or explicit failure with reason func planItineraries(request: PlanningRequest) -> ItineraryResult { // Reset warnings from previous run warnings = [] // Warn on empty sports set if request.preferences.sports.isEmpty { warnings.append(ConstraintViolation( type: .missingData, description: "No sports selected — results may be empty", severity: .warning )) } // Validate date range is not inverted if request.preferences.endDate < request.preferences.startDate { return .failure(PlanningFailure( reason: .missingDateRange, violations: [ ConstraintViolation( type: .dateRange, description: "End date is before start date", severity: .error ) ] )) } // Detect scenario and get the appropriate planner let planner = ScenarioPlannerFactory.planner(for: request) // Delegate to the scenario planner let result = planner.plan(request: request) // Apply preference filters to successful results return applyPreferenceFilters(to: result, request: request) } // MARK: - Private /// Applies allowRepeatCities filter after scenario planners return. /// Note: Region filtering is done during game selection in scenario planners. /// Tracks excluded options as warnings when valid results remain. private func applyPreferenceFilters( to result: ItineraryResult, request: PlanningRequest ) -> ItineraryResult { guard case .success(let originalOptions) = result else { return result } var options = originalOptions // Filter repeat cities (this is enforced during beam search, but double-check here) let preRepeatCount = options.count options = RouteFilters.filterRepeatCities( options, allow: request.preferences.allowRepeatCities ) if options.isEmpty && !request.preferences.allowRepeatCities { let violatingCities = RouteFilters.findRepeatCities(in: originalOptions) return .failure(PlanningFailure( reason: .repeatCityViolation(cities: violatingCities) )) } let repeatCityExcluded = preRepeatCount - options.count if repeatCityExcluded > 0 { warnings.append(ConstraintViolation( type: .general, description: "\(repeatCityExcluded) route(s) excluded for visiting the same city on multiple days", severity: .warning )) } // Must-stop filter: ensure all must-stop cities appear in routes if !request.preferences.mustStopLocations.isEmpty { let requiredCities = request.preferences.mustStopLocations .map { $0.name.lowercased() } .filter { !$0.isEmpty } if !requiredCities.isEmpty { let preMustStopCount = options.count options = options.filter { option in let tripCities = Set(option.stops.map { $0.city.lowercased() }) return requiredCities.allSatisfy { tripCities.contains($0) } } if options.isEmpty { return .failure(PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: .mustStop, description: "No routes include all must-stop cities", severity: .error ) ] )) } let mustStopExcluded = preMustStopCount - options.count if mustStopExcluded > 0 { let cityList = requiredCities.joined(separator: ", ") warnings.append(ConstraintViolation( type: .mustStop, description: "\(mustStopExcluded) route(s) excluded for missing must-stop cities: \(cityList)", severity: .warning )) } } } // Validate travel segments: filter out invalid options let preValidCount = options.count options = options.filter { $0.isValid } if options.isEmpty { return .failure(PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: .segmentMismatch, description: "No valid itineraries could be built", severity: .error ) ] )) } let segmentExcluded = preValidCount - options.count if segmentExcluded > 0 { warnings.append(ConstraintViolation( type: .segmentMismatch, description: "\(segmentExcluded) route(s) excluded due to invalid travel segments", severity: .warning )) } return .success(options) } }