*/ private const HANDLER_MUTABILITY_CATEGORY_MAP = [ 'additionalServices' => self::MUTABILITY_ADDITIONAL_SERVICES, 'courses' => self::MUTABILITY_ADDITIONAL_SERVICES, 'board' => self::MUTABILITY_ADDITIONAL_SERVICES, 'veg' => self::MUTABILITY_ADDITIONAL_SERVICES, 'skiPass' => self::MUTABILITY_ADDITIONAL_SERVICES, 'rentals' => self::MUTABILITY_ADDITIONAL_SERVICES, 'rentalInsurance' => self::MUTABILITY_ADDITIONAL_SERVICES, 'transportationOutbound' => self::MUTABILITY_TRANSPORTATION, 'transportationInbound' => self::MUTABILITY_TRANSPORTATION, 'parking' => self::MUTABILITY_TRANSPORTATION, 'licensePlate' => self::MUTABILITY_TRANSPORTATION, 'transportationDiscountReplacement' => self::MUTABILITY_TRANSPORTATION, 'pickup' => self::MUTABILITY_PICKUPS, 'dropOff' => self::MUTABILITY_PICKUPS, ]; /** @var array Registered handlers indexed by field name */ private array $handlers = []; /** @var string[]|null Cached array of handler names sorted by dependency order */ private ?array $sortedHandlers = null; /** * Initializes the registry with a hybrid array of handlers. * * This constructor supports both simple handlers (passed as class names) and * complex handlers (passed as instantiated service objects). Simple handlers * with no dependencies can be passed as strings and will be instantiated * automatically. Complex handlers with dependencies should be passed as * already-instantiated objects via dependency injection. * * @param array $handlers Array of handler instances or class names * * @throws \Exception If a class name cannot be instantiated */ public function __construct(array $handlers) { foreach ($handlers as $handler) { if (is_string($handler)) { // It's a class name - instantiate it (for simple handlers without dependencies) $handler = new $handler(); } // If it's already an object (service), use as-is (for complex handlers with dependencies) $this->addHandler($handler); } } /** * Registers a participant field handler with the registry. * * Handlers are indexed by their field name to ensure uniqueness and enable * fast lookups. Adding a handler invalidates the dependency sort cache, * forcing a re-sort on the next processing request. * * @param ParticipantFieldHandlerInterface $handler The handler to register */ public function addHandler(ParticipantFieldHandlerInterface $handler): void { $this->handlers[$handler->getFieldName()] = $handler; $this->sortedHandlers = null; // Reset cache to force re-sorting with new handler } /** * Processes all participant fields and synchronizes submitted data with cleaned DTO state. * * This method combines field processing with data synchronization to ensure that * the submitted form data reflects any changes made by field handlers. This is * particularly useful for HTMX form updates where invalid selections need to be * automatically cleared. * * @param array $submittedData The submitted form data containing participants array * @param BookingDto $bookingDto The booking DTO to update with processed field values * * @return array The synchronized submitted data reflecting DTO changes */ public function processFieldsAndSync(array $submittedData, BookingDto $bookingDto): array { // Process all field handlers to clean the DTO $this->processFields($submittedData, $bookingDto); // Synchronize submitted data with the cleaned DTO state return $this->syncSubmittedDataWithDto($submittedData, $bookingDto); } /** * Processes all participant fields from submitted form data using registered handlers. * * This is the main entry point for field processing. It processes handlers in dependency * order, applying each handler to ALL participants before moving to the next handler. * This ensures that cross-participant logic (like family booking detection) has access * to complete data from all participants. * * Processing order: handler-first, then participants * - Process handler A for all participants * - Process handler B for all participants * - etc. * * This is critical for handlers that depend on booking-level state (like insurance * family detection which needs all participants' ages to be processed first). * * @param array $submittedData The submitted form data containing participants array * @param BookingDto $bookingDto The booking DTO to update with processed field values (create or edit) */ public function processFields(array $submittedData, BookingDto $bookingDto): void { // Early return if no participant data exists in submission if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) { return; } // Get handlers sorted by dependency order (uses cache if available) $sortedHandlerNames = $this->getSortedHandlers(); // Process each handler across all participants before moving to the next handler // This ensures booking-level state (like family booking detection) is accurate foreach ($sortedHandlerNames as $handlerName) { if (false === $this->isHandlerMutableForBooking($handlerName, $bookingDto)) { continue; } $handler = $this->handlers[$handlerName]; // Apply this handler to all participants foreach ($submittedData['participants'] as $participantIndex => $participantData) { // Skip invalid participant data if (false === is_array($participantData)) { continue; } // Let each handler decide if it should process this participant's data if ($handler->shouldProcess($participantData, $bookingDto->getMode(), (int) $participantIndex)) { $handler->processField($participantData, $bookingDto, (int) $participantIndex); } } } } /** * Processes field handlers for a single participant. * * This method is used by the card-based booking flows where participants are * edited individually. Unlike processFields() which processes all participants, * this method applies all handlers to only the specified participant. * * Handlers are executed in dependency order to ensure proper data consistency. * * @param array $participantData Submitted data for one participant * @param BookingDto $bookingDto The booking DTO to update * @param int $participantIndex Index of participant to process */ public function processFieldsForParticipant(array $participantData, BookingDto $bookingDto, int $participantIndex): void { // Get handlers sorted by dependency order (uses cache if available) $sortedHandlerNames = $this->getSortedHandlers(); // Apply each handler to the specified participant foreach ($sortedHandlerNames as $handlerName) { if (false === $this->isHandlerMutableForBooking($handlerName, $bookingDto)) { continue; } $handler = $this->handlers[$handlerName]; // Let each handler decide if it should process this participant's data if ($handler->shouldProcess($participantData, $bookingDto->getMode(), $participantIndex)) { $handler->processField($participantData, $bookingDto, $participantIndex); } } } /** * Processes field handlers for a single participant and synchronizes submitted data. * * This method combines field processing with data synchronization to ensure that * the submitted form data reflects any changes made by field handlers. This is * essential for handlers that modify fields (like discount replacement) to have * their changes reflected in the form binding. * * Only fields that are actually modified by handlers (detected by comparing * submitted ID with DTO value after processing) are synchronized. This prevents * overwriting partial user input (like incomplete dates). * * @param array $participantData Submitted data for one participant * @param BookingDto $bookingDto The booking DTO to update * @param int $participantIndex Index of participant to process * * @return array The synchronized participant data reflecting DTO changes */ public function processFieldsForParticipantAndSync(array $participantData, BookingDto $bookingDto, int $participantIndex): array { // Process all field handlers for this participant $this->processFieldsForParticipant($participantData, $bookingDto, $participantIndex); // Synchronize only modified service fields with the cleaned DTO state $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant) { return $participantData; } return $this->syncModifiedServiceFields($participantData, $participant); } /** * Synchronizes only service fields that were modified by handlers. * * This method compares the submitted service ID with the DTO value after processing. * Only fields where the handler assigned a different service are synchronized back. * This prevents overwriting user input for fields like dateOfBirth where the handler * just parses the value without changing it. * * @param array $participantData The submitted participant data * @param ParticipantDto $participant The processed participant DTO * * @return array Updated participant data with modified fields synchronized */ private function syncModifiedServiceFields(array $participantData, ParticipantDto $participant): array { // Define fields that may be reassigned by handlers and need syncing $serviceFields = [ 'transportationOutbound', 'transportationInbound', 'insurance', 'skiPass', 'rentals', 'courses', 'additionalServices', 'board', 'veg', 'pickup', 'dropOff', 'parking', 'licensePlate', ]; foreach ($serviceFields as $fieldName) { if (false === array_key_exists($fieldName, $participantData)) { continue; } if (false === property_exists($participant, $fieldName)) { continue; } $dtoValue = $participant->{$fieldName}; $submittedValue = $participantData[$fieldName]; // Check if the DTO value differs from submitted value if ($this->hasFieldValueChanged($submittedValue, $dtoValue)) { $participantData[$fieldName] = $this->convertDtoValueToSubmittedFormat($dtoValue); } } return $participantData; } /** * Checks if a field value was changed by a handler. * * Compares the submitted value (usually an ID string) with the DTO value * (usually a Service/Insurance object) to detect if a handler reassigned it. * * @param mixed $submittedValue The original submitted value (ID or array of IDs) * @param mixed $dtoValue The DTO value after handler processing * * @return bool True if the values differ (handler modified the field) */ private function hasFieldValueChanged(mixed $submittedValue, mixed $dtoValue): bool { // Handle empty string as null (form submits empty string for unselected) if ('' === $submittedValue) { $submittedValue = null; } // Handle null cases if (null === $dtoValue && null === $submittedValue) { return false; } if (null === $dtoValue || null === $submittedValue) { return true; } // Handle arrays (multiple selections like rentals, courses) if (is_array($submittedValue) && is_array($dtoValue)) { $submittedIds = array_map('strval', $submittedValue); $dtoIds = array_map(fn ($item) => $this->extractId($item), $dtoValue); sort($submittedIds); sort($dtoIds); return $submittedIds !== $dtoIds; } // Handle single service/insurance objects $dtoId = $this->extractId($dtoValue); // Compare as integers for reliable comparison (form may submit string, DTO has int) return (int) $submittedValue !== (int) $dtoId; } /** * Extracts the ID from a service, insurance, or pickup object. * * @param mixed $value The value to extract ID from * * @return string|null The extracted ID or null */ private function extractId(mixed $value): ?string { if ($value instanceof Service || $value instanceof Insurance || $value instanceof Pickup) { return (string) $value->id; } if (is_scalar($value)) { return (string) $value; } return null; } /** * Returns handler names sorted by dependency order using cached results when possible. * * This method implements lazy loading with caching for performance. The dependency * sort is only performed once and the result is cached until handlers are added * or modified. * * @return string[] Array of handler field names in dependency execution order */ private function getSortedHandlers(): array { // Return cached result if available if (null !== $this->sortedHandlers) { return $this->sortedHandlers; } // Perform topological sort and cache the result $this->sortedHandlers = $this->topologicalSort(); return $this->sortedHandlers; } /** * Performs topological sort to determine safe handler execution order. * * This method uses Kahn's algorithm to sort handlers based on their declared * dependencies. It ensures that no handler is executed before its dependencies * have been processed, preventing data consistency issues. * * The algorithm works by: * 1. Building a dependency graph of handlers * 2. Finding handlers with no dependencies (in-degree = 0) * 3. Iteratively removing handlers and updating dependencies * 4. Detecting circular dependencies (deadlock prevention) * * @return string[] Array of handler field names in safe execution order * * @throws \InvalidArgumentException When a handler depends on a non-existent handler * @throws \InvalidArgumentException When circular dependencies are detected */ private function topologicalSort(): array { $inDegree = []; // Count of dependencies for each handler $graph = []; // Adjacency list of handler dependencies $handlerNames = array_keys($this->handlers); // Initialize all handlers with zero dependencies foreach ($handlerNames as $handlerName) { $inDegree[$handlerName] = 0; $graph[$handlerName] = []; } // Build dependency graph by examining each handler's dependencies foreach ($this->handlers as $handlerName => $handler) { foreach ($handler->getDependencies() as $dependency) { // Validate that the dependency exists if (false === isset($this->handlers[$dependency])) { throw new \InvalidArgumentException(sprintf('Handler "%s" depends on unknown handler "%s"', $handlerName, $dependency)); } // Add edge from dependency to dependent handler $graph[$dependency][] = $handlerName; ++$inDegree[$handlerName]; } } // Kahn's topological sort algorithm $queue = []; // Handlers ready to be processed (no remaining dependencies) $result = []; // Final sorted order // Start with handlers that have no dependencies foreach ($inDegree as $handlerName => $degree) { if (0 === $degree) { $queue[] = $handlerName; } } // Process handlers in dependency order while (false === empty($queue)) { $current = array_shift($queue); $result[] = $current; // Remove this handler's dependencies from dependent handlers foreach ($graph[$current] as $dependent) { --$inDegree[$dependent]; // If dependent now has no remaining dependencies, add to queue if (0 === $inDegree[$dependent]) { $queue[] = $dependent; } } } // Detect circular dependencies (if not all handlers were processed) if (count($result) !== count($handlerNames)) { throw new \InvalidArgumentException('Circular dependency detected in participant field handlers'); } return $result; } /** * Synchronizes submitted data with the cleaned DTO state. * * This method updates the submitted form data to reflect any changes made by * field handlers (such as clearing invalid service selections). This ensures * that the form continues processing with cleaned data rather than the original * submitted data that may contain invalid selections. * * In edit mode, this also adds the applicant's personal data to the submitted data * for participant 0, ensuring those fields are bound to the DTO by handleRequest. * * The synchronization is generic and works with any field handlers by examining * the current DTO state and updating the corresponding submitted data fields. * * @param array $submittedData The original submitted form data * @param BookingDto $bookingDto The DTO with cleaned data from field handlers * * @return array Updated submitted data reflecting DTO state */ private function syncSubmittedDataWithDto(array $submittedData, BookingDto $bookingDto): array { // Ensure participants array exists in submitted data if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) { return $submittedData; } // Sync each participant's data with the cleaned DTO foreach ($submittedData['participants'] as $index => $participantData) { if (false === is_array($participantData)) { continue; } $participant = $bookingDto->getParticipant((int) $index); if (null === $participant) { continue; } // Update participant data to match cleaned DTO state $submittedData['participants'][$index] = $this->syncParticipantData($participantData, $participant, $index, $bookingDto); } return $submittedData; } /** * Synchronizes individual participant submitted data with cleaned participant DTO. * * This method examines the participant DTO and updates the submitted data to match * any changes made by field handlers. It automatically detects which fields have * been processed by checking against registered handlers. * * In edit mode for participant 0 (applicant), this also adds the personal data fields * that were patched from the applicant, ensuring they're bound to the DTO. * * @param array $participantData The submitted participant data * @param ParticipantDto $participant The cleaned participant DTO * @param int $index The participant index * @param BookingDto $bookingDto The booking DTO for mode detection * * @return array Updated participant data with synchronized field values */ private function syncParticipantData(array $participantData, ParticipantDto $participant, int $index, BookingDto $bookingDto): array { // Sync fields for all registered handlers foreach ($this->handlers as $fieldName => $handler) { // Only sync fields that were in the original submission // This prevents adding extra fields that would cause validation errors if (property_exists($participant, $fieldName) && array_key_exists($fieldName, $participantData)) { $dtoValue = $participant->{$fieldName}; $participantData[$fieldName] = $this->convertDtoValueToSubmittedFormat($dtoValue); } } return $participantData; } /** * Converts DTO field values to the format expected in submitted form data. * * This method handles the conversion from DTO field values to the format * that Symfony forms expect in submitted data. It supports various data types * including service objects, arrays, and primitive values. * * @param mixed $dtoValue The field value from the DTO * * @return mixed The value in submitted data format */ private function convertDtoValueToSubmittedFormat(mixed $dtoValue): mixed { // Handle null values if (null === $dtoValue) { return null; } // Handle arrays (service collections, etc.) if (is_array($dtoValue)) { $submittedFormat = []; foreach ($dtoValue as $item) { $submittedFormat[] = $this->convertSingleValueToSubmittedFormat($item); } return $submittedFormat; } // Handle single values return $this->convertSingleValueToSubmittedFormat($dtoValue); } /** * Checks whether a field handler is allowed to run based on edit-mode mutability flags. */ private function isHandlerMutableForBooking(string $handlerName, BookingDto $bookingDto): bool { if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { return true; } $mutabilityCategory = self::HANDLER_MUTABILITY_CATEGORY_MAP[$handlerName] ?? null; return match ($mutabilityCategory) { self::MUTABILITY_ADDITIONAL_SERVICES => $bookingDto->travel->additionalServicesMutable, self::MUTABILITY_TRANSPORTATION => $bookingDto->travel->transportationServicesMutable, self::MUTABILITY_PICKUPS => $bookingDto->travel->pickupsMutable, default => true, }; } /** * Converts a single DTO value to submitted form format. * * @param mixed $value The value to convert * * @return mixed The converted value */ private function convertSingleValueToSubmittedFormat(mixed $value): mixed { // Handle Service objects -> convert to ID (as string for form compatibility) if ($value instanceof Service) { return (string) $value->id; } // Handle Pickup objects -> convert to ID (as string for form compatibility) if ($value instanceof Pickup) { return (string) $value->id; } // Handle Insurance objects -> convert to ID (as string for form compatibility) if ($value instanceof Insurance) { return (string) $value->id; } // Handle Address objects -> convert to array of properties if ($value instanceof Address) { return [ 'street' => $value->street, 'postCode' => $value->postCode, 'city' => $value->city, 'district' => $value->district, 'country' => $value->country, ]; } // Handle DateTimeInterface -> convert to array format for BirthdayType widget if ($value instanceof \DateTimeInterface) { return [ 'year' => $value->format('Y'), 'month' => $value->format('n'), 'day' => $value->format('j'), ]; } // Handle numeric values if (is_numeric($value)) { return $value; } // Handle strings and other primitive types return $value; } }