* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXF\Chrono\Entity; use DateTimeImmutable; use DateTimeInterface; use KTXF\Utile\Timestamp; final class EventRangeMetadata { public static function fromStoreDocument(array $document): array { $entity = $document['data'] ?? null; if (!is_array($entity) || !isset($entity['startsOn'])) { return []; } try { $startsOn = new DateTimeImmutable($entity['startsOn']); $endsOn = isset($entity['endsOn']) ? new DateTimeImmutable($entity['endsOn']) : null; } catch (\Exception) { return []; } $rangeStart = $startsOn; $rangeEnd = $endsOn; $pattern = $entity['pattern'] ?? null; if (is_array($pattern)) { $rangeEnd = self::constructRecurringRangeEnd($startsOn, $endsOn, $pattern); } foreach (($entity['mutations'] ?? []) as $mutation) { if (!is_array($mutation) || ($mutation['mutationExclusion'] ?? false)) { continue; } try { if (isset($mutation['startsOn'])) { $mutationStart = new DateTimeImmutable($mutation['startsOn']); if ($mutationStart < $rangeStart) { $rangeStart = $mutationStart; } } if ($rangeEnd !== null && isset($mutation['endsOn'])) { $mutationEnd = new DateTimeImmutable($mutation['endsOn']); if ($mutationEnd > $rangeEnd) { $rangeEnd = $mutationEnd; } } } catch (\Exception) { continue; } } return [ 'startsOn' => Timestamp::toEpoch($rangeStart), 'endsOn' => $rangeEnd !== null ? Timestamp::toEpoch($rangeEnd) : null, ]; } private static function parseRangeDateTime(mixed $value): ?DateTimeImmutable { if (!is_string($value) || trim($value) === '') { return null; } try { return new DateTimeImmutable($value); } catch (\Exception) { return null; } } private static function parseConclusionBoundary(mixed $value, DateTimeImmutable $reference): ?DateTimeImmutable { if (!is_string($value) || trim($value) === '') { return null; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) { return DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s.u', sprintf('%s 23:59:59.999000', $value), $reference->getTimezone() ) ?: null; } return self::parseRangeDateTime($value); } private static function recurringDuration(DateTimeImmutable $startsOn, ?DateTimeImmutable $endsOn): ?\DateInterval { if ($endsOn === null || $endsOn <= $startsOn) { return null; } return $startsOn->diff($endsOn); } private static function positiveIntegerList(mixed $value): array { if (!is_array($value)) { return []; } $list = []; foreach ($value as $entry) { if (!is_numeric($entry)) { continue; } $integer = (int)$entry; if ($integer > 0) { $list[$integer] = $integer; } } ksort($list); return array_values($list); } private static function signedIntegerList(mixed $value): array { if (!is_array($value)) { return []; } $list = []; foreach ($value as $entry) { if (!is_numeric($entry)) { continue; } $integer = (int)$entry; if ($integer !== 0) { $list[$integer] = $integer; } } ksort($list); return array_values($list); } private static function recurrenceWeekdays(array $pattern, DateTimeImmutable $startsOn): array { $weekdays = self::positiveIntegerList($pattern['onDayOfWeek'] ?? null); if ($weekdays === []) { $weekdays = [(int)$startsOn->format('N')]; } return array_values(array_filter($weekdays, static fn(int $value): bool => $value >= 1 && $value <= 7)); } private static function recurrenceMonths(array $pattern, DateTimeImmutable $startsOn): array { $months = self::positiveIntegerList($pattern['onMonthOfYear'] ?? null); if ($months === []) { $months = [(int)$startsOn->format('n')]; } return array_values(array_filter($months, static fn(int $value): bool => $value >= 1 && $value <= 12)); } private static function monthCandidatesAbsolute(DateTimeImmutable $periodStart, array $days, DateTimeImmutable $startsOn): array { $days = $days !== [] ? $days : [(int)$startsOn->format('j')]; $lastDay = (int)$periodStart->format('t'); $candidates = []; foreach ($days as $day) { if ($day < 1 || $day > $lastDay) { continue; } $candidates[] = $periodStart->setDate( (int)$periodStart->format('Y'), (int)$periodStart->format('n'), $day ); } usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right); return $candidates; } private static function nthWeekdayOfMonth(DateTimeImmutable $periodStart, int $weekday, int $position): ?DateTimeImmutable { if ($weekday < 1 || $weekday > 7 || $position === 0) { return null; } $year = (int)$periodStart->format('Y'); $month = (int)$periodStart->format('n'); $lastDay = (int)$periodStart->format('t'); if ($position > 0) { $firstDayWeekday = (int)$periodStart->format('N'); $offset = ($weekday - $firstDayWeekday + 7) % 7; $day = 1 + $offset + (($position - 1) * 7); if ($day > $lastDay) { return null; } return $periodStart->setDate($year, $month, $day); } $lastDayDate = $periodStart->setDate($year, $month, $lastDay); $lastWeekday = (int)$lastDayDate->format('N'); $offset = ($lastWeekday - $weekday + 7) % 7; $day = $lastDay - $offset; $steps = abs($position) - 1; $day -= $steps * 7; if ($day < 1) { return null; } return $periodStart->setDate($year, $month, $day); } private static function monthCandidatesRelative(DateTimeImmutable $periodStart, array $weekdays, array $positions, DateTimeImmutable $startsOn): array { $weekdays = $weekdays !== [] ? $weekdays : [(int)$startsOn->format('N')]; $positions = $positions !== [] ? $positions : [self::positionInMonth($startsOn)]; $candidates = []; foreach ($positions as $position) { foreach ($weekdays as $weekday) { $candidate = self::nthWeekdayOfMonth($periodStart, $weekday, $position); if ($candidate !== null) { $candidates[$candidate->format(DateTimeInterface::ATOM)] = $candidate; } } } $candidates = array_values($candidates); usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right); return $candidates; } private static function positionInMonth(DateTimeImmutable $date): int { $day = (int)$date->format('j'); $lastDay = (int)$date->format('t'); return ($day + 7 > $lastDay) ? -1 : (int)ceil($day / 7); } private static function recurringCandidateStarts(DateTimeImmutable $startsOn, array $pattern, ?DateTimeImmutable $concludes, ?int $iterations): array { $precision = is_string($pattern['precision'] ?? null) ? strtolower((string)$pattern['precision']) : ''; $interval = max(1, (int)($pattern['interval'] ?? 1)); $occurrences = []; $limit = $iterations ?? 512; $append = function (DateTimeImmutable $candidate) use (&$occurrences, $startsOn, $concludes, $iterations): bool { if ($candidate < $startsOn) { return true; } if ($concludes !== null && $candidate > $concludes) { return false; } $occurrences[] = $candidate; return $iterations === null || count($occurrences) < $iterations; }; switch ($precision) { case 'secondly': case 'minutely': case 'hourly': case 'daily': $step = 0; $weekdays = $precision === 'daily' && !empty($pattern['onDayOfWeek']) ? self::recurrenceWeekdays($pattern, $startsOn) : []; while (count($occurrences) < $limit) { $candidate = match ($precision) { 'secondly' => $startsOn->modify(sprintf('+%d seconds', $step * $interval)), 'minutely' => $startsOn->modify(sprintf('+%d minutes', $step * $interval)), 'hourly' => $startsOn->modify(sprintf('+%d hours', $step * $interval)), default => $startsOn->modify(sprintf('+%d days', $step * $interval)), }; if ($candidate === false) { break; } if ($precision === 'daily' && $weekdays !== [] && !in_array((int)$candidate->format('N'), $weekdays, true)) { $step++; if ($concludes !== null && $candidate > $concludes) { break; } continue; } if (!$append($candidate)) { break; } $step++; } break; case 'weekly': $weekdays = self::recurrenceWeekdays($pattern, $startsOn); $weekStart = $startsOn->modify(sprintf('-%d days', ((int)$startsOn->format('N')) - 1)); if ($weekStart === false) { break; } for ($week = 0; count($occurrences) < $limit; $week++) { $periodStart = $weekStart->modify(sprintf('+%d days', $week * $interval * 7)); if ($periodStart === false) { break; } foreach ($weekdays as $weekday) { $candidate = $periodStart->modify(sprintf('+%d days', $weekday - 1)); if ($candidate === false) { continue; } if (!$append($candidate)) { break 2; } } if ($concludes !== null && $periodStart > $concludes) { break; } } break; case 'monthly': $isRelative = strtolower((string)($pattern['pattern'] ?? 'absolute')) === 'relative'; $days = self::positiveIntegerList($pattern['onDayOfMonth'] ?? null); $weekdays = self::recurrenceWeekdays($pattern, $startsOn); $positions = self::signedIntegerList($pattern['onPosition'] ?? null); $monthAnchor = $startsOn->setDate((int)$startsOn->format('Y'), (int)$startsOn->format('n'), 1); for ($month = 0; count($occurrences) < $limit; $month++) { $periodStart = $monthAnchor->modify(sprintf('+%d months', $month * $interval)); if ($periodStart === false) { break; } $candidates = $isRelative ? self::monthCandidatesRelative($periodStart, $weekdays, $positions, $startsOn) : self::monthCandidatesAbsolute($periodStart, $days, $startsOn); foreach ($candidates as $candidate) { if (!$append($candidate)) { break 2; } } if ($concludes !== null && $periodStart > $concludes) { break; } } break; case 'yearly': $isRelative = strtolower((string)($pattern['pattern'] ?? 'absolute')) === 'relative'; $months = self::recurrenceMonths($pattern, $startsOn); $days = self::positiveIntegerList($pattern['onDayOfMonth'] ?? null); $weekdays = self::recurrenceWeekdays($pattern, $startsOn); $positions = self::signedIntegerList($pattern['onPosition'] ?? null); $yearAnchor = $startsOn->setDate((int)$startsOn->format('Y'), 1, 1); for ($year = 0; count($occurrences) < $limit; $year++) { $periodYear = $yearAnchor->modify(sprintf('+%d years', $year * $interval)); if ($periodYear === false) { break; } $candidates = []; foreach ($months as $month) { $periodStart = $periodYear->setDate((int)$periodYear->format('Y'), $month, 1); foreach ( $isRelative ? self::monthCandidatesRelative($periodStart, $weekdays, $positions, $startsOn) : self::monthCandidatesAbsolute($periodStart, $days, $startsOn) as $candidate ) { $candidates[$candidate->format(DateTimeInterface::ATOM)] = $candidate; } } $candidates = array_values($candidates); usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right); foreach ($candidates as $candidate) { if (!$append($candidate)) { break 2; } } if ($concludes !== null && $periodYear > $concludes) { break; } } break; } return $occurrences; } private static function constructRecurringRangeEnd(DateTimeImmutable $startsOn, ?DateTimeImmutable $endsOn, array $pattern): ?DateTimeImmutable { $iterations = isset($pattern['iterations']) && is_numeric($pattern['iterations']) && (int)$pattern['iterations'] > 0 ? (int)$pattern['iterations'] : null; $concludes = self::parseConclusionBoundary($pattern['concludes'] ?? null, $startsOn); if ($iterations === null && $concludes === null) { return null; } $occurrences = self::recurringCandidateStarts($startsOn, $pattern, $concludes, $iterations); $lastStart = $occurrences !== [] ? end($occurrences) : $startsOn; if (!$lastStart instanceof DateTimeImmutable) { return null; } $duration = self::recurringDuration($startsOn, $endsOn); return $duration !== null ? $lastStart->add($duration) : $lastStart; } }