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 { error_log(sprintf('[ProcessFieldsAndSync] Mode: %s', $bookingDto->getMode())); // 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) { $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, (int) $participantIndex)) { $handler->processField($participantData, $bookingDto, (int) $participantIndex); } } } } /** * 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 { error_log(sprintf('[Sync] Participant %d fields in submission: %s', $participant->index ?? -1, implode(', ', array_keys($participantData)))); // 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); error_log(sprintf('[Sync] Participant %d: synced %s', $participant->index ?? -1, $fieldName)); } } 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); } /** * 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 if ($value instanceof Service) { return $value->id; } // Handle Pickup objects -> convert to ID if ($value instanceof Pickup) { return $value->id; } // Handle Insurance objects -> convert to ID if ($value instanceof Insurance) { return $value->id; } // Handle Address objects -> convert to array of properties if ($value instanceof \App\BusProNet\Model\Address) { return [ 'street' => $value->street, 'postCode' => $value->postCode, 'city' => $value->city, 'district' => $value->district, 'country' => $value->country, ]; } // Handle DateTimeInterface -> convert to string format if ($value instanceof \DateTimeInterface) { return $value->format('Y-m-d'); } // Handle numeric values if (is_numeric($value)) { return $value; } // Handle strings and other primitive types return $value; } }