1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.core.metapath.function.library;
7   
8   import java.time.DayOfWeek;
9   import java.time.LocalDate;
10  import java.time.ZoneOffset;
11  import java.time.temporal.IsoFields;
12  import java.time.temporal.WeekFields;
13  import java.util.ArrayList;
14  import java.util.Collections;
15  import java.util.List;
16  import java.util.Locale;
17  import java.util.Set;
18  
19  import dev.metaschema.core.metapath.function.FormatDateTimeFunctionException;
20  import dev.metaschema.core.metapath.item.atomic.IIntegerItem;
21  import dev.metaschema.core.metapath.item.atomic.ITemporalItem;
22  import edu.umd.cs.findbugs.annotations.NonNull;
23  import edu.umd.cs.findbugs.annotations.Nullable;
24  
25  /**
26   * Utility class for parsing and formatting date/time picture strings as defined
27   * in <a href=
28   * "https://www.w3.org/TR/xpath-functions-31/#formatting-dates-and-times"> XPath
29   * Functions 3.1 Section 9.8</a>.
30   */
31  public final class DateTimeFormatUtil {
32    /**
33     * The set of valid component specifier characters recognized in picture string
34     * variable markers.
35     */
36    private static final Set<Character> VALID_SPECIFIERS = Set.of(
37        'Y', 'M', 'D', 'd', 'F', 'W', 'w', 'H', 'h', 'P', 'm', 's', 'f', 'Z',
38        'z', 'C', 'E');
39  
40    /**
41     * The set of valid second presentation modifier characters that may appear as
42     * the last character of a multi-character presentation modifier string.
43     */
44    private static final Set<Character> SECOND_MODIFIERS = Set.of('a', 't', 'c', 'o');
45  
46    private DateTimeFormatUtil() {
47      // utility class
48    }
49  
50    /**
51     * Base class for components of a parsed picture string.
52     */
53    public static class FormatComponent {
54      /**
55       * Protected constructor to prevent direct instantiation.
56       */
57      protected FormatComponent() {
58        // marker class
59      }
60    }
61  
62    /**
63     * A literal text component in a picture string.
64     */
65    public static class LiteralComponent
66        extends FormatComponent {
67      @NonNull
68      private final String text;
69  
70      /**
71       * Construct a new literal component.
72       *
73       * @param text
74       *          the literal text
75       */
76      public LiteralComponent(@NonNull String text) {
77        this.text = text;
78      }
79  
80      /**
81       * Get the literal text.
82       *
83       * @return the text
84       */
85      @NonNull
86      public String getText() {
87        return text;
88      }
89    }
90  
91    /**
92     * A variable marker component in a picture string, representing a date/time
93     * component to be formatted.
94     */
95    public static class VariableMarkerComponent
96        extends FormatComponent {
97      private final char specifier;
98      @Nullable
99      private final String primaryModifier;
100     @Nullable
101     private final Character secondModifier;
102     @Nullable
103     private final Integer minWidth;
104     @Nullable
105     private final Integer maxWidth;
106 
107     /**
108      * Construct a new variable marker component.
109      *
110      * @param specifier
111      *          the component specifier character
112      * @param primaryModifier
113      *          the first presentation modifier, or {@code null}
114      * @param secondModifier
115      *          the second presentation modifier, or {@code null}
116      * @param minWidth
117      *          the minimum width, or {@code null}
118      * @param maxWidth
119      *          the maximum width, or {@code null}
120      */
121     public VariableMarkerComponent(
122         char specifier,
123         @Nullable String primaryModifier,
124         @Nullable Character secondModifier,
125         @Nullable Integer minWidth,
126         @Nullable Integer maxWidth) {
127       this.specifier = specifier;
128       this.primaryModifier = primaryModifier;
129       this.secondModifier = secondModifier;
130       this.minWidth = minWidth;
131       this.maxWidth = maxWidth;
132     }
133 
134     /**
135      * Get the component specifier character.
136      *
137      * @return the specifier
138      */
139     public char getSpecifier() {
140       return specifier;
141     }
142 
143     /**
144      * Get the primary presentation modifier.
145      *
146      * @return the primary modifier, or {@code null} if not specified
147      */
148     @Nullable
149     public String getPrimaryModifier() {
150       return primaryModifier;
151     }
152 
153     /**
154      * Get the second presentation modifier.
155      *
156      * @return the second modifier character, or {@code null} if not specified
157      */
158     @Nullable
159     public Character getSecondModifier() {
160       return secondModifier;
161     }
162 
163     /**
164      * Get the minimum width.
165      *
166      * @return the minimum width, or {@code null} if not specified
167      */
168     @Nullable
169     public Integer getMinWidth() {
170       return minWidth;
171     }
172 
173     /**
174      * Get the maximum width.
175      *
176      * @return the maximum width, or {@code null} if not specified
177      */
178     @Nullable
179     public Integer getMaxWidth() {
180       return maxWidth;
181     }
182   }
183 
184   /**
185    * Parse a picture string into a list of format components.
186    * <p>
187    * The picture string consists of literal substrings and variable markers
188    * enclosed in square brackets. Doubled brackets {@code [[} and {@code ]]} are
189    * treated as escaped literal brackets.
190    *
191    * @param picture
192    *          the picture string to parse
193    * @return an unmodifiable list of format components
194    * @throws FormatDateTimeFunctionException
195    *           with {@link FormatDateTimeFunctionException#INVALID_PICTURE_STRING}
196    *           if the picture string syntax is invalid
197    * @see <a href=
198    *      "https://www.w3.org/TR/xpath-functions-31/#date-picture-string"> XPath
199    *      Functions 3.1 - Date Picture String</a>
200    */
201   @NonNull
202   public static List<FormatComponent> parsePictureString(@NonNull String picture) {
203     List<FormatComponent> components = new ArrayList<>();
204     StringBuilder literal = new StringBuilder();
205     int length = picture.length();
206     int index = 0;
207 
208     while (index < length) {
209       char ch = picture.charAt(index);
210 
211       if (ch == '[') {
212         // Check for escaped open bracket
213         if (index + 1 < length && picture.charAt(index + 1) == '[') {
214           literal.append('[');
215           index += 2;
216         } else {
217           // Flush any accumulated literal text
218           if (literal.length() > 0) {
219             components.add(new LiteralComponent(literal.toString()));
220             literal.setLength(0);
221           }
222 
223           // Find the closing bracket
224           int closeIndex = picture.indexOf(']', index + 1);
225           if (closeIndex < 0) {
226             throw new FormatDateTimeFunctionException(
227                 FormatDateTimeFunctionException.INVALID_PICTURE_STRING,
228                 "Unmatched '[' in picture string: " + picture);
229           }
230 
231           String markerContent = picture.substring(index + 1, closeIndex);
232           components.add(parseVariableMarker(markerContent, picture));
233           index = closeIndex + 1;
234         }
235       } else if (ch == ']') {
236         // Check for escaped close bracket
237         if (index + 1 < length && picture.charAt(index + 1) == ']') {
238           literal.append(']');
239           index += 2;
240         } else {
241           throw new FormatDateTimeFunctionException(
242               FormatDateTimeFunctionException.INVALID_PICTURE_STRING,
243               "Unmatched ']' in picture string: " + picture);
244         }
245       } else {
246         literal.append(ch);
247         index++;
248       }
249     }
250 
251     // Flush any remaining literal text
252     if (literal.length() > 0) {
253       components.add(new LiteralComponent(literal.toString()));
254     }
255 
256     return Collections.unmodifiableList(components);
257   }
258 
259   /**
260    * Parse the content of a variable marker (the text between {@code [} and
261    * {@code ]}) into a {@link VariableMarkerComponent}.
262    *
263    * @param content
264    *          the raw content between the brackets
265    * @param picture
266    *          the full picture string, used for error messages
267    * @return a new variable marker component
268    * @throws FormatDateTimeFunctionException
269    *           with {@link FormatDateTimeFunctionException#INVALID_PICTURE_STRING}
270    *           if the marker syntax is invalid
271    */
272   @NonNull
273   private static VariableMarkerComponent parseVariableMarker(
274       @NonNull String content,
275       @NonNull String picture) {
276     // Strip all whitespace
277     String stripped = content.replaceAll("\\s", "");
278 
279     if (stripped.isEmpty()) {
280       throw new FormatDateTimeFunctionException(
281           FormatDateTimeFunctionException.INVALID_PICTURE_STRING,
282           "Empty variable marker in picture string: " + picture);
283     }
284 
285     // First character is the component specifier
286     char specifier = stripped.charAt(0);
287     if (!VALID_SPECIFIERS.contains(specifier)) {
288       throw new FormatDateTimeFunctionException(
289           FormatDateTimeFunctionException.INVALID_PICTURE_STRING,
290           "Invalid component specifier '" + specifier
291               + "' in picture string: " + picture);
292     }
293 
294     // Remaining string contains presentation + width
295     String remaining = stripped.substring(1);
296 
297     // Find the LAST comma to split presentation from width
298     String presentationPart;
299     String widthPart;
300     int lastComma = remaining.lastIndexOf(',');
301     if (lastComma >= 0) {
302       presentationPart = remaining.substring(0, lastComma);
303       widthPart = remaining.substring(lastComma + 1);
304     } else {
305       presentationPart = remaining;
306       widthPart = null;
307     }
308 
309     // Parse presentation part
310     String primaryModifier = null;
311     Character secondModifier = null;
312 
313     if (!presentationPart.isEmpty()) {
314       if (presentationPart.length() == 1) {
315         // Single character is always the primary modifier
316         primaryModifier = presentationPart;
317       } else {
318         // More than one character: check if last char is a valid second modifier
319         char lastChar = presentationPart.charAt(presentationPart.length() - 1);
320         if (SECOND_MODIFIERS.contains(lastChar)) {
321           secondModifier = lastChar;
322           String primary = presentationPart.substring(0, presentationPart.length() - 1);
323           primaryModifier = primary.isEmpty() ? null : primary;
324         } else {
325           primaryModifier = presentationPart;
326         }
327       }
328     }
329 
330     // Parse width part
331     Integer minWidth = null;
332     Integer maxWidth = null;
333 
334     if (widthPart != null) {
335       Integer[] widths = new Integer[2];
336       parseWidth(widthPart, picture, widths);
337       minWidth = widths[0];
338       maxWidth = widths[1];
339     }
340 
341     return new VariableMarkerComponent(specifier, primaryModifier, secondModifier,
342         minWidth, maxWidth);
343   }
344 
345   /**
346    * Parse a width specification string of the form {@code min-max} or
347    * {@code min}, where either value may be {@code *} to indicate unbounded.
348    *
349    * @param widthPart
350    *          the width specification string
351    * @param picture
352    *          the full picture string, used for error messages
353    * @param result
354    *          a two-element array to receive the parsed minimum (index 0) and
355    *          maximum (index 1) width values; {@code null} indicates unbounded
356    * @throws FormatDateTimeFunctionException
357    *           with {@link FormatDateTimeFunctionException#INVALID_PICTURE_STRING}
358    *           if the width specification is invalid
359    */
360   private static void parseWidth(
361       @NonNull String widthPart,
362       @NonNull String picture,
363       @NonNull Integer[] result) {
364     int dashIndex = widthPart.indexOf('-');
365     String minStr;
366     String maxStr;
367 
368     if (dashIndex >= 0) {
369       minStr = widthPart.substring(0, dashIndex);
370       maxStr = widthPart.substring(dashIndex + 1);
371     } else {
372       minStr = widthPart;
373       maxStr = null;
374     }
375 
376     Integer minWidth = parseWidthValue(minStr, picture);
377     Integer maxWidth = maxStr != null ? parseWidthValue(maxStr, picture) : null;
378 
379     // Validate min-width >= 1
380     if (minWidth != null && minWidth < 1) {
381       throw new FormatDateTimeFunctionException(
382           FormatDateTimeFunctionException.INVALID_PICTURE_STRING,
383           "Minimum width must be at least 1 in picture string: " + picture);
384     }
385 
386     // Validate max-width >= min-width
387     if (minWidth != null && maxWidth != null && maxWidth < minWidth) {
388       throw new FormatDateTimeFunctionException(
389           FormatDateTimeFunctionException.INVALID_PICTURE_STRING,
390           "Maximum width must not be less than minimum width in picture string: "
391               + picture);
392     }
393 
394     result[0] = minWidth;
395     result[1] = maxWidth;
396   }
397 
398   /**
399    * Parse a single width value, which may be a positive integer or {@code *} for
400    * unbounded.
401    *
402    * @param value
403    *          the width value string
404    * @param picture
405    *          the full picture string, used for error messages
406    * @return the parsed integer value, or {@code null} if the value is {@code *}
407    * @throws FormatDateTimeFunctionException
408    *           with {@link FormatDateTimeFunctionException#INVALID_PICTURE_STRING}
409    *           if the value cannot be parsed
410    */
411   @Nullable
412   private static Integer parseWidthValue(@NonNull String value, @NonNull String picture) {
413     if ("*".equals(value)) {
414       return null;
415     }
416     try {
417       return Integer.parseInt(value);
418     } catch (NumberFormatException ex) {
419       throw new FormatDateTimeFunctionException(
420           FormatDateTimeFunctionException.INVALID_PICTURE_STRING,
421           "Invalid width value '" + value + "' in picture string: " + picture,
422           ex);
423     }
424   }
425 
426   // ====================================================================
427   // Formatting Engine
428   // ====================================================================
429 
430   /**
431    * English month names indexed from 0 (January) to 11 (December).
432    */
433   private static final String[] MONTH_NAMES = {
434       "January", "February", "March", "April", "May", "June",
435       "July", "August", "September", "October", "November", "December"
436   };
437 
438   /**
439    * English day-of-week names indexed from 0 (Monday) to 6 (Sunday), matching ISO
440    * 8601 numbering where Monday is day 1.
441    */
442   private static final String[] DAY_NAMES = {
443       "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
444   };
445 
446   /**
447    * Format a temporal value according to a picture string.
448    * <p>
449    * This method implements the formatting algorithm defined in
450    * <a href="https://www.w3.org/TR/xpath-functions-31/#date-picture-string">
451    * XPath Functions 3.1 Section 9.8</a>. The picture string is parsed into
452    * literal and variable marker components, and each variable marker is formatted
453    * according to its component specifier, presentation modifier, and width
454    * modifier.
455    *
456    * @param value
457    *          the temporal value to format
458    * @param picture
459    *          the picture string
460    * @param language
461    *          the language for names, or {@code null} for English
462    * @param calendar
463    *          the calendar system, or {@code null} for Gregorian
464    * @param place
465    *          the place for timezone, or {@code null}
466    * @param allowedMarkers
467    *          the set of allowed component specifiers
468    * @return the formatted string
469    * @throws FormatDateTimeFunctionException
470    *           if formatting fails
471    */
472   @NonNull
473   public static String formatDateTime(
474       @NonNull ITemporalItem value,
475       @NonNull String picture,
476       @Nullable String language,
477       @Nullable String calendar,
478       @Nullable String place,
479       @NonNull Set<Character> allowedMarkers) {
480     List<FormatComponent> components = parsePictureString(picture);
481     StringBuilder result = new StringBuilder();
482 
483     for (FormatComponent component : components) {
484       if (component instanceof LiteralComponent) {
485         result.append(((LiteralComponent) component).getText());
486       } else {
487         VariableMarkerComponent marker = (VariableMarkerComponent) component;
488         char specifier = marker.getSpecifier();
489 
490         if (!allowedMarkers.contains(specifier)) {
491           throw new FormatDateTimeFunctionException(
492               FormatDateTimeFunctionException.COMPONENT_NOT_AVAILABLE,
493               "Component specifier '" + specifier
494                   + "' is not available for this type in picture string: " + picture);
495         }
496 
497         result.append(formatComponent(value, marker, language));
498       }
499     }
500 
501     return result.toString();
502   }
503 
504   /**
505    * Format a single variable marker component.
506    *
507    * @param value
508    *          the temporal value
509    * @param marker
510    *          the variable marker component
511    * @param language
512    *          the language for locale-dependent formatting, or {@code null}
513    * @return the formatted string for this component
514    */
515   @NonNull
516   private static String formatComponent(
517       @NonNull ITemporalItem value,
518       @NonNull VariableMarkerComponent marker,
519       @Nullable String language) {
520     char specifier = marker.getSpecifier();
521     String primaryMod = marker.getPrimaryModifier();
522     Character secondMod = marker.getSecondModifier();
523     Integer minWidth = marker.getMinWidth();
524     Integer maxWidth = marker.getMaxWidth();
525 
526     switch (specifier) {
527     case 'Y':
528       return formatYear(value, primaryMod, secondMod, minWidth, maxWidth, language);
529     case 'M':
530       return formatNameableComponent(value.getMonth(), MONTH_NAMES, 1,
531           primaryMod, secondMod, minWidth, maxWidth, language, "1");
532     case 'D':
533       return formatIntegerComponent(value.getDay(),
534           primaryMod, secondMod, minWidth, maxWidth, language, "1");
535     case 'd':
536       return formatDayOfYear(value, primaryMod, secondMod, minWidth, maxWidth, language);
537     case 'F':
538       return formatDayOfWeek(value, primaryMod, secondMod, minWidth, maxWidth, language);
539     case 'W':
540       return formatWeekOfYear(value, primaryMod, secondMod, minWidth, maxWidth, language);
541     case 'w':
542       return formatWeekOfMonth(value, primaryMod, secondMod, minWidth, maxWidth, language);
543     case 'H':
544       return formatIntegerComponent(value.getHour(),
545           primaryMod, secondMod, minWidth, maxWidth, language, "1");
546     case 'h':
547       return formatIntegerComponent(hourIn12(value.getHour()),
548           primaryMod, secondMod, minWidth, maxWidth, language, "1");
549     case 'P':
550       return formatAmPm(value.getHour(), primaryMod, secondMod, minWidth, maxWidth);
551     case 'm':
552       return formatIntegerComponent(value.getMinute(),
553           primaryMod, secondMod, minWidth, maxWidth, language, "01");
554     case 's':
555       return formatIntegerComponent(value.getSecond(),
556           primaryMod, secondMod, minWidth, maxWidth, language, "01");
557     case 'f':
558       return formatFractionalSeconds(value.getNano(), primaryMod, minWidth, maxWidth);
559     case 'Z':
560       return formatTimezone(value, primaryMod, secondMod);
561     case 'z':
562       return formatGmtTimezone(value, primaryMod, secondMod);
563     case 'C':
564       return formatCalendar(primaryMod, minWidth, maxWidth);
565     case 'E':
566       return formatEra(value.getYear(), primaryMod, minWidth, maxWidth);
567     default:
568       // Should not happen since VALID_SPECIFIERS already checked
569       return "";
570     }
571   }
572 
573   /**
574    * Convert a 24-hour hour value to 12-hour format.
575    *
576    * @param hour24
577    *          the hour in 24-hour format (0-23)
578    * @return the hour in 12-hour format (1-12)
579    */
580   private static int hourIn12(int hour24) {
581     int h = hour24 % 12;
582     return h == 0 ? 12 : h;
583   }
584 
585   /**
586    * Format a year value with special modulo handling per spec 9.8.4.4.
587    *
588    * @param value
589    *          the temporal value
590    * @param primaryMod
591    *          the primary presentation modifier, or {@code null}
592    * @param secondMod
593    *          the second presentation modifier, or {@code null}
594    * @param minWidth
595    *          the minimum width, or {@code null}
596    * @param maxWidth
597    *          the maximum width, or {@code null}
598    * @param language
599    *          the language for formatting, or {@code null}
600    * @return the formatted year string
601    */
602   @NonNull
603   private static String formatYear(
604       @NonNull ITemporalItem value,
605       @Nullable String primaryMod,
606       @Nullable Character secondMod,
607       @Nullable Integer minWidth,
608       @Nullable Integer maxWidth,
609       @Nullable String language) {
610     // Use long arithmetic and clamp to avoid overflow for Integer.MIN_VALUE
611     int year = (int) Math.min(Math.abs((long) value.getYear()), Integer.MAX_VALUE);
612 
613     // Determine the effective format token
614     String effectiveToken = primaryMod != null ? primaryMod : "1";
615 
616     // Spec 9.8.4.4: Determine N for modulo rule
617     // If maxWidth defines a finite value -> N = maxWidth
618     // Else if format token is a decimal digit pattern with W>=2 mandatory digits ->
619     // N = W
620     // Else N = infinity (output full year)
621     int moduloN = Integer.MAX_VALUE;
622 
623     if (maxWidth != null) {
624       moduloN = maxWidth;
625     } else {
626       int mandatoryDigits = countMandatoryDigits(effectiveToken);
627       if (mandatoryDigits >= 2 && isDecimalDigitPattern(effectiveToken)) {
628         moduloN = mandatoryDigits;
629       }
630     }
631 
632     // Apply modulo if N is finite
633     int displayYear = year;
634     if (moduloN < Integer.MAX_VALUE) {
635       int divisor = (int) Math.pow(10, moduloN);
636       displayYear = year % divisor;
637     }
638 
639     // Format the value
640     String formatted = formatIntegerValue(displayYear, effectiveToken, secondMod, language);
641 
642     // Apply width modifiers
643     formatted = applyWidthModifiers(formatted, minWidth, maxWidth, false);
644 
645     // Prepend minus for negative years
646     if (value.getYear() < 0) {
647       formatted = "-" + formatted;
648     }
649 
650     return formatted;
651   }
652 
653   /**
654    * Format a component that can be displayed either as a number or as a name
655    * (e.g., months, days of week).
656    *
657    * @param componentValue
658    *          the numeric value of the component
659    * @param names
660    *          the array of names (0-indexed offset from {@code nameOffset})
661    * @param nameOffset
662    *          the offset subtracted from {@code componentValue} to get the name
663    *          array index
664    * @param primaryMod
665    *          the primary presentation modifier, or {@code null}
666    * @param secondMod
667    *          the second presentation modifier, or {@code null}
668    * @param minWidth
669    *          the minimum width, or {@code null}
670    * @param maxWidth
671    *          the maximum width, or {@code null}
672    * @param language
673    *          the language for formatting, or {@code null}
674    * @param defaultToken
675    *          the default format token when no primary modifier is specified
676    * @return the formatted string
677    */
678   @NonNull
679   private static String formatNameableComponent(
680       int componentValue,
681       @NonNull String[] names,
682       int nameOffset,
683       @Nullable String primaryMod,
684       @Nullable Character secondMod,
685       @Nullable Integer minWidth,
686       @Nullable Integer maxWidth,
687       @Nullable String language,
688       @NonNull String defaultToken) {
689     String effective = primaryMod != null ? primaryMod : defaultToken;
690 
691     // Check if this is a name format
692     if (isNameFormat(effective)) {
693       String name = names[componentValue - nameOffset];
694       name = applyNameCase(name, effective);
695       return applyWidthModifiers(name, minWidth, maxWidth, true);
696     }
697 
698     return formatIntegerComponent(componentValue, primaryMod, secondMod,
699         minWidth, maxWidth, language, defaultToken);
700   }
701 
702   /**
703    * Format a simple integer-valued component.
704    *
705    * @param componentValue
706    *          the integer value
707    * @param primaryMod
708    *          the primary presentation modifier, or {@code null}
709    * @param secondMod
710    *          the second presentation modifier, or {@code null}
711    * @param minWidth
712    *          the minimum width, or {@code null}
713    * @param maxWidth
714    *          the maximum width, or {@code null}
715    * @param language
716    *          the language for formatting, or {@code null}
717    * @param defaultToken
718    *          the default format token
719    * @return the formatted string
720    */
721   @NonNull
722   private static String formatIntegerComponent(
723       int componentValue,
724       @Nullable String primaryMod,
725       @Nullable Character secondMod,
726       @Nullable Integer minWidth,
727       @Nullable Integer maxWidth,
728       @Nullable String language,
729       @NonNull String defaultToken) {
730     String effectiveToken = primaryMod != null ? primaryMod : defaultToken;
731 
732     String formatted = formatIntegerValue(componentValue, effectiveToken, secondMod, language);
733     return applyWidthModifiers(formatted, minWidth, maxWidth, false);
734   }
735 
736   /**
737    * Format an integer using {@link FnFormatInteger#fnFormatInteger}, delegating
738    * numeric, alphabetic, roman, and word formatting to the XPath format-integer
739    * implementation.
740    *
741    * @param componentValue
742    *          the integer value to format
743    * @param formatToken
744    *          the primary format token (e.g., "1", "01", "i", "w")
745    * @param secondMod
746    *          the second modifier character (e.g., 'o' for ordinal), or
747    *          {@code null}
748    * @param language
749    *          the language for locale-dependent formatting, or {@code null}
750    * @return the formatted string
751    */
752   @NonNull
753   private static String formatIntegerValue(
754       int componentValue,
755       @NonNull String formatToken,
756       @Nullable Character secondMod,
757       @Nullable String language) {
758     // Build the format-integer picture
759     String picture = formatToken;
760     if (secondMod != null && secondMod == 'o') {
761       picture = picture + ";o";
762     }
763 
764     return FnFormatInteger.fnFormatInteger(
765         IIntegerItem.valueOf(componentValue),
766         picture,
767         language);
768   }
769 
770   /**
771    * Format the day-of-year component.
772    *
773    * @param value
774    *          the temporal value
775    * @param primaryMod
776    *          the primary modifier, or {@code null}
777    * @param secondMod
778    *          the second modifier, or {@code null}
779    * @param minWidth
780    *          the minimum width, or {@code null}
781    * @param maxWidth
782    *          the maximum width, or {@code null}
783    * @param language
784    *          the language, or {@code null}
785    * @return the formatted day-of-year string
786    */
787   @NonNull
788   private static String formatDayOfYear(
789       @NonNull ITemporalItem value,
790       @Nullable String primaryMod,
791       @Nullable Character secondMod,
792       @Nullable Integer minWidth,
793       @Nullable Integer maxWidth,
794       @Nullable String language) {
795     // Use proxy year >= 1 because LocalDate.of does not support year <= 0
796     int proxyYear = Math.max(1, value.getYear());
797     int dayOfYear = LocalDate.of(proxyYear, value.getMonth(), value.getDay()).getDayOfYear();
798     return formatIntegerComponent(dayOfYear, primaryMod, secondMod, minWidth, maxWidth, language, "1");
799   }
800 
801   /**
802    * Format the day-of-week component. The default presentation modifier for F is
803    * "n" (lowercase name).
804    *
805    * @param value
806    *          the temporal value
807    * @param primaryMod
808    *          the primary modifier, or {@code null}
809    * @param secondMod
810    *          the second modifier, or {@code null}
811    * @param minWidth
812    *          the minimum width, or {@code null}
813    * @param maxWidth
814    *          the maximum width, or {@code null}
815    * @param language
816    *          the language, or {@code null}
817    * @return the formatted day-of-week string
818    */
819   @NonNull
820   private static String formatDayOfWeek(
821       @NonNull ITemporalItem value,
822       @Nullable String primaryMod,
823       @Nullable Character secondMod,
824       @Nullable Integer minWidth,
825       @Nullable Integer maxWidth,
826       @Nullable String language) {
827     // Use proxy year >= 1 because LocalDate.of does not support year <= 0
828     int proxyYear = Math.max(1, value.getYear());
829     DayOfWeek dow = LocalDate.of(proxyYear, value.getMonth(), value.getDay()).getDayOfWeek();
830     int isoValue = dow.getValue(); // Mon=1..Sun=7
831 
832     // Default for F is "n" (lowercase name)
833     String effective = primaryMod != null ? primaryMod : "n";
834 
835     if (isNameFormat(effective)) {
836       String name = DAY_NAMES[isoValue - 1];
837       name = applyNameCase(name, effective);
838       return applyWidthModifiers(name, minWidth, maxWidth, true);
839     }
840 
841     return formatIntegerComponent(isoValue, primaryMod, secondMod,
842         minWidth, maxWidth, language, "n");
843   }
844 
845   /**
846    * Format the ISO week-of-year component.
847    *
848    * @param value
849    *          the temporal value
850    * @param primaryMod
851    *          the primary modifier, or {@code null}
852    * @param secondMod
853    *          the second modifier, or {@code null}
854    * @param minWidth
855    *          the minimum width, or {@code null}
856    * @param maxWidth
857    *          the maximum width, or {@code null}
858    * @param language
859    *          the language, or {@code null}
860    * @return the formatted week-of-year string
861    */
862   @NonNull
863   private static String formatWeekOfYear(
864       @NonNull ITemporalItem value,
865       @Nullable String primaryMod,
866       @Nullable Character secondMod,
867       @Nullable Integer minWidth,
868       @Nullable Integer maxWidth,
869       @Nullable String language) {
870     // Use proxy year >= 1 because LocalDate.of does not support year <= 0
871     int proxyYear = Math.max(1, value.getYear());
872     int week = LocalDate.of(proxyYear, value.getMonth(), value.getDay())
873         .get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
874     return formatIntegerComponent(week, primaryMod, secondMod, minWidth, maxWidth, language, "1");
875   }
876 
877   /**
878    * Format the week-of-month component.
879    *
880    * @param value
881    *          the temporal value
882    * @param primaryMod
883    *          the primary modifier, or {@code null}
884    * @param secondMod
885    *          the second modifier, or {@code null}
886    * @param minWidth
887    *          the minimum width, or {@code null}
888    * @param maxWidth
889    *          the maximum width, or {@code null}
890    * @param language
891    *          the language, or {@code null}
892    * @return the formatted week-of-month string
893    */
894   @NonNull
895   private static String formatWeekOfMonth(
896       @NonNull ITemporalItem value,
897       @Nullable String primaryMod,
898       @Nullable Character secondMod,
899       @Nullable Integer minWidth,
900       @Nullable Integer maxWidth,
901       @Nullable String language) {
902     // Use proxy year >= 1 because LocalDate.of does not support year <= 0
903     int proxyYear = Math.max(1, value.getYear());
904     int week = LocalDate.of(proxyYear, value.getMonth(), value.getDay())
905         .get(WeekFields.ISO.weekOfMonth());
906     return formatIntegerComponent(week, primaryMod, secondMod, minWidth, maxWidth, language, "1");
907   }
908 
909   /**
910    * Format the AM/PM marker. The default presentation is "n" (lowercase name).
911    *
912    * @param hour
913    *          the hour value (0-23)
914    * @param primaryMod
915    *          the primary modifier, or {@code null}
916    * @param secondMod
917    *          the second modifier, or {@code null}
918    * @param minWidth
919    *          the minimum width, or {@code null}
920    * @param maxWidth
921    *          the maximum width, or {@code null}
922    * @return the formatted AM/PM string
923    */
924   @NonNull
925   private static String formatAmPm(
926       int hour,
927       @Nullable String primaryMod,
928       @Nullable Character secondMod,
929       @Nullable Integer minWidth,
930       @Nullable Integer maxWidth) {
931     String effective = primaryMod != null ? primaryMod : "n";
932     String base = hour < 12 ? "am" : "pm";
933 
934     String result;
935     if ("N".equals(effective)) {
936       result = base.toUpperCase(Locale.ROOT);
937     } else if ("Nn".equals(effective)) {
938       result = Character.toUpperCase(base.charAt(0)) + base.substring(1);
939     } else {
940       // default: lowercase
941       result = base;
942     }
943 
944     return applyWidthModifiers(result, minWidth, maxWidth, true);
945   }
946 
947   /**
948    * Format fractional seconds per spec 9.8.4.5.
949    * <p>
950    * The fractional seconds use a "reverse digit" algorithm: the nano value is
951    * converted to a 9-digit string, and the format token determines how many
952    * digits to output. A single-digit pattern with no constraints outputs all
953    * significant (non-trailing-zero) digits.
954    *
955    * @param nano
956    *          the nanosecond value (0-999999999)
957    * @param primaryModifier
958    *          the primary modifier, or {@code null}
959    * @param minWidth
960    *          the minimum width, or {@code null}
961    * @param maxWidth
962    *          the maximum width, or {@code null}
963    * @return the formatted fractional seconds string
964    */
965   @NonNull
966   private static String formatFractionalSeconds(
967       int nano,
968       @Nullable String primaryModifier,
969       @Nullable Integer minWidth,
970       @Nullable Integer maxWidth) {
971     // Convert nano to 9-digit string
972     String nanoStr = String.format("%09d", nano);
973     String effective = primaryModifier != null ? primaryModifier : "1";
974     int mandatoryDigits = countMandatoryDigits(effective);
975 
976     String result;
977     if (mandatoryDigits <= 1 && effective.length() <= 1) {
978       // Single digit pattern = no constraint, use all significant digits
979       result = nanoStr.replaceAll("0+$", "");
980       if (result.isEmpty()) {
981         result = "0";
982       }
983 
984       // Apply width constraints
985       if (maxWidth != null && result.length() > maxWidth) {
986         result = result.substring(0, maxWidth);
987       }
988       if (minWidth != null && result.length() < minWidth) {
989         result = result + "0".repeat(minWidth - result.length());
990       }
991     } else {
992       // Multiple mandatory digits = exact digit count
993       int numDigits = mandatoryDigits;
994       if (minWidth != null && minWidth > numDigits) {
995         numDigits = minWidth;
996       }
997       if (maxWidth != null && maxWidth < numDigits) {
998         numDigits = maxWidth;
999       }
1000       result = nanoStr.substring(0, Math.min(numDigits, 9));
1001     }
1002 
1003     return result;
1004   }
1005 
1006   /**
1007    * Format a timezone offset using the Z specifier per spec 9.8.4.6.
1008    *
1009    * @param value
1010    *          the temporal value
1011    * @param primaryMod
1012    *          the primary modifier, or {@code null}
1013    * @param secondMod
1014    *          the second modifier, or {@code null}
1015    * @return the formatted timezone string
1016    */
1017   @NonNull
1018   private static String formatTimezone(
1019       @NonNull ITemporalItem value,
1020       @Nullable String primaryMod,
1021       @Nullable Character secondMod) {
1022     ZoneOffset offset = value.getZoneOffset();
1023 
1024     // Military timezone format
1025     if (primaryMod != null && "Z".equals(primaryMod)) {
1026       return formatMilitaryTimezone(offset);
1027     }
1028 
1029     if (offset == null) {
1030       return "";
1031     }
1032 
1033     // Check for 't' modifier: UTC -> "Z"
1034     boolean useZ = secondMod != null && secondMod == 't';
1035     if (useZ && offset.getTotalSeconds() == 0) {
1036       return "Z";
1037     }
1038 
1039     String effective = primaryMod != null ? primaryMod : "01:01";
1040     return formatTimezoneNumeric(offset, effective);
1041   }
1042 
1043   /**
1044    * Format a military timezone letter.
1045    *
1046    * @param offset
1047    *          the zone offset, or {@code null} for local time
1048    * @return the military timezone letter
1049    */
1050   @NonNull
1051   private static String formatMilitaryTimezone(@Nullable ZoneOffset offset) {
1052     if (offset == null) {
1053       return "J"; // local time
1054     }
1055 
1056     int totalSeconds = offset.getTotalSeconds();
1057     int totalMinutes = totalSeconds / 60;
1058     int hours = totalMinutes / 60;
1059     int minutes = totalMinutes % 60;
1060 
1061     if (totalSeconds == 0) {
1062       return "Z"; // UTC
1063     }
1064 
1065     // Military letters only for whole-hour offsets -12..+12, excluding 0
1066     if (minutes == 0 && hours >= -12 && hours <= 12) {
1067       if (hours > 0) {
1068         // A=+1, B=+2, ..., I=+9, K=+10, L=+11, M=+12 (skip J at +10 position)
1069         if (hours <= 9) {
1070           return String.valueOf((char) ('A' + hours - 1));
1071         }
1072         // hours 10,11,12: skip J so K=10, L=11, M=12
1073         return String.valueOf((char) ('A' + hours)); // +10->K, +11->L, +12->M
1074       }
1075       // Negative: N=-1, O=-2, ..., Y=-12
1076       return String.valueOf((char) ('N' + (-hours) - 1));
1077     }
1078 
1079     // Non-whole-hour offsets: fallback to numeric
1080     return formatTimezoneNumeric(offset, "01:01");
1081   }
1082 
1083   /**
1084    * Format a numeric timezone offset according to the specified pattern.
1085    * <p>
1086    * The pattern determines the format:
1087    * <ul>
1088    * <li>{@code 0} or {@code 1} - hours only (no leading zero), minutes if
1089    * non-zero</li>
1090    * <li>{@code 00} or {@code 01} - hours with leading zero, minutes if
1091    * non-zero</li>
1092    * <li>{@code 0:00} or {@code 1:01} - hours without leading zero, always show
1093    * minutes with separator</li>
1094    * <li>{@code 00:00} or {@code 01:01} - hours with leading zero, always show
1095    * minutes with separator</li>
1096    * <li>{@code 0000} or {@code 0001} - concatenated hours+minutes, leading zero
1097    * on hours</li>
1098    * <li>{@code 000} or {@code 001} - concatenated hours+minutes, no leading zero
1099    * on hours</li>
1100    * </ul>
1101    *
1102    * @param offset
1103    *          the zone offset
1104    * @param pattern
1105    *          the format pattern
1106    * @return the formatted timezone string
1107    */
1108   @NonNull
1109   private static String formatTimezoneNumeric(
1110       @NonNull ZoneOffset offset,
1111       @NonNull String pattern) {
1112     int totalSeconds = offset.getTotalSeconds();
1113     String sign = totalSeconds >= 0 ? "+" : "-";
1114     int absSeconds = Math.abs(totalSeconds);
1115     int hours = absSeconds / 3600;
1116     int minutes = (absSeconds % 3600) / 60;
1117 
1118     // Determine format from pattern
1119     boolean hasSeparator = pattern.contains(":") || pattern.contains(".");
1120     char separator = pattern.contains(":") ? ':' : '.';
1121     String digitsPart = pattern.replace(":", "").replace(".", "");
1122     int digitCount = digitsPart.length();
1123 
1124     boolean padHours;
1125     boolean alwaysShowMinutes;
1126 
1127     if (hasSeparator) {
1128       // Pattern with separator (e.g., "01:01", "0:00")
1129       int sepIndex = pattern.indexOf(separator);
1130       padHours = sepIndex >= 2;
1131       alwaysShowMinutes = true;
1132     } else if (digitCount >= 3) {
1133       // Concatenated format (e.g., "0000", "000")
1134       padHours = digitCount >= 4;
1135       alwaysShowMinutes = true;
1136       // No separator in output
1137     } else {
1138       // Hours only (e.g., "0", "00", "01")
1139       padHours = digitCount >= 2;
1140       alwaysShowMinutes = false;
1141     }
1142 
1143     String hoursStr = padHours
1144         ? String.format("%02d", hours)
1145         : String.valueOf(hours);
1146 
1147     if (alwaysShowMinutes) {
1148       String minutesStr = String.format("%02d", minutes);
1149       if (hasSeparator) {
1150         return sign + hoursStr + separator + minutesStr;
1151       }
1152       return sign + hoursStr + minutesStr;
1153     }
1154 
1155     // Hours only, minutes if non-zero
1156     if (minutes != 0) {
1157       String minutesStr = String.format("%02d", minutes);
1158       return sign + hoursStr + ":" + minutesStr;
1159     }
1160 
1161     return sign + hoursStr;
1162   }
1163 
1164   /**
1165    * Format a timezone with GMT prefix (z specifier).
1166    *
1167    * @param value
1168    *          the temporal value
1169    * @param primaryMod
1170    *          the primary modifier, or {@code null}
1171    * @param secondMod
1172    *          the second modifier, or {@code null}
1173    * @return the formatted GMT timezone string
1174    */
1175   @NonNull
1176   private static String formatGmtTimezone(
1177       @NonNull ITemporalItem value,
1178       @Nullable String primaryMod,
1179       @Nullable Character secondMod) {
1180     ZoneOffset offset = value.getZoneOffset();
1181     if (offset == null) {
1182       return "";
1183     }
1184 
1185     String tzPart = formatTimezoneNumeric(offset, primaryMod != null ? primaryMod : "01:01");
1186     return "GMT" + tzPart;
1187   }
1188 
1189   /**
1190    * Format the calendar name. Always returns "ad" for the Gregorian calendar.
1191    *
1192    * @param primaryMod
1193    *          the primary modifier, or {@code null}
1194    * @param minWidth
1195    *          the minimum width, or {@code null}
1196    * @param maxWidth
1197    *          the maximum width, or {@code null}
1198    * @return the formatted calendar string
1199    */
1200   @NonNull
1201   private static String formatCalendar(
1202       @Nullable String primaryMod,
1203       @Nullable Integer minWidth,
1204       @Nullable Integer maxWidth) {
1205     String effective = primaryMod != null ? primaryMod : "n";
1206     String result = applyNameCase("ad", effective);
1207     return applyWidthModifiers(result, minWidth, maxWidth, true);
1208   }
1209 
1210   /**
1211    * Format the era indicator. Returns "ad" for non-negative years and "bc" for
1212    * negative years.
1213    *
1214    * @param year
1215    *          the year value
1216    * @param primaryMod
1217    *          the primary modifier, or {@code null}
1218    * @param minWidth
1219    *          the minimum width, or {@code null}
1220    * @param maxWidth
1221    *          the maximum width, or {@code null}
1222    * @return the formatted era string
1223    */
1224   @NonNull
1225   private static String formatEra(
1226       int year,
1227       @Nullable String primaryMod,
1228       @Nullable Integer minWidth,
1229       @Nullable Integer maxWidth) {
1230     String effective = primaryMod != null ? primaryMod : "n";
1231     String base = year >= 0 ? "ad" : "bc";
1232     String result = applyNameCase(base, effective);
1233     return applyWidthModifiers(result, minWidth, maxWidth, true);
1234   }
1235 
1236   // ====================================================================
1237   // Helper methods
1238   // ====================================================================
1239 
1240   /**
1241    * Check if a format modifier represents a name format (N, Nn, or n).
1242    *
1243    * @param modifier
1244    *          the modifier string
1245    * @return {@code true} if the modifier requests name formatting
1246    */
1247   private static boolean isNameFormat(@NonNull String modifier) {
1248     return "N".equals(modifier) || "Nn".equals(modifier) || "n".equals(modifier);
1249   }
1250 
1251   /**
1252    * Apply case transformation to a name string based on the modifier.
1253    *
1254    * @param name
1255    *          the name string in its base form
1256    * @param modifier
1257    *          the modifier controlling case: "N" for uppercase, "n" for lowercase,
1258    *          "Nn" for title case
1259    * @return the name with case applied
1260    */
1261   @NonNull
1262   private static String applyNameCase(@NonNull String name, @NonNull String modifier) {
1263     switch (modifier) {
1264     case "N":
1265       return name.toUpperCase(Locale.ROOT);
1266     case "n":
1267       return name.toLowerCase(Locale.ROOT);
1268     case "Nn":
1269       if (name.isEmpty()) {
1270         return name;
1271       }
1272       return Character.toUpperCase(name.charAt(0))
1273           + name.substring(1).toLowerCase(Locale.ROOT);
1274     default:
1275       return name;
1276     }
1277   }
1278 
1279   /**
1280    * Apply width modifiers to a formatted string, performing padding and
1281    * truncation as needed.
1282    *
1283    * @param value
1284    *          the formatted string
1285    * @param minWidth
1286    *          the minimum width, or {@code null} for no minimum
1287    * @param maxWidth
1288    *          the maximum width, or {@code null} for no maximum
1289    * @param isName
1290    *          {@code true} if the value is a name (pad with spaces on the right),
1291    *          {@code false} if numeric (pad with zeros on the left)
1292    * @return the string adjusted to fit width constraints
1293    */
1294   @NonNull
1295   private static String applyWidthModifiers(
1296       @NonNull String value,
1297       @Nullable Integer minWidth,
1298       @Nullable Integer maxWidth,
1299       boolean isName) {
1300     String result = value;
1301 
1302     // Truncation
1303     if (maxWidth != null && result.length() > maxWidth) {
1304       result = result.substring(0, maxWidth);
1305     }
1306 
1307     // Padding
1308     if (minWidth != null && result.length() < minWidth) {
1309       int padAmount = minWidth - result.length();
1310       if (isName) {
1311         // Pad names with trailing spaces
1312         result = result + " ".repeat(padAmount);
1313       } else {
1314         // Pad numbers with leading zeros
1315         result = "0".repeat(padAmount) + result;
1316       }
1317     }
1318 
1319     return result;
1320   }
1321 
1322   /**
1323    * Count the number of mandatory (digit) characters in a format token.
1324    *
1325    * @param pattern
1326    *          the format token
1327    * @return the count of digit characters
1328    */
1329   private static int countMandatoryDigits(@NonNull String pattern) {
1330     int count = 0;
1331     for (int i = 0; i < pattern.length(); i++) {
1332       if (Character.isDigit(pattern.charAt(i))) {
1333         count++;
1334       }
1335     }
1336     return count;
1337   }
1338 
1339   /**
1340    * Check if a format token is a decimal digit pattern (contains only decimal
1341    * digits and optional grouping separators).
1342    *
1343    * @param token
1344    *          the format token to check
1345    * @return {@code true} if the token is a decimal digit pattern
1346    */
1347   private static boolean isDecimalDigitPattern(@NonNull String token) {
1348     if (token.isEmpty()) {
1349       return false;
1350     }
1351     for (int i = 0; i < token.length(); i++) {
1352       char ch = token.charAt(i);
1353       if (!Character.isDigit(ch) && ch != '#' && ch != ',' && ch != '.' && ch != ';') {
1354         return false;
1355       }
1356     }
1357     return true;
1358   }
1359 }