// // MustStopValidator.swift // SportsTime // import Foundation import CoreLocation /// Validates that must-stop locations are reachable by the route. /// Priority 6 in the rule hierarchy (lowest priority). /// /// A route "passes" a must-stop location if: /// - Any travel segment comes within the proximity threshold (default 25 miles) /// - The must-stop does NOT require a separate overnight stay struct MustStopValidator { // MARK: - Validation Result struct ValidationResult { let isValid: Bool let violations: [ConstraintViolation] let unreachableLocations: [String] static let valid = ValidationResult(isValid: true, violations: [], unreachableLocations: []) static func unreachable(locations: [String]) -> ValidationResult { let violations = locations.map { location in ConstraintViolation( type: .mustStop, description: "Required stop '\(location)' is not reachable within \(Int(MustStopConfig.defaultProximityMiles)) miles of any route segment", severity: .error ) } return ValidationResult(isValid: false, violations: violations, unreachableLocations: locations) } } // MARK: - Properties let config: MustStopConfig // MARK: - Initialization init(config: MustStopConfig = MustStopConfig()) { self.config = config } // MARK: - Validation /// Validates that all must-stop locations can be reached by the route. /// /// - Parameters: /// - mustStopLocations: Array of locations that must be visited/passed /// - stops: The planned stops in the itinerary /// - segments: The travel segments between stops /// - Returns: ValidationResult indicating if all must-stops are reachable func validate( mustStopLocations: [LocationInput], stops: [ItineraryStop], segments: [TravelSegment] ) -> ValidationResult { guard !mustStopLocations.isEmpty else { return .valid } var unreachable: [String] = [] for mustStop in mustStopLocations { if !isReachable(mustStop: mustStop, stops: stops, segments: segments) { unreachable.append(mustStop.name) } } if unreachable.isEmpty { return .valid } else { return .unreachable(locations: unreachable) } } /// Validates must-stop locations from a planning request. /// /// - Parameters: /// - request: The planning request with must-stop preferences /// - stops: The planned stops /// - segments: The travel segments /// - Returns: ValidationResult func validate( request: PlanningRequest, stops: [ItineraryStop], segments: [TravelSegment] ) -> ValidationResult { return validate( mustStopLocations: request.preferences.mustStopLocations, stops: stops, segments: segments ) } // MARK: - Reachability Check /// Checks if a must-stop location is reachable by the route. /// A location is reachable if: /// 1. It's within proximity of any stop, OR /// 2. It's within proximity of any travel segment path /// /// - Parameters: /// - mustStop: The location to check /// - stops: Planned stops /// - segments: Travel segments /// - Returns: True if the location is reachable private func isReachable( mustStop: LocationInput, stops: [ItineraryStop], segments: [TravelSegment] ) -> Bool { guard let mustStopCoord = mustStop.coordinate else { // If we don't have coordinates, we can't validate - assume reachable return true } // Check if any stop is within proximity for stop in stops { if let stopCoord = stop.coordinate { let distance = distanceInMiles(from: mustStopCoord, to: stopCoord) if distance <= config.proximityMiles { return true } } } // Check if any segment passes within proximity for segment in segments { if isNearSegment(point: mustStopCoord, segment: segment) { return true } } return false } /// Checks if a point is near a travel segment. /// Uses perpendicular distance to the segment line. /// /// - Parameters: /// - point: The point to check /// - segment: The travel segment /// - Returns: True if within proximity private func isNearSegment( point: CLLocationCoordinate2D, segment: TravelSegment ) -> Bool { guard let originCoord = segment.fromLocation.coordinate, let destCoord = segment.toLocation.coordinate else { return false } // Calculate perpendicular distance from point to segment let distance = perpendicularDistance( point: point, lineStart: originCoord, lineEnd: destCoord ) return distance <= config.proximityMiles } /// Calculates the minimum distance from a point to a line segment in miles. /// Uses the perpendicular distance if the projection falls on the segment, /// otherwise uses the distance to the nearest endpoint. private func perpendicularDistance( point: CLLocationCoordinate2D, lineStart: CLLocationCoordinate2D, lineEnd: CLLocationCoordinate2D ) -> Double { let pointLoc = CLLocation(latitude: point.latitude, longitude: point.longitude) let startLoc = CLLocation(latitude: lineStart.latitude, longitude: lineStart.longitude) let endLoc = CLLocation(latitude: lineEnd.latitude, longitude: lineEnd.longitude) let lineLength = startLoc.distance(from: endLoc) // Handle degenerate case where start == end if lineLength < 1 { return pointLoc.distance(from: startLoc) / 1609.34 // meters to miles } // Calculate projection parameter t // t = ((P - A) · (B - A)) / |B - A|² let dx = endLoc.coordinate.longitude - startLoc.coordinate.longitude let dy = endLoc.coordinate.latitude - startLoc.coordinate.latitude let px = point.longitude - lineStart.longitude let py = point.latitude - lineStart.latitude let t = max(0, min(1, (px * dx + py * dy) / (dx * dx + dy * dy))) // Calculate closest point on segment let closestLat = lineStart.latitude + t * dy let closestLon = lineStart.longitude + t * dx let closestLoc = CLLocation(latitude: closestLat, longitude: closestLon) // Return distance in miles return pointLoc.distance(from: closestLoc) / 1609.34 } /// Calculates distance between two coordinates in miles. private func distanceInMiles( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D ) -> Double { let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude) let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude) return fromLoc.distance(from: toLoc) / 1609.34 // meters to miles } // MARK: - Route Modification /// Finds the best position to insert a must-stop location into an itinerary. /// Used when we need to add an explicit stop for a must-stop location. /// /// - Parameters: /// - mustStop: The location to insert /// - stops: Current stops in order /// - Returns: The index where the stop should be inserted (1-based, between existing stops) func bestInsertionIndex( for mustStop: LocationInput, in stops: [ItineraryStop] ) -> Int { guard let mustStopCoord = mustStop.coordinate, stops.count >= 2 else { return 1 // Insert after first stop } var bestIndex = 1 var minDetour = Double.greatestFiniteMagnitude for i in 0..<(stops.count - 1) { guard let fromCoord = stops[i].coordinate, let toCoord = stops[i + 1].coordinate else { continue } // Calculate detour: (from→mustStop + mustStop→to) - (from→to) let direct = distanceInMiles(from: fromCoord, to: toCoord) let via = distanceInMiles(from: fromCoord, to: mustStopCoord) + distanceInMiles(from: mustStopCoord, to: toCoord) let detour = via - direct if detour < minDetour { minDetour = detour bestIndex = i + 1 } } return bestIndex } }