JavaScript Python PHP
import * as __biblioteca0 from "cron-parser";
import * as __biblioteca1 from "@js-temporal/polyfill";
function executar(expression = '*/5 * * * *', reference = '2024-01-01T00:00:00Z', timezone = 'America/Sao_Paulo') {
const CronExpressionParser = __biblioteca0["CronExpressionParser"];
const Temporal = __biblioteca1["Temporal"];
const { validateTimeZone: validateTimeZone, parseIsoInstant: parseIsoInstant, localToInstant: localToInstant } = (() => {
const Temporal = __biblioteca1["Temporal"];
function yearRange(year) {
if (year < 1 || year > 9999)
throw new Error('O intervalo suportado é de 0001 a 9999, inclusive no UTC e no fuso selecionado.');
}
function validateTimeZone(zone) {
const value = zone.trim();
try {
if (!value || /^[+-]/.test(value))
throw new Error();
new Intl.DateTimeFormat('en', { timeZone: value }).format(0);
Temporal.Instant.fromEpochMilliseconds(0).toZonedDateTimeISO(value);
return value;
}
catch {
throw new Error('Informe um fuso IANA válido, como America/Sao_Paulo, America/New_York ou UTC.');
}
}
function plain(input, br = false, dateOnly = false) {
const pattern = br ? /^(\d{2})\/(\d{2})\/(\d{4})(?: (\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?)?$/ : /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?)?$/;
const match = input.match(pattern);
if (!match || (!dateOnly && !match[4]) || (dateOnly && match[4]))
throw new Error(`Formato esperado: ${br ? 'DD/MM/YYYY' : 'YYYY-MM-DD'}${dateOnly ? '' : ' HH:mm:ss[.SSS]'}.`);
const year = Number(match[br ? 3 : 1]);
yearRange(year);
try {
return Temporal.PlainDateTime.from({ year, month: Number(match[2]), day: Number(match[br ? 1 : 3]), hour: Number(match[4] ?? 0), minute: Number(match[5] ?? 0), second: Number(match[6] ?? 0), millisecond: Number((match[7] ?? '').padEnd(3, '0') || 0) }, { overflow: 'reject' });
}
catch {
throw new Error('Data ou horário impossível. Verifique dia, mês, ano bissexto e horário (00:00:00 a 23:59:59.999).');
}
}
function checkInstant(instant) {
yearRange(instant.toZonedDateTimeISO('UTC').year);
if (instant.epochNanoseconds % BigInt(1000000) !== BigInt(0))
throw new Error('A precisão máxima aceita é de milissegundos (até três casas decimais nos segundos).');
return instant;
}
function parseIsoInstant(input) {
const match = input.trim().match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)(Z|[+-]\d{2}:\d{2}(?::\d{2})?)$/);
if (!match)
throw new Error('Use YYYY-MM-DDTHH:mm:ss[.SSS]Z ou offset explícito ±HH:mm[:ss]. Precisão máxima: milissegundos.');
plain(match[1]);
try {
return checkInstant(Temporal.Instant.from(match[1] + match[2]));
}
catch (cause) {
throw new Error(`Instante ISO inválido ou fora do intervalo: ${cause instanceof Error ? cause.message : 'verifique o offset'}`);
}
}
function localToInstant(input, zone, br = false) {
const local = plain(input.trim(), br);
const timeZone = validateTimeZone(zone);
try {
return checkInstant(local.toZonedDateTime(timeZone, { disambiguation: 'reject' }).toInstant());
}
catch (cause) {
const earlier = local.toZonedDateTime(timeZone, { disambiguation: 'earlier' });
const later = local.toZonedDateTime(timeZone, { disambiguation: 'later' });
if (earlier.epochNanoseconds !== later.epochNanoseconds) {
const ambiguous = earlier.toPlainDateTime().equals(local) && later.toPlainDateTime().equals(local);
throw new Error(`Horário local ${ambiguous ? 'ambíguo (ocorre duas vezes)' : 'inexistente'} em ${timeZone} por uma transição do fuso. Informe um instante ISO com offset explícito em uma ferramenta que aceite esse formato.`);
}
throw cause;
}
}
return { validateTimeZone: validateTimeZone, parseIsoInstant: parseIsoInstant, localToInstant: localToInstant };
})();
const names = ['Minuto', 'Hora', 'Dia do mês', 'Mês', 'Dia da semana'];
const limits = ['0–59', '0–23', '1–31', '1–12', '0–7; domingo 0/7'];
const keys = ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'];
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
const weekdays = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
function parseCron(expression, reference, timezone) {
if (expression.length > 500)
throw new Error('Expressão muito longa: máximo de 500 caracteres.');
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5)
throw new Error('Use exatamente cinco campos: minuto hora dia-do-mês mês dia-da-semana. Seis/sete campos e macros não são suportados.');
const normalized = parts.map((part, index) => {
const aliases = index === 3 ? months : index === 4 ? weekdays : [];
const value = part.toUpperCase().replace(/[A-Z]+/g, name => aliases.includes(name) ? String(aliases.indexOf(name) + (index === 3 ? 1 : 0)) : name);
if (!/^(?:\*|\d+(?:-\d+)?)(?:\/\d+)?(?:,(?:\*|\d+(?:-\d+)?)(?:\/\d+)?)*$/.test(value))
throw new Error(`${names[index]}: sintaxe não suportada. Use *, listas, intervalos e passos; não use ?, L, W, #, H ou outros dialetos.`);
return value;
});
const zone = validateTimeZone(timezone);
const start = /(?:Z|[+-]\d{2}:\d{2}(?::\d{2})?)$/.test(reference.trim()) ? parseIsoInstant(reference) : localToInstant(reference, zone);
const end = start.toZonedDateTimeISO(zone).add({ years: 50 }).toInstant();
const endMs = Math.min(end.epochMilliseconds, 253402300799999);
let schedule;
try {
schedule = CronExpressionParser.parse(normalized.join(' '), { currentDate: new Date(start.epochMilliseconds), endDate: new Date(endMs), tz: zone });
}
catch (cause) {
throw new Error(`Expressão cron inválida: ${cause instanceof Error ? cause.message : 'verifique os limites dos campos'}`);
}
const fields = keys.map((key, index) => ({ label: names[index], expression: parts[index], meaning: `Limites: ${limits[index]}. Valores aceitos: ${schedule.fields[key].values.join(', ')}${index === 4 ? ' (0 = domingo, 1 = segunda, …, 6 = sábado)' : ''}.` }));
const executions = [];
try {
let previous = start.epochMilliseconds;
for (let index = 0; index < 10; index++) {
const date = schedule.next();
const instant = Temporal.Instant.fromEpochMilliseconds(date.getTime());
if (instant.epochMilliseconds <= previous)
throw new Error('Resultado não posterior à referência.');
const local = instant.toZonedDateTimeISO(zone);
if (local.year < 1 || local.year > 9999)
throw new Error('Ano fora do intervalo 0001–9999.');
executions.push({ local: local.toString({ fractionalSecondDigits: 3 }), utc: instant.toString({ fractionalSecondDigits: 3 }) });
previous = instant.epochMilliseconds;
}
}
catch {
throw new Error('Não foi possível encontrar dez execuções no limite de busca de 50 anos/10.000 iterações por ocorrência, ou no intervalo 0001–9999. A expressão pode não ter ocorrência possível.');
}
return { description: 'Execução nos minutos, horas e meses indicados abaixo, conforme a regra dos campos de dia. Consulte a explicação por campo; não é uma tarefa agendada.', fields, executions };
}
return parseCron(expression, reference, timezone);
}
import re
from datetime import datetime, timedelta, timezone as timezone_module
from zoneinfo import ZoneInfo
from croniter import croniter
def executar(expression='*/5 * * * *', reference='2024-01-01T00:00:00Z', timezone='America/Sao_Paulo'):
def zone(value):
value = value.strip()
if not value or value.startswith(("+", "-")): raise ValueError("Informe um fuso IANA válido.")
try: return ZoneInfo(value)
except Exception as error: raise ValueError("Informe um fuso IANA válido.") from error
def plain(text, br=False, date_only=False):
pattern = r"([0-9]{2})/([0-9]{2})/([0-9]{4})" if br else r"([0-9]{4})-([0-9]{2})-([0-9]{2})"
match = re.fullmatch(pattern + ("" if date_only else (" " if br else "[ T]") + r"([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,3}))?"), text)
if not match: raise ValueError("Use DD/MM/YYYY" if br else "Use YYYY-MM-DD, com HH:mm:ss[.SSS] para data/hora.")
first, month, third = map(int, match.groups()[:3])
year, day = (third, first) if br else (first, third)
parts = [0,0,0,0] if date_only else [int(match[4]), int(match[5]), int(match[6]), int((match[7] or "").ljust(3, "0") or "0")*1000]
try: return datetime(year, month, day, *parts)
except ValueError as error: raise ValueError("Data ou horário impossível.") from error
def iso(text):
match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,3})?)(Z|[+-][0-9]{2}:[0-9]{2}(?::[0-9]{2})?)", text.strip())
if not match: raise ValueError("Use ISO com Z ou offset explícito ±HH:mm[:ss], até milissegundos.")
local = plain(match[1])
offset = 0
if match[2] != "Z":
values = list(map(int, match[2][1:].split(":")))
if values[0] > 23 or values[1] > 59 or (len(values) == 3 and values[2] > 59): raise ValueError("Offset inválido.")
offset = (values[0]*3600 + values[1]*60 + (values[2] if len(values) == 3 else 0)) * (-1 if match[2][0] == "-" else 1)
try: return (local - timedelta(seconds=offset)).replace(tzinfo=timezone_module.utc)
except OverflowError as error: raise ValueError("Intervalo suportado: anos 0001 a 9999.") from error
def local_instant(text, selected, br=False):
local, tz = plain(text.strip(), br), zone(selected)
candidates = set()
for fold in (0,1):
try:
instant = local.replace(tzinfo=tz, fold=fold).astimezone(timezone_module.utc)
if instant.astimezone(tz).replace(tzinfo=None) == local: candidates.add(instant)
except (ValueError, OverflowError): pass
if len(candidates) != 1: raise ValueError("Horário local ambíguo (ocorre duas vezes)." if candidates else "Horário local inexistente ou fora do intervalo 0001–9999.")
return next(iter(candidates))
def utc_text(instant):
return f"{instant.year:04d}-{instant.month:02d}-{instant.day:02d}T{instant.hour:02d}:{instant.minute:02d}:{instant.second:02d}.{instant.microsecond//1000:03d}Z"
def offset_text(local):
seconds = int(local.utcoffset().total_seconds())
sign = "-" if seconds < 0 else "+"
hours, rest = divmod(abs(seconds), 3600)
minutes, seconds = divmod(rest, 60)
return f"{sign}{hours:02d}:{minutes:02d}" + (f":{seconds:02d}" if seconds else "")
def local_text(local, br=False):
day = f"{local.day:02d}/{local.month:02d}/{local.year:04d}" if br else f"{local.year:04d}-{local.month:02d}-{local.day:02d}"
return f"{day} {local.hour:02d}:{local.minute:02d}:{local.second:02d}.{local.microsecond//1000:03d}"
def timestamp(instant, unit):
if unit not in ("Segundos", "Milissegundos"): raise ValueError("Selecione segundos ou milissegundos.")
delta = instant - datetime(1970,1,1,tzinfo=timezone_module.utc)
ms = delta.days*86400000 + delta.seconds*1000 + delta.microseconds//1000
if unit == "Milissegundos": return str(ms)
whole, fraction = divmod(abs(ms),1000)
return ("-" if ms < 0 else "") + str(whole) + (f".{fraction:03d}" if fraction else "")
def from_timestamp(text, unit):
if unit not in ("Segundos", "Milissegundos"): raise ValueError("Selecione segundos ou milissegundos.")
match = re.fullmatch(r"([+-]?)([0-9]+)" + (r"(?:\.([0-9]{1,3}))?" if unit == "Segundos" else ""), text.strip())
if len(text) > 100 or not match: raise ValueError("Timestamp inválido: segundos até três casas; milissegundos inteiros.")
ms = int(match[2]) * (1000 if unit == "Segundos" else 1)
if unit == "Segundos": ms += int((match[3] or "").ljust(3, "0") or "0")
if match[1] == "-": ms = -ms
if not -62135596800000 <= ms <= 253402300799999: raise ValueError("Intervalo suportado: anos 0001 a 9999.")
return datetime(1970,1,1,tzinfo=timezone_module.utc) + timedelta(milliseconds=ms)
def results(instant, selected, unit):
tz = zone(selected)
try: local = instant.astimezone(tz)
except (ValueError, OverflowError) as error: raise ValueError("Intervalo suportado: anos 0001 a 9999 em UTC e local.") from error
offset = offset_text(local)
return [{"label":"Timestamp — " + unit.lower(), "value":timestamp(instant,unit)}, {"label":"UTC — ISO com Z", "value":utc_text(instant)}, {"label":f"Local — {selected.strip()} (offset {offset})", "value":f"{local_text(local)} {offset} [{selected.strip()}]"}]
if len(expression)>500: raise ValueError("Expressão muito longa: máximo de 500 caracteres.")
parts=expression.split()
if len(parts)!=5: raise ValueError("Use exatamente cinco campos; sem macros, segundos ou ano.")
names=["Minuto","Hora","Dia do mês","Mês","Dia da semana"]
limits=["0–59","0–23","1–31","1–12","0–7; domingo 0/7"]
bounds=[(0,59),(0,23),(1,31),(1,12),(0,7)]
aliases=[{}, {}, {}, dict(zip("JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(),range(1,13))), dict(zip("SUN MON TUE WED THU FRI SAT".split(),range(7)))]
expanded=[]
for i,part in enumerate(parts):
token=re.sub(r"[A-Z]+",lambda match:str(aliases[i].get(match[0],match[0])),part.upper())
if not re.fullmatch(r"(?:\*|[0-9]+(?:-[0-9]+)?)(?:/[0-9]+)?(?:,(?:\*|[0-9]+(?:-[0-9]+)?)(?:/[0-9]+)?)*",token): raise ValueError(names[i]+": sintaxe não suportada; use *, listas, intervalos e passos.")
accepted=set()
minimum,maximum=bounds[i]
for term in token.split(","):
base,*step=term.split("/")
stride=int(step[0]) if step else 1
if stride<1: raise ValueError("Passo deve ser positivo.")
if base=="*": lower,upper=minimum,maximum
elif "-" in base: lower,upper=map(int,base.split("-"))
else: lower=int(base); upper=maximum if step else lower
if lower<minimum or upper>maximum or lower>upper: raise ValueError(names[i]+": limite ou intervalo inválido.")
accepted.update(range(lower,upper+1,stride))
if i==4 and token=="7": accepted={0}
expanded.append(sorted(accepted))
selected=timezone.strip()
tz=zone(selected)
start=iso(reference) if re.search(r"(Z|[+-][0-9]{2}:[0-9]{2}(?::[0-9]{2})?)$",reference.strip()) else local_instant(reference,selected)
current=start.astimezone(tz).replace(tzinfo=None)
canonical=[",".join(map(str,values)) for values in expanded]
for i in (2,4):
if parts[i]=="*": canonical[i]="*"
schedules=[canonical]
if parts[2]!="*" and parts[4]!="*":
schedules=[canonical[:4]+["*"], canonical[:2]+["*"]+canonical[3:]]
iterators=[croniter(" ".join(fields),current,ret_type=datetime,max_years_between_matches=50) for fields in schedules]
pending=[iterator.get_next(datetime) for iterator in iterators]
executions=[]; previous=start
ceiling=min(start.year+50,9999)
for attempt in range(100000):
if len(executions)==10: break
candidate=min(pending)
if candidate.year>ceiling: raise ValueError("Sem dez ocorrências no limite de busca de 50 anos.")
for i,value in enumerate(pending):
if value==candidate:
try: pending[i]=iterators[i].get_next(datetime)
except Exception as error: raise ValueError("Sem dez ocorrências no limite de busca.") from error
# First fold for repeated time; imaginary time moves forward by the gap.
instant=candidate.replace(tzinfo=tz,fold=0).astimezone(timezone_module.utc)
if instant<=previous: continue
local=instant.astimezone(tz)
executions.append({"local":local_text(local).replace(" ","T")+offset_text(local)+"["+selected+"]","utc":utc_text(instant)})
previous=instant
if len(executions)!=10: raise ValueError("Limite de 100.000 candidatos; expressão sem ocorrência possível.")
fields=[{"label":names[i],"expression":parts[i],"meaning":f"Limites: {limits[i]}. Valores aceitos: {', '.join(map(str,values))}"+(" (0 = domingo, 1 = segunda, …, 6 = sábado)" if i==4 else "")+"."} for i,values in enumerate(expanded)]
return {"description":"Execução nos minutos, horas e meses indicados abaixo, conforme a regra dos campos de dia. Consulte a explicação por campo; não é uma tarefa agendada.","fields":fields,"executions":executions}
<?php
use Cron\CronExpression;
require_once 'vendor/autoload.php';
function executar($expression = '*/5 * * * *', $reference = '2024-01-01T00:00:00Z', $timezone = 'America/Sao_Paulo') {
if (PHP_INT_SIZE < 8) throw new RuntimeException('Requer PHP de 64 bits.');
$zone = function ($value) {
$value = trim($value);
if (!in_array($value, DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC), true) && $value !== 'UTC') throw new InvalidArgumentException('Informe um fuso IANA válido.');
return new DateTimeZone($value);
};
$plain = function ($text, $br = false, $dateOnly = false) {
$datePattern = $br ? '([0-9]{2})/([0-9]{2})/([0-9]{4})' : '([0-9]{4})-([0-9]{2})-([0-9]{2})';
if (!preg_match('~^'.$datePattern.($dateOnly ? '' : ($br ? ' ' : '[ T]').'([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,3}))?').'$~D', $text, $m)) throw new InvalidArgumentException('Formato de data inválido; use datas explícitas e HH:mm:ss[.SSS].');
[$y,$month,$day] = $br ? [(int)$m[3],(int)$m[2],(int)$m[1]] : [(int)$m[1],(int)$m[2],(int)$m[3]];
$h = (int)($m[4] ?? 0); $minute = (int)($m[5] ?? 0); $second = (int)($m[6] ?? 0);
if ($y < 1 || $y > 9999 || !checkdate($month,$day,$y) || $h > 23 || $minute > 59 || $second > 59) throw new InvalidArgumentException('Data ou horário impossível.');
$normalized = sprintf('%04d-%02d-%02d %02d:%02d:%02d.%s',$y,$month,$day,$h,$minute,$second,str_pad($m[7] ?? '',3,'0'));
return DateTimeImmutable::createFromFormat('!Y-m-d H:i:s.v',$normalized,new DateTimeZone('UTC'));
};
$check = function ($instant) {
$year = (int)$instant->format('Y');
if ($year < 1 || $year > 9999) throw new InvalidArgumentException('Intervalo suportado: anos 0001 a 9999 em UTC e local.');
return $instant;
};
$fromMs = function ($ms) use ($check) {
$seconds = intdiv($ms,1000); $rest = $ms % 1000;
if ($rest < 0) { $seconds--; $rest += 1000; }
return $check((new DateTimeImmutable('@'.$seconds))->setTimezone(new DateTimeZone('UTC'))->modify('+'.$rest.' milliseconds'));
};
$iso = function ($text) use ($plain,$check) {
if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,3})?)(Z|[+-][0-9]{2}:[0-9]{2}(?::[0-9]{2})?)$/D',trim($text),$m)) throw new InvalidArgumentException('Use ISO com Z ou offset explícito; até milissegundos.');
$local = $plain($m[1]); $offset = 0;
if ($m[2] !== 'Z') {
$parts = array_map('intval',explode(':',substr($m[2],1)));
if ($parts[0] > 23 || $parts[1] > 59 || ($parts[2] ?? 0) > 59) throw new InvalidArgumentException('Offset inválido.');
$offset = ($parts[0]*3600 + $parts[1]*60 + ($parts[2] ?? 0))*($m[2][0] === '-' ? -1 : 1);
}
return $check($local->modify(sprintf('%+d seconds',-$offset)));
};
$localInstant = function ($text,$selected,$br = false) use ($plain,$zone,$check) {
$local = $plain(trim($text),$br); $tz = $zone($selected); $wall = $local->getTimestamp();
$offsets = [];
foreach ($tz->getTransitions($wall-172800,$wall+172800) ?: [] as $transition) $offsets[$transition['offset']] = true;
$candidates = [];
foreach (array_keys($offsets) as $offset) {
$candidate = $local->modify(sprintf('%+d seconds',-$offset));
if ($candidate->setTimezone($tz)->format('Y-m-d H:i:s.v') === $local->format('Y-m-d H:i:s.v')) $candidates[$candidate->format('U.v')] = $candidate;
}
if (count($candidates) !== 1) throw new InvalidArgumentException($candidates ? 'Horário local ambíguo (ocorre duas vezes).' : 'Horário local inexistente.');
return $check(array_values($candidates)[0]);
};
$utcText = fn($instant) => $instant->format('Y-m-d\TH:i:s.v\Z');
$offsetText = function ($local) {
$offset = $local->getOffset(); $rest = abs($offset);
return sprintf('%s%02d:%02d',$offset < 0 ? '-' : '+',intdiv($rest,3600),intdiv($rest % 3600,60)).($rest % 60 ? sprintf(':%02d',$rest % 60) : '');
};
$timestamp = function ($instant,$unit) {
if (!in_array($unit,['Segundos','Milissegundos'],true)) throw new InvalidArgumentException('Selecione segundos ou milissegundos.');
$ms = $instant->getTimestamp()*1000 + (int)$instant->format('v');
if ($unit === 'Milissegundos') return (string)$ms;
return ($ms < 0 ? '-' : '').intdiv(abs($ms),1000).(abs($ms) % 1000 ? sprintf('.%03d',abs($ms) % 1000) : '');
};
$fromTimestamp = function ($text,$unit) use ($fromMs) {
if (!in_array($unit,['Segundos','Milissegundos'],true)) throw new InvalidArgumentException('Selecione segundos ou milissegundos.');
if (strlen($text) > 100 || !preg_match('/^([+-]?)([0-9]+)'.($unit === 'Segundos' ? '(?:\.([0-9]{1,3}))?' : '').'$/D',trim($text),$m)) throw new InvalidArgumentException('Timestamp inválido.');
$digits = ltrim($m[2],'0');
if (strlen($digits) > ($unit === 'Segundos' ? 12 : 15)) throw new InvalidArgumentException('Timestamp fora do intervalo.');
$ms = (int)$digits*($unit === 'Segundos' ? 1000 : 1) + ($unit === 'Segundos' ? (int)str_pad($m[3] ?? '',3,'0') : 0);
if ($m[1] === '-') $ms = -$ms;
if ($ms < -62135596800000 || $ms > 253402300799999) throw new InvalidArgumentException('Timestamp fora do intervalo.');
return $fromMs($ms);
};
$results = function ($instant,$selected,$unit) use ($zone,$check,$offsetText,$timestamp,$utcText) {
$local = $check($instant->setTimezone($zone($selected))); $offset = $offsetText($local); $selected = trim($selected);
return [['label'=>'Timestamp — '.strtolower($unit),'value'=>$timestamp($instant,$unit)],['label'=>'UTC — ISO com Z','value'=>$utcText($instant)],['label'=>'Local — '.$selected.' (offset '.$offset.')','value'=>$local->format('Y-m-d H:i:s.v').' '.$offset.' ['.$selected.']']];
};
if (mb_strlen($expression,'UTF-8')>500) throw new InvalidArgumentException('Expressão muito longa.');
$parts=preg_split('/\s+/',trim($expression));
if (count($parts)!==5) throw new InvalidArgumentException('Use exatamente cinco campos; sem macros, segundos ou ano.');
$names=['Minuto','Hora','Dia do mês','Mês','Dia da semana'];
$limits=['0–59','0–23','1–31','1–12','0–7; domingo 0/7'];
$bounds=[[0,59],[0,23],[1,31],[1,12],[0,7]];
$aliases=[[],[],[],array_combine(explode(' ','JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC'),range(1,12)),array_combine(explode(' ','SUN MON TUE WED THU FRI SAT'),range(0,6))];
$expanded=[]; $canonical=[];
foreach ($parts as $i=>$part) {
$token=preg_replace_callback('/[A-Z]+/',fn($m)=>(string)($aliases[$i][$m[0]] ?? $m[0]),strtoupper($part));
if (!preg_match('/^(?:\*|[0-9]+(?:-[0-9]+)?)(?:\/[0-9]+)?(?:,(?:\*|[0-9]+(?:-[0-9]+)?)(?:\/[0-9]+)?)*$/D',$token)) throw new InvalidArgumentException($names[$i].': sintaxe não suportada.');
[$minimum,$maximum]=$bounds[$i]; $accepted=[];
foreach (explode(',',$token) as $term) {
$split=explode('/',$term); $base=$split[0]; $stride=isset($split[1]) ? (int)$split[1] : 1;
if ($stride<1) throw new InvalidArgumentException('Passo deve ser positivo.');
if ($base==='*') [$lower,$upper]=[$minimum,$maximum];
elseif (str_contains($base,'-')) [$lower,$upper]=array_map('intval',explode('-',$base));
else { $lower=(int)$base; $upper=isset($split[1]) ? $maximum : $lower; }
if ($lower<$minimum || $upper>$maximum || $lower>$upper) throw new InvalidArgumentException($names[$i].': limite ou intervalo inválido.');
for ($n=$lower;$n<=$upper;$n+=$stride) $accepted[$n]=true;
}
if ($i===4 && $token==='7') $accepted=[0=>true];
$values=array_keys($accepted); sort($values); $expanded[]=$values;
$canonical[]=in_array($i,[2,4],true) && $part==='*' ? '*' : implode(',',$values);
}
$selected=trim($timezone); $tz=$zone($selected);
$start=preg_match('/(Z|[+-][0-9]{2}:[0-9]{2}(?::[0-9]{2})?)$/D',trim($reference)) ? $iso($reference) : $localInstant($reference,$selected);
$wall=$plain($start->setTimezone($tz)->format('Y-m-d H:i:s.v'));
$schedules=[$canonical];
if ($parts[2]!=='*' && $parts[4]!=='*') {
$a=$canonical; $a[4]='*'; $b=$canonical; $b[2]='*'; $schedules=[$a,$b];
}
$parsers=array_map(fn($fields)=>new CronExpression(implode(' ',$fields)),$schedules);
foreach ($parsers as $parser) $parser->setMaxIterationCount(10000);
$pending=array_map(fn($parser)=>DateTimeImmutable::createFromMutable($parser->getNextRunDate($wall,0,false,'UTC')),$parsers);
$executions=[]; $previous=$start; $ceiling=min((int)$start->format('Y')+50,9999);
for ($attempt=0;$attempt<100000 && count($executions)<10;$attempt++) {
$candidate=min($pending);
if ((int)$candidate->format('Y')>$ceiling) throw new InvalidArgumentException('Sem dez ocorrências no limite de 50 anos.');
foreach ($pending as $i=>$value) if ($value==$candidate) $pending[$i]=DateTimeImmutable::createFromMutable($parsers[$i]->getNextRunDate($candidate,0,false,'UTC'));
$wallSeconds=$candidate->getTimestamp(); $valid=[]; $shifted=[];
foreach ($tz->getTransitions($wallSeconds-172800,$wallSeconds+172800) ?: [] as $transition) {
$instant=$candidate->modify(sprintf('%+d seconds',-$transition['offset']));
$local=$instant->setTimezone($tz);
if ($local->format('Y-m-d H:i:s')===$candidate->format('Y-m-d H:i:s')) $valid[]=$instant;
elseif ($local->format('Y-m-d H:i:s')>$candidate->format('Y-m-d H:i:s')) $shifted[]=$instant;
}
$choices=$valid ?: $shifted;
if (!$choices) throw new InvalidArgumentException('Falha ao interpretar transição do fuso.');
$instant=min($choices);
if ($instant<=$previous) continue;
$local=$check($instant->setTimezone($tz));
$executions[]=['local'=>$local->format('Y-m-d\TH:i:s.v').$offsetText($local).'['.$selected.']','utc'=>$utcText($instant)];
$previous=$instant;
}
if (count($executions)!==10) throw new InvalidArgumentException('Limite de busca; expressão sem dez ocorrências.');
$fields=[];
foreach ($expanded as $i=>$values) $fields[]=['label'=>$names[$i],'expression'=>$parts[$i],'meaning'=>'Limites: '.$limits[$i].'. Valores aceitos: '.implode(', ',$values).($i===4 ? ' (0 = domingo, 1 = segunda, …, 6 = sábado)' : '').'.'];
return ['description'=>'Execução nos minutos, horas e meses indicados abaixo, conforme a regra dos campos de dia. Consulte a explicação por campo; não é uma tarefa agendada.','fields'=>$fields,'executions'=>$executions];
}