using System.Globalization;
namespace w4c_workflows.Services.Triggers;
///
/// Minimal 5-field cron parser (minute hour day-of-month month day-of-week)
/// supporting *, , (list), - (range) and / (step), plus
/// numeric values. Enough for workflow scheduling ("0 */6 * * *",
/// "0 9 * * 1-5"); no seconds/year fields.
///
/// Day-of-week 0 and 7 both mean Sunday. Day matching uses AND semantics across
/// day-of-month and day-of-week, so "0 9 * * 1-5" runs Mon–Fri and
/// "0 0 1 * *" runs on the 1st of the month (the * field matches all
/// values and therefore never filters).
///
public sealed class CronExpression
{
private readonly CronField _minute;
private readonly CronField _hour;
private readonly CronField _dayOfMonth;
private readonly CronField _month;
private readonly CronField _dayOfWeek;
private CronExpression(CronField minute, CronField hour, CronField dayOfMonth, CronField month, CronField dayOfWeek)
{
_minute = minute;
_hour = hour;
_dayOfMonth = dayOfMonth;
_month = month;
_dayOfWeek = dayOfWeek;
}
///
/// Parses a 5-field cron expression. On success is
/// set and is null; otherwise the reverse.
///
public static bool TryParse(string? expression, out CronExpression? parsed, out string? error)
{
parsed = null;
error = null;
if (string.IsNullOrWhiteSpace(expression))
{
error = "cron expression is empty";
return false;
}
var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (fields.Length != 5)
{
error = $"cron expression must have 5 fields, got {fields.Length}";
return false;
}
if (!CronField.TryParse(fields[0], 0, 59, out var minute, out error)) return false;
if (!CronField.TryParse(fields[1], 0, 23, out var hour, out error)) return false;
if (!CronField.TryParse(fields[2], 1, 31, out var dayOfMonth, out error)) return false;
if (!CronField.TryParse(fields[3], 1, 12, out var month, out error)) return false;
// 0–7: 7 is the Sunday alias and is folded into 0 below.
if (!CronField.TryParse(fields[4], 0, 7, out var dayOfWeek, out error)) return false;
dayOfWeek!.AliasValue(7, 0);
parsed = new CronExpression(minute!, hour!, dayOfMonth!, month!, dayOfWeek);
return true;
}
/// Whether the given UTC instant matches this cron expression.
public bool Matches(DateTime utc) =>
_minute.Matches(utc.Minute)
&& _hour.Matches(utc.Hour)
&& _dayOfMonth.Matches(utc.Day)
&& _month.Matches(utc.Month)
&& _dayOfWeek.Matches((int)utc.DayOfWeek);
///
/// The first occurrence strictly after , truncated to
/// the minute and evaluated in UTC. Returns null when no occurrence exists
/// within a bounded 5-year horizon (a guard against malformed expressions
/// such as a 31st-of-the-month-only schedule that never matches).
///
/// The search jumps by field (month → day → hour → minute) instead of walking
/// every minute, so an unmatchable expression costs a few thousand steps
/// rather than ~2.6 million per call.
///
public DateTimeOffset? GetNextOccurrence(DateTimeOffset after)
{
var utc = after.UtcDateTime;
var cursor = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, 0, DateTimeKind.Utc)
.AddMinutes(1);
var horizon = cursor.AddYears(5);
// Safety net for an expression that matches no real instant; the coarse
// jumps above make the real worst case only a few thousand iterations.
var guard = 0;
while (cursor <= horizon)
{
if (++guard > 100_000)
return null;
if (!_month.Matches(cursor.Month))
{
cursor = new DateTime(cursor.Year, cursor.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1);
continue;
}
if (!MatchesDay(cursor))
{
cursor = cursor.Date.AddDays(1);
continue;
}
if (!_hour.Matches(cursor.Hour))
{
cursor = cursor.Date.AddHours(cursor.Hour + 1);
continue;
}
if (!_minute.Matches(cursor.Minute))
{
cursor = cursor.AddMinutes(1);
continue;
}
return new DateTimeOffset(cursor, TimeSpan.Zero);
}
return null;
}
///
/// Day matches when both day-of-month and day-of-week match. A * field
/// matches every value, so the common case behaves as expected.
///
private bool MatchesDay(DateTime utc)
=> _dayOfMonth.Matches(utc.Day) && _dayOfWeek.Matches((int)utc.DayOfWeek);
/// One field of a cron expression, as a fixed-size allowed-value set.
private sealed class CronField
{
private readonly int _min;
private readonly bool[] _allowed;
private CronField(int min, bool[] allowed)
{
_min = min;
_allowed = allowed;
}
public bool Matches(int value) => value >= _min && value - _min < _allowed.Length && _allowed[value - _min];
/// Folds one value into another (Sunday alias 7 → 0).
public void AliasValue(int from, int to)
{
if (from >= _min && from - _min < _allowed.Length)
_allowed[to - _min] |= _allowed[from - _min];
}
public static bool TryParse(string text, int min, int max, out CronField? field, out string? error)
{
field = null;
error = null;
var allowed = new bool[max - min + 1];
foreach (var part in text.Split(','))
{
if (part.Length == 0)
{
error = $"empty cron field segment in '{text}'";
return false;
}
var step = 1;
var range = part;
if (part.Contains('/'))
{
var pieces = part.Split('/');
if (pieces.Length != 2 || !TryParseNumber(pieces[1], out step) || step <= 0)
{
error = $"invalid step in cron field segment '{part}'";
return false;
}
range = pieces[0];
}
int lo, hi;
if (range == "*")
{
lo = min;
hi = max;
}
else if (range.Contains('-'))
{
var pieces = range.Split('-');
if (pieces.Length != 2 || !TryParseNumber(pieces[0], out lo) || !TryParseNumber(pieces[1], out hi))
{
error = $"invalid range in cron field segment '{part}'";
return false;
}
}
else if (TryParseNumber(range, out lo))
{
hi = lo;
}
else
{
error = $"invalid value '{range}' in cron field";
return false;
}
if (lo < min || hi > max || lo > hi)
{
error = $"value out of range [{min},{max}] in cron field segment '{part}'";
return false;
}
for (var value = lo; value <= hi; value += step)
allowed[value - min] = true;
}
field = new CronField(min, allowed);
return true;
}
private static bool TryParseNumber(string text, out int value)
=> int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
}
}