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.commons.time;
018
019import static org.apache.juneau.commons.lang.StateEnum.*;
020import static org.apache.juneau.commons.utils.AssertionUtils.*;
021
022import java.time.*;
023import java.time.format.*;
024import java.time.temporal.*;
025import java.util.*;
026
027import org.apache.juneau.commons.utils.*;
028
029/**
030 * A ZonedDateTime with precision information for granular time operations.
031 *
032 * <p>
033 * This class combines a {@link ZonedDateTime} with a {@link ChronoField} precision identifier,
034 * allowing for granular time operations such as rolling by specific time units.
035 *
036 * <p>
037 * The precision field indicates the granularity of the time value, which determines how the
038 * {@link #roll(int)} method behaves. For example, a precision of {@link ChronoField#YEAR} means
039 * rolling by 1 will advance the year, while a precision of {@link ChronoField#HOUR_OF_DAY} means
040 * rolling by 1 will advance the hour.
041 *
042 * <h5 class='section'>ISO8601 Parsing:</h5>
043 * <p>
044 * The {@link #of(String)} method can parse various ISO8601 timestamp formats:
045 * <ul>
046 *    <li>Date formats: <js>"2011"</js>, <js>"2011-01"</js>, <js>"2011-01-15"</js>
047 *    <li>DateTime formats: <js>"2011-01-15T12"</js>, <js>"2011-01-15T12:30"</js>, <js>"2011-01-15T12:30:45"</js>
048 *    <li>With fractional seconds: <js>"2011-01-15T12:30:45.123"</js>, <js>"2011-01-15T12:30:45,123"</js>
049 *    <li>Time-only formats: <js>"T12"</js>, <js>"T12:30"</js>, <js>"T12:30:45"</js>
050 *    <li>With timezone: <js>"2011-01-15T12:30:45Z"</js>, <js>"2011-01-15T12:30:45+05:30"</js>, <js>"2011-01-15T12:30:45-05:30"</js>
051 * </ul>
052 *
053 * <p>
054 * The precision is automatically determined from the input format. For example:
055 * <ul>
056 *    <li><js>"2011"</js> → {@link ChronoField#YEAR}
057 *    <li><js>"2011-01"</js> → {@link ChronoField#MONTH_OF_YEAR}
058 *    <li><js>"2011-01-15"</js> → {@link ChronoField#DAY_OF_MONTH}
059 *    <li><js>"2011-01-15T12"</js> → {@link ChronoField#HOUR_OF_DAY}
060 *    <li><js>"2011-01-15T12:30"</js> → {@link ChronoField#MINUTE_OF_HOUR}
061 *    <li><js>"2011-01-15T12:30:45"</js> → {@link ChronoField#SECOND_OF_MINUTE}
062 *    <li><js>"2011-01-15T12:30:45.123"</js> → {@link ChronoField#MILLI_OF_SECOND} (1-3 digits)
063 *    <li><js>"2011-01-15T12:30:45.123456789"</js> → {@link ChronoField#NANO_OF_SECOND} (4-9 digits)
064 * </ul>
065 *
066 * <h5 class='section'>Example:</h5>
067 * <p class='bjava'>
068 *    <jc>// Parse an ISO8601 timestamp with year precision</jc>
069 *    GranularZonedDateTime <jv>gdt</jv> = GranularZonedDateTime.<jsm>of</jsm>(<js>"2011"</js>);
070 *    <jc>// Roll forward by one year</jc>
071 *    <jv>gdt</jv> = <jv>gdt</jv>.<jsm>roll</jsm>(1);
072 *    <jc>// Get the ZonedDateTime</jc>
073 *    ZonedDateTime <jv>zdt</jv> = <jv>gdt</jv>.<jsm>getZonedDateTime</jsm>();
074 *    <jc>// Result: 2012-01-01T00:00:00 with system default timezone</jc>
075 * </p>
076 *
077 * <p class='bjava'>
078 *    <jc>// Parse a datetime with hour precision</jc>
079 *    GranularZonedDateTime <jv>gdt2</jv> = GranularZonedDateTime.<jsm>of</jsm>(<js>"2011-01-15T12Z"</js>);
080 *    <jc>// Roll forward by 2 hours</jc>
081 *    <jv>gdt2</jv> = <jv>gdt2</jv>.<jsm>roll</jsm>(<jv>ChronoField</jv>.<jf>HOUR_OF_DAY</jf>, 2);
082 *    <jc>// Result: 2011-01-15T14:00:00Z</jc>
083 * </p>
084 *
085 * <h5 class='section'>Thread Safety:</h5>
086 * <p>
087 * This class is immutable and thread-safe.
088 *
089 * <h5 class='section'>See Also:</h5><ul>
090 *    <li class='jm'>{@link ZonedDateTime}
091 *    <li class='jm'>{@link ChronoField}
092 *    <li class='jm'>{@link DateUtils}
093 * </ul>
094 */
095public class GranularZonedDateTime {
096
097   /**
098    * Creates a GranularZonedDateTime from a Date with the specified precision.
099    *
100    * <p>
101    * The date is converted to a ZonedDateTime using the system default timezone.
102    *
103    * @param date The date to convert.
104    * @param precision The precision of the time value.
105    * @return A new GranularZonedDateTime instance.
106    * @throws IllegalArgumentException if date or precision is null.
107    */
108   public static GranularZonedDateTime of(Date date, ChronoField precision) {
109      return of(date, precision, ZoneId.systemDefault());
110   }
111
112   /**
113    * Creates a GranularZonedDateTime from a Date with the specified precision and timezone.
114    *
115    * <p>
116    * The date is converted to a ZonedDateTime using the specified timezone.
117    *
118    * @param date The date to convert.
119    * @param precision The precision of the time value.
120    * @param zoneId The timezone to use.
121    * @return A new GranularZonedDateTime instance.
122    * @throws IllegalArgumentException if date, precision, or zoneId is null.
123    */
124   public static GranularZonedDateTime of(Date date, ChronoField precision, ZoneId zoneId) {
125      return of(date.toInstant().atZone(zoneId), precision);
126   }
127
128   /**
129    * Parses an ISO8601 timestamp string into a GranularZonedDateTime.
130    *
131    * <p>
132    * This method parses various ISO8601 formats and automatically determines the precision
133    * based on the format. If no timezone is specified in the string, the system default
134    * timezone is used.
135    *
136    * <h5 class='section'>Supported Formats:</h5>
137    * <ul>
138    *    <li>Date: <js>"2011"</js>, <js>"2011-01"</js>, <js>"2011-01-15"</js>
139    *    <li>DateTime: <js>"2011-01-15T12"</js>, <js>"2011-01-15T12:30"</js>, <js>"2011-01-15T12:30:45"</js>
140    *    <li>With fractional seconds: <js>"2011-01-15T12:30:45.123"</js>, <js>"2011-01-15T12:30:45,123"</js>
141    *    <li>Time-only: <js>"T12"</js>, <js>"T12:30"</js>, <js>"T12:30:45"</js> (uses current date)
142    *    <li>With timezone: <js>"2011-01-15T12:30:45Z"</js>, <js>"2011-01-15T12:30:45+05:30"</js>, <js>"2011-01-15T12:30:45-05:30"</js>
143    * </ul>
144    *
145    * <h5 class='section'>Timezone Handling:</h5>
146    * <ul>
147    *    <li>If the string contains a timezone (Z, +HH:mm, -HH:mm, etc.), that timezone is used.
148    *    <li>If no timezone is specified, the system default timezone is used.
149    *    <li>For time-only formats (starting with "T"), the current date is used with the specified or default timezone.
150    * </ul>
151    *
152    * <h5 class='section'>Precision Detection:</h5>
153    * <p>
154    * The precision is automatically determined from the format:
155    * <ul>
156    *    <li>Year only → {@link ChronoField#YEAR}
157    *    <li>Year-Month → {@link ChronoField#MONTH_OF_YEAR}
158    *    <li>Year-Month-Day → {@link ChronoField#DAY_OF_MONTH}
159    *    <li>With hour → {@link ChronoField#HOUR_OF_DAY}
160    *    <li>With minute → {@link ChronoField#MINUTE_OF_HOUR}
161    *    <li>With second → {@link ChronoField#SECOND_OF_MINUTE}
162    *    <li>With 1-3 fractional digits → {@link ChronoField#MILLI_OF_SECOND}
163    *    <li>With 4-9 fractional digits → {@link ChronoField#NANO_OF_SECOND}
164    * </ul>
165    *
166    * @param value The ISO8601 timestamp string to parse.
167    * @return A new GranularZonedDateTime instance.
168    * @throws IllegalArgumentException if timestamp is null.
169    * @throws DateTimeParseException if the timestamp format is invalid.
170    */
171   public static GranularZonedDateTime of(String value) {
172      return of(value, null, null);
173   }
174
175   /**
176    * Parses an ISO8601 timestamp string into a GranularZonedDateTime with a custom time provider.
177    *
178    * <p>
179    * This method is similar to {@link #of(String)}, but allows you to specify a custom
180    * {@link TimeProvider} to use for obtaining the system default timezone and current time.
181    * This is useful for testing or when you need deterministic time behavior.
182    *
183    * <p>
184    * The time provider is used when:
185    * <ul>
186    *    <li>No timezone is specified in the string - uses {@link TimeProvider#getSystemDefaultZoneId()}
187    *    <li>Time-only formats (starting with "T") - uses {@link TimeProvider#now(ZoneId)} to get the current date
188    * </ul>
189    *
190    * <h5 class='section'>Example:</h5>
191    * <p class='bjava'>
192    *    <jc>// Parse with custom time provider for testing</jc>
193    *    <jk>var</jk> <jv>timeProvider</jv> = <jk>new</jk> FakeTimeProvider();
194    *    GranularZonedDateTime <jv>gdt</jv> = GranularZonedDateTime.<jsm>of</jsm>(
195    *       <js>"T12:30:45"</js>,
196    *       <jv>timeProvider</jv>
197    *    );
198    *    <jc>// Result uses the time provider's current date and timezone</jc>
199    * </p>
200    *
201    * @param value The ISO8601 timestamp string to parse.
202    * @param timeProvider The time provider to use for system default timezone and current time.
203    *    If null, {@link TimeProvider#INSTANCE} is used.
204    * @return A new GranularZonedDateTime instance.
205    * @throws IllegalArgumentException if value is null.
206    * @throws DateTimeParseException if the timestamp format is invalid.
207    */
208   public static GranularZonedDateTime of(String value, TimeProvider timeProvider) {
209      return of(value, null, timeProvider);
210   }
211
212
213   /**
214    * Parses an ISO8601 timestamp string into a GranularZonedDateTime with a default timezone and custom time provider.
215    *
216    * <p>
217    * This method is similar to {@link #of(String)}, but allows you to specify both a default
218    * timezone and a custom {@link TimeProvider} to use when no timezone is present in the timestamp string.
219    *
220    * <p>
221    * If the timestamp string contains a timezone (Z, +HH:mm, -HH:mm, etc.), that timezone
222    * takes precedence over the defaultZoneId parameter. The defaultZoneId is only used when
223    * no timezone is specified in the string.
224    *
225    * <p>
226    * The time provider is used when:
227    * <ul>
228    *    <li>No timezone is specified and defaultZoneId is null - uses {@link TimeProvider#getSystemDefaultZoneId()}
229    *    <li>Time-only formats (starting with "T") - uses {@link TimeProvider#now(ZoneId)} to get the current date
230    * </ul>
231    *
232    * <h5 class='section'>Example:</h5>
233    * <p class='bjava'>
234    *    <jc>// Parse with default timezone and custom time provider</jc>
235    *    <jk>var</jk> <jv>timeProvider</jv> = <jk>new</jk> FakeTimeProvider();
236    *    GranularZonedDateTime <jv>gdt1</jv> = GranularZonedDateTime.<jsm>of</jsm>(
237    *       <js>"2011-01-15T12:30:45"</js>,
238    *       <jv>ZoneId</jv>.<jsm>of</jsm>(<js>"America/New_York"</js>),
239    *       <jv>timeProvider</jv>
240    *    );
241    *    <jc>// Result uses America/New_York timezone</jc>
242    *
243    *    <jc>// Parse with timezone in string (defaultZoneId is ignored)</jc>
244    *    GranularZonedDateTime <jv>gdt2</jv> = GranularZonedDateTime.<jsm>of</jsm>(
245    *       <js>"2011-01-15T12:30:45Z"</js>,
246    *       <jv>ZoneId</jv>.<jsm>of</jsm>(<js>"America/New_York"</js>),
247    *       <jv>timeProvider</jv>
248    *    );
249    *    <jc>// Result uses UTC (Z), not America/New_York</jc>
250    * </p>
251    *
252    * @param value The ISO8601 timestamp string to parse.
253    * @param defaultZoneId The default timezone to use if no timezone is specified in the string.
254    *    If null, the time provider's system default timezone is used.
255    * @param timeProvider The time provider to use for system default timezone and current time.
256    *    If null, {@link TimeProvider#INSTANCE} is used.
257    * @return A new GranularZonedDateTime instance.
258    * @throws IllegalArgumentException if value is null.
259    * @throws DateTimeParseException if the timestamp format is invalid.
260    */
261   public static GranularZonedDateTime of(String value, ZoneId defaultZoneId, TimeProvider timeProvider) {
262      assertArgNotNull("value", value);
263      var digit = StringUtils.DIGIT;
264      timeProvider = timeProvider == null ? TimeProvider.INSTANCE : timeProvider;
265
266      // States:
267      // S01: Looking for Y(S02) or T(S07).
268      // S02: Found Y, looking for Y(S02)/-(S03)/T(S07).
269      // S03: Found -, looking for M(S04).
270      // S04: Found M, looking for M(S04)/-(S05)/T(S07).
271      // S05: Found -, looking for D(S10).
272      // S06  Found D, looking for D(S06)/T(S07).
273      // S07: Found T, looking for h(S08)/Z(S15)/+(S16)/-(S17).
274      // S08: Found h, looking for h(S08)/:(S09)/Z(S15)/+(S16)/-(S17).
275      // S09: Found :, looking for m(S10).
276      // S10: Found m, looking for m(S10)/:(S11)/Z(S15)/+(S16)/-(S17).
277      // S11: Found :, looking for s(S12).
278      // S12: Found s, looking for s(S12)/.(S13)/Z(S15)/+(S16)/-(S17).
279      // S13: Found ., looking for S(S14)/Z(S15)/+(S16)/-(S17).
280      // S14: Found S, looking for S(S14)/Z(S15)/+(S16)/-(S17).
281      // S15: Found Z.
282      // S16: Found +, looking for oh(S18).
283      // S17: Found -, looking for oh(S18).
284      // S18: Found oh, looking for oh(S18)/:(S19).
285      // S19: Found :, looking for om(S20).
286      // S20: Found om, looking for om(S20).
287
288
289      int year = 1, month = 1, day = 1, hour = 0, minute = 0, second = 0, nanos = 0, ohour = -1, ominute = -1;
290      boolean nego = false; // negative offset
291      boolean timeOnly = false; // Track if format started with "T" (time-only)
292      ZoneId zoneId = null;
293      var state = S1;
294      var mark = 0;
295      ChronoField precision = ChronoField.YEAR; // Track precision as we go
296
297      for (var i = 0; i < value.length(); i++) {
298         var c = value.charAt(i);
299
300         if (state == S1) {
301            // S01: Looking for Y(S02) or T(S07)
302            if (digit.contains(c)) {
303               mark = i;
304               state = S2;
305            } else if (c == 'T') {
306               timeOnly = true; // Mark as time-only format
307               state = S7;
308            } else {
309               throw bad(value, i);
310            }
311         } else if (state == S2) {
312            // S02: Found Y, looking for Y(S02)/-(S03)/T(S07)
313            if (digit.contains(c)) {
314               // Stay in S2
315            } else if (c == '-') {
316               year = parse(value, 4, mark, i, 0, 9999);
317               state = S3;
318            } else if (c == 'T') {
319               year = parse(value, 4, mark, i, 0, 9999);
320               state = S7;
321            } else if (c == 'Z') {
322               zoneId = ZoneId.of("Z");
323               year = parse(value, 4, mark, i, 0, 9999);
324               state = S15;
325            } else if (c == '+') {
326               year = parse(value, 4, mark, i, 0, 9999);
327               nego = false;
328               state = S16;
329            } else {
330               throw bad(value, i);
331            }
332         } else if (state == S3) {
333            // S03: Found -, looking for M(S04)
334            if (digit.contains(c)) {
335               mark = i;
336               state = S4;
337               precision = ChronoField.MONTH_OF_YEAR;
338            } else {
339               throw bad(value, i);
340            }
341         } else if (state == S4) {
342            // S04: Found M, looking for M(S04)/-(S05)/T(S07)
343            if (digit.contains(c)) {
344               // Stay in S4
345            } else if (c == '-') {
346               month = parse(value, 2, mark, i, 1, 12);
347               state = S5;
348            } else if (c == 'T') {
349               month = parse(value, 2, mark, i, 1, 12);
350               state = S7;
351            } else if (c == 'Z') {
352               month = parse(value, 2, mark, i, 1, 12);
353               zoneId = ZoneId.of("Z");
354               state = S15;
355            } else if (c == '+') {
356               month = parse(value, 2, mark, i, 1, 12);
357               nego = false;
358               state = S16;
359            } else {
360               throw bad(value, i);
361            }
362         } else if (state == S5) {
363            // S05: Found -, looking for D(S06)
364            if (digit.contains(c)) {
365               mark = i;
366               state = S6;
367               precision = ChronoField.DAY_OF_MONTH;
368            } else {
369               throw bad(value, i);
370            }
371         } else if (state == S6) {
372            // S06: Found D, looking for D(S06)/T(S07)
373            if (digit.contains(c)) {
374               // Stay in S6
375            } else if (c == 'T') {
376               day = parse(value, 2, mark, i, 1, 31);
377               state = S7;
378            } else if (c == 'Z') {
379               day = parse(value, 2, mark, i, 1, 31);
380               zoneId = ZoneId.of("Z");
381               state = S15;
382            } else if (c == '+') {
383               day = parse(value, 2, mark, i, 1, 31);
384               nego = false;
385               state = S16;
386            } else if (c == '-') {
387               day = parse(value, 2, mark, i, 1, 31);
388               nego = true;
389               state = S17;
390            } else {
391               throw bad(value, i);
392            }
393         } else if (state == S7) {
394            // S07: Found T, looking for h(S08)/Z(S15)/+(S16)/-(S17)
395            if (digit.contains(c)) {
396               mark = i;
397               state = S8;
398               precision = ChronoField.HOUR_OF_DAY;
399            } else if (c == 'Z') {
400               zoneId = ZoneId.of("Z");
401               if (timeOnly) {
402                  precision = ChronoField.HOUR_OF_DAY;
403               }
404               state = S15;
405            } else if (c == '+') {
406               nego = false;
407               if (timeOnly) {
408                  precision = ChronoField.HOUR_OF_DAY;
409               }
410               state = S16;
411            } else if (c == '-') {
412               nego = true;
413               if (timeOnly) {
414                  precision = ChronoField.HOUR_OF_DAY;
415               }
416               state = S17;
417            } else {
418               throw bad(value, i);
419            }
420         } else if (state == S8) {
421            // S08: Found h, looking for h(S08)/:(S09)/Z(S15)/+(S16)/-(S17)
422            if (digit.contains(c)) {
423               // Stay in S8
424            } else if (c == ':') {
425               hour = parse(value, 2, mark, i, 0, 23);
426               state = S9;
427            } else if (c == 'Z') {
428               hour = parse(value, 2, mark, i, 0, 23);
429               zoneId = ZoneId.of("Z");
430               state = S15;
431            } else if (c == '+') {
432               hour = parse(value, 2, mark, i, 0, 23);
433               nego = false;
434               state = S16;
435            } else if (c == '-') {
436               hour = parse(value, 2, mark, i, 0, 23);
437               nego = true;
438               state = S17;
439            } else {
440               throw bad(value, i);
441            }
442         } else if (state == S9) {
443            // S09: Found :, looking for m(S10)
444            if (digit.contains(c)) {
445               mark = i;
446               state = S10;
447               precision = ChronoField.MINUTE_OF_HOUR;
448            } else {
449               throw bad(value, i);
450            }
451         } else if (state == S10) {
452            // S10: Found m, looking for m(S10)/:(S11)/Z(S15)/+(S16)/-(S17)
453            if (digit.contains(c)) {
454               // Stay in S10
455            } else if (c == ':') {
456               minute = parse(value, 2, mark, i, 0, 59);
457               state = S11;
458            } else if (c == 'Z') {
459               minute = parse(value, 2, mark, i, 0, 59);
460               zoneId = ZoneId.of("Z");
461               state = S15;
462            } else if (c == '+') {
463               minute = parse(value, 2, mark, i, 0, 59);
464               nego = false;
465               state = S16;
466            } else if (c == '-') {
467               minute = parse(value, 2, mark, i, 0, 59);
468               nego = true;
469               state = S17;
470            } else {
471               throw bad(value, i);
472            }
473         } else if (state == S11) {
474            // S11: Found :, looking for s(S12)
475            if (digit.contains(c)) {
476               mark = i;
477               state = S12;
478               precision = ChronoField.SECOND_OF_MINUTE;
479            } else {
480               throw bad(value, i);
481            }
482         } else if (state == S12) {
483            // S12: Found s, looking for s(S12)/.(S13)/Z(S15)/+(S16)/-(S17)
484            if (digit.contains(c)) {
485               // Stay in S12
486         } else if (c == '.' || c == ',') {
487            second = parse(value, 2, mark, i, 0, 59);
488            state = S13;
489            // Precision will be set based on number of fractional digits
490            } else if (c == 'Z') {
491               second = parse(value, 2, mark, i, 0, 59);
492               zoneId = ZoneId.of("Z");
493               state = S15;
494            } else if (c == '+') {
495               second = parse(value, 2, mark, i, 0, 59);
496               nego = false;
497               state = S16;
498            } else if (c == '-') {
499               second = parse(value, 2, mark, i, 0, 59);
500               nego = true;
501               state = S17;
502            } else {
503               throw bad(value, i);
504            }
505         } else if (state == S13) {
506            // S13: Found . or ,, looking for S(S14)/Z(S15)/+(S16)/-(S17)
507            if (digit.contains(c)) {
508               mark = i;
509               state = S14;
510            } else if (c == 'Z') {
511               zoneId = ZoneId.of("Z");
512               state = S15;
513            } else if (c == '+') {
514               nego = false;
515               state = S16;
516            } else if (c == '-') {
517               nego = true;
518               state = S17;
519            } else {
520               throw bad(value, i);
521            }
522         } else if (state == S14) {
523            // S14: Found S, looking for S(S14)/Z(S15)/+(S16)/-(S17)
524            if (digit.contains(c)) {
525               // Stay in S14
526            } else if (c == 'Z') {
527               nanos = parseNanos(value, mark, i);
528               zoneId = ZoneId.of("Z");
529               // Set precision based on number of fractional digits: 1-3 = milliseconds, 4-9 = nanoseconds
530               var digitCount = i - mark;
531               precision = (digitCount <= 3) ? ChronoField.MILLI_OF_SECOND : ChronoField.NANO_OF_SECOND;
532               state = S15;
533            } else if (c == '+') {
534               nanos = parseNanos(value, mark, i);
535               // Set precision based on number of fractional digits: 1-3 = milliseconds, 4-9 = nanoseconds
536               var digitCount = i - mark;
537               precision = (digitCount <= 3) ? ChronoField.MILLI_OF_SECOND : ChronoField.NANO_OF_SECOND;
538               nego = false;
539               state = S16;
540            } else if (c == '-') {
541               nanos = parseNanos(value, mark, i);
542               // Set precision based on number of fractional digits: 1-3 = milliseconds, 4-9 = nanoseconds
543               var digitCount = i - mark;
544               precision = (digitCount <= 3) ? ChronoField.MILLI_OF_SECOND : ChronoField.NANO_OF_SECOND;
545               nego = true;
546               state = S17;
547            } else {
548               throw bad(value, i);
549            }
550         } else if (state == S15) {
551            // Shouldn't find anything after Z
552            throw bad(value, i);
553         } else if (state == S16) {
554            // S16: Found +, looking for oh(S18)
555            if (digit.contains(c)) {
556               mark = i;
557               state = S18;
558            } else {
559               throw bad(value, i);
560            }
561         } else if (state == S17) {
562            // S17: Found -, looking for oh(S18)
563            if (digit.contains(c)) {
564               mark = i;
565               state = S18;
566            } else {
567               throw bad(value, i);
568            }
569         } else if (state == S18) {
570            // S18: Found oh, looking for oh(S18)/:(S19)/end
571            if (digit.contains(c)) {
572               // Stay in S18
573            } else if (c == ':') {
574               ohour = parse(value, 2, mark, i, 0, 18);
575               state = S19;
576            } else {
577               throw bad(value, i);
578            }
579            // If we reach end of string, ohour is complete (2 digits)
580         } else if (state == S19) {
581            // S19: Found :, looking for om(S20)
582            if (digit.contains(c)) {
583               mark = i;
584               state = S20;
585            } else {
586               throw bad(value, i);
587            }
588         } else /* (state == S20) */ {
589            // S20: Found om, looking for om(S20)
590            if (digit.contains(c)) {
591               // Stay in S20
592            } else {
593               throw bad(value, i);
594            }
595         }
596      }
597
598      var end = value.length(); // end is exclusive (one past last character)
599      if (state.isAny(S1, S3, S5, S7, S9, S11, S13, S16, S17, S19)) {
600         throw bad(value, end - 1);
601      } else if (state == S2) {
602         // S02: Found Y, looking for Y(S02)/-(S03)/T(S07).
603         year = parse(value, 4, mark, end, 0, 9999);
604         precision = ChronoField.YEAR;
605      } else if (state == S4) {
606         // S04: Found M, looking for M(S04)/-(S05)/T(S07).
607         month = parse(value, 2, mark, end, 1, 12);
608         precision = ChronoField.MONTH_OF_YEAR;
609      } else if (state == S6) {
610         // S06  Found D, looking for D(S06)/T(S07).
611         day = parse(value, 2, mark, end, 1, 31);
612         precision = ChronoField.DAY_OF_MONTH;
613      } else if (state == S8) {
614         // S08: Found h, looking for h(S08)/:(S09)/Z(S15)/+(S16)/-(S17).
615         hour = parse(value, 2, mark, end, 0, 23);
616         precision = ChronoField.HOUR_OF_DAY;
617      } else if (state == S10) {
618         // S10: Found m, looking for m(S10)/:(S11)/Z(S15)/+(S16)/-(S17).
619         minute = parse(value, 2, mark, end, 0, 59);
620         precision = ChronoField.MINUTE_OF_HOUR;
621      } else if (state == S12) {
622         // S12: Found s, looking for s(S12)/.(S13)/Z(S15)/+(S16)/-(S17).
623         second = parse(value, 2, mark, end, 0, 59);
624         precision = ChronoField.SECOND_OF_MINUTE;
625      } else if (state == S14) {
626         // S14: Found S, looking for S(S14)/Z(S15)/+(S16)/-(S17).
627         nanos = parseNanos(value, mark, end);
628         // Set precision based on number of digits: 1-3 = milliseconds, 4-9 = nanoseconds
629         var digitCount = end - mark;
630         precision = (digitCount <= 3) ? ChronoField.MILLI_OF_SECOND : ChronoField.NANO_OF_SECOND;
631      } else if (state == S15) {
632         // S15: Found Z.
633      } else if (state == S18) {
634         // S18: Found oh, looking for oh(S18)/:(S19).
635         // Check if we have 2 digits (+hh) or 4 digits (+hhmm)
636         if (end - mark == 2) {
637            ohour = parse(value, 2, mark, end, 0, 18);
638         } else if (end - mark == 4) {
639            // +hhmm format: parse hours from mark to mark+2, minutes from mark+2 to end
640            ohour = parse(value, 2, mark, mark + 2, 0, 18);
641            ominute = parse(value, 2, mark + 2, end, 0, 59);
642         } else {
643            throw bad(value, mark);
644         }
645      } else /* (state == S20) */ {
646         // S20: Found om, looking for om(S20).
647         ominute = parse(value, 2, mark, end, 0, 59);
648      }
649
650      // Build ZoneId if we have offset information
651      if (zoneId == null) {
652         if (ohour >= 0) {
653            if (ominute >= 0) {
654               // If negative offset, both hours and minutes must be negative
655               var offset = ZoneOffset.ofHoursMinutes(nego ? -ohour : ohour, nego ? -ominute : ominute);
656               zoneId = offset;
657            } else {
658               var offset = ZoneOffset.ofHours(nego ? -ohour : ohour);
659               zoneId = offset;
660            }
661         }
662      }
663
664      // Use provided default zone if no zone specified, otherwise use system default
665      if (zoneId == null) {
666         zoneId = defaultZoneId != null ? defaultZoneId : timeProvider.getSystemDefaultZoneId();
667      }
668
669      // Construct ZonedDateTime from parsed values
670      // Default values for missing components
671      // For time-only formats (started with "T"), use current date
672      // For date formats, default to 1/1/1
673      if (timeOnly) {
674         // Time-only format: use current year/month/day
675         var now = timeProvider.now(zoneId);
676         year = now.getYear();
677         month = now.getMonthValue();
678         day = now.getDayOfMonth();
679      }
680
681      var localDateTime = LocalDateTime.of(year, month, day, hour, minute, second);
682      if (nanos > 0) {
683         localDateTime = localDateTime.plusNanos(nanos);
684      }
685
686      var zdt = ZonedDateTime.of(localDateTime, zoneId);
687
688      // Return GranularZonedDateTime with the determined precision
689      return new GranularZonedDateTime(zdt, precision);
690   }
691
692   /**
693    * Creates a GranularZonedDateTime from a ZonedDateTime with the specified precision.
694    *
695    * <p>
696    * This is the most direct way to create a GranularZonedDateTime when you already have
697    * a ZonedDateTime and want to specify its precision.
698    *
699    * @param date The ZonedDateTime value.
700    * @param precision The precision of the time value.
701    * @return A new GranularZonedDateTime instance.
702    * @throws IllegalArgumentException if date or precision is null.
703    */
704   public static GranularZonedDateTime of(ZonedDateTime date, ChronoField precision) {
705      return new GranularZonedDateTime(date, precision);
706   }
707
708
709   private static DateTimeParseException bad(String s, int pos) {
710      return new DateTimeParseException("Invalid ISO8601 timestamp", s, pos);
711   }
712
713   private static int parse(String s, int chars, int pos, int end, int min, int max) {
714      if (end-pos != chars) throw bad(s, pos);
715      var i = Integer.parseInt(s, pos, end, 10);
716      if (i < min || i > max) throw bad(s, pos);
717      return i;
718   }
719
720   private static int parseNanos(String s, int pos, int end) {
721      var len = end - pos; // Length of the substring being parsed
722      if (len > 9) {
723         throw bad(s, pos);
724      }
725      var n = Integer.parseInt(s, pos, end, 10);
726      // Convert to nanoseconds based on number of digits
727      // 1 digit = hundreds of milliseconds, 2 = tens, 3 = milliseconds, etc.
728      if (len == 1) return n * 100000000;
729      if (len == 2) return n * 10000000;
730      if (len == 3) return n * 1000000;
731      if (len == 4) return n * 100000;
732      if (len == 5) return n * 10000;
733      if (len == 6) return n * 1000;
734      if (len == 7) return n * 100;
735      if (len == 8) return n * 10;
736      return n;
737   }
738
739   /**
740    * Converts a ChronoField to its corresponding ChronoUnit.
741    *
742    * <p>
743    * This method provides a mapping from date/time fields to time units.
744    * Not all ChronoField values have direct ChronoUnit equivalents.
745    *
746    * <p>
747    * Supported fields:
748    * <ul>
749    *    <li>{@link ChronoField#YEAR} → {@link ChronoUnit#YEARS}
750    *    <li>{@link ChronoField#MONTH_OF_YEAR} → {@link ChronoUnit#MONTHS}
751    *    <li>{@link ChronoField#DAY_OF_MONTH} → {@link ChronoUnit#DAYS}
752    *    <li>{@link ChronoField#HOUR_OF_DAY} → {@link ChronoUnit#HOURS}
753    *    <li>{@link ChronoField#MINUTE_OF_HOUR} → {@link ChronoUnit#MINUTES}
754    *    <li>{@link ChronoField#SECOND_OF_MINUTE} → {@link ChronoUnit#SECONDS}
755    *    <li>{@link ChronoField#MILLI_OF_SECOND} → {@link ChronoUnit#MILLIS}
756    *    <li>{@link ChronoField#NANO_OF_SECOND} → {@link ChronoUnit#NANOS}
757    * </ul>
758    *
759    * @param field The ChronoField to convert
760    * @return The corresponding ChronoUnit, or null if no direct mapping exists
761    */
762   private static ChronoUnit toChronoUnit(ChronoField field) {
763      return switch (field) {
764         case YEAR -> ChronoUnit.YEARS;
765         case MONTH_OF_YEAR -> ChronoUnit.MONTHS;
766         case DAY_OF_MONTH -> ChronoUnit.DAYS;
767         case HOUR_OF_DAY -> ChronoUnit.HOURS;
768         case MINUTE_OF_HOUR -> ChronoUnit.MINUTES;
769         case SECOND_OF_MINUTE -> ChronoUnit.SECONDS;
770         case MILLI_OF_SECOND -> ChronoUnit.MILLIS;
771         case NANO_OF_SECOND -> ChronoUnit.NANOS;
772         default -> null;
773      };
774   }
775
776   /** The ZonedDateTime value */
777   public final ZonedDateTime zdt;
778
779   /** The precision of this time value */
780   public final ChronoField precision;
781
782   /**
783    * Constructor.
784    *
785    * @param zdt The ZonedDateTime value.
786    * @param precision The precision of this time value.
787    */
788   public GranularZonedDateTime(ZonedDateTime zdt, ChronoField precision) {
789      this.zdt = zdt;
790      this.precision = precision;
791   }
792
793   /**
794    * Creates a copy of this object.
795    *
796    * @return A new GranularZonedDateTime with the same values.
797    */
798   public GranularZonedDateTime copy() {
799      return new GranularZonedDateTime(zdt, precision);
800   }
801
802   /**
803    * Returns the ZonedDateTime value.
804    *
805    * @return The ZonedDateTime value.
806    */
807   public ZonedDateTime getZonedDateTime() { return zdt; }
808
809   /**
810    * Returns the precision of this time value.
811    *
812    * <p>
813    * The precision indicates the finest granularity of the time value, which determines
814    * how the value was parsed or created. For example:
815    * <ul>
816    *    <li>{@link ChronoField#YEAR} - Year precision (e.g., "2011")
817    *    <li>{@link ChronoField#MONTH_OF_YEAR} - Month precision (e.g., "2011-01")
818    *    <li>{@link ChronoField#DAY_OF_MONTH} - Day precision (e.g., "2011-01-15")
819    *    <li>{@link ChronoField#HOUR_OF_DAY} - Hour precision (e.g., "2011-01-15T12")
820    *    <li>{@link ChronoField#MINUTE_OF_HOUR} - Minute precision (e.g., "2011-01-15T12:30")
821    *    <li>{@link ChronoField#SECOND_OF_MINUTE} - Second precision (e.g., "2011-01-15T12:30:45")
822    *    <li>{@link ChronoField#MILLI_OF_SECOND} - Millisecond precision (e.g., "2011-01-15T12:30:45.123")
823    *    <li>{@link ChronoField#NANO_OF_SECOND} - Nanosecond precision (e.g., "2011-01-15T12:30:45.123456789")
824    * </ul>
825    *
826    * @return The precision of this time value.
827    */
828   public ChronoField getPrecision() { return precision; }
829
830   @Override
831   public String toString() {
832      return zdt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + "(" + precision + ")";
833   }
834
835   /**
836    * Rolls this time value by the specified amount using the specified field.
837    *
838    * <p>
839    * This method creates a new GranularZonedDateTime by adding the specified amount to the
840    * specified field. The precision of the returned object remains the same as this object.
841    *
842    * <h5 class='section'>Supported Fields:</h5>
843    * <ul>
844    *    <li>{@link ChronoField#YEAR}
845    *    <li>{@link ChronoField#MONTH_OF_YEAR}
846    *    <li>{@link ChronoField#DAY_OF_MONTH}
847    *    <li>{@link ChronoField#HOUR_OF_DAY}
848    *    <li>{@link ChronoField#MINUTE_OF_HOUR}
849    *    <li>{@link ChronoField#SECOND_OF_MINUTE}
850    *    <li>{@link ChronoField#MILLI_OF_SECOND}
851    *    <li>{@link ChronoField#NANO_OF_SECOND}
852    * </ul>
853    *
854    * <h5 class='section'>Example:</h5>
855    * <p class='bjava'>
856    *    <jc>// Create a datetime with hour precision</jc>
857    *    GranularZonedDateTime <jv>gdt</jv> = GranularZonedDateTime.<jsm>of</jsm>(<js>"2011-01-15T12Z"</js>);
858    *    <jc>// Roll forward by 2 hours</jc>
859    *    <jv>gdt</jv> = <jv>gdt</jv>.<jsm>roll</jsm>(<jv>ChronoField</jv>.<jf>HOUR_OF_DAY</jf>, 2);
860    *    <jc>// Result: 2011-01-15T14:00:00Z (precision still HOUR_OF_DAY)</jc>
861    * </p>
862    *
863    * @param field The field to roll by. Must be one of the supported fields listed above.
864    * @param amount The amount to roll by. Positive values roll forward, negative values roll backward.
865    * @return A new GranularZonedDateTime with the rolled value.
866    * @throws IllegalArgumentException If the field is not supported.
867    */
868   public GranularZonedDateTime roll(ChronoField field, int amount) {
869      var unit = toChronoUnit(field);
870      assertArg(unit != null, "Unsupported roll field: {0}", field);
871      var newZdt = zdt.plus(amount, unit);
872      return new GranularZonedDateTime(newZdt, precision);
873   }
874
875   /**
876    * Rolls this time value by the specified amount using the current precision.
877    *
878    * <p>
879    * This is a convenience method that calls {@link #roll(ChronoField, int)} using this
880    * object's precision field.
881    *
882    * <h5 class='section'>Example:</h5>
883    * <p class='bjava'>
884    *    <jc>// Create a datetime with year precision</jc>
885    *    GranularZonedDateTime <jv>gdt</jv> = GranularZonedDateTime.<jsm>of</jsm>(<js>"2011"</js>);
886    *    <jc>// Roll forward by 1 (using YEAR precision)</jc>
887    *    <jv>gdt</jv> = <jv>gdt</jv>.<jsm>roll</jsm>(1);
888    *    <jc>// Result: 2012-01-01T00:00:00 (precision still YEAR)</jc>
889    * </p>
890    *
891    * @param amount The amount to roll by. Positive values roll forward, negative values roll backward.
892    * @return A new GranularZonedDateTime with the rolled value.
893    */
894   public GranularZonedDateTime roll(int amount) {
895      return roll(precision, amount);
896   }
897}