# TranslationManager.java

    /*
    ** Module   : TranslationManager.java
    ** Abstract : i18n support.
    **
    ** Copyright (c) 2021-2023, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
    ** 001 HC  20211001 Initial version.
    **     SBI 20221121 Added getBundles and parseLanguages to reuse the code for the web client
    **     SBI 20221124 Changed getBundles to provide language identifier with its translation resources.
    **     SBI 20221202 Added getDefaultLanguage() and implemented the logic related to the special language value
    **                  given by the "?" question mark.
    ** 002 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
    */

    /*
    ** This program is free software: you can redistribute it and/or modify
    ** it under the terms of the GNU Affero General Public License as
    ** published by the Free Software Foundation, either version 3 of the
    ** License, or (at your option) any later version.
    **
    ** This program is distributed in the hope that it will be useful,
    ** but WITHOUT ANY WARRANTY; without even the implied warranty of
    ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    ** GNU Affero General Public License for more details.
    **
    ** You may find a copy of the GNU Affero GPL version 3 at the following
    ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
    **
    ** Additional terms under GNU Affero GPL version 3 section 7:
    **
    **   Under Section 7 of the GNU Affero GPL version 3, the following additional
    **   terms apply to the works covered under the License.  These additional terms
    **   are non-permissive additional terms allowed under Section 7 of the GNU
    **   Affero GPL version 3 and may not be removed by you.
    **
    **   0. Attribution Requirement.
    **
    **     You must preserve all legal notices or author attributions in the covered
    **     work or Appropriate Legal Notices displayed by works containing the covered
    **     work.  You may not remove from the covered work any author or developer
    **     credit already included within the covered work.
    **
    **   1. No License To Use Trademarks.
    **
    **     This license does not grant any license or rights to use the trademarks
    **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
    **     of Golden Code Development Corporation. You are not authorized to use the
    **     name Golden Code, FWD, or the names of any author or contributor, for
    **     publicity purposes without written authorization.
    **
    **   2. No Misrepresentation of Affiliation.
    **
    **     You may not represent yourself as Golden Code Development Corporation or FWD.
    **
    **     You may not represent yourself for publicity purposes as associated with
    **     Golden Code Development Corporation, FWD, or any author or contributor to
    **     the covered work, without written authorization.
    **
    **   3. No Misrepresentation of Source or Origin.
    **
    **     You may not represent the covered work as solely your work.  All modified
    **     versions of the covered work must be marked in a reasonable way to make it
    **     clear that the modified work is not originating from Golden Code Development
    **     Corporation or FWD.  All modified versions must contain the notices of
    **     attribution required in this license.
    */

    package com.goldencode.p2j.util;

    import com.goldencode.p2j.directory.*;
    import com.goldencode.p2j.security.*;
    import com.goldencode.p2j.util.logging.*;

    import java.util.*;
    import java.util.Map.*;
    import java.util.AbstractMap.*;
    import java.util.concurrent.*;
    import java.util.concurrent.atomic.*;

    /**
     * Implements internationalization tasks, i.e. language bundle resolution, keeping track of current
     * language and the message translation itself.
     */
    public class TranslationManager
    {
       /**
        * Synthetic field names generated in the converted java classes for business logic, frames and legacy
        * classes.
        */
       public static final String REFERENT_ID_FIELD_NAME = "__trid";
       public static final String TR_MANAGER_FIELD_NAME = "__tm";

       /** Logger **/
       private static CentralLogger LOG = CentralLogger.get(TranslationManager.class.getSimpleName());

       /** Context local data */
       private static final ContextLocal<TranslationManager> instance = new ContextLocal<TranslationManager>()
       {
          @Override
          protected synchronized TranslationManager initialValue()
          {
             return new TranslationManager();
          }
       };

       /**
        * Expansion table:
        * Even indexes contain the minimum number of chars for the expansion value to take effect. These entries
        * must be sorted.
        * Odd indexes contain the expansion values. The value is a percentage value the target string
        * will be expanded to.
        */
       private static int[] expansionTable = new int[] {10, 200, 11, 100, 21, 80, 31, 60, 51, 40, 71, 30};

       /** Next referent id */
       private final AtomicLong nextReferentId = new AtomicLong();

       /** Map of referent ids to resource budles */
       private final Map<Long, ResourceBundle> referentBundleMap = new ConcurrentHashMap<>();

       /** Map of referent ids and their referent classes */
       private final Map<Long, Class<?>> referentMap = new ConcurrentHashMap<>();

       /** Map of referent classes and their corresponding referent ids */
       private final Map<Class<?>, Long> referentIdMap = new ConcurrentHashMap<>();

       /** Currently set current languages */
       private volatile List<String> currentLanguages;

       /** Custom bundle control */
       private BundleControl bundleControl = new BundleControl();

       /** String expansion ratio */
       private double expansionRatio = 0;

       /**
        * Returns the context local instance.
        *
        * @return  See above.
        */
       public static TranslationManager getInstance()
       {
          return instance.get();
       }

       /**
        * Converts the supplied string to a valid Java identifier by skipping the unsupported chars from the
        * result.
        *
        * @param   str
        *          The input string.
        *
        * @return  Valid Java identifier.
        */
       public static String getIdentifier(String str)
       {
          StringBuilder sb = new StringBuilder();
          for (int i = 0; i < str.length(); i++)
          {
             if ((i == 0 && Character.isJavaIdentifierStart(str.charAt(i))) ||
                (i > 0 && Character.isJavaIdentifierPart(str.charAt(i))))
             {
                sb.append(str.charAt(i));
             }
          }
          return sb.toString();
       }

       /**
        * Calculates the expanded length of the supplied char. The expansion is calculated from the supplied
        * expansionRatio and the expansion table {@linkplain #expansionTable}.
        * The length is calculates as follows:
        * str.length + round(((expansionTableEntry * expansionRatio) / 100.0) * len).
        *
        * @param   str
        *          The string to be expanded.
        * @param   expansionRatio
        *          The expansion ratio, must be 0 or greater.
        *
        * @return  The calculated expanded length. If the supplied str is {@code null} or empty, or
        *          {@code expansionRatio} is less than 0, the returned value is 0.
        */
       public static int getExpandedLength(String str, double expansionRatio)
       {
          if (expansionRatio <= 0 || str == null || str.isEmpty())
          {
             return 0;
          }

          int len = str.length();
          int entry = 0;
          for (int i = 0; i < expansionTable.length; i+=2)
          {
             if (len >= expansionTable[i])
             {
                entry = i;
             }
             else
             {
                break;
             }
          }

          double effectiveExpansion = (expansionTable[entry + 1] * expansionRatio) / 100.0;
          int newLen = len + (int) Math.round(effectiveExpansion * len);
          return newLen;
       }

       /**
        * Converts the given string of comma separated list of language identifiers to the list of
        * language identifiers.
        *
        * @param    languages
        *           The given string of comma separated list of language identifiers
        * @param    computeDefaultLanguage
        *           True value indicates that the default language is computed if languages value is
        *           given as "?".
        *
        * @return   The list of language identifiers
        */
       public static List<String> parseLanguages(String languages, boolean computeDefaultLanguage)
       {
          if (computeDefaultLanguage && "?".equals(languages))
          {
             languages = getDefaultLanguage();
          }

          return languages == null ? Collections.emptyList() : Arrays.asList(languages.split(","));
       }

       /**
        * Get the JVM default language in the form of language code + _ + country code, for example, en_US.
        *
        * @return   The language identifier
        */
       public static String getDefaultLanguage()
       {
          Locale clientLocale = Locale.getDefault();
          StringBuilder buf = new StringBuilder(clientLocale.getLanguage());
          buf.append("_").append(clientLocale.getCountry());

          return buf.toString();
       }

       /**
        * Returns the iterator over the collection of i18n resources for the given bundle base name and
        * the given list of language identifiers.
        *
        * @param    bundleBaseName
        *           The given bundle base name
        * @param    languages
        *           The given list of language identifier
        *
        * @return   The target iterator over the collection of i18n resources
        */
       public static Iterator<Entry<String, String>> getBundles(String bundleBaseName,
                                                 List<String> languages)
       {
          return languages.stream().<Entry<String, String>>map(
                   (lang) -> new SimpleImmutableEntry<String, String>(lang,
                                                                      bundleBaseName + "_" + getIdentifier(lang)))
                                                                      .iterator();
       }

       /**
        * Default constructor.
        */
       public TranslationManager()
       {
          setCurrentLanguages(EnvironmentOps.getCurrentLanguageString());

          Directory dir = DirectoryManager.getInstance();
          int percent = dir.getInt(Directory.ID_RELATIVE, "text-expansion", 0);
          expansionRatio = percent / 100.0;
       }

       /**
        * Initializes supplied class (aka referent class) for the purpose of i18n. The method returns a unique
        * identifier associated with the supplied referent class. Any further i18n requests must contain this
        * unique id.
        * By convention a referent class has associated set of resource bundles, one for each target language.
        * The resource bundles reside in the same Java package as the referent class, whereas the resource
        * bundle class name is in the form [referent class simple name]_[target language].
        * The current target language is determined by the assigned value to {@code CURRENT-LANGUAGE@} session
        * variable.
        *
        * @param   referentClass
        *          The referent class.
        *
        * @return  Unique id of to the initialized referent class. Any further i18n requests must contain this
        *          id.
        */
       public long initReferent(Class<?> referentClass)
       {
          long id = nextReferentId.incrementAndGet();
          referentMap.put(id, referentClass);
          referentIdMap.put(referentClass, id);
          return id;
       }

       /**
        * Translates the supplied message to the current target language. If no current language is set or
        * no bundle of the target language can be resolved or the resolved bundle doesn't contain translation
        * for the supplied message, the method just returns the supplied message.
        *
        * @param   referentId
        *          Referent class id returned by {@link #initReferent(Class)}.
        * @param   msg
        *          A message to translate.
        *
        * @return  Translated message or {@code msg} if the message can't be translated.
        */
       public String translate(long referentId, String msg)
       {
          return translate(referentId, -1, msg);
       }

       /**
        * Translates the supplied message to the current target language. If no current language is set or
        * no bundle of the target language can be resolved or the resolved bundle doesn't contain translation
        * for the supplied message, the method just returns the supplied message.
        *
        * @param   referentId
        *          Referent class id returned by {@link #initReferent(Class)}.
        * @param   ctxt
        *          A context identifier. It is used to disambiguate messages of the same value but appearing in
        *          different locations in the source.
        * @param   msg
        *          A message to translate.
        *
        * @return  Translated message or {@code msg} if the message can't be translated.
        */
       public String translate(long referentId, long ctxt, String msg)
       {
          if (msg == null || msg.isEmpty())
          {
             return msg;
          }

          ResourceBundle bundle = referentBundleMap.get(referentId);

          if (bundle == null)
          {
             return msg;
          }

          String key = msg;
          if (ctxt > 0)
          {
             key = ctxt + "\u0004" + msg;
          }

          try
          {
             return bundle.getString(key);
          }
          catch (MissingResourceException ex)
          {
             return msg;
          }
       }

       /**
        * Translates the supplied string for the supplied referent and calculates the expansion length (see
        * {@link #getExpandedLength(String, double)} for more details).
        *
        * @param   referentId
        *          Id of the referent.
        * @param   msg
        *          The string to be translated.
        *
        * @return  The translates string with the calculated expansion length.
        */
       public I18nString translateExpand(long referentId, String msg)
       {
          return translateExpand(referentId, -1, msg);
       }

       /**
        * Translates the supplied string for the supplied referent and calculates the expansion length (see
        * {@link #getExpandedLength(String, double)} for more details).
        *
        * @param   referentId
        *          Id of the referent.
        * @param   ctxt
        *          Context id.
        * @param   msg
        *          The string to be translated.
        *
        * @return  The translates string with the calculated expansion length.
        */
       public I18nString translateExpand(long referentId, long ctxt, String msg)
       {
          if (msg == null || msg.isEmpty())
          {
             return new I18nString(msg, msg, 0);
          }

          int expandedLength = getExpandedLength(msg, expansionRatio);

          ResourceBundle bundle = referentBundleMap.get(referentId);

          if (bundle == null)
          {
             return new I18nString(msg, msg, expandedLength);
          }

          String key = msg;
          if (ctxt > 0)
          {
             key = ctxt + "\u0004" + msg;
          }

          String translated = null;

          try
          {
             translated = bundle.getString(key);
          }
          catch (MissingResourceException ex)
          {
             translated = msg;
          }

          return new I18nString(translated, msg, expandedLength);
       }

       /**
        * Sets the current languages. The supplied value may be {@code null} a comma separated string.
        * When {@code null} the effective current language will be cleared and messages will not be translated.
        * When multiple languages are given, they are used during language bundle resolution such that the
        * current languages are iterated until a corresponding resource bundle is found.
        *
        * @param   languages
        *          List of languages to assign as current languages. The value is a comma separated string.
        */
       public void setCurrentLanguages(String languages)
       {
          currentLanguages = parseLanguages(languages, false);
       }

       /**
        * Resolves a resource bundle based on the current languages set with {@link #setCurrentLanguages(String)}
        * and associates it with the supplied referent. Any further translation requests for the referent will be
        * performed against the resolved resource bundle.
        *
        * @param   referentId
        *          The referent id.
        */
       public void applyLanguage(long referentId)
       {
          Class refClass = referentMap.get(referentId);
          if (refClass == null)
          {
             LOG.warning("Failed to apply language to referent. The referent was not initialized.");
             return;
          }

          applyLanguage(refClass);
       }

       /**
        * Resolves a resource bundle based on the current languages set with {@link #setCurrentLanguages(String)}
        * and associates it with the supplied referent class. Any further translation requests for the referent
        * class will be performed against the resolved resource bundle.
        *
        * @param   referentClass
        *          The referent class.
        *
        * @return  {@code true} if the language was successfully applied to the referent, {@code false}
        *          otherwise.
        */
       public boolean applyLanguage(Class<?> referentClass)
       {
          Long refId = referentIdMap.get(referentClass);
          if (refId == null)
          {
             LOG.fine("Failed to apply language for referent '" + referentClass.getName() +
                      "'. The referent wasn't initialized.");
             return false;
          }

          ResourceBundle bundle = resolveBundle(referentClass);
          if (bundle == null)
          {
             LOG.fine("Failed to apply language for referent '" + referentClass.getName() +
                      "'. The bundle for the current language(s) '" + currentLanguages + "' was not found.");
             return false;
          }

          referentBundleMap.put(refId, bundle);
          return true;
       }

       /**
        * Resolves resource bundle for the supplied reference.
        *
        * @param   referentClass
        *          The referent class.
        *
        * @return  The resolved resource bundle.
        */
       private ResourceBundle resolveBundle(Class<?> referentClass)
       {
          List<String> currentLanguages = this.currentLanguages;

          if (currentLanguages == null || currentLanguages.isEmpty())
          {
             return null;
          }

          String bundleBaseName = referentClass.getName();

          Iterator<Entry<String, String>> bundles = getBundles(bundleBaseName, currentLanguages);

          while (bundles.hasNext())
          {
             try
             {
                return ResourceBundle.getBundle(bundles.next().getValue(), bundleControl);
             }
             catch (MissingResourceException ex)
             {
                continue;
             }
          }

          return null;
       }

       /**
        * Custom bundle control to override the bundle name logic.
        */
       private static class BundleControl
       extends ResourceBundle.Control
       {
          /**
           * {@inheritDoc}
           */
          @Override
          public String toBundleName(String baseName, Locale locale)
          {
             return baseName;
          }
       }
    }