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.math.BigInteger;
9   import java.util.ArrayList;
10  import java.util.List;
11  import java.util.Locale;
12  import java.util.regex.Matcher;
13  import java.util.regex.Pattern;
14  
15  import dev.metaschema.core.metapath.DynamicContext;
16  import dev.metaschema.core.metapath.MetapathConstants;
17  import dev.metaschema.core.metapath.function.FormatFunctionException;
18  import dev.metaschema.core.metapath.function.FunctionUtils;
19  import dev.metaschema.core.metapath.function.IArgument;
20  import dev.metaschema.core.metapath.function.IFunction;
21  import dev.metaschema.core.metapath.item.IItem;
22  import dev.metaschema.core.metapath.item.ISequence;
23  import dev.metaschema.core.metapath.item.atomic.IIntegerItem;
24  import dev.metaschema.core.metapath.item.atomic.IStringItem;
25  import dev.metaschema.core.util.ObjectUtils;
26  import edu.umd.cs.findbugs.annotations.NonNull;
27  import edu.umd.cs.findbugs.annotations.Nullable;
28  
29  /**
30   * Implements the XPath 3.1 <a href=
31   * "https://www.w3.org/TR/xpath-functions-31/#func-format-integer">fn:format-integer</a>
32   * functions.
33   *
34   * @see <a href=
35   *      "https://www.w3.org/TR/xpath-functions-31/#func-format-integer">XPath
36   *      3.1 fn:format-integer</a>
37   */
38  public final class FnFormatInteger {
39    private static final String NAME = "format-integer";
40  
41    @NonNull
42    static final IFunction SIGNATURE_TWO_ARG = IFunction.builder()
43        .name(NAME)
44        .namespace(MetapathConstants.NS_METAPATH_FUNCTIONS)
45        .deterministic()
46        .contextDependent()
47        .focusIndependent()
48        .argument(IArgument.builder()
49            .name("value")
50            .type(IIntegerItem.type())
51            .zeroOrOne()
52            .build())
53        .argument(IArgument.builder()
54            .name("picture")
55            .type(IStringItem.type())
56            .one()
57            .build())
58        .returnType(IStringItem.type())
59        .returnOne()
60        .functionHandler(FnFormatInteger::executeTwoArg)
61        .build();
62  
63    @NonNull
64    static final IFunction SIGNATURE_THREE_ARG = IFunction.builder()
65        .name(NAME)
66        .namespace(MetapathConstants.NS_METAPATH_FUNCTIONS)
67        .deterministic()
68        .contextIndependent()
69        .focusIndependent()
70        .argument(IArgument.builder()
71            .name("value")
72            .type(IIntegerItem.type())
73            .zeroOrOne()
74            .build())
75        .argument(IArgument.builder()
76            .name("picture")
77            .type(IStringItem.type())
78            .one()
79            .build())
80        .argument(IArgument.builder()
81            .name("lang")
82            .type(IStringItem.type())
83            .zeroOrOne()
84            .build())
85        .returnType(IStringItem.type())
86        .returnOne()
87        .functionHandler(FnFormatInteger::executeThreeArg)
88        .build();
89  
90    /**
91     * Roman numeral values in descending order, used for converting integers to
92     * Roman numeral representation.
93     */
94    private static final int[] ROMAN_VALUES = { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 };
95  
96    /**
97     * Roman numeral symbols corresponding to {@link #ROMAN_VALUES}.
98     */
99    private static final String[] ROMAN_SYMBOLS = { "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV",
100       "I" };
101 
102   private static final String[] ONES
103       = { "", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
104           "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
105           "seventeen", "eighteen", "nineteen" };
106 
107   private static final String[] TENS
108       = { "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety" };
109 
110   /**
111    * Pattern to match the format modifier portion of a picture string. The
112    * modifier appears after the last {@code ;} in the picture and must match
113    * {@code ^([co](\(.+\))?)?[at]?$}.
114    */
115   private static final Pattern MODIFIER_PATTERN
116       = Pattern.compile("^([co](\\(.+\\))?)?[at]?$");
117 
118   private FnFormatInteger() {
119     // disable construction
120   }
121 
122   @SuppressWarnings("unused")
123   @NonNull
124   private static ISequence<IStringItem> executeTwoArg(
125       @NonNull IFunction function,
126       @NonNull List<ISequence<?>> arguments,
127       @NonNull DynamicContext dynamicContext,
128       IItem focus) {
129 
130     IIntegerItem value = FunctionUtils.asTypeOrNull(arguments.get(0).getFirstItem(true));
131     IStringItem picture = FunctionUtils.asType(
132         ObjectUtils.requireNonNull(arguments.get(1).getFirstItem(true)));
133 
134     String lang = dynamicContext.getStaticContext().getDefaultLanguage();
135 
136     return ISequence.of(IStringItem.valueOf(
137         fnFormatInteger(value, picture.asString(), lang)));
138   }
139 
140   @SuppressWarnings("unused")
141   @NonNull
142   private static ISequence<IStringItem> executeThreeArg(
143       @NonNull IFunction function,
144       @NonNull List<ISequence<?>> arguments,
145       @NonNull DynamicContext dynamicContext,
146       IItem focus) {
147 
148     IIntegerItem value = FunctionUtils.asTypeOrNull(arguments.get(0).getFirstItem(true));
149     IStringItem picture = FunctionUtils.asType(
150         ObjectUtils.requireNonNull(arguments.get(1).getFirstItem(true)));
151     IStringItem lang = FunctionUtils.asTypeOrNull(arguments.get(2).getFirstItem(true));
152 
153     return ISequence.of(IStringItem.valueOf(
154         fnFormatInteger(value, picture.asString(), lang == null ? null : lang.asString())));
155   }
156 
157   /**
158    * An implementation of XPath 3.1 <a href=
159    * "https://www.w3.org/TR/xpath-functions-31/#func-format-integer">fn:format-integer</a>.
160    *
161    * @param value
162    *          the integer value to format, or {@code null} for empty sequence
163    * @param picture
164    *          the picture string controlling the format
165    * @param lang
166    *          the language for locale-dependent formatting, or {@code null} to use
167    *          the default
168    * @return the formatted integer string
169    * @throws FormatFunctionException
170    *           if the picture string contains an invalid format token
171    */
172   @NonNull
173   public static String fnFormatInteger(
174       @Nullable IIntegerItem value,
175       @NonNull String picture,
176       @Nullable String lang) {
177 
178     // If $value is an empty sequence, return zero-length string
179     if (value == null) {
180       return "";
181     }
182 
183     if (picture.isEmpty()) {
184       throw new FormatFunctionException(
185           FormatFunctionException.INVALID_FORMAT_TOKEN,
186           "The picture string for format-integer must not be empty.");
187     }
188 
189     // Parse primary format token and modifier
190     // The modifier is separated by the last ';' that is part of the modifier
191     // syntax. We need to find the split point.
192     String[] parsed = parsePicture(picture);
193     String primaryToken = parsed[0];
194     String modifier = parsed[1];
195 
196     // Parse modifier flags
197     boolean ordinal = false;
198     if (!modifier.isEmpty()) {
199       Matcher modMatcher = MODIFIER_PATTERN.matcher(modifier);
200       if (!modMatcher.matches()) {
201         throw new FormatFunctionException(
202             FormatFunctionException.INVALID_FORMAT_TOKEN,
203             String.format("Invalid format modifier '%s' in picture string '%s'.", modifier, picture));
204       }
205       String modLetters = modMatcher.group(1);
206       if (modLetters != null && modLetters.startsWith("o")) {
207         ordinal = true;
208       }
209     }
210 
211     BigInteger bigValue = value.asInteger();
212     boolean negative = bigValue.signum() < 0;
213     BigInteger absValue = bigValue.abs();
214 
215     String formatted = formatWithPrimaryToken(primaryToken, absValue, picture);
216 
217     // Apply ordinal suffix if requested and supported for this format token.
218     // Per spec: "If ordinal numbering is not supported for the combination of
219     // the format token, the language, and the string appearing in parentheses,
220     // the request is ignored and cardinal numbers are generated instead."
221     // Only decimal digit patterns support ordinal suffix in this implementation.
222     if (ordinal && isDecimalDigitPattern(primaryToken)) {
223       formatted = applyOrdinal(formatted, absValue);
224     }
225 
226     // Prepend minus sign for negative values
227     if (negative) {
228       formatted = "-" + formatted;
229     }
230 
231     return ObjectUtils.notNull(formatted);
232   }
233 
234   /**
235    * Parses the picture string into the primary format token and the format
236    * modifier. The modifier is separated from the primary token by the last
237    * semicolon. However, semicolons can appear as grouping separators within the
238    * primary token. The modifier must match {@code ^([co](\(.+\))?)?[at]?$}.
239    *
240    * @param picture
241    *          the picture string to parse
242    * @return a two-element array where index 0 is the primary format token and
243    *         index 1 is the format modifier
244    */
245   @NonNull
246   private static String[] parsePicture(@NonNull String picture) {
247     // Try splitting at each ';' from the right. The part after the ';' must match
248     // the modifier pattern (or be empty). The first valid split from the right is
249     // the correct one.
250     for (int i = picture.length() - 1; i >= 0; i--) {
251       if (picture.charAt(i) == ';') {
252         String candidateModifier = picture.substring(i + 1);
253         String candidateToken = picture.substring(0, i);
254         if (MODIFIER_PATTERN.matcher(candidateModifier).matches()) {
255           return new String[] { candidateToken, candidateModifier };
256         }
257       }
258     }
259     // No valid modifier split found; the entire picture is the primary token
260     return new String[] { picture, "" };
261   }
262 
263   /**
264    * Formats the absolute integer value using the given primary format token.
265    *
266    * @param primaryToken
267    *          the primary format token
268    * @param absValue
269    *          the absolute value of the integer to format
270    * @param picture
271    *          the original picture string, used in error messages
272    * @return the formatted string
273    * @throws FormatFunctionException
274    *           if the format token is invalid
275    */
276   @NonNull
277   private static String formatWithPrimaryToken(
278       @NonNull String primaryToken,
279       @NonNull BigInteger absValue,
280       @NonNull String picture) {
281 
282     if (primaryToken.isEmpty()) {
283       throw new FormatFunctionException(
284           FormatFunctionException.INVALID_FORMAT_TOKEN,
285           String.format("The primary format token in picture string '%s' must not be empty.", picture));
286     }
287 
288     // Check for known named tokens
289     if ("a".equals(primaryToken)) {
290       return formatAlphabetic(absValue, false);
291     }
292     if ("A".equals(primaryToken)) {
293       return formatAlphabetic(absValue, true);
294     }
295     if ("i".equals(primaryToken)) {
296       return formatRoman(absValue, false);
297     }
298     if ("I".equals(primaryToken)) {
299       return formatRoman(absValue, true);
300     }
301     if ("w".equals(primaryToken)) {
302       return formatWords(absValue, false, false);
303     }
304     if ("W".equals(primaryToken)) {
305       return formatWords(absValue, true, false);
306     }
307     if ("Ww".equals(primaryToken)) {
308       return formatWords(absValue, false, true);
309     }
310 
311     // Must be a decimal digit pattern
312     return formatDecimalDigitPattern(primaryToken, absValue, picture);
313   }
314 
315   /**
316    * Formats an integer as a decimal digit pattern with optional grouping
317    * separators and zero-padding.
318    *
319    * @param pattern
320    *          the decimal digit pattern portion of the picture string
321    * @param absValue
322    *          the absolute value of the integer to format
323    * @param picture
324    *          the original picture string, used in error messages
325    * @return the formatted decimal string
326    * @throws FormatFunctionException
327    *           if the pattern is invalid
328    */
329   @NonNull
330   @SuppressWarnings("PMD.CyclomaticComplexity")
331   private static String formatDecimalDigitPattern(
332       @NonNull String pattern,
333       @NonNull BigInteger absValue,
334       @NonNull String picture) {
335 
336     // Parse the pattern to identify mandatory digits, optional digits, and grouping
337     // separators. Mandatory digits are '0'-'9', optional digits are '#', and
338     // everything else that is not a letter or digit is a grouping separator.
339     List<Character> patternChars = new ArrayList<>();
340     List<Boolean> isSeparator = new ArrayList<>();
341 
342     int mandatoryCount = 0;
343     boolean foundMandatory = false;
344     boolean hasOptional = false;
345 
346     for (int i = 0; i < pattern.length(); i++) {
347       char ch = pattern.charAt(i);
348       patternChars.add(ch);
349 
350       if (ch >= '0' && ch <= '9') {
351         isSeparator.add(false);
352         mandatoryCount++;
353         foundMandatory = true;
354       } else if (ch == '#') {
355         if (foundMandatory) {
356           // optional digits must precede mandatory digits
357           throw new FormatFunctionException(
358               FormatFunctionException.INVALID_FORMAT_TOKEN,
359               String.format(
360                   "In picture string '%s', optional-digit-sign '#' must precede all mandatory-digit-signs.",
361                   picture));
362         }
363         isSeparator.add(false);
364         hasOptional = true;
365       } else if (!Character.isLetterOrDigit(ch)) {
366         // grouping separator
367         isSeparator.add(true);
368       } else {
369         // unrecognized letter/digit that isn't 0-9 or #; fallback to format '1'
370         return ObjectUtils.notNull(absValue.toString());
371       }
372     }
373 
374     if (mandatoryCount == 0) {
375       throw new FormatFunctionException(
376           FormatFunctionException.INVALID_FORMAT_TOKEN,
377           String.format(
378               "The decimal digit pattern in picture string '%s' must contain at least one mandatory digit.",
379               picture));
380     }
381 
382     // Validate: separators not at start or end, and not adjacent
383     validateSeparators(patternChars, isSeparator, picture);
384 
385     // Determine the grouping separator character and positions (from right)
386     // We work from the right side of the pattern.
387     char groupingSep = 0;
388     List<Integer> groupPositions = new ArrayList<>();
389     int digitIndex = 0;
390     for (int i = patternChars.size() - 1; i >= 0; i--) {
391       if (Boolean.TRUE.equals(isSeparator.get(i))) {
392         groupingSep = patternChars.get(i);
393         groupPositions.add(digitIndex);
394       } else {
395         digitIndex++;
396       }
397     }
398 
399     // Format the number with minimum width
400     String digits = absValue.toString();
401     if (digits.length() < mandatoryCount) {
402       StringBuilder padded = new StringBuilder();
403       for (int i = digits.length(); i < mandatoryCount; i++) {
404         padded.append('0');
405       }
406       padded.append(digits);
407       digits = padded.toString();
408     }
409 
410     // Insert grouping separators if any
411     if (!groupPositions.isEmpty() && groupingSep != 0) {
412       digits = insertGroupingSeparators(digits, groupingSep, groupPositions, hasOptional);
413     }
414 
415     return ObjectUtils.notNull(digits);
416   }
417 
418   /**
419    * Validates that grouping separators do not appear at the start or end of the
420    * pattern, and that no two separators are adjacent.
421    *
422    * @param patternChars
423    *          the characters in the pattern
424    * @param isSeparator
425    *          flags indicating which positions are separators
426    * @param picture
427    *          the original picture string, used in error messages
428    * @throws FormatFunctionException
429    *           if separator placement is invalid
430    */
431   private static void validateSeparators(
432       @NonNull List<Character> patternChars,
433       @NonNull List<Boolean> isSeparator,
434       @NonNull String picture) {
435 
436     if (!patternChars.isEmpty()) {
437       if (Boolean.TRUE.equals(isSeparator.get(0))) {
438         throw new FormatFunctionException(
439             FormatFunctionException.INVALID_FORMAT_TOKEN,
440             String.format(
441                 "Grouping separator must not appear at the start of the pattern in picture string '%s'.",
442                 picture));
443       }
444       if (Boolean.TRUE.equals(isSeparator.get(isSeparator.size() - 1))) {
445         throw new FormatFunctionException(
446             FormatFunctionException.INVALID_FORMAT_TOKEN,
447             String.format(
448                 "Grouping separator must not appear at the end of the pattern in picture string '%s'.",
449                 picture));
450       }
451       for (int i = 1; i < isSeparator.size(); i++) {
452         if (Boolean.TRUE.equals(isSeparator.get(i)) && Boolean.TRUE.equals(isSeparator.get(i - 1))) {
453           throw new FormatFunctionException(
454               FormatFunctionException.INVALID_FORMAT_TOKEN,
455               String.format(
456                   "Adjacent grouping separators are not allowed in picture string '%s'.",
457                   picture));
458         }
459       }
460     }
461   }
462 
463   /**
464    * Inserts grouping separators into a digit string at specified positions.
465    *
466    * <p>
467    * If separators appear at regular intervals (all same character, evenly
468    * spaced), the pattern is extrapolated to the left. Otherwise, separators are
469    * inserted only at the explicit positions.
470    *
471    * @param digits
472    *          the digit string to insert separators into
473    * @param separator
474    *          the grouping separator character
475    * @param positions
476    *          the positions (from right, 0-based digit positions) where separators
477    *          appear in the pattern
478    * @param hasOptional
479    *          whether the pattern contains optional-digit-signs
480    * @return the digit string with grouping separators inserted
481    */
482   @NonNull
483   private static String insertGroupingSeparators(
484       @NonNull String digits,
485       char separator,
486       @NonNull List<Integer> positions,
487       boolean hasOptional) {
488 
489     // Determine if the pattern is regular (all positions at same interval)
490     int groupSize = -1;
491     boolean regular = true;
492 
493     List<Integer> sorted = new ArrayList<>(positions);
494     sorted.sort(null);
495 
496     if (sorted.size() == 1) {
497       groupSize = sorted.get(0);
498       regular = true;
499     } else {
500       // Check if all positions are at regular intervals (multiples of the smallest)
501       int candidate = sorted.get(0);
502       regular = true;
503       for (int i = 0; i < sorted.size(); i++) {
504         if (sorted.get(i) != candidate * (i + 1)) {
505           regular = false;
506           break;
507         }
508       }
509       if (regular) {
510         groupSize = candidate;
511       }
512     }
513 
514     // Build result from right to left, inserting separators at group boundaries
515     StringBuilder result = new StringBuilder();
516     int digitCount = digits.length();
517     int rightDigitCount = 0;
518 
519     for (int i = digitCount - 1; i >= 0; i--) {
520       if (rightDigitCount > 0) {
521         boolean insertSep;
522         if (regular && groupSize > 0) {
523           insertSep = rightDigitCount % groupSize == 0;
524         } else {
525           insertSep = sorted.contains(rightDigitCount);
526         }
527         if (insertSep) {
528           result.insert(0, separator);
529         }
530       }
531       result.insert(0, digits.charAt(i));
532       rightDigitCount++;
533     }
534 
535     return ObjectUtils.notNull(result.toString());
536   }
537 
538   /**
539    * Formats an integer as an alphabetic sequence (a, b, ..., z, aa, ab, ...).
540    *
541    * <p>
542    * The value 1 maps to 'a', 2 to 'b', ..., 26 to 'z', 27 to 'aa', 28 to 'ab',
543    * and so on, similar to spreadsheet column names. Zero is formatted as '0'.
544    *
545    * @param absValue
546    *          the absolute value of the integer
547    * @param uppercase
548    *          whether to produce uppercase letters
549    * @return the alphabetic representation
550    */
551   @NonNull
552   private static String formatAlphabetic(@NonNull BigInteger absValue, boolean uppercase) {
553     if (absValue.signum() == 0) {
554       return "0";
555     }
556 
557     char base = uppercase ? 'A' : 'a';
558     StringBuilder result = new StringBuilder();
559     BigInteger remaining = absValue;
560     BigInteger twentySix = BigInteger.valueOf(26);
561 
562     while (remaining.signum() > 0) {
563       remaining = remaining.subtract(BigInteger.ONE);
564       int digit = remaining.mod(twentySix).intValue();
565       result.insert(0, (char) (base + digit));
566       remaining = remaining.divide(twentySix);
567     }
568 
569     return ObjectUtils.notNull(result.toString());
570   }
571 
572   /**
573    * Formats an integer as a Roman numeral string using standard subtractive
574    * notation. Supports values from 1 to 3999.
575    *
576    * @param absValue
577    *          the absolute value of the integer
578    * @param uppercase
579    *          whether to produce uppercase Roman numerals
580    * @return the Roman numeral representation
581    * @throws FormatFunctionException
582    *           if the value is zero or exceeds 3999
583    */
584   @NonNull
585   private static String formatRoman(@NonNull BigInteger absValue, boolean uppercase) {
586     if (absValue.signum() == 0 || absValue.compareTo(BigInteger.valueOf(3999)) > 0) {
587       // Fallback: use decimal for values outside Roman numeral range
588       return ObjectUtils.notNull(absValue.toString());
589     }
590 
591     int num = absValue.intValue();
592     StringBuilder result = new StringBuilder();
593     for (int i = 0; i < ROMAN_VALUES.length; i++) {
594       while (num >= ROMAN_VALUES[i]) {
595         result.append(ROMAN_SYMBOLS[i]);
596         num -= ROMAN_VALUES[i];
597       }
598     }
599 
600     String roman = result.toString();
601     if (!uppercase) {
602       roman = roman.toLowerCase(Locale.ROOT);
603     }
604     return ObjectUtils.notNull(roman);
605   }
606 
607   /**
608    * Formats an integer as English words.
609    *
610    * @param absValue
611    *          the absolute value of the integer
612    * @param allUppercase
613    *          whether to produce all-uppercase output
614    * @param titleCase
615    *          whether to produce title-case output (first letter of each word
616    *          capitalized)
617    * @return the English word representation
618    */
619   @NonNull
620   private static String formatWords(@NonNull BigInteger absValue, boolean allUppercase, boolean titleCase) {
621     String words = numberToWords(absValue);
622 
623     if (allUppercase) {
624       words = words.toUpperCase(Locale.ROOT);
625     } else if (titleCase) {
626       words = toTitleCase(words);
627     }
628 
629     return ObjectUtils.notNull(words);
630   }
631 
632   /**
633    * Converts a non-negative integer to its English word representation. Supports
634    * values up to 999,999,999 and beyond through recursive decomposition.
635    *
636    * @param value
637    *          the non-negative integer value
638    * @return the English word representation in lowercase
639    */
640   @NonNull
641   @SuppressWarnings("PMD.CyclomaticComplexity")
642   private static String numberToWords(@NonNull BigInteger value) {
643     if (value.signum() == 0) {
644       return "zero";
645     }
646 
647     if (value.compareTo(BigInteger.valueOf(20)) < 0) {
648       return ObjectUtils.notNull(ONES[value.intValue()]);
649     }
650 
651     if (value.compareTo(BigInteger.valueOf(100)) < 0) {
652       int tens = value.intValue() / 10;
653       int ones = value.intValue() % 10;
654       if (ones == 0) {
655         return ObjectUtils.notNull(TENS[tens]);
656       }
657       return ObjectUtils.notNull(TENS[tens] + "-" + ONES[ones]);
658     }
659 
660     if (value.compareTo(BigInteger.valueOf(1000)) < 0) {
661       int hundreds = value.intValue() / 100;
662       int remainder = value.intValue() % 100;
663       if (remainder == 0) {
664         return ONES[hundreds] + " hundred";
665       }
666       return ONES[hundreds] + " hundred " + numberToWords(BigInteger.valueOf(remainder));
667     }
668 
669     // Handle thousands, millions, billions, etc.
670     return formatLargeNumber(value);
671   }
672 
673   /**
674    * Formats a number of 1000 or greater using the standard naming convention
675    * (thousand, million, billion, trillion, etc.).
676    *
677    * @param value
678    *          the value to format (must be >= 1000)
679    * @return the English word representation
680    */
681   @NonNull
682   private static String formatLargeNumber(@NonNull BigInteger value) {
683     String[] scaleWords = { "", "thousand", "million", "billion", "trillion",
684         "quadrillion", "quintillion", "sextillion", "septillion" };
685     BigInteger oneThousand = BigInteger.valueOf(1000);
686 
687     // Fall back to decimal representation for values beyond supported scale
688     BigInteger maxSupported = BigInteger.TEN.pow((scaleWords.length) * 3);
689     if (value.compareTo(maxSupported) >= 0) {
690       return ObjectUtils.notNull(value.toString());
691     }
692 
693     // Decompose into groups of three digits
694     List<Integer> groups = new ArrayList<>();
695     BigInteger remaining = value;
696     while (remaining.signum() > 0) {
697       groups.add(remaining.mod(oneThousand).intValue());
698       remaining = remaining.divide(oneThousand);
699     }
700 
701     StringBuilder result = new StringBuilder();
702     for (int i = groups.size() - 1; i >= 0; i--) {
703       int group = groups.get(i);
704       if (group == 0) {
705         continue;
706       }
707       if (result.length() > 0) {
708         result.append(' ');
709       }
710       result.append(numberToWords(BigInteger.valueOf(group)));
711       if (i > 0 && i < scaleWords.length) {
712         result.append(' ').append(scaleWords[i]);
713       }
714     }
715 
716     return ObjectUtils.notNull(result.toString());
717   }
718 
719   /**
720    * Converts a hyphen-separated word string to title case, where the first letter
721    * of each word (split on spaces and hyphens) is capitalized.
722    *
723    * @param input
724    *          the input string in lowercase
725    * @return the title-cased string
726    */
727   @NonNull
728   private static String toTitleCase(@NonNull String input) {
729     StringBuilder result = new StringBuilder();
730     boolean capitalizeNext = true;
731 
732     for (int i = 0; i < input.length(); i++) {
733       char ch = input.charAt(i);
734       if (ch == ' ' || ch == '-') {
735         result.append(ch);
736         capitalizeNext = true;
737       } else if (capitalizeNext) {
738         result.append(Character.toUpperCase(ch));
739         capitalizeNext = false;
740       } else {
741         result.append(ch);
742       }
743     }
744 
745     return ObjectUtils.notNull(result.toString());
746   }
747 
748   /**
749    * Checks whether the given primary format token is a decimal digit pattern
750    * (contains at least one ASCII digit or '#'). Named format tokens like
751    * {@code a}, {@code i}, {@code w}, etc. are not decimal digit patterns.
752    *
753    * @param primaryToken
754    *          the primary format token
755    * @return {@code true} if the token is a decimal digit pattern
756    */
757   private static boolean isDecimalDigitPattern(@NonNull String primaryToken) {
758     for (int i = 0; i < primaryToken.length(); i++) {
759       char ch = primaryToken.charAt(i);
760       if ((ch >= '0' && ch <= '9') || ch == '#') {
761         return true;
762       }
763     }
764     return false;
765   }
766 
767   /**
768    * Appends an ordinal suffix to a formatted number string. For English, the
769    * rules are:
770    * <ul>
771    * <li>If the last two digits are 11, 12, or 13: "th"</li>
772    * <li>If the last digit is 1: "st"</li>
773    * <li>If the last digit is 2: "nd"</li>
774    * <li>If the last digit is 3: "rd"</li>
775    * <li>Otherwise: "th"</li>
776    * </ul>
777    *
778    * @param formatted
779    *          the formatted number string
780    * @param absValue
781    *          the absolute value of the integer
782    * @return the formatted string with ordinal suffix appended
783    */
784   @NonNull
785   private static String applyOrdinal(
786       @NonNull String formatted,
787       @NonNull BigInteger absValue) {
788 
789     int num = absValue.mod(BigInteger.valueOf(100)).intValue();
790     int lastDigit = absValue.mod(BigInteger.valueOf(10)).intValue();
791 
792     String suffix;
793     if (num >= 11 && num <= 13) {
794       suffix = "th";
795     } else if (lastDigit == 1) {
796       suffix = "st";
797     } else if (lastDigit == 2) {
798       suffix = "nd";
799     } else if (lastDigit == 3) {
800       suffix = "rd";
801     } else {
802       suffix = "th";
803     }
804 
805     return ObjectUtils.notNull(formatted + suffix);
806   }
807 }