$start, 'end' => $end < $start ? $end + 86400 : $end ); } private static function clock( int $seconds ): string { $seconds %= 86400; return sprintf( '%02d:%02d:%02d', intdiv( $seconds, 3600 ), intdiv( $seconds % 3600, 60 ), $seconds % 60 ); } public static function normalize_entry( array $entry ): array { if ( array_key_exists( 'periods', $entry ) ) { if ( ! is_array( $entry['periods'] ) || count( $entry['periods'] ) > self::MAX_PERIODS ) { throw new InvalidArgumentException( 'Invalid schedule periods.' ); } $offsets = array(); foreach ( $entry['periods'] as $period ) { if ( ! is_array( $period ) ) { throw new InvalidArgumentException( 'Invalid schedule period.' ); } $offset = self::offset( $period ); if ( isset( $period['day_offset'] ) && ! in_array( (string) $period['day_offset'], array( '0', '1' ), true ) ) { throw new InvalidArgumentException( 'Invalid schedule anchor.' ); } if ( ! empty( $period['day_offset'] ) ) { $offset['start'] += 86400; $offset['end'] += 86400; } if ( $offset['end'] > 172800 ) { throw new InvalidArgumentException( 'Invalid schedule anchor.' ); } $offsets[] = $offset; } usort( $offsets, static fn( $a, $b )=>$a['start'] <=> $b['start'] ); $last = null; foreach ( $offsets as $period ) { if ( null !== $last && $period['start'] < $last ) { throw new InvalidArgumentException( 'Overlapping schedule periods.' ); } $last = $period['end']; } } else { $working = $entry['working'] ?? null; $offsets = $working ? array( self::offset( $working ) ) : array(); $original = $offsets; $breaks = $entry['breaks'] ?? array(); if ( ! is_array( $breaks ) ) { throw new InvalidArgumentException( 'Invalid schedule breaks.' ); } if ( ! empty( $entry['break'] ) ) { $breaks[] = $entry['break']; } foreach ( $breaks as $break ) { $block = self::offset( $break ); if ( $original && $block['start'] < $original[0]['start'] && $original[0]['end'] > 86400 ) { $block['start'] += 86400; $block['end'] += 86400; } if ( ! self::covers( $block['start'], $block['end'], $original ) ) { throw new InvalidArgumentException( 'Break outside working hours.' ); } $offsets = self::subtract( $offsets, $block ); } } if ( count( $offsets ) > self::MAX_PERIODS ) { throw new InvalidArgumentException( 'Too many schedule periods.' ); } $periods = array(); foreach ( $offsets as $period ) { if ( $period['start'] >= 86400 ) { /* A legacy break may leave an after-midnight-only tail: retain its anchor. */$periods[] = array( 'start_time' => self::clock( $period['start'] ), 'end_time' => self::clock( $period['end'] ), 'day_offset' => 1 ); } else { $periods[] = array( 'start_time' => self::clock( $period['start'] ), 'end_time' => self::clock( $period['end'] ) ); } } return array( 'periods' => $periods ); } public static function period_offsets( array $entry ): array { $out = array(); foreach ( self::normalize_entry( $entry )['periods'] as $period ) { $offset = self::offset( $period ); if ( ! empty( $period['day_offset'] ) ) { $offset['start'] += 86400; $offset['end'] += 86400; } $out[] = $offset; } return $out; } public static function intervals( array $entry, DateTimeImmutable $anchor ): array { $out = array(); foreach ( self::period_offsets( $entry ) as $period ) { $resolved = array(); foreach ( array( 'start', 'end' ) as $key ) { $date = $anchor->setTime( 0, 0 )->modify( '+' . intdiv( $period[ $key ], 86400 ) . ' days' )->format( 'Y-m-d' ); $wall = $date . ' ' . self::clock( $period[ $key ] ); $instant = new DateTimeImmutable( $wall, $anchor->getTimezone() ); if ( $instant->format( 'Y-m-d H:i:s' ) !== $wall ) { continue 2; } $resolved[ $key ] = $instant; } if ( $resolved['start'] < $resolved['end'] ) { $out[] = $resolved; } } return self::merge( $out ); } public static function merge( array $intervals ): array { usort( $intervals, static fn( $a, $b )=>$a['start'] <=> $b['start'] ); $out = array(); foreach ( $intervals as $interval ) { $key = array_key_last( $out ); if ( null === $key || $out[ $key ]['end'] < $interval['start'] ) { $out[] = $interval; } elseif ( $interval['end'] > $out[ $key ]['end'] ) { $out[ $key ]['end'] = $interval['end']; } } return $out; } public static function subtract( array $windows, array $block ): array { $out = array(); foreach ( $windows as $window ) { if ( $block['end'] <= $window['start'] || $block['start'] >= $window['end'] ) { $out[] = $window; continue; } if ( $block['start'] > $window['start'] ) { $out[] = array( 'start' => $window['start'], 'end' => $block['start'] ); } if ( $block['end'] < $window['end'] ) { $out[] = array( 'start' => $block['end'], 'end' => $window['end'] ); } } return $out; } public static function intersect( array $left, array $right ): array { $out = array(); foreach ( $left as $a ) { foreach ( $right as $b ) { $start = max( $a['start'], $b['start'] ); $end = min( $a['end'], $b['end'] ); if ( $start < $end ) { $out[] = array( 'start' => $start, 'end' => $end ); } } } return self::merge( $out ); } public static function covers( $start, $end, array $windows ): bool { foreach ( self::merge( $windows ) as $window ) { if ( $start >= $window['start'] && $end <= $window['end'] ) { return true; } } return false; } }