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