001/*
002 * Copyright 2017-2022 Product Mog LLC, 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.lokalized;
018
019import com.lokalized.LocalizedString.LanguageFormSelector;
020import com.lokalized.LocalizedString.LanguageFormTranslation;
021import com.lokalized.LocalizedString.LanguageFormTranslationRule;
022import com.lokalized.LocalizedString.LanguageFormTranslationRange;
023import org.jspecify.annotations.NonNull;
024import org.jspecify.annotations.Nullable;
025
026import javax.annotation.concurrent.NotThreadSafe;
027import javax.annotation.concurrent.ThreadSafe;
028import java.util.ArrayList;
029import java.util.Collections;
030import java.util.Comparator;
031import java.util.HashMap;
032import java.util.HashSet;
033import java.util.LinkedHashMap;
034import java.util.LinkedHashSet;
035import java.util.List;
036import java.util.Locale;
037import java.util.Locale.LanguageRange;
038import java.util.Map;
039import java.util.Map.Entry;
040import java.util.Optional;
041import java.util.Set;
042import java.util.function.Function;
043import java.util.function.Supplier;
044import java.util.logging.Logger;
045import java.util.stream.Collectors;
046
047import static java.lang.String.format;
048import static java.util.Objects.requireNonNull;
049
050/**
051 * Default implementation of a localized string provider.
052 * <p>
053 * It is recommended to use a single instance of this class across your entire application.
054 * <p>
055 * In multi-tenant systems like a web application where each user might have a different locale,
056 * your {@code localeSupplier} might return the locale specified by the current request, e.g.
057 * from a set of {@link LanguageRange} parsed from the {@code Accept-Language} header.
058 *
059 * @author <a href="https://revetkn.com">Mark Allen</a>
060 */
061@ThreadSafe
062public class DefaultStrings implements Strings {
063        @NonNull
064        private static final PhoneticResolver DEFAULT_PHONETIC_RESOLVER;
065
066        static {
067                DEFAULT_PHONETIC_RESOLVER = (term, locale) -> {
068                        throw new IllegalStateException(format("No %s was configured. Provide one via %s.Builder#phoneticResolver(...)",
069                                        PhoneticResolver.class.getSimpleName(), Strings.class.getSimpleName()));
070                };
071        }
072
073        @NonNull
074        private final Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale;
075        @NonNull
076        private final Function<LocaleMatcher, Locale> localeSupplier;
077        @NonNull
078        private final Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode;
079        @NonNull
080        private final FailureMode failureMode;
081        @NonNull
082        private final Locale fallbackLocale;
083        @NonNull
084        private final StringInterpolator stringInterpolator;
085        @NonNull
086        private final ExpressionEvaluator expressionEvaluator;
087        @NonNull
088        private final PhoneticResolver phoneticResolver;
089        @NonNull
090        private final Logger logger;
091
092        /**
093         * Cache of localized strings by key by locale.
094         * <p>
095         * This is our "master" reference localized string storage that other data structures will point to.
096         */
097        @NonNull
098        private final Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale;
099
100        /**
101         * Vends a builder suitable for constructing {@link DefaultStrings) instances.
102         * <p>
103         * This method is package-private and designed to be invoked via {@link Strings#withFallbackLocale(Locale)}.
104         *
105         * @param fallbackLocale the fallback locale used if no others match, not null
106         * @return the builder, not null
107         */
108        @NonNull
109        static Builder withFallbackLocale(@NonNull Locale fallbackLocale) {
110                requireNonNull(fallbackLocale);
111                return new Builder(fallbackLocale);
112        }
113
114        /**
115         * Constructs a localized string provider with builder-supplied data.
116         *
117         * @param fallbackLocale                  fallback locale, not null
118         * @param localizedStringSupplier         supplier of localized strings, not null
119         * @param localeSupplier                  locale supplier, may not be null
120         * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallbacks, may be null
121         * @param failureMode                     strategy for dealing with lookup failures, may be null
122         */
123        protected DefaultStrings(@NonNull Locale fallbackLocale,
124                                                                                                         @NonNull Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier,
125                                                                                                         @NonNull Function<LocaleMatcher, Locale> localeSupplier,
126                                                                                                         @Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode,
127                                                                                                         @Nullable FailureMode failureMode) {
128                this(fallbackLocale, localizedStringSupplier, localeSupplier, tiebreakerLocalesByLanguageCode, failureMode, null);
129        }
130
131        /**
132         * Constructs a localized string provider with builder-supplied data.
133         *
134         * @param fallbackLocale                  fallback locale, not null
135         * @param localizedStringSupplier         supplier of localized strings, not null
136         * @param localeSupplier                  locale supplier, may not be null
137         * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallbacks, may be null
138         * @param failureMode                     strategy for dealing with lookup failures, may be null
139         * @param phoneticResolver                resolver for phonetic categories, may be null (defaults to fail-fast resolver)
140         */
141        protected DefaultStrings(@NonNull Locale fallbackLocale,
142                                                                                                         @NonNull Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier,
143                                                                                                         @NonNull Function<LocaleMatcher, Locale> localeSupplier,
144                                                                                                         @Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode,
145                                                                                                         @Nullable FailureMode failureMode,
146                                                                                                         @Nullable PhoneticResolver phoneticResolver) {
147                requireNonNull(fallbackLocale);
148                requireNonNull(localizedStringSupplier, format("You must specify a 'localizedStringSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName()));
149                requireNonNull(localeSupplier, format("You must specify a 'localeSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName()));
150
151                this.logger = Logger.getLogger(LoggerType.STRINGS.getLoggerName());
152
153                Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>> suppliedLocalizedStringsByLocale = localizedStringSupplier.get();
154
155                if (suppliedLocalizedStringsByLocale == null)
156                        suppliedLocalizedStringsByLocale = Collections.emptyMap();
157
158                // Defensive copy of iterator to unmodifiable set
159                Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = suppliedLocalizedStringsByLocale.entrySet().stream()
160                                .collect(Collectors.toMap(
161                                                entry -> entry.getKey(),
162                                                entry -> {
163                                                        Set<@NonNull LocalizedString> localizedStrings = new LinkedHashSet<>();
164                                                        entry.getValue().forEach(localizedStrings::add);
165                                                        return Collections.unmodifiableSet(localizedStrings);
166                                                }
167                                ));
168
169                this.fallbackLocale = fallbackLocale;
170                this.localizedStringsByLocale = Collections.unmodifiableMap(localizedStringsByLocale);
171
172                // Make our own mapping of tiebreakers based on the provided mapping.
173                // First, defensive copy, then add to the map as needed below.
174                Map<@NonNull String, @Nullable List<@NonNull Locale>> internalTiebreakerLocalesByLanguageCode = new HashMap<>();
175
176                if (tiebreakerLocalesByLanguageCode != null) {
177                        for (Entry<@NonNull String, @Nullable List<@NonNull Locale>> entry : tiebreakerLocalesByLanguageCode.entrySet()) {
178                                @Nullable List<@NonNull Locale> locales = entry.getValue();
179                                internalTiebreakerLocalesByLanguageCode.put(entry.getKey(),
180                                                locales == null ? null : new ArrayList<>(locales));
181                        }
182                }
183
184                // Verify tiebreakers are provided to support locale resolution when ambiguity exists.
185                // For each language code, if there is more than 1 localized strings file that matches it, tiebreakers must be provided.
186                Map<@NonNull String, @NonNull Set<@NonNull Locale>> supportedLocalesByLanguageCode = new HashMap<>(localizedStringsByLocale.size());
187
188                for (Locale supportedLocale : localizedStringsByLocale.keySet()) {
189                        String languageCode = supportedLocale.getLanguage();
190                        Set<@NonNull Locale> locales = supportedLocalesByLanguageCode.get(languageCode);
191
192                        if (locales == null) {
193                                locales = new HashSet<>();
194                                supportedLocalesByLanguageCode.put(languageCode, locales);
195                        }
196
197                        locales.add(supportedLocale);
198                }
199
200                for (Entry<@NonNull String, @NonNull Set<@NonNull Locale>> entry : supportedLocalesByLanguageCode.entrySet()) {
201                        String languageCode = entry.getKey();
202                        List<@NonNull Locale> locales = entry.getValue().stream()
203                                        .sorted(Comparator.comparing(Locale::toLanguageTag))
204                                        .collect(Collectors.toList());
205
206                        if (locales.size() == 1) {
207                                // If there is exactly 1 locale for the language code, it's its own "identity" tiebreaker.
208                                internalTiebreakerLocalesByLanguageCode.put(languageCode, new ArrayList<>(locales));
209                        } else if (locales.size() > 1) {
210                                // We need to provide tiebreakers if a locale has more than 1 strings file.
211                                @Nullable List<@NonNull Locale> providedTiebreakerLocales = internalTiebreakerLocalesByLanguageCode.get(languageCode);
212
213                                if (providedTiebreakerLocales == null || providedTiebreakerLocales.size() == 0) {
214                                        throw new IllegalArgumentException(format("You must specify tiebreaker locales via 'tiebreakerLocalesByLanguageCode' to resolve ambiguity for language code '%s' because localized strings exist for the following locale[s]: %s",
215                                                        languageCode, locales.stream().map(locale -> locale.toLanguageTag()).collect(Collectors.toList())));
216                                } else {
217                                        // First, verify that all tiebreakers actually exist
218                                        Set<@NonNull Locale> supportedLocales = localizedStringsByLocale.keySet();
219
220                                        for (Locale providedTiebreakerLocale : providedTiebreakerLocales)
221                                                if (!supportedLocales.contains(providedTiebreakerLocale))
222                                                        throw new IllegalArgumentException(format("Tiebreaker locale '%s' specified in 'tiebreakerLocalesByLanguageCode' does not have a localized strings file. Supported locales are: %s",
223                                                                        providedTiebreakerLocale.toLanguageTag(), supportedLocales.stream().map(supportedLocale -> supportedLocale.toLanguageTag()).sorted().collect(Collectors.toList())));
224
225                                        // Next, verify that tiebreakers are exhaustively specified
226                                        List<@NonNull Locale> missingLocales = new ArrayList<>(locales.size());
227
228                                        for (Locale locale : locales)
229                                                if (!providedTiebreakerLocales.contains(locale))
230                                                        missingLocales.add(locale);
231
232                                        if (missingLocales.size() > 0)
233                                                throw new IllegalArgumentException(format("Your 'tiebreakerLocalesByLanguageCode' specifies locale[s] %s for language code '%s', but you are missing entries for the following locale[s]: %s",
234                                                                providedTiebreakerLocales.stream().map(providedTiebreakerLocale -> providedTiebreakerLocale.toLanguageTag()).sorted().collect(Collectors.toList()),
235                                                                languageCode,
236                                                                missingLocales.stream().map(missingLocale -> missingLocale.toLanguageTag()).sorted().collect(Collectors.toList())));
237                                }
238
239                                internalTiebreakerLocalesByLanguageCode.put(languageCode, new ArrayList<>(providedTiebreakerLocales));
240                        } else {
241                                // Should never occur
242                                throw new IllegalStateException("No locales match language code");
243                        }
244                }
245
246                Map<@NonNull String, @Nullable List<@NonNull Locale>> finalizedTiebreakerLocalesByLanguageCode = new HashMap<>(internalTiebreakerLocalesByLanguageCode.size());
247
248                for (Entry<@NonNull String, @Nullable List<@NonNull Locale>> entry : internalTiebreakerLocalesByLanguageCode.entrySet()) {
249                        @Nullable List<@NonNull Locale> locales = entry.getValue();
250                        finalizedTiebreakerLocalesByLanguageCode.put(entry.getKey(),
251                                        locales == null ? null : Collections.unmodifiableList(new ArrayList<>(locales)));
252                }
253
254                this.tiebreakerLocalesByLanguageCode = Collections.unmodifiableMap(finalizedTiebreakerLocalesByLanguageCode);
255
256                this.failureMode = failureMode == null ? FailureMode.USE_FALLBACK : failureMode;
257                this.stringInterpolator = new StringInterpolator();
258                this.phoneticResolver = phoneticResolver == null ? DEFAULT_PHONETIC_RESOLVER : phoneticResolver;
259                this.expressionEvaluator = new ExpressionEvaluator(null, this.phoneticResolver);
260
261                Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale =
262                                new HashMap<>(localizedStringsByLocale.size());
263
264                for (Entry<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> entry : localizedStringsByLocale.entrySet()) {
265                        Locale locale = entry.getKey();
266                        Map<@NonNull String, @NonNull LocalizedString> localizedStringsByKey = new LinkedHashMap<>();
267
268                        for (LocalizedString localizedString : entry.getValue()) {
269                                if (localizedString == null)
270                                        throw new IllegalArgumentException(format("Null localized string encountered for locale '%s'", locale.toLanguageTag()));
271
272                                String key = localizedString.getKey();
273                                LocalizedString existing = localizedStringsByKey.putIfAbsent(key, localizedString);
274
275                                if (existing != null)
276                                        throw new IllegalArgumentException(format("Duplicate localized string key '%s' encountered for locale '%s'", key, locale.toLanguageTag()));
277                        }
278
279                        localizedStringsByKeyByLocale.put(locale, Collections.unmodifiableMap(localizedStringsByKey));
280                }
281
282                this.localizedStringsByKeyByLocale = Collections.unmodifiableMap(localizedStringsByKeyByLocale);
283
284                if (!localizedStringsByLocale.containsKey(getFallbackLocale()))
285                        throw new IllegalArgumentException(format("Specified fallback locale is '%s' but no matching " +
286                                                        "localized strings locale was found. Known locales: [%s]", fallbackLocale.toLanguageTag(),
287                                        localizedStringsByLocale.keySet().stream()
288                                                        .map(locale -> locale.toLanguageTag())
289                                                        .sorted()
290                                                        .collect(Collectors.joining(", "))));
291
292                this.localeSupplier = localeSupplier;
293        }
294
295        @NonNull
296        @Override
297        public String get(@NonNull String key) {
298                requireNonNull(key);
299                return get(key, null, getLocaleSupplier().apply(this));
300        }
301
302        @NonNull
303        @Override
304        public String get(@NonNull String key,
305                                                                                @Nullable Map<@NonNull String, @Nullable Object> placeholders) {
306                requireNonNull(key);
307                return get(key, placeholders, getLocaleSupplier().apply(this));
308        }
309
310        @NonNull
311        protected String get(@NonNull String key,
312                                                                                         @Nullable Map<@NonNull String, @Nullable Object> placeholders,
313                                                                                         @NonNull Locale locale) {
314                requireNonNull(key);
315                requireNonNull(locale);
316
317                if (placeholders == null)
318                        placeholders = Collections.emptyMap();
319
320                Locale finalLocale = locale;
321                Map<@NonNull String, @Nullable Object> mutableContext = new HashMap<>(placeholders);
322                Map<@NonNull String, @Nullable Object> immutableContext = Collections.unmodifiableMap(new HashMap<>(placeholders));
323
324                @Nullable Map<@NonNull String, @NonNull LocalizedString> localizedStrings = getLocalizedStringsByKeyByLocale().get(locale);
325
326                if (localizedStrings == null) {
327                        finalLocale = getFallbackLocale();
328                        localizedStrings = getLocalizedStringsByKeyByLocale().get(getFallbackLocale());
329                }
330
331                // Should never occur
332                if (localizedStrings == null)
333                        throw new IllegalStateException(format("Unable to find strings file for both '%s' and fallback locale '%s'",
334                                        locale.toLanguageTag(), getFallbackLocale().toLanguageTag()));
335
336                LocalizedString localizedString = localizedStrings.get(key);
337                String translation = null;
338
339                if (localizedString != null)
340                        translation = getInternal(key, localizedString, mutableContext, immutableContext, finalLocale).orElse(null);
341
342                if (translation == null) {
343                        String message = format("No match for '%s' was found for locale '%s'.", key, locale.toLanguageTag());
344                        logger.finer(message);
345
346                        if (getFailureMode() == FailureMode.FAIL_FAST)
347                                throw new MissingTranslationException(message, key, placeholders, locale);
348
349                        // Not fail-fast?  Merge against the key itself in hopes that the key is a meaningful natural-language value
350                        translation = getStringInterpolator().interpolate(key, mutableContext);
351                }
352
353                return translation;
354        }
355
356        /**
357         * Recursive method which attempts to translate a localized string.
358         *
359         * @param key              the toplevel translation key (always the same regardless of recursion depth), not null
360         * @param localizedString  the localized string on which to operate, not null
361         * @param mutableContext   the mutable context for the translation, not null
362         * @param immutableContext the original user-supplied translation context, not null
363         * @param locale           the locale to use for evaluation, not null
364         * @return the translation, if possible (may not be possible if no translation value specified and no alternative expressions match), not null
365         */
366        @NonNull
367        protected Optional<String> getInternal(@NonNull String key, @NonNull LocalizedString localizedString,
368                                                                                                                                                                 @NonNull Map<@NonNull String, @Nullable Object> mutableContext,
369                                                                                                                                                                 @NonNull Map<@NonNull String, @Nullable Object> immutableContext,
370                                                                                                                                                                 @NonNull Locale locale) {
371                requireNonNull(key);
372                requireNonNull(localizedString);
373                requireNonNull(mutableContext);
374                requireNonNull(immutableContext);
375                requireNonNull(locale);
376
377                // First, see if any alternatives match by evaluating them
378                for (LocalizedString alternative : localizedString.getAlternatives()) {
379                        if (alternativeMatches(alternative, mutableContext, locale)) {
380                                logger.finer(format("An alternative match for '%s' was found for key '%s' and context %s", alternative.getKey(), key, mutableContext));
381
382                                // If we have a matching alternative, recurse into it
383                                return getInternal(key, alternative, mutableContext, immutableContext, locale);
384                        }
385                }
386
387                if (!localizedString.getTranslation().isPresent())
388                        return Optional.empty();
389
390                String translation = localizedString.getTranslation().get();
391
392                for (Entry<@NonNull String, @NonNull LanguageFormTranslation> entry : localizedString.getLanguageFormTranslationsByPlaceholder().entrySet()) {
393                        String placeholderName = entry.getKey();
394                        LanguageFormTranslation languageFormTranslation = entry.getValue();
395
396                        if (languageFormTranslation.isSelectorDriven()) {
397                                mutableContext.put(placeholderName, resolveSelectorDrivenLanguageFormTranslation(key, placeholderName,
398                                                languageFormTranslation, localizedString, immutableContext, locale));
399                                continue;
400                        }
401
402                        Object value = null;
403                        Object rangeStart = null;
404                        Object rangeEnd = null;
405                        Map<@NonNull Cardinality, @NonNull String> translationsByCardinality = new HashMap<>();
406                        Map<@NonNull Ordinality, @NonNull String> translationsByOrdinality = new HashMap<>();
407                        Map<@NonNull Gender, @NonNull String> translationsByGender = new HashMap<>();
408                        Map<@NonNull GrammaticalCase, @NonNull String> translationsByGrammaticalCase = new HashMap<>();
409                        Map<@NonNull Definiteness, @NonNull String> translationsByDefiniteness = new HashMap<>();
410                        Map<@NonNull Classifier, @NonNull String> translationsByClassifier = new HashMap<>();
411                        Map<@NonNull Formality, @NonNull String> translationsByFormality = new HashMap<>();
412                        Map<@NonNull Clusivity, @NonNull String> translationsByClusivity = new HashMap<>();
413                        Map<@NonNull Animacy, @NonNull String> translationsByAnimacy = new HashMap<>();
414                        Map<@NonNull Phonetic, @NonNull String> translationsByPhonetic = new HashMap<>();
415
416                        if (languageFormTranslation.getRange().isPresent()) {
417                                LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get();
418                                rangeStart = unwrapOptional(immutableContext.get(languageFormTranslationRange.getStart()));
419                                rangeEnd = unwrapOptional(immutableContext.get(languageFormTranslationRange.getEnd()));
420                        } else {
421                                value = unwrapOptional(immutableContext.get(languageFormTranslation.getValue().get()));
422                        }
423
424                        for (Entry<@NonNull LanguageForm, @NonNull String> translationEntry : languageFormTranslation.getTranslationsByLanguageForm().entrySet()) {
425                                LanguageForm languageForm = translationEntry.getKey();
426                                String translatedLanguageForm = translationEntry.getValue();
427
428                                if (languageForm instanceof Cardinality)
429                                        translationsByCardinality.put((Cardinality) languageForm, translatedLanguageForm);
430                                else if (languageForm instanceof Ordinality)
431                                        translationsByOrdinality.put((Ordinality) languageForm, translatedLanguageForm);
432                                else if (languageForm instanceof Gender)
433                                        translationsByGender.put((Gender) languageForm, translatedLanguageForm);
434                                else if (languageForm instanceof GrammaticalCase)
435                                        translationsByGrammaticalCase.put((GrammaticalCase) languageForm, translatedLanguageForm);
436                                else if (languageForm instanceof Definiteness)
437                                        translationsByDefiniteness.put((Definiteness) languageForm, translatedLanguageForm);
438                                else if (languageForm instanceof Classifier)
439                                        translationsByClassifier.put((Classifier) languageForm, translatedLanguageForm);
440                                else if (languageForm instanceof Formality)
441                                        translationsByFormality.put((Formality) languageForm, translatedLanguageForm);
442                                else if (languageForm instanceof Clusivity)
443                                        translationsByClusivity.put((Clusivity) languageForm, translatedLanguageForm);
444                                else if (languageForm instanceof Animacy)
445                                        translationsByAnimacy.put((Animacy) languageForm, translatedLanguageForm);
446                                else if (languageForm instanceof Phonetic)
447                                        translationsByPhonetic.put((Phonetic) languageForm, translatedLanguageForm);
448                                else
449                                        throw new IllegalArgumentException(format("Encountered unrecognized language form %s", languageForm));
450                        }
451
452                        int distinctLanguageForms = (translationsByCardinality.size() > 0 ? 1 : 0) +
453                                        (translationsByOrdinality.size() > 0 ? 1 : 0) +
454                                        (translationsByGender.size() > 0 ? 1 : 0) +
455                                        (translationsByGrammaticalCase.size() > 0 ? 1 : 0) +
456                                        (translationsByDefiniteness.size() > 0 ? 1 : 0) +
457                                        (translationsByClassifier.size() > 0 ? 1 : 0) +
458                                        (translationsByFormality.size() > 0 ? 1 : 0) +
459                                        (translationsByClusivity.size() > 0 ? 1 : 0) +
460                                        (translationsByAnimacy.size() > 0 ? 1 : 0) +
461                                        (translationsByPhonetic.size() > 0 ? 1 : 0);
462
463                        if (distinctLanguageForms > 1)
464                                throw new IllegalArgumentException(format("You cannot mix-and-match language forms. Offending localized string was %s", localizedString));
465
466                        if (distinctLanguageForms == 0)
467                                continue;
468
469                        if (languageFormTranslation.getRange().isPresent() && translationsByCardinality.isEmpty())
470                                throw new IllegalArgumentException(format("Range-based translations are only supported for %s. Offending localized string was %s",
471                                                Cardinality.class.getSimpleName(), localizedString));
472
473                        // Handle plural cardinalities
474                        if (translationsByCardinality.size() > 0) {
475                                // Special case: calculate range from min and max if this is a range-driven cardinality
476                                if (languageFormTranslation.getRange().isPresent()) {
477                                        LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get();
478
479                                        if (rangeStart == null)
480                                                throw new IllegalArgumentException(format("Missing range start placeholder '%s' for key '%s'",
481                                                                languageFormTranslationRange.getStart(), key));
482
483                                        if (rangeEnd == null)
484                                                throw new IllegalArgumentException(format("Missing range end placeholder '%s' for key '%s'",
485                                                                languageFormTranslationRange.getEnd(), key));
486
487                                        if (!(rangeStart instanceof Number)) {
488                                                throw new IllegalArgumentException(format("Range start placeholder '%s' for key '%s' must be a %s but was %s",
489                                                                languageFormTranslationRange.getStart(), key, Number.class.getSimpleName(), rangeStart.getClass().getSimpleName()));
490                                        }
491
492                                        if (!(rangeEnd instanceof Number)) {
493                                                throw new IllegalArgumentException(format("Range end placeholder '%s' for key '%s' must be a %s but was %s",
494                                                                languageFormTranslationRange.getEnd(), key, Number.class.getSimpleName(), rangeEnd.getClass().getSimpleName()));
495                                        }
496
497                                        Cardinality startCardinality = Cardinality.forNumber((Number) rangeStart, locale);
498                                        Cardinality endCardinality = Cardinality.forNumber((Number) rangeEnd, locale);
499                                        Cardinality rangeCardinality = Cardinality.forRange(startCardinality, endCardinality, locale);
500
501                                        String cardinalityTranslation = translationsByCardinality.get(rangeCardinality);
502
503                                        if (cardinalityTranslation == null)
504                                                throw new IllegalStateException(format("Missing %s translation for range cardinality %s (start was %s, end was %s). Localized string was %s",
505                                                                Cardinality.class.getSimpleName(), rangeCardinality.name(), startCardinality.name(), endCardinality.name(), localizedString));
506
507                                        mutableContext.put(placeholderName, cardinalityTranslation);
508                                } else {
509                                        // Normal "non-range" cardinality
510                                        if (value == null)
511                                                throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
512                                                                languageFormTranslation.getValue().get(), key));
513
514                                        if (!(value instanceof Number)) {
515                                                throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
516                                                                languageFormTranslation.getValue().get(), key, Number.class.getSimpleName(), value.getClass().getSimpleName()));
517                                        }
518
519                                        Cardinality cardinality = Cardinality.forNumber((Number) value, locale);
520                                        String cardinalityTranslation = translationsByCardinality.get(cardinality);
521
522                                        if (cardinalityTranslation == null)
523                                                throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
524                                                                Cardinality.class.getSimpleName(), cardinality.name(), localizedString));
525
526                                        mutableContext.put(placeholderName, cardinalityTranslation);
527                                }
528                        }
529
530                        // Handle plural ordinalities
531                        if (translationsByOrdinality.size() > 0) {
532                                if (value == null)
533                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
534                                                        languageFormTranslation.getValue().get(), key));
535
536                                if (!(value instanceof Number)) {
537                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
538                                                        languageFormTranslation.getValue().get(), key, Number.class.getSimpleName(), value.getClass().getSimpleName()));
539                                }
540
541                                Ordinality ordinality = Ordinality.forNumber((Number) value, locale);
542                                String ordinalityTranslation = translationsByOrdinality.get(ordinality);
543
544                                if (ordinalityTranslation == null)
545                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
546                                                        Ordinality.class.getSimpleName(), ordinality.name(), localizedString));
547
548                                mutableContext.put(placeholderName, ordinalityTranslation);
549                        }
550
551                        // Handle genders
552                        if (translationsByGender.size() > 0) {
553                                if (value == null)
554                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
555                                                        languageFormTranslation.getValue().get(), key));
556
557                                if (!(value instanceof Gender))
558                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
559                                                        languageFormTranslation.getValue().get(), key, Gender.class.getSimpleName(), value.getClass().getSimpleName()));
560
561                                Gender gender = (Gender) value;
562                                String genderTranslation = translationsByGender.get(gender);
563
564                                if (genderTranslation == null)
565                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
566                                                        Gender.class.getSimpleName(), gender.name(), localizedString));
567
568                                mutableContext.put(placeholderName, genderTranslation);
569                        }
570
571                        // Handle grammatical cases
572                        if (translationsByGrammaticalCase.size() > 0) {
573                                if (value == null)
574                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
575                                                        languageFormTranslation.getValue().get(), key));
576
577                                if (!(value instanceof GrammaticalCase))
578                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
579                                                        languageFormTranslation.getValue().get(), key, GrammaticalCase.class.getSimpleName(), value.getClass().getSimpleName()));
580
581                                GrammaticalCase grammaticalCase = (GrammaticalCase) value;
582                                String grammaticalCaseTranslation = translationsByGrammaticalCase.get(grammaticalCase);
583
584                                if (grammaticalCaseTranslation == null)
585                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
586                                                        GrammaticalCase.class.getSimpleName(), grammaticalCase.name(), localizedString));
587
588                                mutableContext.put(placeholderName, grammaticalCaseTranslation);
589                        }
590
591                        // Handle definiteness
592                        if (translationsByDefiniteness.size() > 0) {
593                                if (value == null)
594                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
595                                                        languageFormTranslation.getValue().get(), key));
596
597                                if (!(value instanceof Definiteness))
598                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
599                                                        languageFormTranslation.getValue().get(), key, Definiteness.class.getSimpleName(), value.getClass().getSimpleName()));
600
601                                Definiteness definiteness = (Definiteness) value;
602                                String definitenessTranslation = translationsByDefiniteness.get(definiteness);
603
604                                if (definitenessTranslation == null)
605                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
606                                                        Definiteness.class.getSimpleName(), definiteness.name(), localizedString));
607
608                                mutableContext.put(placeholderName, definitenessTranslation);
609                        }
610
611                        // Handle classifiers
612                        if (translationsByClassifier.size() > 0) {
613                                if (value == null)
614                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
615                                                        languageFormTranslation.getValue().get(), key));
616
617                                if (!(value instanceof Classifier))
618                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
619                                                        languageFormTranslation.getValue().get(), key, Classifier.class.getSimpleName(), value.getClass().getSimpleName()));
620
621                                Classifier classifier = (Classifier) value;
622                                String classifierTranslation = translationsByClassifier.get(classifier);
623
624                                if (classifierTranslation == null)
625                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
626                                                        Classifier.class.getSimpleName(), classifier.name(), localizedString));
627
628                                mutableContext.put(placeholderName, classifierTranslation);
629                        }
630
631                        // Handle formality
632                        if (translationsByFormality.size() > 0) {
633                                if (value == null)
634                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
635                                                        languageFormTranslation.getValue().get(), key));
636
637                                if (!(value instanceof Formality))
638                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
639                                                        languageFormTranslation.getValue().get(), key, Formality.class.getSimpleName(), value.getClass().getSimpleName()));
640
641                                Formality formality = (Formality) value;
642                                String formalityTranslation = translationsByFormality.get(formality);
643
644                                if (formalityTranslation == null)
645                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
646                                                        Formality.class.getSimpleName(), formality.name(), localizedString));
647
648                                mutableContext.put(placeholderName, formalityTranslation);
649                        }
650
651                        // Handle clusivity
652                        if (translationsByClusivity.size() > 0) {
653                                if (value == null)
654                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
655                                                        languageFormTranslation.getValue().get(), key));
656
657                                if (!(value instanceof Clusivity))
658                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
659                                                        languageFormTranslation.getValue().get(), key, Clusivity.class.getSimpleName(), value.getClass().getSimpleName()));
660
661                                Clusivity clusivity = (Clusivity) value;
662                                String clusivityTranslation = translationsByClusivity.get(clusivity);
663
664                                if (clusivityTranslation == null)
665                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
666                                                        Clusivity.class.getSimpleName(), clusivity.name(), localizedString));
667
668                                mutableContext.put(placeholderName, clusivityTranslation);
669                        }
670
671                        // Handle animacy
672                        if (translationsByAnimacy.size() > 0) {
673                                if (value == null)
674                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
675                                                        languageFormTranslation.getValue().get(), key));
676
677                                if (!(value instanceof Animacy))
678                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
679                                                        languageFormTranslation.getValue().get(), key, Animacy.class.getSimpleName(), value.getClass().getSimpleName()));
680
681                                Animacy animacy = (Animacy) value;
682                                String animacyTranslation = translationsByAnimacy.get(animacy);
683
684                                if (animacyTranslation == null)
685                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
686                                                        Animacy.class.getSimpleName(), animacy.name(), localizedString));
687
688                                mutableContext.put(placeholderName, animacyTranslation);
689                        }
690
691                        // Handle phonetics
692                        if (translationsByPhonetic.size() > 0) {
693                                if (languageFormTranslation.getRange().isPresent())
694                                        throw new IllegalArgumentException(format("Phonetic translations cannot use ranges. Offending localized string was %s", localizedString));
695
696                                if (value == null)
697                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
698                                                        languageFormTranslation.getValue().get(), key));
699
700                                Phonetic phonetic;
701
702                                if (value instanceof Phonetic) {
703                                        phonetic = (Phonetic) value;
704                                } else if (value instanceof CharSequence) {
705                                        PhoneticResolver resolver = getPhoneticResolver();
706                                        phonetic = resolver.resolve(value.toString(), locale);
707
708                                        if (phonetic == null)
709                                                throw new IllegalArgumentException(format("%s returned null for placeholder '%s' in key '%s'",
710                                                                PhoneticResolver.class.getSimpleName(), languageFormTranslation.getValue().get(), key));
711                                } else {
712                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s or %s but was %s",
713                                                        languageFormTranslation.getValue().get(), key, Phonetic.class.getSimpleName(),
714                                                        CharSequence.class.getSimpleName(), value.getClass().getSimpleName()));
715                                }
716
717                                String phoneticTranslation = translationsByPhonetic.get(phonetic);
718
719                                if (phoneticTranslation == null)
720                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
721                                                        Phonetic.class.getSimpleName(), phonetic.name(), localizedString));
722
723                                mutableContext.put(placeholderName, phoneticTranslation);
724                        }
725                }
726
727                translation = getStringInterpolator().interpolate(translation, mutableContext);
728
729                return Optional.of(translation);
730        }
731
732        @NonNull
733        private String resolveSelectorDrivenLanguageFormTranslation(@NonNull String key, @NonNull String placeholderName,
734                                                                                                                                                                                                                                         @NonNull LanguageFormTranslation languageFormTranslation,
735                                                                                                                                                                                                                                         @NonNull LocalizedString localizedString,
736                                                                                                                                                                                                                                         @NonNull Map<@NonNull String, @Nullable Object> immutableContext,
737                                                                                                                                                                                                                                         @NonNull Locale locale) {
738                requireNonNull(key);
739                requireNonNull(placeholderName);
740                requireNonNull(languageFormTranslation);
741                requireNonNull(localizedString);
742                requireNonNull(immutableContext);
743                requireNonNull(locale);
744
745                Map<@NonNull LanguageFormType, @NonNull LanguageForm> resolvedLanguageFormsByType = new LinkedHashMap<>();
746
747                for (LanguageFormSelector selector : languageFormTranslation.getSelectors()) {
748                        Object selectorValue = unwrapOptional(immutableContext.get(selector.getValue()));
749
750                        if (selectorValue == null)
751                                throw new IllegalArgumentException(format("Missing value for selector '%s' in placeholder '%s' for key '%s'",
752                                                selector.getValue(), placeholderName, key));
753
754                        resolvedLanguageFormsByType.put(selector.getForm(),
755                                        resolveSelectorLanguageFormValue(key, selector.getValue(), selector.getForm(), selectorValue, locale));
756                }
757
758                @Nullable LanguageFormTranslationRule matchedRule = null;
759                int matchedSpecificity = -1;
760
761                for (LanguageFormTranslationRule translationRule : languageFormTranslation.getTranslationRules()) {
762                        if (!selectorTranslationRuleMatches(translationRule, resolvedLanguageFormsByType))
763                                continue;
764
765                        int specificity = translationRule.getWhenByLanguageFormType().size();
766
767                        if (matchedRule != null && specificity == matchedSpecificity)
768                                throw new IllegalStateException(format("Ambiguous selector-based translations for placeholder '%s' with selector values %s. " +
769                                                "Localized string was %s", placeholderName, resolvedLanguageFormsByType, localizedString));
770
771                        if (matchedRule == null || specificity > matchedSpecificity) {
772                                matchedRule = translationRule;
773                                matchedSpecificity = specificity;
774                        }
775                }
776
777                if (matchedRule == null)
778                        throw new IllegalStateException(format("Missing selector-based translation for placeholder '%s' with selector values %s. " +
779                                        "Localized string was %s", placeholderName, resolvedLanguageFormsByType, localizedString));
780
781                return matchedRule.getValue();
782        }
783
784        private boolean selectorTranslationRuleMatches(@NonNull LanguageFormTranslationRule translationRule,
785                                                                                                                                                                                                 @NonNull Map<@NonNull LanguageFormType, @NonNull LanguageForm> resolvedLanguageFormsByType) {
786                requireNonNull(translationRule);
787                requireNonNull(resolvedLanguageFormsByType);
788
789                for (Entry<@NonNull LanguageFormType, @NonNull LanguageForm> ruleEntry : translationRule.getWhenByLanguageFormType().entrySet()) {
790                        LanguageForm resolvedLanguageForm = resolvedLanguageFormsByType.get(ruleEntry.getKey());
791
792                        if (!ruleEntry.getValue().equals(resolvedLanguageForm))
793                                return false;
794                }
795
796                return true;
797        }
798
799        @NonNull
800        private LanguageForm resolveSelectorLanguageFormValue(@NonNull String key, @NonNull String selectorValueName,
801                                                                                                                                                                                                                 @NonNull LanguageFormType languageFormType,
802                                                                                                                                                                                                                 @NonNull Object selectorValue,
803                                                                                                                                                                                                                 @NonNull Locale locale) {
804                requireNonNull(key);
805                requireNonNull(selectorValueName);
806                requireNonNull(languageFormType);
807                requireNonNull(selectorValue);
808                requireNonNull(locale);
809
810                switch (languageFormType) {
811                        case CARDINALITY:
812                                if (!(selectorValue instanceof Number))
813                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
814                                                        selectorValueName, key, Number.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
815
816                                return Cardinality.forNumber((Number) selectorValue, locale);
817                        case ORDINALITY:
818                                if (!(selectorValue instanceof Number))
819                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
820                                                        selectorValueName, key, Number.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
821
822                                return Ordinality.forNumber((Number) selectorValue, locale);
823                        case GENDER:
824                                if (!(selectorValue instanceof Gender))
825                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
826                                                        selectorValueName, key, Gender.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
827
828                                return (Gender) selectorValue;
829                        case CASE:
830                                if (!(selectorValue instanceof GrammaticalCase))
831                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
832                                                        selectorValueName, key, GrammaticalCase.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
833
834                                return (GrammaticalCase) selectorValue;
835                        case DEFINITENESS:
836                                if (!(selectorValue instanceof Definiteness))
837                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
838                                                        selectorValueName, key, Definiteness.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
839
840                                return (Definiteness) selectorValue;
841                        case CLASSIFIER:
842                                if (!(selectorValue instanceof Classifier))
843                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
844                                                        selectorValueName, key, Classifier.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
845
846                                return (Classifier) selectorValue;
847                        case FORMALITY:
848                                if (!(selectorValue instanceof Formality))
849                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
850                                                        selectorValueName, key, Formality.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
851
852                                return (Formality) selectorValue;
853                        case CLUSIVITY:
854                                if (!(selectorValue instanceof Clusivity))
855                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
856                                                        selectorValueName, key, Clusivity.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
857
858                                return (Clusivity) selectorValue;
859                        case ANIMACY:
860                                if (!(selectorValue instanceof Animacy))
861                                        throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s but was %s",
862                                                        selectorValueName, key, Animacy.class.getSimpleName(), selectorValue.getClass().getSimpleName()));
863
864                                return (Animacy) selectorValue;
865                        case PHONETIC:
866                                if (selectorValue instanceof Phonetic)
867                                        return (Phonetic) selectorValue;
868
869                                if (selectorValue instanceof CharSequence) {
870                                        Phonetic phonetic = getPhoneticResolver().resolve(selectorValue.toString(), locale);
871
872                                        if (phonetic == null)
873                                                throw new IllegalArgumentException(format("%s returned null for selector value '%s' in key '%s'",
874                                                                PhoneticResolver.class.getSimpleName(), selectorValueName, key));
875
876                                        return phonetic;
877                                }
878
879                                throw new IllegalArgumentException(format("Selector value '%s' in key '%s' must be a %s or %s but was %s",
880                                                selectorValueName, key, Phonetic.class.getSimpleName(), CharSequence.class.getSimpleName(),
881                                                selectorValue.getClass().getSimpleName()));
882                        default:
883                                throw new IllegalArgumentException(format("Encountered unrecognized selector language form type %s", languageFormType));
884                }
885        }
886
887        private boolean alternativeMatches(@NonNull LocalizedString alternative,
888                                                                                                                                                 @NonNull Map<@NonNull String, @Nullable Object> context,
889                                                                                                                                                 @NonNull Locale locale) {
890                requireNonNull(alternative);
891                requireNonNull(context);
892                requireNonNull(locale);
893
894                List<@NonNull Token> expressionTokens = alternative.getExpressionTokens();
895
896                if (expressionTokens != null)
897                        return getExpressionEvaluator().evaluateReversePolishNotationTokens(expressionTokens, context, locale);
898
899                return getExpressionEvaluator().evaluate(alternative.getKey(), context, locale);
900        }
901
902        @NonNull
903        @Override
904        public Locale bestMatchFor(@NonNull Locale locale) {
905                requireNonNull(locale);
906                return bestMatchFor(List.of(new LanguageRange(locale.toLanguageTag())));
907        }
908
909        @NonNull
910        @Override
911        public Locale bestMatchFor(@NonNull List<@NonNull LanguageRange> languageRanges) {
912                requireNonNull(languageRanges);
913
914                if (languageRanges.isEmpty())
915                        return getFallbackLocale();
916
917                List<@NonNull LanguageRange> sortedLanguageRanges = new ArrayList<>(languageRanges);
918                sortedLanguageRanges.sort(Comparator.comparingDouble(LanguageRange::getWeight).reversed());
919                List<@NonNull Locale> availableLocales = new ArrayList<>(getLocalizedStringsByLocale().keySet());
920                availableLocales.sort(Comparator.comparing(Locale::toLanguageTag));
921
922                // Walk through each LanguageRange in preference order
923                for (LanguageRange languageRange : sortedLanguageRanges) {
924                        String range = languageRange.getRange(); // e.g. "pt" or "pt-PT"
925                        double weight = languageRange.getWeight();
926
927                        if (weight <= 0)
928                                continue;
929
930                        if ("*".equals(range))
931                                return getFallbackLocale();
932
933                        // Exact tag match?
934                        for (Locale locale : availableLocales)
935                                if (locale.toLanguageTag().equalsIgnoreCase(range))
936                                        return locale;
937
938                        // Primary-tag candidates (e.g. "pt" or "pt-XX")
939                        String primary = range.split("-")[0]; // e.g. "pt"
940
941                        if ("*".equals(primary)) {
942                                List<Locale> filteredCandidates = Locale.filter(Collections.singletonList(languageRange), availableLocales,
943                                                Locale.FilteringMode.EXTENDED_FILTERING);
944
945                                if (!filteredCandidates.isEmpty())
946                                        return filteredCandidates.get(0);
947
948                                continue;
949                        }
950
951                        List<@NonNull Locale> candidates = availableLocales.stream()
952                                        .filter(locale -> locale.getLanguage().equalsIgnoreCase(primary))
953                                        .collect(Collectors.toList());
954
955                        if (candidates.isEmpty())
956                                continue; // try the next LanguageRange
957
958                        List<Locale> filteredCandidates = Locale.filter(Collections.singletonList(languageRange), candidates,
959                                        Locale.FilteringMode.EXTENDED_FILTERING);
960
961                        if (!filteredCandidates.isEmpty()) {
962                                boolean hasSpecificMatch = filteredCandidates.stream()
963                                                .anyMatch(locale -> !locale.toLanguageTag().equalsIgnoreCase(locale.getLanguage()));
964
965                                if (hasSpecificMatch)
966                                        candidates = filteredCandidates;
967                        }
968
969                        if (candidates.size() == 1)
970                                return candidates.get(0);
971
972                        // Tie‐breaker list for this primary tag?
973                        @Nullable List<@NonNull Locale> tiebreakers = getTiebreakerLocalesByLanguageCode().get(primary);
974
975                        if (tiebreakers != null)
976                                for (Locale tiebreaker : tiebreakers)
977                                        if (candidates.contains(tiebreaker))
978                                                return tiebreaker;
979
980                        return candidates.get(0);
981                }
982
983                // 4) Nothing matched at all
984                return getFallbackLocale();
985        }
986
987        @Nullable
988        private static Object unwrapOptional(@Nullable Object value) {
989                if (value instanceof Optional)
990                        return ((Optional<?>) value).orElse(null);
991
992                return value;
993        }
994
995        /**
996         * Gets the set of localized strings for each locale.
997         *
998         * @return the set of localized strings for each locale, not null
999         */
1000        @NonNull
1001        public Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> getLocalizedStringsByLocale() {
1002                return localizedStringsByLocale;
1003        }
1004
1005        /**
1006         * Gets the locale supplier.
1007         *
1008         * @return the locale supplier, not null
1009         */
1010        @NonNull
1011        public Function<LocaleMatcher, Locale> getLocaleSupplier() {
1012                return this.localeSupplier;
1013        }
1014
1015        /**
1016         * Gets the mapping of a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales.
1017         *
1018         * @return the per-language-code "tiebreaker" locales, not null
1019         */
1020        @NonNull
1021        public Map<@NonNull String, @Nullable List<@NonNull Locale>> getTiebreakerLocalesByLanguageCode() {
1022                return this.tiebreakerLocalesByLanguageCode;
1023        }
1024
1025        /**
1026         * Gets the strategy for handling string lookup failures.
1027         *
1028         * @return the strategy for handling string lookup failures, not null
1029         */
1030        @NonNull
1031        public FailureMode getFailureMode() {
1032                return failureMode;
1033        }
1034
1035        /**
1036         * Gets the fallback locale.
1037         *
1038         * @return the fallback locale, not null
1039         */
1040        @NonNull
1041        public Locale getFallbackLocale() {
1042                return fallbackLocale;
1043        }
1044
1045        /**
1046         * Gets the string interpolator used to merge placeholders into translations.
1047         *
1048         * @return the string interpolator, not null
1049         */
1050        @NonNull
1051        protected StringInterpolator getStringInterpolator() {
1052                return stringInterpolator;
1053        }
1054
1055        /**
1056         * Gets the expression evaluator used to determine if alternative expressions match the evaluation context.
1057         *
1058         * @return the expression evaluator, not null
1059         */
1060        @NonNull
1061        protected ExpressionEvaluator getExpressionEvaluator() {
1062                return expressionEvaluator;
1063        }
1064
1065        /**
1066         * Gets the phonetic resolver used to determine phonetic categories.
1067         *
1068         * @return the phonetic resolver, not null
1069         */
1070        @NonNull
1071        protected PhoneticResolver getPhoneticResolver() {
1072                return phoneticResolver;
1073        }
1074
1075        /**
1076         * Gets our "master" cache of localized strings by key by locale.
1077         *
1078         * @return the cache of localized strings by key by locale, not null
1079         */
1080        @NonNull
1081        protected Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> getLocalizedStringsByKeyByLocale() {
1082                return localizedStringsByKeyByLocale;
1083        }
1084
1085        /**
1086         * Strategies for handling localized string lookup failures.
1087         */
1088        public enum FailureMode {
1089                /**
1090                 * The system will attempt a series of fallbacks in order to not throw an exception at runtime.
1091                 * <p>
1092                 * This mode is useful for production, where we often want program execution to continue in the face of
1093                 * localization errors.
1094                 */
1095                USE_FALLBACK,
1096                /**
1097                 * The system will throw an exception if a localization is missing for the specified locale.
1098                 * <p>
1099                 * This mode is useful for testing, since problems are uncovered right away when execution halts.
1100                 */
1101                FAIL_FAST
1102        }
1103
1104        /**
1105         * Builder used to construct instances of {@link DefaultStrings}.
1106         * <p>
1107         * This class is intended for use by a single thread.
1108         *
1109         * @author <a href="https://revetkn.com">Mark Allen</a>
1110         */
1111        @NotThreadSafe
1112        public static class Builder {
1113                @NonNull
1114                private final Locale fallbackLocale;
1115                @Nullable
1116                private Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier;
1117                @Nullable
1118                private Function<LocaleMatcher, Locale> localeSupplier;
1119                @Nullable
1120                private Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode;
1121                @Nullable
1122                private FailureMode failureMode;
1123                @Nullable
1124                private PhoneticResolver phoneticResolver;
1125
1126                /**
1127                 * Constructs a strings builder with a default locale.
1128                 *
1129                 * @param fallbackLocale fallback locale, not null
1130                 */
1131                protected Builder(@NonNull Locale fallbackLocale) {
1132                        requireNonNull(fallbackLocale);
1133                        this.fallbackLocale = fallbackLocale;
1134                }
1135
1136                /**
1137                 * Applies a localized string supplier to this builder.
1138                 *
1139                 * @param localizedStringSupplier localized string supplier, may be null
1140                 * @return this builder instance, useful for chaining. not null
1141                 */
1142                @NonNull
1143                public Builder localizedStringSupplier(@Nullable Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier) {
1144                        this.localizedStringSupplier = localizedStringSupplier;
1145                        return this;
1146                }
1147
1148                /**
1149                 * Applies a locale supplier to this builder.
1150                 *
1151                 * @param localeSupplier locale supplier, may be null
1152                 * @return this builder instance, useful for chaining. not null
1153                 */
1154                @NonNull
1155                public Builder localeSupplier(@Nullable Function<LocaleMatcher, Locale> localeSupplier) {
1156                        this.localeSupplier = localeSupplier;
1157                        return this;
1158                }
1159
1160                /**
1161                 * Applies a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales to this builder.
1162                 *
1163                 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null
1164                 * @return this builder instance, useful for chaining. not null
1165                 */
1166                @NonNull
1167                public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode) {
1168                        this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode;
1169                        return this;
1170                }
1171
1172                /**
1173                 * Applies a failure mode to this builder.
1174                 *
1175                 * @param failureMode strategy for dealing with lookup failures, may be null
1176                 * @return this builder instance, useful for chaining. not null
1177                 */
1178                @NonNull
1179                public Builder failureMode(@Nullable FailureMode failureMode) {
1180                        this.failureMode = failureMode;
1181                        return this;
1182                }
1183
1184                /**
1185                 * Applies a phonetic resolver to this builder.
1186                 *
1187                 * @param phoneticResolver phonetic resolver, may be null (defaults to fail-fast resolver)
1188                 * @return this builder instance, useful for chaining. not null
1189                 */
1190                @NonNull
1191                public Builder phoneticResolver(@Nullable PhoneticResolver phoneticResolver) {
1192                        this.phoneticResolver = phoneticResolver;
1193                        return this;
1194                }
1195
1196                /**
1197                 * Constructs an instance of {@link DefaultStrings}.
1198                 *
1199                 * @return an instance of {@link DefaultStrings}, not null
1200                 */
1201                @NonNull
1202                public DefaultStrings build() {
1203                        return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, tiebreakerLocalesByLanguageCode,
1204                                        failureMode, phoneticResolver);
1205                }
1206        }
1207}