001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.marshall.utils;
018
019import static org.apache.juneau.commons.reflect.ReflectionUtils.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021import static org.apache.juneau.commons.utils.ThrowableUtils.*;
022
023import java.lang.reflect.*;
024import java.time.*;
025import java.time.Duration;
026import java.time.format.*;
027import java.time.temporal.*;
028import java.util.*;
029
030import javax.xml.datatype.*;
031
032import org.apache.juneau.commons.reflect.*;
033import org.apache.juneau.marshall.*;
034import org.apache.juneau.marshall.swaps.*;
035
036/**
037 * Centralized ISO 8601 formatting and parsing utility for date/time and Duration types.
038 *
039 * <p>
040 * Provides the built-in serialization format for {@link Temporal}, {@link Calendar},
041 * {@link Date}, {@link XMLGregorianCalendar}, and {@link Duration} objects.
042 *
043 * <h5 class='section'>See Also:</h5><ul>
044 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/Marshalling">Marshalling</a>
045 * </ul>
046 */
047@SuppressWarnings({
048   "java:S1172", // The ClassMeta sourceType/type parameters of the public formatDate/formatCalendar/formatTemporal/formatAsDate/formatAsDateTime are unused by the current logic but kept for API stability
049   "unchecked" // Casts of parsed Duration/Period/Calendar/Date/Temporal values to T are guaranteed by the requested target ClassMeta<T>
050})
051public final class Iso8601Utils {
052
053   private Iso8601Utils() {}
054
055   private static final ZoneId Z = ZoneId.of("Z");
056
057   private static final DateTimeFormatter YEAR_FORMATTER = DateTimeFormatter.ofPattern("uuuu");
058   private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM");
059
060   private static final Map<Class<?>,DateTimeFormatter> DEFAULT_FORMATTERS = Map.ofEntries(
061      Map.entry(Instant.class, DateTimeFormatter.ISO_INSTANT),
062      Map.entry(ZonedDateTime.class, DateTimeFormatter.ISO_OFFSET_DATE_TIME),
063      Map.entry(OffsetDateTime.class, DateTimeFormatter.ISO_OFFSET_DATE_TIME),
064      Map.entry(LocalDate.class, DateTimeFormatter.ISO_LOCAL_DATE),
065      Map.entry(LocalDateTime.class, DateTimeFormatter.ISO_LOCAL_DATE_TIME),
066      Map.entry(LocalTime.class, DateTimeFormatter.ISO_LOCAL_TIME),
067      Map.entry(OffsetTime.class, DateTimeFormatter.ISO_OFFSET_TIME),
068      Map.entry(Year.class, YEAR_FORMATTER),
069      Map.entry(YearMonth.class, YEAR_MONTH_FORMATTER)
070   );
071
072   private static DatatypeFactory datatypeFactory;
073   static {
074      try {
075         datatypeFactory = DatatypeFactory.newInstance();
076      } catch (DatatypeConfigurationException e) {
077         throw toRex(e);
078      }
079   }
080
081   //-----------------------------------------------------------------------------------------------------------------
082   // Formatting
083   //-----------------------------------------------------------------------------------------------------------------
084
085   /**
086    * Formats a date/time or Duration value to its ISO 8601 string representation.
087    *
088    * <p>
089    * Convenience dispatcher that uses default per-type wire formats; callers that need format-aware
090    * output should use the per-type helpers directly:
091    * {@link #formatDate}, {@link #formatCalendar}, {@link #formatTemporal},
092    * {@link #formatDuration}, {@link #formatPeriod}.
093    *
094    * @param value The value to format.  <jk>null</jk> returns <jk>null</jk> (consistent with the per-type helpers).
095    * @param type The class metadata for the value (used for selecting the appropriate formatter for Temporal types).
096    * @param timeZone The session time zone (used when the value lacks zone info).
097    * @return The ISO 8601 string representation, or <jk>null</jk> if {@code value} is <jk>null</jk>.
098    */
099   public static String format(Object value, ClassMeta<?> type, TimeZone timeZone) {
100      if (value == null)
101         return null;
102      if (value instanceof Duration value2)
103         return formatDuration(value2, DurationFormat.ISO_8601_WITH_DAYS);
104      if (value instanceof Period value2)
105         return formatPeriod(value2, PeriodFormat.ISO_8601);
106      if (value instanceof Calendar || value instanceof XMLGregorianCalendar)
107         return formatCalendar(value, type, CalendarFormat.ISO_OFFSET_DATE_TIME, timeZone);
108      if (value instanceof Date value2)
109         return formatDate(value2, type, DateFormat.ISO_LOCAL_DATE_TIME, timeZone);
110      if (value instanceof TemporalAccessor value2)
111         return formatTemporal(value2, type, TemporalFormat.DEFAULT, timeZone);
112      return value.toString();
113   }
114
115   /**
116    * Formats a {@link Duration} value using the supplied wire format.
117    *
118    * @param value The value to format. <jk>null</jk> returns <jk>null</jk>.
119    * @param format The duration wire format. May be <jk>null</jk> (defaults to {@link DurationFormat#ISO_8601_WITH_DAYS}).
120    * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>.
121    */
122   public static String formatDuration(Duration value, DurationFormat format) {
123      if (value == null)
124         return null;
125      return (format == null ? DurationFormat.ISO_8601_WITH_DAYS : format).format(value);
126   }
127
128   /**
129    * Formats a {@link Period} value using the supplied wire format.
130    *
131    * @param value The value to format. <jk>null</jk> returns <jk>null</jk>.
132    * @param format The period wire format. May be <jk>null</jk> (defaults to {@link PeriodFormat#ISO_8601}).
133    * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>.
134    */
135   public static String formatPeriod(Period value, PeriodFormat format) {
136      if (value == null)
137         return null;
138      return (format == null ? PeriodFormat.ISO_8601 : format).format(value);
139   }
140
141   /**
142    * Formats a {@link Date} value using the supplied wire format.
143    *
144    * <p>
145    * {@code java.sql.Date}, {@code java.sql.Time}, and {@code java.sql.Timestamp} subclasses all dispatch
146    * through {@link DateFormat#format(Date, ZoneId)} just like {@code Date}.
147    *
148    * @param value The value to format. <jk>null</jk> returns <jk>null</jk>.
149    * @param sourceType The class metadata for the value (currently unused, kept for symmetry with the parse helper).
150    * @param format The date wire format. May be <jk>null</jk> (defaults to {@link DateFormat#ISO_LOCAL_DATE_TIME}).
151    * @param timeZone The session time zone (used as the default zone).
152    * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>.
153    */
154   public static String formatDate(Date value, ClassMeta<?> sourceType, DateFormat format, TimeZone timeZone) {
155      if (value == null)
156         return null;
157      ZoneId zone = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
158      return (format == null ? DateFormat.ISO_LOCAL_DATE_TIME : format).format(value, zone);
159   }
160
161   /**
162    * Formats a {@link Calendar} or {@link XMLGregorianCalendar} value using the supplied wire format.
163    *
164    * <p>
165    * {@link XMLGregorianCalendar} values always emit {@link XMLGregorianCalendar#toXMLFormat()} regardless
166    * of the configured {@code format} — mirroring the parse-side rule and the historical wire output.
167    * {@link Calendar} (including {@link GregorianCalendar}) values are formatted using the supplied
168    * {@link CalendarFormat}.
169    *
170    * @param value The value to format. Must be a {@link Calendar} or {@link XMLGregorianCalendar}.
171    *    <jk>null</jk> returns <jk>null</jk>.
172    * @param sourceType The class metadata for the value (currently unused, kept for symmetry with the parse helper).
173    * @param format The calendar wire format. May be <jk>null</jk> (defaults to {@link CalendarFormat#ISO_OFFSET_DATE_TIME}).
174    * @param timeZone The session time zone (used as the default zone when the value lacks zone info).
175    * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>.
176    */
177   public static String formatCalendar(Object value, ClassMeta<?> sourceType, CalendarFormat format, TimeZone timeZone) {
178      if (value == null)
179         return null;
180      if (value instanceof XMLGregorianCalendar value2)
181         return value2.toXMLFormat();
182      ZoneId zone = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
183      return (format == null ? CalendarFormat.ISO_OFFSET_DATE_TIME : format).format((Calendar)value, zone);
184   }
185
186   /**
187    * Formats a {@link TemporalAccessor} value using the supplied wire format.
188    *
189    * @param value The value to format. Must be a {@link Temporal} subtype. <jk>null</jk> returns <jk>null</jk>.
190    * @param sourceType The class metadata for the value (currently unused, kept for symmetry with the parse helper).
191    * @param format The temporal wire format. May be <jk>null</jk> (defaults to {@link TemporalFormat#DEFAULT}).
192    * @param timeZone The session time zone (used as the default zone when the value lacks zone info).
193    * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>.
194    */
195   public static String formatTemporal(TemporalAccessor value, ClassMeta<?> sourceType, TemporalFormat format, TimeZone timeZone) {
196      if (value == null)
197         return null;
198      ZoneId zone = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
199      return (format == null ? TemporalFormat.DEFAULT : format).format(value, zone);
200   }
201
202   /**
203    * Formats a date/time value as an ISO date (date-only, for OpenAPI 'date' format).
204    *
205    * @param value The value to format.  <jk>null</jk> returns <jk>null</jk>.
206    * @param type The class metadata.
207    * @param timeZone The session time zone.
208    * @return The ISO date string, or <jk>null</jk> if {@code value} is <jk>null</jk>.
209    */
210   public static String formatAsDate(Object value, ClassMeta<?> type, TimeZone timeZone) {
211      if (value == null)
212         return null;
213      ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
214      if (value instanceof Calendar value2) {
215         ZonedDateTime zdt = (value2 instanceof GregorianCalendar value3) ? value3.toZonedDateTime() : value2.toInstant().atZone(value2.getTimeZone().toZoneId());
216         return DateTimeFormatter.ISO_DATE.format(zdt);
217      }
218      if (value instanceof Date value2)
219         return DateTimeFormatter.ISO_DATE.format(value2.toInstant().atZone(zoneId));
220      if (value instanceof Temporal value2)
221         return DateTimeFormatter.ISO_DATE.format(ZonedDateTime.from(new DefaultingTemporalAccessor(value2, zoneId)));
222      return value.toString();
223   }
224
225   /**
226    * Formats a date/time value as an ISO date-time (for OpenAPI 'date-time' format).
227    *
228    * @param value The value to format.  <jk>null</jk> returns <jk>null</jk>.
229    * @param type The class metadata.
230    * @param timeZone The session time zone.
231    * @return The ISO date-time string, or <jk>null</jk> if {@code value} is <jk>null</jk>.
232    */
233   public static String formatAsDateTime(Object value, ClassMeta<?> type, TimeZone timeZone) {
234      if (value == null)
235         return null;
236      ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
237      if (value instanceof Calendar value2) {
238         ZonedDateTime zdt = (value2 instanceof GregorianCalendar value3) ? value3.toZonedDateTime() : value2.toInstant().atZone(value2.getTimeZone().toZoneId());
239         return DateTimeFormatter.ISO_INSTANT.format(zdt.toInstant());
240      }
241      if (value instanceof Date value2)
242         return DateTimeFormatter.ISO_INSTANT.format(value2.toInstant());
243      if (value instanceof Temporal value2) {
244         ZonedDateTime zdt = ZonedDateTime.from(new DefaultingTemporalAccessor(value2, zoneId));
245         return DateTimeFormatter.ISO_INSTANT.format(zdt.toInstant());
246      }
247      return value.toString();
248   }
249
250   //-----------------------------------------------------------------------------------------------------------------
251   // Parsing
252   //-----------------------------------------------------------------------------------------------------------------
253
254   /**
255    * Parses an ISO 8601 string into the specified date/time target type.
256    *
257    * <p>
258    * Dispatches to the appropriate per-type parser based on the target's {@link ClassMeta} category:
259    * {@link #parseDate parseDate}, {@link #parseCalendar parseCalendar}, {@link #parseTemporal parseTemporal},
260    * {@link #parseDuration parseDuration}, or {@link #parsePeriod parsePeriod} — each using its default
261    * wire format.
262    *
263    * @param <T> The target type.
264    * @param iso8601 The ISO 8601 string to parse. <jk>null</jk> returns <jk>null</jk>.
265    * @param targetType The target class metadata.
266    * @param timeZone The session time zone (used for types that need a default zone).
267    * @return The parsed object, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or the target type is
268    *    not a recognized date/time/duration/period type.
269    */
270   public static <T> T parse(String iso8601, ClassMeta<T> targetType, TimeZone timeZone) {
271      if (iso8601 == null)
272         return null;
273      if (targetType.isDate())
274         return parseDate(iso8601, targetType, DateFormat.ISO_LOCAL_DATE_TIME, timeZone);
275      if (targetType.isCalendar())
276         return parseCalendar(iso8601, targetType, CalendarFormat.ISO_OFFSET_DATE_TIME, timeZone);
277      if (targetType.isTemporal())
278         return (T) parseTemporal(iso8601, (ClassMeta<? extends Temporal>)(ClassMeta<?>)targetType, TemporalFormat.DEFAULT, timeZone);
279      if (targetType.isDuration())
280         return (T) parseDuration(iso8601, DurationFormat.MILLIS);
281      if (targetType.isPeriod())
282         return (T) parsePeriod(iso8601, PeriodFormat.ISO_8601);
283      return null;
284   }
285
286   /**
287    * Parses an ISO 8601 / numeric wire value into a {@link Duration}.
288    *
289    * <p>
290    * Handles ISO 8601 forms ({@code "PT1H"}, {@code "-PT30M"}, {@code "PT-6H"}, {@code "PT1.5S"}, …) as well
291    * as the bare-number wire forms produced by {@link DurationFormat#MILLIS}, {@link DurationFormat#SECONDS},
292    * {@link DurationFormat#NANOS}, and {@link DurationFormat#HOCON} (e.g. {@code "500ms"}, {@code "1.5s"}).
293    *
294    * <p>
295    * The {@code formatHint} disambiguates bare integer strings that could be milliseconds, nanos, or seconds —
296    * for all other numeric or ISO forms the input is self-describing and the hint is ignored.
297    *
298    * @param iso8601 The wire value to parse. <jk>null</jk> or empty returns <jk>null</jk>.
299    * @param formatHint The duration wire format used to disambiguate bare integer inputs. May be <jk>null</jk>
300    *    (defaults to {@link DurationFormat#MILLIS}).
301    * @return The parsed {@link Duration}, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or empty.
302    */
303   public static Duration parseDuration(String iso8601, DurationFormat formatHint) {
304      if (ie(iso8601))
305         return null;
306      String s = iso8601.trim();
307      // Strip optional surrounding quotes
308      if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\""))
309         s = s.substring(1, s.length() - 1).trim();
310      if (s.startsWith("PT-"))
311         s = "-PT" + s.substring(3);
312      // Number sniffing and explicit format hints.
313      if (isIntegerLiteral(s))
314         return (formatHint == null ? DurationFormat.MILLIS : formatHint).parse(s);
315      if (isDecimalLiteral(s))
316         return DurationFormat.SECONDS.parse(s);
317      if (isHoconLiteral(s))
318         return DurationFormat.HOCON.parse(s);
319      // Use manual parser for full control (handles PTnH, PTnM, PTn.nS, -PTnH, PT-6H, etc.)
320      Duration d = parseDurationManual(s);
321      if (d != null)
322         return d;
323      // Fallback to standard parser
324      try {
325         return Duration.parse(s);
326      } catch (Exception e) {
327         throw toRex(e);
328      }
329   }
330
331   /**
332    * Parses an ISO 8601 / numeric wire value into a {@link Period}.
333    *
334    * <p>
335    * Handles standard ISO 8601 period strings ({@code "P1Y2M3D"}, …) as well as the bare-integer wire form
336    * produced by {@link PeriodFormat#DAYS}.
337    *
338    * @param value The wire value to parse. <jk>null</jk> or empty returns <jk>null</jk>.
339    * @param formatHint The period wire format used to interpret bare integers. May be <jk>null</jk>
340    *    (defaults to {@link PeriodFormat#DAYS}).
341    * @return The parsed {@link Period}, or <jk>null</jk> if {@code value} is <jk>null</jk> or empty.
342    */
343   public static Period parsePeriod(String value, PeriodFormat formatHint) {
344      if (ie(value))
345         return null;
346      String s = value.trim();
347      if (isIntegerLiteral(s))
348         return (formatHint == null ? PeriodFormat.DAYS : formatHint).parse(s);
349      return Period.parse(s);
350   }
351
352   /**
353    * Parses an ISO 8601 wire value into a {@link Calendar} or {@link XMLGregorianCalendar}.
354    *
355    * <p>
356    * Dispatches by target type:
357    * <ul>
358    *    <li>{@link XMLGregorianCalendar} targets always use {@link DatatypeFactory#newXMLGregorianCalendar(String)}
359    *       and ignore {@code formatHint}.
360    *    <li>{@link Calendar} / {@link GregorianCalendar} targets honor {@code formatHint} when set to anything
361    *       other than {@link CalendarFormat#NOT_SET} or {@link CalendarFormat#ISO_OFFSET_DATE_TIME}; the default
362    *       path uses content-based formatter autodetection.
363    * </ul>
364    *
365    * @param <T> The target type.
366    * @param iso8601 The wire value to parse. <jk>null</jk> returns <jk>null</jk>.
367    * @param targetType The target class metadata. Must represent a {@link Calendar} or {@link XMLGregorianCalendar}.
368    * @param formatHint The calendar wire format. May be <jk>null</jk> (treated as {@link CalendarFormat#NOT_SET}).
369    * @param timeZone The session time zone (used as the default zone when the wire value lacks zone info).
370    * @return The parsed value, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or the target is not a
371    *    recognized calendar type.
372    */
373   public static <T> T parseCalendar(String iso8601, ClassMeta<T> targetType, CalendarFormat formatHint, TimeZone timeZone) {
374      if (iso8601 == null)
375         return null;
376      Class<T> tc = targetType.inner();
377      if (XMLGregorianCalendar.class.isAssignableFrom(tc))
378         return (T) datatypeFactory.newXMLGregorianCalendar(iso8601);
379      if (! Calendar.class.isAssignableFrom(tc))
380         return null;
381      ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
382      if (formatHint != null && formatHint != CalendarFormat.NOT_SET && formatHint != CalendarFormat.ISO_OFFSET_DATE_TIME)
383         return (T) formatHint.parse(iso8601, zoneId);
384      return (T) parseCalendarDefault(iso8601, zoneId);
385   }
386
387   /**
388    * Parses an ISO 8601 wire value into a {@link Date}.
389    *
390    * <p>
391    * Honors {@code formatHint} when set to anything other than {@link DateFormat#NOT_SET} or
392    * {@link DateFormat#ISO_LOCAL_DATE_TIME}; the default path uses content-based formatter autodetection.
393    *
394    * <p>
395    * Only direct {@link Date} targets are produced — {@code java.sql.*} subclasses fall through to
396    * <jk>null</jk>, matching the original dispatch.
397    *
398    * @param <T> The target type.
399    * @param iso8601 The wire value to parse. <jk>null</jk> returns <jk>null</jk>.
400    * @param targetType The target class metadata. Must represent {@link Date}.
401    * @param formatHint The date wire format. May be <jk>null</jk> (treated as {@link DateFormat#NOT_SET}).
402    * @param timeZone The session time zone (used as the default zone when the wire value lacks zone info).
403    * @return The parsed {@link Date}, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or the target is
404    *    not exactly {@code Date}.
405    */
406   public static <T> T parseDate(String iso8601, ClassMeta<T> targetType, DateFormat formatHint, TimeZone timeZone) {
407      if (iso8601 == null)
408         return null;
409      if (targetType.inner() != Date.class)
410         return null;
411      ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
412      if (formatHint != null && formatHint != DateFormat.NOT_SET && formatHint != DateFormat.ISO_LOCAL_DATE_TIME)
413         return (T) formatHint.parse(iso8601, zoneId);
414      return (T) parseDateDefault(iso8601, zoneId);
415   }
416
417   /**
418    * Parses an ISO 8601 wire value into the specified {@link Temporal} subtype.
419    *
420    * <p>
421    * Honors {@code formatHint} when set to anything other than {@link TemporalFormat#NOT_SET} or
422    * {@link TemporalFormat#DEFAULT}; the default path uses the per-subtype default formatter
423    * (e.g. {@link DateTimeFormatter#ISO_INSTANT} for {@link Instant}, {@link DateTimeFormatter#ISO_LOCAL_DATE}
424    * for {@link LocalDate}, …).
425    *
426    * @param <T> The target temporal subtype.
427    * @param iso8601 The wire value to parse. <jk>null</jk> returns <jk>null</jk>.
428    * @param targetType The target class metadata. Must represent a {@link Temporal} subtype.
429    * @param formatHint The temporal wire format. May be <jk>null</jk> (treated as {@link TemporalFormat#NOT_SET}).
430    * @param timeZone The session time zone (used as the default zone when the wire value lacks zone info).
431    * @return The parsed value, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or the target is not a
432    *    {@link Temporal} subtype.
433    */
434   public static <T> T parseTemporal(String iso8601, ClassMeta<T> targetType, TemporalFormat formatHint, TimeZone timeZone) {
435      if (iso8601 == null)
436         return null;
437      Class<T> tc = targetType.inner();
438      if (! Temporal.class.isAssignableFrom(tc))
439         return null;
440      ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
441      Class<? extends Temporal> ttc = (Class<? extends Temporal>) tc;
442      if (formatHint != null && formatHint != TemporalFormat.NOT_SET && formatHint != TemporalFormat.DEFAULT)
443         return (T) formatHint.parse(iso8601, ttc, zoneId);
444      return (T) parseTemporalDefault(iso8601, ttc, zoneId);
445   }
446
447   /**
448    * Hand-written state machine that parses the {@code PT}-prefixed body as a strict, gapless sequence of
449    * {@code number[whitespace]unit} components ({@code H}/{@code M}/{@code S}, case-insensitive).
450    *
451    * <p>
452    * Whitespace is permitted between a number and its unit and between/around components, but every
453    * non-whitespace character must belong to a valid component. The parser rejects (by returning <jk>null</jk>,
454    * the same signal used for any input it cannot handle) when it encounters stray characters, a malformed
455    * number (e.g. more than one decimal point, or a trailing dot with no fraction), or a number without a
456    * trailing unit. On rejection {@link #parseDuration} falls through to {@link Duration#parse(CharSequence)},
457    * which throws for genuinely invalid values. Numeric conversion uses the floating-point expression
458    * {@code (long)(value * 1_000_000_000)} to preserve historical rounding for well-formed inputs.
459    *
460    * @param s The candidate duration string (already trimmed/dequoted/PT-normalized by {@link #parseDuration}).
461    * @return The parsed {@link Duration}, or <jk>null</jk> if the string is too short, lacks a {@code PT}/{@code pt}
462    *    prefix, contains no component, or contains any stray/malformed content.
463    */
464   @SuppressWarnings({
465      "java:S3776" // Hand-written ISO-8601 duration lexer/state-machine; decomposing the char-by-char scan would obscure the parse invariants without changing behavior.
466   })
467   private static Duration parseDurationManual(String s) {
468      if (s == null || s.length() < 3)
469         return null;
470      boolean neg = s.startsWith("-");
471      String s2 = neg ? s.substring(1) : s;
472      if (!s2.startsWith("PT") && !s2.startsWith("pt"))
473         return null;
474      s2 = s2.substring(2);
475      long totalNanos = 0;
476      boolean found = false;
477      int len = s2.length();
478      int i = 0;
479      while (i < len) {
480         // Whitespace between/around components is skipped; any other non-component character is rejected.
481         if (isRegexWhitespace(s2.charAt(i))) {
482            i++;
483            continue;
484         }
485         // A component must begin with a digit; any other character is stray junk → reject.
486         if (!isAsciiDigit(s2.charAt(i)))
487            return null;
488         int start = i;
489         while (i < len && isAsciiDigit(s2.charAt(i)))  // Integer part.
490            i++;
491         // Optional fraction: a single '.' that MUST be followed by at least one digit.
492         if (i < len && s2.charAt(i) == '.') {
493            i++;
494            if (i >= len || !isAsciiDigit(s2.charAt(i)))
495               return null;  // Trailing dot with no fraction → malformed.
496            while (i < len && isAsciiDigit(s2.charAt(i)))
497               i++;
498         }
499         int numEnd = i;
500         while (i < len && isRegexWhitespace(s2.charAt(i)))  // Optional whitespace between number and unit.
501            i++;
502         // A unit designator is required; a number without one (or followed by stray chars) is malformed.
503         if (i >= len || !isDurationUnit(s2.charAt(i)))
504            return null;
505         found = true;
506         double v = Double.parseDouble(s2.substring(start, numEnd));
507         long nanos = (long)(v * 1_000_000_000);
508         char unit = Character.toUpperCase(s2.charAt(i));
509         switch (unit) {
510            case 'H': totalNanos += nanos * 3600; break;
511            case 'M': totalNanos += nanos * 60; break;
512            case 'S': totalNanos += nanos; break;
513            default: break; // Unreachable; isDurationUnit(char) already restricted the unit to H, M or S.
514         }
515         i++;  // Consume the unit.
516      }
517      if (!found)
518         return null;
519      Duration d = Duration.ofNanos(totalNanos);
520      return neg ? d.negated() : d;
521   }
522
523   private static boolean isAsciiDigit(char c) {
524      return c >= '0' && c <= '9';
525   }
526
527   /**
528    * Matches the regex {@code \s} character class (without the {@code UNICODE_CHARACTER_CLASS} flag).
529    */
530   private static boolean isRegexWhitespace(char c) {
531      return c == ' ' || c == '\t' || c == '\n' || c == '\u000B' || c == '\f' || c == '\r';
532   }
533
534   private static boolean isDurationUnit(char c) {
535      return c == 'H' || c == 'h' || c == 'M' || c == 'm' || c == 'S' || c == 's';
536   }
537
538   /**
539    * State-machine equivalent of {@code ^[+-]?\d++$} — an optional sign followed by one or more ASCII digits.
540    *
541    * @param s The string to test.
542    * @return <jk>true</jk> if the entire string is an optionally-signed integer literal.
543    */
544   private static boolean isIntegerLiteral(String s) {
545      int len = s.length();
546      int i = 0;
547      if (i < len && (s.charAt(i) == '+' || s.charAt(i) == '-'))
548         i++;
549      if (i == len)
550         return false;
551      for (int k = i; k < len; k++)
552         if (!isAsciiDigit(s.charAt(k)))
553            return false;
554      return true;
555   }
556
557   /**
558    * State-machine equivalent of {@code ^[+-]?\d++\.\d++$} — an optionally-signed decimal with digits on both
559    * sides of a single dot.
560    *
561    * @param s The string to test.
562    * @return <jk>true</jk> if the entire string is an optionally-signed decimal literal.
563    */
564   private static boolean isDecimalLiteral(String s) {
565      int len = s.length();
566      int i = 0;
567      if (i < len && (s.charAt(i) == '+' || s.charAt(i) == '-'))
568         i++;
569      int intStart = i;
570      while (i < len && isAsciiDigit(s.charAt(i)))
571         i++;
572      if (i == intStart)
573         return false;
574      if (i >= len || s.charAt(i) != '.')
575         return false;
576      i++;
577      int fracStart = i;
578      while (i < len && isAsciiDigit(s.charAt(i)))
579         i++;
580      if (i == fracStart)
581         return false;
582      return i == len;
583   }
584
585   /**
586    * State-machine equivalent of {@code ^[+-]?\d++(?:\.\d++)?(?:ns|us|ms|s|m|h|d)$} — an optionally-signed number
587    * (with optional fraction) followed by a HOCON time unit.
588    *
589    * @param s The string to test.
590    * @return <jk>true</jk> if the entire string is an optionally-signed HOCON duration literal.
591    */
592   private static boolean isHoconLiteral(String s) {
593      int len = s.length();
594      int i = 0;
595      if (i < len && (s.charAt(i) == '+' || s.charAt(i) == '-'))
596         i++;
597      int intStart = i;
598      while (i < len && isAsciiDigit(s.charAt(i)))
599         i++;
600      if (i == intStart)
601         return false;
602      // Optional fraction consumed only when '.' is followed by at least one digit; otherwise left for the unit.
603      if (i < len && s.charAt(i) == '.' && i + 1 < len && isAsciiDigit(s.charAt(i + 1))) {
604         i++;
605         while (i < len && isAsciiDigit(s.charAt(i)))
606            i++;
607      }
608      String unit = s.substring(i);
609      return eqa(unit, "ns", "us", "ms", "s", "m", "h", "d");
610   }
611
612   private static Calendar parseCalendarDefault(String iso8601, ZoneId zoneId) {
613      var formatter = selectParserFormatter(iso8601);
614      var ta = new DefaultingTemporalAccessor(formatter.parse(iso8601), zoneId);
615      return GregorianCalendar.from(ZonedDateTime.from(ta));
616   }
617
618   private static Date parseDateDefault(String iso8601, ZoneId zoneId) {
619      var formatter = selectParserFormatter(iso8601);
620      var ta = new DefaultingTemporalAccessor(formatter.parse(iso8601), zoneId);
621      return Date.from(ZonedDateTime.from(ta).toInstant());
622   }
623
624   private static <T extends Temporal> T parseTemporalDefault(String iso8601, Class<T> tc, ZoneId zoneId) {
625      ZoneId offset = (tc == Instant.class) ? Z : zoneId;
626      var formatter = getFormatterForType(tc);
627      var ta = new DefaultingTemporalAccessor(formatter.parse(iso8601), offset);
628
629      try {
630         var parseMethod = info(tc).getPublicMethod(
631            x -> x.isStatic()
632               && x.isNotDeprecated()
633               && x.hasName("from")
634               && x.hasReturnType(tc)
635               && x.hasParameterTypes(TemporalAccessor.class)
636         ).map(MethodInfo::inner).orElse(null);
637
638         if (parseMethod != null)
639            return (T) parseMethod.invoke(null, ta);
640      } catch (IllegalAccessException | InvocationTargetException e) {
641         throw toRex(e);
642      }
643
644      throw iaex("Cannot parse ISO 8601 string into type: %s", tc.getName());
645   }
646
647   private static DateTimeFormatter getFormatterForType(Class<?> tc) {
648      var f = DEFAULT_FORMATTERS.get(tc);
649      return or(f, DateTimeFormatter.ISO_INSTANT);
650   }
651
652   /**
653    * Auto-detects the appropriate parser formatter based on the string content.
654    */
655   private static DateTimeFormatter selectParserFormatter(String iso8601) {
656      boolean hasTime = iso8601.contains("T");
657      boolean hasZone = iso8601.endsWith("Z") || iso8601.contains("+")
658         || (iso8601.length() > 10 && iso8601.lastIndexOf('-') > 10);
659      if (hasTime && hasZone)
660         return DateTimeFormatter.ISO_OFFSET_DATE_TIME;
661      if (hasTime)
662         return DateTimeFormatter.ISO_LOCAL_DATE_TIME;
663      if (hasZone)
664         return DateTimeFormatter.ISO_DATE;
665      return DateTimeFormatter.ISO_LOCAL_DATE;
666   }
667
668   /**
669    * Converts epoch milliseconds to the target date/time type.
670    *
671    * @param <T> The target type.
672    * @param epochMillis The epoch milliseconds value.
673    * @param targetType The target class metadata.
674    * @param timeZone The session time zone.
675    * @return The converted date/time object, or <jk>null</jk> if the target type is not a recognized date/time type.
676    */
677   public static <T> T fromEpochMillis(long epochMillis, ClassMeta<T> targetType, TimeZone timeZone) {
678      Class<T> tc = targetType.inner();
679      ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault();
680      Instant instant = Instant.ofEpochMilli(epochMillis);
681
682      if (tc == Instant.class) return (T) instant;
683      if (tc == ZonedDateTime.class) return (T) instant.atZone(zoneId);
684      if (tc == OffsetDateTime.class) return (T) instant.atZone(zoneId).toOffsetDateTime();
685      if (tc == LocalDateTime.class) return (T) instant.atZone(zoneId).toLocalDateTime();
686      if (tc == LocalDate.class) return (T) instant.atZone(zoneId).toLocalDate();
687      if (tc == LocalTime.class) return (T) instant.atZone(zoneId).toLocalTime();
688      if (tc == OffsetTime.class) return (T) instant.atZone(zoneId).toOffsetDateTime().toOffsetTime();
689      if (tc == Date.class) return (T) Date.from(instant);
690      if (Calendar.class.isAssignableFrom(tc)) {
691         var cal = GregorianCalendar.from(instant.atZone(zoneId));
692         return (T) cal;
693      }
694      if (XMLGregorianCalendar.class.isAssignableFrom(tc)) {
695         var gc = GregorianCalendar.from(instant.atZone(zoneId));
696         return (T) datatypeFactory.newXMLGregorianCalendar(gc);
697      }
698
699      return null;
700   }
701}