package com.xfestudio.xfeservermanager.core.trigger;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/** Normalizes interval configuration and calculates server-local wall-clock occurrences. */
public final class TriggerIntervalSchedule {
public static final long MAXIMUM_SECONDS = 31_536_000L;
private static final long SECONDS_PER_DAY = 86_400L;
private static final Set<String> PARAMETERS = Set.of("hours", "minutes", "seconds");
private TriggerIntervalSchedule() { }
/**
* Converts either legacy {@code seconds=<total>} or hour/minute/second components to the
* canonical, persistence-safe total-seconds representation. Supplying hours or minutes opts
* into component mode, in which minutes and seconds are each restricted to 0..59.
*/
public static Map<String, String> normalizeConfiguration(Map<String, String> configuration) {
Map<String, String> supplied = new LinkedHashMap<>(
configuration == null ? Map.of() : configuration);
for (String parameter : supplied.keySet()) {
if (!PARAMETERS.contains(parameter)) {
throw new IllegalArgumentException(
"schedule.interval does not accept event configuration parameter " + parameter);
}
}
boolean componentMode = supplied.containsKey("hours") || supplied.containsKey("minutes");
long total;
if (componentMode) {
long hours = component(supplied, "hours", Long.MAX_VALUE);
long minutes = component(supplied, "minutes", 59);
long seconds = component(supplied, "seconds", 59);
try {
total = Math.addExact(Math.addExact(Math.multiplyExact(hours, 3_600L),
Math.multiplyExact(minutes, 60L)), seconds);
} catch (ArithmeticException overflow) {
throw invalidInterval(overflow);
}
} else {
total = requiredLong(supplied.get("seconds"), "seconds");
}
validateTotal(total);
return Map.of("seconds", Long.toString(total));
}
public static long totalSeconds(Map<String, String> configuration) {
return Long.parseLong(normalizeConfiguration(configuration).get("seconds"));
}
/**
* Returns the most recent due boundary, never a backlog. Alignment uses a continuous local
* wall-clock timeline anchored at the local 1970-01-01 day boundary. A candidate must not
* predate activation and must be strictly newer than the last completed persisted epoch.
*/
public static Optional<Instant> dueOccurrence(
Instant now, Instant activatedAt, Instant completedOccurrence,
long intervalSeconds, ZoneId zone) {
Objects.requireNonNull(now, "now");
Objects.requireNonNull(activatedAt, "activatedAt");
Objects.requireNonNull(zone, "zone");
validateTotal(intervalSeconds);
Instant candidate = alignedAtOrBefore(now, intervalSeconds, zone);
if (candidate.isBefore(activatedAt)
|| (completedOccurrence != null && !candidate.isAfter(completedOccurrence))) {
return Optional.empty();
}
return Optional.of(candidate);
}
private static Instant alignedAtOrBefore(Instant now, long intervalSeconds, ZoneId zone) {
LocalDateTime localNow = LocalDateTime.ofInstant(now, zone);
long wallSecond;
try {
wallSecond = Math.addExact(Math.multiplyExact(
localNow.toLocalDate().toEpochDay(), SECONDS_PER_DAY),
localNow.toLocalTime().toSecondOfDay());
} catch (ArithmeticException overflow) {
throw new IllegalArgumentException("instant is outside the supported local schedule range", overflow);
}
long aligned = Math.multiplyExact(Math.floorDiv(wallSecond, intervalSeconds), intervalSeconds);
// A boundary inside a daylight-saving gap is shifted forward by ZoneRules and can land
// after now. Jump back by enough full intervals rather than walking one second at a time.
for (int attempt = 0; attempt < 4; attempt++) {
Instant candidate = wallSecondToInstant(aligned, zone);
if (!candidate.isAfter(now)) return candidate;
long ahead = Math.max(0L, candidate.getEpochSecond() - now.getEpochSecond());
long intervals = Math.floorDiv(ahead, intervalSeconds) + 1L;
aligned = Math.subtractExact(aligned, Math.multiplyExact(intervals, intervalSeconds));
}
throw new IllegalArgumentException("could not resolve a local interval boundary");
}
private static Instant wallSecondToInstant(long wallSecond, ZoneId zone) {
long epochDay = Math.floorDiv(wallSecond, SECONDS_PER_DAY);
int secondOfDay = (int) Math.floorMod(wallSecond, SECONDS_PER_DAY);
try {
return LocalDateTime.of(LocalDate.ofEpochDay(epochDay), LocalTime.ofSecondOfDay(secondOfDay))
.atZone(zone).toInstant();
} catch (DateTimeException exception) {
throw new IllegalArgumentException("local interval boundary is outside the supported range", exception);
}
}
private static long component(Map<String, String> supplied, String name, long maximum) {
if (!supplied.containsKey(name)) return 0L;
long parsed = requiredLong(supplied.get(name), name);
if (parsed < 0 || parsed > maximum) throw invalidComponent(name, maximum, null);
return parsed;
}
private static long requiredLong(String value, String name) {
if (value == null || value.isBlank()) throw invalidComponent(name, Long.MAX_VALUE, null);
try {
return Long.parseLong(value.strip());
} catch (NumberFormatException exception) {
throw invalidComponent(name, Long.MAX_VALUE, exception);
}
}
private static void validateTotal(long seconds) {
if (seconds < 1 || seconds > MAXIMUM_SECONDS) throw invalidInterval(null);
}
private static IllegalArgumentException invalidComponent(
String name, long maximum, RuntimeException cause) {
String range = maximum == Long.MAX_VALUE ? "a whole number" : "between 0 and " + maximum;
return new IllegalArgumentException(name + " must be " + range, cause);
}
private static IllegalArgumentException invalidInterval(RuntimeException cause) {
return new IllegalArgumentException(
"interval seconds must be between 1 and " + MAXIMUM_SECONDS, cause);
}
}
package com.xfestudio.xfeservermanager.core.trigger;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/** Normalizes interval configuration and calculates server-local wall-clock occurrences. */
public final class TriggerIntervalSchedule {
public static final long MAXIMUM_SECONDS = 31_536_000L;
private static final long SECONDS_PER_DAY = 86_400L;
private static final Set<String> PARAMETERS = Set.of("hours", "minutes", "seconds");
private TriggerIntervalSchedule() { }
/**
* Converts either legacy {@code seconds=<total>} or hour/minute/second components to the
* canonical, persistence-safe total-seconds representation. Supplying hours or minutes opts
* into component mode, in which minutes and seconds are each restricted to 0..59.
*/
public static Map<String, String> normalizeConfiguration(Map<String, String> configuration) {
Map<String, String> supplied = new LinkedHashMap<>(
configuration == null ? Map.of() : configuration);
for (String parameter : supplied.keySet()) {
if (!PARAMETERS.contains(parameter)) {
throw new IllegalArgumentException(
"schedule.interval does not accept event configuration parameter " + parameter);
}
}
boolean componentMode = supplied.containsKey("hours") || supplied.containsKey("minutes");
long total;
if (componentMode) {
long hours = component(supplied, "hours", Long.MAX_VALUE);
long minutes = component(supplied, "minutes", 59);
long seconds = component(supplied, "seconds", 59);
try {
total = Math.addExact(Math.addExact(Math.multiplyExact(hours, 3_600L),
Math.multiplyExact(minutes, 60L)), seconds);
} catch (ArithmeticException overflow) {
throw invalidInterval(overflow);
}
} else {
total = requiredLong(supplied.get("seconds"), "seconds");
}
validateTotal(total);
return Map.of("seconds", Long.toString(total));
}
public static long totalSeconds(Map<String, String> configuration) {
return Long.parseLong(normalizeConfiguration(configuration).get("seconds"));
}
/**
* Returns the most recent due boundary, never a backlog. Alignment uses a continuous local
* wall-clock timeline anchored at the local 1970-01-01 day boundary. A candidate must not
* predate activation and must be strictly newer than the last completed persisted epoch.
*/
public static Optional<Instant> dueOccurrence(
Instant now, Instant activatedAt, Instant completedOccurrence,
long intervalSeconds, ZoneId zone) {
Objects.requireNonNull(now, "now");
Objects.requireNonNull(activatedAt, "activatedAt");
Objects.requireNonNull(zone, "zone");
validateTotal(intervalSeconds);
Instant candidate = alignedAtOrBefore(now, intervalSeconds, zone);
if (candidate.isBefore(activatedAt)
|| (completedOccurrence != null && !candidate.isAfter(completedOccurrence))) {
return Optional.empty();
}
return Optional.of(candidate);
}
private static Instant alignedAtOrBefore(Instant now, long intervalSeconds, ZoneId zone) {
LocalDateTime localNow = LocalDateTime.ofInstant(now, zone);
long wallSecond;
try {
wallSecond = Math.addExact(Math.multiplyExact(
localNow.toLocalDate().toEpochDay(), SECONDS_PER_DAY),
localNow.toLocalTime().toSecondOfDay());
} catch (ArithmeticException overflow) {
throw new IllegalArgumentException("instant is outside the supported local schedule range", overflow);
}
long aligned = Math.multiplyExact(Math.floorDiv(wallSecond, intervalSeconds), intervalSeconds);
// A boundary inside a daylight-saving gap is shifted forward by ZoneRules and can land
// after now. Jump back by enough full intervals rather than walking one second at a time.
for (int attempt = 0; attempt < 4; attempt++) {
Instant candidate = wallSecondToInstant(aligned, zone);
if (!candidate.isAfter(now)) return candidate;
long ahead = Math.max(0L, candidate.getEpochSecond() - now.getEpochSecond());
long intervals = Math.floorDiv(ahead, intervalSeconds) + 1L;
aligned = Math.subtractExact(aligned, Math.multiplyExact(intervals, intervalSeconds));
}
throw new IllegalArgumentException("could not resolve a local interval boundary");
}
private static Instant wallSecondToInstant(long wallSecond, ZoneId zone) {
long epochDay = Math.floorDiv(wallSecond, SECONDS_PER_DAY);
int secondOfDay = (int) Math.floorMod(wallSecond, SECONDS_PER_DAY);
try {
return LocalDateTime.of(LocalDate.ofEpochDay(epochDay), LocalTime.ofSecondOfDay(secondOfDay))
.atZone(zone).toInstant();
} catch (DateTimeException exception) {
throw new IllegalArgumentException("local interval boundary is outside the supported range", exception);
}
}
private static long component(Map<String, String> supplied, String name, long maximum) {
if (!supplied.containsKey(name)) return 0L;
long parsed = requiredLong(supplied.get(name), name);
if (parsed < 0 || parsed > maximum) throw invalidComponent(name, maximum, null);
return parsed;
}
private static long requiredLong(String value, String name) {
if (value == null || value.isBlank()) throw invalidComponent(name, Long.MAX_VALUE, null);
try {
return Long.parseLong(value.strip());
} catch (NumberFormatException exception) {
throw invalidComponent(name, Long.MAX_VALUE, exception);
}
}
private static void validateTotal(long seconds) {
if (seconds < 1 || seconds > MAXIMUM_SECONDS) throw invalidInterval(null);
}
private static IllegalArgumentException invalidComponent(
String name, long maximum, RuntimeException cause) {
String range = maximum == Long.MAX_VALUE ? "a whole number" : "between 0 and " + maximum;
return new IllegalArgumentException(name + " must be " + range, cause);
}
private static IllegalArgumentException invalidInterval(RuntimeException cause) {
return new IllegalArgumentException(
"interval seconds must be between 1 and " + MAXIMUM_SECONDS, cause);
}
}