content: /*
   *  Licensed to the Apache Software Foundation (ASF) under one
   *  or more contributor license agreements.  See the NOTICE file
   *  distributed with this work for additional information
   *  regarding copyright ownership.  The ASF licenses this file
   *  to you under the Apache License, Version 2.0 (the
   *  "License"); you may not use this file except in compliance
   *  with the License.  You may obtain a copy of the License at
   *
   *        http://www.apache.org/licenses/LICENSE-2.0
   *
   *  Unless required by applicable law or agreed to in writing,
   *  software distributed under the License is distributed on an
   *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
   *  KIND, either express or implied.  See the License for the
   *  specific language governing permissions and limitations
   *  under the License.
   */
  package org.apache.causeway.core.runtimeservices.i18n.po;
  
  import java.util.Collections;
  import java.util.List;
  import java.util.Locale;
  import java.util.Map;
  import java.util.Set;
  import java.util.stream.Collectors;
  
  import org.apache.causeway.applib.services.i18n.LanguageProvider;
  import org.apache.causeway.applib.services.i18n.Mode;
  import org.apache.causeway.applib.services.i18n.TranslationContext;
  import org.apache.causeway.applib.services.i18n.TranslationsResolver;
  import org.apache.causeway.commons.collections.Can;
  import org.apache.causeway.commons.internal.base._Strings;
  import org.apache.causeway.commons.internal.collections._Lists;
  import org.apache.causeway.commons.internal.collections._Maps;
  import org.apache.causeway.commons.internal.collections._Sets;
  
  import lombok.extern.log4j.Log4j2;
  
  @Log4j2
  class PoReader extends PoAbstract {
  
      public static final String DASH = "-";
      public static final String UNDERSCORE = "_";
  
      private final Map<Locale, Map<ContextAndMsgId, String>> translationByKeyByLocale = _Maps.newHashMap();
      private final Map<Locale, Boolean> usesFallbackByLocale = _Maps.newHashMap();
  
      /**
       * The basename of the translations file, hard-coded to <tt>translations</tt>.
       *
       * <p>
       *     This means that the reader will search for <tt>translations_en-US.po</tt>, <tt>translations_en.po</tt>,
       *     <tt>translations.po</tt>, according to the location that the provided
       *     {@link org.apache.causeway.applib.services.i18n.TranslationsResolver} searches.
       * </p>
       *
       * <p>
       *     For example, if using the Wicket implementation, then will search for these files
       *     under <tt>/WEB-INF</tt> directory.
       * </p>
       */
      private final String basename = "translations";
      private final Can<TranslationsResolver> translationsResolver;
      private final LanguageProvider languageProvider;
  
      private List<String> fallback;
  
      public PoReader(final TranslationServicePo translationServicePo) {
          super(translationServicePo, Mode.READ);
          translationsResolver = translationServicePo.getTranslationsResolver();
          if(translationsResolver.isEmpty()) {
              log.warn("No TranslationsResolver available");
          }
          languageProvider = translationServicePo.getLanguageProvider();
      }
  
      // -- init, shutdown
  
      /**
       * Not API
       */
      void init() {
          fallback = readUrl(basename + ".po");
          if(fallback == null) {
              log.info("No fallback translations found; i18n is in effect disabled for this application");
              fallback = Collections.emptyList();
          }
      }
  
      @Override
      public String translate(final TranslationContext context, final String msgId) {
          if(translationsResolver == null) {
              // already logged as WARN (in constructor) if null.
              return msgId;
          }
          return translate(context, msgId, ContextAndMsgId.Type.REGULAR);
      }
  
      @Override
      String translate(final TranslationContext context, final String msgId, final String msgIdPlural, final int num) {
  
          final String msgIdToUse;
          final ContextAndMsgId.Type type;
          if (num == 1) {
              msgIdToUse = msgId;
              type = ContextAndMsgId.Type.REGULAR;
          } else {
              msgIdToUse = msgIdPlural;
              type = ContextAndMsgId.Type.PLURAL_ONLY;
          }
  
          return translate(context, msgIdToUse, type);
      }
  
      void clearCache() {
          translationByKeyByLocale.clear();
          usesFallbackByLocale.clear();
          init();
      }
  
      private String translate(final TranslationContext context, final String msgId, final ContextAndMsgId.Type type) {
  
          final Locale targetLocale;
          try {
              targetLocale = languageProvider.getPreferredLanguage()
                      .orElse(null);
              if(targetLocale == null) {
                  // eg if request from RO viewer and the (default) LocaleProviderWicket is being used.
                  return msgId;
              }
          } catch(final RuntimeException ex){
              logInfoIfNotPreviously("Failed to obtain locale, returning the original msgId");
              return msgId;
          }
  
          final Map<ContextAndMsgId, String> translationsByKey = readAndCacheTranslationsIfRequired(targetLocale);
  
          // search for translation with a context
          final ContextAndMsgId key = new ContextAndMsgId(context.getName(), msgId, type);
          final String translation = lookupTranslation(translationsByKey, key);
          if (!_Strings.isNullOrEmpty(translation)) {
              return translation;
          }
  
          // else search for translation without a context
          final ContextAndMsgId keyNoContext = new ContextAndMsgId("", msgId, type);
          final String translationNoContext = lookupTranslation(translationsByKey, keyNoContext);
          if (!_Strings.isNullOrEmpty(translationNoContext)) {
              return translationNoContext;
          }
  
          // to avoid chattiness in the log, we only log if there are ANY translations at all for the target locale.
          // the algorithm for searching for translations looks for:
          // 1. language_country
          // 2. language
          // 3. fallback
          // so this message is only ever displayed if the locale isn't using fallback (ie a translation is genuinely missing)
          final Boolean usesFallback = usesFallbackByLocale.get(targetLocale);
          if(usesFallback == null || !usesFallback) {
              logInfoIfNotPreviously("No translation found for: " + key);
          }
  
          return msgId;
      }
  
      private String lookupTranslation(final Map<ContextAndMsgId, String> translationsByKey, final ContextAndMsgId key) {
          final String s = translationsByKey.get(key);
          return s != null? s.trim(): null;
      }
  
      private Map<ContextAndMsgId, String> readAndCacheTranslationsIfRequired(final Locale locale) {
          Map<ContextAndMsgId, String> translationsByKey = translationByKeyByLocale.get(locale);
          if(translationsByKey != null) {
              return translationsByKey;
          }
  
          translationsByKey = _Maps.newHashMap();
          read(locale, translationsByKey);
          translationByKeyByLocale.put(locale, translationsByKey);
  
          return translationsByKey;
      }
  
      /**
       * @param locale - the .po file to load
       * @param translationsByKey - the translations to be populated
       */
      private void read(final Locale locale, final Map<ContextAndMsgId, String> translationsByKey) {
          final List<String> contents = readPo(locale);
  
          Block block = new Block();
          for (final String line : contents) {
              block = block.parseLine(line, translationsByKey);
          }
      }
  
      protected List<String> readPo(final Locale locale) {
          final List<String> lines = readPoElseNull(locale);
          if(lines != null) {
              usesFallbackByLocale.put(locale, false);
              return lines;
          }
  
          // this is only ever logged the first time that a user using this particular locale is encountered
          logInfoIfNotPreviously("Could not locate translations for locale: " + locale + ", using fallback");
  
          usesFallbackByLocale.put(locale, true);
          return fallback;
      }
  
      private List<String> readPoElseNull(final Locale locale) {
          final String country = locale.getCountry().toUpperCase(Locale.ROOT);
          final String language = locale.getLanguage().toLowerCase(Locale.ROOT);
  
          final List<String> candidates = _Lists.newArrayList();
          if(!_Strings.isNullOrEmpty(language)) {
              if(!_Strings.isNullOrEmpty(country)) {
                  candidates.add(basename + DASH       + language + UNDERSCORE + country+ ".po");
                  candidates.add(basename + DASH       + language + DASH       + country+ ".po");
                  candidates.add(basename + UNDERSCORE + language + UNDERSCORE + country+ ".po");
                  candidates.add(basename + UNDERSCORE + language + DASH       + country+ ".po");
              }
              candidates.add(basename + DASH + language + ".po");
              candidates.add(basename + UNDERSCORE + language + ".po");
          }
  
          for (final String candidate : candidates) {
              var lines = readUrl(candidate);
              if(!lines.isEmpty()) {
                  return lines;
              }
          }
          return null;
      }
  
      private List<String> readUrl(final String candidate) {
          return translationsResolver.stream()
                  .map(resolver->resolver.readLines(candidate))
                  .flatMap(List::stream)
                  .collect(Collectors.toList());
      }
  
      // to avoid flooding the logs
      private void logInfoIfNotPreviously(final String infoMessage) {
          if(!loggedInfoMessages.contains(infoMessage)) {
              log.info(infoMessage);
              loggedInfoMessages.add(infoMessage);
          }
      }
  
      private final Set<String> loggedInfoMessages = _Sets.newConcurrentHashSet();
  
  }
  